diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2004195..775abba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,37 @@ jobs: - name: go vet run: go vet ./... + # Le jeu de règles de .golangci.yml est VERT, et c'est ce qui le rend + # utile ici : il n'échoue que sur ce qu'on vient d'écrire. Ce qu'il + # n'active pas est écrit dans ce fichier, avec le compte relevé et la + # raison — `make audit` le rejoue à la demande, et ne tourne pas ici. + # + # `go install` plutôt que golangci/golangci-lint-action, et c'est un choix : + # toutes les actions de ce fichier sont épinglées par SHA, et ajouter une + # action de plus voudrait dire relever puis maintenir un SHA de plus. + # + # La VERSION n'est pas écrite ici : elle est lue dans le Makefile, qui en + # est la source unique. Écrite aux deux endroits, elle finit par diverger, + # et un développeur verrait alors rouge là où la CI voit vert — ou + # l'inverse, ce qui est pire, parce que personne ne cherche la cause d'un + # vert. + # + # L'installation se fait dans un répertoire jetable pour que le module + # courant n'en garde AUCUNE trace : `make deps` compare go.mod aux deux + # tables de §17.1 dans les deux sens (ADR-039), et une dépendance de + # développement qui s'y inscrirait ouvrirait un écart permanent. + # + # Puis c'est `make lint` qui est appelé, et non la commande recopiée : si + # la cible change, la CI suit. C'est le principe que pose l'en-tête de + # make.ps1 — « le Makefile reste la référence, c'est lui que la CI + # exécute » — et une étape qui porte son nom sans l'exécuter le trahit. + - name: make lint + run: | + version=$(make -s golangci-version) + (cd "$(mktemp -d)" && go mod init lintinstall >/dev/null 2>&1 \ + && go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$version") + make lint + - name: make boundary run: go run ./tools/boundary @@ -268,6 +299,27 @@ jobs: working-directory: web run: npm ci + # AVANT `svelte-check`, et l'ordre porte une intention : ces deux-là lisent le code + # sans rien exécuter, ils répondent en quelques secondes, et ce qu'ils reprochent se + # corrige sans réfléchir. Les faire passer en premier évite d'attendre une suite de + # mille tests pour apprendre qu'il manque une accolade de style. + # + # `npm run lint` ne double PAS `npm run check` : svelte-check ne vérifie que des + # TYPES. Une promesse qu'on oublie d'attendre, un `${}` posé sur un objet qui rendra + # « [object Object] » à un bénévole, un fichier qui enfle — rien de tout cela n'est + # une erreur de type, et rien ne le regardait avant ce jeu de règles. + - name: eslint + working-directory: web + run: npm run lint + + # Prettier arrive sur un dépôt écrit à la main : les 55 fichiers qui ne suivent pas + # encore sa mise en forme sont nommés un par un dans `web/.prettierignore`, avec la + # raison. C'est un CLIQUET — tout fichier écrit à partir d'aujourd'hui est vérifié, + # et la liste se vide fichier par fichier sans qu'un lot ait à s'arrêter pour ça. + - name: prettier + working-directory: web + run: npm run format:check + - name: svelte-check working-directory: web run: npm run check diff --git a/.golangci-audit.yml b/.golangci-audit.yml new file mode 100644 index 0000000..21c8248 --- /dev/null +++ b/.golangci-audit.yml @@ -0,0 +1,101 @@ +# Configuration de golangci-lint pour OpenScale — cible `make audit`, NON BLOQUANTE. +# +# Elle active large et ne fait échouer personne. Sa raison d'être est de RENDRE +# VISIBLE ce que `.golangci.yml` écarte : le jeu bloquant est vert parce qu'il +# ne contient que ce qui tient aujourd'hui, et un dépôt où l'on ne verrait plus +# jamais le reste finirait par croire qu'il n'y a plus rien à faire. +# +# Elle ne tourne PAS dans l'intégration continue. Elle se lance à la main, quand +# on ouvre un lot de qualité, et son relevé sert à le dimensionner. +# +# Les raisons pour lesquelles chacun de ces linters n'est pas bloquant sont +# écrites dans `.golangci.yml`, avec le compte relevé. Ne les dupliquez pas ici : +# deux listes finissent toujours par diverger. + +version: "2" + +# SANS CE BLOC, LA CIBLE D'INVENTAIRE MENT — et c'est exactement ce qu'elle est +# censée éviter. golangci-lint tronque par défaut à 3 signalements identiques et +# 50 par linter : cette configuration rendait 136 signalements de production là +# où il y en a 432, et les comptes qu'on en tirait étaient faux d'un facteur 10 +# sur errcheck. Une cible dont le rôle est de MONTRER la dette ne peut pas être +# celle qui la sous-estime. +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + # golangci-lint ne rend QU'UN signalement par ligne par défaut. Quand gocognit + # et funlen visent la même fonction, un seul sort — et lequel dépend de + # l'ordre, pas du fond. Un inventaire ne peut pas se permettre ça : c'est ce + # qui faisait dire que funlen ne voyait que deux fonctions alors qu'il en voit + # neuf dès que gocognit se tait sur la même ligne. + uniq-by-line: false + +run: + timeout: 15m + tests: true + +linters: + default: none + enable: + # Le socle Go, que le jeu bloquant ne peut pas encore porter + - errcheck + - staticcheck + - unused + + # Ce qui mesure ce qu'un corps de fonction est devenu + - gocognit + - funlen + - nestif + - cyclop + + # Sécurité et exhaustivité + - gosec + - exhaustive + + # Fautes probables + - bodyclose + - errorlint + - nilerr + - noctx + - errname + - predeclared + + # Style et conventions + - revive + - gocritic + - goconst + - unparam + - prealloc + - unconvert + - usestdlibvars + - wastedassign + - whitespace + - copyloopvar + - ineffassign + - govet + + settings: + cyclop: + max-complexity: 15 + funlen: + lines: 80 + statements: 50 + gocognit: + min-complexity: 25 + nestif: + min-complexity: 5 + goconst: + min-len: 4 + min-occurrences: 4 + + exclusions: + generated: lax + paths: + - web/node_modules + +formatters: + enable: + - gofmt + exclusions: + paths: + - web/node_modules diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d4d1b8e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,299 @@ +# Configuration de golangci-lint pour OpenScale — cible `make lint`, BLOQUANTE. +# +# Elle est verte, et c'est une décision, pas une facilité : un jeu de règles qui +# rougit dès le premier jour n'est pas lu, il est contourné. Chaque linter a été +# MESURÉ sur le dépôt avant d'être retenu ou écarté, et le compte relevé est +# écrit à côté de la raison. +# +# Un linter écarté ici n'est pas un linter jugé sans valeur : c'est un lot de +# travail qui n'a pas encore été fait. Le compte dit ce qu'il coûterait, et +# `make audit` — qui active TOUT et ne bloque rien — le montre à la demande. +# +# L'outil s'installe HORS module : +# go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest +# et jamais par un `tools.go`. `make deps` compare go.mod aux deux tables de +# docs/02-architecture.md §17.1 dans les deux sens (ADR-039) : une dépendance de +# développement inscrite dans go.mod y ouvrirait un écart permanent. + +version: "2" + +# SANS CE BLOC, ON DÉCIDE SUR DES CHIFFRES FAUX. golangci-lint tronque par +# défaut à 3 signalements identiques et 50 par linter : la première mesure de ce +# dépôt annonçait 14 pour errcheck là où il y en a 147, et 7 pour goconst là où +# il y en a 85. Les comptes écrits plus bas sont ceux d'après cette correction, +# `uniq-by-line` désactivé compris — voir la note en fin de fichier. +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +run: + timeout: 10m + tests: true + +linters: + # PAS `default: standard` : errcheck (50), staticcheck (20) et unused (5) ont + # de l'existant, et les rendre verts demande de changer des comportements. + # Ils sont dans `make audit`, avec leur compte, en attendant leur lot. + default: none + + enable: + - govet # le socle que la CI joue déjà par `go vet` + - ineffassign # affectation jamais lue + - copyloopvar # Go 1.22+ : la variable de boucle ne se recopie plus + - usestdlibvars # http.MethodGet plutôt que "GET" + - wastedassign # valeur écrite puis écrasée sans lecture + - whitespace # ligne vide en tête ou en fin de bloc + - nestif # imbrication de `if` au-delà de ce qui se lit + + # Zéro signalement en production, tous les deux : ils ne coûtent rien + # aujourd'hui et interdisent une faute réelle demain. bodyclose surtout — + # ce poste lit son catalogue en WebDAV, et un corps de réponse non refermé + # fuit une connexion par import. + - bodyclose + - predeclared # masquer `max`, `min`, `close`… + + # --- Ce qu'un corps de fonction ne doit pas devenir -------------------- + # + # Ces deux-là sont au seuil QUI MORD, et les fonctions existantes qui le + # dépassent sont exclues NOMMÉMENT, plus bas, chacune avec son chiffre. + # + # C'est un choix contre les deux réflexes habituels. Poser le seuil + # au-dessus du pire cas — 70, la valeur de Template.ValidateOn — n'aurait + # rien interdit : tout ce qu'on peut écrire de raisonnable passe dessous. + # Ne pas les activer du tout aurait laissé la dette dans un commentaire que + # personne ne relit. Nommer les fautives fait les deux à la fois : aucun + # code NOUVEAU ne peut dépasser, et la liste d'exclusions EST la liste de + # travail — visible, chiffrée, et qui rétrécit quand on la traite. + # + # Une exclusion se retire quand la fonction repasse sous le seuil. Aucune ne + # s'ajoute sans que ce fichier dise pourquoi. + - gocognit + - funlen + + settings: + nestif: + # Seuil posé JUSTE AU-DESSUS du pire cas mesuré : c'est un cliquet. Il + # n'exige pas de réparer l'existant, il interdit de l'aggraver. + min-complexity: 6 + gocognit: + # 25 est le seuil usuel, et il mord ici : neuf fonctions de production le + # dépassent. Elles sont nommées dans exclusions.rules. + min-complexity: 25 + funlen: + lines: 80 + statements: 50 + + exclusions: + generated: lax + + paths: + # Un fichier Go VENDORÉ dans les dépendances npm : web/node_modules porte + # flatted/golang/pkg/flatted/flatted.go, qui n'est pas notre code et que + # rien ne compile. Sans cette ligne, il apparaît dans chaque relevé. + - web/node_modules + + rules: + # Un test qui ÉPELLE un scénario est long par construction, et le + # raccourcir le rendrait moins lisible, pas plus. + # + # bodyclose y est aussi : 135 signalements, tous sur des réponses qu'un + # banc lit et jette dans la foulée, dans un processus qui s'arrête après. + # predeclared également : 18 signalements, tous des variables locales + # nommées `copy` dans un test qui copie une base — le mot dit ce qu'il + # fait, et aucune de ces portées n'appelle la fonction prédéclarée. + # + # LES DEUX SONT À ZÉRO EN PRODUCTION, et c'est ce qui les rend gratuits : + # ils n'exigent rien du code livré et interdisent la faute demain. + - path: _test\.go + linters: + - funlen + - gocognit + - nestif + - bodyclose + - predeclared + + # ── LA DETTE DE COMPLEXITÉ, NOMMÉE ──────────────────────────────────── + # + # Neuf fonctions au-dessus de 25 en complexité COGNITIVE, deux au-dessus + # des bornes de longueur. Relevé du 03/08/2026, sans troncature. + # + # Le lot de rangement qui a produit ce fichier était MÉCANIQUE : il a + # déplacé du code entre fichiers, ce qui ne simplifie aucun corps de + # fonction. Ces onze-là sont donc exactement ce qu'elles étaient avant, et + # les traiter est le lot le plus utile qui reste. + # + # Chaque ligne porte sa valeur. Quand une fonction repasse sous le seuil, + # sa ligne se retire — et le fichier redevient plus court, ce qui est le + # seul indicateur de progrès qui ne se falsifie pas. + + # Cognitif ET longueur — les plus lourdes, celles qui coûtent des deux + # côtés. La valeur cognitive d'abord, la longueur ensuite. + - linters: [gocognit, funlen] # cognitif 70, 77 instr. — la pire du dépôt + path: internal/domain/template_validate\.go + text: "ValidateOn" + - linters: [gocognit, funlen] # cognitif 33, 105 instr. — la racine de composition + path: cmd/openscale/serve\.go + text: "'serve'|func .serve." + - linters: [gocognit, funlen] # cognitif 28, 55 instr. + path: cmd/openscale/capture\.go + text: "'capture'|func .capture." + - linters: [gocognit, funlen] # cognitif 28, 87 lignes — la boucle du Hub + path: internal/station/loop\.go + text: "'run'|Hub..run" + - linters: [gocognit, funlen] # cognitif 26, 83 lignes + path: internal/domain/safeguard\.go + text: "'Evaluate'|func .Evaluate." + + # Cognitif seul — longues à suivre, mais pas longues à lire. + - linters: [gocognit] # 35 + path: internal/domain/validate_options\.go + text: "OptionSchema..check" + - linters: [gocognit] # 32 + path: internal/domain/ean13\.go + text: "validateCodeSets" + - linters: [gocognit] # 32 + path: tools/boundary/drivers\.go + text: "registryEntry" + - linters: [gocognit] # 28 + path: tools/boundary/clock\.go + text: "checkNoClockReads" + + # Longueur seule — droites, mais trop longues d'un tenant. + - linters: [funlen] # 88 lignes pour 80 + path: internal/web/configwrite\.go + text: "writeConfig" + - linters: [funlen] # 65 instructions pour 50 + path: internal/web/sessionroutes\.go + text: "recoverSession" + - linters: [funlen] # 83 lignes pour 80 + path: cmd/openscale/price\.go + text: "runPrice" + - linters: [funlen] # 56 instructions pour 50 + path: internal/diag/doctor_config\.go + text: "checkConfiguration" + +formatters: + # gofmt seul. Ni gofumpt ni goimports : le dépôt est gofmt-é d'un bout à + # l'autre (`gofmt -l .` sort vide), et changer de formateur produirait un diff + # massif sans rapport avec ce qu'on corrigerait. + enable: + - gofmt + exclusions: + paths: + - web/node_modules + +# ───────────────────────────────────────────────────────────────────────────── +# CE QUI N'EST PAS ACTIVÉ, ET CE QUE ÇA COÛTERAIT +# ───────────────────────────────────────────────────────────────────────────── +# +# Comptes relevés le 03/08/2026, production et tests séparés, `make audit` +# joué SANS troncature et SANS déduplication par ligne. Les premiers chiffres +# écrits ici étaient ceux de la sortie tronquée, faux d'un facteur 10 sur +# errcheck — c'est la raison du bloc `issues:` en tête des deux fichiers. +# +# Ordonnés par ce qu'ils rapporteraient si on les traitait. +# +# errcheck 147 prod, 249 test — des retours d'erreur non vérifiés. Le plus +# gros gisement du dépôt, et le plus lent : chaque occurrence +# demande de décider quoi faire de l'erreur. Beaucoup sont des +# Close() différés, mais pas tous, et c'est le « pas tous » qui +# coûte. +# +# goconst 85 prod, 335 test — des chaînes répétées. Plusieurs sont des +# textes affichés en FRANÇAIS : les extraire en constante +# déplacerait un libellé hors du fichier qui le montre, ce que +# la revue de wording ne veut pas. À trier avant de traiter. +# +# gosec 71 prod — G115 conversions d'entiers, G301/G302/G306 droits de +# fichiers, G204 sous-processus (sc.exe, schtasks.exe). Ce sont +# les gestes normaux d'une application de poste qui pilote des +# services Windows. Soixante et onze annotations #nosec +# justifiées une par une : un lot en soi. → `make audit`. +# +# errorlint 30 prod — des fmt.Errorf en %v là où %w conviendrait. Passer à +# %w change l'ENVELOPPE : errors.Is se mettrait à correspondre +# là où il ne correspondait pas. Comportement, pas style — et +# trente fois, donc trente décisions. +# +# revive 20 prod — la plupart sûrs (unused-parameter, blank-imports, +# empty-block, time-naming) ; six exigent de changer une +# SIGNATURE (error-return ×3, context-as-argument ×2). +# Activable après le lot des sûrs, avec les six exclus. +# Deux faux positifs connus, à exclure et non à corriger : +# `defaultBackoffMin` va avec `defaultBackoffMax` — « Min » y +# est MINIMUM, pas minutes ; et la branche vide du verrou de +# stabilité documente « la fenêtre continue, l'ancre ne bouge +# pas ». +# +# exhaustive 17 prod — des switch sur énumération sans tous les cas, DONT +# TROIS sur domain.State, la machine à états. Ajouter les cas +# manquants change le comportement. +# +# staticcheck 12 prod, 4 test — dont neuf S1016 (conversion de struct plutôt +# que littéral champ à champ), sûrs ; et QF1001, QF1002, ST1005, +# qui touchent une expression booléenne, un switch et un message +# d'erreur FRANÇAIS — donc un texte affiché. +# +# cyclop 9 prod — complexité CYCLOMATIQUE, qui compte chaque `case` +# d'un switch à égalité avec un `if` imbriqué. domain.State +# .String() — seize états, un case chacun, lisible d'un coup +# d'œil — y marque 17 quand gocognit lui donne 1. Mauvais juge +# pour ce dépôt ; gocognit, qui EST activé, dit la même chose +# en mieux. +# +# noctx 9 prod, 56 test — des requêtes HTTP sans context. Dix sont +# délibérées : la sonde `net.Listen` teste si l'adresse est +# prenable et la rend aussitôt. Les autres changent des +# signatures. +# +# errname 5 prod — des types d'erreur nommés hors convention : +# DatabaseFailure, DowntimeRefused, ErrUnknownFont. Les +# renommer change une API publique. +# +# unparam 5 prod — des paramètres inutilisés dans des fonctions qui +# implémentent une interface. Les retirer casse l'interface. +# +# nilerr 4 prod — rendre nil alors que l'erreur ne l'est pas. Trois +# sont dans internal/diag/probes.go, où une SONDE qui échoue +# répond légitimement « je ne sais pas » plutôt qu'une erreur. +# À lire une par une avant de trancher, jamais en bloc. +# +# unconvert 2 prod — et ce sont DEUX FAUX POSITIFS, gardés comme exemple : +# exit := error(ErrScriptExhausted) (scale/replay) +# exitErr := error(ErrLoopStopped) (scale/serial) +# La conversion n'est pas inutile, elle donne à la variable le +# type statique `error` au lieu du type concret de la sentinelle. +# La retirer change le type de la variable. Le linter a tort ; +# c'est pourquoi il n'est pas dans le jeu bloquant. +# +# unused 2 prod, 3 test — du code mort réel, dont `type page` dans +# internal/catalog/example/ qui est un MODÈLE À COPIER cité par +# docs/08 : y retirer une déclaration demande de vérifier que le +# document ne s'en sert pas. +# +# gocritic 2 prod — et ce sont DEUX VRAIS PIÈGES, nommés ici parce qu'ils +# ne relèvent pas du style : cmd/openscale/config.go et +# cmd/openscale/serve.go font `faults := append(decodeFaults, …)`, +# qui écrit dans le tableau de l'APPELANT si sa capacité le +# permet. C'est sur le chemin qui décide si le poste part hors +# service. Le réparer est un correctif, pas un rangement. +# +# prealloc 2 prod — de la micro-optimisation. Sans mesure qui la +# justifie, elle ajoute du bruit. +# +# UN PIÈGE DE MESURE À CONNAÎTRE AVANT DE TOUCHER À CE FICHIER. +# golangci-lint ne rend QU'UN signalement par ligne (`uniq-by-line`, actif par +# défaut). Quand gocognit et funlen visent la même fonction, un seul sort — et +# lequel dépend de l'ordre, pas du fond. C'est pourquoi cinq des règles +# d'exclusion ci-dessus nomment les DEUX linters : dès que gocognit se tait sur +# `serve`, `capture`, `Evaluate`, `ValidateOn` ou `(*Hub).run`, funlen les voit. +# Sans cette précaution, le cliquet aurait cinq trous que rien ne signalerait. +# `.golangci-audit.yml` désactive `uniq-by-line` pour cette raison : un +# inventaire qui cache la moitié de ce qu'il compte ne sert à rien. +# +# Deux appendAssign signalés par gocritic méritent d'être nommés ici parce que +# ce sont de VRAIS pièges et non du style : cmd/openscale/config.go et +# cmd/openscale/serve.go font `faults := append(decodeFaults, …)`, qui écrit +# dans le tableau de l'appelant si sa capacité le permet. C'est sur le chemin +# qui décide si le poste part hors service. Le réparer est un correctif, pas un +# rangement : il n'a donc pas été fait ici. diff --git a/0 b/0 deleted file mode 100644 index e69de29..0000000 diff --git a/Makefile b/Makefile index 961ef5a..744782d 100644 --- a/Makefile +++ b/Makefile @@ -26,12 +26,22 @@ LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.dat TARGETS := windows/amd64 linux/amd64 linux/arm64 -.PHONY: all test vet boundary deps driver build dist release front front-check clean cover help +.PHONY: all test vet lint audit boundary deps driver build dist release front front-check clean cover help all: test build help: - @echo "Cibles : test · driver · vet · boundary · deps · build · dist · release · cover · front · front-check · clean" + @echo "Cibles : test · driver · vet · lint · audit · boundary · deps · build · dist · release · cover · front · front-check · clean" + +# GOLANGCI est cherché dans le PATH d'abord, puis dans le GOPATH, parce que +# `go install` y dépose l'outil sans que le PATH le sache toujours sous Windows. +GOLANGCI ?= $(shell command -v golangci-lint 2>/dev/null || echo "$$(go env GOPATH)/bin/golangci-lint") + +# La version est ÉPINGLÉE ici, et c'est le seul endroit où elle est écrite : la +# CI, make.ps1 et ce fichier la lisent tous d'ici. Un développeur sur une +# version plus récente verrait rouge là où la CI voit vert — ou l'inverse, ce +# qui est pire, parce que personne ne cherche la cause d'un vert. +GOLANGCI_VERSION ?= v2.12.2 # front construit l'écran client vers internal/web/dist, qui est COMMITÉ : `go # build` doit fonctionner sur une machine sans Node (§14.1). @@ -52,6 +62,44 @@ front-check: front vet: go vet ./... +# lint est BLOQUANTE et VERTE, et les deux vont ensemble : un jeu de règles qui +# rougit dès le premier jour n'est pas lu, il est contourné. .golangci.yml ne +# contient donc que ce que le dépôt tient AUJOURD'HUI, et il écrit à côté de +# chaque linter écarté le nombre de signalements qu'il produirait — pour que +# « pas activé » ne se lise jamais « sans valeur ». +# +# L'outil s'installe HORS module, et c'est ADR-039 qui l'impose : `make deps` +# compare go.mod aux deux tables de §17.1 dans les deux sens, et une dépendance +# de développement inscrite là y ouvrirait un écart permanent. Pas de tools.go. +# La CI lit la version par ici plutôt qu'en la recopiant dans son fichier. Une +# cible ordinaire, et non une astuce de ligne de commande : ce qu'on ne peut pas +# lancer à la main pour vérifier finit par casser sans qu'on sache où. +.PHONY: golangci-version +golangci-version: + @echo $(GOLANGCI_VERSION) + +lint: + @test -x "$(GOLANGCI)" || { \ + echo "lint : golangci-lint introuvable."; \ + echo " go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)"; \ + exit 1; \ + } + $(GOLANGCI) run ./... + +# audit active TOUT et ne bloque rien. Elle ne tourne pas dans l'intégration +# continue : elle se lance à la main quand on ouvre un lot de qualité, et son +# relevé sert à le dimensionner. Le `|| true` est la cible même de cette règle, +# pas un oubli — une cible d'inventaire qui échoue est une cible qu'on cesse de +# lancer. +audit: + @test -x "$(GOLANGCI)" || { \ + echo "audit : golangci-lint introuvable."; \ + echo " go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)"; \ + exit 1; \ + } + @$(GOLANGCI) run -c .golangci-audit.yml ./... || true + @echo "audit : relevé ci-dessus. Les raisons de chaque exclusion sont dans .golangci.yml" + boundary: go run ./tools/boundary diff --git a/SUIVI.md b/SUIVI.md index 8e66689..2788b28 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -3,6 +3,53 @@ > Tableau de bord. À mettre à jour au fil de l'eau — c'est le premier fichier à lire > pour savoir où on en est. +**Un fichier, une responsabilité : 281 fichiers de production deviennent 405, et rien +d'autre ne bouge (03/08/2026).** Le dépôt portait vingt-quatre fichiers de plus de 600 +lignes, dont cinq au-dessus de 1 400 ; il en reste **cinq**, tous des pages `.svelte` +de l'administration. Le geste est **mécanique et rien d'autre** : découper un fichier en +plusieurs fichiers du même paquet, déplacer des déclarations entre eux, extraire un +helper non exporté quand une duplication est réellement constatée. Aucun identifiant +exporté, aucune signature, aucun comportement. Les 61 routes HTTP, les 353 balises +`json:` et les 152 codes de statut d'`internal/web` sont identiques au caractère près ; +`internal/domain` rend ses fautes de validation dans le même ordre, mot pour mot. + +Les plus gros, avant → après : `domain/config.go` **2408 → 377**, `domain/machine.go` +**1679 → 182**, `diag/doctor.go` **1481 → 292**, `printing/conformance/conformance.go` +**1134 → 273**, `station/station.go` **1128 → 256**, `station/hub.go` **1097 → 341**, +`cmd/openscale/serve.go` **1079 → 528**, `web/admin.go` **746 → supprimé**, réparti en +cinq fichiers nommés par sujet dont aucun ne pouvait honnêtement s'appeler « admin ». +Côté front, `Rules.svelte` **1214 → 291**, `Hardware.svelte` **1462 → 1085**, +`Catalog.svelte` **2139 → 1502**. Le total de production passe de 74 460 à 77 081 lignes, +soit **+3,5 %** : ce sont les `package`, les `import` et un commentaire d'en-tête par +fichier neuf, qui dit ce que le fichier rassemble. + +**Ce sont des `wc -l`, des deux côtés, et cette précision a coûté un chiffre faux.** +`Measure-Object -Line` de PowerShell ne compte **que les lignes non vides** : il rend 354 +là où `wc -l` rend 377 pour le même `config.go`, un écart de 6 % qui a d'abord été +rapporté comme un résultat. Deux mesures dans deux unités ne se comparent pas, et un +« 2408 → 354 » mélangeait les deux. + +**La vérification a rattrapé quatre pertes réelles, qu'aucune relecture n'avait vues.** +Le découpage a été comparé mécaniquement — multiensemble des lignes, des littéraux +chaîne et des déclarations, par `go/ast` et jamais par `grep` — et cette comparaison a +trouvé ce que trois lectures de diff avaient laissé passer : le paragraphe qui justifie +le **contrôle 43** (§11.5, ADR-026) disparu de `CheckPrice`, le godoc de `Command`, huit +lignes d'en-tête dans `catalog`, sept phrases de justification dans le front. Toutes +restaurées. La leçon tient en une ligne : **sur du texte, la relecture ne remplace pas le +comptage.** + +**Trois choses restent ouvertes, et elles sont nommées.** Neuf fonctions dépassent le +seuil de complexité **cognitive** de 25 — `(*Template).ValidateOn` à **70**, listées une +par une dans `.golangci.yml` avec leur compte : déplacer du code entre fichiers ne +simplifie pas un corps de fonction, et les rouvrir n'est pas du rangement. L'**ordre** +dans lequel `Config.Validate` rend ses fautes n'était couvert que **par accident** — +chaque test cherchait sa faute par son champ, si bien qu'intervertir deux groupes de +contrôles laissait la suite verte ; c'est ce que voient `openscale doctor`, l'écran +d'administration et un bénévole devant un poste en ERR-CFG-01, et un test l'épingle +désormais (`validate_order_test.go`). Enfin `admin-catalog.test.ts` lit le **texte source** +de la page qu'il éprouve, ce qui interdit structurellement de descendre `Catalog.svelte` +sous ~1 500 lignes : c'est le test qu'il faut reprendre d'abord, pas la page. + **Un `config.json` ancien se met à jour tout seul, après qu'un poste réel soit tombé dessus (01/08/2026).** Un poste de test mis à jour a démarré en **configuration d'usine (ERR-CFG-01)** : son fichier, conservé tel quel comme la procédure de mise à jour le diff --git a/cmd/openscale/adapters.go b/cmd/openscale/adapters.go new file mode 100644 index 0000000..bf02822 --- /dev/null +++ b/cmd/openscale/adapters.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + + "openscale/internal/station" + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// This file holds the three adapters `serve` needs that belong to no screen: the store +// seen as the station's technical sink, the journal relay for the interval before the +// Hub exists, and the HTTP server handed over after the station was built. The +// adapters of the administration routes are in admin.go. + +// technicalSink adapts the store to what the station writes technical lines through. +// +// Two structures that carry the same six values, and the conversion is the price of cut +// 3 of §5.2: internal/station names no storage type, so it declares what it needs and +// the composition root joins the two. +type technicalSink struct{ db *store.DB } + +// RecordTechnical appends one line to the persisted technical journal. +func (s technicalSink) RecordTechnical(ctx context.Context, e station.TechnicalEntry) error { + return s.db.RecordTechnical(ctx, store.TechnicalEntry{ + OccurredAt: e.At, Level: e.Level, Source: e.Source, + Code: e.Code, Message: e.Message, Detail: e.Detail, + }) +} + +// relayLog is the technical journal the drivers are given BEFORE the Hub that owns one +// exists. +// +// The interval is real and short — a driver is built, then the station, then the Hub — +// and a driver that reported a bad option during it would otherwise report it into +// nothing. Until the Hub is attached the lines go to the console, where whoever started +// the service by hand can read them; afterwards they go where every other line goes. +type relayLog struct { + fallback io.Writer + + mu sync.RWMutex + target ports.TechnicalLog +} + +// attach points the relay at the journal of the running station. +func (l *relayLog) attach(target ports.TechnicalLog) { + l.mu.Lock() + defer l.mu.Unlock() + l.target = target +} + +// Technical records one event. +func (l *relayLog) Technical(level, source, code, message, detail string) { + l.mu.RLock() + target := l.target + l.mu.RUnlock() + if target != nil { + target.Technical(level, source, code, message, detail) + return + } + fmt.Fprintf(l.fallback, "openscale [%s] %s %s : %s %s\n", level, source, code, message, detail) +} + +// heldServer is the HTTP server as Station.Stop sees it, handed over after the station +// was built. +// +// station.Options.Server is fixed at construction and the server cannot exist before +// the Hub whose subscribers it closes on shutdown. Rather than move the shutdown +// sequence out of Station.Stop — which is where §13.4 is written and tested — the +// composition root hands over a holder and fills it one line later. +type heldServer struct { + mu sync.RWMutex + server *http.Server +} + +// hold puts the server in place. It is called once, before anything can serve. +func (h *heldServer) hold(server *http.Server) { + h.mu.Lock() + defer h.mu.Unlock() + h.server = server +} + +// Shutdown stops accepting and waits for the active requests, up to ctx. +// +// A holder that was never filled shuts nothing down and says so with a nil error: the +// station failed before it ever served, and a shutdown that reported a failure there +// would name a server that does not exist. +func (h *heldServer) Shutdown(ctx context.Context) error { + h.mu.RLock() + server := h.server + h.mu.RUnlock() + if server == nil { + return nil + } + return server.Shutdown(ctx) +} diff --git a/cmd/openscale/admin_test.go b/cmd/openscale/admin_test.go index d63e641..0da4351 100644 --- a/cmd/openscale/admin_test.go +++ b/cmd/openscale/admin_test.go @@ -1,26 +1,15 @@ package main import ( - "bytes" - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" - "io" - "mime/multipart" "net/http" - "net/http/cookiejar" - "net/url" "os" "path/filepath" "strings" "testing" - "time" - - "golang.org/x/crypto/argon2" "openscale/internal/domain" - "openscale/internal/scale/gramxfoc" ) // THE ADMINISTRATION ROUTES, DRIVEN AGAINST THE REAL THING. @@ -34,6 +23,11 @@ import ( // The one thing these tests cannot assert is a sixty-second countdown: `serve` runs on the // SYSTEM clock, by construction. That assertion belongs to internal/web and to // internal/station, where the clock is injected, and it is made there. +// +// What is left here is the screens that READ AND WRITE THE STATION'S OWN STATE: the +// dashboard, the configuration, the catalog, the journal and the decisions. The screens +// that ask the MACHINE what is plugged in are in adminhardware_test.go, and the bench +// they all share in adminbench_test.go. // TestTheDashboardShowsTheInventoryOfSection14_4 is the sentence a volunteer reads, word // for word, out of the REAL reference file. @@ -77,91 +71,6 @@ func TestTheDashboardShowsTheInventoryOfSection14_4(t *testing.T) { } } -// TestTheTroubleshootingRoutesAnswerWithoutAPassword is ADR-033, checked in both -// directions on the same running station. -// -// The criterion moved from the DOOR to the ACT: « ce qui change ce que le poste vend, ou -// la façon dont il pèse » is protected, everything one can merely look at is not. Testing -// the scale, asking the printer for its status, printing a demonstration label, reading a -// configuration whose two hashes are redacted before they leave — none of those changes -// anything, so they answer to whoever is standing at the counter, who can already unplug -// the printer. -func TestTheTroubleshootingRoutesAnswerWithoutAPassword(t *testing.T) { - bench := newServeBench(t, localDropCatalog) - bench.start() - - for _, c := range []struct { - route string - body string - }{ - {"/admin/api/troubleshooting/test-scale", ""}, - {"/admin/api/troubleshooting/test-printer", ""}, - {"/admin/api/troubleshooting/test-label", ""}, - {"/admin/api/troubleshooting/reprint", ""}, - {"/admin/api/troubleshooting/reload-catalog", ""}, - {"/admin/api/troubleshooting/roll-changed", ""}, - {"/admin/api/troubleshooting/fallback-printer", `{"on":true}`}, - } { - t.Run(c.route, func(t *testing.T) { - response := bench.post(t, c.route, c.body) - defer response.Body.Close() - if response.StatusCode == http.StatusUnauthorized { - t.Fatalf("%s exige un mot de passe : ADR-018 dit le contraire, et un bénévole "+ - "seul devant un poste muet ne peut plus rien tester", c.route) - } - if response.StatusCode == http.StatusNotImplemented { - t.Fatalf("%s répond 501 : le collaborateur n'est pas câblé dans serve.go", c.route) - } - // The answer is French, whatever it is: this route is read by a volunteer. - if body := readBody(t, response); !hasFrenchSentence(body) { - t.Fatalf("%s répond %d sans phrase française : %s", c.route, response.StatusCode, body) - } - }) - } - - // Ce qui s'OUVRE en lecture. Le mot de passe qu'il fallait pour lire un numéro de - // port n'achetait rien : la charge utile est expurgée de ses deux empreintes avant - // de partir, et le journal est déjà dans diagnostic.zip, que personne ne protège. - for _, route := range []string{ - "/admin/api/config", "/admin/api/config/versions", "/admin/api/ports", - "/admin/api/printers", "/admin/api/journal", "/admin/api/journal/export.csv", - "/admin/api/technical", "/admin/api/imports", - } { - t.Run("lecture ouverte "+route, func(t *testing.T) { - response := bench.get(route) - defer response.Body.Close() - if response.StatusCode == http.StatusUnauthorized || - response.StatusCode == http.StatusConflict { - t.Fatalf("%s répond %d : ADR-033 l'ouvre en LECTURE, on n'y écrit rien", - route, response.StatusCode) - } - }) - } - - // Ce qui reste fermé, et les deux qui viennent d'y entrer. - for _, c := range []struct{ method, route, body string }{ - {http.MethodPut, "/admin/api/config", `{}`}, - {http.MethodGet, "/admin/api/config/export", ""}, - {http.MethodPost, "/admin/api/config/restore", `{"version":1}`}, - // Elle coupe la balance et laisse le CLIENT taper son propre poids. - {http.MethodPost, "/admin/api/troubleshooting/manual-entry", `{"on":true}`}, - // Il remplace toute la grille par un fichier qu'on a apporté. - {http.MethodPost, "/admin/api/catalog/import", `{}`}, - } { - t.Run("acte protégé "+c.route, func(t *testing.T) { - response := bench.do(t, c.method, c.route, c.body) - defer response.Body.Close() - // 401 « session absente » sur un poste qui a un mot de passe, 409 « aucun mot - // de passe posé » sinon : les deux refusent, et l'écran les distingue. - if response.StatusCode != http.StatusUnauthorized && - response.StatusCode != http.StatusConflict { - t.Fatalf("%s %s répond %d sans session : cet acte change ce que le poste "+ - "vend ou la façon dont il pèse", c.method, c.route, response.StatusCode) - } - }) - } -} - // TestAnInvalidConfigurationComesBackWithEveryFaultAtOnce is step 2 of §11.4, against the // real file and the real registries. // @@ -380,190 +289,6 @@ func TestAFileThatIsNotACatalogIsRefusedWhileTheVolunteerIsStillLooking(t *testi } } -// TestTheHardwareRoutesAnswerFromThePlatform is the wiring of the Matériel page (§14.4). -// -// What the enumeration finds on the machine running the test is unknowable — a build agent -// has an unpredictable number of serial ports and print queues — so what is asserted is -// what a screen depends on: a 200, a well-formed list, and never a 501. A 501 here would -// mean the collaborator is not wired, which is exactly the state this lot removes. -func TestTheHardwareRoutesAnswerFromThePlatform(t *testing.T) { - bench := newServeBench(t, withPassword) - bench.start() - bench.login(t) - - ports := bench.get("/admin/api/ports") - defer ports.Body.Close() - if ports.StatusCode != http.StatusOK { - t.Fatalf("GET /admin/api/ports = %d : %s", ports.StatusCode, readBody(t, ports)) - } - var enumerated struct { - Ports []struct { - Name string `json:"name"` - Description string `json:"description"` - } `json:"ports"` - } - decodeInto(t, ports, &enumerated) - for _, port := range enumerated.Ports { - if strings.TrimSpace(port.Name) == "" { - t.Fatalf("un port sans nom est servi à l'écran : %+v", enumerated.Ports) - } - } - - printers := bench.get("/admin/api/printers") - defer printers.Body.Close() - if printers.StatusCode != http.StatusOK { - t.Fatalf("GET /admin/api/printers = %d : %s", printers.StatusCode, readBody(t, printers)) - } -} - -// TestTheLabelPreviewIsThePNGOfTheRenderer is decision A2: ONE renderer, not two. -// -// A preview produced by a second code path would be a picture of what somebody hoped the -// printer would do. The bytes are checked to be a PNG and the route to be cacheless — the -// settings screen refreshes it at every keystroke, and a cached one would show the previous -// offset. -func TestTheLabelPreviewIsThePNGOfTheRenderer(t *testing.T) { - bench := newServeBench(t, withPassword) - bench.start() - bench.login(t) - - response := bench.get("/admin/api/label/preview.png?demo=1") - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("GET /admin/api/label/preview.png = %d : %s", - response.StatusCode, readBody(t, response)) - } - if got := response.Header.Get("Content-Type"); got != "image/png" { - t.Fatalf("Content-Type %q, attendu image/png", got) - } - if got := response.Header.Get("Cache-Control"); got != "no-store" { - t.Fatalf("Cache-Control %q : un aperçu mis en cache montre le décalage précédent", got) - } - body := []byte(readBody(t, response)) - if !bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n")) { - t.Fatalf("le corps n'est pas un PNG : %q", body[:min(16, len(body))]) - } - - // The dual grid is the crowded case, and it must render too: that is what the flag is - // for — seeing the two-tier layout without having to configure it first. - dual := bench.get("/admin/api/label/preview.png?demo=1&dual=1") - defer dual.Body.Close() - if dual.StatusCode != http.StatusOK { - t.Fatalf("aperçu bi-tarif = %d : %s", dual.StatusCode, readBody(t, dual)) - } - - // And without a weighing in flight, the aperçu of the LIVE label says so in French - // rather than drawing an empty label. - live := bench.get("/admin/api/label/preview.png") - defer live.Body.Close() - if live.StatusCode != http.StatusUnprocessableEntity { - t.Fatalf("aperçu sans pesée en cours = %d, attendu 422", live.StatusCode) - } -} - -// TestReplayingAFrameGoesThroughTheDecoder is the button « Rejouer cette trame » of the -// Journal page. -// -// The frame is the one the reference vector is written around, and the route is what turns a -// frame that caused an unexplained refusal into a permanent test — without a trip to the -// shop and without a scale. A frame the grammar of §9.2 refuses is a 422 that SAYS SO, -// because « ça ne se décode pas » is the answer, not a failure of the button. -// -// It is decoded with the grammar THIS STATION declares, which is why the bench declares -// one: a frame from the journal of this station was emitted by the scale of this station, -// and replaying it through another protocol would answer « la balance a émis quelque chose -// que la grammaire refuse » — a lie about the hardware, and an invitation to go and look -// at a scale that is fine. -func TestReplayingAFrameGoesThroughTheDecoder(t *testing.T) { - bench := newServeBench(t, withPassword, declaringScaleType(gramxfoc.IDRS)) - bench.start() - bench.login(t) - - response := bench.post(t, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) - defer response.Body.Close() - if response.StatusCode != http.StatusAccepted { - t.Fatalf("POST /admin/api/replay = %d, attendu 202 : %s", - response.StatusCode, readBody(t, response)) - } - - refused := bench.post(t, "/admin/api/replay", `{"frame":"XX,YY,ZZZ"}`) - defer refused.Body.Close() - if refused.StatusCode != http.StatusUnprocessableEntity { - t.Fatalf("une trame illisible répond %d, attendu 422 : %s", - refused.StatusCode, readBody(t, refused)) - } - if body := readBody(t, refused); !strings.Contains(body, "décode") { - t.Fatalf("le refus ne dit pas que la trame ne se décode pas : %s", body) - } -} - -// TestAStationThatDeclaresNoProtocolCannotReplayAFrame is the refusal that replaces a -// silent wrong answer. -// -// This route used to build the grammar of §9.2 whatever scale.type said. On a station -// declaring no protocol — or another one — it therefore answered about a grammar nobody -// chose, and « cette trame ne se décode pas » would have been said of the wrong one. The -// refusal now names the setting to fill in, and names a page that exists. -func TestAStationThatDeclaresNoProtocolCannotReplayAFrame(t *testing.T) { - bench := newServeBench(t, withPassword) - bench.start() - bench.login(t) - - refused := bench.post(t, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) - defer refused.Body.Close() - if refused.StatusCode != http.StatusUnprocessableEntity { - t.Fatalf("POST /admin/api/replay = %d sur un poste sans protocole, attendu 422 : %s", - refused.StatusCode, readBody(t, refused)) - } - body := readBody(t, refused) - if !strings.Contains(body, "scale.type") { - t.Fatalf("le refus ne nomme pas le réglage à renseigner : %s", body) - } -} - -// declaringScaleType makes the bench a station that NAMES a weighing protocol without -// opening a port: scale.present stays false, so no serial handle is taken. -// -// It is a real configuration and not a contrivance — it is what a station looks like -// between the moment « Détecter automatiquement » proposed a protocol and the moment the -// scale is plugged in — and the port has to be there because serial.OptionSchema declares -// it Required, so a type with no options would be a fault and the station would fall back -// on the neutral profile, which names no hardware at all. -func declaringScaleType(id string) func(*domain.Config) { - return func(cfg *domain.Config) { - cfg.Scale.Type = id - cfg.Scale.Options = domain.DriverOptions{"port": json.RawMessage(`"COM8"`)} - } -} - -// TestTheRollAndTheFallbackActOnThePrinterInService is the wiring of two of the nine -// buttons, and of the honest refusal behind one of them. -// -// « J'ai changé le rouleau » is the only gesture that tells this application anything true -// about the paper, so it must reach the counter of the printer IN SERVICE. « Imprimer sur -// l'imprimante du poste N » must refuse, in French, on a station where no fallback is -// configured — which is the shipped state — instead of pretending to switch. -func TestTheRollAndTheFallbackActOnThePrinterInService(t *testing.T) { - bench := newServeBench(t) - bench.start() - - roll := bench.post(t, "/admin/api/troubleshooting/roll-changed", "") - defer roll.Body.Close() - if roll.StatusCode != http.StatusOK { - t.Fatalf("roll-changed = %d : %s", roll.StatusCode, readBody(t, roll)) - } - - fallback := bench.post(t, "/admin/api/troubleshooting/fallback-printer", `{"on":true}`) - defer fallback.Body.Close() - if fallback.StatusCode == http.StatusOK { - t.Fatal("la bascule vers une imprimante de secours a réussi sur un poste qui n'en " + - "déclare aucune") - } - if body := readBody(t, fallback); !strings.Contains(body, "secours") { - t.Fatalf("le refus ne nomme pas ce qui manque : %s", body) - } -} - // TestADecisionIsRecordedThroughTheOneRoute is §10.6 and ADR-017: « ne plus proposer » and // the minimum-weight waiver are two COLUMNS of one table, not two mechanisms. func TestADecisionIsRecordedThroughTheOneRoute(t *testing.T) { @@ -677,394 +402,3 @@ func TestForgettingTheQuarantineReachesTheBase(t *testing.T) { t.Fatalf("la réponse ne dit pas ce qui a été fait : %s", body) } } - -// --- The bench, extended for the administration surface ---------------------- - -// importAnswer is the inventory as the routes publish it, and the only place a test reads -// those figures from: the DTO of internal/web, not the domain type behind it. -type importAnswer struct { - OccurredAt string `json:"occurred_at"` - Source string `json:"source"` - FileName string `json:"file_name"` - Result string `json:"result"` - RowsRead int `json:"rows_read_count"` - Weighable int `json:"weighable_count"` - NotWeighable int `json:"not_weighable_count"` - Anomalies int `json:"anomalies_count"` - UnitMismatches int `json:"unit_mismatches_count"` - ImagesDecoded int `json:"images_decoded_count"` -} - -// localDropCatalog switches the bench to the local drop, which is the source the -// drag-and-drop and the watched directory both need (§10.1). -// -// The shipped file watches a WebDAV share — the real supply chain of the cooperative — and -// a test cannot reach it. The poll interval goes down to one second because these tests -// wait on the WALL clock: `serve` runs on the system clock by construction, so the only -// honest way to keep them fast is to make the station poll faster, which is a supported -// setting (§11.2). -func localDropCatalog(cfg *domain.Config) { - cfg.Catalog.Type = domain.CatalogSourceLocalDrop - cfg.Catalog.Options = stripOptions(cfg.Catalog.Options, - "url", "username", "password") - cfg.Catalog.Options = overlayOptions(cfg.Catalog.Options, map[string]any{ - "poll_interval_s": 1, - "stable_polls": 2, - }) -} - -// withPassword puts a REAL argon2id hash in the configuration, so that a test can open a -// session. -// -// The shipped file carries a placeholder on purpose: nobody knows the password of a station -// that has not been installed. This is what `openscale config password` writes, and the -// format is the one internal/web reads back — salt and cost included, so a hash written by -// another binary keeps opening. -func withPassword(cfg *domain.Config) { - cfg.Admin.PasswordHash = argon2idHash(benchPassword) -} - -// benchPassword is the password of the bench. It is long enough to pass the controls of -// §11.3 and it is not a secret: it lives in a test. -const benchPassword = "un-mot-de-passe-de-banc-2026" - -// argon2idHash writes one PHC string the session store can verify. -// -// The cost is the lowest argon2id takes, not the one an installed station writes: -// web.VerifySecret reads m, t and p back from the string it is given, so the bench -// pays for the FORMAT — which is what these tests are about — and not for the seconds -// of key derivation that protect a password nobody is attacking here. -func argon2idHash(secret string) string { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - panic("tirage du sel impossible : " + err.Error()) - } - const ( - memory = 8 - iterations = 1 - threads = 1 - keyLength = 32 - ) - key := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, keyLength) - return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", - argon2.Version, memory, iterations, threads, - base64.RawStdEncoding.EncodeToString(salt), - base64.RawStdEncoding.EncodeToString(key)) -} - -// login opens an administration session and keeps the cookie. -func (b *serveBench) login(t *testing.T) { - t.Helper() - response := b.post(t, "/admin/api/session", - `{"password":`+quoteJSON(benchPassword)+`}`) - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("ouverture de session = %d : %s", response.StatusCode, readBody(t, response)) - } - cookies := response.Cookies() - if len(cookies) == 0 { - t.Fatal("la session n'a pas posé de cookie") - } - // A jar, and not a header pasted by hand: the session travels in a cookie, and a test - // that assembled the header itself would be testing its own helper. Every request of - // the bench carries it afterwards, GET included. - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("bocal à cookies : %v", err) - } - base, err := url.Parse("http://" + b.address + "/") - if err != nil { - t.Fatalf("adresse du poste : %v", err) - } - jar.SetCookies(base, cookies) - b.client.Jar = jar - b.cookie = cookies[0] -} - -// post issues one POST with a JSON body and the session cookie, if there is one. -func (b *serveBench) post(t *testing.T, path, body string) *http.Response { - t.Helper() - return b.request(t, http.MethodPost, path, "application/json", strings.NewReader(body)) -} - -// do issues one request by whatever method, which is what a table of routes needs. -func (b *serveBench) do(t *testing.T, method, path, body string) *http.Response { - t.Helper() - return b.request(t, method, path, "application/json", strings.NewReader(body)) -} - -// put issues one PUT with a JSON body. -func (b *serveBench) put(t *testing.T, path string, body []byte) *http.Response { - t.Helper() - return b.request(t, http.MethodPut, path, "application/json", bytes.NewReader(body)) -} - -// upload issues one multipart POST, which is what a drag-and-drop really sends. -func (b *serveBench) upload(t *testing.T, path, name string, content []byte) *http.Response { - t.Helper() - var body bytes.Buffer - form := multipart.NewWriter(&body) - part, err := form.CreateFormFile("file", name) - if err != nil { - t.Fatalf("formulaire multipart : %v", err) - } - if _, err := part.Write(content); err != nil { - t.Fatalf("écriture du fichier dans le formulaire : %v", err) - } - if err := form.Close(); err != nil { - t.Fatalf("clôture du formulaire : %v", err) - } - return b.request(t, http.MethodPost, path, form.FormDataContentType(), &body) -} - -// request issues one request against the running station, carrying the session cookie. -func (b *serveBench) request(t *testing.T, method, path, contentType string, body io.Reader) *http.Response { - t.Helper() - request, err := http.NewRequest(method, "http://"+b.address+path, body) - if err != nil { - t.Fatalf("%s %s : %v", method, path, err) - } - request.Header.Set("Content-Type", contentType) - response, err := b.client.Do(request) - if err != nil { - t.Fatalf("%s %s : %v", method, path, err) - } - return response -} - -// readConfig reads the configuration the STATION is serving, through the route. -func (b *serveBench) readConfig(t *testing.T) domain.Config { - t.Helper() - response := b.get("/admin/api/config") - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("GET /admin/api/config = %d : %s", response.StatusCode, readBody(t, response)) - } - var payload struct { - Config json.RawMessage `json:"config"` - } - decodeInto(t, response, &payload) - var cfg domain.Config - if err := json.Unmarshal(payload.Config, &cfg); err != nil { - t.Fatalf("configuration servie illisible : %v", err) - } - return cfg -} - -// diskConfig reads the file itself, which is what the next start will read. -func (b *serveBench) diskConfig(t *testing.T) domain.Config { - t.Helper() - return readConfigFile(t, b.configPath) -} - -// configVersion reads one of the rotated backups. -func (b *serveBench) configVersion(t *testing.T, version int) domain.Config { - t.Helper() - return readConfigFile(t, fmt.Sprintf("%s.%d", b.configPath, version)) -} - -// readConfigFile parses one configuration file of the bench. -func readConfigFile(t *testing.T, path string) domain.Config { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("lecture de %s : %v", path, err) - } - var cfg domain.Config - if err := json.Unmarshal(raw, &cfg); err != nil { - t.Fatalf("%s illisible : %v", path, err) - } - return cfg -} - -// dropCatalog puts one fixture in the directory the station watches, BEFORE it starts. -func (b *serveBench) dropCatalog(t *testing.T, fixture string) { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "catalog", fixture)) - if err != nil { - t.Fatalf("lecture de la fixture %s : %v", fixture, err) - } - path := b.watchedFile() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("création du répertoire surveillé : %v", err) - } - if err := os.WriteFile(path, raw, 0o644); err != nil { - t.Fatalf("dépôt de %s : %v", fixture, err) - } -} - -// watchedFile is the file the local drop watches: flv_.csv, derived from -// station.number and from nothing else (§11.2). -func (b *serveBench) watchedFile() string { - return filepath.Join(b.dataDir, "catalog", "incoming", "flv_2.csv") -} - -// awaitCatalogInventory waits for an import to have taken service and returns its -// inventory, as the dashboard publishes it. -// -// It POLLS a route rather than sleeping: the station runs on the system clock here, and the -// only honest way to wait for a poll interval is to ask until the answer changes. The -// budget is generous and never elapses in a passing run. -func (b *serveBench) awaitCatalogInventory(t *testing.T) importAnswer { - t.Helper() - deadline := time.Now().Add(startBudget) - for time.Now().Before(deadline) { - response := b.get("/admin/api/health") - var dashboard struct { - Catalog *importAnswer `json:"catalog"` - } - decodeInto(t, response, &dashboard) - _ = response.Body.Close() - if dashboard.Catalog != nil && dashboard.Catalog.Result != "" { - return *dashboard.Catalog - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("aucun catalogue n'a pris service en %s\n%s", startBudget, b.output()) - return importAnswer{} -} - -// technicalAnswer is what the Journal page reads on /admin/api/technical. -type technicalAnswer struct { - Entries []struct { - OccurredAt string `json:"occurred_at"` - Level string `json:"level"` - Source string `json:"source"` - Message string `json:"message"` - } `json:"entries"` -} - -// awaitTechnicalLines polls until the start-up lines of the station have REACHED THE -// BASE, and never merely until the acts that produce them have run. -// -// The two are not the same instant, and the gap between them is invisible on a fast -// machine. `Hub.logTechnical` hands the entry to a CHANNEL — a non-blocking send, so that -// journalling can never hold up the one goroutine that decides — and `journalWorker.run` -// drains it on ANOTHER goroutine, which is what finally writes it where this route can -// read it. A socket that answers proves the station is UP; it proves nothing about that -// drain having run. -// -// Read straight after `start`, this was a race the repository won locally and lost on a -// loaded CI runner: « le journal technique est vide : l'adaptateur ne lit pas la base ». -// Delaying the drain by 50 ms reproduces it every time, which is how it was found — the -// same instrumentation, and the same cause, as the three station-side readings of a32a9a2. -func (b *serveBench) awaitTechnicalLines(t *testing.T) technicalAnswer { - t.Helper() - deadline := time.Now().Add(startBudget) - for time.Now().Before(deadline) { - response := b.get("/admin/api/technical") - var lines technicalAnswer - decodeInto(t, response, &lines) - _ = response.Body.Close() - if len(lines.Entries) != 0 { - return lines - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("le journal technique est resté vide %s : l'adaptateur ne lit pas la base\n%s", - startBudget, b.output()) - return technicalAnswer{} -} - -// awaitAcknowledgement waits for the watched file to be gone, which is what an -// acknowledgement IS (ADR-004). -// -// It comes after the transaction on purpose — a crash between reading and applying must -// lose nothing — so the dashboard can already carry the inventory while the file is still -// there for a few milliseconds. -func (b *serveBench) awaitAcknowledgement(t *testing.T) { - t.Helper() - deadline := time.Now().Add(startBudget) - for time.Now().Before(deadline) { - if _, err := os.Stat(b.watchedFile()); err != nil { - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("%s existe encore après %s : l'acquittement est la suppression du fichier", - b.watchedFile(), startBudget) -} - -// --- Small helpers ---------------------------------------------------------- - -// readBody reads one response as text without closing it: the caller owns the body. -func readBody(t *testing.T, response *http.Response) string { - t.Helper() - raw, err := io.ReadAll(response.Body) - if err != nil { - t.Fatalf("lecture du corps : %v", err) - } - return string(raw) -} - -// decodeInto reads one JSON body into a value. -func decodeInto(t *testing.T, response *http.Response, into any) { - t.Helper() - raw := readBody(t, response) - if err := json.Unmarshal([]byte(raw), into); err != nil { - t.Fatalf("corps illisible (%s) : %v", raw, err) - } -} - -// mustJSON serialises one value, or fails the test. -func mustJSON(t *testing.T, value any) []byte { - t.Helper() - raw, err := json.Marshal(value) - if err != nil { - t.Fatalf("sérialisation : %v", err) - } - return raw -} - -// quoteJSON renders one JSON string. -func quoteJSON(s string) string { - raw, _ := json.Marshal(s) - return string(raw) -} - -// hasFrenchSentence reports whether an answer carries something a volunteer can read. -// -// It is deliberately crude: what it catches is an answer with no message at all, which is -// what a route that forgot its wording looks like. -func hasFrenchSentence(body string) bool { - return strings.Contains(body, `"message"`) || strings.Contains(body, `"health"`) || - strings.Contains(body, `"connected"`) -} - -// keysOf lists the names of a map, for a failure message. -func keysOf(files map[string]string) []string { - names := make([]string, 0, len(files)) - for name := range files { - names = append(names, name) - } - return names -} - -// stripOptions removes the keys a source does not accept, so that a configuration switched -// from one source to the other still validates (control 41). -func stripOptions(base domain.DriverOptions, keys ...string) domain.DriverOptions { - out := make(domain.DriverOptions, len(base)) - for key, value := range base { - out[key] = value - } - for _, key := range keys { - delete(out, key) - } - return out -} - -// overlayOptions writes a few driver options over the ones a configuration carries. -func overlayOptions(base domain.DriverOptions, overlay map[string]any) domain.DriverOptions { - out := make(domain.DriverOptions, len(base)+len(overlay)) - for key, value := range base { - out[key] = value - } - for key, value := range overlay { - raw, err := json.Marshal(value) - if err != nil { - panic("option " + key + " : " + err.Error()) - } - out[key] = raw - } - return out -} diff --git a/cmd/openscale/adminbench_test.go b/cmd/openscale/adminbench_test.go new file mode 100644 index 0000000..c966c0a --- /dev/null +++ b/cmd/openscale/adminbench_test.go @@ -0,0 +1,418 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "golang.org/x/crypto/argon2" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "openscale/internal/domain" +) + +// What the administration surface ADDS to the bench of servebench_test.go: a session to +// log into, the four verbs its routes are driven with, the reads that come back from +// disk rather than from memory, and the waits that let a catalog arrive. Below them, +// the small helpers every assertion of these screens is written with. + +// --- The bench, extended for the administration surface ---------------------- + +// importAnswer is the inventory as the routes publish it, and the only place a test reads +// those figures from: the DTO of internal/web, not the domain type behind it. +type importAnswer struct { + OccurredAt string `json:"occurred_at"` + Source string `json:"source"` + FileName string `json:"file_name"` + Result string `json:"result"` + RowsRead int `json:"rows_read_count"` + Weighable int `json:"weighable_count"` + NotWeighable int `json:"not_weighable_count"` + Anomalies int `json:"anomalies_count"` + UnitMismatches int `json:"unit_mismatches_count"` + ImagesDecoded int `json:"images_decoded_count"` +} + +// localDropCatalog switches the bench to the local drop, which is the source the +// drag-and-drop and the watched directory both need (§10.1). +// +// The shipped file watches a WebDAV share — the real supply chain of the cooperative — and +// a test cannot reach it. The poll interval goes down to one second because these tests +// wait on the WALL clock: `serve` runs on the system clock by construction, so the only +// honest way to keep them fast is to make the station poll faster, which is a supported +// setting (§11.2). +func localDropCatalog(cfg *domain.Config) { + cfg.Catalog.Type = domain.CatalogSourceLocalDrop + cfg.Catalog.Options = stripOptions(cfg.Catalog.Options, + "url", "username", "password") + cfg.Catalog.Options = overlayOptions(cfg.Catalog.Options, map[string]any{ + "poll_interval_s": 1, + "stable_polls": 2, + }) +} + +// withPassword puts a REAL argon2id hash in the configuration, so that a test can open a +// session. +// +// The shipped file carries a placeholder on purpose: nobody knows the password of a station +// that has not been installed. This is what `openscale config password` writes, and the +// format is the one internal/web reads back — salt and cost included, so a hash written by +// another binary keeps opening. +func withPassword(cfg *domain.Config) { + cfg.Admin.PasswordHash = argon2idHash(benchPassword) +} + +// benchPassword is the password of the bench. It is long enough to pass the controls of +// §11.3 and it is not a secret: it lives in a test. +const benchPassword = "un-mot-de-passe-de-banc-2026" + +// argon2idHash writes one PHC string the session store can verify. +// +// The cost is the lowest argon2id takes, not the one an installed station writes: +// web.VerifySecret reads m, t and p back from the string it is given, so the bench +// pays for the FORMAT — which is what these tests are about — and not for the seconds +// of key derivation that protect a password nobody is attacking here. +func argon2idHash(secret string) string { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + panic("tirage du sel impossible : " + err.Error()) + } + const ( + memory = 8 + iterations = 1 + threads = 1 + keyLength = 32 + ) + key := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, keyLength) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, memory, iterations, threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)) +} + +// login opens an administration session and keeps the cookie. +func (b *serveBench) login(t *testing.T) { + t.Helper() + response := b.post(t, "/admin/api/session", + `{"password":`+quoteJSON(benchPassword)+`}`) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("ouverture de session = %d : %s", response.StatusCode, readBody(t, response)) + } + cookies := response.Cookies() + if len(cookies) == 0 { + t.Fatal("la session n'a pas posé de cookie") + } + // A jar, and not a header pasted by hand: the session travels in a cookie, and a test + // that assembled the header itself would be testing its own helper. Every request of + // the bench carries it afterwards, GET included. + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("bocal à cookies : %v", err) + } + base, err := url.Parse("http://" + b.address + "/") + if err != nil { + t.Fatalf("adresse du poste : %v", err) + } + jar.SetCookies(base, cookies) + b.client.Jar = jar + b.cookie = cookies[0] +} + +// post issues one POST with a JSON body and the session cookie, if there is one. +func (b *serveBench) post(t *testing.T, path, body string) *http.Response { + t.Helper() + return b.request(t, http.MethodPost, path, "application/json", strings.NewReader(body)) +} + +// do issues one request by whatever method, which is what a table of routes needs. +func (b *serveBench) do(t *testing.T, method, path, body string) *http.Response { + t.Helper() + return b.request(t, method, path, "application/json", strings.NewReader(body)) +} + +// put issues one PUT with a JSON body. +func (b *serveBench) put(t *testing.T, path string, body []byte) *http.Response { + t.Helper() + return b.request(t, http.MethodPut, path, "application/json", bytes.NewReader(body)) +} + +// upload issues one multipart POST, which is what a drag-and-drop really sends. +func (b *serveBench) upload(t *testing.T, path, name string, content []byte) *http.Response { + t.Helper() + var body bytes.Buffer + form := multipart.NewWriter(&body) + part, err := form.CreateFormFile("file", name) + if err != nil { + t.Fatalf("formulaire multipart : %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("écriture du fichier dans le formulaire : %v", err) + } + if err := form.Close(); err != nil { + t.Fatalf("clôture du formulaire : %v", err) + } + return b.request(t, http.MethodPost, path, form.FormDataContentType(), &body) +} + +// request issues one request against the running station, carrying the session cookie. +func (b *serveBench) request(t *testing.T, method, path, contentType string, body io.Reader) *http.Response { + t.Helper() + request, err := http.NewRequest(method, "http://"+b.address+path, body) + if err != nil { + t.Fatalf("%s %s : %v", method, path, err) + } + request.Header.Set("Content-Type", contentType) + response, err := b.client.Do(request) + if err != nil { + t.Fatalf("%s %s : %v", method, path, err) + } + return response +} + +// readConfig reads the configuration the STATION is serving, through the route. +func (b *serveBench) readConfig(t *testing.T) domain.Config { + t.Helper() + response := b.get("/admin/api/config") + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/api/config = %d : %s", response.StatusCode, readBody(t, response)) + } + var payload struct { + Config json.RawMessage `json:"config"` + } + decodeInto(t, response, &payload) + var cfg domain.Config + if err := json.Unmarshal(payload.Config, &cfg); err != nil { + t.Fatalf("configuration servie illisible : %v", err) + } + return cfg +} + +// diskConfig reads the file itself, which is what the next start will read. +func (b *serveBench) diskConfig(t *testing.T) domain.Config { + t.Helper() + return readConfigFile(t, b.configPath) +} + +// configVersion reads one of the rotated backups. +func (b *serveBench) configVersion(t *testing.T, version int) domain.Config { + t.Helper() + return readConfigFile(t, fmt.Sprintf("%s.%d", b.configPath, version)) +} + +// readConfigFile parses one configuration file of the bench. +func readConfigFile(t *testing.T, path string) domain.Config { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + var cfg domain.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("%s illisible : %v", path, err) + } + return cfg +} + +// dropCatalog puts one fixture in the directory the station watches, BEFORE it starts. +func (b *serveBench) dropCatalog(t *testing.T, fixture string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "catalog", fixture)) + if err != nil { + t.Fatalf("lecture de la fixture %s : %v", fixture, err) + } + path := b.watchedFile() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("création du répertoire surveillé : %v", err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatalf("dépôt de %s : %v", fixture, err) + } +} + +// watchedFile is the file the local drop watches: flv_.csv, derived from +// station.number and from nothing else (§11.2). +func (b *serveBench) watchedFile() string { + return filepath.Join(b.dataDir, "catalog", "incoming", "flv_2.csv") +} + +// awaitCatalogInventory waits for an import to have taken service and returns its +// inventory, as the dashboard publishes it. +// +// It POLLS a route rather than sleeping: the station runs on the system clock here, and the +// only honest way to wait for a poll interval is to ask until the answer changes. The +// budget is generous and never elapses in a passing run. +func (b *serveBench) awaitCatalogInventory(t *testing.T) importAnswer { + t.Helper() + deadline := time.Now().Add(startBudget) + for time.Now().Before(deadline) { + response := b.get("/admin/api/health") + var dashboard struct { + Catalog *importAnswer `json:"catalog"` + } + decodeInto(t, response, &dashboard) + _ = response.Body.Close() + if dashboard.Catalog != nil && dashboard.Catalog.Result != "" { + return *dashboard.Catalog + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("aucun catalogue n'a pris service en %s\n%s", startBudget, b.output()) + return importAnswer{} +} + +// technicalAnswer is what the Journal page reads on /admin/api/technical. +type technicalAnswer struct { + Entries []struct { + OccurredAt string `json:"occurred_at"` + Level string `json:"level"` + Source string `json:"source"` + Message string `json:"message"` + } `json:"entries"` +} + +// awaitTechnicalLines polls until the start-up lines of the station have REACHED THE +// BASE, and never merely until the acts that produce them have run. +// +// The two are not the same instant, and the gap between them is invisible on a fast +// machine. `Hub.logTechnical` hands the entry to a CHANNEL — a non-blocking send, so that +// journalling can never hold up the one goroutine that decides — and `journalWorker.run` +// drains it on ANOTHER goroutine, which is what finally writes it where this route can +// read it. A socket that answers proves the station is UP; it proves nothing about that +// drain having run. +// +// Read straight after `start`, this was a race the repository won locally and lost on a +// loaded CI runner: « le journal technique est vide : l'adaptateur ne lit pas la base ». +// Delaying the drain by 50 ms reproduces it every time, which is how it was found — the +// same instrumentation, and the same cause, as the three station-side readings of a32a9a2. +func (b *serveBench) awaitTechnicalLines(t *testing.T) technicalAnswer { + t.Helper() + deadline := time.Now().Add(startBudget) + for time.Now().Before(deadline) { + response := b.get("/admin/api/technical") + var lines technicalAnswer + decodeInto(t, response, &lines) + _ = response.Body.Close() + if len(lines.Entries) != 0 { + return lines + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("le journal technique est resté vide %s : l'adaptateur ne lit pas la base\n%s", + startBudget, b.output()) + return technicalAnswer{} +} + +// awaitAcknowledgement waits for the watched file to be gone, which is what an +// acknowledgement IS (ADR-004). +// +// It comes after the transaction on purpose — a crash between reading and applying must +// lose nothing — so the dashboard can already carry the inventory while the file is still +// there for a few milliseconds. +func (b *serveBench) awaitAcknowledgement(t *testing.T) { + t.Helper() + deadline := time.Now().Add(startBudget) + for time.Now().Before(deadline) { + if _, err := os.Stat(b.watchedFile()); err != nil { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("%s existe encore après %s : l'acquittement est la suppression du fichier", + b.watchedFile(), startBudget) +} + +// --- Small helpers ---------------------------------------------------------- + +// readBody reads one response as text without closing it: the caller owns the body. +func readBody(t *testing.T, response *http.Response) string { + t.Helper() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("lecture du corps : %v", err) + } + return string(raw) +} + +// decodeInto reads one JSON body into a value. +func decodeInto(t *testing.T, response *http.Response, into any) { + t.Helper() + raw := readBody(t, response) + if err := json.Unmarshal([]byte(raw), into); err != nil { + t.Fatalf("corps illisible (%s) : %v", raw, err) + } +} + +// mustJSON serialises one value, or fails the test. +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("sérialisation : %v", err) + } + return raw +} + +// quoteJSON renders one JSON string. +func quoteJSON(s string) string { + raw, _ := json.Marshal(s) + return string(raw) +} + +// hasFrenchSentence reports whether an answer carries something a volunteer can read. +// +// It is deliberately crude: what it catches is an answer with no message at all, which is +// what a route that forgot its wording looks like. +func hasFrenchSentence(body string) bool { + return strings.Contains(body, `"message"`) || strings.Contains(body, `"health"`) || + strings.Contains(body, `"connected"`) +} + +// keysOf lists the names of a map, for a failure message. +func keysOf(files map[string]string) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + return names +} + +// stripOptions removes the keys a source does not accept, so that a configuration switched +// from one source to the other still validates (control 41). +func stripOptions(base domain.DriverOptions, keys ...string) domain.DriverOptions { + out := make(domain.DriverOptions, len(base)) + for key, value := range base { + out[key] = value + } + for _, key := range keys { + delete(out, key) + } + return out +} + +// overlayOptions writes a few driver options over the ones a configuration carries. +func overlayOptions(base domain.DriverOptions, overlay map[string]any) domain.DriverOptions { + out := make(domain.DriverOptions, len(base)+len(overlay)) + for key, value := range base { + out[key] = value + } + for key, value := range overlay { + raw, err := json.Marshal(value) + if err != nil { + panic("option " + key + " : " + err.Error()) + } + out[key] = raw + } + return out +} diff --git a/cmd/openscale/adminhardware_test.go b/cmd/openscale/adminhardware_test.go new file mode 100644 index 0000000..a912907 --- /dev/null +++ b/cmd/openscale/adminhardware_test.go @@ -0,0 +1,287 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/scale/gramxfoc" +) + +// The expert screens of §14.4 — the ones whose answers come from the MACHINE and not +// from the station's own state: which ports and queues exist, what the troubleshooting +// buttons do without a password, the aperçu rendered by the renderer that prints, one +// frame replayed through this station's grammar, and the roll counter of the printer +// IN SERVICE. + +// TestTheTroubleshootingRoutesAnswerWithoutAPassword is ADR-033, checked in both +// directions on the same running station. +// +// The criterion moved from the DOOR to the ACT: « ce qui change ce que le poste vend, ou +// la façon dont il pèse » is protected, everything one can merely look at is not. Testing +// the scale, asking the printer for its status, printing a demonstration label, reading a +// configuration whose two hashes are redacted before they leave — none of those changes +// anything, so they answer to whoever is standing at the counter, who can already unplug +// the printer. +func TestTheTroubleshootingRoutesAnswerWithoutAPassword(t *testing.T) { + bench := newServeBench(t, localDropCatalog) + bench.start() + + for _, c := range []struct { + route string + body string + }{ + {"/admin/api/troubleshooting/test-scale", ""}, + {"/admin/api/troubleshooting/test-printer", ""}, + {"/admin/api/troubleshooting/test-label", ""}, + {"/admin/api/troubleshooting/reprint", ""}, + {"/admin/api/troubleshooting/reload-catalog", ""}, + {"/admin/api/troubleshooting/roll-changed", ""}, + {"/admin/api/troubleshooting/fallback-printer", `{"on":true}`}, + } { + t.Run(c.route, func(t *testing.T) { + response := bench.post(t, c.route, c.body) + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized { + t.Fatalf("%s exige un mot de passe : ADR-018 dit le contraire, et un bénévole "+ + "seul devant un poste muet ne peut plus rien tester", c.route) + } + if response.StatusCode == http.StatusNotImplemented { + t.Fatalf("%s répond 501 : le collaborateur n'est pas câblé dans serve.go", c.route) + } + // The answer is French, whatever it is: this route is read by a volunteer. + if body := readBody(t, response); !hasFrenchSentence(body) { + t.Fatalf("%s répond %d sans phrase française : %s", c.route, response.StatusCode, body) + } + }) + } + + // Ce qui s'OUVRE en lecture. Le mot de passe qu'il fallait pour lire un numéro de + // port n'achetait rien : la charge utile est expurgée de ses deux empreintes avant + // de partir, et le journal est déjà dans diagnostic.zip, que personne ne protège. + for _, route := range []string{ + "/admin/api/config", "/admin/api/config/versions", "/admin/api/ports", + "/admin/api/printers", "/admin/api/journal", "/admin/api/journal/export.csv", + "/admin/api/technical", "/admin/api/imports", + } { + t.Run("lecture ouverte "+route, func(t *testing.T) { + response := bench.get(route) + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized || + response.StatusCode == http.StatusConflict { + t.Fatalf("%s répond %d : ADR-033 l'ouvre en LECTURE, on n'y écrit rien", + route, response.StatusCode) + } + }) + } + + // Ce qui reste fermé, et les deux qui viennent d'y entrer. + for _, c := range []struct{ method, route, body string }{ + {http.MethodPut, "/admin/api/config", `{}`}, + {http.MethodGet, "/admin/api/config/export", ""}, + {http.MethodPost, "/admin/api/config/restore", `{"version":1}`}, + // Elle coupe la balance et laisse le CLIENT taper son propre poids. + {http.MethodPost, "/admin/api/troubleshooting/manual-entry", `{"on":true}`}, + // Il remplace toute la grille par un fichier qu'on a apporté. + {http.MethodPost, "/admin/api/catalog/import", `{}`}, + } { + t.Run("acte protégé "+c.route, func(t *testing.T) { + response := bench.do(t, c.method, c.route, c.body) + defer response.Body.Close() + // 401 « session absente » sur un poste qui a un mot de passe, 409 « aucun mot + // de passe posé » sinon : les deux refusent, et l'écran les distingue. + if response.StatusCode != http.StatusUnauthorized && + response.StatusCode != http.StatusConflict { + t.Fatalf("%s %s répond %d sans session : cet acte change ce que le poste "+ + "vend ou la façon dont il pèse", c.method, c.route, response.StatusCode) + } + }) + } +} + +// TestTheHardwareRoutesAnswerFromThePlatform is the wiring of the Matériel page (§14.4). +// +// What the enumeration finds on the machine running the test is unknowable — a build agent +// has an unpredictable number of serial ports and print queues — so what is asserted is +// what a screen depends on: a 200, a well-formed list, and never a 501. A 501 here would +// mean the collaborator is not wired, which is exactly the state this lot removes. +func TestTheHardwareRoutesAnswerFromThePlatform(t *testing.T) { + bench := newServeBench(t, withPassword) + bench.start() + bench.login(t) + + ports := bench.get("/admin/api/ports") + defer ports.Body.Close() + if ports.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/api/ports = %d : %s", ports.StatusCode, readBody(t, ports)) + } + var enumerated struct { + Ports []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"ports"` + } + decodeInto(t, ports, &enumerated) + for _, port := range enumerated.Ports { + if strings.TrimSpace(port.Name) == "" { + t.Fatalf("un port sans nom est servi à l'écran : %+v", enumerated.Ports) + } + } + + printers := bench.get("/admin/api/printers") + defer printers.Body.Close() + if printers.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/api/printers = %d : %s", printers.StatusCode, readBody(t, printers)) + } +} + +// TestTheLabelPreviewIsThePNGOfTheRenderer is decision A2: ONE renderer, not two. +// +// A preview produced by a second code path would be a picture of what somebody hoped the +// printer would do. The bytes are checked to be a PNG and the route to be cacheless — the +// settings screen refreshes it at every keystroke, and a cached one would show the previous +// offset. +func TestTheLabelPreviewIsThePNGOfTheRenderer(t *testing.T) { + bench := newServeBench(t, withPassword) + bench.start() + bench.login(t) + + response := bench.get("/admin/api/label/preview.png?demo=1") + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/api/label/preview.png = %d : %s", + response.StatusCode, readBody(t, response)) + } + if got := response.Header.Get("Content-Type"); got != "image/png" { + t.Fatalf("Content-Type %q, attendu image/png", got) + } + if got := response.Header.Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control %q : un aperçu mis en cache montre le décalage précédent", got) + } + body := []byte(readBody(t, response)) + if !bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n")) { + t.Fatalf("le corps n'est pas un PNG : %q", body[:min(16, len(body))]) + } + + // The dual grid is the crowded case, and it must render too: that is what the flag is + // for — seeing the two-tier layout without having to configure it first. + dual := bench.get("/admin/api/label/preview.png?demo=1&dual=1") + defer dual.Body.Close() + if dual.StatusCode != http.StatusOK { + t.Fatalf("aperçu bi-tarif = %d : %s", dual.StatusCode, readBody(t, dual)) + } + + // And without a weighing in flight, the aperçu of the LIVE label says so in French + // rather than drawing an empty label. + live := bench.get("/admin/api/label/preview.png") + defer live.Body.Close() + if live.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("aperçu sans pesée en cours = %d, attendu 422", live.StatusCode) + } +} + +// TestReplayingAFrameGoesThroughTheDecoder is the button « Rejouer cette trame » of the +// Journal page. +// +// The frame is the one the reference vector is written around, and the route is what turns a +// frame that caused an unexplained refusal into a permanent test — without a trip to the +// shop and without a scale. A frame the grammar of §9.2 refuses is a 422 that SAYS SO, +// because « ça ne se décode pas » is the answer, not a failure of the button. +// +// It is decoded with the grammar THIS STATION declares, which is why the bench declares +// one: a frame from the journal of this station was emitted by the scale of this station, +// and replaying it through another protocol would answer « la balance a émis quelque chose +// que la grammaire refuse » — a lie about the hardware, and an invitation to go and look +// at a scale that is fine. +func TestReplayingAFrameGoesThroughTheDecoder(t *testing.T) { + bench := newServeBench(t, withPassword, declaringScaleType(gramxfoc.IDRS)) + bench.start() + bench.login(t) + + response := bench.post(t, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) + defer response.Body.Close() + if response.StatusCode != http.StatusAccepted { + t.Fatalf("POST /admin/api/replay = %d, attendu 202 : %s", + response.StatusCode, readBody(t, response)) + } + + refused := bench.post(t, "/admin/api/replay", `{"frame":"XX,YY,ZZZ"}`) + defer refused.Body.Close() + if refused.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("une trame illisible répond %d, attendu 422 : %s", + refused.StatusCode, readBody(t, refused)) + } + if body := readBody(t, refused); !strings.Contains(body, "décode") { + t.Fatalf("le refus ne dit pas que la trame ne se décode pas : %s", body) + } +} + +// TestAStationThatDeclaresNoProtocolCannotReplayAFrame is the refusal that replaces a +// silent wrong answer. +// +// This route used to build the grammar of §9.2 whatever scale.type said. On a station +// declaring no protocol — or another one — it therefore answered about a grammar nobody +// chose, and « cette trame ne se décode pas » would have been said of the wrong one. The +// refusal now names the setting to fill in, and names a page that exists. +func TestAStationThatDeclaresNoProtocolCannotReplayAFrame(t *testing.T) { + bench := newServeBench(t, withPassword) + bench.start() + bench.login(t) + + refused := bench.post(t, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) + defer refused.Body.Close() + if refused.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("POST /admin/api/replay = %d sur un poste sans protocole, attendu 422 : %s", + refused.StatusCode, readBody(t, refused)) + } + body := readBody(t, refused) + if !strings.Contains(body, "scale.type") { + t.Fatalf("le refus ne nomme pas le réglage à renseigner : %s", body) + } +} + +// declaringScaleType makes the bench a station that NAMES a weighing protocol without +// opening a port: scale.present stays false, so no serial handle is taken. +// +// It is a real configuration and not a contrivance — it is what a station looks like +// between the moment « Détecter automatiquement » proposed a protocol and the moment the +// scale is plugged in — and the port has to be there because serial.OptionSchema declares +// it Required, so a type with no options would be a fault and the station would fall back +// on the neutral profile, which names no hardware at all. +func declaringScaleType(id string) func(*domain.Config) { + return func(cfg *domain.Config) { + cfg.Scale.Type = id + cfg.Scale.Options = domain.DriverOptions{"port": json.RawMessage(`"COM8"`)} + } +} + +// TestTheRollAndTheFallbackActOnThePrinterInService is the wiring of two of the nine +// buttons, and of the honest refusal behind one of them. +// +// « J'ai changé le rouleau » is the only gesture that tells this application anything true +// about the paper, so it must reach the counter of the printer IN SERVICE. « Imprimer sur +// l'imprimante du poste N » must refuse, in French, on a station where no fallback is +// configured — which is the shipped state — instead of pretending to switch. +func TestTheRollAndTheFallbackActOnThePrinterInService(t *testing.T) { + bench := newServeBench(t) + bench.start() + + roll := bench.post(t, "/admin/api/troubleshooting/roll-changed", "") + defer roll.Body.Close() + if roll.StatusCode != http.StatusOK { + t.Fatalf("roll-changed = %d : %s", roll.StatusCode, readBody(t, roll)) + } + + fallback := bench.post(t, "/admin/api/troubleshooting/fallback-printer", `{"on":true}`) + defer fallback.Body.Close() + if fallback.StatusCode == http.StatusOK { + t.Fatal("la bascule vers une imprimante de secours a réussi sur un poste qui n'en " + + "déclare aucune") + } + if body := readBody(t, fallback); !strings.Contains(body, "secours") { + t.Fatalf("le refus ne nomme pas ce qui manque : %s", body) + } +} diff --git a/cmd/openscale/capture.go b/cmd/openscale/capture.go index fe6166a..7991101 100644 --- a/cmd/openscale/capture.go +++ b/cmd/openscale/capture.go @@ -6,16 +6,20 @@ import ( "fmt" "io" "os" - "strconv" "strings" "time" "openscale/internal/domain" "openscale/internal/platform" - "openscale/internal/scale" "openscale/internal/scale/serial" ) +// This file is the `openscale capture` subcommand: it listens to a serial port for a +// bounded duration and produces the two things the measurement campaign of §21 n° 3 +// went to the shop for — a summary that leads with the OBSERVED cadence, and a file +// `openscale replay` reads back. The file format is in corpus.go, the summary in +// report.go, and the choice of grammar in protocol.go. + const ( // nominalRate is the cadence a GRAM DECLARES, and the single figure this pair of // commands exists to replace. The 400 ms that has circulated for years is the @@ -129,63 +133,6 @@ Options : }, out) } -// protocolList is the French tail of every sentence that offers a choice of grammar. -func protocolList(r *scale.Registry) string { - descriptors := r.Descriptors() - if len(descriptors) == 0 { - return "aucun protocole n'est embarqué dans ce binaire" - } - ids := make([]string, 0, len(descriptors)) - for _, descriptor := range descriptors { - ids = append(ids, descriptor.ID) - } - return strings.Join(ids, ", ") -} - -// defaultProtocol is the protocol these two diagnostic commands decode with when nobody -// says otherwise: the first the composition root registered. -// -// # Why a default is legitimate HERE and not in the detection -// -// The detection answers « y a-t-il une balance ? » and its answer goes into a -// configuration file: naming the first entry of a registry there is a GUESS presented as -// a finding, and it stops being true the day a second grammar is registered. These two -// commands answer a different question. Somebody is standing in front of the hardware, -// they know which scale it is, and both commands PRINT the protocol they used — in the -// summary and, for a capture, in the header of the file it writes. A default that is -// announced is a convenience; a default that is silent is the defect of 29/07. -// -// An empty registry yields an empty string, and decoderOf turns that into a refusal that -// names the situation rather than a nil decoder three frames deeper. -func defaultProtocol(r *scale.Registry) string { - descriptors := r.Descriptors() - if len(descriptors) == 0 { - return "" - } - return descriptors[0].ID -} - -// decoderOf resolves the --type flag into a protocol and a decoder of its own. -// -// It returns the ID as well as the decoder because both commands SAY which grammar they -// used: a capture writes it into the file it produces, so that `openscale replay` never -// has to guess, and a replay prints it above the frames it re-displays. -func decoderOf(r *scale.Registry, requested string) (string, domain.Decoder, error) { - chosen := strings.TrimSpace(requested) - if chosen == "" { - chosen = defaultProtocol(r) - } - if chosen == "" { - return "", nil, errors.New("aucun protocole de balance n'est embarqué dans ce binaire : " + - "il n'y a aucune grammaire pour décoder ces octets") - } - decoder, err := r.NewDecoder(chosen) - if err != nil { - return "", nil, err - } - return chosen, decoder, nil -} - // captureRequest is one run of the capture, with the two seams that make it testable // without a scale on the bench: link.Open hands back a byte stream, and link.Clock is // the injected clock every instant comes from. @@ -342,160 +289,6 @@ func writeCaptureOutcome(out io.Writer, req captureRequest, report frameReport, req.protocol) } -// corpusWriter writes the LIVING CORPUS format of §15.4: -// -// # openscale capture — COM8, 2026-07-25 -// @0 ST,GS,+ 1.236KG -// @412 ST,GS,+ 0.850KG -// -// One frame per line, exactly the bytes the scale sent, terminator included, optionally -// preceded by "@ " -- the delay since the FIRST frame, separated by ONE space. -// -// THE FORMAT IS DEFINED BY internal/scale/replay, NOT HERE. That package parses it for -// `openscale replay`, for the « Rejouer cette trame » button of the journal and for the -// tests; this writer is the other half of that one contract, and the round trip is -// frozen by a test. Two readers of the living corpus is the failure worth avoiding: -// the corpus is only permanent evidence if everything reads it the same way. -// -// WHY '@' rather than a bare number: the grammar of §9.2 lets a frame begin with a -// status letter, a sign, a blank OR A DIGIT, so a leading number could be a timestamp -// or the start of a frame. '@' can be neither, and needs no rule to tell them apart. -// -// WHY the offset is worth its bytes: without it a replay can only space the frames at -// the NOMINAL cadence, and the "median cadence measured" of the L3 criterion would be -// the nominal rate handed straight back -- the very figure §21 n° 3 exists to replace. -// A file with no timestamp cannot measure a cadence, and replay says so instead of -// printing a plausible number. -// -// The only byte the writer does not reproduce verbatim is a lone CR terminator, which -// becomes CR LF so the file stays line-oriented. No scale of this parc sends one. -type corpusWriter struct { - to io.Writer - // cut is the decoder of the captured protocol, asked ONE question: where does the - // frame at the head of these bytes end. It is the same decoder the summary decodes - // with, which is the point — a file cut by one grammar and counted by another is how - // a capture came back announcing 194 frames over a file holding none. - cut domain.Decoder - // pending holds the bytes of a frame whose end has not arrived yet. - pending []byte - // origin is the instant of the FIRST line, which is t = 0 of the file. Offsets are - // relative so that a capture is self-contained and two captures are comparable. - origin time.Time - // lines counts the frames written. - lines int -} - -// header writes the comment lines that make a capture file self-describing. -// -// Self-describing because it outlives the session that produced it: a file that lands -// in the living corpus in six months has to say which port, which link settings and -// which day it came from, and a support archive (diagnostic.zip) carries it with no -// context at all. -func (w *corpusWriter) header(req captureRequest, start time.Time) error { - _, err := fmt.Fprintf(w.to, - "# openscale capture — %s · %d bauds %d%s%d · %s · durée demandée %s\n"+ - "# "+protocolMarker+"%s\n"+ - "# Corpus vivant (§15.4) : une trame par ligne, telle que la balance l'a émise.\n"+ - "# « @ » en tête de ligne porte l'écart en millisecondes depuis la PREMIÈRE\n"+ - "# trame. Sans ce marqueur la ligne est la trame entière — c'est le format des\n"+ - "# fichiers déjà présents dans internal/scale/testdata/frames/.\n"+ - "# Toute ligne commençant par # est un commentaire.\n", - req.link.Port, req.link.Baud, captureBits, captureParity, captureStop, - start.UTC().Format(time.RFC3339), req.duration, req.protocol) - return err -} - -// protocolMarker is the header line that names the grammar a capture was cut with. -// -// It exists so that `openscale replay` reads the protocol off the FILE instead of -// guessing: the two commands are one round trip, and a capture that did not say which -// grammar produced it would have to be replayed on the memory of whoever ran it. It is -// written as a comment, so every reader of the format already skips it. -const protocolMarker = "protocole : " - -// feed appends the bytes of one read and writes every line the stream now completes. -// -// now is the instant of that read, and it becomes the offset of every frame it -// completes: the resolution of the file is one read, which is exactly the resolution -// the driver itself has. -func (w *corpusWriter) feed(p []byte, now time.Time) error { - w.pending = append(w.pending, p...) - for { - // The DECODER OF THE CAPTURED PROTOCOL and NOT a terminator search of our own: a - // GRAM XFOC PLUS delimits with control codes and sends no CR or LF at all, so a - // writer that looked for line endings wrote a file with no frames in it while the - // summary above it counted 194. Asking the decoder rather than one package's - // function is what lets this command write the corpus of a protocol whose frames - // carry no delimiter at all. - consumed := w.cut.FrameEnd(w.pending) - if consumed < 0 { - return nil - } - line := w.pending[:consumed] - w.pending = w.pending[consumed:] - if err := w.writeLine(line, now); err != nil { - return err - } - } -} - -// finish writes whatever the last read left unterminated, as a COMMENT. -// -// As a comment because a fragment is not a frame. Writing "ST,GS,+ 1.2" as a line of -// the corpus would add to the permanent tests a frame no scale ever sent, and turning -// a truncated frame into a mass is the one thing frame.Parse exists to refuse. It is -// kept, quoted, because it is evidence: it is precisely the artefact of the 18-byte -// read that fills degraded-18-byte-read.txt. -func (w *corpusWriter) finish() error { - if len(w.pending) == 0 { - return nil - } - _, err := fmt.Fprintf(w.to, "# fin de capture, trame incomplète et donc NON rejouée : %q\n", w.pending) - w.pending = nil - return err -} - -// writeLine writes one frame with its offset, adding the single byte that keeps the -// file line-oriented and nothing else. -func (w *corpusWriter) writeLine(line []byte, now time.Time) error { - if len(trimTerminator(line)) == 0 { - return nil // a bare terminator carries no frame - } - if w.lines == 0 { - w.origin = now - } - w.lines++ - - // "@ " then the frame VERBATIM. Exactly one separator, because a frame of this - // grammar may legitimately begin with a blank -- " 0.996kg" is one of the corpus -- - // and eating a second space would change the mass. - out := make([]byte, 0, len(line)+16) - out = append(out, '@') - out = strconv.AppendInt(out, now.Sub(w.origin).Milliseconds(), 10) - out = append(out, ' ') - out = append(out, line...) - if out[len(out)-1] != '\n' { - out = append(out, '\n') - } - _, err := w.to.Write(out) - return err -} - -// trimTerminator returns the line without its trailing CR, LF or CRLF. -// -// It is the last piece of line-ending knowledge left in this command, and it is only ever -// applied to a frame the DECODER has already cut: it strips the bytes a text protocol -// ends on so that a corpus line stays line-oriented, and does nothing at all to a -// transmission delimited by control codes. Its companion — a search for the first CR or -// LF, which is how this command used to decide where a frame ENDED — is gone, because -// that decision belongs to the grammar and to nothing else. -func trimTerminator(line []byte) []byte { - for len(line) > 0 && (line[len(line)-1] == '\n' || line[len(line)-1] == '\r') { - line = line[:len(line)-1] - } - return line -} - // writeHexDump writes one read the way a diagnostic has to see it: the bytes in // hexadecimal, then the same bytes as text. // @@ -519,177 +312,3 @@ func writeHexDump(out io.Writer, since time.Duration, p []byte) { fmt.Fprintf(out, " %-*s|%s|\n", perRow*3, hex.String(), text.String()) } } - -// frameReport accumulates everything `capture` and `replay` say about a stream of -// frames: how many were decoded, how stable they were, and at what cadence. -type frameReport struct { - // lines is how many frame lines the stream offered -- read from a file, or written - // by a capture. It is the denominator of the §18 demonstration: "100 frames out of - // 100, where the legacy application lost one in two". - lines int - // frames is how many measurements came out of the decoder. - frames int - stable, unstable, unspecified, overload int - // resyncs is what the decoder of the protocol reports. A line that resynchronises - // constantly is a cabling problem, not a parser problem. - resyncs int - rate domain.RateMeter - // measured says whether the instants the cadence was computed from are REAL ones. A - // file with no timestamp gets reconstituted instants, and a median computed from - // those would hand the nominal rate back as though it had been observed -- which is - // exactly the confusion §21 n° 3 exists to end. - measured bool -} - -// observe folds one decoded measurement into the report. -func (r *frameReport) observe(m domain.Measurement) { - r.frames++ - switch { - case m.Overload: - r.overload++ - case m.Stability == domain.Stable: - r.stable++ - case m.Stability == domain.Unstable: - r.unstable++ - default: - r.unspecified++ - } - r.rate.Observe(m) -} - -// write ends both commands with the two figures §21 n° 3 sends somebody to the shop -// for: the observed cadence and the proportion of stable frames. -func (r *frameReport) write(out io.Writer, policy domain.StabilityPolicy) { - fmt.Fprintln(out, "Résumé") - fmt.Fprintf(out, " %d trame%s décodée%s sur %d ligne%s, %d resynchronisation%s\n", - r.frames, plural(r.frames), plural(r.frames), - r.lines, plural(r.lines), r.resyncs, plural(r.resyncs)) - r.writeCadence(out, policy) - fmt.Fprintf(out, " trames stables : %d sur %d (%s) · instables : %d · sans indication : %d\n", - r.stable, r.frames, percent(r.stable, r.frames), r.unstable, r.unspecified) - if r.overload > 0 { - fmt.Fprintf(out, " trames en surcharge (OL) : %d — la balance se déclare hors capacité\n", r.overload) - } -} - -// writeCadence writes the median, the expiry it derives and, when it applies, the -// amber-light sentence of §15.4. -// -// It NEVER prints a median it cannot stand behind. Three answers, and they are not -// interchangeable: no timestamps at all, not enough intervals yet (RateMeter needs -// eight), or a real measurement. -func (r *frameReport) writeCadence(out io.Writer, policy domain.StabilityPolicy) { - if !r.measured { - fmt.Fprintf(out, " cadence : NON MESURABLE — le fichier ne porte pas d'horodates. Les instants\n"+ - " sont reconstitués à %s, la cadence NOMINALE déclarée, qui est justement le\n"+ - " chiffre qu'une mesure doit remplacer (§21 n° 3).\n", millis(nominalRate)) - return - } - median, ok := r.rate.Median() - if !ok { - observed := r.rate.Observations() - fmt.Fprintf(out, " cadence : pas encore mesurable — %d intervalle%s observé%s, il en faut 8\n", - observed, plural(observed), plural(observed)) - return - } - fmt.Fprintf(out, " cadence observée : médiane %s %s\n", - millis(median), observationsLabel(r.rate.Observations())) - fmt.Fprintf(out, " péremption dérivée : %s (facteur %d, plancher %s, plafond %s)\n", - millis(r.rate.Expiry(policy, nominalRate)), policy.ExpiryFactor, - millis(time.Duration(policy.ExpiryFloor)), millis(time.Duration(policy.ExpiryCeiling))) - if tooSlow, slow := r.rate.RateIsTooSlow(policy); tooSlow { - fmt.Fprintf(out, " ATTENTION : la balance émet toutes les %s ; le poids est considéré périmé au\n"+ - " bout de %s. Le poste se taira entre deux trames (§15.4) — vérifier le câble\n"+ - " et le réglage de la balance.\n", - secondsLabel(slow), secondsLabel(time.Duration(policy.ExpiryCeiling))) - } -} - -// observationsLabel says how many intervals the median rests on, and says it honestly -// when the ring is full: RateMeter remembers the LAST 64, so on a thirty-minute -// capture the figure is a recent median and not the median of the whole session. It -// is the same number the dashboard and `openscale doctor` act on, which is the point -// -- the capture must report what production will decide with. -func observationsLabel(n int) string { - if n >= 64 { - return "sur les 64 derniers intervalles" - } - return fmt.Sprintf("sur %d intervalle%s", n, plural(n)) -} - -// plural is the French plural mark: nothing up to one, "s" beyond. French writes -// « 0 trame » and « 1 trame », and these sentences are read by volunteers. -func plural(n int) string { - if n > 1 { - return "s" - } - return "" -} - -// frameLine renders one decoded measurement -- its rank, when it arrived, what it -// weighed and what the scale said about its own reading -- followed by whatever the -// caller has to add: the latch state for replay, nothing for capture. -func frameLine(rank int, since time.Duration, m domain.Measurement, tail string) string { - line := fmt.Sprintf("%4d %10s %9s kg %s", - rank, offsetLabel(since), m.Gross.Kilos(), stabilityLabel(m)) - if tail == "" { - return line - } - return fmt.Sprintf("%-50s%s", line, tail) -} - -// stabilityLabel is what the frame said about itself, in French. -// -// Overload comes FIRST because it dominates: a scale over capacity may report any -// mass at all, including a plausible one, and safeguard rule 1 fires on the flag -// rather than on the value. -func stabilityLabel(m domain.Measurement) string { - if m.Overload { - return "surcharge (OL)" - } - switch m.Stability { - case domain.Stable: - return "stable" - case domain.Unstable: - return "instable" - default: - return "sans indication" - } -} - -// offsetLabel renders a delay since the start, French comma, milliseconds. -func offsetLabel(d time.Duration) string { - ms := d.Milliseconds() - sign := "+" - if ms < 0 { - sign, ms = "-", -ms - } - return fmt.Sprintf("%s%d,%03d s", sign, ms/1000, ms%1000) -} - -// millis renders a duration in whole milliseconds, the unit the configuration keys -// carry in their own names (expiry_floor_ms, min_duration_ms). -func millis(d time.Duration) string { return fmt.Sprintf("%d ms", d.Milliseconds()) } - -// secondsLabel renders a duration in seconds with at most one decimal, so that the -// amber-light sentence reads exactly as §15.4 writes it: « la balance émet toutes les -// 2,4 s ; le poids est considéré périmé au bout de 5 s ». -func secondsLabel(d time.Duration) string { - tenths := (d.Milliseconds() + 50) / 100 - if tenths%10 == 0 { - return fmt.Sprintf("%d s", tenths/10) - } - return fmt.Sprintf("%d,%d s", tenths/10, tenths%10) -} - -// percent renders part/whole with one decimal, French comma. -// -// Integer arithmetic: there is no float anywhere in this application, and a -// percentage on a diagnostic screen is no reason to introduce the first one. -func percent(part, whole int) string { - if whole <= 0 { - return "—" - } - tenths := 1000 * part / whole - return fmt.Sprintf("%d,%d %%", tenths/10, tenths%10) -} diff --git a/cmd/openscale/capture_test.go b/cmd/openscale/capture_test.go index 8f395cd..75a8549 100644 --- a/cmd/openscale/capture_test.go +++ b/cmd/openscale/capture_test.go @@ -15,6 +15,14 @@ import ( "openscale/internal/scale/serial" ) +// The `openscale capture` subcommand: what it measures, what it prints, and what it +// refuses. It is the instrument of unknown n° 3 of §21 — the real emission cadence of +// the scale — so the assertions are about the OBSERVED median and the stable ratio. +// +// The serial port it listens to is a double, in scriptedstream_test.go; the file it +// writes is asserted in corpuswriter_test.go, and the French units of its summary in +// report_test.go. + // captureStart is the instant every capture test begins at. A fixed one, because the // clock is INJECTED and nothing here has any business reading the real one. var captureStart = time.Date(2026, 7, 25, 9, 30, 0, 0, time.UTC) @@ -31,105 +39,6 @@ const ( cadence = 412 * time.Millisecond ) -// --- the port --------------------------------------------------------------------- - -// scriptedRead is one answer of a scripted port: how long the read took, what it -// hands back, and whether it fails. -type scriptedRead struct { - after time.Duration - data string - err error -} - -// scriptedStream is the io.ReadCloser a test hands back instead of a serial port. -// -// Every read ADVANCES THE INJECTED CLOCK by the delay the script gives it, which is -// what lets a thirty-minute capture be exercised in microseconds and without a single -// time.Sleep: the instants the capture records are the ones the script decided, and -// the cadence it measures is the one the script emitted at. -type scriptedStream struct { - clock *fake.Clock - script []scriptedRead - at int - closes int - // silence is what the stream does once the script runs dry: it comes back with no - // byte and no error, which is what a real port does between two frames, and it is - // what lets the capture reach its deadline. - silence time.Duration - // endErr, when set, is what the port answers instead of staying silent -- a cable - // pulled in the middle of a measurement campaign. - endErr error - // link is the last set of options the opener was handed, and opens counts how many - // times it was called at all. - // - // A double that IGNORED its options cannot tell a caller which built a usable link - // from one which handed over a struct with no bitrate, no parity and no stop bits -- - // and a real port refuses the second before it touches the device. Recording them is - // what makes that assertion possible; internal/scale/gramxfoc does the same with the - // port name. - link serial.Options - opens int -} - -// newScriptedStream returns a port that answers these reads, in order, and then goes -// quiet one read timeout at a time. -func newScriptedStream(clock *fake.Clock, script ...scriptedRead) *scriptedStream { - return &scriptedStream{clock: clock, script: script, silence: time.Second} -} - -// emitting returns a port that sends count frames at the nominal cadence of the -// script, marking the frames at the given ranks unstable. -func emitting(clock *fake.Clock, count int, unstableRanks ...int) *scriptedStream { - unstable := make(map[int]bool, len(unstableRanks)) - for _, rank := range unstableRanks { - unstable[rank] = true - } - script := make([]scriptedRead, 0, count) - for rank := 1; rank <= count; rank++ { - frame := nominalFrame - if unstable[rank] { - frame = unstableFrame - } - script = append(script, scriptedRead{after: cadence, data: frame}) - } - return newScriptedStream(clock, script...) -} - -func (s *scriptedStream) Read(buffer []byte) (int, error) { - if s.at >= len(s.script) { - s.clock.Advance(s.silence) - return 0, s.endErr - } - read := s.script[s.at] - s.at++ - s.clock.Advance(read.after) - return copy(buffer, read.data), read.err -} - -func (s *scriptedStream) Close() error { - s.closes++ - return nil -} - -// opener is the seam capture is injected through: a serial port cannot be opened by -// `go test`, so the whole command is exercised through this. -// -// It KEEPS what it was handed, so that a test can assert on the link a caller built and -// not only on the bytes it read back. -func (s *scriptedStream) opener() serial.Opener { - return func(o serial.Options) (io.ReadCloser, error) { - s.link = o - s.opens++ - return s, nil - } -} - -// refusingOpener is a port that is not there: the commonest failure of the bench, and -// the one a volunteer meets when the adapter is on another COM number. -func refusingOpener(err error) serial.Opener { - return func(serial.Options) (io.ReadCloser, error) { return nil, err } -} - // --- the bench -------------------------------------------------------------------- // benchProtocol is the grammar these tests capture with, resolved THROUGH THE REGISTRY @@ -321,54 +230,6 @@ func TestCaptureDumpsHexadecimalAndText(t *testing.T) { requireLine(t, screen, "1 +0,412 s 1,236 kg stable") } -// TestCaptureFileIsSelfDescribing: a capture outlives the session that produced it. -// It lands in the living corpus months later, or inside a diagnostic.zip with no -// context at all, and it has to say which port, which link settings and which day it -// came from. -func TestCaptureFileIsSelfDescribing(t *testing.T) { - clock := fake.NewClock(captureStart) - _, file, _ := runCaptureOnScript(t, emitting(clock, 3), clock, 5*time.Second, true) - - const wantHeader = "# openscale capture — COM8 · 9600 bauds 8N1 · 2026-07-25T09:30:00Z · durée demandée 5s" - if !strings.HasPrefix(file, wantHeader) { - t.Errorf("en-tête inattendu :\n%s", file) - } - for _, want := range []string{ - "# Corpus vivant (§15.4)", - "# Toute ligne commençant par # est un commentaire.", - } { - if !strings.Contains(file, want) { - t.Errorf("le fichier ne se décrit pas : %q absent de\n%s", want, file) - } - } -} - -// TestCaptureKeepsAnUnterminatedFrameAsAComment: a fragment is not a frame. Writing -// "ST,GS,+ 1.2" as a line of the corpus would add a frame no scale ever sent to the -// permanent tests, and turning a truncated frame into a mass is the one thing -// frame.Parse exists to refuse. It is kept, quoted, because it is evidence. -func TestCaptureKeepsAnUnterminatedFrameAsAComment(t *testing.T) { - clock := fake.NewClock(captureStart) - stream := newScriptedStream(clock, - scriptedRead{after: cadence, data: nominalFrame}, - scriptedRead{after: cadence, data: "ST,GS,+ 1.2"}, - ) - _, file, path := runCaptureOnScript(t, stream, clock, 5*time.Second, true) - - if !strings.Contains(file, `# fin de capture, trame incomplète et donc NON rejouée : "ST,GS,+ 1.2"`) { - t.Errorf("le reliquat n'a pas été conservé en commentaire :\n%s", file) - } - if strings.Contains(file, "@412 ST,GS,+ 1.2\n") { - t.Errorf("le reliquat a été écrit comme une trame :\n%s", file) - } - // And replaying it back decodes the one frame that was whole, and only that one. - var out bytes.Buffer - if err := runReplay([]string{path, "--quiet"}, &out); err != nil { - t.Fatalf("runReplay : %v", err) - } - requireLine(t, out.String(), "1 trame décodée sur 1 ligne, 0 resynchronisation") -} - // TestCaptureNeverReconnects is the one place capture departs from the production // loop of internal/scale/serial, and it is deliberate: a cadence measured across an // outage describes the outage. The link that drops ends the capture, is NAMED, and @@ -569,71 +430,6 @@ func TestRunCaptureRefusesBeforeTouchingTheHardware(t *testing.T) { } } -// TestFrenchNumbersUseIntegerArithmetic: no float ever reaches this application, and a -// percentage on a diagnostic screen is no reason to introduce the first one. -func TestFrenchNumbersUseIntegerArithmetic(t *testing.T) { - percents := []struct { - part, whole int - want string - }{ - {10, 12, "83,3 %"}, - {100, 100, "100,0 %"}, - {0, 7, "0,0 %"}, - {1, 3, "33,3 %"}, - {1, 0, "—"}, - } - for _, c := range percents { - if got := percent(c.part, c.whole); got != c.want { - t.Errorf("percent(%d, %d) = %q, want %q", c.part, c.whole, got, c.want) - } - } - - durations := []struct { - d time.Duration - milli, seconds string - }{ - {412 * time.Millisecond, "412 ms", "0,4 s"}, - {2400 * time.Millisecond, "2400 ms", "2,4 s"}, - {5 * time.Second, "5000 ms", "5 s"}, - {0, "0 ms", "0 s"}, - } - for _, c := range durations { - if got := millis(c.d); got != c.milli { - t.Errorf("millis(%s) = %q, want %q", c.d, got, c.milli) - } - if got := secondsLabel(c.d); got != c.seconds { - t.Errorf("secondsLabel(%s) = %q, want %q", c.d, got, c.seconds) - } - } - - offsets := []struct { - d time.Duration - want string - }{ - {0, "+0,000 s"}, - {412 * time.Millisecond, "+0,412 s"}, - {75 * time.Second, "+75,000 s"}, - {-2 * time.Millisecond, "-0,002 s"}, - } - for _, c := range offsets { - if got := offsetLabel(c.d); got != c.want { - t.Errorf("offsetLabel(%s) = %q, want %q", c.d, got, c.want) - } - } -} - -// TestObservationsLabelSaysWhichIntervalsTheMedianRestsOn. RateMeter remembers the -// LAST 64 intervals, so on a thirty-minute capture the median is a recent one and not -// the median of the session. Saying « sur 64 intervalles » flat would be a quiet lie. -func TestObservationsLabelSaysWhichIntervalsTheMedianRestsOn(t *testing.T) { - if got := observationsLabel(11); got != "sur 11 intervalles" { - t.Errorf("observationsLabel(11) = %q", got) - } - if got := observationsLabel(64); got != "sur les 64 derniers intervalles" { - t.Errorf("observationsLabel(64) = %q", got) - } -} - // TestCaptureUsageIsFrenchAndNamesThePeakHour. The usage is read by whoever runs the // binary, and the audience of this project is a cooperative, not a Go developer -- // and the one instruction that decides whether the measurement is worth anything is @@ -650,72 +446,3 @@ func TestCaptureUsageIsFrenchAndNamesThePeakHour(t *testing.T) { } } } - -// TestCaptureDoesNotSplitACRLFAcrossTwoLines: a terminator delivered in two reads is -// still ONE terminator, exactly as frame.Accumulator treats it. The opposite would -// double the line count of every capture taken on a busy machine. -func TestCaptureDoesNotSplitACRLFAcrossTwoLines(t *testing.T) { - clock := fake.NewClock(captureStart) - stream := newScriptedStream(clock, - scriptedRead{after: cadence, data: "ST,GS,+ 1.236KG\r"}, - scriptedRead{after: 2 * time.Millisecond, data: "\nST,GS,+ 0.850KG\r\n"}, - ) - screen, file, _ := runCaptureOnScript(t, stream, clock, 5*time.Second, true) - - requireLine(t, screen, "2 trames décodées sur 2 lignes, 0 resynchronisation") - if got := strings.Count(file, "\n"); got != 9 { - t.Errorf("%d lignes dans le fichier, 7 de commentaire + 2 de trame attendues :\n%s", got, file) - } - if !strings.Contains(file, "@0 ST,GS,+ 1.236KG\r\n") { - t.Errorf("la trame coupée n'a pas été recollée :\n%q", file) - } -} - -// failingWriter is a disk that fills up in the middle of a capture. -type failingWriter struct{ err error } - -func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } - -// TestCorpusWriterGivesUpLoudlyWhenItCannotWrite. A capture that lost frames to a -// full disk and said nothing would produce a corpus file that LOOKS complete, and the -// cadence measured from it would be a fiction -- the exact failure the living corpus -// exists to make impossible. -func TestCorpusWriterGivesUpLoudlyWhenItCannotWrite(t *testing.T) { - _, decoder := benchProtocol(t) - writer := &corpusWriter{to: failingWriter{err: errors.New("disque plein")}, cut: decoder} - if err := writer.feed([]byte(nominalFrame), captureStart); err == nil { - t.Error("une trame perdue n'a pas été signalée") - } - // A fragment waits for its terminator, so feeding it writes nothing; it is finish - // that has to fail on it. - if err := writer.feed([]byte("ST,GS,+ 1.2"), captureStart); err != nil { - t.Errorf("une trame incomplète a été écrite avant son terminateur : %v", err) - } - if err := writer.finish(); err == nil { - t.Error("un reliquat perdu n'a pas été signalé") - } - if err := writer.header(captureRequest{link: serial.Options{Port: "COM8"}}, captureStart); err == nil { - t.Error("un en-tête perdu n'a pas été signalé") - } -} - -// Compile-time proof that the scripted port satisfies what an Opener has to return. -var _ io.ReadCloser = (*scriptedStream)(nil) - -// TestScriptedStreamNeverNeedsTheRealClock guards the guard: if this double ever -// stopped driving the injected clock, every temporal assertion above would silently -// become a test of nothing. -func TestScriptedStreamNeverNeedsTheRealClock(t *testing.T) { - clock := fake.NewClock(captureStart) - stream := emitting(clock, 3) - buffer := make([]byte, 64) - for i := 1; i <= 3; i++ { - n, err := stream.Read(buffer) - if err != nil || n == 0 { - t.Fatalf("lecture %d : %d octets, %v", i, n, err) - } - if want := captureStart.Add(time.Duration(i) * cadence); !clock.Now().Equal(want) { - t.Fatalf("lecture %d : horloge à %s, %s attendu", i, clock.Now(), want) - } - } -} diff --git a/cmd/openscale/config.go b/cmd/openscale/config.go index 435d8d5..e463d4b 100644 --- a/cmd/openscale/config.go +++ b/cmd/openscale/config.go @@ -1,23 +1,20 @@ package main import ( - "bufio" - "context" - "encoding/json" "errors" "flag" "fmt" "io" - "os" "path/filepath" - "strings" "openscale/internal/domain" "openscale/internal/platform" - "openscale/internal/printing/transport" - "openscale/internal/web" ) +// This file is the `config` subcommand of §15.1: which action runs, and the French +// sentences their refusals share. The two actions that only READ the file are in +// configread.go, the three that REWRITE it in configwrite.go. + // runConfig is `openscale config validate|export|fingerprint|password|recovery-code` // (§15.1, §14.4). // @@ -130,70 +127,6 @@ démarrage : arrêtez le service, lancez la commande, redémarrez-le. Le termina qui est tapé — c'est une console de poste, pas un poste de travail partagé. ` -// validateConfig runs the controls of §11.3 with the REAL registries of this binary, -// and prints every fault at once. -// -// Every fault and not the first: a volunteer who came to fix one file should leave -// having fixed it, and not discover the second fault after a restart. The exit code is -// what makes it usable from install.ps1 — a non-zero status means « this station will -// start in factory configuration ». -// -// That promise is only true because the DECODING faults are counted here too. A block -// that will not decode falls back on the neutral profile, and the substitute passes -// Validate without a word: judging on Validate alone answered « aucune faute » about a -// station that comes up in ERR-CFG-01, while serve — reading the very same file through -// the very same door — reported it. -func validateConfig(out io.Writer, path string, cfg domain.Config, - notes []domain.MigrationNote, decodeFaults []domain.Fault) error { - - reportPendingMigrations(out, path, notes) - - scales, printers := scaleRegistry(), printerRegistry() - // The decoding faults FIRST, and the concatenation is the one serve.go already makes: - // a block that was replaced is what makes every value below it suspect, so it is read - // before the judgements passed on those values. - faults := append(decodeFaults, cfg.Validate(domain.Registries{ - Scales: scales.Descriptors(), - Printers: printers.Descriptors(), - Transports: transport.Descriptors(), - CatalogSources: catalogSourceDescriptors(), - })...) - if len(faults) == 0 { - fmt.Fprintf(out, "%s : aucune faute. Empreinte des réglages partagés : %s\n", - path, cfg.Fingerprint()) - return nil - } - fmt.Fprintf(out, "%s : %d faute(s).\n", path, len(faults)) - for _, fault := range faults { - fmt.Fprintf(out, " %s\n", fault.String()) - } - return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( - "%s comporte %d faute(s) : le poste démarrerait en configuration d'usine (ERR-CFG-01)", - path, len(faults))} -} - -// reportPendingMigrations names what LoadConfig had to change to bring the file up to the -// schema this binary speaks, BEFORE the fault list: a volunteer reading a fault about a -// field they never touched should learn first that the field came from an old file, not -// discover it after wondering why the value looks wrong. -// -// It says NOTHING when there is nothing pending, on purpose: a station already at this -// schema must not see a paragraph on every `config validate`, only the ones that changed -// something. A retired key Migrate has no translation for (the six of the numbering plan) -// earns no note here either — control 20 names it in the fault list right below, which is -// where a pure refusal has always been reported. -func reportPendingMigrations(out io.Writer, path string, notes []domain.MigrationNote) { - if len(notes) == 0 { - return - } - fmt.Fprintf(out, "%s : ce fichier n'est pas encore au schéma %d que ce binaire écrit — "+ - "%d migration(s) en attente, qu'« openscale config migrate » appliquerait :\n", - path, domain.CurrentSchemaVersion, len(notes)) - for _, note := range notes { - fmt.Fprintf(out, " %s\n", note) - } -} - // refuseWhatWasNotRead stops an action that would ANSWER ABOUT a file this binary did not // read whole. // @@ -266,257 +199,3 @@ func readFailure(path string, err error) string { } return fmt.Sprintf("le fichier de configuration %s ne peut pas être lu : %v", path, err) } - -// minPasswordLength is the floor POST /admin/api/session/recovery already holds (§14.4). -// -// The same figure in the two places that set a password, because a station where the -// terminal accepted four characters and the screen refused them would be a station whose -// rule depends on which door somebody came through. -const minPasswordLength = 8 - -// setAdminPassword is `openscale config password` (§14.4). -// -// It writes the FILE, through the same store the administration screen saves with, so a -// password set from a terminal rotates the versions and lands atomically like any other -// change. The station does not see it before it restarts: nothing re-reads config.json -// while the service runs, and pretending otherwise would have somebody typing a password -// that works only after the next power cut. -func setAdminPassword(in io.Reader, out io.Writer, path string) error { - store, err := platform.NewConfigStore(path) - if err != nil { - return err - } - ctx := context.Background() - cfg, err := store.Read(ctx) - if err != nil { - return errors.New(readFailure(path, err)) - } - - fmt.Fprintf(out, "Nouveau mot de passe d'administration pour %s\n"+ - "(au moins %d caractères, il s'affiche à l'écran) : ", path, minPasswordLength) - typed, err := readSecretLine(in) - if err != nil { - return err - } - if len([]rune(typed)) < minPasswordLength { - return fmt.Errorf("le mot de passe doit faire au moins %d caractères", minPasswordLength) - } - - hash, err := web.HashSecret(typed) - if err != nil { - return err - } - cfg.Admin.PasswordHash = hash - cfg.ModifiedAt = platform.NewSystemClock().Now() - if err := store.Save(ctx, cfg); err != nil { - return err - } - - fmt.Fprintf(out, "\nMot de passe d'administration posé dans %s.\n", path) - if cfg.Admin.RecoveryCodeHash == "" { - fmt.Fprintf(out, "Ce poste n'a AUCUN code de secours : sans lui, ce mot de passe "+ - "perdu se rattrape uniquement ici. Tirez-en un avec « openscale config "+ - "recovery-code » et recopiez-le sur la fiche d'installation.\n") - } - fmt.Fprintf(out, "Redémarrez le service pour que le poste le prenne en compte.\n") - return nil -} - -// mintRecoveryCode is `openscale config recovery-code` (§14.4, important-10). -// -// The code is shown ONCE, in clear, and never again: the configuration keeps its argon2id -// hash and nothing else. Whoever runs this command has one job left, and it is written on -// the last line — copy the eight characters onto the installation sheet, which goes into -// the shop's folder and not onto the station. -func mintRecoveryCode(out io.Writer, path string) error { - store, err := platform.NewConfigStore(path) - if err != nil { - return err - } - ctx := context.Background() - cfg, err := store.Read(ctx) - if err != nil { - return errors.New(readFailure(path, err)) - } - replacing := cfg.Admin.RecoveryCodeHash != "" - - code, err := web.NewRecoveryCode() - if err != nil { - return err - } - hash, err := web.HashSecret(code) - if err != nil { - return err - } - cfg.Admin.RecoveryCodeHash = hash - cfg.ModifiedAt = platform.NewSystemClock().Now() - if err := store.Save(ctx, cfg); err != nil { - return err - } - - if replacing { - fmt.Fprintf(out, "L'ancien code de secours de ce poste ne fonctionne plus : "+ - "la fiche déjà classée est à corriger.\n") - } - fmt.Fprintf(out, "Code de secours de ce poste : %s\n", code) - fmt.Fprintf(out, "Recopiez-le sur la fiche d'installation MAINTENANT : il ne sera "+ - "plus jamais affiché.\n") - return nil -} - -// readSecretLine reads ONE line off the standard input. -// -// No echo suppression, and it is a deliberate refusal rather than an oversight: turning -// the terminal echo off means a terminal package, which means a seventh module in a -// perimeter §17.1 closes at six, bought for the one call it would take — ADR-039 weighs -// a dependency on the surface actually called, and this one is a single call. The usage -// text says the line is visible, and the machine it is typed on is the station's own -// console. -func readSecretLine(in io.Reader) (string, error) { - if in == nil { - return "", errors.New("aucune entrée standard : le mot de passe se tape au clavier, " + - "ou s'envoie par un tube") - } - line, err := bufio.NewReader(in).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", fmt.Errorf("lecture du mot de passe : %w", err) - } - // Typed on a Windows console the line ends with \r\n, piped from a file it may end - // with nothing at all. Only those two characters go: a password is allowed to end - // with a space, and trimming it would refuse tomorrow what it accepted today. - return strings.TrimRight(line, "\r\n"), nil -} - -// exportConfig writes what §11.5 clones. -// -// It is the SAME domain.Config.Export the administration route calls, and it has to be: -// two exports that differed by a field would produce two fingerprints, and the eight -// characters four volunteers compare by eye would stop meaning anything. -func exportConfig(out io.Writer, cfg domain.Config, hardware bool, output string) error { - exported := cfg.Export(hardware) - // The recovery code is printed on the installation sheet OF ONE STATION. Carrying it - // into a clone is the « four stations sharing one secret nobody chose » that Export - // already refuses for the password, and the administration route redacts it here too. - exported.Admin.RecoveryCodeHash = "" - - raw, err := json.MarshalIndent(exported, "", " ") - if err != nil { - return fmt.Errorf("l'export n'a pas pu être encodé : %w", err) - } - raw = append(raw, '\n') - - if output == "" { - _, err = out.Write(raw) - return err - } - if err := os.WriteFile(output, raw, 0o644); err != nil { - return fmt.Errorf("l'export n'a pas pu être écrit dans %s : %w", output, err) - } - fmt.Fprintf(out, "export écrit dans %s — empreinte %s\n", output, cfg.Fingerprint()) - return nil -} - -// migrateConfig is `openscale config migrate`. -// -// It writes through the same store the administration screen saves with, so a migration -// rotates config.json.1 … .5 and lands atomically like any other change. Nothing new is -// invented for it, and that is the point: the version of before is one file away. -// -// It is IDEMPOTENT. update.ps1 and update.sh call it at every update, and a station that is -// already at this schema must come out of it with its file untouched -- rotating five -// versions over a no-operation is how the version that mattered falls off the end. -// -// A refused point suspends the WHOLE write, not only its own key: what could be carried -// stays computed, correctly, in the cfg this run holds in memory, but nothing at all -// reaches disk while a single point is still refused -- see the comment on the refusal -// branch below for why that has to hold even for a file that carries one point migrate CAN -// write and one it cannot. -// -// A block that would not DECODE suspends it the same way, and that one is not a migration -// question at all: what this command holds for such a block is the neutral profile, and -// rewriting the file would post the factory value over whatever the shop had declared. -func migrateConfig(out io.Writer, path string) error { - cfg, notes, decodeFaults, err := platform.LoadConfig(path) - if err != nil { - return fmt.Errorf("le fichier de configuration %s ne peut pas être lu : %w", path, err) - } - - // A key control 20 refuses outright earns NO note of its own when Migrate has no - // translation to attempt for it: the six keys of the numbering plan are a pure - // refusal, unchanged since they entered the code already retired (configmigration.go), - // and migrationSteps never touches them. cfg.Retired() is the only place that survives - // for them, and it is also what ConfigStore.Save is about to consult -- so a key still - // there is folded into the very same accounting as the notes, under the same name a - // note would use, before Save is ever called. - retired := cfg.Retired() - named := make(map[string]bool, len(notes)) - for _, note := range notes { - named[note.Key] = true - } - for _, key := range retired { - if named[key] { - continue - } - notes = append(notes, domain.MigrationNote{ - Key: key, Action: domain.MigrationRefused, Message: domain.RetiredKeyReason(key), - }) - } - - if len(notes) == 0 && len(decodeFaults) == 0 { - fmt.Fprintf(out, "%s est déjà à la forme que ce binaire lit : rien à faire.\n", path) - return nil - } - - refused := 0 - if len(notes) > 0 { - fmt.Fprintf(out, "%s : %d changement(s).\n", path, len(notes)) - for _, note := range notes { - fmt.Fprintf(out, " %s\n", note) - if note.Action == domain.MigrationRefused { - refused++ - } - } - } - - // ConfigStore.Save calls cfg.RefuseIfRetired, and a MIXED file -- one point carried, - // one refused -- would reach it and be refused there too, only AFTER this command had - // already said it was writing. Leaving here, before Save is ever called, is what keeps - // « rien n'est écrit » true unconditionally, whether the refusal came with a note (an - // unconvertible discount, a file from a newer binary) or without one (the numbering - // plan, folded in just above). - if refused > 0 { - return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( - "%s comporte %d point(s) que ce binaire ne devine pas : le fichier n'est pas "+ - "modifié, tranchez-le puis relancez la migration", path, refused)} - } - - // A block that would not decode is the SAME suspension, for a worse reason. Block-by- - // block decoding replaces it with the one of the neutral profile so that the station - // still serves its fault list -- but that substitute is a plausible factory value - // NOBODY DECLARED, and writing it back would make it the shop's own. Measured on the - // delivered file with an unreadable `pricing` block: the members' 10 % discount - // disappeared, the command announced one unrelated change and exited 0, and update.ps1 - // runs it on its own after every successful update. - if len(decodeFaults) > 0 { - for _, fault := range decodeFaults { - fmt.Fprintf(out, " %s\n", fault.String()) - } - return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( - "%s : le fichier n'est pas modifié, le réécrire poserait la configuration d'usine "+ - "%s. Corrigez-le, puis relancez la migration", - unreadablePart(path, decodeFaults), - (&domain.UnreadableBlocksError{Faults: decodeFaults}).InTheirPlace())} - } - - store, err := platform.NewConfigStore(path) - if err != nil { - return err - } - cfg.ModifiedAt = platform.NewSystemClock().Now() - if err := store.Save(context.Background(), cfg); err != nil { - return fmt.Errorf("%s n'a pas pu être réécrit : %w", path, err) - } - fmt.Fprintf(out, "%s réécrit ; la version d'avant est dans %s.1.\n", path, path) - fmt.Fprintf(out, "Redémarrez le service pour qu'il lise le fichier réécrit.\n") - return nil -} diff --git a/cmd/openscale/config_test.go b/cmd/openscale/config_test.go index fa07434..df0afc4 100644 --- a/cmd/openscale/config_test.go +++ b/cmd/openscale/config_test.go @@ -4,19 +4,24 @@ import ( "bytes" "context" "encoding/json" - "fmt" "os" "path/filepath" - "regexp" - "sort" "strings" "testing" "openscale/internal/domain" "openscale/internal/platform" - "openscale/internal/web" ) +// The `config` subcommand: the fixtures every one of its tests builds a file out of, +// what the dispatch refuses, and — the family this file is really about — what EVERY +// door does with a file this binary could not read WHOLE. Validate names the fault, +// fingerprint and export refuse to answer about it, and migrate refuses to write the +// factory value over it. +// +// The actions that only READ a healthy file are in configread_test.go, the three that +// REWRITE one in configwrite_test.go. + // deliveredConfig is the configuration file of §17.2 — the one the release archive // carries and the installer copies. func deliveredConfig(t *testing.T) string { @@ -24,187 +29,6 @@ func deliveredConfig(t *testing.T) string { return filepath.Join("..", "..", "testdata", "config-lacagette.json") } -// TestCloningAStationShowsTheSAMEEightCharacters is the criterion of §18 for L8, at the -// gesture a volunteer actually performs: « clone la configuration vers les 3 autres -// postes et vérifie l'empreinte ». -// -// Station 1 exports WITHOUT its hardware block, stations 3 and 4 receive that file and -// then do their own two hardware steps — a different COM port, a different print queue, a -// different number, a different name, a different listen address. The four stations must -// display ONE string of eight characters, or the check is worthless. -// -// # Why each hardware step sets ONE KEY and never replaces the block -// -// Since the export stopped dropping the option maps whole, Fingerprint compares what -// those maps hold -- the label offset, the darkness, the speed, the serial settings. -// Writing `cloned.Scale.Options = DriverOptions{"port": "COM3"}` would therefore not -// merely name a port: it would throw away the baud rate, the parity and the reconnection -// backoff the clone had just delivered. Two stations identical in every respect would -// then show two different strings, and the one check a volunteer can do by eye would be -// reporting a divergence the test itself invented. -// -// It is written this way because it is what the ADMINISTRATION SCREEN does (§15.5): the -// two steps after an import are « choisissez le port » and « choisissez la file », one -// field each, and the file behind them keeps every key nobody touched. A screen that -// rewrote the whole block on one edit would be the bug this test would then be blessing. -func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) { - out := &strings.Builder{} - export := filepath.Join(t.TempDir(), "config-export.json") - if err := runConfig([]string{"export", deliveredConfig(t), "--output", export}, nil, out); err != nil { - t.Fatalf("export : %v", err) - } - - exported := readJSONConfig(t, export) - if exported.Station.Number != 0 || exported.Network.Listen != "" { - t.Fatalf("l'export porte encore le poste n° %d et l'adresse %q : ce sont les deux "+ - "choses qu'un clone ne doit pas hériter", exported.Station.Number, exported.Network.Listen) - } - if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" { - t.Fatal("l'export porte un secret d'administration") - } - - reference := fingerprintOf(t, deliveredConfig(t)) - for _, station := range []struct { - number int - name string - port string - queue string - listen string - }{ - {3, "Poste 3 — légumes", "COM3", "SATO WS408_3", "127.0.0.1:8086"}, - {4, "Poste 4 — vrac", "COM12", "SATO WS408_4", "0.0.0.0:8085"}, - } { - cloned := exported - cloned.Station.Number, cloned.Station.Name = station.number, station.name - cloned.Network.Listen = station.listen - // The two hardware steps of §15.5, done on the screen after the import: ONE - // field each, on top of the options the clone delivered. See the head of this - // test for why replacing the block instead would make two homogeneous stations - // diverge. - cloned.Scale.Options = exported.Scale.Options.WithText("port", station.port) - cloned.Printer.Options = exported.Printer.Options.WithText("queue", station.queue) - - path := filepath.Join(t.TempDir(), "config.json") - writeJSONConfig(t, path, cloned) - if got := fingerprintOf(t, path); got != reference { - t.Fatalf("poste %d affiche %q, le poste de référence %q : deux postes réglés à "+ - "l'identique doivent afficher la même empreinte", station.number, got, reference) - } - } -} - -// TestOneBusinessSettingApartAndTheFingerprintDiverges is the other half of the check: -// an eight-character digest that never moved would be a green light nobody can trust. -// -// The five values below are the ones §11.5 says MUST be identical across the fleet — the -// price grid, a safeguard, the label template, a category, the retention — and each is a -// value that, if it silently differed on one station, would produce wrong prices or wrong -// labels on that station alone. -func TestOneBusinessSettingApartAndTheFingerprintDiverges(t *testing.T) { - reference := readJSONConfig(t, deliveredConfig(t)) - referenceFingerprint := reference.Fingerprint() - - for name, diverge := range map[string]func(*domain.Config){ - "une remise de tarif": func(c *domain.Config) { c.Pricing.Tiers[0].Discount = 200 }, - "le seuil de panier vide": func(c *domain.Config) { c.Limits.EmptyMax = 12 }, - "le gabarit d'étiquette": func(c *domain.Config) { c.Printer.Template = "weighing_neutral_single" }, - "une catégorie masquée": func(c *domain.Config) { c.Catalog.Categories[0].Visible = false }, - "la rétention du journal": func(c *domain.Config) { c.Journal.MaxDays = 30 }, - } { - t.Run(name, func(t *testing.T) { - diverging := readJSONConfig(t, deliveredConfig(t)) - diverge(&diverging) - path := filepath.Join(t.TempDir(), "config.json") - writeJSONConfig(t, path, diverging) - if got := fingerprintOf(t, path); got == referenceFingerprint { - t.Fatalf("empreinte inchangée (%q) alors que %s a changé : le parc paraîtrait "+ - "homogène en ne l'étant pas", got, name) - } - }) - } -} - -// TestTheFingerprintIsEightCharactersAndNothingElse is what makes the check doable by -// eye: eight characters read out over the telephone, and no trailing noise a volunteer -// would have to ignore. -func TestTheFingerprintIsEightCharactersAndNothingElse(t *testing.T) { - out := &strings.Builder{} - if err := runConfig([]string{"fingerprint", deliveredConfig(t)}, nil, out); err != nil { - t.Fatalf("fingerprint : %v", err) - } - printed := strings.TrimSpace(out.String()) - if len(printed) != 8 { - t.Fatalf("empreinte affichée %q, %d caractères, attendu 8", printed, len(printed)) - } - if strings.ContainsAny(printed, " \t") { - t.Fatalf("empreinte affichée %q : elle doit se lire d'un trait", printed) - } -} - -// TestTheHardwareBlockIsKeptWhenItIsAskedFor covers the other export: a backup of THIS -// station, which does carry its port and its queue. -func TestTheHardwareBlockIsKeptWhenItIsAskedFor(t *testing.T) { - out := &strings.Builder{} - path := filepath.Join(t.TempDir(), "sauvegarde.json") - if err := runConfig([]string{"export", deliveredConfig(t), "--hardware", "--output", path}, nil, out); err != nil { - t.Fatalf("export : %v", err) - } - exported := readJSONConfig(t, path) - if exported.Station.Number != 2 { - t.Fatalf("le numéro de poste a été perdu : %d", exported.Station.Number) - } - if len(exported.Scale.Options) == 0 { - t.Fatal("le bloc scale.options a été perdu alors que --hardware le demande") - } - // Even there, the password never leaves. - if exported.Admin.PasswordHash != "" { - t.Fatal("--hardware a fait sortir le mot de passe d'administration") - } -} - -// TestValidateNamesEveryFaultAndReturnsNonZero is what install.ps1 reads: a volunteer who -// came to fix one file should leave having fixed it, and a script has to be able to tell -// that the station will start in factory configuration. -func TestValidateNamesEveryFaultAndReturnsNonZero(t *testing.T) { - broken := readJSONConfig(t, deliveredConfig(t)) - broken.Station.Number = 0 - broken.Printer.Template = "gabarit-inexistant" - broken.Journal.MaxRows = -1 - path := filepath.Join(t.TempDir(), "config.json") - writeJSONConfig(t, path, broken) - - out := &strings.Builder{} - err := runConfig([]string{"validate", path}, nil, out) - if err == nil { - t.Fatal("une configuration fautive a été validée sans erreur") - } - if exitCodeFor(err) == 0 { - t.Fatal("code de sortie nul sur une configuration fautive") - } - printed := out.String() - for _, field := range []string{"station.number", "printer.template", "journal"} { - if !strings.Contains(printed, field) { - t.Errorf("la faute sur %s n'est pas nommée :\n%s", field, printed) - } - } -} - -// TestValidateOfTheDeliveredFileIsGreenAndSaysItsFingerprint is the file of §17.2 checked -// against the REAL registries of this binary: the drivers, the transports, the templates -// and the catalog sources it actually carries. -func TestValidateOfTheDeliveredFileIsGreenAndSaysItsFingerprint(t *testing.T) { - out := &strings.Builder{} - if err := runConfig([]string{"validate", deliveredConfig(t)}, nil, out); err != nil { - t.Fatalf("la configuration livrée est refusée : %v\n%s", err, out.String()) - } - if !strings.Contains(out.String(), "aucune faute") { - t.Fatalf("sortie inattendue : %s", out.String()) - } - if !strings.Contains(out.String(), fingerprintOf(t, deliveredConfig(t))) { - t.Fatalf("l'empreinte n'est pas affichée avec le verdict : %s", out.String()) - } -} - // TestConfigRefusesWhatItCannotDo keeps the usage honest. func TestConfigRefusesWhatItCannotDo(t *testing.T) { for name, args := range map[string][]string{ @@ -225,91 +49,6 @@ func TestConfigRefusesWhatItCannotDo(t *testing.T) { } } -// TestTheCommandLineOpensAStationNobodyCanLogInTo is the hole this command closes. -// -// The delivered configuration carries no password — §11.5 ships the values of the site, -// not the secrets of one station — so a station straight out of install.ps1 answers 409 on -// its login form, 409 on its recovery form and 401 on every route that writes. It was -// locked out of its own administration, and §14.4 names the way back in. -func TestTheCommandLineOpensAStationNobodyCanLogInTo(t *testing.T) { - path := copyDelivered(t) - if before := readJSONConfig(t, path).Admin.PasswordHash; before != "" { - t.Fatalf("la configuration livrée porte déjà un mot de passe : %q", before) - } - - out := &strings.Builder{} - if err := runConfig([]string{"password", path}, strings.NewReader("mot-de-passe-du-poste\n"), out); err != nil { - t.Fatalf("config password : %v", err) - } - - after := readJSONConfig(t, path) - if !web.VerifySecret(after.Admin.PasswordHash, "mot-de-passe-du-poste") { - t.Fatalf("empreinte écrite = %q : elle ne vérifie pas le mot de passe tapé", - after.Admin.PasswordHash) - } - // The station is asked to restart, because nothing re-reads config.json while the - // service runs. A command that stayed silent about it would be read as « c'est fait ». - if !strings.Contains(out.String(), "Redémarrez le service") { - t.Errorf("la commande ne dit pas qu'il faut redémarrer : %q", out.String()) - } - // And it touched ONE field: everything the delivered file carried is still there. - before := readJSONConfig(t, copyDelivered(t)) - after.Admin.PasswordHash, after.ModifiedAt = "", before.ModifiedAt - if after.Fingerprint() != before.Fingerprint() { - t.Error("la commande a changé autre chose que le mot de passe") - } -} - -// TestAPasswordTooShortIsRefusedByBOTHDoors: the floor is the same on the terminal and on -// the recovery form, or the rule would depend on which door somebody came through. -func TestAPasswordTooShortIsRefusedByBOTHDoors(t *testing.T) { - path := copyDelivered(t) - err := runConfig([]string{"password", path}, strings.NewReader("court\n"), &strings.Builder{}) - if err == nil { - t.Fatal("un mot de passe de cinq caractères a été accepté") - } - if hash := readJSONConfig(t, path).Admin.PasswordHash; hash != "" { - t.Fatal("un mot de passe refusé a tout de même été écrit") - } -} - -// TestTheRecoveryCodeIsPrintedOnceAndStoredHashed (§14.4, important-10). -// -// It is generated AT INSTALLATION, and install.ps1 has no way to produce an argon2id -// hash: this command is where the eight characters of the installation sheet come from. -func TestTheRecoveryCodeIsPrintedOnceAndStoredHashed(t *testing.T) { - path := copyDelivered(t) - out := &strings.Builder{} - if err := runConfig([]string{"recovery-code", path}, nil, out); err != nil { - t.Fatalf("config recovery-code : %v", err) - } - - code := codePrintedBy(t, out.String()) - if len(code) != web.RecoveryCodeLength { - t.Fatalf("code affiché = %q, attendu %d caractères", code, web.RecoveryCodeLength) - } - hash := readJSONConfig(t, path).Admin.RecoveryCodeHash - if !web.VerifySecret(hash, code) { - t.Fatalf("l'empreinte écrite ne vérifie pas le code affiché %q", code) - } - // The clear code is nowhere in the file: the only copy is the printed sheet. - if raw, err := os.ReadFile(path); err != nil || strings.Contains(string(raw), code) { - t.Fatal("le code de secours est écrit en clair dans la configuration") - } - - // Drawn a second time, it says what it costs: the sheet already in the folder is wrong. - second := &strings.Builder{} - if err := runConfig([]string{"recovery-code", path}, nil, second); err != nil { - t.Fatalf("second tirage : %v", err) - } - if !strings.Contains(second.String(), "ne fonctionne plus") { - t.Errorf("un second tirage ne prévient pas que l'ancien code est mort : %q", second.String()) - } - if web.VerifySecret(readJSONConfig(t, path).Admin.RecoveryCodeHash, code) { - t.Error("le premier code de secours ouvre encore la porte") - } -} - // copyDelivered produces the file a station straight out of install.ps1 actually reads, // in a temporary directory, because these two commands WRITE. // @@ -327,17 +66,6 @@ func copyDelivered(t *testing.T) string { return path } -// codePrintedBy reads the eight characters out of what the command said. -func codePrintedBy(t *testing.T, printed string) string { - t.Helper() - const marker = "Code de secours de ce poste : " - index := strings.Index(printed, marker) - if index < 0 { - t.Fatalf("le code de secours n'est pas affiché : %q", printed) - } - return strings.TrimSpace(strings.SplitN(printed[index+len(marker):], "\n", 2)[0]) -} - // fingerprintOf runs the subcommand a volunteer runs, and returns what it printed. func fingerprintOf(t *testing.T, path string) string { t.Helper() @@ -374,271 +102,6 @@ func writeJSONConfig(t *testing.T, path string, cfg domain.Config) { } } -// siteValueShapes are the FORMS a value that designates a site takes, whatever it -// contains: an address of any scheme, a UNC share, a lettered drive, a host on the -// wire, a mailbox. -// -// Shapes and not values, because docs/00-donnees-retirees.md already paid for the -// other approach: the first sweep of this repository « cherchait des motifs DEVINÉS -// […] au lieu du motif GÉNÉRIQUE d'une adresse », two addresses on a neighbouring -// domain went through it, and the history had to be rewritten a second time. A net -// woven from the values one fixture happens to carry catches nothing else. -// -// They are deliberately narrow enough to stay silent on what an export legitimately -// carries: a category colour (#C0392B), a template name, a rounding word and the -// « config.json.1 à .5 » of the _readme. A bare-domain shape would have flagged that -// last one, which is how a net earns the right to be ignored. -var siteValueShapes = []struct { - what string - shape *regexp.Regexp -}{ - {"une URL", regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://`)}, - {"un chemin UNC", regexp.MustCompile(`\\\\[^\\]+\\`)}, - {"un chemin avec lettre de lecteur", regexp.MustCompile(`(?i)\b[a-z]:[\\/]`)}, - {"une adresse IPv4", regexp.MustCompile(`\b\d{1,3}(?:\.\d{1,3}){3}\b`)}, - {"une adresse de courriel", regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)}, -} - -// TestTheDeliveredExportShipsNoHostNoAccountNoQueue is the net under the strip list. -// -// It asserts on the SHAPE of every string the export carries, so it bites the day -// somebody ships a host, a share or an account this file has never heard of — which -// the previous version could not do: it looked for the five literals of the fixture, -// all five already stripped, while catalog.images.path walked a NAS name straight out -// of the door. The archive is published on GitHub: what leaves here leaves for good. -// -// The five literals stay underneath. They cost nothing and they pin the regression -// that was found by hand, at the exact values that were found. -func TestTheDeliveredExportShipsNoHostNoAccountNoQueue(t *testing.T) { - raw, err := os.ReadFile(deliveredConfig(t)) - if err != nil { - t.Fatalf("lecture de la configuration livrée : %v", err) - } - var delivered domain.Config - if err := json.Unmarshal(raw, &delivered); err != nil { - t.Fatalf("décodage de la configuration livrée : %v", err) - } - - refuseSiteValues(t, "la configuration livrée", delivered.Export(false)) - - // And the same export for a cooperative whose site values share NOTHING with the - // five below: another host, another account, another queue, another port, a print - // spool on a drive letter, and the photo share of a NAS. Only a shape catches those. - elsewhere := delivered - elsewhere.Scale.Options = delivered.Scale.Options.WithText("port", "COM9") - elsewhere.Printer.Options = delivered.Printer.Options. - WithText("queue", "Zebra ZD421_7"). - WithText("address", "192.168.0.43:9100"). - WithText("path", `D:\spool\etiquettes`) - elsewhere.Catalog.Options = delivered.Catalog.Options. - WithText("url", "https://partage.exemple.lan:8443/dav/"). - WithText("username", "poste-pesee") - elsewhere.Catalog.Images.Path = `\\nas.exemple.lan\photos\produits` - refuseSiteValues(t, "une configuration d'un autre site", elsewhere.Export(false)) - - shipped, err := json.Marshal(delivered.Export(false)) - if err != nil { - t.Fatalf("encodage de l'export : %v", err) - } - forbidden := map[string]string{ - "dav.example.org": "un nom d'hôte", - "balance": "un compte", - "SATO WS408_2": "une file d'impression", - "SATO WS408_3": "une file d'impression de repli", - "COM8": "un port série", - } - for value, what := range forbidden { - if bytes.Contains(shipped, []byte(value)) { - t.Errorf("le fichier livré porte %s (%q) : il est publié sur GitHub et installé sur quatre postes", - what, value) - } - } -} - -// refuseSiteValues fails on every string of the export whose shape designates a site. -func refuseSiteValues(t *testing.T, subject string, exported domain.Config) { - t.Helper() - carried := stringsCarriedBy(t, exported) - paths := make([]string, 0, len(carried)) - for path := range carried { - paths = append(paths, path) - } - // Sorted, so that two runs name the offending fields in the same order. - sort.Strings(paths) - for _, path := range paths { - for _, form := range siteValueShapes { - if form.shape.MatchString(carried[path]) { - t.Errorf("l'export de %s porte %s en %s (%q) : l'archive est publiée sur "+ - "GitHub et installée sur quatre postes", subject, form.what, path, carried[path]) - } - } - } -} - -// stringsCarriedBy reports every string an export carries, keyed by its dotted path. -// -// It walks the DOCUMENT rather than the Go structure so that no field can escape by -// being typed instead of being a key of a DriverOptions map — which is exactly how -// catalog.images.path escaped the strip list. The path is what makes a failure -// actionable: it names the field a volunteer has to go and empty. -func stringsCarriedBy(t *testing.T, exported domain.Config) map[string]string { - t.Helper() - raw, err := json.Marshal(exported) - if err != nil { - t.Fatalf("encodage de l'export : %v", err) - } - var document any - if err := json.Unmarshal(raw, &document); err != nil { - t.Fatalf("relecture de l'export : %v", err) - } - found := make(map[string]string) - collectStrings("", document, found) - return found -} - -// TestMigrateWritesOnceAndSaysSoTheSecondTime: the command is what update.ps1 and update.sh -// call, so running it twice on the same station -- two updates in a row -- must be a -// no-operation the second time, and must not rotate config.json.1 over a version that -// mattered. -func TestMigrateWritesOnceAndSaysSoTheSecondTime(t *testing.T) { - directory := t.TempDir() - path := filepath.Join(directory, "config.json") - if err := os.WriteFile(path, []byte( - `{"version":1,"station":{"number":2},"ui":{"tile_size":"large"}}`), 0o644); err != nil { - t.Fatalf("écriture : %v", err) - } - - var first bytes.Buffer - if err := runConfig([]string{"migrate", path}, nil, &first); err != nil { - t.Fatalf("première migration : %v", err) - } - if !strings.Contains(first.String(), "tile_size") { - t.Errorf("la première migration ne dit pas ce qu'elle a changé :\n%s", first.String()) - } - if _, err := os.Stat(path + ".1"); err != nil { - t.Errorf("la version d'avant n'a pas été gardée : %v", err) - } - - migrated, err := os.ReadFile(path) - if err != nil { - t.Fatalf("relecture : %v", err) - } - - var second bytes.Buffer - if err := runConfig([]string{"migrate", path}, nil, &second); err != nil { - t.Fatalf("seconde migration : %v", err) - } - if !strings.Contains(second.String(), "rien à faire") { - t.Errorf("la seconde migration n'annonce pas qu'elle ne fait rien :\n%s", second.String()) - } - again, err := os.ReadFile(path) - if err != nil { - t.Fatalf("relecture : %v", err) - } - if string(again) != string(migrated) { - t.Errorf("la seconde migration a réécrit le fichier :\n%s\n%s", migrated, again) - } -} - -// TestMigrateLeavesARefusedKeyInPlace: what this binary will not guess stays where it is, -// and the command says so with a non-zero status so update.ps1 can show it. -func TestMigrateLeavesARefusedKeyInPlace(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.json") - if err := os.WriteFile(path, []byte( - `{"version":1,"barcode":{"weight_decimals":3}}`), 0o644); err != nil { - t.Fatalf("écriture : %v", err) - } - - var out bytes.Buffer - err := runConfig([]string{"migrate", path}, nil, &out) - if err == nil { - t.Fatal("une clé refusée doit donner un code de retour non nul") - } - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatalf("relecture : %v", readErr) - } - if !strings.Contains(string(after), "weight_decimals") { - t.Errorf("la clé refusée a été retirée quand même : %s", after) - } -} - -// TestMigrateMixesTwoKindsOfRefusalWithoutDuplicating watches a COINCIDENCE, not a rule: -// migrateConfig's deduplication between Migrate's notes and cfg.Retired() works because -// carryCoefficientToDiscount (configmigration.go) and scanRetired (config.go) build the -// SAME dotted path for the same field -- two independent functions that never cite one -// another. If either one changes its template one day without the other following, the -// deduplication breaks IN SILENCE: a key would show up twice, under two different -// messages, with no test noticing. This one mixes both families of refusal in the same -// file to catch that. -// -// pricing.tiers[0].coef_num AND pricing.tiers[0].coef_den both stay in the document -// (carryCoefficientToDiscount only removes them on the branch that succeeds), so -// scanRetired finds both of them -- but Migrate only ever wrote ONE note, under -// "pricing.tiers[0].coef_num". The deduplication therefore cancels only that one: coef_den -// gets its own line, with its own reason (ADR-034, "there is no more denominator"). That is -// NOT a duplicated message -- it is a third point, on a third JSON key that genuinely still -// stands -- and this test pins that too, so a future reader does not mistake it for an -// unfixed bug. -func TestMigrateMixesTwoKindsOfRefusalWithoutDuplicating(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.json") - before := []byte(`{"version":1,"pricing":{"tiers":[ - {"code":"X","label":"X","abbrev":"X","coef_num":3,"coef_den":7,"rank":1} - ]},"barcode":{"weight_decimals":3}}`) - if err := os.WriteFile(path, before, 0o644); err != nil { - t.Fatalf("écriture : %v", err) - } - - var out bytes.Buffer - err := runConfig([]string{"migrate", path}, nil, &out) - if err == nil { - t.Fatal("un fichier portant deux familles de clés refusées doit rendre un code non nul") - } - - printed := out.String() - for _, key := range []string{ - "pricing.tiers[0].coef_num", "pricing.tiers[0].coef_den", "barcode.weight_decimals", - } { - if count := strings.Count(printed, key); count != 1 { - t.Errorf("%s apparaît %d fois dans la sortie, attendu 1 :\n%s", key, count, printed) - } - } - if !strings.Contains(printed, "3 changement(s)") { - t.Errorf("le compte de points refusés n'est pas 3 (coef_num, coef_den, weight_decimals) :\n%s", printed) - } - if !strings.Contains(err.Error(), "3 point(s)") { - t.Errorf("l'erreur ne nomme pas les 3 points refusés : %v", err) - } - - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatalf("relecture : %v", readErr) - } - if string(after) != string(before) { - t.Errorf("le fichier a été modifié alors qu'un point reste refusé :\navant : %s\naprès : %s", - before, after) - } -} - -func collectStrings(path string, value any, out map[string]string) { - switch typed := value.(type) { - case string: - out[path] = typed - case map[string]any: - for key, nested := range typed { - child := key - if path != "" { - child = path + "." + key - } - collectStrings(child, nested, out) - } - case []any: - for index, item := range typed { - collectStrings(fmt.Sprintf("%s[%d]", path, index), item, out) - } - } -} - // shopFileWithAnUnreadablePricingBlock writes the delivered configuration of §17.2 with // two things done to it, and both are needed to reproduce the defect. // @@ -808,30 +271,6 @@ func TestExportRefusesAConfigurationItDidNotReadWhole(t *testing.T) { } } -// TestTheDeliveredFileStillExportsAndFingerprints keeps the two refusals above from -// becoming a refusal of everything: the file of §17.2 is sound, and both commands must -// answer on it exactly as before. -func TestTheDeliveredFileStillExportsAndFingerprints(t *testing.T) { - written := filepath.Join(t.TempDir(), "export.json") - - var printed bytes.Buffer - if err := runConfig([]string{"fingerprint", deliveredConfig(t)}, nil, &printed); err != nil { - t.Fatalf("empreinte du fichier livré : %v", err) - } - if len(strings.TrimSpace(printed.String())) != 8 { - t.Errorf("empreinte %q, attendu huit caractères", strings.TrimSpace(printed.String())) - } - - var exported bytes.Buffer - if err := runConfig([]string{"export", deliveredConfig(t), "--output", written}, nil, - &exported); err != nil { - t.Fatalf("export du fichier livré : %v", err) - } - if _, statErr := os.Stat(written); statErr != nil { - t.Errorf("l'export du fichier livré n'a rien écrit : %v", statErr) - } -} - // TestComingBackFromManualEntryIsNotStoppedByAFaultInAnotherBlock is the SINGLE-BLOCK // door, and the one whose failure is invisible from a desk. // diff --git a/cmd/openscale/configread.go b/cmd/openscale/configread.go new file mode 100644 index 0000000..e18317a --- /dev/null +++ b/cmd/openscale/configread.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "openscale/internal/domain" + "openscale/internal/printing/transport" +) + +// This file holds the `config` actions that never modify the station's own file: +// validate names every fault at once, export writes what §11.5 clones onto the other +// stations. Both only READ config.json — the three that rewrite it are in +// configwrite.go. + +// validateConfig runs the controls of §11.3 with the REAL registries of this binary, +// and prints every fault at once. +// +// Every fault and not the first: a volunteer who came to fix one file should leave +// having fixed it, and not discover the second fault after a restart. The exit code is +// what makes it usable from install.ps1 — a non-zero status means « this station will +// start in factory configuration ». +// +// That promise is only true because the DECODING faults are counted here too. A block +// that will not decode falls back on the neutral profile, and the substitute passes +// Validate without a word: judging on Validate alone answered « aucune faute » about a +// station that comes up in ERR-CFG-01, while serve — reading the very same file through +// the very same door — reported it. +func validateConfig(out io.Writer, path string, cfg domain.Config, + notes []domain.MigrationNote, decodeFaults []domain.Fault) error { + reportPendingMigrations(out, path, notes) + + scales, printers := scaleRegistry(), printerRegistry() + // The decoding faults FIRST, and the concatenation is the one serve.go already makes: + // a block that was replaced is what makes every value below it suspect, so it is read + // before the judgements passed on those values. + faults := append(decodeFaults, cfg.Validate(domain.Registries{ + Scales: scales.Descriptors(), + Printers: printers.Descriptors(), + Transports: transport.Descriptors(), + CatalogSources: catalogSourceDescriptors(), + })...) + if len(faults) == 0 { + fmt.Fprintf(out, "%s : aucune faute. Empreinte des réglages partagés : %s\n", + path, cfg.Fingerprint()) + return nil + } + fmt.Fprintf(out, "%s : %d faute(s).\n", path, len(faults)) + for _, fault := range faults { + fmt.Fprintf(out, " %s\n", fault.String()) + } + return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( + "%s comporte %d faute(s) : le poste démarrerait en configuration d'usine (ERR-CFG-01)", + path, len(faults))} +} + +// reportPendingMigrations names what LoadConfig had to change to bring the file up to the +// schema this binary speaks, BEFORE the fault list: a volunteer reading a fault about a +// field they never touched should learn first that the field came from an old file, not +// discover it after wondering why the value looks wrong. +// +// It says NOTHING when there is nothing pending, on purpose: a station already at this +// schema must not see a paragraph on every `config validate`, only the ones that changed +// something. A retired key Migrate has no translation for (the six of the numbering plan) +// earns no note here either — control 20 names it in the fault list right below, which is +// where a pure refusal has always been reported. +func reportPendingMigrations(out io.Writer, path string, notes []domain.MigrationNote) { + if len(notes) == 0 { + return + } + fmt.Fprintf(out, "%s : ce fichier n'est pas encore au schéma %d que ce binaire écrit — "+ + "%d migration(s) en attente, qu'« openscale config migrate » appliquerait :\n", + path, domain.CurrentSchemaVersion, len(notes)) + for _, note := range notes { + fmt.Fprintf(out, " %s\n", note) + } +} + +// exportConfig writes what §11.5 clones. +// +// It is the SAME domain.Config.Export the administration route calls, and it has to be: +// two exports that differed by a field would produce two fingerprints, and the eight +// characters four volunteers compare by eye would stop meaning anything. +func exportConfig(out io.Writer, cfg domain.Config, hardware bool, output string) error { + exported := cfg.Export(hardware) + // The recovery code is printed on the installation sheet OF ONE STATION. Carrying it + // into a clone is the « four stations sharing one secret nobody chose » that Export + // already refuses for the password, and the administration route redacts it here too. + exported.Admin.RecoveryCodeHash = "" + + raw, err := json.MarshalIndent(exported, "", " ") + if err != nil { + return fmt.Errorf("l'export n'a pas pu être encodé : %w", err) + } + raw = append(raw, '\n') + + if output == "" { + _, err = out.Write(raw) + return err + } + if err := os.WriteFile(output, raw, 0o644); err != nil { + return fmt.Errorf("l'export n'a pas pu être écrit dans %s : %w", output, err) + } + fmt.Fprintf(out, "export écrit dans %s — empreinte %s\n", output, cfg.Fingerprint()) + return nil +} diff --git a/cmd/openscale/configread_test.go b/cmd/openscale/configread_test.go new file mode 100644 index 0000000..1c6aedd --- /dev/null +++ b/cmd/openscale/configread_test.go @@ -0,0 +1,366 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "openscale/internal/domain" +) + +// The two `config` actions that never modify the station's own file: validate names +// every fault at once, export writes what §11.5 clones onto the other stations — and +// the eight characters four volunteers compare BY EYE have to say the same thing on +// all four. + +// TestCloningAStationShowsTheSAMEEightCharacters is the criterion of §18 for L8, at the +// gesture a volunteer actually performs: « clone la configuration vers les 3 autres +// postes et vérifie l'empreinte ». +// +// Station 1 exports WITHOUT its hardware block, stations 3 and 4 receive that file and +// then do their own two hardware steps — a different COM port, a different print queue, a +// different number, a different name, a different listen address. The four stations must +// display ONE string of eight characters, or the check is worthless. +// +// # Why each hardware step sets ONE KEY and never replaces the block +// +// Since the export stopped dropping the option maps whole, Fingerprint compares what +// those maps hold -- the label offset, the darkness, the speed, the serial settings. +// Writing `cloned.Scale.Options = DriverOptions{"port": "COM3"}` would therefore not +// merely name a port: it would throw away the baud rate, the parity and the reconnection +// backoff the clone had just delivered. Two stations identical in every respect would +// then show two different strings, and the one check a volunteer can do by eye would be +// reporting a divergence the test itself invented. +// +// It is written this way because it is what the ADMINISTRATION SCREEN does (§15.5): the +// two steps after an import are « choisissez le port » and « choisissez la file », one +// field each, and the file behind them keeps every key nobody touched. A screen that +// rewrote the whole block on one edit would be the bug this test would then be blessing. +func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) { + out := &strings.Builder{} + export := filepath.Join(t.TempDir(), "config-export.json") + if err := runConfig([]string{"export", deliveredConfig(t), "--output", export}, nil, out); err != nil { + t.Fatalf("export : %v", err) + } + + exported := readJSONConfig(t, export) + if exported.Station.Number != 0 || exported.Network.Listen != "" { + t.Fatalf("l'export porte encore le poste n° %d et l'adresse %q : ce sont les deux "+ + "choses qu'un clone ne doit pas hériter", exported.Station.Number, exported.Network.Listen) + } + if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" { + t.Fatal("l'export porte un secret d'administration") + } + + reference := fingerprintOf(t, deliveredConfig(t)) + for _, station := range []struct { + number int + name string + port string + queue string + listen string + }{ + {3, "Poste 3 — légumes", "COM3", "SATO WS408_3", "127.0.0.1:8086"}, + {4, "Poste 4 — vrac", "COM12", "SATO WS408_4", "0.0.0.0:8085"}, + } { + cloned := exported + cloned.Station.Number, cloned.Station.Name = station.number, station.name + cloned.Network.Listen = station.listen + // The two hardware steps of §15.5, done on the screen after the import: ONE + // field each, on top of the options the clone delivered. See the head of this + // test for why replacing the block instead would make two homogeneous stations + // diverge. + cloned.Scale.Options = exported.Scale.Options.WithText("port", station.port) + cloned.Printer.Options = exported.Printer.Options.WithText("queue", station.queue) + + path := filepath.Join(t.TempDir(), "config.json") + writeJSONConfig(t, path, cloned) + if got := fingerprintOf(t, path); got != reference { + t.Fatalf("poste %d affiche %q, le poste de référence %q : deux postes réglés à "+ + "l'identique doivent afficher la même empreinte", station.number, got, reference) + } + } +} + +// TestOneBusinessSettingApartAndTheFingerprintDiverges is the other half of the check: +// an eight-character digest that never moved would be a green light nobody can trust. +// +// The five values below are the ones §11.5 says MUST be identical across the fleet — the +// price grid, a safeguard, the label template, a category, the retention — and each is a +// value that, if it silently differed on one station, would produce wrong prices or wrong +// labels on that station alone. +func TestOneBusinessSettingApartAndTheFingerprintDiverges(t *testing.T) { + reference := readJSONConfig(t, deliveredConfig(t)) + referenceFingerprint := reference.Fingerprint() + + for name, diverge := range map[string]func(*domain.Config){ + "une remise de tarif": func(c *domain.Config) { c.Pricing.Tiers[0].Discount = 200 }, + "le seuil de panier vide": func(c *domain.Config) { c.Limits.EmptyMax = 12 }, + "le gabarit d'étiquette": func(c *domain.Config) { c.Printer.Template = "weighing_neutral_single" }, + "une catégorie masquée": func(c *domain.Config) { c.Catalog.Categories[0].Visible = false }, + "la rétention du journal": func(c *domain.Config) { c.Journal.MaxDays = 30 }, + } { + t.Run(name, func(t *testing.T) { + diverging := readJSONConfig(t, deliveredConfig(t)) + diverge(&diverging) + path := filepath.Join(t.TempDir(), "config.json") + writeJSONConfig(t, path, diverging) + if got := fingerprintOf(t, path); got == referenceFingerprint { + t.Fatalf("empreinte inchangée (%q) alors que %s a changé : le parc paraîtrait "+ + "homogène en ne l'étant pas", got, name) + } + }) + } +} + +// TestTheFingerprintIsEightCharactersAndNothingElse is what makes the check doable by +// eye: eight characters read out over the telephone, and no trailing noise a volunteer +// would have to ignore. +func TestTheFingerprintIsEightCharactersAndNothingElse(t *testing.T) { + out := &strings.Builder{} + if err := runConfig([]string{"fingerprint", deliveredConfig(t)}, nil, out); err != nil { + t.Fatalf("fingerprint : %v", err) + } + printed := strings.TrimSpace(out.String()) + if len(printed) != 8 { + t.Fatalf("empreinte affichée %q, %d caractères, attendu 8", printed, len(printed)) + } + if strings.ContainsAny(printed, " \t") { + t.Fatalf("empreinte affichée %q : elle doit se lire d'un trait", printed) + } +} + +// TestTheHardwareBlockIsKeptWhenItIsAskedFor covers the other export: a backup of THIS +// station, which does carry its port and its queue. +func TestTheHardwareBlockIsKeptWhenItIsAskedFor(t *testing.T) { + out := &strings.Builder{} + path := filepath.Join(t.TempDir(), "sauvegarde.json") + if err := runConfig([]string{"export", deliveredConfig(t), "--hardware", "--output", path}, nil, out); err != nil { + t.Fatalf("export : %v", err) + } + exported := readJSONConfig(t, path) + if exported.Station.Number != 2 { + t.Fatalf("le numéro de poste a été perdu : %d", exported.Station.Number) + } + if len(exported.Scale.Options) == 0 { + t.Fatal("le bloc scale.options a été perdu alors que --hardware le demande") + } + // Even there, the password never leaves. + if exported.Admin.PasswordHash != "" { + t.Fatal("--hardware a fait sortir le mot de passe d'administration") + } +} + +// TestValidateNamesEveryFaultAndReturnsNonZero is what install.ps1 reads: a volunteer who +// came to fix one file should leave having fixed it, and a script has to be able to tell +// that the station will start in factory configuration. +func TestValidateNamesEveryFaultAndReturnsNonZero(t *testing.T) { + broken := readJSONConfig(t, deliveredConfig(t)) + broken.Station.Number = 0 + broken.Printer.Template = "gabarit-inexistant" + broken.Journal.MaxRows = -1 + path := filepath.Join(t.TempDir(), "config.json") + writeJSONConfig(t, path, broken) + + out := &strings.Builder{} + err := runConfig([]string{"validate", path}, nil, out) + if err == nil { + t.Fatal("une configuration fautive a été validée sans erreur") + } + if exitCodeFor(err) == 0 { + t.Fatal("code de sortie nul sur une configuration fautive") + } + printed := out.String() + for _, field := range []string{"station.number", "printer.template", "journal"} { + if !strings.Contains(printed, field) { + t.Errorf("la faute sur %s n'est pas nommée :\n%s", field, printed) + } + } +} + +// TestValidateOfTheDeliveredFileIsGreenAndSaysItsFingerprint is the file of §17.2 checked +// against the REAL registries of this binary: the drivers, the transports, the templates +// and the catalog sources it actually carries. +func TestValidateOfTheDeliveredFileIsGreenAndSaysItsFingerprint(t *testing.T) { + out := &strings.Builder{} + if err := runConfig([]string{"validate", deliveredConfig(t)}, nil, out); err != nil { + t.Fatalf("la configuration livrée est refusée : %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "aucune faute") { + t.Fatalf("sortie inattendue : %s", out.String()) + } + if !strings.Contains(out.String(), fingerprintOf(t, deliveredConfig(t))) { + t.Fatalf("l'empreinte n'est pas affichée avec le verdict : %s", out.String()) + } +} + +// siteValueShapes are the FORMS a value that designates a site takes, whatever it +// contains: an address of any scheme, a UNC share, a lettered drive, a host on the +// wire, a mailbox. +// +// Shapes and not values, because docs/00-donnees-retirees.md already paid for the +// other approach: the first sweep of this repository « cherchait des motifs DEVINÉS +// […] au lieu du motif GÉNÉRIQUE d'une adresse », two addresses on a neighbouring +// domain went through it, and the history had to be rewritten a second time. A net +// woven from the values one fixture happens to carry catches nothing else. +// +// They are deliberately narrow enough to stay silent on what an export legitimately +// carries: a category colour (#C0392B), a template name, a rounding word and the +// « config.json.1 à .5 » of the _readme. A bare-domain shape would have flagged that +// last one, which is how a net earns the right to be ignored. +var siteValueShapes = []struct { + what string + shape *regexp.Regexp +}{ + {"une URL", regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://`)}, + {"un chemin UNC", regexp.MustCompile(`\\\\[^\\]+\\`)}, + {"un chemin avec lettre de lecteur", regexp.MustCompile(`(?i)\b[a-z]:[\\/]`)}, + {"une adresse IPv4", regexp.MustCompile(`\b\d{1,3}(?:\.\d{1,3}){3}\b`)}, + {"une adresse de courriel", regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)}, +} + +// TestTheDeliveredExportShipsNoHostNoAccountNoQueue is the net under the strip list. +// +// It asserts on the SHAPE of every string the export carries, so it bites the day +// somebody ships a host, a share or an account this file has never heard of — which +// the previous version could not do: it looked for the five literals of the fixture, +// all five already stripped, while catalog.images.path walked a NAS name straight out +// of the door. The archive is published on GitHub: what leaves here leaves for good. +// +// The five literals stay underneath. They cost nothing and they pin the regression +// that was found by hand, at the exact values that were found. +func TestTheDeliveredExportShipsNoHostNoAccountNoQueue(t *testing.T) { + raw, err := os.ReadFile(deliveredConfig(t)) + if err != nil { + t.Fatalf("lecture de la configuration livrée : %v", err) + } + var delivered domain.Config + if err := json.Unmarshal(raw, &delivered); err != nil { + t.Fatalf("décodage de la configuration livrée : %v", err) + } + + refuseSiteValues(t, "la configuration livrée", delivered.Export(false)) + + // And the same export for a cooperative whose site values share NOTHING with the + // five below: another host, another account, another queue, another port, a print + // spool on a drive letter, and the photo share of a NAS. Only a shape catches those. + elsewhere := delivered + elsewhere.Scale.Options = delivered.Scale.Options.WithText("port", "COM9") + elsewhere.Printer.Options = delivered.Printer.Options. + WithText("queue", "Zebra ZD421_7"). + WithText("address", "192.168.0.43:9100"). + WithText("path", `D:\spool\etiquettes`) + elsewhere.Catalog.Options = delivered.Catalog.Options. + WithText("url", "https://partage.exemple.lan:8443/dav/"). + WithText("username", "poste-pesee") + elsewhere.Catalog.Images.Path = `\\nas.exemple.lan\photos\produits` + refuseSiteValues(t, "une configuration d'un autre site", elsewhere.Export(false)) + + shipped, err := json.Marshal(delivered.Export(false)) + if err != nil { + t.Fatalf("encodage de l'export : %v", err) + } + forbidden := map[string]string{ + "dav.example.org": "un nom d'hôte", + "balance": "un compte", + "SATO WS408_2": "une file d'impression", + "SATO WS408_3": "une file d'impression de repli", + "COM8": "un port série", + } + for value, what := range forbidden { + if bytes.Contains(shipped, []byte(value)) { + t.Errorf("le fichier livré porte %s (%q) : il est publié sur GitHub et installé sur quatre postes", + what, value) + } + } +} + +// refuseSiteValues fails on every string of the export whose shape designates a site. +func refuseSiteValues(t *testing.T, subject string, exported domain.Config) { + t.Helper() + carried := stringsCarriedBy(t, exported) + paths := make([]string, 0, len(carried)) + for path := range carried { + paths = append(paths, path) + } + // Sorted, so that two runs name the offending fields in the same order. + sort.Strings(paths) + for _, path := range paths { + for _, form := range siteValueShapes { + if form.shape.MatchString(carried[path]) { + t.Errorf("l'export de %s porte %s en %s (%q) : l'archive est publiée sur "+ + "GitHub et installée sur quatre postes", subject, form.what, path, carried[path]) + } + } + } +} + +// stringsCarriedBy reports every string an export carries, keyed by its dotted path. +// +// It walks the DOCUMENT rather than the Go structure so that no field can escape by +// being typed instead of being a key of a DriverOptions map — which is exactly how +// catalog.images.path escaped the strip list. The path is what makes a failure +// actionable: it names the field a volunteer has to go and empty. +func stringsCarriedBy(t *testing.T, exported domain.Config) map[string]string { + t.Helper() + raw, err := json.Marshal(exported) + if err != nil { + t.Fatalf("encodage de l'export : %v", err) + } + var document any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("relecture de l'export : %v", err) + } + found := make(map[string]string) + collectStrings("", document, found) + return found +} + +func collectStrings(path string, value any, out map[string]string) { + switch typed := value.(type) { + case string: + out[path] = typed + case map[string]any: + for key, nested := range typed { + child := key + if path != "" { + child = path + "." + key + } + collectStrings(child, nested, out) + } + case []any: + for index, item := range typed { + collectStrings(fmt.Sprintf("%s[%d]", path, index), item, out) + } + } +} + +// TestTheDeliveredFileStillExportsAndFingerprints keeps the two refusals above from +// becoming a refusal of everything: the file of §17.2 is sound, and both commands must +// answer on it exactly as before. +func TestTheDeliveredFileStillExportsAndFingerprints(t *testing.T) { + written := filepath.Join(t.TempDir(), "export.json") + + var printed bytes.Buffer + if err := runConfig([]string{"fingerprint", deliveredConfig(t)}, nil, &printed); err != nil { + t.Fatalf("empreinte du fichier livré : %v", err) + } + if len(strings.TrimSpace(printed.String())) != 8 { + t.Errorf("empreinte %q, attendu huit caractères", strings.TrimSpace(printed.String())) + } + + var exported bytes.Buffer + if err := runConfig([]string{"export", deliveredConfig(t), "--output", written}, nil, + &exported); err != nil { + t.Fatalf("export du fichier livré : %v", err) + } + if _, statErr := os.Stat(written); statErr != nil { + t.Errorf("l'export du fichier livré n'a rien écrit : %v", statErr) + } +} diff --git a/cmd/openscale/configwrite.go b/cmd/openscale/configwrite.go new file mode 100644 index 0000000..9a4850a --- /dev/null +++ b/cmd/openscale/configwrite.go @@ -0,0 +1,267 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "strings" + + "openscale/internal/domain" + "openscale/internal/platform" + "openscale/internal/web" +) + +// This file holds the three `config` actions that REWRITE the station's own file — +// migrate, password and recovery-code. All three go through the store the +// administration screen saves with, so a change made from a terminal rotates the five +// versions and lands atomically like any other (§11.4), and the station does not see +// it before it restarts. + +// minPasswordLength is the floor POST /admin/api/session/recovery already holds (§14.4). +// +// The same figure in the two places that set a password, because a station where the +// terminal accepted four characters and the screen refused them would be a station whose +// rule depends on which door somebody came through. +const minPasswordLength = 8 + +// editConfigFile reads the station's configuration, hands it to change, and writes it +// back. +// +// The two callers below each set ONE field of the administration block, and each used +// to carry its own copy of the open, the read and the write. What SURROUNDS the change +// is gathered here so that the third caller somebody writes later cannot forget it: the +// store, which is what rotates config.json.1 … .5 and lands the file atomically (§11.4), +// and the instant that dates the file. +// +// NOTHING reaches disk when change refuses. A password that could not be hashed, or a +// line nobody typed, leaves the file exactly as it was. +func editConfigFile(path string, change func(cfg *domain.Config) error) error { + store, err := platform.NewConfigStore(path) + if err != nil { + return err + } + ctx := context.Background() + cfg, err := store.Read(ctx) + if err != nil { + return errors.New(readFailure(path, err)) + } + if err := change(&cfg); err != nil { + return err + } + cfg.ModifiedAt = platform.NewSystemClock().Now() + return store.Save(ctx, cfg) +} + +// setAdminPassword is `openscale config password` (§14.4). +// +// It writes the FILE, through the same store the administration screen saves with, so a +// password set from a terminal rotates the versions and lands atomically like any other +// change. The station does not see it before it restarts: nothing re-reads config.json +// while the service runs, and pretending otherwise would have somebody typing a password +// that works only after the next power cut. +func setAdminPassword(in io.Reader, out io.Writer, path string) error { + // Read inside the change, because the last sentence of the command depends on it: a + // station with no recovery code has one gesture left to make. + var withoutRecoveryCode bool + err := editConfigFile(path, func(cfg *domain.Config) error { + fmt.Fprintf(out, "Nouveau mot de passe d'administration pour %s\n"+ + "(au moins %d caractères, il s'affiche à l'écran) : ", path, minPasswordLength) + typed, err := readSecretLine(in) + if err != nil { + return err + } + if len([]rune(typed)) < minPasswordLength { + return fmt.Errorf("le mot de passe doit faire au moins %d caractères", minPasswordLength) + } + + hash, err := web.HashSecret(typed) + if err != nil { + return err + } + cfg.Admin.PasswordHash = hash + withoutRecoveryCode = cfg.Admin.RecoveryCodeHash == "" + return nil + }) + if err != nil { + return err + } + + fmt.Fprintf(out, "\nMot de passe d'administration posé dans %s.\n", path) + if withoutRecoveryCode { + fmt.Fprintf(out, "Ce poste n'a AUCUN code de secours : sans lui, ce mot de passe "+ + "perdu se rattrape uniquement ici. Tirez-en un avec « openscale config "+ + "recovery-code » et recopiez-le sur la fiche d'installation.\n") + } + fmt.Fprintf(out, "Redémarrez le service pour que le poste le prenne en compte.\n") + return nil +} + +// mintRecoveryCode is `openscale config recovery-code` (§14.4, important-10). +// +// The code is shown ONCE, in clear, and never again: the configuration keeps its argon2id +// hash and nothing else. Whoever runs this command has one job left, and it is written on +// the last line — copy the eight characters onto the installation sheet, which goes into +// the shop's folder and not onto the station. +func mintRecoveryCode(out io.Writer, path string) error { + var ( + code string + replacing bool + ) + err := editConfigFile(path, func(cfg *domain.Config) error { + replacing = cfg.Admin.RecoveryCodeHash != "" + + minted, err := web.NewRecoveryCode() + if err != nil { + return err + } + hash, err := web.HashSecret(minted) + if err != nil { + return err + } + cfg.Admin.RecoveryCodeHash = hash + code = minted + return nil + }) + if err != nil { + return err + } + + if replacing { + fmt.Fprintf(out, "L'ancien code de secours de ce poste ne fonctionne plus : "+ + "la fiche déjà classée est à corriger.\n") + } + fmt.Fprintf(out, "Code de secours de ce poste : %s\n", code) + fmt.Fprintf(out, "Recopiez-le sur la fiche d'installation MAINTENANT : il ne sera "+ + "plus jamais affiché.\n") + return nil +} + +// readSecretLine reads ONE line off the standard input. +// +// No echo suppression, and it is a deliberate refusal rather than an oversight: turning +// the terminal echo off means a terminal package, which means a seventh module in a +// perimeter §17.1 closes at six, bought for the one call it would take — ADR-039 weighs +// a dependency on the surface actually called, and this one is a single call. The usage +// text says the line is visible, and the machine it is typed on is the station's own +// console. +func readSecretLine(in io.Reader) (string, error) { + if in == nil { + return "", errors.New("aucune entrée standard : le mot de passe se tape au clavier, " + + "ou s'envoie par un tube") + } + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("lecture du mot de passe : %w", err) + } + // Typed on a Windows console the line ends with \r\n, piped from a file it may end + // with nothing at all. Only those two characters go: a password is allowed to end + // with a space, and trimming it would refuse tomorrow what it accepted today. + return strings.TrimRight(line, "\r\n"), nil +} + +// migrateConfig is `openscale config migrate`. +// +// It writes through the same store the administration screen saves with, so a migration +// rotates config.json.1 … .5 and lands atomically like any other change. Nothing new is +// invented for it, and that is the point: the version of before is one file away. +// +// It is IDEMPOTENT. update.ps1 and update.sh call it at every update, and a station that is +// already at this schema must come out of it with its file untouched -- rotating five +// versions over a no-operation is how the version that mattered falls off the end. +// +// A refused point suspends the WHOLE write, not only its own key: what could be carried +// stays computed, correctly, in the cfg this run holds in memory, but nothing at all +// reaches disk while a single point is still refused -- see the comment on the refusal +// branch below for why that has to hold even for a file that carries one point migrate CAN +// write and one it cannot. +// +// A block that would not DECODE suspends it the same way, and that one is not a migration +// question at all: what this command holds for such a block is the neutral profile, and +// rewriting the file would post the factory value over whatever the shop had declared. +func migrateConfig(out io.Writer, path string) error { + cfg, notes, decodeFaults, err := platform.LoadConfig(path) + if err != nil { + return fmt.Errorf("le fichier de configuration %s ne peut pas être lu : %w", path, err) + } + + // A key control 20 refuses outright earns NO note of its own when Migrate has no + // translation to attempt for it: the six keys of the numbering plan are a pure + // refusal, unchanged since they entered the code already retired (configmigration.go), + // and migrationSteps never touches them. cfg.Retired() is the only place that survives + // for them, and it is also what ConfigStore.Save is about to consult -- so a key still + // there is folded into the very same accounting as the notes, under the same name a + // note would use, before Save is ever called. + retired := cfg.Retired() + named := make(map[string]bool, len(notes)) + for _, note := range notes { + named[note.Key] = true + } + for _, key := range retired { + if named[key] { + continue + } + notes = append(notes, domain.MigrationNote{ + Key: key, Action: domain.MigrationRefused, Message: domain.RetiredKeyReason(key), + }) + } + + if len(notes) == 0 && len(decodeFaults) == 0 { + fmt.Fprintf(out, "%s est déjà à la forme que ce binaire lit : rien à faire.\n", path) + return nil + } + + refused := 0 + if len(notes) > 0 { + fmt.Fprintf(out, "%s : %d changement(s).\n", path, len(notes)) + for _, note := range notes { + fmt.Fprintf(out, " %s\n", note) + if note.Action == domain.MigrationRefused { + refused++ + } + } + } + + // ConfigStore.Save calls cfg.RefuseIfRetired, and a MIXED file -- one point carried, + // one refused -- would reach it and be refused there too, only AFTER this command had + // already said it was writing. Leaving here, before Save is ever called, is what keeps + // « rien n'est écrit » true unconditionally, whether the refusal came with a note (an + // unconvertible discount, a file from a newer binary) or without one (the numbering + // plan, folded in just above). + if refused > 0 { + return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( + "%s comporte %d point(s) que ce binaire ne devine pas : le fichier n'est pas "+ + "modifié, tranchez-le puis relancez la migration", path, refused)} + } + + // A block that would not decode is the SAME suspension, for a worse reason. Block-by- + // block decoding replaces it with the one of the neutral profile so that the station + // still serves its fault list -- but that substitute is a plausible factory value + // NOBODY DECLARED, and writing it back would make it the shop's own. Measured on the + // delivered file with an unreadable `pricing` block: the members' 10 % discount + // disappeared, the command announced one unrelated change and exited 0, and update.ps1 + // runs it on its own after every successful update. + if len(decodeFaults) > 0 { + for _, fault := range decodeFaults { + fmt.Fprintf(out, " %s\n", fault.String()) + } + return &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( + "%s : le fichier n'est pas modifié, le réécrire poserait la configuration d'usine "+ + "%s. Corrigez-le, puis relancez la migration", + unreadablePart(path, decodeFaults), + (&domain.UnreadableBlocksError{Faults: decodeFaults}).InTheirPlace())} + } + + store, err := platform.NewConfigStore(path) + if err != nil { + return err + } + cfg.ModifiedAt = platform.NewSystemClock().Now() + if err := store.Save(context.Background(), cfg); err != nil { + return fmt.Errorf("%s n'a pas pu être réécrit : %w", path, err) + } + fmt.Fprintf(out, "%s réécrit ; la version d'avant est dans %s.1.\n", path, path) + fmt.Fprintf(out, "Redémarrez le service pour qu'il lise le fichier réécrit.\n") + return nil +} diff --git a/cmd/openscale/configwrite_test.go b/cmd/openscale/configwrite_test.go new file mode 100644 index 0000000..b642fb1 --- /dev/null +++ b/cmd/openscale/configwrite_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/web" +) + +// The three `config` actions that REWRITE the station's own file — migrate, password +// and recovery-code. What they share is the store: five rotating versions, an atomic +// landing, and a station that does not see the change before it restarts. + +// TestTheCommandLineOpensAStationNobodyCanLogInTo is the hole this command closes. +// +// The delivered configuration carries no password — §11.5 ships the values of the site, +// not the secrets of one station — so a station straight out of install.ps1 answers 409 on +// its login form, 409 on its recovery form and 401 on every route that writes. It was +// locked out of its own administration, and §14.4 names the way back in. +func TestTheCommandLineOpensAStationNobodyCanLogInTo(t *testing.T) { + path := copyDelivered(t) + if before := readJSONConfig(t, path).Admin.PasswordHash; before != "" { + t.Fatalf("la configuration livrée porte déjà un mot de passe : %q", before) + } + + out := &strings.Builder{} + if err := runConfig([]string{"password", path}, strings.NewReader("mot-de-passe-du-poste\n"), out); err != nil { + t.Fatalf("config password : %v", err) + } + + after := readJSONConfig(t, path) + if !web.VerifySecret(after.Admin.PasswordHash, "mot-de-passe-du-poste") { + t.Fatalf("empreinte écrite = %q : elle ne vérifie pas le mot de passe tapé", + after.Admin.PasswordHash) + } + // The station is asked to restart, because nothing re-reads config.json while the + // service runs. A command that stayed silent about it would be read as « c'est fait ». + if !strings.Contains(out.String(), "Redémarrez le service") { + t.Errorf("la commande ne dit pas qu'il faut redémarrer : %q", out.String()) + } + // And it touched ONE field: everything the delivered file carried is still there. + before := readJSONConfig(t, copyDelivered(t)) + after.Admin.PasswordHash, after.ModifiedAt = "", before.ModifiedAt + if after.Fingerprint() != before.Fingerprint() { + t.Error("la commande a changé autre chose que le mot de passe") + } +} + +// TestAPasswordTooShortIsRefusedByBOTHDoors: the floor is the same on the terminal and on +// the recovery form, or the rule would depend on which door somebody came through. +func TestAPasswordTooShortIsRefusedByBOTHDoors(t *testing.T) { + path := copyDelivered(t) + err := runConfig([]string{"password", path}, strings.NewReader("court\n"), &strings.Builder{}) + if err == nil { + t.Fatal("un mot de passe de cinq caractères a été accepté") + } + if hash := readJSONConfig(t, path).Admin.PasswordHash; hash != "" { + t.Fatal("un mot de passe refusé a tout de même été écrit") + } +} + +// TestTheRecoveryCodeIsPrintedOnceAndStoredHashed (§14.4, important-10). +// +// It is generated AT INSTALLATION, and install.ps1 has no way to produce an argon2id +// hash: this command is where the eight characters of the installation sheet come from. +func TestTheRecoveryCodeIsPrintedOnceAndStoredHashed(t *testing.T) { + path := copyDelivered(t) + out := &strings.Builder{} + if err := runConfig([]string{"recovery-code", path}, nil, out); err != nil { + t.Fatalf("config recovery-code : %v", err) + } + + code := codePrintedBy(t, out.String()) + if len(code) != web.RecoveryCodeLength { + t.Fatalf("code affiché = %q, attendu %d caractères", code, web.RecoveryCodeLength) + } + hash := readJSONConfig(t, path).Admin.RecoveryCodeHash + if !web.VerifySecret(hash, code) { + t.Fatalf("l'empreinte écrite ne vérifie pas le code affiché %q", code) + } + // The clear code is nowhere in the file: the only copy is the printed sheet. + if raw, err := os.ReadFile(path); err != nil || strings.Contains(string(raw), code) { + t.Fatal("le code de secours est écrit en clair dans la configuration") + } + + // Drawn a second time, it says what it costs: the sheet already in the folder is wrong. + second := &strings.Builder{} + if err := runConfig([]string{"recovery-code", path}, nil, second); err != nil { + t.Fatalf("second tirage : %v", err) + } + if !strings.Contains(second.String(), "ne fonctionne plus") { + t.Errorf("un second tirage ne prévient pas que l'ancien code est mort : %q", second.String()) + } + if web.VerifySecret(readJSONConfig(t, path).Admin.RecoveryCodeHash, code) { + t.Error("le premier code de secours ouvre encore la porte") + } +} + +// codePrintedBy reads the eight characters out of what the command said. +func codePrintedBy(t *testing.T, printed string) string { + t.Helper() + const marker = "Code de secours de ce poste : " + index := strings.Index(printed, marker) + if index < 0 { + t.Fatalf("le code de secours n'est pas affiché : %q", printed) + } + return strings.TrimSpace(strings.SplitN(printed[index+len(marker):], "\n", 2)[0]) +} + +// TestMigrateWritesOnceAndSaysSoTheSecondTime: the command is what update.ps1 and update.sh +// call, so running it twice on the same station -- two updates in a row -- must be a +// no-operation the second time, and must not rotate config.json.1 over a version that +// mattered. +func TestMigrateWritesOnceAndSaysSoTheSecondTime(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "config.json") + if err := os.WriteFile(path, []byte( + `{"version":1,"station":{"number":2},"ui":{"tile_size":"large"}}`), 0o644); err != nil { + t.Fatalf("écriture : %v", err) + } + + var first bytes.Buffer + if err := runConfig([]string{"migrate", path}, nil, &first); err != nil { + t.Fatalf("première migration : %v", err) + } + if !strings.Contains(first.String(), "tile_size") { + t.Errorf("la première migration ne dit pas ce qu'elle a changé :\n%s", first.String()) + } + if _, err := os.Stat(path + ".1"); err != nil { + t.Errorf("la version d'avant n'a pas été gardée : %v", err) + } + + migrated, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture : %v", err) + } + + var second bytes.Buffer + if err := runConfig([]string{"migrate", path}, nil, &second); err != nil { + t.Fatalf("seconde migration : %v", err) + } + if !strings.Contains(second.String(), "rien à faire") { + t.Errorf("la seconde migration n'annonce pas qu'elle ne fait rien :\n%s", second.String()) + } + again, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture : %v", err) + } + if string(again) != string(migrated) { + t.Errorf("la seconde migration a réécrit le fichier :\n%s\n%s", migrated, again) + } +} + +// TestMigrateLeavesARefusedKeyInPlace: what this binary will not guess stays where it is, +// and the command says so with a non-zero status so update.ps1 can show it. +func TestMigrateLeavesARefusedKeyInPlace(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte( + `{"version":1,"barcode":{"weight_decimals":3}}`), 0o644); err != nil { + t.Fatalf("écriture : %v", err) + } + + var out bytes.Buffer + err := runConfig([]string{"migrate", path}, nil, &out) + if err == nil { + t.Fatal("une clé refusée doit donner un code de retour non nul") + } + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("relecture : %v", readErr) + } + if !strings.Contains(string(after), "weight_decimals") { + t.Errorf("la clé refusée a été retirée quand même : %s", after) + } +} + +// TestMigrateMixesTwoKindsOfRefusalWithoutDuplicating watches a COINCIDENCE, not a rule: +// migrateConfig's deduplication between Migrate's notes and cfg.Retired() works because +// carryCoefficientToDiscount (configmigration.go) and scanRetired (config.go) build the +// SAME dotted path for the same field -- two independent functions that never cite one +// another. If either one changes its template one day without the other following, the +// deduplication breaks IN SILENCE: a key would show up twice, under two different +// messages, with no test noticing. This one mixes both families of refusal in the same +// file to catch that. +// +// pricing.tiers[0].coef_num AND pricing.tiers[0].coef_den both stay in the document +// (carryCoefficientToDiscount only removes them on the branch that succeeds), so +// scanRetired finds both of them -- but Migrate only ever wrote ONE note, under +// "pricing.tiers[0].coef_num". The deduplication therefore cancels only that one: coef_den +// gets its own line, with its own reason (ADR-034, "there is no more denominator"). That is +// NOT a duplicated message -- it is a third point, on a third JSON key that genuinely still +// stands -- and this test pins that too, so a future reader does not mistake it for an +// unfixed bug. +func TestMigrateMixesTwoKindsOfRefusalWithoutDuplicating(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := []byte(`{"version":1,"pricing":{"tiers":[ + {"code":"X","label":"X","abbrev":"X","coef_num":3,"coef_den":7,"rank":1} + ]},"barcode":{"weight_decimals":3}}`) + if err := os.WriteFile(path, before, 0o644); err != nil { + t.Fatalf("écriture : %v", err) + } + + var out bytes.Buffer + err := runConfig([]string{"migrate", path}, nil, &out) + if err == nil { + t.Fatal("un fichier portant deux familles de clés refusées doit rendre un code non nul") + } + + printed := out.String() + for _, key := range []string{ + "pricing.tiers[0].coef_num", "pricing.tiers[0].coef_den", "barcode.weight_decimals", + } { + if count := strings.Count(printed, key); count != 1 { + t.Errorf("%s apparaît %d fois dans la sortie, attendu 1 :\n%s", key, count, printed) + } + } + if !strings.Contains(printed, "3 changement(s)") { + t.Errorf("le compte de points refusés n'est pas 3 (coef_num, coef_den, weight_decimals) :\n%s", printed) + } + if !strings.Contains(err.Error(), "3 point(s)") { + t.Errorf("l'erreur ne nomme pas les 3 points refusés : %v", err) + } + + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("relecture : %v", readErr) + } + if string(after) != string(before) { + t.Errorf("le fichier a été modifié alors qu'un point reste refusé :\navant : %s\naprès : %s", + before, after) + } +} diff --git a/cmd/openscale/corpus.go b/cmd/openscale/corpus.go new file mode 100644 index 0000000..6d65858 --- /dev/null +++ b/cmd/openscale/corpus.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "io" + "strconv" + "time" + + "openscale/internal/domain" +) + +// This file writes the LIVING CORPUS of §15.4 — one frame per line, exactly the bytes +// the scale sent, each preceded by its delay since the first. THE FORMAT IS DEFINED BY +// internal/scale/replay, which reads it back; this is the other half of that one +// contract. + +// corpusWriter writes the LIVING CORPUS format of §15.4: +// +// # openscale capture — COM8, 2026-07-25 +// @0 ST,GS,+ 1.236KG +// @412 ST,GS,+ 0.850KG +// +// One frame per line, exactly the bytes the scale sent, terminator included, optionally +// preceded by "@ " -- the delay since the FIRST frame, separated by ONE space. +// +// THE FORMAT IS DEFINED BY internal/scale/replay, NOT HERE. That package parses it for +// `openscale replay`, for the « Rejouer cette trame » button of the journal and for the +// tests; this writer is the other half of that one contract, and the round trip is +// frozen by a test. Two readers of the living corpus is the failure worth avoiding: +// the corpus is only permanent evidence if everything reads it the same way. +// +// WHY '@' rather than a bare number: the grammar of §9.2 lets a frame begin with a +// status letter, a sign, a blank OR A DIGIT, so a leading number could be a timestamp +// or the start of a frame. '@' can be neither, and needs no rule to tell them apart. +// +// WHY the offset is worth its bytes: without it a replay can only space the frames at +// the NOMINAL cadence, and the "median cadence measured" of the L3 criterion would be +// the nominal rate handed straight back -- the very figure §21 n° 3 exists to replace. +// A file with no timestamp cannot measure a cadence, and replay says so instead of +// printing a plausible number. +// +// The only byte the writer does not reproduce verbatim is a lone CR terminator, which +// becomes CR LF so the file stays line-oriented. No scale of this parc sends one. +type corpusWriter struct { + to io.Writer + // cut is the decoder of the captured protocol, asked ONE question: where does the + // frame at the head of these bytes end. It is the same decoder the summary decodes + // with, which is the point — a file cut by one grammar and counted by another is how + // a capture came back announcing 194 frames over a file holding none. + cut domain.Decoder + // pending holds the bytes of a frame whose end has not arrived yet. + pending []byte + // origin is the instant of the FIRST line, which is t = 0 of the file. Offsets are + // relative so that a capture is self-contained and two captures are comparable. + origin time.Time + // lines counts the frames written. + lines int +} + +// header writes the comment lines that make a capture file self-describing. +// +// Self-describing because it outlives the session that produced it: a file that lands +// in the living corpus in six months has to say which port, which link settings and +// which day it came from, and a support archive (diagnostic.zip) carries it with no +// context at all. +func (w *corpusWriter) header(req captureRequest, start time.Time) error { + _, err := fmt.Fprintf(w.to, + "# openscale capture — %s · %d bauds %d%s%d · %s · durée demandée %s\n"+ + "# "+protocolMarker+"%s\n"+ + "# Corpus vivant (§15.4) : une trame par ligne, telle que la balance l'a émise.\n"+ + "# « @ » en tête de ligne porte l'écart en millisecondes depuis la PREMIÈRE\n"+ + "# trame. Sans ce marqueur la ligne est la trame entière — c'est le format des\n"+ + "# fichiers déjà présents dans internal/scale/testdata/frames/.\n"+ + "# Toute ligne commençant par # est un commentaire.\n", + req.link.Port, req.link.Baud, captureBits, captureParity, captureStop, + start.UTC().Format(time.RFC3339), req.duration, req.protocol) + return err +} + +// protocolMarker is the header line that names the grammar a capture was cut with. +// +// It exists so that `openscale replay` reads the protocol off the FILE instead of +// guessing: the two commands are one round trip, and a capture that did not say which +// grammar produced it would have to be replayed on the memory of whoever ran it. It is +// written as a comment, so every reader of the format already skips it. +const protocolMarker = "protocole : " + +// feed appends the bytes of one read and writes every line the stream now completes. +// +// now is the instant of that read, and it becomes the offset of every frame it +// completes: the resolution of the file is one read, which is exactly the resolution +// the driver itself has. +func (w *corpusWriter) feed(p []byte, now time.Time) error { + w.pending = append(w.pending, p...) + for { + // The DECODER OF THE CAPTURED PROTOCOL and NOT a terminator search of our own: a + // GRAM XFOC PLUS delimits with control codes and sends no CR or LF at all, so a + // writer that looked for line endings wrote a file with no frames in it while the + // summary above it counted 194. Asking the decoder rather than one package's + // function is what lets this command write the corpus of a protocol whose frames + // carry no delimiter at all. + consumed := w.cut.FrameEnd(w.pending) + if consumed < 0 { + return nil + } + line := w.pending[:consumed] + w.pending = w.pending[consumed:] + if err := w.writeLine(line, now); err != nil { + return err + } + } +} + +// finish writes whatever the last read left unterminated, as a COMMENT. +// +// As a comment because a fragment is not a frame. Writing "ST,GS,+ 1.2" as a line of +// the corpus would add to the permanent tests a frame no scale ever sent, and turning +// a truncated frame into a mass is the one thing frame.Parse exists to refuse. It is +// kept, quoted, because it is evidence: it is precisely the artefact of the 18-byte +// read that fills degraded-18-byte-read.txt. +func (w *corpusWriter) finish() error { + if len(w.pending) == 0 { + return nil + } + _, err := fmt.Fprintf(w.to, "# fin de capture, trame incomplète et donc NON rejouée : %q\n", w.pending) + w.pending = nil + return err +} + +// writeLine writes one frame with its offset, adding the single byte that keeps the +// file line-oriented and nothing else. +func (w *corpusWriter) writeLine(line []byte, now time.Time) error { + if len(trimTerminator(line)) == 0 { + return nil // a bare terminator carries no frame + } + if w.lines == 0 { + w.origin = now + } + w.lines++ + + // "@ " then the frame VERBATIM. Exactly one separator, because a frame of this + // grammar may legitimately begin with a blank -- " 0.996kg" is one of the corpus -- + // and eating a second space would change the mass. + out := make([]byte, 0, len(line)+16) + out = append(out, '@') + out = strconv.AppendInt(out, now.Sub(w.origin).Milliseconds(), 10) + out = append(out, ' ') + out = append(out, line...) + if out[len(out)-1] != '\n' { + out = append(out, '\n') + } + _, err := w.to.Write(out) + return err +} + +// trimTerminator returns the line without its trailing CR, LF or CRLF. +// +// It is the last piece of line-ending knowledge left in this command, and it is only ever +// applied to a frame the DECODER has already cut: it strips the bytes a text protocol +// ends on so that a corpus line stays line-oriented, and does nothing at all to a +// transmission delimited by control codes. Its companion — a search for the first CR or +// LF, which is how this command used to decide where a frame ENDED — is gone, because +// that decision belongs to the grammar and to nothing else. +func trimTerminator(line []byte) []byte { + for len(line) > 0 && (line[len(line)-1] == '\n' || line[len(line)-1] == '\r') { + line = line[:len(line)-1] + } + return line +} diff --git a/cmd/openscale/corpuswriter_test.go b/cmd/openscale/corpuswriter_test.go new file mode 100644 index 0000000..a3a9af7 --- /dev/null +++ b/cmd/openscale/corpuswriter_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "openscale/internal/fake" + "openscale/internal/scale/serial" +) + +// The file a capture WRITES — the living corpus of §15.4. It has to be self-describing, +// it must never turn a truncated frame into a line somebody replays, it must not split a +// CRLF across two lines, and it must give up loudly rather than silently when it cannot +// write. + +// TestCaptureFileIsSelfDescribing: a capture outlives the session that produced it. +// It lands in the living corpus months later, or inside a diagnostic.zip with no +// context at all, and it has to say which port, which link settings and which day it +// came from. +func TestCaptureFileIsSelfDescribing(t *testing.T) { + clock := fake.NewClock(captureStart) + _, file, _ := runCaptureOnScript(t, emitting(clock, 3), clock, 5*time.Second, true) + + const wantHeader = "# openscale capture — COM8 · 9600 bauds 8N1 · 2026-07-25T09:30:00Z · durée demandée 5s" + if !strings.HasPrefix(file, wantHeader) { + t.Errorf("en-tête inattendu :\n%s", file) + } + for _, want := range []string{ + "# Corpus vivant (§15.4)", + "# Toute ligne commençant par # est un commentaire.", + } { + if !strings.Contains(file, want) { + t.Errorf("le fichier ne se décrit pas : %q absent de\n%s", want, file) + } + } +} + +// TestCaptureKeepsAnUnterminatedFrameAsAComment: a fragment is not a frame. Writing +// "ST,GS,+ 1.2" as a line of the corpus would add a frame no scale ever sent to the +// permanent tests, and turning a truncated frame into a mass is the one thing +// frame.Parse exists to refuse. It is kept, quoted, because it is evidence. +func TestCaptureKeepsAnUnterminatedFrameAsAComment(t *testing.T) { + clock := fake.NewClock(captureStart) + stream := newScriptedStream(clock, + scriptedRead{after: cadence, data: nominalFrame}, + scriptedRead{after: cadence, data: "ST,GS,+ 1.2"}, + ) + _, file, path := runCaptureOnScript(t, stream, clock, 5*time.Second, true) + + if !strings.Contains(file, `# fin de capture, trame incomplète et donc NON rejouée : "ST,GS,+ 1.2"`) { + t.Errorf("le reliquat n'a pas été conservé en commentaire :\n%s", file) + } + if strings.Contains(file, "@412 ST,GS,+ 1.2\n") { + t.Errorf("le reliquat a été écrit comme une trame :\n%s", file) + } + // And replaying it back decodes the one frame that was whole, and only that one. + var out bytes.Buffer + if err := runReplay([]string{path, "--quiet"}, &out); err != nil { + t.Fatalf("runReplay : %v", err) + } + requireLine(t, out.String(), "1 trame décodée sur 1 ligne, 0 resynchronisation") +} + +// TestCaptureDoesNotSplitACRLFAcrossTwoLines: a terminator delivered in two reads is +// still ONE terminator, exactly as frame.Accumulator treats it. The opposite would +// double the line count of every capture taken on a busy machine. +func TestCaptureDoesNotSplitACRLFAcrossTwoLines(t *testing.T) { + clock := fake.NewClock(captureStart) + stream := newScriptedStream(clock, + scriptedRead{after: cadence, data: "ST,GS,+ 1.236KG\r"}, + scriptedRead{after: 2 * time.Millisecond, data: "\nST,GS,+ 0.850KG\r\n"}, + ) + screen, file, _ := runCaptureOnScript(t, stream, clock, 5*time.Second, true) + + requireLine(t, screen, "2 trames décodées sur 2 lignes, 0 resynchronisation") + if got := strings.Count(file, "\n"); got != 9 { + t.Errorf("%d lignes dans le fichier, 7 de commentaire + 2 de trame attendues :\n%s", got, file) + } + if !strings.Contains(file, "@0 ST,GS,+ 1.236KG\r\n") { + t.Errorf("la trame coupée n'a pas été recollée :\n%q", file) + } +} + +// failingWriter is a disk that fills up in the middle of a capture. +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +// TestCorpusWriterGivesUpLoudlyWhenItCannotWrite. A capture that lost frames to a +// full disk and said nothing would produce a corpus file that LOOKS complete, and the +// cadence measured from it would be a fiction -- the exact failure the living corpus +// exists to make impossible. +func TestCorpusWriterGivesUpLoudlyWhenItCannotWrite(t *testing.T) { + _, decoder := benchProtocol(t) + writer := &corpusWriter{to: failingWriter{err: errors.New("disque plein")}, cut: decoder} + if err := writer.feed([]byte(nominalFrame), captureStart); err == nil { + t.Error("une trame perdue n'a pas été signalée") + } + // A fragment waits for its terminator, so feeding it writes nothing; it is finish + // that has to fail on it. + if err := writer.feed([]byte("ST,GS,+ 1.2"), captureStart); err != nil { + t.Errorf("une trame incomplète a été écrite avant son terminateur : %v", err) + } + if err := writer.finish(); err == nil { + t.Error("un reliquat perdu n'a pas été signalé") + } + if err := writer.header(captureRequest{link: serial.Options{Port: "COM8"}}, captureStart); err == nil { + t.Error("un en-tête perdu n'a pas été signalé") + } +} diff --git a/cmd/openscale/detect.go b/cmd/openscale/detect.go new file mode 100644 index 0000000..93b5657 --- /dev/null +++ b/cmd/openscale/detect.go @@ -0,0 +1,424 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + "openscale/internal/domain" + "openscale/internal/scale" + "openscale/internal/scale/serial" + "openscale/internal/web" +) + +// This file is the detection of §14.4: it opens ONE port, listens for a bounded +// window, feeds the same bytes to every protocol this binary carries, and says what +// answered — « COM8 : 12 trames valides, GRAM XFOC ». It is the detection that answers +// « y a-t-il une balance ? », never the operator. + +// detectWindow is how long a detection listens on one port: three seconds, the figure +// §11.4 gives the « Appliquer et tester » step. +// +// It is spent on the INJECTED clock, so a test of the detection runs in microseconds and +// a real one takes the three seconds a volunteer is standing there for. +const detectWindow = 3 * time.Second + +// captureCeiling bounds what the capture route may ask for. +// +// Sixty seconds: long enough to catch an intermittent cable, short enough that an HTTP +// handler is never held for a minute — the volunteer pressed a button and is watching a +// spinner. The half-hour campaign of §21 n° 3 is `openscale capture`, on the command +// line, where nobody is waiting. +const captureCeiling = 60 * time.Second + +// framesKept is how many raw frames a detection reports back. +// +// Twenty, which is the « visualiseur des 20 dernières trames brutes » of §14.4. A +// three-second window at the nominal cadence yields about eight, so the ceiling only +// bites on a scale that babbles — and there, twenty lines is already the diagnosis. +const framesKept = 20 + +// bytesKept bounds the raw stream a listening window holds on to before it is cut into +// frames. +// +// The cut happens at the END and not read by read, because which protocol's cut applies +// is only known once something has been recognised. Sixty-four kibibytes is more than a +// minute at 9600 bauds — the whole of captureCeiling — so on the hardware of this parc +// nothing is ever dropped; what the bound really covers is a device babbling on a wrong +// bitrate, and there the TAIL is what a diagnosis wants anyway. +const bytesKept = 64 * 1024 + +// DetectScale opens one port, applies the parser and says what answered — « COM8 : +// 12 trames valides, GRAM XFOC » (§14.4). +// +// # It is the detection that answers « y a-t-il une balance ? », not the operator +// +// That is the sentence §14.4 uses, and it is why this route exists at all: an operator +// choosing a protocol from a drop-down list is guessing, and a station configured on a +// guess fails at the counter. Here the port is opened, the grammar of §9.2 is applied to +// what comes out of it, and the answer is a count. +// +// # A refusal has to say WHICH refusal it is +// +// On Windows a port already held by the scale of this station cannot be opened a second +// time, and « accès refusé » alone would send a volunteer hunting for a permission +// problem that does not exist. That hint is only true of a refusal that came from the +// SYSTEM, though: link settings no port could accept are named — with the key of +// scale.options to correct — before anything is opened, and must never reach the screen +// as a port somebody else is holding. +func (h adminHardware) DetectScale(ctx context.Context, port string) (web.ScaleDetection, error) { + if strings.TrimSpace(port) == "" { + return web.ScaleDetection{}, errors.New("indiquez le port à écouter : COM8, /dev/balance-serial") + } + candidates := h.serialCandidates() + if len(candidates) == 0 { + // Nothing to recognise WITH, so nothing is opened: a serial port is exclusive, and + // holding one for three seconds to hand back silence is the answer that sends a + // volunteer looking for a cable. The screen says what to do instead. + return web.ScaleDetection{Port: port, Message: h.nothingDetectable(port)}, nil + } + + reading, err := h.listen(ctx, port, detectWindow, candidates) + if err != nil { + return web.ScaleDetection{}, err + } + recognised := reading.recognised() + + report := web.ScaleDetection{ + Port: port, ValidCount: reading.validCount(), Frames: reading.frames(), + } + switch { + case len(recognised) > 0: + // The protocol the FRAMES named, never the first entry of a registry. What goes + // into the form is the driver that recognised what came out of the cable, and the + // sentence names every model that recognised the same stream — which is what the + // two GRAM entries do, since they share one grammar and differ only by the sticker. + report.Driver = recognised[0].Descriptor.ID + report.Message = fmt.Sprintf("%s : %d trame(s) valide(s) en %s — %s.", + port, report.ValidCount, detectWindow, modelsRecognising(recognised)) + case reading.read > 0: + // Bytes, but no frame: something is talking on that port and it is not a scale + // this binary understands. That is a different remedy — bitrate, or another + // device on the same cable — and it must not be reported as silence. + report.Message = fmt.Sprintf("%s : %d octet(s) reçus, aucune trame reconnue. "+ + "Vérifiez la vitesse de la liaison, ou l'appareil branché sur ce port.", port, reading.read) + default: + report.Message = fmt.Sprintf("%s : aucun octet reçu en %s. La balance est-elle "+ + "allumée, et le câble branché sur ce port ?", port, detectWindow) + } + return report, nil +} + +// CaptureFrames records the raw frames of one port for a bounded duration, which is what +// a support call needs: the bytes, not our reading of them (§14.4, §15.4). +func (h adminHardware) CaptureFrames(ctx context.Context, port string, d time.Duration) ([]string, error) { + if strings.TrimSpace(port) == "" { + return nil, errors.New("indiquez le port à écouter : COM8, /dev/balance-serial") + } + candidates := h.serialCandidates() + if len(candidates) == 0 { + // Where a frame ENDS is a fact about a protocol, so a binary with no protocol has + // no way to cut a stream into frames. Saying so is the honest answer; handing back + // an empty list would look like a silent cable. + return nil, errors.New(h.nothingDetectable(port)) + } + if d <= 0 || d > captureCeiling { + d = detectWindow + } + reading, err := h.listen(ctx, port, d, candidates) + if err != nil { + return nil, err + } + return reading.frames(), nil +} + +// serialCandidates is one fresh decoder per protocol that declares it can be recognised +// on a serial port. +// +// THE ENUMERATION IS OURS, THE RECOGNITION IS THEIRS. Which ports this machine has is a +// question for the operating system, answered by Ports in hardware.go; what a frame of a +// given protocol looks like is a question for the driver, and it answers it by handing +// over a decoder. Between the two there is nothing left here that has to change when a +// model is added. +func (h adminHardware) serialCandidates() []scale.Candidate { + if h.scales == nil { + return nil + } + return h.scales.Candidates(scale.EndpointSerialPort) +} + +// nothingDetectable is what the screen reads when no protocol of this binary knows how to +// be recognised by listening to a port. +// +// It is a LEGITIMATE outcome and not a fault: a scale that only speaks when it is polled +// cannot be found by listening, and a driver saying so is more useful than a button whose +// only possible answer is silence (§9.3, ADR-025). The sentence therefore ends on what to +// do — choose the protocol by hand — and names the ones that exist, because « aucun » with +// no list is how a volunteer concludes the machine is broken. +func (h adminHardware) nothingDetectable(port string) string { + labels := make([]string, 0, len(h.registries.Scales)) + for _, descriptor := range h.registries.Scales { + labels = append(labels, descriptor.Label) + } + if len(labels) == 0 { + return fmt.Sprintf("%s : aucun protocole n'est embarqué dans ce binaire, il n'y a "+ + "rien à détecter.", port) + } + return fmt.Sprintf("%s : aucun protocole de ce binaire ne sait se détecter en écoutant "+ + "un port série. Choisissez-le à la main dans la liste : %s.", + port, strings.Join(labels, ", ")) +} + +// portReading is everything one listening window observed on a port. +type portReading struct { + // read is how many bytes arrived, all of them, which is what tells silence from a + // device talking a language nobody here speaks. + read int + // raw is the tail of the stream, kept verbatim so that it can be cut into frames once + // it is known WHICH protocol's cut applies. Bounded by bytesKept. + raw []byte + // candidates are the protocols that were tried, in registration order, each holding + // the decoder it was tried with and what that decoder made of the stream. + candidates []candidateReading +} + +// candidateReading is what one protocol made of the stream. +type candidateReading struct { + scale.Candidate + // count is how many measurements this protocol's decoder yielded. Zero means it did + // not recognise the stream, which is an answer about the stream and not about the + // driver. + count int +} + +// recognised reports the protocols whose decoder yielded at least one measurement, in +// registration order. +// +// SEVERAL is the normal case of this parc and not an ambiguity to resolve: the GRAM XFOC +// RS and the GRAM XFOC + share one grammar, so both recognise the same bytes, and the +// choice between them is read off the sticker (§9.3). Picking one and staying quiet about +// the other is what the screen may not do. +func (r portReading) recognised() []candidateReading { + var out []candidateReading + for _, candidate := range r.candidates { + if candidate.count > 0 { + out = append(out, candidate) + } + } + return out +} + +// validCount is how many frames the detection announces, which is the count of the +// protocol that recognised the most. +func (r portReading) validCount() int { + best := 0 + for _, candidate := range r.candidates { + if candidate.count > best { + best = candidate.count + } + } + return best +} + +// frames cuts the stream into the raw frames the viewer of §14.4 shows. +// +// It is cut by the decoder that RECOGNISED, and by the first candidate when none did. +// That second case is a presentation choice and claims nothing: the bytes are shown so +// that somebody can read them out on the telephone, and slicing them on a grammar that +// refused them is still more legible than one line of 3 000 characters. +// +// It asks the decoder rather than searching for CR or LF, and that is the whole point of +// the method being on the decoder: a GRAM XFOC PLUS delimits with control codes and sends +// no line ending at all, so this viewer showed NOTHING on the very hardware of the bench +// while announcing twelve valid frames beside it — the defect that cost the capture of +// 29/07, one storey up. +func (r portReading) frames() []string { + decoder := r.cutter() + if decoder == nil { + return nil + } + var frames []string + for pending := r.raw; len(pending) > 0; { + end := decoder.FrameEnd(pending) + if end < 0 { + break // the rest of this frame never arrived + } + line := trimTerminator(pending[:end]) + pending = pending[end:] + if len(bytes.TrimSpace(line)) == 0 { + continue + } + frames = append(frames, string(line)) + if len(frames) > framesKept { + frames = frames[len(frames)-framesKept:] + } + } + return frames +} + +// cutter is the decoder whose cut the frame viewer shows. +func (r portReading) cutter() domain.Decoder { + if recognised := r.recognised(); len(recognised) > 0 { + return recognised[0].Decoder + } + if len(r.candidates) > 0 { + return r.candidates[0].Decoder + } + return nil +} + +// listen opens the port, reads for one window and reports what every candidate protocol +// made of what came out of it. +// +// It NEVER RECONNECTS, exactly like `openscale capture` and for the same reason: a +// cadence measured across an outage describes the outage. A link that drops ends the +// listening, and everything read up to then is still reported — the frames ARE the +// diagnosis, and a lost cable is not a failure of this function. +// +// Every candidate is fed the SAME bytes, each into its own decoder. Feeding them one +// after another would need the port opened once per protocol, on a link that is exclusive +// and a scale that is not repeating itself. +func (h adminHardware) listen(ctx context.Context, port string, window time.Duration, + candidates []scale.Candidate) (portReading, error) { + link, err := h.linkFor(port) + if err != nil { + return portReading{}, err + } + stream, err := link.Open(link) + if err != nil { + // Every reason the LINK itself could be unusable has already been named by linkFor, + // with the key of scale.options to correct, so what is left here comes from the + // system — and that is what makes the two causes below the only two. Whoever adds a + // check upstream of this call must name its own refusal there, or a settings mistake + // will once again reach a volunteer as a port that somebody else is holding. + return portReading{}, fmt.Errorf("le port %s n'a pas pu être ouvert : %w. Deux causes "+ + "possibles : un autre programme le tient — la balance de ce poste en premier, "+ + "un port série est EXCLUSIF sous Windows — ou bien ce port n'existe plus sur "+ + "cette machine", port, err) + } + defer stream.Close() + + reading := portReading{candidates: make([]candidateReading, 0, len(candidates))} + for _, candidate := range candidates { + reading.candidates = append(reading.candidates, candidateReading{Candidate: candidate}) + } + + deadline := h.clock.Now().Add(window) + buffer := make([]byte, defaultReadBuffer) + for h.clock.Now().Before(deadline) { + if ctx.Err() != nil { + // The browser gave up, or the budget of the handler ran out. What was read is + // still worth reporting: the caller decides, and a cancelled context is not a + // reason to throw eight frames away. + break + } + // The Opener contract requires this Read to BLOCK until bytes arrive or its own + // timeout elapses (internal/scale/serial): a Read returning (0, nil) at once would + // make this loop spin. + n, readErr := stream.Read(buffer) + if n > 0 { + now := h.clock.Now() + reading.read += n + reading.raw = keepTail(append(reading.raw, buffer[:n]...), bytesKept) + for i := range reading.candidates { + reading.candidates[i].count += len(reading.candidates[i].Decoder.Feed(buffer[:n], now)) + } + } + if readErr != nil { + break + } + } + return reading, nil +} + +// keepTail bounds a growing buffer by dropping from the FRONT. +// +// From the front because the viewer shows the LAST frames: on a device that babbles, what +// is worth reading is what it is saying now. +func keepTail(data []byte, limit int) []byte { + if len(data) <= limit { + return data + } + return append([]byte(nil), data[len(data)-limit:]...) +} + +// linkFor is the link a detection listens on: the settings THIS station declares, +// completed by the defaults of the parc, on the port being probed. +// +// # The completion is written here on purpose +// +// A link assembled field by field carried no bitrate, no character size, no parity and no +// stop bits, and the real opener refuses such a link before it reaches the device: the +// detection could not succeed on any port of any machine, and every port of a scan came +// back accused of being taken. Calling serial.Options.Complete at the place where the +// port is BOUND is what makes that visible to the next reader, and what lets the caller +// tell a refusal of these settings from a refusal of the system. +// +// # The port always wins over the configuration +// +// A scan probes the ports of the machine one after the other. Taking the port from the +// configuration would interrogate the same one N times and report the other N-1 as silent. +func (h adminHardware) linkFor(port string) (serial.Options, error) { + link, err := h.declaredLink() + if err != nil { + return serial.Options{}, fmt.Errorf("les réglages série de ce poste sont refusés, "+ + "corrigez-les avant de détecter : %w", err) + } + link.Port = port + link.Clock = h.clock + if h.open != nil { + link.Open = h.open + } + return link, nil +} + +// declaredLink reads the link settings this station declares and completes them. +// +// A station that has declared nothing yet — the one being installed, which is precisely +// when this route is used — falls back on the defaults of the parc. A station that +// declares 19200 bauds is listened to at 19200: at the figure of the parc its scale would +// answer bytes that decode to nothing, and the screen would send somebody to check a +// cable that is fine. +func (h adminHardware) declaredLink() (serial.Options, error) { + var declared domain.DriverOptions + if h.config != nil { + declared = h.config().Scale.Options + } + link, err := serial.ParseOptions(declared) + if err != nil { + return serial.Options{}, err + } + return link.Complete() +} + +// defaultReadBuffer is how many bytes one read may hand back. +// +// The same 4 KiB as the production loop, and NOT the 16 of the legacy SetupComm: a queue +// smaller than one 18-byte frame is one of the two reasons the legacy corpus is full of +// half frames (§9.1). +const defaultReadBuffer = 4096 + +// modelsRecognising names the models whose decoder recognised the frames that were read. +// +// It is built from WHAT ANSWERED and no longer from the whole registry, and the two are +// the same sentence only as long as one grammar exists. The moment a second protocol is +// registered, « même décodeur » becomes false of the pair — and the honest list is the +// one the bytes themselves drew. +// +// Several models is the normal case here: the GRAM XFOC RS and the GRAM XFOC + are one +// grammar and two stickers, the frames cannot tell them apart, and the sentence says so +// instead of picking one (§9.3). +func modelsRecognising(recognised []candidateReading) string { + labels := make([]string, 0, len(recognised)) + for _, candidate := range recognised { + labels = append(labels, candidate.Descriptor.Label) + } + if len(labels) == 1 { + return labels[0] + } + return strings.Join(labels, " ou ") + " : même décodeur, le choix se lit sur l'étiquette " + + "de la balance (§9.3)" +} diff --git a/cmd/openscale/doctor.go b/cmd/openscale/doctor.go index 82dac4b..06e6ca2 100644 --- a/cmd/openscale/doctor.go +++ b/cmd/openscale/doctor.go @@ -273,7 +273,6 @@ Code de retour : 0 quand aucun contrôle n'est en échec, 1 sinon. // of the same facts, and the day the two disagree nobody would know which one to believe. func newStationDiagnostic(o serveOptions, clock platform.SystemClock, address string, registries domain.Registries, db *store.DB) (*diag.Bundle, error) { - doctor, err := diag.New(diag.Options{ Clock: clock, ConfigPath: o.configPath, diff --git a/cmd/openscale/drivers_test.go b/cmd/openscale/drivers_test.go index 3eab4a8..fd7ad9f 100644 --- a/cmd/openscale/drivers_test.go +++ b/cmd/openscale/drivers_test.go @@ -2,22 +2,26 @@ package main import ( "encoding/json" - "reflect" "strings" "testing" "openscale/internal/domain" "openscale/internal/fake" "openscale/internal/printing" - printerexample "openscale/internal/printing/example" "openscale/internal/printing/raster" "openscale/internal/printing/transport" - "openscale/internal/scale" - scaleexample "openscale/internal/scale/example" "openscale/internal/scale/gramxfoc" "openscale/internal/station/ports" ) +// What THIS binary was built with, and what it can actually build out of it: the +// delivered configuration validates, every printer and every scale of the registry is +// constructible, a driver that declares a transport receives one, and the neutral +// profile of §11.3 gets a real printer. +// +// The completeness of a registry ENTRY is checked in registry_test.go; the two example +// drivers of docs/07 are in exampledrivers_test.go. + // TestTheDeliveredConfigurationValidatesAgainstThisBinary is the test that keeps the // option schema of a driver and the file a station really runs on from drifting apart. // @@ -300,312 +304,6 @@ func TestChaqueTypeDImprimanteDuDomaineEstConstructible(t *testing.T) { // These tests therefore read the DESCRIPTORS and nothing else, which is exactly what // those three readers see. -// schemaExemptions names the drivers allowed to declare no option at all, one by one and -// with the reason, because an empty schema is otherwise the mark of a driver whose -// configuration nobody wired: the administration screen generates a form with no field -// and control 7 of §11.3 refuses every key the file carries for it. -// -// A LIST OF NAMES, deliberately, and short. Anybody adding to it is stating that a driver -// takes NOTHING from a configuration, which is a design decision and not a shortcut past -// a red test. -var schemaExemptions = map[string]string{ - domain.PrinterPreview: "le profil neutre ne porte AUCUN printer.options — noirceur, vitesse " + - "et nombre de copies se règlent sur une vraie impression — donc le driver sur lequel un " + - "poste en configuration d'usine retombe ne doit en réclamer aucun (§11.3)", -} - -// TestChaqueEntreeDuRegistreEstComplete. -// -// One sub-test per registered driver, on both registries, checking what a driver DECLARES -// rather than what it does. -func TestChaqueEntreeDuRegistreEstComplete(t *testing.T) { - scales, printers := scaleRegistry().Descriptors(), printerRegistry().Descriptors() - if len(scales) == 0 || len(printers) == 0 { - t.Fatalf("%d protocole(s) et %d driver(s) d'impression enregistrés : un registre vide "+ - "rend ce test muet", len(scales), len(printers)) - } - - for _, descriptor := range scales { - t.Run("balance/"+descriptor.ID, func(t *testing.T) { - checkIdentity(t, descriptor, "scale.type") - checkOptionSchema(t, descriptor) - checkScaleDetection(t, descriptor) - checkDecoder(t, descriptor) - }) - } - for _, descriptor := range printers { - t.Run("imprimante/"+descriptor.ID, func(t *testing.T) { - checkIdentity(t, descriptor, "printer.type") - checkOptionSchema(t, descriptor) - checkSelfTests(t, descriptor) - checkHeadGeometry(t, descriptor) - }) - } -} - -// checkIdentity holds the two strings every reader of a registry uses: the KEY a -// configuration file carries, and the WORDING a volunteer picks from a list. -// -// They are not interchangeable and the test says so on both sides. The key is compared -// exactly, so it is spelled the way a lookup spells it — lower case, no space; the label -// is what somebody reads on the hardware or in a menu, so a label shaped like an -// identifier is a driver that never wrote one (§9.3, §8.2). -func checkIdentity(t *testing.T, d domain.DriverDescriptor, key string) { - t.Helper() - if d.ID == "" { - t.Fatalf("un driver s'enregistre sans identifiant : c'est la valeur de %s dans "+ - "config.json, et la clé de la recherche du registre", key) - } - if !isRegistryKey(d.ID) { - t.Errorf("l'identifiant %q n'est pas une clé de registre : %s se compare caractère "+ - "pour caractère, donc l'identifiant s'écrit en minuscules, en chiffres et en traits "+ - "d'union — « gram-xfoc-plus », « raster »", d.ID, key) - } - switch { - case d.Label == "": - t.Errorf("le driver %q s'enregistre sans libellé : c'est ce qu'un bénévole lit dans "+ - "la liste déroulante, et un menu sans mot ne se choisit pas", d.ID) - case d.Label == d.ID || isRegistryKey(d.Label): - t.Errorf("le driver %q se présente comme %q, qui est un identifiant : le libellé est "+ - "le nom imprimé sur l'appareil (« GRAM XFOC + ») ou une phrase française qui dit ce "+ - "que le driver fait, jamais la clé de configuration", d.ID, d.Label) - } -} - -// checkOptionSchema is what the administration screen generates its form from and what -// control 7 of §11.3 validates a file against. An entry missing from it is a field the -// form offers and the driver never reads, or the other way round. -func checkOptionSchema(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - if len(d.Options) == 0 { - if why, exempt := schemaExemptions[d.ID]; exempt { - t.Logf("le driver %q ne déclare aucune option, et c'est un requis : %s", d.ID, why) - return - } - t.Errorf("le driver %q ne déclare aucune option : l'écran d'administration génère son "+ - "formulaire à partir de ce schéma, et le contrôle 7 de §11.3 refuse toute clé que "+ - "printer.options ou scale.options porte pour lui. Si ce driver ne prend vraiment RIEN "+ - "d'une configuration, inscrivez-le dans schemaExemptions avec la raison — ET traitez "+ - "le cas dans TestTheDeliveredConfigurationValidatesOnEveryPrinterOfThisBinary, qui "+ - "soumet la configuration LIVRÉE à chaque driver du registre : elle porte les options "+ - "de `raster`, que le contrôle 7 refusera pour le vôtre. Les deux vont ensemble, sans "+ - "quoi l'exemption rend ce test-ci vert et l'autre rouge", d.ID) - return - } - checkOptionKeys(t, d.ID, d.Options, "") -} - -// checkOptionKeys walks a schema, nested groups included, and holds the spelling of a key -// to what config.json carries: lower case and underscores, never a space and never a -// capital (§11.2). -func checkOptionKeys(t *testing.T, driverID string, schema []domain.OptionSchema, path string) { - t.Helper() - seen := make(map[string]bool, len(schema)) - for _, option := range schema { - full := path + option.Key - switch { - case option.Key == "": - t.Errorf("le driver %q déclare une option sans clé sous %q : une option sans nom "+ - "ne peut être ni saisie ni validée", driverID, path) - continue - case !isOptionKey(option.Key): - t.Errorf("le driver %q déclare l'option %q : une clé de config.json s'écrit en "+ - "minuscules, chiffres et tirets bas — « roll_capacity », « backoff_min_ms »", - driverID, full) - case seen[option.Key]: - t.Errorf("le driver %q déclare deux fois l'option %q : le formulaire généré "+ - "porterait deux champs pour une seule valeur", driverID, full) - } - seen[option.Key] = true - if len(option.Options) != 0 { - checkOptionKeys(t, driverID, option.Options, full+".") - } - } -} - -// checkSelfTests holds the buttons the Matériel page draws to the catalogue of §8.6. -// -// The registry already refuses a name the catalogue does not carry; what is verified here -// is the trip that name makes through the DESCRIPTOR — the plain strings the domain and -// then the front end read. A conversion that drifted would put on the screen a button -// whose route answers « auto-test inconnu ». -func checkSelfTests(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - for _, what := range d.SelfTests { - if _, err := printing.LookupSelfTest(what); err != nil { - t.Errorf("le driver %q déclare l'auto-test %q, que le catalogue de §8.6 ne porte "+ - "pas : %v. Un nom sans bouton est un auto-test que personne ne peut lancer", - d.ID, what, err) - } - } -} - -// checkHeadGeometry holds the three figures hard rules 3, 4 and 8 of §7.5 measure a -// template against (controls 29 and 38). -// -// ALL THREE OR NONE. Zero everywhere is the honest declaration of a driver that inks no -// paper — the rules then bear on domain.ReferenceHead — but a printable area declared -// without the pitch it is counted in is a number nobody can convert to a millimetre, and -// a validation would compare dots at one resolution against a template at another. -func checkHeadGeometry(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - head := d.Capabilities - declared := head.DotsPerMM != 0 || head.InkedWidthDots != 0 || head.InkedHeightDots != 0 - if declared && (head.DotsPerMM <= 0 || head.InkedWidthDots <= 0 || head.InkedHeightDots <= 0) { - t.Errorf("le driver %q déclare une géométrie incomplète (%g dots/mm, %d × %d dots "+ - "encrés) : les trois vont ensemble, parce qu'une surface en dots ne se convertit en "+ - "millimètres qu'au pas de la tête. Un driver qui n'encre aucun papier les laisse "+ - "toutes les trois à zéro et les règles de §7.5 portent alors sur domain.ReferenceHead", - d.ID, head.DotsPerMM, head.InkedWidthDots, head.InkedHeightDots) - } - if head.MaxCopies < 1 { - t.Errorf("le driver %q accepte %d copie(s) : une imprimante qui n'en accepte aucune "+ - "est une imprimante dont chaque étiquette est refusée", d.ID, head.MaxCopies) - } - checkTheGeometryWasMeasuredAndNotCopied(t, d) -} - -// checkTheGeometryWasMeasuredAndNotCopied is the clause NO conformance bench can hold. -// -// The printer suite runs against a BUILT driver and prints the template the subject -// declares. A driver that copied the head of the parc — 8 dots/mm, 280 × 200 dots encrés — -// into a package that never touched a WS408 passes all eighteen clauses: the template -// matches the declaration, so the geometry check is satisfied by the copy itself. -// -// What the copy then does is invisible until a station runs it. The three figures travel -// through printing.Registry.Descriptors into domain.Registries.PrinterHead, where controls -// 29 and 38 of §11.3 measure a template against them: every station naming that driver -// validates its label against a print head nobody owns, and §11.3 puts a station whose -// validation fails out of service. -// -// A driver has exactly two honest answers, and neither of them is « the same as raster ». -// It inks no paper, and the three figures stay at ZERO — the rules then bear on -// domain.ReferenceHead, which is what internal/printing/preview declares. Or it drives a -// head, and the three were MEASURED on that head — in which case coinciding with a WS408 to -// the dot is not something a measurement does twice by accident. -func checkTheGeometryWasMeasuredAndNotCopied(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - if d.ID == domain.PrinterRaster { - return // the driver of the parc IS the WS408: that is where the figures come from - } - head, reference := d.Capabilities, domain.ReferenceHead() - if head.DotsPerMM == reference.DotsPerMM && - head.InkedWidthDots == reference.InkedWidthDots && - head.InkedHeightDots == reference.InkedHeightDots { - t.Errorf("le driver %q déclare exactement la géométrie de la tête de référence "+ - "(%g dots/mm, %d × %d dots encrés), et il n'est pas le driver de cette tête. "+ - "Deux réponses honnêtes existent, et « la même que raster » n'en fait pas partie :\n"+ - " — ce driver n'encre aucun papier : laissez les TROIS chiffres à zéro, les règles "+ - "de §7.5 portent alors sur domain.ReferenceHead, et supprimez le refus du gabarit "+ - "étranger (internal/printing/preview montre cette forme) ;\n"+ - " — ce driver pilote une tête : les trois chiffres se MESURENT sur du papier, par "+ - "les auto-tests `ruler` et `alignment`, et une mesure ne retombe pas au dot près sur "+ - "une WS408 par hasard.\n"+ - "Recopiés, ils voyagent jusqu'aux contrôles 29 et 38 de §11.3 : chaque poste qui "+ - "nomme ce driver valide son gabarit contre une tête que personne ne possède", - d.ID, head.DotsPerMM, head.InkedWidthDots, head.InkedHeightDots) - } -} - -// checkScaleDetection holds what the descriptor promises about « Détecter -// automatiquement » to what the registry can really try (§14.4). -// -// A protocol that declared a serial endpoint and never appeared among the candidates -// would offer a detection whose only possible outcome is silence — which is the answer a -// broken cable gives, and it sends a volunteer looking for one. -func checkScaleDetection(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - if d.Endpoint != domain.EndpointSerialPort && d.Endpoint != domain.EndpointNone { - t.Errorf("le protocole %q déclare le point d'accès %q : `openscale doctor` et l'écran "+ - "d'administration ne lisent que %q et %q, et une troisième orthographe est un "+ - "contrôle qui ne s'applique à rien", d.ID, d.Endpoint, - domain.EndpointSerialPort, domain.EndpointNone) - return - } - proposed := false - for _, candidate := range scaleRegistry().Candidates(scale.EndpointSerialPort) { - if candidate.Descriptor.ID == d.ID { - proposed = true - } - } - if wanted := d.Endpoint == domain.EndpointSerialPort; proposed != wanted { - t.Errorf("le protocole %q déclare le point d'accès %q et la détection sur port série "+ - "le propose = %t : la déclaration du descripteur et ce que le registre essaie "+ - "vraiment sont la même chose, ou la détection ment", d.ID, d.Endpoint, proposed) - } -} - -// checkDecoder holds the grammar every tool that reads bytes without running a station -// asks the registry for: the detection of §14.4, `openscale capture`, `openscale replay` -// and the « Rejouer cette trame » button. -// -// Register already refuses a driver whose decoder factory is nil. What it cannot see is a -// factory that ANSWERS nil, or one that hands the same accumulator to two callers — and -// that second one is the fabricated mass this whole grammar exists to refuse: half a frame -// read on one port, completed by the bytes of another, on a label somebody sticks on a bag. -func checkDecoder(t *testing.T, d domain.DriverDescriptor) { - t.Helper() - registry := scaleRegistry() - first, err := registry.NewDecoder(d.ID) - if err != nil { - t.Fatalf("le protocole %q est enregistré et le registre n'en donne aucun décodeur : %v", - d.ID, err) - } - second, err := registry.NewDecoder(d.ID) - if err != nil { - t.Fatalf("second décodeur de %q : %v", d.ID, err) - } - if first == nil || second == nil { - t.Fatalf("la fabrique de décodeurs de %q répond nil : `openscale capture`, la détection "+ - "et « Rejouer cette trame » lisent des octets sans faire tourner de poste, et chacun "+ - "appelle celle-ci", d.ID) - } - if address(first) == address(second) && address(first) != 0 { - t.Errorf("les deux décodeurs de %q sont le même objet : un décodeur retient les octets "+ - "qui attendent la fin de leur trame, et deux ports qui partagent ce tampon "+ - "complètent la demi-trame de l'un avec les octets de l'autre — une masse que "+ - "personne n'a pesée, sur une étiquette collée sur un sac", d.ID) - } - if resyncs := first.Resyncs(); resyncs != 0 { - t.Errorf("un décodeur neuf de %q annonce déjà %d resynchronisation(s) : il n'est pas "+ - "neuf, il est partagé", d.ID, resyncs) - } -} - -// address is the identity of the value behind an interface, or zero when it has none. -func address(v any) uintptr { - value := reflect.ValueOf(v) - if value.Kind() != reflect.Pointer { - return 0 - } - return value.Pointer() -} - -// isRegistryKey reports whether s is spelled the way a registry key is: the lookup is an -// exact string comparison, and the case of a suffix is precisely what split the legacy -// code into two functions for one protocol. -func isRegistryKey(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { - return false - } - } - return true -} - -// isOptionKey reports whether s is spelled the way config.json carries an option key. -func isOptionKey(s string) bool { - for _, r := range s { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { - return false - } - } - return s != "" -} - // TestUnDriverQuiDeclareUnTransportEnRecoitUn is the one convention of the composition // root that nothing verified. // @@ -707,104 +405,6 @@ func TestChaqueBalanceDuRegistreEstConstructible(t *testing.T) { } } -// --- Les deux drivers d'exemple ---------------------------------------------- - -// TestLesDriversDExempleNeSontJamaisEnregistres. -// -// internal/scale/example and internal/printing/example are COMPLETE drivers written to be -// copied (docs/07-ajouter-un-materiel.md). Registering either one would put in the drop-down -// list of a volunteer a value no station can honour — a toy protocol no scale of the parc -// speaks, a printer that writes into memory — and the fault would surface as a station that -// validates its configuration and then weighs nothing, or prints nothing. -// -// It is exactly the reasoning drivers.go already applies to `sbpl`, which §8.1 names and no -// station carries, and it is worth a test rather than a comment: the one-line registration -// of §5.2 is one line to add BY MISTAKE too, and the mistake reads like an improvement. -func TestLesDriversDExempleNeSontJamaisEnregistres(t *testing.T) { - for _, descriptor := range scaleRegistry().Descriptors() { - if descriptor.ID == scaleexample.ID { - t.Errorf("le protocole d'exemple %q est enregistré : c'est un protocole JOUET, "+ - "qu'aucune balance ne parle. Un poste qui le choisit valide sa configuration "+ - "puis ne pèse rien", descriptor.ID) - } - } - for _, descriptor := range printerRegistry().Descriptors() { - if descriptor.ID == printerexample.ID { - t.Errorf("le driver d'impression d'exemple %q est enregistré : il écrit en "+ - "mémoire et n'imprime rien. Un poste qui le choisit annonce « Étiquette "+ - "envoyée à l'imprimante » pendant que rien ne sort", descriptor.ID) - } - } -} - -// TestLesDriversDExempleRestentEnregistrables is the other half, and without it the test -// above is satisfied by two packages that no longer compile as drivers. -// -// What is verified here is what NO conformance bench sees: the REGISTRY ENTRY, the value a -// driver package hands cmd/openscale and which the administration screen, `openscale doctor` -// and Config.Validate all read WITHOUT building anything. A driver can pass every bench and -// still register with an empty label, no option schema, or an endpoint that promises a -// detection nothing can perform — and an example that did any of those teaches it. -// -// The registries are THROWAWAY, built here and nowhere else: the examples are held to the -// completeness of a registered driver without ever becoming one. -func TestLesDriversDExempleRestentEnregistrables(t *testing.T) { - scales := scale.NewRegistry() - scales.Register(scaleexample.Driver()) - printers := printing.NewRegistry() - printers.Register(printerexample.Driver()) - - for _, descriptor := range scales.Descriptors() { - t.Run("balance/"+descriptor.ID, func(t *testing.T) { - checkIdentity(t, descriptor, "scale.type") - checkOptionSchema(t, descriptor) - checkExampleDecoder(t, scales, descriptor.ID) - if descriptor.Endpoint != domain.EndpointSerialPort { - t.Errorf("le protocole d'exemple déclare le point d'accès %q : l'exemple "+ - "montre la détection de §14.4, et un exemple qui ne la déclare pas ne "+ - "montre plus comment la déclarer", descriptor.Endpoint) - } - if len(scales.Candidates(scale.EndpointSerialPort)) != 1 { - t.Error("le protocole d'exemple déclare le port série et la détection ne le " + - "propose pas : la déclaration du descripteur et ce que le registre essaie " + - "vraiment sont la même chose, ou la détection ment") - } - }) - } - for _, descriptor := range printers.Descriptors() { - t.Run("imprimante/"+descriptor.ID, func(t *testing.T) { - checkIdentity(t, descriptor, "printer.type") - checkOptionSchema(t, descriptor) - checkSelfTests(t, descriptor) - checkHeadGeometry(t, descriptor) - }) - } -} - -// checkExampleDecoder holds the example to the clause the registry itself cannot see: a -// decoder FACTORY that answers nil, or that hands the same accumulator to two callers. -// -// The second is the fabricated mass the whole grammar exists to refuse — half a frame read -// on one port completed by the bytes of another, on a label somebody sticks on a bag. -func checkExampleDecoder(t *testing.T, registry *scale.Registry, id string) { - t.Helper() - first, err := registry.NewDecoder(id) - if err != nil { - t.Fatalf("le protocole %q ne donne aucun décodeur : %v", id, err) - } - second, err := registry.NewDecoder(id) - if err != nil { - t.Fatalf("second décodeur de %q : %v", id, err) - } - if first == nil || second == nil { - t.Fatalf("la fabrique de décodeurs de %q répond nil", id) - } - if address(first) == address(second) && address(first) != 0 { - t.Errorf("les deux décodeurs de %q sont le même objet : deux ports qui partagent ce "+ - "tampon complètent la demi-trame de l'un avec les octets de l'autre", id) - } -} - // nopLog swallows what a driver reports while a test builds it. type nopLog struct{} diff --git a/cmd/openscale/exampledrivers_test.go b/cmd/openscale/exampledrivers_test.go new file mode 100644 index 0000000..a3f4bd2 --- /dev/null +++ b/cmd/openscale/exampledrivers_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "testing" + + "openscale/internal/domain" + "openscale/internal/printing" + printerexample "openscale/internal/printing/example" + "openscale/internal/scale" + scaleexample "openscale/internal/scale/example" +) + +// The two example drivers of docs/07: they are never registered in a shipped binary — +// a model nobody has must not appear in a volunteer's drop-down list — and they stay +// registrABLE, which is the whole promise the document makes to whoever copies them. + +// --- Les deux drivers d'exemple ---------------------------------------------- + +// TestLesDriversDExempleNeSontJamaisEnregistres. +// +// internal/scale/example and internal/printing/example are COMPLETE drivers written to be +// copied (docs/07-ajouter-un-materiel.md). Registering either one would put in the drop-down +// list of a volunteer a value no station can honour — a toy protocol no scale of the parc +// speaks, a printer that writes into memory — and the fault would surface as a station that +// validates its configuration and then weighs nothing, or prints nothing. +// +// It is exactly the reasoning drivers.go already applies to `sbpl`, which §8.1 names and no +// station carries, and it is worth a test rather than a comment: the one-line registration +// of §5.2 is one line to add BY MISTAKE too, and the mistake reads like an improvement. +func TestLesDriversDExempleNeSontJamaisEnregistres(t *testing.T) { + for _, descriptor := range scaleRegistry().Descriptors() { + if descriptor.ID == scaleexample.ID { + t.Errorf("le protocole d'exemple %q est enregistré : c'est un protocole JOUET, "+ + "qu'aucune balance ne parle. Un poste qui le choisit valide sa configuration "+ + "puis ne pèse rien", descriptor.ID) + } + } + for _, descriptor := range printerRegistry().Descriptors() { + if descriptor.ID == printerexample.ID { + t.Errorf("le driver d'impression d'exemple %q est enregistré : il écrit en "+ + "mémoire et n'imprime rien. Un poste qui le choisit annonce « Étiquette "+ + "envoyée à l'imprimante » pendant que rien ne sort", descriptor.ID) + } + } +} + +// TestLesDriversDExempleRestentEnregistrables is the other half, and without it the test +// above is satisfied by two packages that no longer compile as drivers. +// +// What is verified here is what NO conformance bench sees: the REGISTRY ENTRY, the value a +// driver package hands cmd/openscale and which the administration screen, `openscale doctor` +// and Config.Validate all read WITHOUT building anything. A driver can pass every bench and +// still register with an empty label, no option schema, or an endpoint that promises a +// detection nothing can perform — and an example that did any of those teaches it. +// +// The registries are THROWAWAY, built here and nowhere else: the examples are held to the +// completeness of a registered driver without ever becoming one. +func TestLesDriversDExempleRestentEnregistrables(t *testing.T) { + scales := scale.NewRegistry() + scales.Register(scaleexample.Driver()) + printers := printing.NewRegistry() + printers.Register(printerexample.Driver()) + + for _, descriptor := range scales.Descriptors() { + t.Run("balance/"+descriptor.ID, func(t *testing.T) { + checkIdentity(t, descriptor, "scale.type") + checkOptionSchema(t, descriptor) + checkExampleDecoder(t, scales, descriptor.ID) + if descriptor.Endpoint != domain.EndpointSerialPort { + t.Errorf("le protocole d'exemple déclare le point d'accès %q : l'exemple "+ + "montre la détection de §14.4, et un exemple qui ne la déclare pas ne "+ + "montre plus comment la déclarer", descriptor.Endpoint) + } + if len(scales.Candidates(scale.EndpointSerialPort)) != 1 { + t.Error("le protocole d'exemple déclare le port série et la détection ne le " + + "propose pas : la déclaration du descripteur et ce que le registre essaie " + + "vraiment sont la même chose, ou la détection ment") + } + }) + } + for _, descriptor := range printers.Descriptors() { + t.Run("imprimante/"+descriptor.ID, func(t *testing.T) { + checkIdentity(t, descriptor, "printer.type") + checkOptionSchema(t, descriptor) + checkSelfTests(t, descriptor) + checkHeadGeometry(t, descriptor) + }) + } +} + +// checkExampleDecoder holds the example to the clause the registry itself cannot see: a +// decoder FACTORY that answers nil, or that hands the same accumulator to two callers. +// +// The second is the fabricated mass the whole grammar exists to refuse — half a frame read +// on one port completed by the bytes of another, on a label somebody sticks on a bag. +func checkExampleDecoder(t *testing.T, registry *scale.Registry, id string) { + t.Helper() + first, err := registry.NewDecoder(id) + if err != nil { + t.Fatalf("le protocole %q ne donne aucun décodeur : %v", id, err) + } + second, err := registry.NewDecoder(id) + if err != nil { + t.Fatalf("second décodeur de %q : %v", id, err) + } + if first == nil || second == nil { + t.Fatalf("la fabrique de décodeurs de %q répond nil", id) + } + if address(first) == address(second) && address(first) != 0 { + t.Errorf("les deux décodeurs de %q sont le même objet : deux ports qui partagent ce "+ + "tampon complètent la demi-trame de l'un avec les octets de l'autre", id) + } +} diff --git a/cmd/openscale/failure.go b/cmd/openscale/failure.go new file mode 100644 index 0000000..268a61c --- /dev/null +++ b/cmd/openscale/failure.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "errors" + + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// This file is how `openscale serve` FAILS: the technical code a volunteer reads out +// on the telephone, the exit code the service manager acts on, and the one line of the +// technical journal that survives the process. + +// The exit codes a service manager reads. +const ( + // exitFailure is what any refusal of a subcommand returns: a configuration that + // cannot be read, a database that cannot be opened. systemd restarts, the Windows + // SCM restarts, and the install sheet says what to look at. + exitFailure = 1 + // exitFatal is the code of §13.4: the socket could not be taken, or the server + // stopped serving on its own. « Un poste ne peut pas tourner normalement en étant + // mort. » + exitFatal = 3 + // exitRestart is a stop somebody ASKED FOR, from the administration screen. + // + // It is non-zero ON PURPOSE, and that is the whole mechanism: a non-zero code is + // what makes the SCM apply the recovery actions of §15.2 and systemd its + // Restart=always. A clean 0 would be recorded as a stop nobody undoes, and the + // station would wait for a human who thinks it is coming back. + exitRestart = 4 +) + +// The technical codes of §13.4, each with the sentence a volunteer reads. +const ( + // codeAnotherInstance is ERR-SYS-01: the address refuses a bind AND answers a + // probe. THE SOCKET IS THE SINGLE-INSTANCE LOCK — no lock file left behind by a + // crash, no Windows named mutex — and telling this case from the next one is what + // keeps a volunteer from hunting for a ghost process. + codeAnotherInstance = "ERR-SYS-01" + // codeCannotListen is ERR-SYS-02: the address refuses a bind and answers nothing. + // It is an address this station cannot have, which is a different remedy. + codeCannotListen = "ERR-SYS-02" + // codeServerStopped is ERR-SYS-03: Serve returned without a shutdown having been + // asked for. + codeServerStopped = "ERR-SYS-03" + // codeRestartAsked is ERR-SYS-09: a volunteer asked for a restart from the + // administration screen. + // + // It is written to the technical journal BEFORE the stop, because nothing written + // afterwards would ever be written — and because the Windows event log will record + // this stop as « inattendu », which it is not. That line is the only place the + // intention survives. + codeRestartAsked = "ERR-SYS-09" +) + +// serviceFailure is a failure of `openscale serve` that names its technical code and +// the exit code the service manager reads. +// +// §13.4 has `fatal` write to the text journal, to the technical journal AND to stderr, +// then exit 3. Those are three different call sites here, deliberately: the technical +// journal is written where the failure happens, because only there is the database in +// hand; stderr and the exit code belong to main, because only main can exit. +type serviceFailure struct { + // Code is the ERR-SYS-nn a volunteer reads on the telephone. + Code string + // Exit is what the process returns. + Exit int + // Message is FRENCH and complete: it names what is wrong and what to do about it. + Message string + // Err is the underlying failure, kept so that errors.Is reaches it. + Err error +} + +// Error reports the code and the French sentence, which is what stderr carries. +func (f *serviceFailure) Error() string { + if f.Code == "" { + return f.Message + } + return f.Code + " : " + f.Message +} + +// Unwrap yields the failure this one was built on. +func (f *serviceFailure) Unwrap() error { return f.Err } + +// exitCodeFor reports the code the process returns for one error. +func exitCodeFor(err error) int { + var failure *serviceFailure + if errors.As(err, &failure) && failure.Exit != 0 { + return failure.Exit + } + return exitFailure +} + +// recordFailure writes one fatal error to the technical journal, which is the half of +// §13.4's `fatal` that survives the process. +// +// It is written SYNCHRONOUSLY and directly to the store: the Hub's journal worker may +// not be running yet, or may already have been drained. And it is written on a FRESH +// context, never on the one that is being cancelled — the line that says why the +// station is stopping must not be the first casualty of the stop. +func recordFailure(db *store.DB, clk ports.Clock, err error) { + var failure *serviceFailure + if !errors.As(err, &failure) { + return + } + _ = db.RecordTechnical(context.Background(), store.TechnicalEntry{ + OccurredAt: clk.Now(), Level: store.LevelCritical, Source: store.LogSourceSystem, + Code: failure.Code, Message: failure.Message, Detail: detailOf(failure.Err), + }) +} + +// detailOf reports the technical tail of a failure, or nothing. +func detailOf(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/cmd/openscale/fallback.go b/cmd/openscale/fallback.go new file mode 100644 index 0000000..675ee05 --- /dev/null +++ b/cmd/openscale/fallback.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "openscale/internal/domain" +) + +// This file is what `serve` runs, and says, when the configuration it read is not what +// this binary expects: the neutral profile of §11.3 with the two blocks that must +// survive it, and the lists — every fault, every migration — written where whoever +// started the service can read them. + +// fallbackProfile is what a station RUNS when its own configuration is unusable (§11.3). +// +// It is the neutral profile, plus the two things that must survive the fallback — and +// both were found by starting a station out of the box and trying to repair it from its +// own screen. +// +// # The administration block +// +// §11.3 replaces the configuration a station OPERATES ON. It has no business replacing +// the identity of whoever administers it: the password and the recovery code are the +// answer to « qui a le droit de réparer ce poste », and that answer is on the +// installation sheet, in the shop's folder, matching the hash IN THE FILE. Dropping them +// left the login form answering « aucun mot de passe n'est défini » and the recovery form +// answering « ce poste n'a pas de code de secours » — on the ONE station both exist for. +// The screen was then unreachable on exactly the station §11.3 says it must serve. +// +// # The network block +// +// Same rule, and it was learnt the same way. The neutral profile replaces what the +// station RUNS ON; it has no business replacing the way one REACHES it in order to +// repair it. Its address is 127.0.0.1:8085, which every station of the parc shares, so +// borrowing it moved a station off the address its file declares — while the kiosk, which +// reads that same file and reads it successfully because a faulty file is still a +// readable one, kept opening the declared address. A black client screen on the very +// station §11.3 exists to keep alive, and an administration screen shut back onto the +// loopback at the moment a volunteer arrives with a laptop to fix it. +// +// The address of the file is kept only while it is USABLE: when the faults name the +// network block itself, the neutral profile provides it, because a fallback that copied +// an unbindable address would turn ERR-CFG-01 — a station serving its fault list — into +// ERR-SYS-02, a station that is not there at all. +func fallbackProfile(broken domain.Config, faults []domain.Fault) domain.Config { + cfg := domain.NeutralProfile() + cfg.Admin = broken.Admin + if !faultedOn(faults, "network") { + cfg.Network = broken.Network + } + return cfg +} + +// faultedOn reports whether any fault names a field of one configuration section. +// +// It matches the section and everything beneath it — "network" answers for +// "network.listen" — so a control added to that section later is covered without this +// function having to learn its name. Half a block is what must never be borrowed: an +// address open to the network behind an administration screen closed to it is harder to +// diagnose than a fallback that is wrong in both directions at once. +func faultedOn(faults []domain.Fault, section string) bool { + for _, fault := range faults { + if fault.Field == section || strings.HasPrefix(fault.Field, section+".") { + return true + } + } + return false +} + +// reportFaults writes the whole list of §11.3 where whoever started the service can +// read it. +// +// ALL of them and not the first: a volunteer who came to fix one file should leave +// having fixed it, and not discover the second fault after a restart. +func reportFaults(out io.Writer, path string, faults []domain.Fault) { + fmt.Fprintf(out, "openscale : %s comporte %d faute(s) — le poste démarre en configuration "+ + "d'usine (ERR-CFG-01) et sert l'écran d'administration :\n", path, len(faults)) + for _, fault := range faults { + fmt.Fprintf(out, " %s\n", fault.String()) + } +} + +// reportMigration writes what this binary had to change to read the file, where whoever +// started the service can read it. +// +// It says nothing when there is nothing to say: a station whose file is already at this +// schema must not print a paragraph at every boot. +func reportMigration(out io.Writer, path string, notes []domain.MigrationNote) { + if len(notes) == 0 { + return + } + fmt.Fprintf(out, "openscale : %s a été écrit par une version précédente — %d "+ + "changement(s), appliqués EN MÉMOIRE. Le fichier n'est pas modifié ; "+ + "« openscale config migrate » l'écrit :\n", path, len(notes)) + for _, note := range notes { + fmt.Fprintf(out, " %s\n", note) + } +} diff --git a/cmd/openscale/fallback_test.go b/cmd/openscale/fallback_test.go new file mode 100644 index 0000000..7629b93 --- /dev/null +++ b/cmd/openscale/fallback_test.go @@ -0,0 +1,258 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/domain" +) + +// §11.3: an unusable configuration NEVER kills the process. The station comes up on the +// neutral profile, in the one terminal state, and serves the whole list of faults — with +// the two things that must survive the fallback, because both were found by trying to +// repair a station from its own screen: the KEYS (who may repair it) and the DOOR (the +// address it is reached on). + +// TestAnUnreadableConfigurationRefusesToServe is the other half of §11.3. +// +// A configuration that is INVALID never kills the process: the station starts on the +// neutral profile and serves the whole list of faults, which is the assertion of +// TestAnInvalidConfigurationStillServes below, and — since porte 1 — of +// TestATrulyBrokenConfigurationStillServes too: even a document that is not JSON at all +// falls back rather than refusing. A file that cannot be READ AT ALL is a different fact — +// there is no station number, no listening address and nothing an administration screen +// could safely write back — and it alone refuses, in French, naming the file, with a +// non-zero exit code and NO PANIC. +func TestAnUnreadableConfigurationRefusesToServe(t *testing.T) { + missing := filepath.Join(t.TempDir(), "config.json") + + // A BOUNDED context, so that a subcommand which starts anyway fails here instead of + // hanging: a station built on a configuration nobody could read would listen on an + // address nobody chose and wait for a signal for ever, and a test that hangs says + // nothing to whoever broke it. + ctx, cancel := context.WithTimeout(context.Background(), startBudget) + defer cancel() + + var out bytes.Buffer + err := runServe(ctx, []string{"--config", missing, "--data", t.TempDir()}, &out) + if err == nil { + t.Fatal("un fichier absent a laissé le poste démarrer") + } + if code := exitCodeFor(err); code == 0 { + t.Fatalf("code de sortie %d : un démarrage refusé doit être visible du gestionnaire de service", code) + } + message := explain(err) + if !strings.Contains(message, missing) { + t.Fatalf("le refus ne nomme pas le fichier fautif : %s", message) + } + if !strings.Contains(message, "configuration") { + t.Fatalf("le refus n'est pas en français et ne dit pas de quoi il parle : %s", message) + } +} + +// TestATrulyBrokenConfigurationStillServes is porte 1 (LoadConfig, +// TestLoadConfigOfATruncatedFileIsNotAnError), exercised at the level `serve` runs at. +// +// A document that is not JSON at all used to refuse to start, alongside a missing file. +// It no longer does: DecodeConfigBlockByBlock falls back to the neutral profile for a +// document it cannot decode at all exactly as it does for one bad block, and the station +// serves ERR-CFG-01 like any other invalid configuration +// (TestAnInvalidConfigurationStillServes) — the file is illisible, but it EXISTS, and a +// wrong path in a service unit is the one case that must still refuse. +func TestATrulyBrokenConfigurationStillServes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte("{\"station\": {\"number\": "), 0o644); err != nil { + t.Fatalf("écriture du fichier cassé : %v", err) + } + + b := &serveBench{ + t: t, + configPath: path, + dataDir: filepath.Join(dir, "data"), + out: &syncBuffer{}, + returned: make(chan error, 1), + client: &http.Client{}, + } + // --listen, and not the neutral profile's own 127.0.0.1:8085: that address is shared + // by every station of the parc, including the one this developer may have installed + // on their own machine. + b.options = serveOptions{configPath: path, dataDir: b.dataDir, listen: freeAddress(t)} + b.start() + + live := b.get("/healthz") + if live.StatusCode != http.StatusOK { + t.Fatalf("/healthz = %d : un document illisible a tué le poste", live.StatusCode) + } + _ = live.Body.Close() + + if got := b.output(); !strings.Contains(got, "ERR-CFG-01") { + t.Fatalf("la sortie ne nomme pas ERR-CFG-01 :\n%s", got) + } + + if err := b.stop(); err != nil { + t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) + } +} + +// TestAnInvalidConfigurationStillServes is the guiding principle 7 of §11.3: « le poste +// démarre toujours ». +// +// A negative discount would print a negative price, so control 13 refuses it. The +// station starts anyway, on the neutral profile loaded IN MEMORY AND NEVER WRITTEN, +// in the one terminal state, and it serves — because a broken configuration must +// never produce a black screen, and because the screen that fixes it is served by +// the very process the configuration broke. +func TestAnInvalidConfigurationStillServes(t *testing.T) { + bench := newServeBench(t, func(cfg *domain.Config) { + cfg.Pricing.Tiers[0].Discount = -10 + }) + bench.start() + + live := bench.get("/healthz") + if live.StatusCode != http.StatusOK { + t.Fatalf("/healthz = %d : une configuration invalide a tué le poste", live.StatusCode) + } + _ = live.Body.Close() + + if got := bench.output(); !strings.Contains(got, "ERR-CFG-01") { + t.Fatalf("la sortie ne nomme pas ERR-CFG-01 :\n%s", got) + } + if got := bench.output(); !strings.Contains(got, "discount_percent") { + t.Fatalf("la liste des fautes ne nomme pas le champ fautif :\n%s", got) + } + // The file on disk is UNTOUCHED: the neutral profile is loaded in memory and + // nothing writes it back over what an operator typed. + raw, err := os.ReadFile(bench.configPath) + if err != nil { + t.Fatalf("relecture de la configuration : %v", err) + } + if !bytes.Contains(raw, []byte(`"discount_percent": -1`)) { + t.Fatalf("le fichier fautif a été réécrit par le poste :\n%s", raw) + } + + if err := bench.stop(); err != nil { + t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) + } +} + +// TestTheFallbackProfileKeepsTheKEYSToTheStation. +// +// §11.3 replaces the configuration a station OPERATES ON when that configuration is +// unusable. It has no business replacing the identity of whoever administers it — and +// dropping it locked the screen on the one station §11.3 exists to keep serving: the +// login form answered « aucun mot de passe n'est défini » and the recovery form « ce +// poste n'a pas de code de secours », about a file that carried both. +func TestTheFallbackProfileKeepsTheKEYSToTheStation(t *testing.T) { + broken := shippedConfig(t) + broken.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$c2VsLWRlLXRlc3Q$Y2xlLWRlLXRlc3QtcG91ci1jZS1wb3N0ZQ" + broken.Admin.RecoveryCodeHash = "$argon2id$v=19$m=65536,t=3,p=2$YXV0cmUtc2VsLTAx$Y2xlLWR1LWNvZGUtZGUtc2Vjb3Vycy1pY2k" + broken.Station.Coop = "Les Amis de la Coopé" + + fallback := fallbackProfile(broken, faultsOn("pricing.tiers[0].discount_percent")) + if fallback.Admin.PasswordHash != broken.Admin.PasswordHash { + t.Error("le profil de repli oublie le mot de passe d'administration du fichier") + } + if fallback.Admin.RecoveryCodeHash != broken.Admin.RecoveryCodeHash { + t.Error("le profil de repli oublie le code de secours du fichier") + } + // Everything the station OPERATES on is the neutral profile, and nothing else is + // borrowed from a file that carries faults. + if fallback.Station.Coop == broken.Station.Coop { + t.Error("le profil de repli fait tourner le poste sur la configuration fautive") + } +} + +// TestTheFallbackProfileKeepsTheDOORToTheStation is the same rule applied to the network +// block, and it is the rule the bench of 2026-07-29 discovered the hard way. +// +// The keys are useless behind a door nobody can find. §11.3 replaces what a station RUNS +// ON, never the way one REACHES it in order to repair it: the address a station answers +// on is written on the installation sheet and dialled by the kiosk from that same file, +// and admin_on_lan is what lets a volunteer arrive with a laptop rather than a keyboard. +// Borrowing the neutral 127.0.0.1:8085 moved the service off the address the file +// declares while the kiosk kept opening it — a black client screen — and shut the +// administration screen back onto the loopback at the worst possible moment. +func TestTheFallbackProfileKeepsTheDOORToTheStation(t *testing.T) { + broken := shippedConfig(t) + broken.Network = domain.NetworkConfig{Listen: "127.0.0.1:8099", AdminOnLAN: true} + + fallback := fallbackProfile(broken, faultsOn("pricing.tiers[0].discount_percent")) + if fallback.Network.Listen != broken.Network.Listen { + t.Errorf("adresse du repli = %q, attendu %q : le repli jette une adresse d'écoute qui "+ + "n'est pas fautive", fallback.Network.Listen, broken.Network.Listen) + } + if !fallback.Network.AdminOnLAN { + t.Error("le repli referme l'écran d'administration sur la boucle locale, au moment " + + "même où un bénévole vient réparer le poste depuis son portable") + } +} + +// TestTheFallbackProfileTakesTheNeutralAddressWhenTheFileAddressIsItselfFaulted is what +// keeps the test above from being an invitation to copy an unbindable address. +// +// When the faults name the network block, the file has nothing usable to lend: the +// neutral profile provides the address, exactly as before. Without this case, a fallback +// that copied « 127.0.0.1 » — no port — would turn ERR-CFG-01, a station serving its +// fault list, into ERR-SYS-02, a station that is not there at all. +func TestTheFallbackProfileTakesTheNeutralAddressWhenTheFileAddressIsItselfFaulted(t *testing.T) { + broken := shippedConfig(t) + broken.Network = domain.NetworkConfig{Listen: "127.0.0.1", AdminOnLAN: true} + + fallback := fallbackProfile(broken, faultsOn("network.listen")) + if want := domain.NeutralProfile().Network.Listen; fallback.Network.Listen != want { + t.Errorf("adresse du repli = %q, attendu %q : le repli a recopié une adresse "+ + "inliable", fallback.Network.Listen, want) + } + if fallback.Network.AdminOnLAN { + t.Error("le repli a gardé la moitié d'un bloc network fautif : l'écran " + + "d'administration s'ouvre au réseau sur une configuration que personne n'a validée") + } +} + +// faultsOn builds the verdict of a Validate that found exactly these fields wrong. +func faultsOn(fields ...string) []domain.Fault { + faults := make([]domain.Fault, 0, len(fields)) + for _, field := range fields { + faults = append(faults, domain.Fault{Field: field, Message: "faute de banc d'essai"}) + } + return faults +} + +// TestAFaultyConfigurationStillServesOnTheAddressItsFileDeclares is what the bench of +// 2026-07-29 paid for, and the assertion no test made until it did. +// +// The station shipped that day carried network.listen 8099 AND eight faults elsewhere. +// The fallback threw the address out with the rest, the service came up on the +// 127.0.0.1:8085 of the neutral profile, and the kiosk — which reads the FILE, and reads +// it successfully because a faulty file is still a readable one — opened 8099. A black +// client screen, on the very station §11.3 exists to keep alive. +// +// So: a fault ANYWHERE BUT on the address, an address in the file, no flag, and the +// station must serve on the address its file names. +func TestAFaultyConfigurationStillServesOnTheAddressItsFileDeclares(t *testing.T) { + bench := newServeBench(t, func(cfg *domain.Config) { + cfg.Pricing.Tiers[0].Discount = -10 + }).listenFlag("") + bench.start() + + if bench.address != bench.fileAddress { + t.Fatalf("le poste sert sur %q alors que son fichier déclare %q : le repli a jeté une "+ + "adresse d'écoute qui n'était pas fautive, et l'écran client ouvre une adresse que "+ + "rien ne sert", bench.address, bench.fileAddress) + } + // And it really is the fallback that is being observed, not a configuration that + // turned out to be valid after all. + if got := bench.output(); !strings.Contains(got, "ERR-CFG-01") { + t.Fatalf("la sortie ne nomme pas ERR-CFG-01 : ce banc ne traverse pas le repli\n%s", got) + } + + if err := bench.stop(); err != nil { + t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) + } +} diff --git a/cmd/openscale/framereplay.go b/cmd/openscale/framereplay.go new file mode 100644 index 0000000..248ab71 --- /dev/null +++ b/cmd/openscale/framereplay.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "errors" + "strings" + + "openscale/internal/domain" +) + +// This file is the « Rejouer cette trame » button of the journal page (§14.4): one +// recorded frame pushed back through THIS station's own grammar, and handed to the Hub +// exactly as a driver would hand it over — so the station reacts as it really would. + +// stationDecoder builds a decoder of the protocol THIS station declares. +// +// A fresh one per call, never a field of this struct: the « Rejouer cette trame » button +// can be pressed twice, and a decoder that kept half of the first frame would complete it +// with the beginning of the second — the fabricated mass the grammar exists to refuse. +// +// A station that declares no protocol is refused by name. It is the case of a station +// running without a scale, and « rejouer une trame » there is a question with no grammar +// to answer it; saying so is more useful than decoding with somebody else's. +func (h adminHardware) stationDecoder() (domain.Decoder, error) { + if h.scales == nil { + return nil, errors.New("aucun protocole de balance n'est embarqué dans ce binaire : " + + "il n'y a pas de grammaire pour lire cette trame") + } + var declared string + if h.config != nil { + declared = h.config().Scale.Type + } + if strings.TrimSpace(declared) == "" { + return nil, errors.New("ce poste ne déclare aucun protocole de balance (scale.type) : " + + "une trame se rejoue dans la grammaire du protocole qui l'a émise, jamais dans " + + "une autre. Renseignez scale.type sur la page Matériel") + } + decoder, err := h.scales.NewDecoder(declared) + if err != nil { + return nil, err + } + return decoder, nil +} + +// Replay pushes one recorded frame back through the decoder (§14.4, page Journal). +// +// # What it is for +// +// A frame that caused an unexplained refusal becomes a permanent test, without a trip to +// the shop and without a scale (§15.4). It goes through the SAME grammar the driver uses +// — there is one — so « ça se décode » here means « ça se décode en service ». +// +// # What it deliberately does +// +// The decoded measurement is handed to the Hub exactly as a driver would hand it over, so +// the station reacts as it really would: the safeguards run, the banner appears, the state +// moves. A decoder called in isolation would answer the easy half of the question. +// +// # It decodes with THIS station's protocol +// +// The frame comes from the journal of this station, so the grammar that has to read it is +// the one scale.type names — not whichever the registry holds first. A frame replayed +// through the wrong grammar decodes to nothing and says « la balance a émis quelque chose +// que la grammaire refuse », which would be a lie about the scale and an invitation to go +// and look at it. +func (h adminHardware) Replay(ctx context.Context, raw string) error { + decoder, err := h.stationDecoder() + if err != nil { + return err + } + // A frame copied from a screen has lost whatever closed it. Adding a terminator back + // is not tolerance about the format: it is the byte a copy-paste cannot carry, and it + // is added ONLY when the protocol says the frame is still incomplete — a transmission + // that closes on its own control codes needs nothing and must not be padded. + if decoder.FrameEnd([]byte(raw)) < 0 { + raw += "\r\n" + } + measurements := decoder.Feed([]byte(raw), h.clock.Now()) + if len(measurements) == 0 { + return errors.New("cette trame ne se décode pas : aucune mesure. C'est la réponse — " + + "la balance a émis quelque chose que la grammaire de ce protocole refuse") + } + + h.technical.Technical(domain.LevelInfo, "scale", "", + "Trame rejouée depuis le journal.", strings.TrimSpace(raw)) + for _, measurement := range measurements { + select { + case h.hub.Measurements() <- domain.ScaleEvent{ + Status: domain.StatusConnected, Measurement: &measurement}: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} diff --git a/cmd/openscale/hardware.go b/cmd/openscale/hardware.go index 9f9e57c..2f5ae59 100644 --- a/cmd/openscale/hardware.go +++ b/cmd/openscale/hardware.go @@ -1,18 +1,11 @@ package main import ( - "bytes" "context" - "errors" - "fmt" - "image" "net" - "strings" - "time" "openscale/internal/domain" "openscale/internal/platform" - "openscale/internal/printing" "openscale/internal/scale" "openscale/internal/scale/serial" "openscale/internal/station" @@ -23,38 +16,10 @@ import ( // This file answers the « what is actually plugged in? » questions of the expert screens // (§14.4). Every one of them is platform-specific, which is why none of them lives in // internal/web. - -// detectWindow is how long a detection listens on one port: three seconds, the figure -// §11.4 gives the « Appliquer et tester » step. -// -// It is spent on the INJECTED clock, so a test of the detection runs in microseconds and -// a real one takes the three seconds a volunteer is standing there for. -const detectWindow = 3 * time.Second - -// captureCeiling bounds what the capture route may ask for. -// -// Sixty seconds: long enough to catch an intermittent cable, short enough that an HTTP -// handler is never held for a minute — the volunteer pressed a button and is watching a -// spinner. The half-hour campaign of §21 n° 3 is `openscale capture`, on the command -// line, where nobody is waiting. -const captureCeiling = 60 * time.Second - -// framesKept is how many raw frames a detection reports back. // -// Twenty, which is the « visualiseur des 20 dernières trames brutes » of §14.4. A -// three-second window at the nominal cadence yields about eight, so the ceiling only -// bites on a scale that babbles — and there, twenty lines is already the diagnosis. -const framesKept = 20 - -// bytesKept bounds the raw stream a listening window holds on to before it is cut into -// frames. -// -// The cut happens at the END and not read by read, because which protocol's cut applies -// is only known once something has been recognised. Sixty-four kibibytes is more than a -// minute at 9600 bauds — the whole of captureCeiling — so on the hardware of this parc -// nothing is ever dropped; what the bound really covers is a device babbling on a wrong -// bitrate, and there the TAIL is what a diagnosis wants anyway. -const bytesKept = 64 * 1024 +// What is left here is the ENUMERATION — which ports and which print destinations this +// machine has. Listening to one of those ports is in detect.go, the aperçu of the +// label in preview.go, and the frame replayed from the journal in framereplay.go. // adminHardware is everything the administration screens ask of the machine itself. type adminHardware struct { @@ -145,520 +110,3 @@ func printersOf(queues []platform.PrintQueue) []web.PrinterInfo { } return out } - -// DetectScale opens one port, applies the parser and says what answered — « COM8 : -// 12 trames valides, GRAM XFOC » (§14.4). -// -// # It is the detection that answers « y a-t-il une balance ? », not the operator -// -// That is the sentence §14.4 uses, and it is why this route exists at all: an operator -// choosing a protocol from a drop-down list is guessing, and a station configured on a -// guess fails at the counter. Here the port is opened, the grammar of §9.2 is applied to -// what comes out of it, and the answer is a count. -// -// # A refusal has to say WHICH refusal it is -// -// On Windows a port already held by the scale of this station cannot be opened a second -// time, and « accès refusé » alone would send a volunteer hunting for a permission -// problem that does not exist. That hint is only true of a refusal that came from the -// SYSTEM, though: link settings no port could accept are named — with the key of -// scale.options to correct — before anything is opened, and must never reach the screen -// as a port somebody else is holding. -func (h adminHardware) DetectScale(ctx context.Context, port string) (web.ScaleDetection, error) { - if strings.TrimSpace(port) == "" { - return web.ScaleDetection{}, errors.New("indiquez le port à écouter : COM8, /dev/balance-serial") - } - candidates := h.serialCandidates() - if len(candidates) == 0 { - // Nothing to recognise WITH, so nothing is opened: a serial port is exclusive, and - // holding one for three seconds to hand back silence is the answer that sends a - // volunteer looking for a cable. The screen says what to do instead. - return web.ScaleDetection{Port: port, Message: h.nothingDetectable(port)}, nil - } - - reading, err := h.listen(ctx, port, detectWindow, candidates) - if err != nil { - return web.ScaleDetection{}, err - } - recognised := reading.recognised() - - report := web.ScaleDetection{ - Port: port, ValidCount: reading.validCount(), Frames: reading.frames(), - } - switch { - case len(recognised) > 0: - // The protocol the FRAMES named, never the first entry of a registry. What goes - // into the form is the driver that recognised what came out of the cable, and the - // sentence names every model that recognised the same stream — which is what the - // two GRAM entries do, since they share one grammar and differ only by the sticker. - report.Driver = recognised[0].Descriptor.ID - report.Message = fmt.Sprintf("%s : %d trame(s) valide(s) en %s — %s.", - port, report.ValidCount, detectWindow, modelsRecognising(recognised)) - case reading.read > 0: - // Bytes, but no frame: something is talking on that port and it is not a scale - // this binary understands. That is a different remedy — bitrate, or another - // device on the same cable — and it must not be reported as silence. - report.Message = fmt.Sprintf("%s : %d octet(s) reçus, aucune trame reconnue. "+ - "Vérifiez la vitesse de la liaison, ou l'appareil branché sur ce port.", port, reading.read) - default: - report.Message = fmt.Sprintf("%s : aucun octet reçu en %s. La balance est-elle "+ - "allumée, et le câble branché sur ce port ?", port, detectWindow) - } - return report, nil -} - -// CaptureFrames records the raw frames of one port for a bounded duration, which is what -// a support call needs: the bytes, not our reading of them (§14.4, §15.4). -func (h adminHardware) CaptureFrames(ctx context.Context, port string, d time.Duration) ([]string, error) { - if strings.TrimSpace(port) == "" { - return nil, errors.New("indiquez le port à écouter : COM8, /dev/balance-serial") - } - candidates := h.serialCandidates() - if len(candidates) == 0 { - // Where a frame ENDS is a fact about a protocol, so a binary with no protocol has - // no way to cut a stream into frames. Saying so is the honest answer; handing back - // an empty list would look like a silent cable. - return nil, errors.New(h.nothingDetectable(port)) - } - if d <= 0 || d > captureCeiling { - d = detectWindow - } - reading, err := h.listen(ctx, port, d, candidates) - if err != nil { - return nil, err - } - return reading.frames(), nil -} - -// serialCandidates is one fresh decoder per protocol that declares it can be recognised -// on a serial port. -// -// THE ENUMERATION IS OURS, THE RECOGNITION IS THEIRS. Which ports this machine has is a -// question for the operating system and it is answered by Ports above; what a frame of a -// given protocol looks like is a question for the driver, and it answers it by handing -// over a decoder. Between the two there is nothing left here that has to change when a -// model is added. -func (h adminHardware) serialCandidates() []scale.Candidate { - if h.scales == nil { - return nil - } - return h.scales.Candidates(scale.EndpointSerialPort) -} - -// nothingDetectable is what the screen reads when no protocol of this binary knows how to -// be recognised by listening to a port. -// -// It is a LEGITIMATE outcome and not a fault: a scale that only speaks when it is polled -// cannot be found by listening, and a driver saying so is more useful than a button whose -// only possible answer is silence (§9.3, ADR-025). The sentence therefore ends on what to -// do — choose the protocol by hand — and names the ones that exist, because « aucun » with -// no list is how a volunteer concludes the machine is broken. -func (h adminHardware) nothingDetectable(port string) string { - labels := make([]string, 0, len(h.registries.Scales)) - for _, descriptor := range h.registries.Scales { - labels = append(labels, descriptor.Label) - } - if len(labels) == 0 { - return fmt.Sprintf("%s : aucun protocole n'est embarqué dans ce binaire, il n'y a "+ - "rien à détecter.", port) - } - return fmt.Sprintf("%s : aucun protocole de ce binaire ne sait se détecter en écoutant "+ - "un port série. Choisissez-le à la main dans la liste : %s.", - port, strings.Join(labels, ", ")) -} - -// portReading is everything one listening window observed on a port. -type portReading struct { - // read is how many bytes arrived, all of them, which is what tells silence from a - // device talking a language nobody here speaks. - read int - // raw is the tail of the stream, kept verbatim so that it can be cut into frames once - // it is known WHICH protocol's cut applies. Bounded by bytesKept. - raw []byte - // candidates are the protocols that were tried, in registration order, each holding - // the decoder it was tried with and what that decoder made of the stream. - candidates []candidateReading -} - -// candidateReading is what one protocol made of the stream. -type candidateReading struct { - scale.Candidate - // count is how many measurements this protocol's decoder yielded. Zero means it did - // not recognise the stream, which is an answer about the stream and not about the - // driver. - count int -} - -// recognised reports the protocols whose decoder yielded at least one measurement, in -// registration order. -// -// SEVERAL is the normal case of this parc and not an ambiguity to resolve: the GRAM XFOC -// RS and the GRAM XFOC + share one grammar, so both recognise the same bytes, and the -// choice between them is read off the sticker (§9.3). Picking one and staying quiet about -// the other is what the screen may not do. -func (r portReading) recognised() []candidateReading { - var out []candidateReading - for _, candidate := range r.candidates { - if candidate.count > 0 { - out = append(out, candidate) - } - } - return out -} - -// validCount is how many frames the detection announces, which is the count of the -// protocol that recognised the most. -func (r portReading) validCount() int { - best := 0 - for _, candidate := range r.candidates { - if candidate.count > best { - best = candidate.count - } - } - return best -} - -// frames cuts the stream into the raw frames the viewer of §14.4 shows. -// -// It is cut by the decoder that RECOGNISED, and by the first candidate when none did. -// That second case is a presentation choice and claims nothing: the bytes are shown so -// that somebody can read them out on the telephone, and slicing them on a grammar that -// refused them is still more legible than one line of 3 000 characters. -// -// It asks the decoder rather than searching for CR or LF, and that is the whole point of -// the method being on the decoder: a GRAM XFOC PLUS delimits with control codes and sends -// no line ending at all, so this viewer showed NOTHING on the very hardware of the bench -// while announcing twelve valid frames beside it — the defect that cost the capture of -// 29/07, one storey up. -func (r portReading) frames() []string { - decoder := r.cutter() - if decoder == nil { - return nil - } - var frames []string - for pending := r.raw; len(pending) > 0; { - end := decoder.FrameEnd(pending) - if end < 0 { - break // the rest of this frame never arrived - } - line := trimTerminator(pending[:end]) - pending = pending[end:] - if len(bytes.TrimSpace(line)) == 0 { - continue - } - frames = append(frames, string(line)) - if len(frames) > framesKept { - frames = frames[len(frames)-framesKept:] - } - } - return frames -} - -// cutter is the decoder whose cut the frame viewer shows. -func (r portReading) cutter() domain.Decoder { - if recognised := r.recognised(); len(recognised) > 0 { - return recognised[0].Decoder - } - if len(r.candidates) > 0 { - return r.candidates[0].Decoder - } - return nil -} - -// listen opens the port, reads for one window and reports what every candidate protocol -// made of what came out of it. -// -// It NEVER RECONNECTS, exactly like `openscale capture` and for the same reason: a -// cadence measured across an outage describes the outage. A link that drops ends the -// listening, and everything read up to then is still reported — the frames ARE the -// diagnosis, and a lost cable is not a failure of this function. -// -// Every candidate is fed the SAME bytes, each into its own decoder. Feeding them one -// after another would need the port opened once per protocol, on a link that is exclusive -// and a scale that is not repeating itself. -func (h adminHardware) listen(ctx context.Context, port string, window time.Duration, - candidates []scale.Candidate) (portReading, error) { - link, err := h.linkFor(port) - if err != nil { - return portReading{}, err - } - stream, err := link.Open(link) - if err != nil { - // Every reason the LINK itself could be unusable has already been named by linkFor, - // with the key of scale.options to correct, so what is left here comes from the - // system — and that is what makes the two causes below the only two. Whoever adds a - // check upstream of this call must name its own refusal there, or a settings mistake - // will once again reach a volunteer as a port that somebody else is holding. - return portReading{}, fmt.Errorf("le port %s n'a pas pu être ouvert : %w. Deux causes "+ - "possibles : un autre programme le tient — la balance de ce poste en premier, "+ - "un port série est EXCLUSIF sous Windows — ou bien ce port n'existe plus sur "+ - "cette machine", port, err) - } - defer stream.Close() - - reading := portReading{candidates: make([]candidateReading, 0, len(candidates))} - for _, candidate := range candidates { - reading.candidates = append(reading.candidates, candidateReading{Candidate: candidate}) - } - - deadline := h.clock.Now().Add(window) - buffer := make([]byte, defaultReadBuffer) - for h.clock.Now().Before(deadline) { - if ctx.Err() != nil { - // The browser gave up, or the budget of the handler ran out. What was read is - // still worth reporting: the caller decides, and a cancelled context is not a - // reason to throw eight frames away. - break - } - // The Opener contract requires this Read to BLOCK until bytes arrive or its own - // timeout elapses (internal/scale/serial): a Read returning (0, nil) at once would - // make this loop spin. - n, readErr := stream.Read(buffer) - if n > 0 { - now := h.clock.Now() - reading.read += n - reading.raw = keepTail(append(reading.raw, buffer[:n]...), bytesKept) - for i := range reading.candidates { - reading.candidates[i].count += len(reading.candidates[i].Decoder.Feed(buffer[:n], now)) - } - } - if readErr != nil { - break - } - } - return reading, nil -} - -// keepTail bounds a growing buffer by dropping from the FRONT. -// -// From the front because the viewer shows the LAST frames: on a device that babbles, what -// is worth reading is what it is saying now. -func keepTail(data []byte, limit int) []byte { - if len(data) <= limit { - return data - } - return append([]byte(nil), data[len(data)-limit:]...) -} - -// linkFor is the link a detection listens on: the settings THIS station declares, -// completed by the defaults of the parc, on the port being probed. -// -// # The completion is written here on purpose -// -// A link assembled field by field carried no bitrate, no character size, no parity and no -// stop bits, and the real opener refuses such a link before it reaches the device: the -// detection could not succeed on any port of any machine, and every port of a scan came -// back accused of being taken. Calling serial.Options.Complete at the place where the -// port is BOUND is what makes that visible to the next reader, and what lets the caller -// tell a refusal of these settings from a refusal of the system. -// -// # The port always wins over the configuration -// -// A scan probes the ports of the machine one after the other. Taking the port from the -// configuration would interrogate the same one N times and report the other N-1 as silent. -func (h adminHardware) linkFor(port string) (serial.Options, error) { - link, err := h.declaredLink() - if err != nil { - return serial.Options{}, fmt.Errorf("les réglages série de ce poste sont refusés, "+ - "corrigez-les avant de détecter : %w", err) - } - link.Port = port - link.Clock = h.clock - if h.open != nil { - link.Open = h.open - } - return link, nil -} - -// declaredLink reads the link settings this station declares and completes them. -// -// A station that has declared nothing yet — the one being installed, which is precisely -// when this route is used — falls back on the defaults of the parc. A station that -// declares 19200 bauds is listened to at 19200: at the figure of the parc its scale would -// answer bytes that decode to nothing, and the screen would send somebody to check a -// cable that is fine. -func (h adminHardware) declaredLink() (serial.Options, error) { - var declared domain.DriverOptions - if h.config != nil { - declared = h.config().Scale.Options - } - link, err := serial.ParseOptions(declared) - if err != nil { - return serial.Options{}, err - } - return link.Complete() -} - -// stationDecoder builds a decoder of the protocol THIS station declares. -// -// A fresh one per call, never a field of this struct: the « Rejouer cette trame » button -// can be pressed twice, and a decoder that kept half of the first frame would complete it -// with the beginning of the second — the fabricated mass the grammar exists to refuse. -// -// A station that declares no protocol is refused by name. It is the case of a station -// running without a scale, and « rejouer une trame » there is a question with no grammar -// to answer it; saying so is more useful than decoding with somebody else's. -func (h adminHardware) stationDecoder() (domain.Decoder, error) { - if h.scales == nil { - return nil, errors.New("aucun protocole de balance n'est embarqué dans ce binaire : " + - "il n'y a pas de grammaire pour lire cette trame") - } - var declared string - if h.config != nil { - declared = h.config().Scale.Type - } - if strings.TrimSpace(declared) == "" { - return nil, errors.New("ce poste ne déclare aucun protocole de balance (scale.type) : " + - "une trame se rejoue dans la grammaire du protocole qui l'a émise, jamais dans " + - "une autre. Renseignez scale.type sur la page Matériel") - } - decoder, err := h.scales.NewDecoder(declared) - if err != nil { - return nil, err - } - return decoder, nil -} - -// defaultReadBuffer is how many bytes one read may hand back. -// -// The same 4 KiB as the production loop, and NOT the 16 of the legacy SetupComm: a queue -// smaller than one 18-byte frame is one of the two reasons the legacy corpus is full of -// half frames (§9.1). -const defaultReadBuffer = 4096 - -// modelsRecognising names the models whose decoder recognised the frames that were read. -// -// It is built from WHAT ANSWERED and no longer from the whole registry, and the two are -// the same sentence only as long as one grammar exists. The moment a second protocol is -// registered, « même décodeur » becomes false of the pair — and the honest list is the -// one the bytes themselves drew. -// -// Several models is the normal case here: the GRAM XFOC RS and the GRAM XFOC + are one -// grammar and two stickers, the frames cannot tell them apart, and the sentence says so -// instead of picking one (§9.3). -func modelsRecognising(recognised []candidateReading) string { - labels := make([]string, 0, len(recognised)) - for _, candidate := range recognised { - labels = append(labels, candidate.Descriptor.Label) - } - if len(labels) == 1 { - return labels[0] - } - return strings.Join(labels, " ou ") + " : même décodeur, le choix se lit sur l'étiquette " + - "de la balance (§9.3)" -} - -// LabelPreview renders the label as a PNG, through the SAME renderer that prints (A2). -// -// One renderer and not two is the whole of decision A2: an aperçu produced by a second -// code path would be a picture of what somebody hoped the printer would do. The offset is -// recomposed into the template on every call, so that a volunteer pressing the ±1 dot -// arrow sees the label move. -func (h adminHardware) LabelPreview(_ context.Context, q web.PreviewQuery) ([]byte, error) { - cfg := h.hub.Config() - templates, err := templatesFor(cfg, h.registries) - if err != nil { - return nil, err - } - name := q.Template - if name == "" { - name = cfg.Printer.Template - } - template, known := templates[name] - if !known { - return nil, fmt.Errorf("gabarit %q inconnu ; gabarits disponibles : %s", - name, strings.Join(h.registries.TemplateNames(), ", ")) - } - - image, err := h.previewImage(cfg, template, q) - if err != nil { - return nil, err - } - var out bytes.Buffer - if err := printing.EncodePNG(&out, image); err != nil { - return nil, fmt.Errorf("aperçu non encodé : %w", err) - } - return out.Bytes(), nil -} - -// previewImage draws either the demonstration label or the one the station is holding. -// -// The station's own label is the default because that is what makes the aperçu a -// verification rather than an illustration: after a weighing, the screen shows the very -// label that came out. Demo is what the settings screen asks for while nobody is weighing. -func (h adminHardware) previewImage(cfg domain.Config, template domain.Template, - q web.PreviewQuery) (*image.Gray, error) { - if q.Demo { - rules := cfg.Pricing - if q.Dual { - // The two-tier grid of the document, so that an operator sees the crowded case - // — the one where a field can overflow — without having to configure it first. - rules = domain.LaCagetteRules() - } - image, _, err := renderDemo(template, rules, printing.RenderOptions{}) - return image, err - } - - snapshot := h.hub.State() - label := snapshot.Label - if label == nil { - label = snapshot.LastLabel - } - if label == nil { - return nil, errors.New("aucune étiquette en cours sur ce poste : demandez l'aperçu de " + - "démonstration (demo=1), ou pesez un produit") - } - return printing.Rasterize(&template, *label, domain.LocaleFrench, printing.RenderOptions{}) -} - -// Replay pushes one recorded frame back through the decoder (§14.4, page Journal). -// -// # What it is for -// -// A frame that caused an unexplained refusal becomes a permanent test, without a trip to -// the shop and without a scale (§15.4). It goes through the SAME grammar the driver uses -// — there is one — so « ça se décode » here means « ça se décode en service ». -// -// # What it deliberately does -// -// The decoded measurement is handed to the Hub exactly as a driver would hand it over, so -// the station reacts as it really would: the safeguards run, the banner appears, the state -// moves. A decoder called in isolation would answer the easy half of the question. -// -// # It decodes with THIS station's protocol -// -// The frame comes from the journal of this station, so the grammar that has to read it is -// the one scale.type names — not whichever the registry holds first. A frame replayed -// through the wrong grammar decodes to nothing and says « la balance a émis quelque chose -// que la grammaire refuse », which would be a lie about the scale and an invitation to go -// and look at it. -func (h adminHardware) Replay(ctx context.Context, raw string) error { - decoder, err := h.stationDecoder() - if err != nil { - return err - } - // A frame copied from a screen has lost whatever closed it. Adding a terminator back - // is not tolerance about the format: it is the byte a copy-paste cannot carry, and it - // is added ONLY when the protocol says the frame is still incomplete — a transmission - // that closes on its own control codes needs nothing and must not be padded. - if decoder.FrameEnd([]byte(raw)) < 0 { - raw += "\r\n" - } - measurements := decoder.Feed([]byte(raw), h.clock.Now()) - if len(measurements) == 0 { - return errors.New("cette trame ne se décode pas : aucune mesure. C'est la réponse — " + - "la balance a émis quelque chose que la grammaire de ce protocole refuse") - } - - h.technical.Technical(domain.LevelInfo, "scale", "", - "Trame rejouée depuis le journal.", strings.TrimSpace(raw)) - for _, measurement := range measurements { - select { - case h.hub.Measurements() <- domain.ScaleEvent{ - Status: domain.StatusConnected, Measurement: &measurement}: - case <-ctx.Done(): - return ctx.Err() - } - } - return nil -} diff --git a/cmd/openscale/listen.go b/cmd/openscale/listen.go new file mode 100644 index 0000000..5c5e237 --- /dev/null +++ b/cmd/openscale/listen.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "net" + "time" + + "openscale/internal/station/ports" + "openscale/internal/web" +) + +// This file takes the socket, and tells the TWO refusals apart: an address something +// is already answering on — another instance of this application — and an address this +// machine cannot have. THE SOCKET IS THE SINGLE-INSTANCE LOCK. + +// probeBudget is how long the single-instance probe waits for the address to answer. +// +// It is a NETWORK deadline in the TCP stack of the kernel, of the same nature as the +// write deadline of internal/web/stream.go, and it is spent before the injected clock +// exists as far as this decision is concerned: no business decision rests on it, and no +// test can be made to wait on it — a refused bind answers or does not answer at once. +const probeBudget = 250 * time.Millisecond + +// listen opens the socket and tells the TWO failures apart. +// +// THE SOCKET IS THE SINGLE-INSTANCE LOCK (internal/web/binder.go), and that package +// deliberately leaves the discrimination to its caller: only the caller can probe the +// address. The two cases need two different sentences — an address that refuses a bind +// AND answers is another instance of this very application (ERR-SYS-01); one that +// refuses and answers nothing is an address this station cannot have (ERR-SYS-02) — +// and sending a volunteer hunting for a ghost process is the failure this tells apart. +func listen(clk ports.Clock, address string, log ports.TechnicalLog) (*web.Binder, error) { + binder, err := web.Listen(clk, address, log) + if err == nil { + return binder, nil + } + if respondsToProbe(address) { + return nil, &serviceFailure{Code: codeAnotherInstance, Exit: exitFatal, Err: err, Message: fmt.Sprintf( + "une autre instance d'OpenScale est déjà lancée sur ce poste : %s répond déjà. "+ + "Arrêtez le service avant d'en lancer un second.", address)} + } + return nil, &serviceFailure{Code: codeCannotListen, Exit: exitFatal, Err: err, Message: fmt.Sprintf( + "impossible d'écouter sur %s : %v. Cette adresse n'appartient pas à ce poste, "+ + "ou le port est réservé.", address, err)} +} + +// respondsToProbe reports whether something is already answering on that address. +// +// A bare TCP connection and nothing more. Asking /healthz would say « and it is us », +// which is a stronger claim than this decision needs and a weaker probe than it looks: +// an instance in « configuration d'usine » answers, one wedged mid-shutdown may not, +// and either way the remedy a volunteer reads is the same — stop what is holding the +// address before starting a second one. +func respondsToProbe(address string) bool { + conn, err := net.DialTimeout("tcp", address, probeBudget) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/cmd/openscale/paths.go b/cmd/openscale/paths.go new file mode 100644 index 0000000..369e0a8 --- /dev/null +++ b/cmd/openscale/paths.go @@ -0,0 +1,35 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" +) + +// This file says where, under the data directory of §11.1, the service keeps what it +// produces: the photos of the catalog, the raw frames a transport drops and the files +// a driver renders. + +// imagesRoot is the photo directory of §11.1, laid out as +// <2 first characters of the sha>/. (§10.7). +func imagesRoot(dataDir string) string { return filepath.Join(dataDir, "images") } + +// imagesDir is that directory as the HTTP layer reads it. +func imagesDir(dataDir string) fs.FS { return os.DirFS(imagesRoot(dataDir)) } + +// labelsDir is where the `file` transport drops one copy per label (§11.1). +func labelsDir(dataDir string) string { return filepath.Join(dataDir, "labels") } + +// previewsDir is where a driver that PRODUCES FILES writes them — today, the `preview` +// driver's PNG and PDF of each label. +// +// A directory OF ITS OWN, and not the one above. Both answer « envoyez-moi le fichier de la +// dernière étiquette », and that sentence is how support works: mixing the raw frames of a +// transport with the images of an aperçu would make it a question with two answers. +// +// KNOWN DRIFT, and it is worth stating rather than discovering: this directory is handed to +// EVERY driver, so a second file-producing driver would share it silently and re-open the +// ambiguity this split closed. There is only one such driver today. Adding a second means +// giving each its own sub-directory — the argument above is about ONE answer per question, +// not about the `preview` driver. +func previewsDir(dataDir string) string { return filepath.Join(dataDir, "previews") } diff --git a/cmd/openscale/preview.go b/cmd/openscale/preview.go new file mode 100644 index 0000000..efbb1e0 --- /dev/null +++ b/cmd/openscale/preview.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "strings" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/web" +) + +// This file renders the aperçu of the label THROUGH THE SAME RENDERER THAT PRINTS +// (decision A2): an aperçu produced by a second code path would be a picture of what +// somebody hoped the printer would do. + +// LabelPreview renders the label as a PNG, through the SAME renderer that prints (A2). +// +// One renderer and not two is the whole of decision A2: an aperçu produced by a second +// code path would be a picture of what somebody hoped the printer would do. The offset is +// recomposed into the template on every call, so that a volunteer pressing the ±1 dot +// arrow sees the label move. +func (h adminHardware) LabelPreview(_ context.Context, q web.PreviewQuery) ([]byte, error) { + cfg := h.hub.Config() + templates, err := templatesFor(cfg, h.registries) + if err != nil { + return nil, err + } + name := q.Template + if name == "" { + name = cfg.Printer.Template + } + template, known := templates[name] + if !known { + return nil, fmt.Errorf("gabarit %q inconnu ; gabarits disponibles : %s", + name, strings.Join(h.registries.TemplateNames(), ", ")) + } + + image, err := h.previewImage(cfg, template, q) + if err != nil { + return nil, err + } + var out bytes.Buffer + if err := printing.EncodePNG(&out, image); err != nil { + return nil, fmt.Errorf("aperçu non encodé : %w", err) + } + return out.Bytes(), nil +} + +// previewImage draws either the demonstration label or the one the station is holding. +// +// The station's own label is the default because that is what makes the aperçu a +// verification rather than an illustration: after a weighing, the screen shows the very +// label that came out. Demo is what the settings screen asks for while nobody is weighing. +func (h adminHardware) previewImage(cfg domain.Config, template domain.Template, + q web.PreviewQuery) (*image.Gray, error) { + if q.Demo { + rules := cfg.Pricing + if q.Dual { + // The two-tier grid of the document, so that an operator sees the crowded case + // — the one where a field can overflow — without having to configure it first. + rules = domain.LaCagetteRules() + } + image, _, err := renderDemo(template, rules, printing.RenderOptions{}) + return image, err + } + + snapshot := h.hub.State() + label := snapshot.Label + if label == nil { + label = snapshot.LastLabel + } + if label == nil { + return nil, errors.New("aucune étiquette en cours sur ce poste : demandez l'aperçu de " + + "démonstration (demo=1), ou pesez un produit") + } + return printing.Rasterize(&template, *label, domain.LocaleFrench, printing.RenderOptions{}) +} diff --git a/cmd/openscale/protocol.go b/cmd/openscale/protocol.go new file mode 100644 index 0000000..608f971 --- /dev/null +++ b/cmd/openscale/protocol.go @@ -0,0 +1,70 @@ +package main + +import ( + "errors" + "strings" + + "openscale/internal/domain" + "openscale/internal/scale" +) + +// This file answers, for `openscale capture` and `openscale replay`, WHICH grammar a +// stream of bytes is read with: the list a usage line offers, the one used when nobody +// says, and the decoder the registry hands back for it. + +// protocolList is the French tail of every sentence that offers a choice of grammar. +func protocolList(r *scale.Registry) string { + descriptors := r.Descriptors() + if len(descriptors) == 0 { + return "aucun protocole n'est embarqué dans ce binaire" + } + ids := make([]string, 0, len(descriptors)) + for _, descriptor := range descriptors { + ids = append(ids, descriptor.ID) + } + return strings.Join(ids, ", ") +} + +// defaultProtocol is the protocol these two diagnostic commands decode with when nobody +// says otherwise: the first the composition root registered. +// +// # Why a default is legitimate HERE and not in the detection +// +// The detection answers « y a-t-il une balance ? » and its answer goes into a +// configuration file: naming the first entry of a registry there is a GUESS presented as +// a finding, and it stops being true the day a second grammar is registered. These two +// commands answer a different question. Somebody is standing in front of the hardware, +// they know which scale it is, and both commands PRINT the protocol they used — in the +// summary and, for a capture, in the header of the file it writes. A default that is +// announced is a convenience; a default that is silent is the defect of 29/07. +// +// An empty registry yields an empty string, and decoderOf turns that into a refusal that +// names the situation rather than a nil decoder three frames deeper. +func defaultProtocol(r *scale.Registry) string { + descriptors := r.Descriptors() + if len(descriptors) == 0 { + return "" + } + return descriptors[0].ID +} + +// decoderOf resolves the --type flag into a protocol and a decoder of its own. +// +// It returns the ID as well as the decoder because both commands SAY which grammar they +// used: a capture writes it into the file it produces, so that `openscale replay` never +// has to guess, and a replay prints it above the frames it re-displays. +func decoderOf(r *scale.Registry, requested string) (string, domain.Decoder, error) { + chosen := strings.TrimSpace(requested) + if chosen == "" { + chosen = defaultProtocol(r) + } + if chosen == "" { + return "", nil, errors.New("aucun protocole de balance n'est embarqué dans ce binaire : " + + "il n'y a aucune grammaire pour décoder ces octets") + } + decoder, err := r.NewDecoder(chosen) + if err != nil { + return "", nil, err + } + return chosen, decoder, nil +} diff --git a/cmd/openscale/registry_test.go b/cmd/openscale/registry_test.go new file mode 100644 index 0000000..1e728e1 --- /dev/null +++ b/cmd/openscale/registry_test.go @@ -0,0 +1,321 @@ +package main + +import ( + "reflect" + "testing" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/scale" +) + +// Completeness of a registry entry: a driver hands over its identity, its option schema, +// its self-tests, the geometry of its head and its decoder, and this is what refuses one +// that forgot any of them. It is the guard that makes « adding a model is one package and +// one line » true of a model somebody adds next year. + +// schemaExemptions names the drivers allowed to declare no option at all, one by one and +// with the reason, because an empty schema is otherwise the mark of a driver whose +// configuration nobody wired: the administration screen generates a form with no field +// and control 7 of §11.3 refuses every key the file carries for it. +// +// A LIST OF NAMES, deliberately, and short. Anybody adding to it is stating that a driver +// takes NOTHING from a configuration, which is a design decision and not a shortcut past +// a red test. +var schemaExemptions = map[string]string{ + domain.PrinterPreview: "le profil neutre ne porte AUCUN printer.options — noirceur, vitesse " + + "et nombre de copies se règlent sur une vraie impression — donc le driver sur lequel un " + + "poste en configuration d'usine retombe ne doit en réclamer aucun (§11.3)", +} + +// TestChaqueEntreeDuRegistreEstComplete. +// +// One sub-test per registered driver, on both registries, checking what a driver DECLARES +// rather than what it does. +func TestChaqueEntreeDuRegistreEstComplete(t *testing.T) { + scales, printers := scaleRegistry().Descriptors(), printerRegistry().Descriptors() + if len(scales) == 0 || len(printers) == 0 { + t.Fatalf("%d protocole(s) et %d driver(s) d'impression enregistrés : un registre vide "+ + "rend ce test muet", len(scales), len(printers)) + } + + for _, descriptor := range scales { + t.Run("balance/"+descriptor.ID, func(t *testing.T) { + checkIdentity(t, descriptor, "scale.type") + checkOptionSchema(t, descriptor) + checkScaleDetection(t, descriptor) + checkDecoder(t, descriptor) + }) + } + for _, descriptor := range printers { + t.Run("imprimante/"+descriptor.ID, func(t *testing.T) { + checkIdentity(t, descriptor, "printer.type") + checkOptionSchema(t, descriptor) + checkSelfTests(t, descriptor) + checkHeadGeometry(t, descriptor) + }) + } +} + +// checkIdentity holds the two strings every reader of a registry uses: the KEY a +// configuration file carries, and the WORDING a volunteer picks from a list. +// +// They are not interchangeable and the test says so on both sides. The key is compared +// exactly, so it is spelled the way a lookup spells it — lower case, no space; the label +// is what somebody reads on the hardware or in a menu, so a label shaped like an +// identifier is a driver that never wrote one (§9.3, §8.2). +func checkIdentity(t *testing.T, d domain.DriverDescriptor, key string) { + t.Helper() + if d.ID == "" { + t.Fatalf("un driver s'enregistre sans identifiant : c'est la valeur de %s dans "+ + "config.json, et la clé de la recherche du registre", key) + } + if !isRegistryKey(d.ID) { + t.Errorf("l'identifiant %q n'est pas une clé de registre : %s se compare caractère "+ + "pour caractère, donc l'identifiant s'écrit en minuscules, en chiffres et en traits "+ + "d'union — « gram-xfoc-plus », « raster »", d.ID, key) + } + switch { + case d.Label == "": + t.Errorf("le driver %q s'enregistre sans libellé : c'est ce qu'un bénévole lit dans "+ + "la liste déroulante, et un menu sans mot ne se choisit pas", d.ID) + case d.Label == d.ID || isRegistryKey(d.Label): + t.Errorf("le driver %q se présente comme %q, qui est un identifiant : le libellé est "+ + "le nom imprimé sur l'appareil (« GRAM XFOC + ») ou une phrase française qui dit ce "+ + "que le driver fait, jamais la clé de configuration", d.ID, d.Label) + } +} + +// checkOptionSchema is what the administration screen generates its form from and what +// control 7 of §11.3 validates a file against. An entry missing from it is a field the +// form offers and the driver never reads, or the other way round. +func checkOptionSchema(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + if len(d.Options) == 0 { + if why, exempt := schemaExemptions[d.ID]; exempt { + t.Logf("le driver %q ne déclare aucune option, et c'est un requis : %s", d.ID, why) + return + } + t.Errorf("le driver %q ne déclare aucune option : l'écran d'administration génère son "+ + "formulaire à partir de ce schéma, et le contrôle 7 de §11.3 refuse toute clé que "+ + "printer.options ou scale.options porte pour lui. Si ce driver ne prend vraiment RIEN "+ + "d'une configuration, inscrivez-le dans schemaExemptions avec la raison — ET traitez "+ + "le cas dans TestTheDeliveredConfigurationValidatesOnEveryPrinterOfThisBinary, qui "+ + "soumet la configuration LIVRÉE à chaque driver du registre : elle porte les options "+ + "de `raster`, que le contrôle 7 refusera pour le vôtre. Les deux vont ensemble, sans "+ + "quoi l'exemption rend ce test-ci vert et l'autre rouge", d.ID) + return + } + checkOptionKeys(t, d.ID, d.Options, "") +} + +// checkOptionKeys walks a schema, nested groups included, and holds the spelling of a key +// to what config.json carries: lower case and underscores, never a space and never a +// capital (§11.2). +func checkOptionKeys(t *testing.T, driverID string, schema []domain.OptionSchema, path string) { + t.Helper() + seen := make(map[string]bool, len(schema)) + for _, option := range schema { + full := path + option.Key + switch { + case option.Key == "": + t.Errorf("le driver %q déclare une option sans clé sous %q : une option sans nom "+ + "ne peut être ni saisie ni validée", driverID, path) + continue + case !isOptionKey(option.Key): + t.Errorf("le driver %q déclare l'option %q : une clé de config.json s'écrit en "+ + "minuscules, chiffres et tirets bas — « roll_capacity », « backoff_min_ms »", + driverID, full) + case seen[option.Key]: + t.Errorf("le driver %q déclare deux fois l'option %q : le formulaire généré "+ + "porterait deux champs pour une seule valeur", driverID, full) + } + seen[option.Key] = true + if len(option.Options) != 0 { + checkOptionKeys(t, driverID, option.Options, full+".") + } + } +} + +// checkSelfTests holds the buttons the Matériel page draws to the catalogue of §8.6. +// +// The registry already refuses a name the catalogue does not carry; what is verified here +// is the trip that name makes through the DESCRIPTOR — the plain strings the domain and +// then the front end read. A conversion that drifted would put on the screen a button +// whose route answers « auto-test inconnu ». +func checkSelfTests(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + for _, what := range d.SelfTests { + if _, err := printing.LookupSelfTest(what); err != nil { + t.Errorf("le driver %q déclare l'auto-test %q, que le catalogue de §8.6 ne porte "+ + "pas : %v. Un nom sans bouton est un auto-test que personne ne peut lancer", + d.ID, what, err) + } + } +} + +// checkHeadGeometry holds the three figures hard rules 3, 4 and 8 of §7.5 measure a +// template against (controls 29 and 38). +// +// ALL THREE OR NONE. Zero everywhere is the honest declaration of a driver that inks no +// paper — the rules then bear on domain.ReferenceHead — but a printable area declared +// without the pitch it is counted in is a number nobody can convert to a millimetre, and +// a validation would compare dots at one resolution against a template at another. +func checkHeadGeometry(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + head := d.Capabilities + declared := head.DotsPerMM != 0 || head.InkedWidthDots != 0 || head.InkedHeightDots != 0 + if declared && (head.DotsPerMM <= 0 || head.InkedWidthDots <= 0 || head.InkedHeightDots <= 0) { + t.Errorf("le driver %q déclare une géométrie incomplète (%g dots/mm, %d × %d dots "+ + "encrés) : les trois vont ensemble, parce qu'une surface en dots ne se convertit en "+ + "millimètres qu'au pas de la tête. Un driver qui n'encre aucun papier les laisse "+ + "toutes les trois à zéro et les règles de §7.5 portent alors sur domain.ReferenceHead", + d.ID, head.DotsPerMM, head.InkedWidthDots, head.InkedHeightDots) + } + if head.MaxCopies < 1 { + t.Errorf("le driver %q accepte %d copie(s) : une imprimante qui n'en accepte aucune "+ + "est une imprimante dont chaque étiquette est refusée", d.ID, head.MaxCopies) + } + checkTheGeometryWasMeasuredAndNotCopied(t, d) +} + +// checkTheGeometryWasMeasuredAndNotCopied is the clause NO conformance bench can hold. +// +// The printer suite runs against a BUILT driver and prints the template the subject +// declares. A driver that copied the head of the parc — 8 dots/mm, 280 × 200 dots encrés — +// into a package that never touched a WS408 passes all eighteen clauses: the template +// matches the declaration, so the geometry check is satisfied by the copy itself. +// +// What the copy then does is invisible until a station runs it. The three figures travel +// through printing.Registry.Descriptors into domain.Registries.PrinterHead, where controls +// 29 and 38 of §11.3 measure a template against them: every station naming that driver +// validates its label against a print head nobody owns, and §11.3 puts a station whose +// validation fails out of service. +// +// A driver has exactly two honest answers, and neither of them is « the same as raster ». +// It inks no paper, and the three figures stay at ZERO — the rules then bear on +// domain.ReferenceHead, which is what internal/printing/preview declares. Or it drives a +// head, and the three were MEASURED on that head — in which case coinciding with a WS408 to +// the dot is not something a measurement does twice by accident. +func checkTheGeometryWasMeasuredAndNotCopied(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + if d.ID == domain.PrinterRaster { + return // the driver of the parc IS the WS408: that is where the figures come from + } + head, reference := d.Capabilities, domain.ReferenceHead() + if head.DotsPerMM == reference.DotsPerMM && + head.InkedWidthDots == reference.InkedWidthDots && + head.InkedHeightDots == reference.InkedHeightDots { + t.Errorf("le driver %q déclare exactement la géométrie de la tête de référence "+ + "(%g dots/mm, %d × %d dots encrés), et il n'est pas le driver de cette tête. "+ + "Deux réponses honnêtes existent, et « la même que raster » n'en fait pas partie :\n"+ + " — ce driver n'encre aucun papier : laissez les TROIS chiffres à zéro, les règles "+ + "de §7.5 portent alors sur domain.ReferenceHead, et supprimez le refus du gabarit "+ + "étranger (internal/printing/preview montre cette forme) ;\n"+ + " — ce driver pilote une tête : les trois chiffres se MESURENT sur du papier, par "+ + "les auto-tests `ruler` et `alignment`, et une mesure ne retombe pas au dot près sur "+ + "une WS408 par hasard.\n"+ + "Recopiés, ils voyagent jusqu'aux contrôles 29 et 38 de §11.3 : chaque poste qui "+ + "nomme ce driver valide son gabarit contre une tête que personne ne possède", + d.ID, head.DotsPerMM, head.InkedWidthDots, head.InkedHeightDots) + } +} + +// checkScaleDetection holds what the descriptor promises about « Détecter +// automatiquement » to what the registry can really try (§14.4). +// +// A protocol that declared a serial endpoint and never appeared among the candidates +// would offer a detection whose only possible outcome is silence — which is the answer a +// broken cable gives, and it sends a volunteer looking for one. +func checkScaleDetection(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + if d.Endpoint != domain.EndpointSerialPort && d.Endpoint != domain.EndpointNone { + t.Errorf("le protocole %q déclare le point d'accès %q : `openscale doctor` et l'écran "+ + "d'administration ne lisent que %q et %q, et une troisième orthographe est un "+ + "contrôle qui ne s'applique à rien", d.ID, d.Endpoint, + domain.EndpointSerialPort, domain.EndpointNone) + return + } + proposed := false + for _, candidate := range scaleRegistry().Candidates(scale.EndpointSerialPort) { + if candidate.Descriptor.ID == d.ID { + proposed = true + } + } + if wanted := d.Endpoint == domain.EndpointSerialPort; proposed != wanted { + t.Errorf("le protocole %q déclare le point d'accès %q et la détection sur port série "+ + "le propose = %t : la déclaration du descripteur et ce que le registre essaie "+ + "vraiment sont la même chose, ou la détection ment", d.ID, d.Endpoint, proposed) + } +} + +// checkDecoder holds the grammar every tool that reads bytes without running a station +// asks the registry for: the detection of §14.4, `openscale capture`, `openscale replay` +// and the « Rejouer cette trame » button. +// +// Register already refuses a driver whose decoder factory is nil. What it cannot see is a +// factory that ANSWERS nil, or one that hands the same accumulator to two callers — and +// that second one is the fabricated mass this whole grammar exists to refuse: half a frame +// read on one port, completed by the bytes of another, on a label somebody sticks on a bag. +func checkDecoder(t *testing.T, d domain.DriverDescriptor) { + t.Helper() + registry := scaleRegistry() + first, err := registry.NewDecoder(d.ID) + if err != nil { + t.Fatalf("le protocole %q est enregistré et le registre n'en donne aucun décodeur : %v", + d.ID, err) + } + second, err := registry.NewDecoder(d.ID) + if err != nil { + t.Fatalf("second décodeur de %q : %v", d.ID, err) + } + if first == nil || second == nil { + t.Fatalf("la fabrique de décodeurs de %q répond nil : `openscale capture`, la détection "+ + "et « Rejouer cette trame » lisent des octets sans faire tourner de poste, et chacun "+ + "appelle celle-ci", d.ID) + } + if address(first) == address(second) && address(first) != 0 { + t.Errorf("les deux décodeurs de %q sont le même objet : un décodeur retient les octets "+ + "qui attendent la fin de leur trame, et deux ports qui partagent ce tampon "+ + "complètent la demi-trame de l'un avec les octets de l'autre — une masse que "+ + "personne n'a pesée, sur une étiquette collée sur un sac", d.ID) + } + if resyncs := first.Resyncs(); resyncs != 0 { + t.Errorf("un décodeur neuf de %q annonce déjà %d resynchronisation(s) : il n'est pas "+ + "neuf, il est partagé", d.ID, resyncs) + } +} + +// address is the identity of the value behind an interface, or zero when it has none. +func address(v any) uintptr { + value := reflect.ValueOf(v) + if value.Kind() != reflect.Pointer { + return 0 + } + return value.Pointer() +} + +// isRegistryKey reports whether s is spelled the way a registry key is: the lookup is an +// exact string comparison, and the case of a suffix is precisely what split the legacy +// code into two functions for one protocol. +func isRegistryKey(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + return true +} + +// isOptionKey reports whether s is spelled the way config.json carries an option key. +func isOptionKey(s string) bool { + for _, r := range s { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + return s != "" +} diff --git a/cmd/openscale/report.go b/cmd/openscale/report.go new file mode 100644 index 0000000..7463e95 --- /dev/null +++ b/cmd/openscale/report.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "io" + "time" + + "openscale/internal/domain" +) + +// This file is what `openscale capture` and `openscale replay` both SAY about a stream +// of frames: how many decoded, how stable they were, at what cadence — and every +// French unit those two sentences are written in. + +// frameReport accumulates everything `capture` and `replay` say about a stream of +// frames: how many were decoded, how stable they were, and at what cadence. +type frameReport struct { + // lines is how many frame lines the stream offered -- read from a file, or written + // by a capture. It is the denominator of the §18 demonstration: "100 frames out of + // 100, where the legacy application lost one in two". + lines int + // frames is how many measurements came out of the decoder. + frames int + stable, unstable, unspecified, overload int + // resyncs is what the decoder of the protocol reports. A line that resynchronises + // constantly is a cabling problem, not a parser problem. + resyncs int + rate domain.RateMeter + // measured says whether the instants the cadence was computed from are REAL ones. A + // file with no timestamp gets reconstituted instants, and a median computed from + // those would hand the nominal rate back as though it had been observed -- which is + // exactly the confusion §21 n° 3 exists to end. + measured bool +} + +// observe folds one decoded measurement into the report. +func (r *frameReport) observe(m domain.Measurement) { + r.frames++ + switch { + case m.Overload: + r.overload++ + case m.Stability == domain.Stable: + r.stable++ + case m.Stability == domain.Unstable: + r.unstable++ + default: + r.unspecified++ + } + r.rate.Observe(m) +} + +// write ends both commands with the two figures §21 n° 3 sends somebody to the shop +// for: the observed cadence and the proportion of stable frames. +func (r *frameReport) write(out io.Writer, policy domain.StabilityPolicy) { + fmt.Fprintln(out, "Résumé") + fmt.Fprintf(out, " %d trame%s décodée%s sur %d ligne%s, %d resynchronisation%s\n", + r.frames, plural(r.frames), plural(r.frames), + r.lines, plural(r.lines), r.resyncs, plural(r.resyncs)) + r.writeCadence(out, policy) + fmt.Fprintf(out, " trames stables : %d sur %d (%s) · instables : %d · sans indication : %d\n", + r.stable, r.frames, percent(r.stable, r.frames), r.unstable, r.unspecified) + if r.overload > 0 { + fmt.Fprintf(out, " trames en surcharge (OL) : %d — la balance se déclare hors capacité\n", r.overload) + } +} + +// writeCadence writes the median, the expiry it derives and, when it applies, the +// amber-light sentence of §15.4. +// +// It NEVER prints a median it cannot stand behind. Three answers, and they are not +// interchangeable: no timestamps at all, not enough intervals yet (RateMeter needs +// eight), or a real measurement. +func (r *frameReport) writeCadence(out io.Writer, policy domain.StabilityPolicy) { + if !r.measured { + fmt.Fprintf(out, " cadence : NON MESURABLE — le fichier ne porte pas d'horodates. Les instants\n"+ + " sont reconstitués à %s, la cadence NOMINALE déclarée, qui est justement le\n"+ + " chiffre qu'une mesure doit remplacer (§21 n° 3).\n", millis(nominalRate)) + return + } + median, ok := r.rate.Median() + if !ok { + observed := r.rate.Observations() + fmt.Fprintf(out, " cadence : pas encore mesurable — %d intervalle%s observé%s, il en faut 8\n", + observed, plural(observed), plural(observed)) + return + } + fmt.Fprintf(out, " cadence observée : médiane %s %s\n", + millis(median), observationsLabel(r.rate.Observations())) + fmt.Fprintf(out, " péremption dérivée : %s (facteur %d, plancher %s, plafond %s)\n", + millis(r.rate.Expiry(policy, nominalRate)), policy.ExpiryFactor, + millis(time.Duration(policy.ExpiryFloor)), millis(time.Duration(policy.ExpiryCeiling))) + if tooSlow, slow := r.rate.RateIsTooSlow(policy); tooSlow { + fmt.Fprintf(out, " ATTENTION : la balance émet toutes les %s ; le poids est considéré périmé au\n"+ + " bout de %s. Le poste se taira entre deux trames (§15.4) — vérifier le câble\n"+ + " et le réglage de la balance.\n", + secondsLabel(slow), secondsLabel(time.Duration(policy.ExpiryCeiling))) + } +} + +// observationsLabel says how many intervals the median rests on, and says it honestly +// when the ring is full: RateMeter remembers the LAST 64, so on a thirty-minute +// capture the figure is a recent median and not the median of the whole session. It +// is the same number the dashboard and `openscale doctor` act on, which is the point +// -- the capture must report what production will decide with. +func observationsLabel(n int) string { + if n >= 64 { + return "sur les 64 derniers intervalles" + } + return fmt.Sprintf("sur %d intervalle%s", n, plural(n)) +} + +// plural is the French plural mark: nothing up to one, "s" beyond. French writes +// « 0 trame » and « 1 trame », and these sentences are read by volunteers. +func plural(n int) string { + if n > 1 { + return "s" + } + return "" +} + +// frameLine renders one decoded measurement -- its rank, when it arrived, what it +// weighed and what the scale said about its own reading -- followed by whatever the +// caller has to add: the latch state for replay, nothing for capture. +func frameLine(rank int, since time.Duration, m domain.Measurement, tail string) string { + line := fmt.Sprintf("%4d %10s %9s kg %s", + rank, offsetLabel(since), m.Gross.Kilos(), stabilityLabel(m)) + if tail == "" { + return line + } + return fmt.Sprintf("%-50s%s", line, tail) +} + +// stabilityLabel is what the frame said about itself, in French. +// +// Overload comes FIRST because it dominates: a scale over capacity may report any +// mass at all, including a plausible one, and safeguard rule 1 fires on the flag +// rather than on the value. +func stabilityLabel(m domain.Measurement) string { + if m.Overload { + return "surcharge (OL)" + } + switch m.Stability { + case domain.Stable: + return "stable" + case domain.Unstable: + return "instable" + default: + return "sans indication" + } +} + +// offsetLabel renders a delay since the start, French comma, milliseconds. +func offsetLabel(d time.Duration) string { + ms := d.Milliseconds() + sign := "+" + if ms < 0 { + sign, ms = "-", -ms + } + return fmt.Sprintf("%s%d,%03d s", sign, ms/1000, ms%1000) +} + +// millis renders a duration in whole milliseconds, the unit the configuration keys +// carry in their own names (expiry_floor_ms, min_duration_ms). +func millis(d time.Duration) string { return fmt.Sprintf("%d ms", d.Milliseconds()) } + +// secondsLabel renders a duration in seconds with at most one decimal, so that the +// amber-light sentence reads exactly as §15.4 writes it: « la balance émet toutes les +// 2,4 s ; le poids est considéré périmé au bout de 5 s ». +func secondsLabel(d time.Duration) string { + tenths := (d.Milliseconds() + 50) / 100 + if tenths%10 == 0 { + return fmt.Sprintf("%d s", tenths/10) + } + return fmt.Sprintf("%d,%d s", tenths/10, tenths%10) +} + +// percent renders part/whole with one decimal, French comma. +// +// Integer arithmetic: there is no float anywhere in this application, and a +// percentage on a diagnostic screen is no reason to introduce the first one. +func percent(part, whole int) string { + if whole <= 0 { + return "—" + } + tenths := 1000 * part / whole + return fmt.Sprintf("%d,%d %%", tenths/10, tenths%10) +} diff --git a/cmd/openscale/report_test.go b/cmd/openscale/report_test.go new file mode 100644 index 0000000..4a5d9b9 --- /dev/null +++ b/cmd/openscale/report_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "testing" + "time" +) + +// The summary `capture` and `replay` share, and the French it is written in: integer +// arithmetic everywhere — there is no float in this application — and a median that +// says how many intervals it rests on. + +// TestFrenchNumbersUseIntegerArithmetic: no float ever reaches this application, and a +// percentage on a diagnostic screen is no reason to introduce the first one. +func TestFrenchNumbersUseIntegerArithmetic(t *testing.T) { + percents := []struct { + part, whole int + want string + }{ + {10, 12, "83,3 %"}, + {100, 100, "100,0 %"}, + {0, 7, "0,0 %"}, + {1, 3, "33,3 %"}, + {1, 0, "—"}, + } + for _, c := range percents { + if got := percent(c.part, c.whole); got != c.want { + t.Errorf("percent(%d, %d) = %q, want %q", c.part, c.whole, got, c.want) + } + } + + durations := []struct { + d time.Duration + milli, seconds string + }{ + {412 * time.Millisecond, "412 ms", "0,4 s"}, + {2400 * time.Millisecond, "2400 ms", "2,4 s"}, + {5 * time.Second, "5000 ms", "5 s"}, + {0, "0 ms", "0 s"}, + } + for _, c := range durations { + if got := millis(c.d); got != c.milli { + t.Errorf("millis(%s) = %q, want %q", c.d, got, c.milli) + } + if got := secondsLabel(c.d); got != c.seconds { + t.Errorf("secondsLabel(%s) = %q, want %q", c.d, got, c.seconds) + } + } + + offsets := []struct { + d time.Duration + want string + }{ + {0, "+0,000 s"}, + {412 * time.Millisecond, "+0,412 s"}, + {75 * time.Second, "+75,000 s"}, + {-2 * time.Millisecond, "-0,002 s"}, + } + for _, c := range offsets { + if got := offsetLabel(c.d); got != c.want { + t.Errorf("offsetLabel(%s) = %q, want %q", c.d, got, c.want) + } + } +} + +// TestObservationsLabelSaysWhichIntervalsTheMedianRestsOn. RateMeter remembers the +// LAST 64 intervals, so on a thirty-minute capture the median is a recent one and not +// the median of the session. Saying « sur 64 intervalles » flat would be a quiet lie. +func TestObservationsLabelSaysWhichIntervalsTheMedianRestsOn(t *testing.T) { + if got := observationsLabel(11); got != "sur 11 intervalles" { + t.Errorf("observationsLabel(11) = %q", got) + } + if got := observationsLabel(64); got != "sur les 64 derniers intervalles" { + t.Errorf("observationsLabel(64) = %q", got) + } +} diff --git a/cmd/openscale/scriptedstream_test.go b/cmd/openscale/scriptedstream_test.go new file mode 100644 index 0000000..05a0f35 --- /dev/null +++ b/cmd/openscale/scriptedstream_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "io" + "testing" + "time" + + "openscale/internal/fake" + "openscale/internal/scale/serial" +) + +// The serial port a capture is driven against: a scripted stream that hands back the +// bytes of a scale, read by read, ON THE INJECTED CLOCK. It is what makes the whole of +// `openscale capture` testable with no scale on the bench (§9.1). + +// --- the port --------------------------------------------------------------------- + +// scriptedRead is one answer of a scripted port: how long the read took, what it +// hands back, and whether it fails. +type scriptedRead struct { + after time.Duration + data string + err error +} + +// scriptedStream is the io.ReadCloser a test hands back instead of a serial port. +// +// Every read ADVANCES THE INJECTED CLOCK by the delay the script gives it, which is +// what lets a thirty-minute capture be exercised in microseconds and without a single +// time.Sleep: the instants the capture records are the ones the script decided, and +// the cadence it measures is the one the script emitted at. +type scriptedStream struct { + clock *fake.Clock + script []scriptedRead + at int + closes int + // silence is what the stream does once the script runs dry: it comes back with no + // byte and no error, which is what a real port does between two frames, and it is + // what lets the capture reach its deadline. + silence time.Duration + // endErr, when set, is what the port answers instead of staying silent -- a cable + // pulled in the middle of a measurement campaign. + endErr error + // link is the last set of options the opener was handed, and opens counts how many + // times it was called at all. + // + // A double that IGNORED its options cannot tell a caller which built a usable link + // from one which handed over a struct with no bitrate, no parity and no stop bits -- + // and a real port refuses the second before it touches the device. Recording them is + // what makes that assertion possible; internal/scale/gramxfoc does the same with the + // port name. + link serial.Options + opens int +} + +// newScriptedStream returns a port that answers these reads, in order, and then goes +// quiet one read timeout at a time. +func newScriptedStream(clock *fake.Clock, script ...scriptedRead) *scriptedStream { + return &scriptedStream{clock: clock, script: script, silence: time.Second} +} + +// emitting returns a port that sends count frames at the nominal cadence of the +// script, marking the frames at the given ranks unstable. +func emitting(clock *fake.Clock, count int, unstableRanks ...int) *scriptedStream { + unstable := make(map[int]bool, len(unstableRanks)) + for _, rank := range unstableRanks { + unstable[rank] = true + } + script := make([]scriptedRead, 0, count) + for rank := 1; rank <= count; rank++ { + frame := nominalFrame + if unstable[rank] { + frame = unstableFrame + } + script = append(script, scriptedRead{after: cadence, data: frame}) + } + return newScriptedStream(clock, script...) +} + +func (s *scriptedStream) Read(buffer []byte) (int, error) { + if s.at >= len(s.script) { + s.clock.Advance(s.silence) + return 0, s.endErr + } + read := s.script[s.at] + s.at++ + s.clock.Advance(read.after) + return copy(buffer, read.data), read.err +} + +func (s *scriptedStream) Close() error { + s.closes++ + return nil +} + +// opener is the seam capture is injected through: a serial port cannot be opened by +// `go test`, so the whole command is exercised through this. +// +// It KEEPS what it was handed, so that a test can assert on the link a caller built and +// not only on the bytes it read back. +func (s *scriptedStream) opener() serial.Opener { + return func(o serial.Options) (io.ReadCloser, error) { + s.link = o + s.opens++ + return s, nil + } +} + +// refusingOpener is a port that is not there: the commonest failure of the bench, and +// the one a volunteer meets when the adapter is on another COM number. +func refusingOpener(err error) serial.Opener { + return func(serial.Options) (io.ReadCloser, error) { return nil, err } +} + +// Compile-time proof that the scripted port satisfies what an Opener has to return. +var _ io.ReadCloser = (*scriptedStream)(nil) + +// TestScriptedStreamNeverNeedsTheRealClock guards the guard: if this double ever +// stopped driving the injected clock, every temporal assertion above would silently +// become a test of nothing. +func TestScriptedStreamNeverNeedsTheRealClock(t *testing.T) { + clock := fake.NewClock(captureStart) + stream := emitting(clock, 3) + buffer := make([]byte, 64) + for i := 1; i <= 3; i++ { + n, err := stream.Read(buffer) + if err != nil || n == 0 { + t.Fatalf("lecture %d : %d octets, %v", i, n, err) + } + if want := captureStart.Add(time.Duration(i) * cadence); !clock.Now().Equal(want) { + t.Fatalf("lecture %d : horloge à %s, %s attendu", i, clock.Now(), want) + } + } +} diff --git a/cmd/openscale/serve.go b/cmd/openscale/serve.go index 948d6c7..b664795 100644 --- a/cmd/openscale/serve.go +++ b/cmd/openscale/serve.go @@ -6,13 +6,8 @@ import ( "flag" "fmt" "io" - "io/fs" - "net" "net/http" "os" - "path/filepath" - "strings" - "sync" "time" catalogpkg "openscale/internal/catalog" @@ -20,9 +15,6 @@ import ( "openscale/internal/diag" "openscale/internal/domain" "openscale/internal/platform" - "openscale/internal/printing" - "openscale/internal/scale" - "openscale/internal/scale/absent" "openscale/internal/station" "openscale/internal/station/ports" "openscale/internal/store" @@ -41,94 +33,11 @@ import ( // file's only contribution is to cancel the root context BEFORE calling it, so that // every in-flight request context dies with it (the HTTP server derives them from that // context through BaseContext). - -// The exit codes a service manager reads. -const ( - // exitFailure is what any refusal of a subcommand returns: a configuration that - // cannot be read, a database that cannot be opened. systemd restarts, the Windows - // SCM restarts, and the install sheet says what to look at. - exitFailure = 1 - // exitFatal is the code of §13.4: the socket could not be taken, or the server - // stopped serving on its own. « Un poste ne peut pas tourner normalement en étant - // mort. » - exitFatal = 3 - // exitRestart is a stop somebody ASKED FOR, from the administration screen. - // - // It is non-zero ON PURPOSE, and that is the whole mechanism: a non-zero code is - // what makes the SCM apply the recovery actions of §15.2 and systemd its - // Restart=always. A clean 0 would be recorded as a stop nobody undoes, and the - // station would wait for a human who thinks it is coming back. - exitRestart = 4 -) - -// The technical codes of §13.4, each with the sentence a volunteer reads. -const ( - // codeAnotherInstance is ERR-SYS-01: the address refuses a bind AND answers a - // probe. THE SOCKET IS THE SINGLE-INSTANCE LOCK — no lock file left behind by a - // crash, no Windows named mutex — and telling this case from the next one is what - // keeps a volunteer from hunting for a ghost process. - codeAnotherInstance = "ERR-SYS-01" - // codeCannotListen is ERR-SYS-02: the address refuses a bind and answers nothing. - // It is an address this station cannot have, which is a different remedy. - codeCannotListen = "ERR-SYS-02" - // codeServerStopped is ERR-SYS-03: Serve returned without a shutdown having been - // asked for. - codeServerStopped = "ERR-SYS-03" - // codeRestartAsked is ERR-SYS-09: a volunteer asked for a restart from the - // administration screen. - // - // It is written to the technical journal BEFORE the stop, because nothing written - // afterwards would ever be written — and because the Windows event log will record - // this stop as « inattendu », which it is not. That line is the only place the - // intention survives. - codeRestartAsked = "ERR-SYS-09" -) - -// probeBudget is how long the single-instance probe waits for the address to answer. // -// It is a NETWORK deadline in the TCP stack of the kernel, of the same nature as the -// write deadline of internal/web/stream.go, and it is spent before the injected clock -// exists as far as this decision is concerned: no business decision rests on it, and no -// test can be made to wait on it — a refused bind answers or does not answer at once. -const probeBudget = 250 * time.Millisecond - -// serviceFailure is a failure of `openscale serve` that names its technical code and -// the exit code the service manager reads. -// -// §13.4 has `fatal` write to the text journal, to the technical journal AND to stderr, -// then exit 3. Those are three different call sites here, deliberately: the technical -// journal is written where the failure happens, because only there is the database in -// hand; stderr and the exit code belong to main, because only main can exit. -type serviceFailure struct { - // Code is the ERR-SYS-nn a volunteer reads on the telephone. - Code string - // Exit is what the process returns. - Exit int - // Message is FRENCH and complete: it names what is wrong and what to do about it. - Message string - // Err is the underlying failure, kept so that errors.Is reaches it. - Err error -} - -// Error reports the code and the French sentence, which is what stderr carries. -func (f *serviceFailure) Error() string { - if f.Code == "" { - return f.Message - } - return f.Code + " : " + f.Message -} - -// Unwrap yields the failure this one was built on. -func (f *serviceFailure) Unwrap() error { return f.Err } - -// exitCodeFor reports the code the process returns for one error. -func exitCodeFor(err error) int { - var failure *serviceFailure - if errors.As(err, &failure) && failure.Exit != 0 { - return failure.Exit - } - return exitFailure -} +// What this file no longer holds, and where it went: the exit codes and the ERR-SYS +// sentences are in failure.go, the socket in listen.go, the neutral profile of §11.3 +// in fallback.go, the templates and the two devices in wiring.go, the three holders in +// adapters.go, and the layout of the data directory in paths.go. // serveOptions is what `openscale serve` was told, once the flags, the two environment // variables of §11.1 and the defaults have been resolved. @@ -617,463 +526,3 @@ func lastCatalogImport(ctx context.Context, db *store.DB, log ports.TechnicalLog } return time.Time{} } - -// listen opens the socket and tells the TWO failures apart. -// -// THE SOCKET IS THE SINGLE-INSTANCE LOCK (internal/web/binder.go), and that package -// deliberately leaves the discrimination to its caller: only the caller can probe the -// address. The two cases need two different sentences — an address that refuses a bind -// AND answers is another instance of this very application (ERR-SYS-01); one that -// refuses and answers nothing is an address this station cannot have (ERR-SYS-02) — -// and sending a volunteer hunting for a ghost process is the failure this tells apart. -func listen(clk ports.Clock, address string, log ports.TechnicalLog) (*web.Binder, error) { - binder, err := web.Listen(clk, address, log) - if err == nil { - return binder, nil - } - if respondsToProbe(address) { - return nil, &serviceFailure{Code: codeAnotherInstance, Exit: exitFatal, Err: err, Message: fmt.Sprintf( - "une autre instance d'OpenScale est déjà lancée sur ce poste : %s répond déjà. "+ - "Arrêtez le service avant d'en lancer un second.", address)} - } - return nil, &serviceFailure{Code: codeCannotListen, Exit: exitFatal, Err: err, Message: fmt.Sprintf( - "impossible d'écouter sur %s : %v. Cette adresse n'appartient pas à ce poste, "+ - "ou le port est réservé.", address, err)} -} - -// respondsToProbe reports whether something is already answering on that address. -// -// A bare TCP connection and nothing more. Asking /healthz would say « and it is us », -// which is a stronger claim than this decision needs and a weaker probe than it looks: -// an instance in « configuration d'usine » answers, one wedged mid-shutdown may not, -// and either way the remedy a volunteer reads is the same — stop what is holding the -// address before starting a second one. -func respondsToProbe(address string) bool { - conn, err := net.DialTimeout("tcp", address, probeBudget) - if err != nil { - return false - } - _ = conn.Close() - return true -} - -// recordFailure writes one fatal error to the technical journal, which is the half of -// §13.4's `fatal` that survives the process. -// -// It is written SYNCHRONOUSLY and directly to the store: the Hub's journal worker may -// not be running yet, or may already have been drained. And it is written on a FRESH -// context, never on the one that is being cancelled — the line that says why the -// station is stopping must not be the first casualty of the stop. -func recordFailure(db *store.DB, clk ports.Clock, err error) { - var failure *serviceFailure - if !errors.As(err, &failure) { - return - } - _ = db.RecordTechnical(context.Background(), store.TechnicalEntry{ - OccurredAt: clk.Now(), Level: store.LevelCritical, Source: store.LogSourceSystem, - Code: failure.Code, Message: failure.Message, Detail: detailOf(failure.Err), - }) -} - -// detailOf reports the technical tail of a failure, or nothing. -func detailOf(err error) string { - if err == nil { - return "" - } - return err.Error() -} - -// fallbackProfile is what a station RUNS when its own configuration is unusable (§11.3). -// -// It is the neutral profile, plus the two things that must survive the fallback — and -// both were found by starting a station out of the box and trying to repair it from its -// own screen. -// -// # The administration block -// -// §11.3 replaces the configuration a station OPERATES ON. It has no business replacing -// the identity of whoever administers it: the password and the recovery code are the -// answer to « qui a le droit de réparer ce poste », and that answer is on the -// installation sheet, in the shop's folder, matching the hash IN THE FILE. Dropping them -// left the login form answering « aucun mot de passe n'est défini » and the recovery form -// answering « ce poste n'a pas de code de secours » — on the ONE station both exist for. -// The screen was then unreachable on exactly the station §11.3 says it must serve. -// -// # The network block -// -// Same rule, and it was learnt the same way. The neutral profile replaces what the -// station RUNS ON; it has no business replacing the way one REACHES it in order to -// repair it. Its address is 127.0.0.1:8085, which every station of the parc shares, so -// borrowing it moved a station off the address its file declares — while the kiosk, which -// reads that same file and reads it successfully because a faulty file is still a -// readable one, kept opening the declared address. A black client screen on the very -// station §11.3 exists to keep alive, and an administration screen shut back onto the -// loopback at the moment a volunteer arrives with a laptop to fix it. -// -// The address of the file is kept only while it is USABLE: when the faults name the -// network block itself, the neutral profile provides it, because a fallback that copied -// an unbindable address would turn ERR-CFG-01 — a station serving its fault list — into -// ERR-SYS-02, a station that is not there at all. -func fallbackProfile(broken domain.Config, faults []domain.Fault) domain.Config { - cfg := domain.NeutralProfile() - cfg.Admin = broken.Admin - if !faultedOn(faults, "network") { - cfg.Network = broken.Network - } - return cfg -} - -// faultedOn reports whether any fault names a field of one configuration section. -// -// It matches the section and everything beneath it — "network" answers for -// "network.listen" — so a control added to that section later is covered without this -// function having to learn its name. Half a block is what must never be borrowed: an -// address open to the network behind an administration screen closed to it is harder to -// diagnose than a fallback that is wrong in both directions at once. -func faultedOn(faults []domain.Fault, section string) bool { - for _, fault := range faults { - if fault.Field == section || strings.HasPrefix(fault.Field, section+".") { - return true - } - } - return false -} - -// reportFaults writes the whole list of §11.3 where whoever started the service can -// read it. -// -// ALL of them and not the first: a volunteer who came to fix one file should leave -// having fixed it, and not discover the second fault after a restart. -func reportFaults(out io.Writer, path string, faults []domain.Fault) { - fmt.Fprintf(out, "openscale : %s comporte %d faute(s) — le poste démarre en configuration "+ - "d'usine (ERR-CFG-01) et sert l'écran d'administration :\n", path, len(faults)) - for _, fault := range faults { - fmt.Fprintf(out, " %s\n", fault.String()) - } -} - -// reportMigration writes what this binary had to change to read the file, where whoever -// started the service can read it. -// -// It says nothing when there is nothing to say: a station whose file is already at this -// schema must not print a paragraph at every boot. -func reportMigration(out io.Writer, path string, notes []domain.MigrationNote) { - if len(notes) == 0 { - return - } - fmt.Fprintf(out, "openscale : %s a été écrit par une version précédente — %d "+ - "changement(s), appliqués EN MÉMOIRE. Le fichier n'est pas modifié ; "+ - "« openscale config migrate » l'écrit :\n", path, len(notes)) - for _, note := range notes { - fmt.Fprintf(out, " %s\n", note) - } -} - -// templatesFor resolves the label layouts this station runs on, with the operator's -// offset RECOMPOSED into the geometry. -// -// THE OFFSET IS CARRIED BY THE TEMPLATE AND BY NOTHING ELSE. printer.options.offset_x -// looks like the command of the printer language and it is not: the template is -// the only one of the two that the preview screen, the PDF export and the raster driver -// all show, so a volunteer pressing the ±1 dot arrow sees the label move. Feeding both -// would move it twice, and internal/printing/raster refuses such a job outright — see -// the godoc of checkTheOffsetIsAppliedOnce. -func templatesFor(cfg domain.Config, reg domain.Registries) (map[string]domain.Template, error) { - offsetX, _ := cfg.Printer.Options.Int(optionOffsetX) - offsetY, _ := cfg.Printer.Options.Int(optionOffsetY) - - shipped := domain.ShippedTemplates() - out := make(map[string]domain.Template, len(shipped)) - for name, template := range shipped { - template.OffsetXDots = int(offsetX) - template.OffsetYDots = int(offsetY) - out[name] = template - } - if _, ok := out[cfg.Printer.Template]; !ok { - return nil, &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( - "printer.template : gabarit inconnu %q ; gabarits disponibles : %s", - cfg.Printer.Template, strings.Join(reg.TemplateNames(), ", "))} - } - return out, nil -} - -// buildScale opens the weight source this configuration names, and NEVER refuses to -// start over it. -// -// A scale that cannot be opened is an amber light and a fallback to manual entry, never -// a station that will not start (guiding principle 7): Station.Start already degrades -// on a Start that fails, and this covers the step before — a protocol no driver of this -// binary answers to, or options it cannot read. -func buildScale(cfg domain.Config, reg *scale.Registry, clk ports.Clock, log ports.TechnicalLog) ports.Scale { - weigher, err := newScale(cfg, reg, clk, log) - if err != nil { - log.Technical(domain.LevelError, "scale", "ERR-SCL-03", - "La balance déclarée n'a pas pu être construite : le poste passe en saisie manuelle.", - err.Error()) - return absent.New(log) - } - return weigher -} - -// newScale builds the weight source of one configuration. -// -// A station that declares it has NO scale gets the absent source, and that is an -// explicit declaration rather than an inference: scale.present false turns the light -// off instead of leaving it red, and typing the weight becomes nominal (§9.3). -func newScale(cfg domain.Config, reg *scale.Registry, clk ports.Clock, log ports.TechnicalLog) (ports.Scale, error) { - if !cfg.Scale.Present { - return absent.New(log), nil - } - return reg.New(cfg.Scale.Type, cfg.Scale.Options, clk, log) -} - -// buildPrinter builds the printer this configuration names, and never refuses to start -// over it either. -// -// The station keeps serving with a printer that says, in French, why it cannot print: -// the weighing is still journalled, the reprint bar is still there, and the -// administration screen names the offending key. A station that refused to start over a -// print queue would be a station nobody can reconfigure. -func buildPrinter(cfg domain.Config, reg *printing.Registry, templates map[string]domain.Template, - clk ports.Clock, log ports.TechnicalLog, dataDir string) ports.Printer { - printer, err := newPrinter(cfg, reg, templates, clk, log, dataDir) - if err != nil { - log.Technical(domain.LevelError, "printer", "ERR-PRN-01", - "L'imprimante déclarée n'a pas pu être construite.", err.Error()) - return unbuiltPrinter{reason: err.Error()} - } - return printer -} - -// newPrinter builds the print service of one configuration: the driver -// printer.type names, over the transport printer.options.transport names, with the -// retries and the roll counter of §8.2 around it. -// -// The transport is built ONLY for a driver that declares one. Built first and -// unconditionally, as it was, it refused the empty `printer.options` of the neutral profile -// before the driver was ever consulted — so a station in factory configuration got -// `unbuiltPrinter` and answered « l'imprimante configurée n'a pas pu être construite » to -// every button of the troubleshooting screen, which is the one screen that station exists -// to serve. -func newPrinter(cfg domain.Config, reg *printing.Registry, templates map[string]domain.Template, - clk ports.Clock, log ports.TechnicalLog, dataDir string) (ports.Printer, error) { - var byteLayer ports.Transport - if declaresTransport(reg, cfg.Printer.Type) { - opened, err := newTransport(cfg.Printer.Options, clk, labelsDir(dataDir)) - if err != nil { - return nil, err - } - byteLayer = opened - } - driver, err := reg.New(cfg.Printer.Type, printing.DriverConfig{ - Options: cfg.Printer.Options, - Transport: byteLayer, - OutputDir: previewsDir(dataDir), - Template: templates[cfg.Printer.Template], - Clock: clk, - Log: log, - DemoLabel: func() (domain.Label, error) { return demoLabel(cfg.Pricing) }, - }) - if err != nil { - if byteLayer != nil { - _ = byteLayer.Close() - } - return nil, err - } - capacity, _ := cfg.Printer.Options.Int(optionRollCapacity) - service, err := printing.NewService(printing.ServiceOptions{ - Main: driver, - // A driver with no transport names itself: printing.NewService falls back on the - // label of the descriptor, which for `preview` already says it prints nothing. - MainName: describeTransport(byteLayer), - Clock: clk, - // The roll counter has NO persistent store yet: internal/store carries no - // AddLabels/SetLabels pair, so the count restarts with the process. What it - // still buys is the 90 % amber light within one service, and « J'ai changé le - // rouleau » still resets it. - Roll: printing.NewRollCounter(nil, int(capacity), log), - Log: log, - }) - if err != nil { - // Returned as a NIL INTERFACE and never as a typed nil pointer: a caller - // checking `printer != nil` on a *Service that failed to build would get true. - _ = driver.Close() - return nil, err - } - return service, nil -} - -// declaresTransport reports whether the driver printer.type names carries a byte transport -// at all. -// -// The answer comes from the driver's OWN option schema — the same schema control 7 of -// §11.3 validates printer.options against and the administration screen generates its form -// from — so a driver added later says for itself whether the composition root has a device -// to open for it, with no list to keep in step here. -// -// An unknown driver answers false, and that is deliberate: reg.New refuses it one line -// later with the list of the ones that exist, and building a transport first would replace -// that refusal with a complaint about a transport key nobody typed. -func declaresTransport(reg *printing.Registry, driverID string) bool { - for _, descriptor := range reg.Descriptors() { - if descriptor.ID != driverID { - continue - } - for _, option := range descriptor.Options { - if option.Key == optionTransport { - return true - } - } - } - return false -} - -// describeTransport is the French name of the byte layer, or nothing when there is none. -func describeTransport(byteLayer ports.Transport) string { - if byteLayer == nil { - return "" - } - return byteLayer.Describe() -} - -// unbuiltPrinter is what a station has when its configuration names a printer this -// binary cannot build. -// -// It exists so that the station STARTS anyway: station.New requires a printer, and a -// nil one would take the whole poste out of service over a queue name. Every refusal it -// answers is KindConfig — no retry, and the administration screen shows what is -// configured against what actually exists (§8.5). -type unbuiltPrinter struct{ reason string } - -// Descriptor reports a driver that exists in name only. -func (p unbuiltPrinter) Descriptor() domain.PrinterDescriptor { - return domain.PrinterDescriptor{ID: "unbuilt", Label: "Imprimante non construite"} -} - -// Print refuses, naming why the printer could not be built. -func (p unbuiltPrinter) Print(context.Context, ports.PrintJob) (ports.PrintReceipt, error) { - return ports.PrintReceipt{}, p.refuse("printer.Print") -} - -// Status reports that nothing can be known about a printer that was never opened. -func (p unbuiltPrinter) Status(context.Context) ports.PrinterStatus { - return ports.PrinterStatus{Health: ports.PrinterFaulted, - Detail: "l'imprimante configurée n'a pas pu être construite : " + p.reason} -} - -// SelfTest refuses, for the same reason Print does. -func (p unbuiltPrinter) SelfTest(context.Context, string) error { return p.refuse("printer.SelfTest") } - -// Close has nothing to release. -func (p unbuiltPrinter) Close() error { return nil } - -func (p unbuiltPrinter) refuse(op string) error { - return &ports.PrintError{Kind: ports.KindConfig, Op: op, Message: "l'imprimante configurée " + - "n'a pas pu être construite : " + p.reason} -} - -// technicalSink adapts the store to what the station writes technical lines through. -// -// Two structures that carry the same six values, and the conversion is the price of cut -// 3 of §5.2: internal/station names no storage type, so it declares what it needs and -// the composition root joins the two. -type technicalSink struct{ db *store.DB } - -// RecordTechnical appends one line to the persisted technical journal. -func (s technicalSink) RecordTechnical(ctx context.Context, e station.TechnicalEntry) error { - return s.db.RecordTechnical(ctx, store.TechnicalEntry{ - OccurredAt: e.At, Level: e.Level, Source: e.Source, - Code: e.Code, Message: e.Message, Detail: e.Detail, - }) -} - -// relayLog is the technical journal the drivers are given BEFORE the Hub that owns one -// exists. -// -// The interval is real and short — a driver is built, then the station, then the Hub — -// and a driver that reported a bad option during it would otherwise report it into -// nothing. Until the Hub is attached the lines go to the console, where whoever started -// the service by hand can read them; afterwards they go where every other line goes. -type relayLog struct { - fallback io.Writer - - mu sync.RWMutex - target ports.TechnicalLog -} - -// attach points the relay at the journal of the running station. -func (l *relayLog) attach(target ports.TechnicalLog) { - l.mu.Lock() - defer l.mu.Unlock() - l.target = target -} - -// Technical records one event. -func (l *relayLog) Technical(level, source, code, message, detail string) { - l.mu.RLock() - target := l.target - l.mu.RUnlock() - if target != nil { - target.Technical(level, source, code, message, detail) - return - } - fmt.Fprintf(l.fallback, "openscale [%s] %s %s : %s %s\n", level, source, code, message, detail) -} - -// heldServer is the HTTP server as Station.Stop sees it, handed over after the station -// was built. -// -// station.Options.Server is fixed at construction and the server cannot exist before -// the Hub whose subscribers it closes on shutdown. Rather than move the shutdown -// sequence out of Station.Stop — which is where §13.4 is written and tested — the -// composition root hands over a holder and fills it one line later. -type heldServer struct { - mu sync.RWMutex - server *http.Server -} - -// hold puts the server in place. It is called once, before anything can serve. -func (h *heldServer) hold(server *http.Server) { - h.mu.Lock() - defer h.mu.Unlock() - h.server = server -} - -// Shutdown stops accepting and waits for the active requests, up to ctx. -// -// A holder that was never filled shuts nothing down and says so with a nil error: the -// station failed before it ever served, and a shutdown that reported a failure there -// would name a server that does not exist. -func (h *heldServer) Shutdown(ctx context.Context) error { - h.mu.RLock() - server := h.server - h.mu.RUnlock() - if server == nil { - return nil - } - return server.Shutdown(ctx) -} - -// imagesRoot is the photo directory of §11.1, laid out as -// <2 first characters of the sha>/. (§10.7). -func imagesRoot(dataDir string) string { return filepath.Join(dataDir, "images") } - -// imagesDir is that directory as the HTTP layer reads it. -func imagesDir(dataDir string) fs.FS { return os.DirFS(imagesRoot(dataDir)) } - -// labelsDir is where the `file` transport drops one copy per label (§11.1). -func labelsDir(dataDir string) string { return filepath.Join(dataDir, "labels") } - -// previewsDir is where a driver that PRODUCES FILES writes them — today, the `preview` -// driver's PNG and PDF of each label. -// -// A directory OF ITS OWN, and not the one above. Both answer « envoyez-moi le fichier de la -// dernière étiquette », and that sentence is how support works: mixing the raw frames of a -// transport with the images of an aperçu would make it a question with two answers. -// -// KNOWN DRIFT, and it is worth stating rather than discovering: this directory is handed to -// EVERY driver, so a second file-producing driver would share it silently and re-open the -// ambiguity this split closed. There is only one such driver today. Adding a second means -// giving each its own sub-directory — the argument above is about ONE answer per question, -// not about the `preview` driver. -func previewsDir(dataDir string) string { return filepath.Join(dataDir, "previews") } diff --git a/cmd/openscale/serve_test.go b/cmd/openscale/serve_test.go index 3de4374..232b5b6 100644 --- a/cmd/openscale/serve_test.go +++ b/cmd/openscale/serve_test.go @@ -1,18 +1,12 @@ package main import ( - "bufio" "bytes" "context" - "encoding/json" "errors" - "io" "net" "net/http" - "os" - "path/filepath" "strings" - "sync" "testing" "time" @@ -28,6 +22,11 @@ import ( // the weight is nominal (§9.3), the second is how a frame is looked at during // development and how remote support works (§8.4). Every layer below is the real one: // the real registries, the real drivers, the real SQLite base, the real routes. +// +// What is left here is the SERVICE itself: it starts, it stops inside its budget, it +// takes its socket or says which of the two refusals it met, and it obeys --listen. +// What it does with a configuration it cannot use is in fallback_test.go; the bench +// all three files run on is in servebench_test.go. // stopBudget is the assertion of §13.4: « arrêt complet en moins de 3 s avec 4 abonnés // SSE ». It is a WALL-CLOCK budget on purpose — this is the endurance criterion, and it @@ -110,88 +109,6 @@ func TestServeStopsUnderThreeSecondsWithFourSubscribers(t *testing.T) { } } -// TestAnUnreadableConfigurationRefusesToServe is the other half of §11.3. -// -// A configuration that is INVALID never kills the process: the station starts on the -// neutral profile and serves the whole list of faults, which is the assertion of -// TestAnInvalidConfigurationStillServes below, and — since porte 1 — of -// TestATrulyBrokenConfigurationStillServes too: even a document that is not JSON at all -// falls back rather than refusing. A file that cannot be READ AT ALL is a different fact — -// there is no station number, no listening address and nothing an administration screen -// could safely write back — and it alone refuses, in French, naming the file, with a -// non-zero exit code and NO PANIC. -func TestAnUnreadableConfigurationRefusesToServe(t *testing.T) { - missing := filepath.Join(t.TempDir(), "config.json") - - // A BOUNDED context, so that a subcommand which starts anyway fails here instead of - // hanging: a station built on a configuration nobody could read would listen on an - // address nobody chose and wait for a signal for ever, and a test that hangs says - // nothing to whoever broke it. - ctx, cancel := context.WithTimeout(context.Background(), startBudget) - defer cancel() - - var out bytes.Buffer - err := runServe(ctx, []string{"--config", missing, "--data", t.TempDir()}, &out) - if err == nil { - t.Fatal("un fichier absent a laissé le poste démarrer") - } - if code := exitCodeFor(err); code == 0 { - t.Fatalf("code de sortie %d : un démarrage refusé doit être visible du gestionnaire de service", code) - } - message := explain(err) - if !strings.Contains(message, missing) { - t.Fatalf("le refus ne nomme pas le fichier fautif : %s", message) - } - if !strings.Contains(message, "configuration") { - t.Fatalf("le refus n'est pas en français et ne dit pas de quoi il parle : %s", message) - } -} - -// TestATrulyBrokenConfigurationStillServes is porte 1 (LoadConfig, -// TestLoadConfigOfATruncatedFileIsNotAnError), exercised at the level `serve` runs at. -// -// A document that is not JSON at all used to refuse to start, alongside a missing file. -// It no longer does: DecodeConfigBlockByBlock falls back to the neutral profile for a -// document it cannot decode at all exactly as it does for one bad block, and the station -// serves ERR-CFG-01 like any other invalid configuration -// (TestAnInvalidConfigurationStillServes) — the file is illisible, but it EXISTS, and a -// wrong path in a service unit is the one case that must still refuse. -func TestATrulyBrokenConfigurationStillServes(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - if err := os.WriteFile(path, []byte("{\"station\": {\"number\": "), 0o644); err != nil { - t.Fatalf("écriture du fichier cassé : %v", err) - } - - b := &serveBench{ - t: t, - configPath: path, - dataDir: filepath.Join(dir, "data"), - out: &syncBuffer{}, - returned: make(chan error, 1), - client: &http.Client{}, - } - // --listen, and not the neutral profile's own 127.0.0.1:8085: that address is shared - // by every station of the parc, including the one this developer may have installed - // on their own machine. - b.options = serveOptions{configPath: path, dataDir: b.dataDir, listen: freeAddress(t)} - b.start() - - live := b.get("/healthz") - if live.StatusCode != http.StatusOK { - t.Fatalf("/healthz = %d : un document illisible a tué le poste", live.StatusCode) - } - _ = live.Body.Close() - - if got := b.output(); !strings.Contains(got, "ERR-CFG-01") { - t.Fatalf("la sortie ne nomme pas ERR-CFG-01 :\n%s", got) - } - - if err := b.stop(); err != nil { - t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) - } -} - // TestASecondInstanceCannotTakeTheSocket is failure test 16. // // THE SOCKET IS THE SINGLE-INSTANCE LOCK: no lock file left behind by a crash, no @@ -271,163 +188,6 @@ func TestAnAddressThisStationCannotHaveIsNotAnotherInstance(t *testing.T) { } } -// TestAnInvalidConfigurationStillServes is the guiding principle 7 of §11.3: « le poste -// démarre toujours ». -// -// A negative discount would print a negative price, so control 13 refuses it. The -// station starts anyway, on the neutral profile loaded IN MEMORY AND NEVER WRITTEN, -// in the one terminal state, and it serves — because a broken configuration must -// never produce a black screen, and because the screen that fixes it is served by -// the very process the configuration broke. -func TestAnInvalidConfigurationStillServes(t *testing.T) { - bench := newServeBench(t, func(cfg *domain.Config) { - cfg.Pricing.Tiers[0].Discount = -10 - }) - bench.start() - - live := bench.get("/healthz") - if live.StatusCode != http.StatusOK { - t.Fatalf("/healthz = %d : une configuration invalide a tué le poste", live.StatusCode) - } - _ = live.Body.Close() - - if got := bench.output(); !strings.Contains(got, "ERR-CFG-01") { - t.Fatalf("la sortie ne nomme pas ERR-CFG-01 :\n%s", got) - } - if got := bench.output(); !strings.Contains(got, "discount_percent") { - t.Fatalf("la liste des fautes ne nomme pas le champ fautif :\n%s", got) - } - // The file on disk is UNTOUCHED: the neutral profile is loaded in memory and - // nothing writes it back over what an operator typed. - raw, err := os.ReadFile(bench.configPath) - if err != nil { - t.Fatalf("relecture de la configuration : %v", err) - } - if !bytes.Contains(raw, []byte(`"discount_percent": -1`)) { - t.Fatalf("le fichier fautif a été réécrit par le poste :\n%s", raw) - } - - if err := bench.stop(); err != nil { - t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) - } -} - -// TestTheFallbackProfileKeepsTheKEYSToTheStation. -// -// §11.3 replaces the configuration a station OPERATES ON when that configuration is -// unusable. It has no business replacing the identity of whoever administers it — and -// dropping it locked the screen on the one station §11.3 exists to keep serving: the -// login form answered « aucun mot de passe n'est défini » and the recovery form « ce -// poste n'a pas de code de secours », about a file that carried both. -func TestTheFallbackProfileKeepsTheKEYSToTheStation(t *testing.T) { - broken := shippedConfig(t) - broken.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$c2VsLWRlLXRlc3Q$Y2xlLWRlLXRlc3QtcG91ci1jZS1wb3N0ZQ" - broken.Admin.RecoveryCodeHash = "$argon2id$v=19$m=65536,t=3,p=2$YXV0cmUtc2VsLTAx$Y2xlLWR1LWNvZGUtZGUtc2Vjb3Vycy1pY2k" - broken.Station.Coop = "Les Amis de la Coopé" - - fallback := fallbackProfile(broken, faultsOn("pricing.tiers[0].discount_percent")) - if fallback.Admin.PasswordHash != broken.Admin.PasswordHash { - t.Error("le profil de repli oublie le mot de passe d'administration du fichier") - } - if fallback.Admin.RecoveryCodeHash != broken.Admin.RecoveryCodeHash { - t.Error("le profil de repli oublie le code de secours du fichier") - } - // Everything the station OPERATES on is the neutral profile, and nothing else is - // borrowed from a file that carries faults. - if fallback.Station.Coop == broken.Station.Coop { - t.Error("le profil de repli fait tourner le poste sur la configuration fautive") - } -} - -// TestTheFallbackProfileKeepsTheDOORToTheStation is the same rule applied to the network -// block, and it is the rule the bench of 2026-07-29 discovered the hard way. -// -// The keys are useless behind a door nobody can find. §11.3 replaces what a station RUNS -// ON, never the way one REACHES it in order to repair it: the address a station answers -// on is written on the installation sheet and dialled by the kiosk from that same file, -// and admin_on_lan is what lets a volunteer arrive with a laptop rather than a keyboard. -// Borrowing the neutral 127.0.0.1:8085 moved the service off the address the file -// declares while the kiosk kept opening it — a black client screen — and shut the -// administration screen back onto the loopback at the worst possible moment. -func TestTheFallbackProfileKeepsTheDOORToTheStation(t *testing.T) { - broken := shippedConfig(t) - broken.Network = domain.NetworkConfig{Listen: "127.0.0.1:8099", AdminOnLAN: true} - - fallback := fallbackProfile(broken, faultsOn("pricing.tiers[0].discount_percent")) - if fallback.Network.Listen != broken.Network.Listen { - t.Errorf("adresse du repli = %q, attendu %q : le repli jette une adresse d'écoute qui "+ - "n'est pas fautive", fallback.Network.Listen, broken.Network.Listen) - } - if !fallback.Network.AdminOnLAN { - t.Error("le repli referme l'écran d'administration sur la boucle locale, au moment " + - "même où un bénévole vient réparer le poste depuis son portable") - } -} - -// TestTheFallbackProfileTakesTheNeutralAddressWhenTheFileAddressIsItselfFaulted is what -// keeps the test above from being an invitation to copy an unbindable address. -// -// When the faults name the network block, the file has nothing usable to lend: the -// neutral profile provides the address, exactly as before. Without this case, a fallback -// that copied « 127.0.0.1 » — no port — would turn ERR-CFG-01, a station serving its -// fault list, into ERR-SYS-02, a station that is not there at all. -func TestTheFallbackProfileTakesTheNeutralAddressWhenTheFileAddressIsItselfFaulted(t *testing.T) { - broken := shippedConfig(t) - broken.Network = domain.NetworkConfig{Listen: "127.0.0.1", AdminOnLAN: true} - - fallback := fallbackProfile(broken, faultsOn("network.listen")) - if want := domain.NeutralProfile().Network.Listen; fallback.Network.Listen != want { - t.Errorf("adresse du repli = %q, attendu %q : le repli a recopié une adresse "+ - "inliable", fallback.Network.Listen, want) - } - if fallback.Network.AdminOnLAN { - t.Error("le repli a gardé la moitié d'un bloc network fautif : l'écran " + - "d'administration s'ouvre au réseau sur une configuration que personne n'a validée") - } -} - -// faultsOn builds the verdict of a Validate that found exactly these fields wrong. -func faultsOn(fields ...string) []domain.Fault { - faults := make([]domain.Fault, 0, len(fields)) - for _, field := range fields { - faults = append(faults, domain.Fault{Field: field, Message: "faute de banc d'essai"}) - } - return faults -} - -// TestAFaultyConfigurationStillServesOnTheAddressItsFileDeclares is what the bench of -// 2026-07-29 paid for, and the assertion no test made until it did. -// -// The station shipped that day carried network.listen 8099 AND eight faults elsewhere. -// The fallback threw the address out with the rest, the service came up on the -// 127.0.0.1:8085 of the neutral profile, and the kiosk — which reads the FILE, and reads -// it successfully because a faulty file is still a readable one — opened 8099. A black -// client screen, on the very station §11.3 exists to keep alive. -// -// So: a fault ANYWHERE BUT on the address, an address in the file, no flag, and the -// station must serve on the address its file names. -func TestAFaultyConfigurationStillServesOnTheAddressItsFileDeclares(t *testing.T) { - bench := newServeBench(t, func(cfg *domain.Config) { - cfg.Pricing.Tiers[0].Discount = -10 - }).listenFlag("") - bench.start() - - if bench.address != bench.fileAddress { - t.Fatalf("le poste sert sur %q alors que son fichier déclare %q : le repli a jeté une "+ - "adresse d'écoute qui n'était pas fautive, et l'écran client ouvre une adresse que "+ - "rien ne sert", bench.address, bench.fileAddress) - } - // And it really is the fallback that is being observed, not a configuration that - // turned out to be valid after all. - if got := bench.output(); !strings.Contains(got, "ERR-CFG-01") { - t.Fatalf("la sortie ne nomme pas ERR-CFG-01 : ce banc ne traverse pas le repli\n%s", got) - } - - if err := bench.stop(); err != nil { - t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) - } -} - // TestTheListenFlagWinsOverTheAddressOfTheFile carries the assertion of // TestTheFallbackProfileKeepsTheKEYSToTheStation all the way to the socket. // @@ -524,283 +284,3 @@ func TestAFaultyListenAddressIsStillReportedWhenTheFlagIsGiven(t *testing.T) { t.Fatalf("serve a rendu une erreur sur un arrêt demandé : %v", err) } } - -// --- The bench -------------------------------------------------------------- - -// serveBench is one `openscale serve` in this process, on a configuration written to a -// temporary directory. -type serveBench struct { - t *testing.T - configPath string - dataDir string - options serveOptions - // fileAddress is the address the CONFIGURATION FILE declares, kept apart from - // options.listen so that a test can say which of the two the station really bound. - fileAddress string - - cancel context.CancelFunc - returned chan error - // reaped reports that stop already took the return value, so that the cleanup does - // not wait on a channel nobody will write to again. - reaped bool - address string - out *syncBuffer - client *http.Client - // cookie is the administration session, once a test has opened one. It is carried by - // hand rather than by a jar so that a test can assert what an UNAUTHENTICATED caller - // gets on the very same station (ADR-018). - cookie *http.Cookie - streams []*http.Response - // ended is closed, one per stream, when the SSE body reaches its end. It is what - // proves the handlers left of their own accord rather than being cut off. - ended []chan struct{} -} - -// newServeBench writes a configuration a station can really run on and prepares the -// subcommand over it. -// -// The configuration is the DELIVERED one, patched in two places and no more: no scale, -// because a test bench has none and `scale.present = false` is a supported deployment; -// and the `file` transport, which writes one label per file into the data directory. -// Inventing a configuration here would prove nothing about the station anybody runs. -func newServeBench(t *testing.T, tweak ...func(*domain.Config)) *serveBench { - t.Helper() - dir := t.TempDir() - - cfg := shippedConfig(t) - cfg.Scale.Present = false - cfg.Scale.Type = "" - cfg.Scale.Options = nil - cfg.Printer.Options = mustOptions(t, cfg.Printer.Options, map[string]any{ - "transport": domain.TransportFile, - "queue": "", - "path": filepath.Join(dir, "labels"), - }) - cfg.Network.Listen = freeAddress(t) - for _, f := range tweak { - f(&cfg) - } - - b := &serveBench{ - t: t, - configPath: filepath.Join(dir, "config.json"), - dataDir: filepath.Join(dir, "data"), - fileAddress: cfg.Network.Listen, - out: &syncBuffer{}, - returned: make(chan error, 1), - } - writeConfig(t, b.configPath, cfg) - // The address travels as the FLAG as well as in the file, so that a bench never lands - // on 127.0.0.1:8085 — the address of every station of the parc, including the one this - // developer has installed on their own machine — whatever the station decides to bind. - // A test that means to prove WHICH of the two was served separates them with - // listenFlag. - b.options = serveOptions{configPath: b.configPath, dataDir: b.dataDir, listen: cfg.Network.Listen} - // No Timeout on the client: an SSE body is read for as long as the station keeps - // it open, and a client-side deadline would end the stream itself — which is the - // one thing these tests must not do. - b.client = &http.Client{} - t.Cleanup(func() { - for _, stream := range b.streams { - _ = stream.Body.Close() - } - }) - return b -} - -// listenFlag sets what --listen carries, the empty string meaning « no flag at all ». -// -// It is what tells the address of the FILE from the address of the FLAG: newServeBench -// puts the same one in both, so a bench left as it comes proves nothing about which of -// the two a station bound. -func (b *serveBench) listenFlag(address string) *serveBench { - b.options.listen = address - return b -} - -// run executes the subcommand to completion and returns what it refused, which is what -// a start-up failure test asserts on. -func (b *serveBench) run(ctx context.Context) error { - b.t.Helper() - return serve(ctx, b.options, b.out) -} - -// start launches the station and waits for it to be serving. -func (b *serveBench) start() { - b.t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - b.cancel = cancel - - serving := make(chan string, 1) - options := b.options - options.serving = func(address string) { serving <- address } - go func() { b.returned <- serve(ctx, options, b.out) }() - - select { - case b.address = <-serving: - case err := <-b.returned: - cancel() - b.t.Fatalf("le poste n'a jamais servi : %v\n%s", err, b.out.String()) - case <-time.After(startBudget): - cancel() - b.t.Fatalf("le poste n'a pas ouvert sa socket en %s\n%s", startBudget, b.out.String()) - } - b.t.Cleanup(func() { - if b.reaped { - return - } - cancel() - select { - case <-b.returned: - case <-time.After(startBudget): - } - }) -} - -// stop asks for the shutdown and waits for the subcommand to return. -// -// IT CLOSES NO STREAM. The bodies are released by the bench's own cleanup, after every -// assertion has run, so that « the stream ended » means the STATION ended it and never -// « the test pulled the plug ». -func (b *serveBench) stop() error { - b.t.Helper() - b.cancel() - select { - case err := <-b.returned: - b.reaped = true - return err - case <-time.After(startBudget): - b.t.Fatalf("serve n'est jamais rendu\n%s", b.out.String()) - return nil - } -} - -// get issues one request against the running station. -func (b *serveBench) get(path string) *http.Response { - b.t.Helper() - response, err := b.client.Get("http://" + b.address + path) - if err != nil { - b.t.Fatalf("GET %s : %v", path, err) - } - return response -} - -// subscribe opens one SSE stream and waits for its first event, so that the shutdown -// budget is measured against handlers that are really in flight. -func (b *serveBench) subscribe() { - b.t.Helper() - response, err := b.client.Get("http://" + b.address + "/api/v1/stream") - if err != nil { - b.t.Fatalf("abonnement SSE : %v", err) - } - if response.StatusCode != http.StatusOK { - response.Body.Close() - b.t.Fatalf("abonnement SSE refusé : %d", response.StatusCode) - } - reader := bufio.NewReader(response.Body) - if _, err := reader.ReadString('\n'); err != nil { - response.Body.Close() - b.t.Fatalf("le flux SSE n'a rien émis : %v", err) - } - // The rest of the stream is drained by a goroutine of its own, so that the station - // is never held back by a reader that stopped reading — and so that the END of the - // stream is observable. - ended := make(chan struct{}) - go func() { - defer close(ended) - _, _ = io.Copy(io.Discard, reader) - }() - b.streams = append(b.streams, response) - b.ended = append(b.ended, ended) -} - -// output is everything the subcommand printed. -func (b *serveBench) output() string { return b.out.String() } - -// --- Fixtures --------------------------------------------------------------- - -// shippedConfig reads the configuration actually delivered with the binary. -// -// The real file and not a literal: a test that invents its own thresholds proves -// nothing about the station anybody will run. -func shippedConfig(t *testing.T) domain.Config { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "config-lacagette.json")) - if err != nil { - t.Fatalf("lecture de la configuration livrée : %v", err) - } - var cfg domain.Config - if err := json.Unmarshal(raw, &cfg); err != nil { - t.Fatalf("configuration livrée illisible : %v", err) - } - return cfg -} - -// writeConfig writes one configuration where the subcommand will read it. -func writeConfig(t *testing.T, path string, cfg domain.Config) { - t.Helper() - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - t.Fatalf("sérialisation de la configuration : %v", err) - } - if err := os.WriteFile(path, raw, 0o644); err != nil { - t.Fatalf("écriture de la configuration : %v", err) - } -} - -// mustOptions overlays a few driver options onto the ones the delivered file carries. -func mustOptions(t *testing.T, base domain.DriverOptions, overlay map[string]any) domain.DriverOptions { - t.Helper() - out := make(domain.DriverOptions, len(base)+len(overlay)) - for key, value := range base { - out[key] = value - } - for key, value := range overlay { - raw, err := json.Marshal(value) - if err != nil { - t.Fatalf("option %s : %v", key, err) - } - out[key] = raw - } - return out -} - -// freeAddress reserves a port and gives it back, so that two tests running side by side -// never fight over one. -// -// network.listen is validated as a host:port in [1, 65535] (control 2), so « port 0 » -// cannot travel through a configuration file: the address has to be a real one by the -// time the file is written. -func freeAddress(t *testing.T) string { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("réservation d'un port : %v", err) - } - address := listener.Addr().String() - if err := listener.Close(); err != nil { - t.Fatalf("libération du port : %v", err) - } - return address -} - -// syncBuffer is the console of the subcommand, readable from the test goroutine while -// the station writes to it from its own. -type syncBuffer struct { - mu sync.Mutex - buffer bytes.Buffer -} - -// Write appends to the buffer. -func (b *syncBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buffer.Write(p) -} - -// String reports everything written so far. -func (b *syncBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buffer.String() -} diff --git a/cmd/openscale/servebench_test.go b/cmd/openscale/servebench_test.go new file mode 100644 index 0000000..06e24b4 --- /dev/null +++ b/cmd/openscale/servebench_test.go @@ -0,0 +1,304 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "openscale/internal/domain" +) + +// The bench: a whole `openscale serve` running in this process, on the real +// configuration file, the real base, the real registries and the real routes. What +// stands in for the hardware is not a mock — it is a supported deployment. +// +// What the ADMINISTRATION surface adds to this bench is in adminbench_test.go. + +// --- The bench -------------------------------------------------------------- + +// serveBench is one `openscale serve` in this process, on a configuration written to a +// temporary directory. +type serveBench struct { + t *testing.T + configPath string + dataDir string + options serveOptions + // fileAddress is the address the CONFIGURATION FILE declares, kept apart from + // options.listen so that a test can say which of the two the station really bound. + fileAddress string + + cancel context.CancelFunc + returned chan error + // reaped reports that stop already took the return value, so that the cleanup does + // not wait on a channel nobody will write to again. + reaped bool + address string + out *syncBuffer + client *http.Client + // cookie is the administration session, once a test has opened one. It is carried by + // hand rather than by a jar so that a test can assert what an UNAUTHENTICATED caller + // gets on the very same station (ADR-018). + cookie *http.Cookie + streams []*http.Response + // ended is closed, one per stream, when the SSE body reaches its end. It is what + // proves the handlers left of their own accord rather than being cut off. + ended []chan struct{} +} + +// newServeBench writes a configuration a station can really run on and prepares the +// subcommand over it. +// +// The configuration is the DELIVERED one, patched in two places and no more: no scale, +// because a test bench has none and `scale.present = false` is a supported deployment; +// and the `file` transport, which writes one label per file into the data directory. +// Inventing a configuration here would prove nothing about the station anybody runs. +func newServeBench(t *testing.T, tweak ...func(*domain.Config)) *serveBench { + t.Helper() + dir := t.TempDir() + + cfg := shippedConfig(t) + cfg.Scale.Present = false + cfg.Scale.Type = "" + cfg.Scale.Options = nil + cfg.Printer.Options = mustOptions(t, cfg.Printer.Options, map[string]any{ + "transport": domain.TransportFile, + "queue": "", + "path": filepath.Join(dir, "labels"), + }) + cfg.Network.Listen = freeAddress(t) + for _, f := range tweak { + f(&cfg) + } + + b := &serveBench{ + t: t, + configPath: filepath.Join(dir, "config.json"), + dataDir: filepath.Join(dir, "data"), + fileAddress: cfg.Network.Listen, + out: &syncBuffer{}, + returned: make(chan error, 1), + } + writeConfig(t, b.configPath, cfg) + // The address travels as the FLAG as well as in the file, so that a bench never lands + // on 127.0.0.1:8085 — the address of every station of the parc, including the one this + // developer has installed on their own machine — whatever the station decides to bind. + // A test that means to prove WHICH of the two was served separates them with + // listenFlag. + b.options = serveOptions{configPath: b.configPath, dataDir: b.dataDir, listen: cfg.Network.Listen} + // No Timeout on the client: an SSE body is read for as long as the station keeps + // it open, and a client-side deadline would end the stream itself — which is the + // one thing these tests must not do. + b.client = &http.Client{} + t.Cleanup(func() { + for _, stream := range b.streams { + _ = stream.Body.Close() + } + }) + return b +} + +// listenFlag sets what --listen carries, the empty string meaning « no flag at all ». +// +// It is what tells the address of the FILE from the address of the FLAG: newServeBench +// puts the same one in both, so a bench left as it comes proves nothing about which of +// the two a station bound. +func (b *serveBench) listenFlag(address string) *serveBench { + b.options.listen = address + return b +} + +// run executes the subcommand to completion and returns what it refused, which is what +// a start-up failure test asserts on. +func (b *serveBench) run(ctx context.Context) error { + b.t.Helper() + return serve(ctx, b.options, b.out) +} + +// start launches the station and waits for it to be serving. +func (b *serveBench) start() { + b.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + b.cancel = cancel + + serving := make(chan string, 1) + options := b.options + options.serving = func(address string) { serving <- address } + go func() { b.returned <- serve(ctx, options, b.out) }() + + select { + case b.address = <-serving: + case err := <-b.returned: + cancel() + b.t.Fatalf("le poste n'a jamais servi : %v\n%s", err, b.out.String()) + case <-time.After(startBudget): + cancel() + b.t.Fatalf("le poste n'a pas ouvert sa socket en %s\n%s", startBudget, b.out.String()) + } + b.t.Cleanup(func() { + if b.reaped { + return + } + cancel() + select { + case <-b.returned: + case <-time.After(startBudget): + } + }) +} + +// stop asks for the shutdown and waits for the subcommand to return. +// +// IT CLOSES NO STREAM. The bodies are released by the bench's own cleanup, after every +// assertion has run, so that « the stream ended » means the STATION ended it and never +// « the test pulled the plug ». +func (b *serveBench) stop() error { + b.t.Helper() + b.cancel() + select { + case err := <-b.returned: + b.reaped = true + return err + case <-time.After(startBudget): + b.t.Fatalf("serve n'est jamais rendu\n%s", b.out.String()) + return nil + } +} + +// get issues one request against the running station. +func (b *serveBench) get(path string) *http.Response { + b.t.Helper() + response, err := b.client.Get("http://" + b.address + path) + if err != nil { + b.t.Fatalf("GET %s : %v", path, err) + } + return response +} + +// subscribe opens one SSE stream and waits for its first event, so that the shutdown +// budget is measured against handlers that are really in flight. +func (b *serveBench) subscribe() { + b.t.Helper() + response, err := b.client.Get("http://" + b.address + "/api/v1/stream") + if err != nil { + b.t.Fatalf("abonnement SSE : %v", err) + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + b.t.Fatalf("abonnement SSE refusé : %d", response.StatusCode) + } + reader := bufio.NewReader(response.Body) + if _, err := reader.ReadString('\n'); err != nil { + response.Body.Close() + b.t.Fatalf("le flux SSE n'a rien émis : %v", err) + } + // The rest of the stream is drained by a goroutine of its own, so that the station + // is never held back by a reader that stopped reading — and so that the END of the + // stream is observable. + ended := make(chan struct{}) + go func() { + defer close(ended) + _, _ = io.Copy(io.Discard, reader) + }() + b.streams = append(b.streams, response) + b.ended = append(b.ended, ended) +} + +// output is everything the subcommand printed. +func (b *serveBench) output() string { return b.out.String() } + +// --- Fixtures --------------------------------------------------------------- + +// shippedConfig reads the configuration actually delivered with the binary. +// +// The real file and not a literal: a test that invents its own thresholds proves +// nothing about the station anybody will run. +func shippedConfig(t *testing.T) domain.Config { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "config-lacagette.json")) + if err != nil { + t.Fatalf("lecture de la configuration livrée : %v", err) + } + var cfg domain.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("configuration livrée illisible : %v", err) + } + return cfg +} + +// writeConfig writes one configuration where the subcommand will read it. +func writeConfig(t *testing.T, path string, cfg domain.Config) { + t.Helper() + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("sérialisation de la configuration : %v", err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatalf("écriture de la configuration : %v", err) + } +} + +// mustOptions overlays a few driver options onto the ones the delivered file carries. +func mustOptions(t *testing.T, base domain.DriverOptions, overlay map[string]any) domain.DriverOptions { + t.Helper() + out := make(domain.DriverOptions, len(base)+len(overlay)) + for key, value := range base { + out[key] = value + } + for key, value := range overlay { + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("option %s : %v", key, err) + } + out[key] = raw + } + return out +} + +// freeAddress reserves a port and gives it back, so that two tests running side by side +// never fight over one. +// +// network.listen is validated as a host:port in [1, 65535] (control 2), so « port 0 » +// cannot travel through a configuration file: the address has to be a real one by the +// time the file is written. +func freeAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("réservation d'un port : %v", err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("libération du port : %v", err) + } + return address +} + +// syncBuffer is the console of the subcommand, readable from the test goroutine while +// the station writes to it from its own. +type syncBuffer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +// Write appends to the buffer. +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.Write(p) +} + +// String reports everything written so far. +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} diff --git a/cmd/openscale/wiring.go b/cmd/openscale/wiring.go new file mode 100644 index 0000000..cca62e0 --- /dev/null +++ b/cmd/openscale/wiring.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/scale" + "openscale/internal/scale/absent" + "openscale/internal/station/ports" +) + +// This file builds, out of one configuration, the three things a station weighs and +// prints with: the label templates, the weight source and the print service. None of +// them is ever a reason to refuse to start (guiding principle 7) — a scale that cannot +// be opened falls back on manual entry, and a printer that cannot be built says so, in +// French, on every button of the troubleshooting screen. + +// templatesFor resolves the label layouts this station runs on, with the operator's +// offset RECOMPOSED into the geometry. +// +// THE OFFSET IS CARRIED BY THE TEMPLATE AND BY NOTHING ELSE. printer.options.offset_x +// looks like the command of the printer language and it is not: the template is +// the only one of the two that the preview screen, the PDF export and the raster driver +// all show, so a volunteer pressing the ±1 dot arrow sees the label move. Feeding both +// would move it twice, and internal/printing/raster refuses such a job outright — see +// the godoc of checkTheOffsetIsAppliedOnce. +func templatesFor(cfg domain.Config, reg domain.Registries) (map[string]domain.Template, error) { + offsetX, _ := cfg.Printer.Options.Int(optionOffsetX) + offsetY, _ := cfg.Printer.Options.Int(optionOffsetY) + + shipped := domain.ShippedTemplates() + out := make(map[string]domain.Template, len(shipped)) + for name, template := range shipped { + template.OffsetXDots = int(offsetX) + template.OffsetYDots = int(offsetY) + out[name] = template + } + if _, ok := out[cfg.Printer.Template]; !ok { + return nil, &serviceFailure{Exit: exitFailure, Message: fmt.Sprintf( + "printer.template : gabarit inconnu %q ; gabarits disponibles : %s", + cfg.Printer.Template, strings.Join(reg.TemplateNames(), ", "))} + } + return out, nil +} + +// buildScale opens the weight source this configuration names, and NEVER refuses to +// start over it. +// +// A scale that cannot be opened is an amber light and a fallback to manual entry, never +// a station that will not start (guiding principle 7): Station.Start already degrades +// on a Start that fails, and this covers the step before — a protocol no driver of this +// binary answers to, or options it cannot read. +func buildScale(cfg domain.Config, reg *scale.Registry, clk ports.Clock, log ports.TechnicalLog) ports.Scale { + weigher, err := newScale(cfg, reg, clk, log) + if err != nil { + log.Technical(domain.LevelError, "scale", "ERR-SCL-03", + "La balance déclarée n'a pas pu être construite : le poste passe en saisie manuelle.", + err.Error()) + return absent.New(log) + } + return weigher +} + +// newScale builds the weight source of one configuration. +// +// A station that declares it has NO scale gets the absent source, and that is an +// explicit declaration rather than an inference: scale.present false turns the light +// off instead of leaving it red, and typing the weight becomes nominal (§9.3). +func newScale(cfg domain.Config, reg *scale.Registry, clk ports.Clock, log ports.TechnicalLog) (ports.Scale, error) { + if !cfg.Scale.Present { + return absent.New(log), nil + } + return reg.New(cfg.Scale.Type, cfg.Scale.Options, clk, log) +} + +// buildPrinter builds the printer this configuration names, and never refuses to start +// over it either. +// +// The station keeps serving with a printer that says, in French, why it cannot print: +// the weighing is still journalled, the reprint bar is still there, and the +// administration screen names the offending key. A station that refused to start over a +// print queue would be a station nobody can reconfigure. +func buildPrinter(cfg domain.Config, reg *printing.Registry, templates map[string]domain.Template, + clk ports.Clock, log ports.TechnicalLog, dataDir string) ports.Printer { + printer, err := newPrinter(cfg, reg, templates, clk, log, dataDir) + if err != nil { + log.Technical(domain.LevelError, "printer", "ERR-PRN-01", + "L'imprimante déclarée n'a pas pu être construite.", err.Error()) + return unbuiltPrinter{reason: err.Error()} + } + return printer +} + +// newPrinter builds the print service of one configuration: the driver +// printer.type names, over the transport printer.options.transport names, with the +// retries and the roll counter of §8.2 around it. +// +// The transport is built ONLY for a driver that declares one. Built first and +// unconditionally, as it was, it refused the empty `printer.options` of the neutral profile +// before the driver was ever consulted — so a station in factory configuration got +// `unbuiltPrinter` and answered « l'imprimante configurée n'a pas pu être construite » to +// every button of the troubleshooting screen, which is the one screen that station exists +// to serve. +func newPrinter(cfg domain.Config, reg *printing.Registry, templates map[string]domain.Template, + clk ports.Clock, log ports.TechnicalLog, dataDir string) (ports.Printer, error) { + var byteLayer ports.Transport + if declaresTransport(reg, cfg.Printer.Type) { + opened, err := newTransport(cfg.Printer.Options, clk, labelsDir(dataDir)) + if err != nil { + return nil, err + } + byteLayer = opened + } + driver, err := reg.New(cfg.Printer.Type, printing.DriverConfig{ + Options: cfg.Printer.Options, + Transport: byteLayer, + OutputDir: previewsDir(dataDir), + Template: templates[cfg.Printer.Template], + Clock: clk, + Log: log, + DemoLabel: func() (domain.Label, error) { return demoLabel(cfg.Pricing) }, + }) + if err != nil { + if byteLayer != nil { + _ = byteLayer.Close() + } + return nil, err + } + capacity, _ := cfg.Printer.Options.Int(optionRollCapacity) + service, err := printing.NewService(printing.ServiceOptions{ + Main: driver, + // A driver with no transport names itself: printing.NewService falls back on the + // label of the descriptor, which for `preview` already says it prints nothing. + MainName: describeTransport(byteLayer), + Clock: clk, + // The roll counter has NO persistent store yet: internal/store carries no + // AddLabels/SetLabels pair, so the count restarts with the process. What it + // still buys is the 90 % amber light within one service, and « J'ai changé le + // rouleau » still resets it. + Roll: printing.NewRollCounter(nil, int(capacity), log), + Log: log, + }) + if err != nil { + // Returned as a NIL INTERFACE and never as a typed nil pointer: a caller + // checking `printer != nil` on a *Service that failed to build would get true. + _ = driver.Close() + return nil, err + } + return service, nil +} + +// declaresTransport reports whether the driver printer.type names carries a byte transport +// at all. +// +// The answer comes from the driver's OWN option schema — the same schema control 7 of +// §11.3 validates printer.options against and the administration screen generates its form +// from — so a driver added later says for itself whether the composition root has a device +// to open for it, with no list to keep in step here. +// +// An unknown driver answers false, and that is deliberate: reg.New refuses it one line +// later with the list of the ones that exist, and building a transport first would replace +// that refusal with a complaint about a transport key nobody typed. +func declaresTransport(reg *printing.Registry, driverID string) bool { + for _, descriptor := range reg.Descriptors() { + if descriptor.ID != driverID { + continue + } + for _, option := range descriptor.Options { + if option.Key == optionTransport { + return true + } + } + } + return false +} + +// describeTransport is the French name of the byte layer, or nothing when there is none. +func describeTransport(byteLayer ports.Transport) string { + if byteLayer == nil { + return "" + } + return byteLayer.Describe() +} + +// unbuiltPrinter is what a station has when its configuration names a printer this +// binary cannot build. +// +// It exists so that the station STARTS anyway: station.New requires a printer, and a +// nil one would take the whole poste out of service over a queue name. Every refusal it +// answers is KindConfig — no retry, and the administration screen shows what is +// configured against what actually exists (§8.5). +type unbuiltPrinter struct{ reason string } + +// Descriptor reports a driver that exists in name only. +func (p unbuiltPrinter) Descriptor() domain.PrinterDescriptor { + return domain.PrinterDescriptor{ID: "unbuilt", Label: "Imprimante non construite"} +} + +// Print refuses, naming why the printer could not be built. +func (p unbuiltPrinter) Print(context.Context, ports.PrintJob) (ports.PrintReceipt, error) { + return ports.PrintReceipt{}, p.refuse("printer.Print") +} + +// Status reports that nothing can be known about a printer that was never opened. +func (p unbuiltPrinter) Status(context.Context) ports.PrinterStatus { + return ports.PrinterStatus{Health: ports.PrinterFaulted, + Detail: "l'imprimante configurée n'a pas pu être construite : " + p.reason} +} + +// SelfTest refuses, for the same reason Print does. +func (p unbuiltPrinter) SelfTest(context.Context, string) error { return p.refuse("printer.SelfTest") } + +// Close has nothing to release. +func (p unbuiltPrinter) Close() error { return nil } + +func (p unbuiltPrinter) refuse(op string) error { + return &ports.PrintError{Kind: ports.KindConfig, Op: op, Message: "l'imprimante configurée " + + "n'a pas pu être construite : " + p.reason} +} diff --git a/deploy/backup_test.go b/deploy/backup_test.go new file mode 100644 index 0000000..26fbed5 --- /dev/null +++ b/deploy/backup_test.go @@ -0,0 +1,203 @@ +package deploy + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +// The backup and the restore played FOR REAL, on a throwaway directory: the eleven +// scenarios install.ps1 carries run under a real PowerShell, because a snapshot read back +// by an earlier version and an account password wrongly renewed are two failures no +// reading of a script catches. + +// --- The backup and the restore, on a throwaway directory --------------------------- + +// TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory exercises important-15 where it can +// be exercised: the FILE half of the backup, on a directory the test owns. +// +// The registry half cannot be run without an elevated session, and a test that asked for +// one would be a test nobody runs. What is proved here is everything the file layer +// promises — an existing snapshot is never overwritten, a timestamped backup is taken, a +// restore puts the exact bytes back — plus the one thing that would silently ruin the +// snapshot: ConvertTo-Json's default depth of 2, which writes +// « System.Collections.Hashtable » in place of a nested object. +func TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory(t *testing.T) { + // WINDOWS ONLY, and not out of laziness: common.ps1 derives every path it touches + // from $env:ProgramFiles and $env:ProgramData, which are EMPTY on Linux. PowerShell + // is installed on the CI runner, so the harness starts and then fails on a + // Join-Path with a null argument — a failure that says nothing about the backup and + // everything about the machine it ran on. + if runtime.GOOS != "windows" { + t.Skip("common.ps1 dérive ses chemins de %ProgramFiles% et %ProgramData% : " + + "ce banc n'a de sens que sur Windows") + } + common, err := filepath.Abs(filepath.Join("windows", "common.ps1")) + if err != nil { + t.Fatalf("chemin de common.ps1 : %v", err) + } + + // One work directory PER SHELL, and not one for both: step 2 proves that a second + // install.ps1 does not overwrite the first snapshot, so a reused directory would make + // the second subtest fail on step 1 for a reason that has nothing to do with the shell. + for _, shell := range powershellPaths(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + work := t.TempDir() + harness := filepath.Join(work, "harness.ps1") + body := `$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. '` + strings.ReplaceAll(common, "'", "''") + `' + +$work = '` + strings.ReplaceAll(work, "'", "''") + `' +$restore = Join-Path $work 'restore.json' +$binary = Join-Path $work 'openscale.exe' +$backups = Join-Path $work 'backups' + +# --- 1. L'instantané est écrit, avec ses sous-objets -------------------------------- +$snapshot = @{ + saved_at = '2026-07-26T08:00:00' + winlogon = @{ AutoAdminLogon = '0'; DefaultUserName = 'ancien'; DefaultPassword = $null } + power = @{ scheme_guid = '381b4222-f694-41f0-9685-ff5bb260df2e'; usb_selective_suspend_ac = 1 } +} +if (-not (Save-Snapshot -Path $restore -Snapshot $snapshot)) { throw 'le premier instantané n''a pas été écrit' } +$read = Read-Snapshot -Path $restore +if ($read.winlogon.DefaultUserName -ne 'ancien') { throw 'le sous-objet winlogon a été perdu' } +if ($read.power.usb_selective_suspend_ac -ne 1) { throw 'la suspension USB n''a pas été sauvegardée' } +$raw = Get-Content -Path $restore -Raw +if ($raw -match 'System.Collections.Hashtable') { throw 'restore.json contient un objet non serialise (ConvertTo-Json -Depth)' } + +# --- 2. Un second install.ps1 n'écrase PAS l'instantané d'origine ------------------- +$second = @{ saved_at = '2026-12-25T00:00:00'; winlogon = @{ AutoAdminLogon = '1' } } +if (Save-Snapshot -Path $restore -Snapshot $second) { throw 'le second instantané a écrasé le premier' } +$read = Read-Snapshot -Path $restore +if ($read.saved_at -ne '2026-07-26T08:00:00') { throw 'l''instantané d''origine a été perdu' } + +# --- 3. Sauvegarde horodatée d'un binaire, puis restauration ------------------------ +Set-Content -Path $binary -Value 'VERSION 1' -Encoding utf8 +$copy = Backup-File -Path $binary -Directory $backups -Stamp '2026-07-26T08-00-00' +if (-not (Test-Path $copy)) { throw 'la sauvegarde du binaire n''existe pas' } +if ($copy -notlike '*openscale-2026-07-26T08-00-00.exe') { throw "nom de sauvegarde inattendu : $copy" } + +Set-Content -Path $binary -Value 'VERSION 2 CASSEE' -Encoding utf8 +Restore-File -Backup $copy -Target $binary | Out-Null +if ((Get-Content -Path $binary -Raw).Trim() -ne 'VERSION 1') { throw 'la restauration n''a pas remis la version précédente' } +if (-not (Test-Path $copy)) { throw 'la restauration a consommé la sauvegarde : un second essai serait impossible' } + +# --- 4. Deux sauvegardes le même jour ne se recouvrent pas ------------------------- +$other = Backup-File -Path $binary -Directory $backups -Stamp '2026-07-26T09-30-00' +if ($other -eq $copy) { throw 'deux sauvegardes portent le même nom' } +if ((Get-ChildItem $backups).Count -ne 2) { throw 'une sauvegarde a écrasé l''autre' } + +# --- 5. Ce qui doit échouer échoue ------------------------------------------------ +try { Backup-File -Path (Join-Path $work 'absent.exe') -Directory $backups; throw 'ECHEC ATTENDU' } +catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'sauvegarder un fichier absent a réussi' } } +try { Restore-File -Backup (Join-Path $work 'absent.bak') -Target $binary; throw 'ECHEC ATTENDU' } +catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'restaurer une sauvegarde absente a réussi' } } + +# --- 6. L'adresse d'écoute vient du fichier, pas d'une supposition ----------------- +$config = Join-Path $work 'config.json' +Set-Content -Path $config -Value '{ "network": { "listen": "0.0.0.0:9000" } }' -Encoding utf8 +$address = Get-ListenAddress -ConfigPath $config +if ($address -ne 'http://127.0.0.1:9000') { throw "adresse deduite $address" } +$address = Get-ListenAddress -ConfigPath (Join-Path $work 'inexistant.json') +if ($address -ne 'http://127.0.0.1:8085') { throw "adresse par defaut $address" } + +# --- 7. La fiche d'installation porte ce qu'un bénévole doit y lire --------------- +$sheet = Join-Path $work 'install-sheet.txt' +Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -Fingerprint 'a1b2c3d4' -StationNumber '2' -Version 'openscale 2.0.0' | Out-Null +$text = Get-Content -Path $sheet -Raw +foreach ($expected in @('MOT-DE-PASSE-TEST', 'a1b2c3d4', 'openscale', 'CODE DE SECOURS', 'doctor')) { + if ($text -notmatch [regex]::Escape($expected)) { throw "la fiche ne porte pas $expected" } +} +# Sans code connu, la ligne reste à remplir à la main : c'est un poste réinstallé, dont +# le fichier porte déjà une empreinte que personne ne peut relire. +if ($text -notmatch 'RECOPIER ICI') { throw 'la fiche sans code ne demande pas de le recopier' } + +# --- 8. Le code de secours de §14.4 est IMPRIMÉ quand l'installeur vient de le tirer - +Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -Fingerprint 'a1b2c3d4' -StationNumber '2' -Version 'openscale 2.0.0' -RecoveryCode 'K7M4Q2XR' | Out-Null +$text = Get-Content -Path $sheet -Raw +if ($text -notmatch 'K7M4Q2XR') { throw 'la fiche ne porte pas le code de secours tiré à l''installation' } +if ($text -match 'RECOPIER ICI') { throw 'la fiche demande de recopier un code qu''elle porte déjà' } +if ($text -notmatch 'seule copie') { throw 'la fiche ne dit pas qu''elle est la seule copie du code' } + +# --- 9. Un instantané écrit par une version ANTÉRIEURE se relit sans exploser ------ +# restore.json n'est jamais réécrit : celui d'un poste installé il y a six mois ne +# connaît pas les sections que l'installeur d'aujourd'hui y met. Sous +# « Set-StrictMode -Version Latest », lire une propriété absente ÉCHOUE — et ce serait +# la désinstallation, le geste qui doit toujours marcher, qui casserait. +$old = Read-Snapshot -Path $restore +if ($null -ne (Get-SnapshotValue (Get-SnapshotValue $old 'service_control') 'AutoStartDelay')) { + throw 'une section absente de l''instantane a rendu une valeur' +} +if ((Get-SnapshotValue $old.winlogon 'DefaultUserName') -ne 'ancien') { + throw 'Get-SnapshotValue perd une valeur presente' +} + +# --- 10. Le mot de passe du compte Windows : ce qui le renouvelle, et ce qui ne le +# renouvelle PAS. La règle vient du code de secours, trois étapes plus loin dans +# install.ps1 : « la fiche déjà rangée dans le classeur doit rester vraie ». Le mot de +# passe Windows la violait — un install.ps1 relancé, geste que TROUBLESHOOTING.md +# recommande, rendait fausses toutes les fiches classées. +$neuf = Resolve-AccountPassword -AccountExists $false +if (-not $neuf.Change) { throw 'un compte qui n''existe pas doit recevoir un mot de passe' } +if ($neuf.Password.Length -ne 20) { throw "mot de passe tiré de $($neuf.Password.Length) caractères" } +if ((Resolve-AccountPassword -AccountExists $false).Password -eq $neuf.Password) { + throw 'deux tirages rendent le même mot de passe' +} + +$garde = Resolve-AccountPassword -AccountExists $true -KnownPassword 'AncienMotDePasse' +if ($garde.Change) { throw 'une réinstallation renouvelle le mot de passe : les fiches classées deviennent fausses' } +if ($garde.Password -ne 'AncienMotDePasse') { throw 'le mot de passe conservé n''est pas celui du poste' } +if ($garde.Warning) { throw 'conserver le mot de passe n''est pas un incident' } + +$choisi = Resolve-AccountPassword -AccountExists $true -KnownPassword 'AncienMotDePasse' -Requested 'poire-balance-samedi' +if (-not $choisi.Change) { throw '-AccountPassword n''a pas été appliqué' } +if ($choisi.Password -ne 'poire-balance-samedi') { throw 'le mot de passe demandé n''a pas été retenu' } + +# Sans trace du mot de passe en place, il FAUT en poser un nouveau — mais en le disant : +# la fiche classée devient fausse, et un poste passé par « harden.ps1 -AutologonSecret » +# garde l'ancien dans les secrets LSA, donc son ouverture de session automatique cesse. +$perdu = Resolve-AccountPassword -AccountExists $true +if (-not $perdu.Change) { throw 'sans trace du mot de passe, il faut bien en poser un nouveau' } +if (-not $perdu.Warning) { throw 'un renouvellement silencieux casse la fiche classée sans le dire' } + +# Le plancher est LU, pas recopié : il vaut 4 parce qu'un compte sans droits sur un poste +# en libre-service doit s'ouvrir facilement, et ce banc doit rester vrai le jour où ce +# raisonnement change. Ce qui est vérifié, c'est qu'il y en a un et qu'il tient. +$plancher = $script:MinimumPasswordLength +$juste = 'a' * $plancher +if ((Resolve-AccountPassword -AccountExists $true -Requested $juste).Password -ne $juste) { + throw "un mot de passe de $plancher caractères, le plancher exactement, a été refusé" +} +try { Resolve-AccountPassword -AccountExists $true -Requested ('a' * ($plancher - 1)) | Out-Null; throw 'ECHEC ATTENDU' } +catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'un mot de passe plus court que le plancher a été accepté' } } +try { Resolve-AccountPassword -AccountExists $true -Requested ' ' | Out-Null; throw 'ECHEC ATTENDU' } +catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'un mot de passe fait d''espaces a été accepté' } } + +# --- 11. La fiche dit si le mot de passe a changé --------------------------------- +# Un bénévole qui range la nouvelle fiche à côté de l'ancienne doit savoir laquelle +# ouvre la session. C'est la fiche qui le porte, pas le journal, qui reste sur le poste. +$sheet = Join-Path $work 'install-sheet.txt' +Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -PasswordChanged $false | Out-Null +$text = Get-Content -Path $sheet -Raw +if ($text -notmatch 'INCHANG') { throw 'la fiche ne dit pas que le mot de passe n''a pas changé' } +Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -PasswordChanged $true | Out-Null +$text = Get-Content -Path $sheet -Raw +if ($text -match 'INCHANG') { throw 'la fiche annonce inchangé un mot de passe qui vient d''être posé' } +if ($text -notmatch 'fiches? pr') { throw 'la fiche ne dit pas que les fiches précédentes sont périmées' } + +Write-Output 'TOUT-EST-VERIFIE' +` + writeScript(t, harness, body) + + output, err := runPowerShell(t, shell, harness) + if err != nil { + t.Fatalf("la sauvegarde ou la restauration a échoué sous %s :\n%s", shell, output) + } + if !strings.Contains(output, "TOUT-EST-VERIFIE") { + t.Fatalf("le banc ne s'est pas terminé :\n%s", output) + } + }) + } +} diff --git a/deploy/bootstrap_test.go b/deploy/bootstrap_test.go index 3861a87..3aa0c14 100644 --- a/deploy/bootstrap_test.go +++ b/deploy/bootstrap_test.go @@ -13,7 +13,7 @@ import ( // Les tests de l'installation en une commande. // -// Ils lisent bootstrap.ps1 et bootstrap.cmd comme deploy_test.go lit les autres scripts : +// Ils lisent bootstrap.ps1 et bootstrap.cmd comme le reste de ce paquet lit ses scripts : // par le texte, sans Windows sous la main. Ce que Windows seul peut prouver — l'invite // UAC, la réponse de l'API, le service qui démarre — reste la recette de §15.2 ; ce qui // est vérifiable ici l'est ici. diff --git a/deploy/delivery_test.go b/deploy/delivery_test.go new file mode 100644 index 0000000..7ef2bf1 --- /dev/null +++ b/deploy/delivery_test.go @@ -0,0 +1,230 @@ +package deploy + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// What is actually DELIVERED: the scheduled task that reopens the client screen on its +// own, the archive of §17.2 with everything it has to carry, and the documentation a +// volunteer will have in front of them. Three artefacts nobody ever reads again until one +// of them is missing. + +// --- The scheduled task ------------------------------------------------------------- + +// scheduledTask is the part of the task XML this test reads. +type scheduledTask struct { + Triggers struct { + Logon struct { + UserID string `xml:"UserId"` + } `xml:"LogonTrigger"` + } `xml:"Triggers"` + Principals struct { + Principal struct { + UserID string `xml:"UserId"` + LogonType string `xml:"LogonType"` + RunLevel string `xml:"RunLevel"` + } `xml:"Principal"` + } `xml:"Principals"` + Settings struct { + ExecutionTimeLimit string `xml:"ExecutionTimeLimit"` + MultipleInstancesPolicy string `xml:"MultipleInstancesPolicy"` + Enabled string `xml:"Enabled"` + DisallowStartIfOnBatteries string `xml:"DisallowStartIfOnBatteries"` + } `xml:"Settings"` + Actions struct { + Exec struct { + Command string `xml:"Command"` + Arguments string `xml:"Arguments"` + } `xml:"Exec"` + } `xml:"Actions"` +} + +// TestTheKioskTaskIsWhatMakesTheScreenComeBackAlone reads the scheduled task the way +// Windows will. +// +// Every assertion below is one way the criterion of §18 fails silently: a task that needs +// a password stops working the day it changes, a task with the default three-day execution +// limit closes the client screen on the fourth day of continuous opening, and a task that +// runs elevated makes a self-service station an administrator session. +func TestTheKioskTaskIsWhatMakesTheScreenComeBackAlone(t *testing.T) { + raw := readFile(t, filepath.Join("windows", "openscale-kiosk.xml")) + var task scheduledTask + if err := xml.Unmarshal([]byte(raw), &task); err != nil { + t.Fatalf("openscale-kiosk.xml n'est pas un XML exploitable : %v", err) + } + + if task.Principals.Principal.LogonType != "InteractiveToken" { + t.Fatalf("LogonType=%q : InteractiveToken est ce qui évite de fournir un mot de passe "+ + "à schtasks — une tâche enregistrée avec un mot de passe cesse de démarrer le jour "+ + "où il change", task.Principals.Principal.LogonType) + } + if task.Principals.Principal.RunLevel == "HighestAvailable" { + t.Error("RunLevel=HighestAvailable : le kiosque tourne sans privilèges") + } + if task.Settings.ExecutionTimeLimit != "PT0S" { + t.Fatalf("ExecutionTimeLimit=%q : la valeur par défaut de Windows arrêterait l'écran "+ + "client au bout de trois jours d'ouverture continue", task.Settings.ExecutionTimeLimit) + } + if task.Settings.MultipleInstancesPolicy != "IgnoreNew" { + t.Errorf("MultipleInstancesPolicy=%q : deux superviseurs, c'est deux navigateurs qui se "+ + "relancent l'un l'autre", task.Settings.MultipleInstancesPolicy) + } + if task.Settings.DisallowStartIfOnBatteries != "false" { + t.Errorf("DisallowStartIfOnBatteries=%q : sur un poste derrière un onduleur, Windows "+ + "refuserait de lancer l'écran client", task.Settings.DisallowStartIfOnBatteries) + } + if task.Actions.Exec.Arguments != "kiosk" { + t.Fatalf("la tâche lance %q, attendu la sous-commande kiosk", task.Actions.Exec.Arguments) + } + if task.Triggers.Logon.UserID == "" { + t.Error("aucun déclencheur d'ouverture de session : le poste ne reviendrait pas seul sur l'écran client") + } +} + +// TestEveryPlaceholderOfTheTaskIsSubstitutedByTheInstaller catches the drift that leaves a +// station launching a program called « %OPENSCALE_BINARY% ». +func TestEveryPlaceholderOfTheTaskIsSubstitutedByTheInstaller(t *testing.T) { + raw := readFile(t, filepath.Join("windows", "openscale-kiosk.xml")) + installer := readFile(t, filepath.Join("windows", "install.ps1")) + + placeholders := regexp.MustCompile(`%OPENSCALE_[A-Z_]+%`).FindAllString(raw, -1) + if len(placeholders) == 0 { + t.Fatal("le XML ne porte aucun marqueur : il contient donc un chemin en dur") + } + for _, placeholder := range placeholders { + if !strings.Contains(installer, placeholder) { + t.Errorf("le marqueur %s du XML n'est substitué par aucune ligne de install.ps1 : "+ + "la tâche lancerait un programme de ce nom", placeholder) + } + } +} + +// TestTheDeliveredArchiveHasEverythingSection17_2Lists is the packaging half: a volunteer +// copies one archive, and every file §17.2 names has to be in it. +// +// The archive is built by `make dist`, which this test does not run — building three +// targets takes a minute. What it checks is the SOURCE of each member: the file exists in +// the repository, or the Makefile knows how to produce it. +func TestTheDeliveredArchiveHasEverythingSection17_2Lists(t *testing.T) { + makefile := readFile(t, filepath.Join("..", "Makefile")) + for what, needle := range map[string]string{ + "les scripts et les unités de deploy/": "deploy/", + "la notice d'installation": "INSTALLATION.md", + "le guide de dépannage": "TROUBLESHOOTING.md", + "les empreintes des fichiers": "SHA256SUMS", + "la configuration livrée": "config-lacagette.json", + "la licence et les composants tiers": "THIRD-PARTY.md", + } { + if !strings.Contains(makefile, needle) { + t.Errorf("la cible release du Makefile n'emporte pas %s (« %s » absent), que §17.2 liste", + what, needle) + } + } + // The delivered configuration is PRODUCED by the binary and not copied: §17.2 says + // « SANS le bloc matériel », and a straight copy of the development file would ship + // the COM8 and the SATO WS408_2 of this machine — two values no station of the fleet + // may inherit, and which would break the fingerprint comparison of §15.5. + if !strings.Contains(makefile, "config export") { + t.Error("la cible release recopie config-lacagette.json au lieu de l'EXPORTER : " + + "l'archive emporterait le port série et la file d'impression du poste de développement") + } + for _, path := range []string{ + filepath.Join("windows", "install.ps1"), + filepath.Join("windows", "uninstall.ps1"), + filepath.Join("windows", "update.ps1"), + filepath.Join("windows", "harden.ps1"), + filepath.Join("windows", "openscale-kiosk.xml"), + filepath.Join("windows", "start.bat"), + filepath.Join("windows", "common.ps1"), + filepath.Join("linux", "openscale.service"), + filepath.Join("linux", "openscale-kiosk.service"), + filepath.Join("linux", "99-openscale.rules"), + filepath.Join("linux", "49-openscale-reboot.rules"), + filepath.Join("linux", "install.sh"), + filepath.Join("linux", "update.sh"), + filepath.Join("linux", "uninstall.sh"), + filepath.Join("linux", "bootstrap.sh"), + filepath.Join("..", "INSTALLATION.md"), + filepath.Join("..", "TROUBLESHOOTING.md"), + filepath.Join("..", "testdata", "config-lacagette.json"), + } { + if _, err := os.Stat(path); err != nil { + t.Errorf("%s manque au livrable : %v", path, err) + } + } +} + +// TestTheDocumentationIsWrittenForAVolunteer checks what can be checked about prose: that +// the two documents start from what somebody SEES. +// +// TROUBLESHOOTING.md has to be navigable by symptom — « l'écran est noir », « ça +// n'imprime plus », « les prix sont faux » — because a volunteer does not know the codes. +// The codes come after, as a way of confirming. +func TestTheDocumentationIsWrittenForAVolunteer(t *testing.T) { + troubleshooting := readFile(t, filepath.Join("..", "TROUBLESHOOTING.md")) + for _, symptom := range []string{ + "écran est noir", "n'imprime plus", "prix", "balance", "catalogue", + } { + if !strings.Contains(strings.ToLower(troubleshooting), strings.ToLower(symptom)) { + t.Errorf("TROUBLESHOOTING.md ne parle pas du symptôme « %s »", symptom) + } + } + // The first heading a reader meets must be a symptom, not a code: a document that + // opens on ERR-SCL-02 is a document written for whoever wrote the code. + firstHeading := "" + for _, line := range strings.Split(troubleshooting, "\n") { + if strings.HasPrefix(line, "## ") { + firstHeading = line + break + } + } + if regexp.MustCompile(`ERR-[A-Z]+-\d+`).MatchString(firstHeading) { + t.Errorf("le premier titre de TROUBLESHOOTING.md est un code (%q) : un bénévole voit un "+ + "symptôme, pas un code", firstHeading) + } + + installation := readFile(t, filepath.Join("..", "INSTALLATION.md")) + for _, step := range []string{ + "install.ps1", "redémarr", "empreinte", "15 minutes", "SmartScreen", + } { + if !strings.Contains(strings.ToLower(installation), strings.ToLower(step)) { + t.Errorf("INSTALLATION.md ne parle pas de « %s »", step) + } + } +} + +// TestTheFifteenMinutesAreCountedAndNotClaimed keeps the promise of §15.5 measurable. +// +// « Un bénévole installe un poste seul en 15 minutes » is the criterion of L8. A document +// that asserted it without counting would be a document that discovers on site that it +// takes forty. INSTALLATION.md therefore carries a table of steps with a duration each, +// and the sum has to be stated. +func TestTheFifteenMinutesAreCountedAndNotClaimed(t *testing.T) { + installation := readFile(t, filepath.Join("..", "INSTALLATION.md")) + minutes := regexp.MustCompile(`(?m)^\|.*?\|\s*(\d+)\s*(?:min|minutes?)\b`).FindAllStringSubmatch(installation, -1) + if len(minutes) < 5 { + t.Fatalf("INSTALLATION.md ne chiffre que %d étapes : les 15 minutes seraient une "+ + "affirmation, pas un compte", len(minutes)) + } + total := 0 + for _, match := range minutes { + value, err := strconv.Atoi(match[1]) + if err != nil { + t.Fatalf("durée illisible %q", match[1]) + } + total += value + } + if !strings.Contains(installation, fmt.Sprintf("%d minutes", total)) && + !strings.Contains(installation, fmt.Sprintf("%d min", total)) { + t.Errorf("les étapes totalisent %d minutes, et ce total n'est écrit nulle part dans "+ + "INSTALLATION.md : le lecteur ne peut pas vérifier la promesse", total) + } + t.Logf("les étapes chiffrées de INSTALLATION.md totalisent %d minutes", total) +} diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go deleted file mode 100644 index 773a5b5..0000000 --- a/deploy/deploy_test.go +++ /dev/null @@ -1,1822 +0,0 @@ -package deploy - -import ( - "bytes" - "encoding/xml" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "testing" - "time" - - "openscale/internal/platform" - "openscale/internal/station" -) - -// --- Les unités systemd ------------------------------------------------------------- - -// unitPath is one shipped unit. -func unitPath(name string) string { return filepath.Join("linux", name) } - -// byteOrderMark is what every shipped .ps1 starts with, and it is NOT optional. -// -// Windows PowerShell 5.1 reads a UTF-8 file with no BOM as ANSI, which turns every -// accented character of these scripts into mojibake — « compilé » becomes « compil├® » in -// the installation log. The marker is therefore part of the delivery, and it is the -// READER here that has to know it: without this, the first line is "<#" instead of -// "<#", codeOnly never enters block-comment mode, and the prose of every .SYNOPSIS is -// searched as if it were code. -const byteOrderMark = "\ufeff" - -// readFile reads a shipped file, and fails the test rather than the installer. -func readFile(t *testing.T, path string) string { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("lecture de %s : %v", path, err) - } - return strings.TrimPrefix(string(raw), byteOrderMark) -} - -// codeOnly strips the comments out of a script or a unit file. -// -// It exists because the naive search is worse than no search: every file here EXPLAINS in -// a comment what it must not do — « jamais /readyz », « dans l'ordre inverse, icacls -// échoue » — and a test that read those comments as code would forbid the very sentences -// that keep the next reader from reintroducing the bug. -// -// `#` covers both shells, systemd units and PowerShell line comments; `<# … #>` is the -// PowerShell block comment, which is where the .SYNOPSIS of each script lives. -func codeOnly(script string) string { - var out strings.Builder - inBlock := false - for _, line := range strings.Split(script, "\n") { - trimmed := strings.TrimSpace(line) - switch { - case inBlock: - if strings.Contains(trimmed, "#>") { - inBlock = false - } - out.WriteString("\n") - continue - case strings.HasPrefix(trimmed, "<#"): - inBlock = !strings.Contains(trimmed, "#>") - out.WriteString("\n") - continue - case strings.HasPrefix(trimmed, "#"): - out.WriteString("\n") - continue - } - // A trailing comment on a line of code: keep the code, drop the comment. The `#` - // of a PowerShell string would be lost too, and no line here has one. - if index := strings.Index(line, " #"); index >= 0 { - line = line[:index] - } - out.WriteString(line) - out.WriteString("\n") - } - return out.String() -} - -// TestTheProseOfAScriptIsNeverReadAsCode is the regression of the byte order mark. -// -// The two tests it protects — the order of the steps of install.ps1 and the « jamais -// /readyz » of update.ps1 — both search for a word every script names in its own header in -// order to explain why it must NOT do it. Reading that header as code makes them fail on -// the shipped files, which is a red suite that accuses working scripts. -func TestTheProseOfAScriptIsNeverReadAsCode(t *testing.T) { - // Every shipped .ps1 carries the marker, and a run that no longer found one would mean - // somebody « fixed » the encoding and broke the accents of a whole parc. - for _, name := range []string{"install.ps1", "update.ps1", "common.ps1", "harden.ps1"} { - raw, err := os.ReadFile(filepath.Join("windows", name)) - if err != nil { - t.Fatalf("lecture de %s : %v", name, err) - } - if !strings.HasPrefix(string(raw), byteOrderMark) { - t.Errorf("%s ne commence plus par la marque UTF-8 : PowerShell 5.1 lira ses "+ - "accents en ANSI", name) - } - } - - // And the reader hands the text over WITHOUT it, which is what puts « <# » back at the - // start of the first line where codeOnly looks for it. - for _, name := range []string{"install.ps1", "update.ps1"} { - text := readFile(t, filepath.Join("windows", name)) - if strings.HasPrefix(text, byteOrderMark) { - t.Errorf("%s est lu avec sa marque UTF-8 : le bloc d'en-tête sera lu comme du code", name) - } - if strings.Contains(codeOnly(text), ".SYNOPSIS") { - t.Errorf("le bloc d'en-tête de %s survit à codeOnly", name) - } - } -} - -// directive reads one systemd directive out of a unit file. -// -// It reads the LAST occurrence, which is what systemd does for most directives: a unit -// that set Restart= twice would be judged here on the line that does not apply. -func directive(unit, name string) (string, bool) { - value, found := "", false - for _, line := range strings.Split(unit, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "#") || !strings.HasPrefix(line, name+"=") { - continue - } - value, found = strings.TrimSpace(strings.TrimPrefix(line, name+"=")), true - } - return value, found -} - -// environmentComplaints are the systemd-analyze lines that describe the MACHINE and not -// the unit: an ExecStart pointing at a binary this machine has not installed. -// -// It is the only complaint this test forgives, and the reason is that forgiving nothing -// would make the test runnable only on a station where OpenScale is ALREADY installed — -// where it would prove nothing new. Every other complaint still fails. -var environmentComplaints = regexp.MustCompile(`(?m)^.*(is not executable|does not exist).*$\r?\n?`) - -// TestTheUnitIsValidAccordingToSystemdItself runs systemd-analyze when the machine has -// one, which is the only authority on whether systemd will accept a unit. -// -// It skips on Windows, where there is no systemd to ask. The tests below cover what -// matters even there, because a unit is also a document read by whoever debugs the -// station at 8 a.m. -func TestTheUnitIsValidAccordingToSystemdItself(t *testing.T) { - analyze, err := exec.LookPath("systemd-analyze") - if err != nil { - t.Skip("systemd-analyze absent : les directives sont vérifiées par le test suivant") - } - output, err := exec.Command(analyze, "verify", - unitPath("openscale.service"), unitPath("openscale-kiosk.service")).CombinedOutput() - // systemd-analyze checks that ExecStart points at something EXECUTABLE, which on a - // CI runner it never is: nothing is installed there. That complaint is about the - // MACHINE, not about the unit, and failing on it would mean this test can only run - // on a station that is already installed — where it proves nothing new. Every other - // complaint is real and still fails. - if err != nil { - remaining := environmentComplaints.ReplaceAll(output, nil) - if len(bytes.TrimSpace(remaining)) > 0 { - t.Fatalf("systemd-analyze verify refuse les unités : %v\n%s", err, remaining) - } - t.Logf("systemd-analyze ne trouve pas les binaires, ce qui est normal ici :\n%s", output) - return - } - if len(output) > 0 { - t.Logf("systemd-analyze verify a des remarques :\n%s", output) - } -} - -// TestTheStopTimeoutFollowsTheMeasuredShutdownBudget is the §13.4 fix, guarded where it -// can actually drift. -// -// The bug is worth restating: TimeoutStopSec was written 20 s against a shutdown whose -// real budget was 20 s, systemd sent a SIGKILL at the very instant the shutdown was -// finishing, and update.ps1 failed intermittently on a station where nothing was wrong. -// The unit therefore may not carry a round number somebody liked — it has to be at least -// 1.5 × the sum of the budgets the code actually spends, and RAISING one of those budgets -// in Go has to turn this test red. -func TestTheStopTimeoutFollowsTheMeasuredShutdownBudget(t *testing.T) { - unit := readFile(t, unitPath("openscale.service")) - value, found := directive(unit, "TimeoutStopSec") - if !found { - t.Fatal("openscale.service ne déclare pas TimeoutStopSec : systemd appliquerait son défaut de 90 s") - } - seconds, err := strconv.Atoi(strings.TrimSuffix(value, "s")) - if err != nil { - t.Fatalf("TimeoutStopSec=%q illisible : %v", value, err) - } - - internal := station.ShutdownBudget() - required := internal * 3 / 2 - if got := time.Duration(seconds) * time.Second; got < required { - t.Fatalf("TimeoutStopSec=%s, or l'arrêt peut dépenser %s de budgets bornés et §13.4 "+ - "demande au moins 1,5 × (%s) : systemd enverrait un SIGKILL au moment où l'arrêt "+ - "s'achève, et update.ps1 échouerait par intermittence sur un poste sain", - got, internal, required) - } -} - -// TestTheWatchdogIsFedByHealthzAndNothingElse guards the most important rule of §15.3. -// -// A watchdog fed by the health of a device restarts a station when a roll of labels runs -// out, and that station loses a customer's weighing to go and fetch one. The unit asks for -// a watchdog; what feeds it is the liveness of the Hub loop, through /healthz, and the word -// readyz must not appear anywhere in either unit. -func TestTheWatchdogIsFedByHealthzAndNothingElse(t *testing.T) { - unit := readFile(t, unitPath("openscale.service")) - if _, found := directive(unit, "WatchdogSec"); !found { - t.Fatal("openscale.service ne déclare aucun WatchdogSec : une boucle du Hub bloquée ne serait jamais reprise") - } - if kind, _ := directive(unit, "Type"); kind != "notify" { - t.Fatalf("Type=%q : un chien de garde exige Type=notify, sinon systemd n'attend aucun message", kind) - } - if access, _ := directive(unit, "NotifyAccess"); access != "main" { - t.Fatalf("NotifyAccess=%q, attendu main", access) - } - for _, name := range []string{"openscale.service", "openscale-kiosk.service"} { - if strings.Contains(codeOnly(readFile(t, unitPath(name))), "readyz") { - t.Errorf("%s lit /readyz : rien d'automatique ne doit le lire (§14.5, §15.3)", name) - } - } -} - -// TestTheUnitStartsOnAStationWithNoMountPoint is the simplification §15.3 insists on. -// -// Under ProtectSystem=strict a ReadWritePaths= pointing at a path that does not exist makes -// the unit FAIL to start, and a RequiresMountsFor= naming an absent mount adds a dependency -// nothing satisfies. Either one contradicts guiding principle 7 — « le poste démarre -// toujours » — for a deployment mode the document does not even ship. -func TestTheUnitStartsOnAStationWithNoMountPoint(t *testing.T) { - unit := readFile(t, unitPath("openscale.service")) - if _, found := directive(unit, "RequiresMountsFor"); found { - t.Fatal("RequiresMountsFor= dans l'unité : personne n'écrit ça pour lire un fichier de 10 ko (§15.3)") - } - writable, found := directive(unit, "ReadWritePaths") - if !found { - t.Fatal("ProtectSystem=strict sans ReadWritePaths : le poste ne pourrait pas écrire sa base") - } - if strings.Contains(writable, "/srv/") { - t.Fatalf("ReadWritePaths=%q nomme un point de montage : sous ProtectSystem=strict, "+ - "un chemin inexistant fait ÉCHOUER le démarrage", writable) - } - // The three directories the station really writes into, and the two the Go code - // spells for this platform. - for _, required := range []string{"/var/lib/openscale", "/etc/openscale"} { - if !strings.Contains(writable, required) { - t.Errorf("ReadWritePaths=%q n'autorise pas %s", writable, required) - } - } - if strings.Contains(unit, "ProtectSystem=strict") && !strings.Contains(writable, "/var/log") { - t.Errorf("ReadWritePaths=%q n'autorise aucun répertoire de journal", writable) - } -} - -// TestTheUnitsAgreeWithTheGoCodeOnEveryPath keeps the two halves of one station from -// pointing at two different directories. -// -// internal/platform/paths.go is the ONLY place in the Go code allowed to spell a default -// path (§11.1). A unit that named another one would give the service a configuration file -// the administration screen does not write to, and nobody would see it until a volunteer -// changed a setting that had no effect. -func TestTheUnitsAgreeWithTheGoCodeOnEveryPath(t *testing.T) { - unit := readFile(t, unitPath("openscale.service")) - writable, _ := directive(unit, "ReadWritePaths") - - // The Go defaults, read on the platform the unit is for. Windows paths would be a - // meaningless comparison, so what is compared is the Linux constants the code - // carries, which is what these tests can see from here. - for _, path := range []string{"/etc/openscale", "/var/lib/openscale"} { - if !strings.Contains(writable, path) { - t.Errorf("l'unité n'autorise pas %s, que le code Go nomme comme emplacement par défaut", path) - } - } - start, found := directive(unit, "ExecStart") - if !found || !strings.HasSuffix(start, " serve") { - t.Fatalf("ExecStart=%q : l'unité doit lancer la sous-commande serve et rien d'autre", start) - } - if user, _ := directive(unit, "User"); user == "root" { - t.Error("User=root : le poste tourne sous un compte sans privilèges") - } -} - -// TestTheKioskUnitIsWantedByAThingThatExists is the trap §15.3 names outright: -// systemd.special(7) discourages graphical-session.target in a WantedBy=, it is only ever -// activated by a session manager, and on a minimal station the unit would never start. -func TestTheKioskUnitIsWantedByAThingThatExists(t *testing.T) { - unit := readFile(t, unitPath("openscale-kiosk.service")) - wanted, found := directive(unit, "WantedBy") - if !found { - t.Fatal("l'unité du kiosque n'a pas de WantedBy : « systemctl enable » n'aurait rien à activer") - } - if strings.Contains(wanted, "graphical-session.target") { - t.Fatal("WantedBy=graphical-session.target : sur un poste minimal, l'unité ne démarrerait JAMAIS (§15.3)") - } - if wanted != "multi-user.target" { - t.Fatalf("WantedBy=%q, attendu multi-user.target", wanted) - } - if start, _ := directive(unit, "ExecStart"); !strings.Contains(start, "cage") || - !strings.HasSuffix(start, "openscale kiosk") { - t.Fatalf("ExecStart=%q : le kiosque est « cage -d -- openscale kiosk » (§15.3)", start) - } - if pam, _ := directive(unit, "PAMName"); pam != "login" { - t.Fatalf("PAMName=%q, attendu login : sans vraie session, cage ne trouve ni clavier ni GPU", pam) - } - if tty, _ := directive(unit, "TTYPath"); tty != "/dev/tty1" { - t.Fatalf("TTYPath=%q, attendu /dev/tty1", tty) - } - // A kiosk that REQUIRED the service would leave a black screen on a station whose - // configuration is broken — exactly the station somebody needs to reach the - // administration screen of (§11.3). - if _, found := directive(unit, "Requires"); found { - t.Error("Requires= sur le service : un poste dont le service refuse de démarrer doit " + - "quand même afficher quelque chose (principe directeur 7)") - } -} - -// TestTheRebootRuleGrantsOneActionToOneAccount. -// -// This file is a privilege, shipped in an installer and applied by root on a machine -// nobody audits afterwards. What bounds it is written here rather than left to a review -// that will not happen: one action, one account, and NOT the power-off — a station -// switched off from the screen does not switch itself back on, and nothing offers it. -func TestTheRebootRuleGrantsOneActionToOneAccount(t *testing.T) { - rule := readFile(t, filepath.Join("linux", "49-openscale-reboot.rules")) - - if !strings.Contains(rule, "org.freedesktop.login1.reboot") { - t.Fatal("la règle n'accorde pas le redémarrage : le bouton sera refusé sur tout poste Linux") - } - if !strings.Contains(rule, "subject.user === 'openscale'") { - t.Error("la règle ne se limite pas au compte du service") - } - for _, forbidden := range []string{"power-off", "ignore-inhibit", "multiple-sessions"} { - // The comments name these three to say they are EXCLUDED, so only the code is - // searched: a test reading the whole file would fail on its own documentation. - if strings.Contains(rulesCode(rule), forbidden) { - t.Errorf("la règle accorde aussi %q, qui n'a jamais été demandé", forbidden) - } - } -} - -// rulesCode strips the comments of a polkit rule, so that what it SAYS is not read as -// what it does. -func rulesCode(rule string) string { - var code strings.Builder - for _, line := range strings.Split(rule, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "//") { - continue - } - code.WriteString(line) - code.WriteString("\n") - } - return code.String() -} - -// TestInstallPosesTheRebootRule: without it the button answers « accès refusé » on a -// station where everything else works, which is the failure nobody would diagnose. -func TestInstallPosesTheRebootRule(t *testing.T) { - script := readFile(t, filepath.Join("linux", "install.sh")) - if !strings.Contains(script, "49-openscale-reboot.rules") { - t.Fatal("install.sh ne pose pas la règle polkit") - } - if !strings.Contains(script, "/etc/polkit-1/rules.d") { - t.Error("install.sh ne nomme pas le répertoire où polkit lit ses règles") - } - removal := readFile(t, filepath.Join("linux", "uninstall.sh")) - if !strings.Contains(removal, "49-openscale-reboot.rules") { - t.Error("uninstall.sh laisse la règle polkit derrière lui : un privilège survit au poste") - } -} - -// TestTheUdevRuleDoesNotInventAVendorIdentifier holds the line §15.3 draws: « les -// idVendor sont relevés par lsusb sur site — on ne les invente pas ». -// -// A rule carrying a made-up identifier creates no symlink and sends somebody looking for -// an hour. The printer rule is therefore shipped COMMENTED, with the procedure to fill it -// in, and the placeholder must not be live. -func TestTheUdevRuleDoesNotInventAVendorIdentifier(t *testing.T) { - rules := readFile(t, filepath.Join("linux", "99-openscale.rules")) - for number, line := range strings.Split(rules, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.Contains(trimmed, "XXXX") { - t.Fatalf("ligne %d : une règle udev active porte un identifiant inventé (XXXX)", number+1) - } - } - if !strings.Contains(rules, `SYMLINK+="openscale-serial`) { - t.Error("aucun symlink stable pour le port série : /dev/ttyUSB0 devient ttyUSB1 après un rebranchement") - } - if !strings.Contains(rules, "lsusb") { - t.Error("la règle de l'imprimante ne dit pas comment relever ses identifiants") - } -} - -// --- La tâche planifiée ------------------------------------------------------------- - -// scheduledTask is the part of the task XML this test reads. -type scheduledTask struct { - Triggers struct { - Logon struct { - UserID string `xml:"UserId"` - } `xml:"LogonTrigger"` - } `xml:"Triggers"` - Principals struct { - Principal struct { - UserID string `xml:"UserId"` - LogonType string `xml:"LogonType"` - RunLevel string `xml:"RunLevel"` - } `xml:"Principal"` - } `xml:"Principals"` - Settings struct { - ExecutionTimeLimit string `xml:"ExecutionTimeLimit"` - MultipleInstancesPolicy string `xml:"MultipleInstancesPolicy"` - Enabled string `xml:"Enabled"` - DisallowStartIfOnBatteries string `xml:"DisallowStartIfOnBatteries"` - } `xml:"Settings"` - Actions struct { - Exec struct { - Command string `xml:"Command"` - Arguments string `xml:"Arguments"` - } `xml:"Exec"` - } `xml:"Actions"` -} - -// TestTheKioskTaskIsWhatMakesTheScreenComeBackAlone reads the scheduled task the way -// Windows will. -// -// Every assertion below is one way the criterion of §18 fails silently: a task that needs -// a password stops working the day it changes, a task with the default three-day execution -// limit closes the client screen on the fourth day of continuous opening, and a task that -// runs elevated makes a self-service station an administrator session. -func TestTheKioskTaskIsWhatMakesTheScreenComeBackAlone(t *testing.T) { - raw := readFile(t, filepath.Join("windows", "openscale-kiosk.xml")) - var task scheduledTask - if err := xml.Unmarshal([]byte(raw), &task); err != nil { - t.Fatalf("openscale-kiosk.xml n'est pas un XML exploitable : %v", err) - } - - if task.Principals.Principal.LogonType != "InteractiveToken" { - t.Fatalf("LogonType=%q : InteractiveToken est ce qui évite de fournir un mot de passe "+ - "à schtasks — une tâche enregistrée avec un mot de passe cesse de démarrer le jour "+ - "où il change", task.Principals.Principal.LogonType) - } - if task.Principals.Principal.RunLevel == "HighestAvailable" { - t.Error("RunLevel=HighestAvailable : le kiosque tourne sans privilèges") - } - if task.Settings.ExecutionTimeLimit != "PT0S" { - t.Fatalf("ExecutionTimeLimit=%q : la valeur par défaut de Windows arrêterait l'écran "+ - "client au bout de trois jours d'ouverture continue", task.Settings.ExecutionTimeLimit) - } - if task.Settings.MultipleInstancesPolicy != "IgnoreNew" { - t.Errorf("MultipleInstancesPolicy=%q : deux superviseurs, c'est deux navigateurs qui se "+ - "relancent l'un l'autre", task.Settings.MultipleInstancesPolicy) - } - if task.Settings.DisallowStartIfOnBatteries != "false" { - t.Errorf("DisallowStartIfOnBatteries=%q : sur un poste derrière un onduleur, Windows "+ - "refuserait de lancer l'écran client", task.Settings.DisallowStartIfOnBatteries) - } - if task.Actions.Exec.Arguments != "kiosk" { - t.Fatalf("la tâche lance %q, attendu la sous-commande kiosk", task.Actions.Exec.Arguments) - } - if task.Triggers.Logon.UserID == "" { - t.Error("aucun déclencheur d'ouverture de session : le poste ne reviendrait pas seul sur l'écran client") - } -} - -// TestEveryPlaceholderOfTheTaskIsSubstitutedByTheInstaller catches the drift that leaves a -// station launching a program called « %OPENSCALE_BINARY% ». -func TestEveryPlaceholderOfTheTaskIsSubstitutedByTheInstaller(t *testing.T) { - raw := readFile(t, filepath.Join("windows", "openscale-kiosk.xml")) - installer := readFile(t, filepath.Join("windows", "install.ps1")) - - placeholders := regexp.MustCompile(`%OPENSCALE_[A-Z_]+%`).FindAllString(raw, -1) - if len(placeholders) == 0 { - t.Fatal("le XML ne porte aucun marqueur : il contient donc un chemin en dur") - } - for _, placeholder := range placeholders { - if !strings.Contains(installer, placeholder) { - t.Errorf("le marqueur %s du XML n'est substitué par aucune ligne de install.ps1 : "+ - "la tâche lancerait un programme de ce nom", placeholder) - } - } -} - -// --- Ce sur quoi les scripts et le binaire doivent être d'accord --------------------- - -// TestTheScriptsCallOnlySubcommandsThisBinaryHas is the drift guard that matters most, -// because its failure mode is silent: `schtasks` records the task, the service registers, -// and nothing runs. -func TestTheScriptsCallOnlySubcommandsThisBinaryHas(t *testing.T) { - dispatched := subcommandsOfTheBinary(t) - // What the shipped scripts invoke, read out of the scripts themselves. - invoked := map[string][]string{ - filepath.Join("windows", "install.ps1"): {"service", "config", "doctor"}, - filepath.Join("windows", "update.ps1"): {"service"}, - filepath.Join("windows", "uninstall.ps1"): {"service"}, - filepath.Join("windows", "start.bat"): {"serve", "kiosk"}, - filepath.Join("linux", "install.sh"): {"config", "doctor"}, - filepath.Join("linux", "update.sh"): {"config"}, - } - for path, subcommands := range invoked { - script := readFile(t, path) - for _, subcommand := range subcommands { - if !dispatched[subcommand] { - t.Errorf("%s appelle « openscale %s », que main.go ne connaît pas", path, subcommand) - } - if !strings.Contains(script, subcommand) { - t.Errorf("%s ne contient plus « %s » : ce test a cessé de vérifier quoi que ce soit", path, subcommand) - } - } - } - // The task XML and the kiosk unit launch one subcommand each, and both are named in - // files the tests above already read. - if !dispatched["kiosk"] { - t.Error("la sous-commande kiosk n'existe pas : ni la tâche planifiée ni l'unité du kiosque ne lanceraient quoi que ce soit") - } -} - -// subcommandsOfTheBinary reads what main.go dispatches, so that renaming a subcommand -// breaks this test instead of an installation. -func subcommandsOfTheBinary(t *testing.T) map[string]bool { - t.Helper() - source := readFile(t, filepath.Join("..", "cmd", "openscale", "main.go")) - found := make(map[string]bool) - for _, match := range regexp.MustCompile(`case "([a-z-]+)"`).FindAllStringSubmatch(source, -1) { - found[match[1]] = true - } - if len(found) < 5 { - t.Fatalf("seulement %d sous-commandes trouvées dans main.go : la lecture est fausse", len(found)) - } - return found -} - -// TestTheScriptsAndTheBinaryAgreeOnTheNames keeps one station from being called two -// things. -func TestTheScriptsAndTheBinaryAgreeOnTheNames(t *testing.T) { - common := readFile(t, filepath.Join("windows", "common.ps1")) - if !strings.Contains(common, "$script:ServiceName = '"+platform.ServiceName+"'") { - t.Errorf("common.ps1 ne nomme pas le service %q : sc.exe et le binaire ne parleraient pas "+ - "du même service", platform.ServiceName) - } - // The Windows data root the Go code spells, and the one the installer creates. - root := filepath.Dir(platform.DefaultDataDir()) - if goos := platform.DefaultConfigPath(); strings.Contains(goos, `\`) { - if !strings.Contains(common, filepath.Base(root)) { - t.Errorf("common.ps1 ne crée pas %s, où le binaire lit sa configuration", root) - } - } - // The Linux units, against the same authority. - unit := readFile(t, unitPath("openscale.service")) - if !strings.Contains(unit, "/usr/local/bin/openscale") { - t.Error("l'unité ne lance pas /usr/local/bin/openscale, que install.sh installe") - } -} - -// --- La sauvegarde et la restauration, sur un répertoire factice --------------------- - -// powershellPath finds a PowerShell able to dot-source common.ps1. -// powershellPaths returns EVERY PowerShell installed on this machine, and not the first one. -// -// The plural is the whole point, and it is what v0.1 cost. The singular version tried -// « pwsh » first and CI runs on ubuntu-latest, so these scripts had never once been read by -// the shell they are written for — common.ps1 says so in its own header: « le poste sur -// lequel ces scripts tournent n'a que 5.1 ». Running under every shell present costs one -// subtest on Linux, where only pwsh exists, and finds on Windows what no Linux runner can. -func powershellPaths(t *testing.T) []string { - t.Helper() - var found []string - for _, candidate := range []string{"pwsh", "powershell"} { - if path, err := exec.LookPath(candidate); err == nil { - found = append(found, path) - } - } - if len(found) == 0 { - t.Skip("ni pwsh ni powershell : les scripts ne peuvent pas être analysés sur cette machine") - } - return found -} - -// runPowerShell runs a script and returns its output. -func runPowerShell(t *testing.T, shell, script string) (string, error) { - t.Helper() - command := exec.Command(shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", script) - output, err := command.CombinedOutput() - return string(output), err -} - -// writeScript writes a test harness the way a .ps1 has to be written: with the mark. -// -// The harnesses below carry French, and a test bench that broke on its own accents would -// accuse the script it is exercising. It obeys the rule it enforces — see -// TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds. -func writeScript(t *testing.T, path, body string) { - t.Helper() - if err := os.WriteFile(path, append(append([]byte{}, utf8Mark...), body...), 0o644); err != nil { - t.Fatalf("écriture du banc %s : %v", path, err) - } -} - -// utf8Mark is the byte order mark, EF BB BF. -var utf8Mark = []byte{0xEF, 0xBB, 0xBF} - -// powerShellScripts lists every PowerShell script of the repository. -// -// The whole repository is walked rather than deploy/windows: make.ps1 lives at the root and -// carries the same traps as the installers. -func powerShellScripts(t *testing.T) []string { - t.Helper() - var scripts []string - walk := func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - // dist and bin are git-ignored: what they hold is build OUTPUT, not source. A - // `make release` left in place puts a copy of the scripts there, and a test - // would accuse a file that is not in the repository. .claude holds git - // worktrees, which are whole checkouts of OTHER branches: a test walking them - // reports twice, and blames this tree for what another one carries. - switch entry.Name() { - case ".git", ".claude", "node_modules", "dist", "bin": - return fs.SkipDir - } - return nil - } - switch strings.ToLower(filepath.Ext(path)) { - case ".ps1", ".psm1": - scripts = append(scripts, path) - } - return nil - } - if err := filepath.WalkDir("..", walk); err != nil { - t.Fatalf("parcours du dépôt : %v", err) - } - if len(scripts) == 0 { - t.Fatal("aucun script PowerShell trouvé dans le dépôt : ce test ne prouve plus rien") - } - return scripts -} - -// TestAPilotStationIsToldHowToStart is the regression of a station installed on 01/08/2026. -// -// The closing screen of the installer promised EVERYBODY a station that « revient SEUL » -// after a reboot, and asked for that reboot as the compulsory acceptance. A pilot station -// does no such thing, by construction: its service is installed with --start demand, which -// is exactly what leaves the Access application relaunchable in two minutes. The operator -// was left in front of a station that was installed, correct, and that nothing anywhere -// said how to switch on — neither the installer, nor INSTALLATION.md, nor TROUBLESHOOTING. -// -// So the promise belongs to the branch that keeps it, and the other branch owes the four -// gestures of a pilot station instead. -func TestAPilotStationIsToldHowToStart(t *testing.T) { - // codeOnly and not the raw text: the comment that guards this branch QUOTES the promise - // it exists to forbid, and a test reading it would accuse the very sentence that keeps - // the next reader from putting the defect back. - script := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) - - // From the closing banner and not from the top of the file: `$startMode = if ($Pilot)` - // decides the service start mode a hundred lines earlier, and a search that stopped - // there would read the whole installer as if it were the message. - banner := strings.Index(script, "IL RESTE TROIS CHOSES") - if banner < 0 { - t.Fatal("install.ps1 n'affiche plus son message de fin : ce test ne prouve plus rien") - } - branch := strings.Index(script[banner:], "if ($Pilot) {") - if branch < 0 { - t.Fatal("install.ps1 ne distingue plus le mode pilote dans son message de fin") - } - branch += banner - end := strings.Index(script[branch:], "\nelse {") - if end < 0 { - t.Fatal("la branche pilote du message de fin n'a pas d'autre branche") - } - pilot := script[branch : branch+end] - - // The gestures a pilot station lives on. `service start` is the one whose absence was - // the whole defect; `stop` is what gives the machine back to Access, and without it the - // pilot mode has no way out. - for what, needle := range map[string]string{ - "le démarrage du service": "service start", - "l'arrêt du service": "service stop", - "l'ouverture de l'écran": "kiosk", - "les raccourcis du Bureau": "Bureau", - "le chemin complet du binaire": "$($paths.Binary)", - } { - if !strings.Contains(pilot, needle) { - t.Errorf("le message de fin d'un poste PILOTE ne dit pas %s (« %s » absent)", - what, needle) - } - } - - // And the promise stays where it holds. The needle is the promise as the production - // branch words it, not the word « seul »: the pilot branch has to be free to say that - // the station does NOT come back on its own, and that the client screen recovers by - // itself once the service is up — two true sentences that a blunter rule would forbid. - if strings.Contains(pilot, "revient SEUL") { - t.Error("le message de fin promet à un poste PILOTE qu'il revient SEUL : son service " + - "est en démarrage « demand », il ne reviendra pas") - } -} - -// TestThePilotShortcutsLeaveWhenTheyStopMeaningAnything. -// -// Two buttons on the public desktop are two promises. One that survives a reinstallation in -// production would switch off a station nobody must switch off; one that survives the -// uninstaller would launch a binary that has just been deleted. Set-PilotShortcuts is -// therefore called in BOTH modes of the installer — the removal is what the false branch -// does — and once more by the uninstaller. -func TestThePilotShortcutsLeaveWhenTheyStopMeaningAnything(t *testing.T) { - installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) - call := regexp.MustCompile(`Set-PilotShortcuts\s+-Pilot\s+\(\[bool\]\$Pilot\)`) - if !call.MatchString(installer) { - t.Error("install.ps1 n'appelle pas Set-PilotShortcuts avec les DEUX modes : réinstaller " + - "en production un poste qui était en pilote y laisserait ses deux raccourcis") - } - - remover := codeOnly(readFile(t, filepath.Join("windows", "uninstall.ps1"))) - if !strings.Contains(remover, "Set-PilotShortcuts -Pilot $false") { - t.Error("uninstall.ps1 ne retire pas les raccourcis du Bureau : ils lanceraient un " + - "binaire supprimé") - } - - // The elevation flag is a byte of the file, and nothing else sets it: WScript.Shell has - // no property for it. Losing this line turns « Démarrer le poste » into an access - // denied in front of a volunteer. - common := codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))) - if !strings.Contains(common, "-bor 0x20") { - t.Error("common.ps1 ne pose plus le drapeau d'élévation de l'octet 0x15 : les deux " + - "raccourcis répondront « accès refusé »") - } -} - -// TestNoDotSourcedConstantLandsOnAParameterOfItsCaller is the second half of the same trap, -// and the one no single file shows. -// -// A dot-source runs the sourced file IN THE CALLER'S SCOPE, and the parameters of a script -// live in that script's scope. common.ps1 sets `$script:InstallDir` and `$script:DataRoot`; -// bootstrap.ps1 declares `-InstallDir` and `-DataRoot`. Loading the first therefore -// REPLACED what the operator had asked with the factory locations — measured on a bench: -// `-InstallDir D:\OpenScale` comes back out as `C:\Program Files\OpenScale`, and the three -// branches that choose the paths always take the first. Nothing warned, and the station was -// installed somewhere else than where it had been asked to go. -// -// Renaming a parameter is not an option: -InstallDir and -DataRoot are the public names of -// two options, and TestTheInstallerDeclaresEveryParameterTheBootstrapPasses holds bootstrap -// and installer in step. What is asked is therefore put out of reach BEFORE the dot-source, -// under a name common.ps1 does not know — and what this test checks is exactly that: past -// the dot-source, the parameter is EMPTIED, so reading it is the defect. A rule about where -// the value is read survives a rename; one about how it is saved would not. -func TestNoDotSourcedConstantLandsOnAParameterOfItsCaller(t *testing.T) { - shared := map[string]bool{} - constant := regexp.MustCompile(`^\$script:(\w+)\s*=[^=]`) - for _, line := range strings.Split(codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))), "\n") { - if match := constant.FindStringSubmatch(strings.TrimSpace(line)); match != nil { - shared[strings.ToLower(match[1])] = true - } - } - if len(shared) == 0 { - t.Fatal("common.ps1 ne pose plus aucune variable de script : ce test ne prouve plus rien") - } - - // `\$nom` cannot match inside `$requestedNom` nor behind the `-Nom` of a call: the - // dollar sign has to touch the name. - readers := map[string]*regexp.Regexp{} - for name := range shared { - readers[name] = regexp.MustCompile(`(?i)\$` + regexp.QuoteMeta(name) + `\b`) - } - - for _, script := range []string{"bootstrap.ps1", "install.ps1", "update.ps1", "uninstall.ps1", "harden.ps1"} { - lines := strings.Split(codeOnly(readFile(t, filepath.Join("windows", script))), "\n") - source := -1 - for number, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), ". (") && strings.Contains(line, "common.ps1") { - source = number - } - } - if source < 0 { - t.Errorf("%s ne charge plus common.ps1 : ce test ne prouve plus rien pour lui", script) - continue - } - - for number, line := range lines[source+1:] { - for name, reader := range readers { - if reader.MatchString(line) && !strings.Contains(strings.ToLower(line), "$script:"+name) { - t.Errorf("%s, ligne %d : $%s est lu APRÈS le point-source de common.ps1, "+ - "qui vient de l'écraser avec la valeur d'usine — ce que l'opérateur a "+ - "demandé se met à l'abri avant.\n %s", - script, source+number+2, name, strings.TrimSpace(line)) - } - } - } - } -} - -// TestNoScriptConstantIsSilentlyReassigned is the regression of v1.1, and it is a trap -// PowerShell lays rather than a typo somebody made. -// -// Variable names are case-INSENSITIVE, and an unqualified assignment written at the top -// level of a script writes into the SCRIPT scope. `$checksumAsset = $release.assets | …` -// was therefore not a new variable at all: it overwrote `$script:ChecksumAsset`, the -// constant holding the NAME of that asset. Three lines later, `Join-Path $workspace -// $script:ChecksumAsset` built a path out of a stringified object — -// « …\Temp\openscale-v1.1\@{url=https:\\api.github.com\…; id=497915905; …} » — and -// Invoke-WebRequest answered « Le format du chemin d'accès donné n'est pas pris en charge », -// naming neither the variable nor the line that emptied it. No station could get past the -// fingerprint check, and nothing in deploy/ saw it: these tests read the scripts, they do -// not run them against an API. -// -// The rule is one a reader can hold in their head: a constant of the header is written -// ONCE. No script here has any reason to reassign one, so a second assignment — whatever -// its case, whatever its scope prefix — is the defect and not a style. -func TestNoScriptConstantIsSilentlyReassigned(t *testing.T) { - // Both patterns are anchored on the START of the statement, and that is what separates - // an assignment from a parameter default: `param([string]$DataRoot = $script:DataRoot)` - // declares a LOCAL and shadows nothing, whereas the defect is a line that opens on the - // variable it is about to empty. - declaration := regexp.MustCompile(`^\$script:(\w+)\s*=[^=]`) - - for _, script := range powerShellScripts(t) { - lines := strings.Split(codeOnly(readFile(t, script)), "\n") - - // `\$nom` cannot match inside `$script:nom`: what follows the dollar sign there is - // « script: ». Case-insensitive, because PowerShell is — that is the whole trap. - clobbers := map[string]*regexp.Regexp{} - declared := map[string]int{} - for number, line := range lines { - for _, match := range declaration.FindAllStringSubmatch(strings.TrimSpace(line), -1) { - name := strings.ToLower(match[1]) - declared[name] = number + 1 - clobbers[name] = regexp.MustCompile(`(?i)^\$` + regexp.QuoteMeta(name) + `\s*=[^=]`) - } - } - - for number, line := range lines { - for name, clobber := range clobbers { - if clobber.MatchString(strings.TrimSpace(line)) { - t.Errorf("%s, ligne %d : cette affectation écrase $script:%s, la constante "+ - "déclarée ligne %d — les noms de variables PowerShell sont insensibles "+ - "à la casse, et à la racine d'un script une affectation non qualifiée "+ - "écrit dans la portée du script.\n %s", - script, number+1, name, declared[name], strings.TrimSpace(line)) - } - } - } - } -} - -// TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds is the encoding contract, -// and it exists because v0.1 shipped without it. -// -// Windows PowerShell 5.1 — the ONLY PowerShell on a station, and the one a right-click -// « Exécuter avec PowerShell » starts — decodes a .ps1 with no mark as ANSI. PowerShell 7 -// assumes UTF-8. The two therefore read every accent in these scripts differently, and one -// sequence is fatal rather than merely ugly: « — » is E2 80 94 in UTF-8, which CP1252 reads -// as « — » whose last character is U+201D, a closing DOUBLE QUOTE for the PowerShell -// parser. The string literal ends on the dash, the rest of the line becomes code, and the -// installer stops parsing. That is what v0.1 did on the machine it was written for: five -// scripts, thirteen parse errors, and not one line executed. -// -// The rule is « all of them » and not « those with an accent », because a script that is -// ASCII today gets its first French message tomorrow, and whoever writes that message will -// not be thinking about byte order marks. -// -// The whole repository is walked rather than deploy/windows: make.ps1 lives at the root and -// carries the same trap. -func TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds(t *testing.T) { - for _, script := range powerShellScripts(t) { - // bootstrap.ps1 est la seule exception, et c'est la règle INVERSE plutôt qu'une - // absence de règle : c'est le seul .ps1 que personne ne lit sur un disque — « irm … - // | iex » le donne au parseur comme un FLUX, où la marque se colle au « <# » de son - // en-tête et fait lire tout le fichier comme du code. Il ne porte donc pas la - // marque, et pas d'accent dans son code non plus, ce qui est exactement ce qui rend - // une relecture en CP1252 sans effet. Les deux moitiés sont tenues par - // TestTheBootstrapIsReadAsAStreamSoItCarriesNeitherMarkNorAccent. - if filepath.Base(script) == bootstrapPath { - continue - } - raw, err := os.ReadFile(script) - if err != nil { - t.Errorf("lecture de %s : %v", script, err) - continue - } - if bytes.HasPrefix(raw, utf8Mark) { - continue - } - t.Errorf("%s n'a pas de marque d'ordre des octets (EF BB BF) : Windows PowerShell 5.1 "+ - "le lira en ANSI.\n%s", script, whatFiveOneWillRead(raw)) - } -} - -// whatFiveOneWillRead shows the first line a mark-less file would lose, as CP1252 reads it. -// -// The failure message names the damage instead of citing three magic bytes: whoever adds a -// script sees the line that will break and why, not a constant to silence. -func whatFiveOneWillRead(raw []byte) string { - for number, line := range bytes.Split(raw, []byte("\n")) { - decoded, differs := decodeCP1252(line) - if !differs { - continue - } - return fmt.Sprintf(" ligne %d, écrite en UTF-8 : %s\n"+ - " ligne %d, telle que 5.1 la lira : %s", - number+1, strings.TrimRight(string(line), "\r"), - number+1, strings.TrimRight(decoded, "\r")) - } - return " (ce fichier est en ASCII pur, donc il survivrait aujourd'hui — la règle vaut " + - "pour tous, parce qu'il ne le restera pas)" -} - -// cp1252Extras is the 0x80–0x9F range of CP1252, the only place it differs from Latin-1. -// -// COPIED from the Windows code page table, never derived. Five positions are unassigned -// (0x81, 0x8D, 0x8F, 0x90, 0x9D) and Windows maps them to the control character of the same -// value; they are written out here so the table has thirty-two entries and no hole to -// misread. 0x94 is U+201D, the one that ends a string literal, and 0x97 is the em dash it -// comes from. -var cp1252Extras = [32]rune{ - 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, - 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, - 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, - 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, -} - -// decodeCP1252 reads bytes the way Windows PowerShell 5.1 reads a script with no mark, and -// says whether that reading differs from the UTF-8 one. -func decodeCP1252(raw []byte) (string, bool) { - var text strings.Builder - differs := false - for _, b := range raw { - switch { - case b < 0x80: - text.WriteByte(b) - case b < 0xA0: - text.WriteRune(cp1252Extras[b-0x80]) - differs = true - default: - text.WriteRune(rune(b)) - differs = true - } - } - return text.String(), differs -} - -// TestEveryPowerShellScriptParses is the syntactic check: a script with a typo in it is a -// station half-installed, and the typo is found by whoever runs it as administrator on a -// Saturday morning. -// -// It uses the PowerShell parser itself rather than a heuristic, and it checks the four -// scripts plus the shared file — under EVERY PowerShell installed, because the encoding -// defect above is invisible to PowerShell 7 and fatal to 5.1. -func TestEveryPowerShellScriptParses(t *testing.T) { - scripts, err := filepath.Glob(filepath.Join("windows", "*.ps1")) - if err != nil || len(scripts) == 0 { - t.Fatalf("aucun script PowerShell trouvé : %v", err) - } - - body := `$ErrorActionPreference = 'Stop' -$failed = 0 -foreach ($path in $args) { } -$scripts = @(` + quoteForPowerShell(scripts) + `) -foreach ($script in $scripts) { - $tokens = $null - $errors = $null - [void][System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path $script), [ref]$tokens, [ref]$errors) - if ($errors.Count -gt 0) { - $failed = 1 - Write-Output "FAUTE $script" - foreach ($e in $errors) { Write-Output (" ligne {0} : {1}" -f $e.Extent.StartLineNumber, $e.Message) } - } else { - Write-Output "OK $script" - } -} -exit $failed -` - for _, shell := range powershellPaths(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - harness := filepath.Join(t.TempDir(), "parse.ps1") - writeScript(t, harness, body) - output, err := runPowerShell(t, shell, harness) - if err != nil { - t.Fatalf("un script PowerShell ne s'analyse pas sous %s :\n%s", shell, output) - } - for _, script := range scripts { - if !strings.Contains(output, "OK "+script) && !strings.Contains(output, filepath.Base(script)) { - t.Errorf("%s n'a pas été analysé :\n%s", script, output) - } - } - t.Logf("%s", strings.TrimSpace(output)) - }) - } -} - -// quoteForPowerShell renders a list of paths as a PowerShell array literal. -func quoteForPowerShell(paths []string) string { - quoted := make([]string, 0, len(paths)) - for _, path := range paths { - absolute, err := filepath.Abs(path) - if err != nil { - absolute = path - } - quoted = append(quoted, "'"+strings.ReplaceAll(absolute, "'", "''")+"'") - } - return strings.Join(quoted, ", ") -} - -// TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory exercises important-15 where it can -// be exercised: the FILE half of the backup, on a directory the test owns. -// -// The registry half cannot be run without an elevated session, and a test that asked for -// one would be a test nobody runs. What is proved here is everything the file layer -// promises — an existing snapshot is never overwritten, a timestamped backup is taken, a -// restore puts the exact bytes back — plus the one thing that would silently ruin the -// snapshot: ConvertTo-Json's default depth of 2, which writes -// « System.Collections.Hashtable » in place of a nested object. -func TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory(t *testing.T) { - // WINDOWS ONLY, and not out of laziness: common.ps1 derives every path it touches - // from $env:ProgramFiles and $env:ProgramData, which are EMPTY on Linux. PowerShell - // is installed on the CI runner, so the harness starts and then fails on a - // Join-Path with a null argument — a failure that says nothing about the backup and - // everything about the machine it ran on. - if runtime.GOOS != "windows" { - t.Skip("common.ps1 dérive ses chemins de %ProgramFiles% et %ProgramData% : " + - "ce banc n'a de sens que sur Windows") - } - common, err := filepath.Abs(filepath.Join("windows", "common.ps1")) - if err != nil { - t.Fatalf("chemin de common.ps1 : %v", err) - } - - // One work directory PER SHELL, and not one for both: step 2 proves that a second - // install.ps1 does not overwrite the first snapshot, so a reused directory would make - // the second subtest fail on step 1 for a reason that has nothing to do with the shell. - for _, shell := range powershellPaths(t) { - t.Run(filepath.Base(shell), func(t *testing.T) { - work := t.TempDir() - harness := filepath.Join(work, "harness.ps1") - body := `$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -. '` + strings.ReplaceAll(common, "'", "''") + `' - -$work = '` + strings.ReplaceAll(work, "'", "''") + `' -$restore = Join-Path $work 'restore.json' -$binary = Join-Path $work 'openscale.exe' -$backups = Join-Path $work 'backups' - -# --- 1. L'instantané est écrit, avec ses sous-objets -------------------------------- -$snapshot = @{ - saved_at = '2026-07-26T08:00:00' - winlogon = @{ AutoAdminLogon = '0'; DefaultUserName = 'ancien'; DefaultPassword = $null } - power = @{ scheme_guid = '381b4222-f694-41f0-9685-ff5bb260df2e'; usb_selective_suspend_ac = 1 } -} -if (-not (Save-Snapshot -Path $restore -Snapshot $snapshot)) { throw 'le premier instantané n''a pas été écrit' } -$read = Read-Snapshot -Path $restore -if ($read.winlogon.DefaultUserName -ne 'ancien') { throw 'le sous-objet winlogon a été perdu' } -if ($read.power.usb_selective_suspend_ac -ne 1) { throw 'la suspension USB n''a pas été sauvegardée' } -$raw = Get-Content -Path $restore -Raw -if ($raw -match 'System.Collections.Hashtable') { throw 'restore.json contient un objet non serialise (ConvertTo-Json -Depth)' } - -# --- 2. Un second install.ps1 n'écrase PAS l'instantané d'origine ------------------- -$second = @{ saved_at = '2026-12-25T00:00:00'; winlogon = @{ AutoAdminLogon = '1' } } -if (Save-Snapshot -Path $restore -Snapshot $second) { throw 'le second instantané a écrasé le premier' } -$read = Read-Snapshot -Path $restore -if ($read.saved_at -ne '2026-07-26T08:00:00') { throw 'l''instantané d''origine a été perdu' } - -# --- 3. Sauvegarde horodatée d'un binaire, puis restauration ------------------------ -Set-Content -Path $binary -Value 'VERSION 1' -Encoding utf8 -$copy = Backup-File -Path $binary -Directory $backups -Stamp '2026-07-26T08-00-00' -if (-not (Test-Path $copy)) { throw 'la sauvegarde du binaire n''existe pas' } -if ($copy -notlike '*openscale-2026-07-26T08-00-00.exe') { throw "nom de sauvegarde inattendu : $copy" } - -Set-Content -Path $binary -Value 'VERSION 2 CASSEE' -Encoding utf8 -Restore-File -Backup $copy -Target $binary | Out-Null -if ((Get-Content -Path $binary -Raw).Trim() -ne 'VERSION 1') { throw 'la restauration n''a pas remis la version précédente' } -if (-not (Test-Path $copy)) { throw 'la restauration a consommé la sauvegarde : un second essai serait impossible' } - -# --- 4. Deux sauvegardes le même jour ne se recouvrent pas ------------------------- -$other = Backup-File -Path $binary -Directory $backups -Stamp '2026-07-26T09-30-00' -if ($other -eq $copy) { throw 'deux sauvegardes portent le même nom' } -if ((Get-ChildItem $backups).Count -ne 2) { throw 'une sauvegarde a écrasé l''autre' } - -# --- 5. Ce qui doit échouer échoue ------------------------------------------------ -try { Backup-File -Path (Join-Path $work 'absent.exe') -Directory $backups; throw 'ECHEC ATTENDU' } -catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'sauvegarder un fichier absent a réussi' } } -try { Restore-File -Backup (Join-Path $work 'absent.bak') -Target $binary; throw 'ECHEC ATTENDU' } -catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'restaurer une sauvegarde absente a réussi' } } - -# --- 6. L'adresse d'écoute vient du fichier, pas d'une supposition ----------------- -$config = Join-Path $work 'config.json' -Set-Content -Path $config -Value '{ "network": { "listen": "0.0.0.0:9000" } }' -Encoding utf8 -$address = Get-ListenAddress -ConfigPath $config -if ($address -ne 'http://127.0.0.1:9000') { throw "adresse deduite $address" } -$address = Get-ListenAddress -ConfigPath (Join-Path $work 'inexistant.json') -if ($address -ne 'http://127.0.0.1:8085') { throw "adresse par defaut $address" } - -# --- 7. La fiche d'installation porte ce qu'un bénévole doit y lire --------------- -$sheet = Join-Path $work 'install-sheet.txt' -Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -Fingerprint 'a1b2c3d4' -StationNumber '2' -Version 'openscale 2.0.0' | Out-Null -$text = Get-Content -Path $sheet -Raw -foreach ($expected in @('MOT-DE-PASSE-TEST', 'a1b2c3d4', 'openscale', 'CODE DE SECOURS', 'doctor')) { - if ($text -notmatch [regex]::Escape($expected)) { throw "la fiche ne porte pas $expected" } -} -# Sans code connu, la ligne reste à remplir à la main : c'est un poste réinstallé, dont -# le fichier porte déjà une empreinte que personne ne peut relire. -if ($text -notmatch 'RECOPIER ICI') { throw 'la fiche sans code ne demande pas de le recopier' } - -# --- 8. Le code de secours de §14.4 est IMPRIMÉ quand l'installeur vient de le tirer - -Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -Fingerprint 'a1b2c3d4' -StationNumber '2' -Version 'openscale 2.0.0' -RecoveryCode 'K7M4Q2XR' | Out-Null -$text = Get-Content -Path $sheet -Raw -if ($text -notmatch 'K7M4Q2XR') { throw 'la fiche ne porte pas le code de secours tiré à l''installation' } -if ($text -match 'RECOPIER ICI') { throw 'la fiche demande de recopier un code qu''elle porte déjà' } -if ($text -notmatch 'seule copie') { throw 'la fiche ne dit pas qu''elle est la seule copie du code' } - -# --- 9. Un instantané écrit par une version ANTÉRIEURE se relit sans exploser ------ -# restore.json n'est jamais réécrit : celui d'un poste installé il y a six mois ne -# connaît pas les sections que l'installeur d'aujourd'hui y met. Sous -# « Set-StrictMode -Version Latest », lire une propriété absente ÉCHOUE — et ce serait -# la désinstallation, le geste qui doit toujours marcher, qui casserait. -$old = Read-Snapshot -Path $restore -if ($null -ne (Get-SnapshotValue (Get-SnapshotValue $old 'service_control') 'AutoStartDelay')) { - throw 'une section absente de l''instantane a rendu une valeur' -} -if ((Get-SnapshotValue $old.winlogon 'DefaultUserName') -ne 'ancien') { - throw 'Get-SnapshotValue perd une valeur presente' -} - -# --- 10. Le mot de passe du compte Windows : ce qui le renouvelle, et ce qui ne le -# renouvelle PAS. La règle vient du code de secours, trois étapes plus loin dans -# install.ps1 : « la fiche déjà rangée dans le classeur doit rester vraie ». Le mot de -# passe Windows la violait — un install.ps1 relancé, geste que TROUBLESHOOTING.md -# recommande, rendait fausses toutes les fiches classées. -$neuf = Resolve-AccountPassword -AccountExists $false -if (-not $neuf.Change) { throw 'un compte qui n''existe pas doit recevoir un mot de passe' } -if ($neuf.Password.Length -ne 20) { throw "mot de passe tiré de $($neuf.Password.Length) caractères" } -if ((Resolve-AccountPassword -AccountExists $false).Password -eq $neuf.Password) { - throw 'deux tirages rendent le même mot de passe' -} - -$garde = Resolve-AccountPassword -AccountExists $true -KnownPassword 'AncienMotDePasse' -if ($garde.Change) { throw 'une réinstallation renouvelle le mot de passe : les fiches classées deviennent fausses' } -if ($garde.Password -ne 'AncienMotDePasse') { throw 'le mot de passe conservé n''est pas celui du poste' } -if ($garde.Warning) { throw 'conserver le mot de passe n''est pas un incident' } - -$choisi = Resolve-AccountPassword -AccountExists $true -KnownPassword 'AncienMotDePasse' -Requested 'poire-balance-samedi' -if (-not $choisi.Change) { throw '-AccountPassword n''a pas été appliqué' } -if ($choisi.Password -ne 'poire-balance-samedi') { throw 'le mot de passe demandé n''a pas été retenu' } - -# Sans trace du mot de passe en place, il FAUT en poser un nouveau — mais en le disant : -# la fiche classée devient fausse, et un poste passé par « harden.ps1 -AutologonSecret » -# garde l'ancien dans les secrets LSA, donc son ouverture de session automatique cesse. -$perdu = Resolve-AccountPassword -AccountExists $true -if (-not $perdu.Change) { throw 'sans trace du mot de passe, il faut bien en poser un nouveau' } -if (-not $perdu.Warning) { throw 'un renouvellement silencieux casse la fiche classée sans le dire' } - -# Le plancher est LU, pas recopié : il vaut 4 parce qu'un compte sans droits sur un poste -# en libre-service doit s'ouvrir facilement, et ce banc doit rester vrai le jour où ce -# raisonnement change. Ce qui est vérifié, c'est qu'il y en a un et qu'il tient. -$plancher = $script:MinimumPasswordLength -$juste = 'a' * $plancher -if ((Resolve-AccountPassword -AccountExists $true -Requested $juste).Password -ne $juste) { - throw "un mot de passe de $plancher caractères, le plancher exactement, a été refusé" -} -try { Resolve-AccountPassword -AccountExists $true -Requested ('a' * ($plancher - 1)) | Out-Null; throw 'ECHEC ATTENDU' } -catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'un mot de passe plus court que le plancher a été accepté' } } -try { Resolve-AccountPassword -AccountExists $true -Requested ' ' | Out-Null; throw 'ECHEC ATTENDU' } -catch { if ($_.Exception.Message -eq 'ECHEC ATTENDU') { throw 'un mot de passe fait d''espaces a été accepté' } } - -# --- 11. La fiche dit si le mot de passe a changé --------------------------------- -# Un bénévole qui range la nouvelle fiche à côté de l'ancienne doit savoir laquelle -# ouvre la session. C'est la fiche qui le porte, pas le journal, qui reste sur le poste. -$sheet = Join-Path $work 'install-sheet.txt' -Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -PasswordChanged $false | Out-Null -$text = Get-Content -Path $sheet -Raw -if ($text -notmatch 'INCHANG') { throw 'la fiche ne dit pas que le mot de passe n''a pas changé' } -Write-InstallSheet -Path $sheet -Account 'openscale' -Password 'MOT-DE-PASSE-TEST' -PasswordChanged $true | Out-Null -$text = Get-Content -Path $sheet -Raw -if ($text -match 'INCHANG') { throw 'la fiche annonce inchangé un mot de passe qui vient d''être posé' } -if ($text -notmatch 'fiches? pr') { throw 'la fiche ne dit pas que les fiches précédentes sont périmées' } - -Write-Output 'TOUT-EST-VERIFIE' -` - writeScript(t, harness, body) - - output, err := runPowerShell(t, shell, harness) - if err != nil { - t.Fatalf("la sauvegarde ou la restauration a échoué sous %s :\n%s", shell, output) - } - if !strings.Contains(output, "TOUT-EST-VERIFIE") { - t.Fatalf("le banc ne s'est pas terminé :\n%s", output) - } - }) - } -} - -// TestEveryNativeCallOfTheInstallerIsGuarded is the sentence §15.2 puts in a comment box: -// « $ErrorActionPreference = 'Stop' DOES NOT CATCH a native executable ». -// -// icacls, schtasks, powercfg and the binary itself can fail silently and let the script run -// to completion while announcing a successful install. Every one of them must be followed -// by a check. -func TestEveryNativeCallOfTheInstallerIsGuarded(t *testing.T) { - installer := readFile(t, filepath.Join("windows", "install.ps1")) - // codeOnly keeps the line numbering, so the two views can be read side by side: the - // stripped one to find the calls, the original one to find the exemption comments. - original := strings.Split(installer, "\n") - lines := strings.Split(codeOnly(installer), "\n") - - natives := regexp.MustCompile(`^\s*(icacls|schtasks|powercfg|sc\.exe|& \$paths\.Binary)`) - guard := regexp.MustCompile(`Assert-Success|LASTEXITCODE`) - - for index, line := range lines { - if !natives.MatchString(line) { - continue - } - // An exemption is written AT THE CALL SITE, in French, and it is the only way out. - // `openscale doctor` returns non-zero when a control is red — that is its whole job - // — and aborting the installation on it would hide the diagnosis it was called to - // print. - if index < len(original) && strings.Contains(original[index], "non gardé") { - continue - } - // The guard is on one of the next few lines: some calls are followed by a - // continuation or by the assignment of their output. - guarded := false - for lookahead := index + 1; lookahead < len(lines) && lookahead <= index+4; lookahead++ { - if guard.MatchString(lines[lookahead]) { - guarded = true - break - } - } - if !guarded { - t.Errorf("install.ps1 ligne %d : appel natif sans contrôle d'erreur — "+ - "$ErrorActionPreference = 'Stop' NE LE RATTRAPE PAS (§15.2)\n %s", - index+1, strings.TrimSpace(line)) - } - } -} - -// TestTheInstallerDoesTheStepsOfSection15_2InOrder guards the one ordering mistake §15.2 -// marks with a star. -// -// The ACL of step 2 NAMES the account of step 1. In the reverse order icacls fails on a -// non-existent principal, the failure goes uncaught — it is a native executable — and the -// ACL described as mandatory is never applied. The station then starts once, cannot write -// its database, and the reason is three steps upstream. -func TestTheInstallerDoesTheStepsOfSection15_2InOrder(t *testing.T) { - // The COMMENTS are stripped first, and that is not a detail: the header of install.ps1 - // explains this very ordering trap, so the word « icacls » appears in prose long before - // the call. A test that read the explanation would forbid explaining. - installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) - positions := map[string]int{ - "sauvegarde": strings.Index(installer, "Get-SystemSettings"), - "compte": strings.Index(installer, "New-LocalUser"), - "acl": strings.Index(installer, "icacls"), - // ★ L'arrêt AVANT la copie, et c'est un ordre payé cher. Un poste déjà installé - // exécute son propre binaire — le service, et la tâche du kiosque — et chacun tient - // le fichier ouvert. Sans cet arrêt, Copy-Item échoue avec « le processus ne peut - // pas accéder au fichier », et l'installeur ne rate QUE les postes qui marchent : - // exactement ceux sur lesquels TROUBLESHOOTING.md et doctor demandent de le relancer. - // L'idempotence annoncée dans l'en-tête d'install.ps1 tient à cette ligne-ci. - "arrêt": strings.Index(installer, "Stop-OpenScaleBinaryHolders"), - "binaire": strings.Index(installer, "Copy-Item -Path $source"), - "session auto": strings.Index(installer, "AutoAdminLogon"), - "service": strings.Index(installer, "service install"), - "tâche": strings.Index(installer, "schtasks /create"), - "fiche": strings.Index(installer, "Write-InstallSheet"), - } - for name, position := range positions { - if position < 0 { - t.Fatalf("l'étape « %s » est absente de install.ps1", name) - } - } - order := []string{ - "sauvegarde", "compte", "acl", "arrêt", "binaire", "session auto", "service", - "tâche", "fiche"} - for i := 1; i < len(order); i++ { - if positions[order[i-1]] >= positions[order[i]] { - t.Fatalf("« %s » vient après « %s » dans install.ps1 : §15.2 fixe l'ordre inverse", - order[i-1], order[i]) - } - } -} - -// TestTheInstallerLeavesAWayIntoTheAdministration is the hole a whole install had. -// -// §11.5 ships a configuration WITHOUT secrets, so a station comes out of install.ps1 with -// no administration password: the login form answers 409, `PUT /admin/api/config` answers -// 401, and the expert pages are unreachable — on a station whose configuration is -// incomplete BY DESIGN and has to be finished from those very pages. §14.4 closes it with -// eight characters « générés à l'installation, imprimés sur la fiche », and this is where -// they are generated. -func TestTheInstallerLeavesAWayIntoTheAdministration(t *testing.T) { - installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) - for what, needle := range map[string]string{ - "le tirage du code de secours": "config recovery-code", - "son contrôle": "Assert-Success 'openscale config recovery-code'", - "sa remise à la fiche": "-RecoveryCode $recoveryCode", - "la lecture de l'empreinte en place": "recovery_code_hash", - } { - if !strings.Contains(installer, needle) { - t.Errorf("install.ps1 ne fait pas %s (« %s » absent)", what, needle) - } - } - // Le code en clair ne part JAMAIS dans install.log : ce journal reste sur le poste, - // la fiche part au classeur. - for _, line := range strings.Split(installer, "\n") { - if strings.Contains(line, "Write-Step") && strings.Contains(line, "$recoveryCode") { - t.Errorf("install.ps1 écrit le code de secours dans le journal : %s", - strings.TrimSpace(line)) - } - } -} - -// TestAReinstallLeavesTheSheetInTheBinderTrue guards the rule install.ps1 already applies -// to the recovery code, three steps further down, and used to break for the Windows -// password: « la fiche déjà rangée dans le classeur doit rester vraie ». -// -// The old line reset the account password on EVERY run. Relaunching install.ps1 is what -// TROUBLESHOOTING.md and `openscale doctor` recommend on a station whose automatic logon -// is gone — so the recommended gesture silently invalidated every sheet already filed, and -// the twenty random characters on them are the only way back into the Windows session. -func TestAReinstallLeavesTheSheetInTheBinderTrue(t *testing.T) { - installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) - for what, needle := range map[string]string{ - "la décision de renouveler ou non": "Resolve-AccountPassword", - "le mot de passe choisi par l'équipe": "$AccountPassword", - "la relecture du mot de passe en place": "Get-RegistryValue $script:WinlogonKey 'DefaultPassword'", - "le contrôle qu'il ouvre encore le compte": "Test-LocalCredential", - "la remise à la fiche de ce qui a changé": "-PasswordChanged", - } { - if !strings.Contains(installer, needle) { - t.Errorf("install.ps1 ne fait pas %s (« %s » absent)", what, needle) - } - } - // Set-LocalUser -Password reste possible — un poste dont personne ne connaît plus le - // mot de passe doit pouvoir en recevoir un —, mais JAMAIS inconditionnellement. - lines := strings.Split(installer, "\n") - for number, line := range lines { - if !strings.Contains(line, "Set-LocalUser") || !strings.Contains(line, "-Password") { - continue - } - guarded := false - for lookback := number; lookback >= 0 && lookback >= number-6; lookback-- { - if strings.Contains(lines[lookback], "Change") { - guarded = true - break - } - } - if !guarded { - t.Errorf("install.ps1 ligne %d : le mot de passe du compte est réécrit sans condition, "+ - "donc toute fiche déjà classée devient fausse\n %s", number+1, strings.TrimSpace(line)) - } - } - // Le plancher du mot de passe choisi est DÉCLARÉ, et il est délibérément plus bas que - // celui du mot de passe d'administration : le premier ouvre une session sans droits sur - // un poste en libre-service, le second donne le droit de changer le poste. Le banc - // PowerShell lit la constante et vérifie qu'elle tient ; ce qui est gardé ici, c'est - // qu'elle existe — sans elle, ce banc ne mesurerait rien. - common := readFile(t, filepath.Join("windows", "common.ps1")) - if !regexp.MustCompile(`\$script:MinimumPasswordLength = \d+`).MatchString(common) { - t.Fatal("common.ps1 ne déclare plus $script:MinimumPasswordLength : -AccountPassword " + - "n'aurait plus de plancher, et le banc PowerShell n'aurait plus rien à lire") - } -} - -// TestTheUpdaterVerifiesHealthzAndRestoresOnFailure reads update.ps1 for the four things -// §15.5 requires of it. -func TestTheUpdaterVerifiesHealthzAndRestoresOnFailure(t *testing.T) { - updater := readFile(t, filepath.Join("windows", "update.ps1")) - for what, needle := range map[string]string{ - // « service stop » ne suffit pas à nommer cette exigence, et c'est la correction : - // la tâche du kiosque exécute le MÊME binaire, donc arrêter le service seul laissait - // le fichier verrouillé. Le mot cherché est celui du geste complet. - "l'arrêt de TOUT ce qui tient le binaire": "Stop-OpenScaleBinaryHolders", - "la sauvegarde horodatée du binaire": "Backup-File", - "la vérification de /healthz": "Test-StationHealth", - "la restauration automatique": "Restore-File", - "la copie de base à remettre à la main": "openscale.db.before-", - } { - if !strings.Contains(updater, needle) { - t.Errorf("update.ps1 ne fait pas %s (« %s » absent)", what, needle) - } - } - if strings.Contains(codeOnly(updater), "readyz") { - t.Error("update.ps1 lit /readyz : une imprimante sans papier ferait restaurer la " + - "version précédente d'un poste parfaitement sain (§15.5)") - } - for _, script := range []string{ - filepath.Join("linux", "update.sh"), filepath.Join("linux", "install.sh"), - filepath.Join("windows", "common.ps1"), - } { - if strings.Contains(codeOnly(readFile(t, script)), "readyz") { - t.Errorf("%s lit /readyz", script) - } - } -} - -// TestTheUninstallerPutsBackWhatTheInstallerOverwrote is important-15: without it the -// switch is irreversible and going back to the Access application impossible. -func TestTheUninstallerPutsBackWhatTheInstallerOverwrote(t *testing.T) { - uninstaller := readFile(t, filepath.Join("windows", "uninstall.ps1")) - for what, needle := range map[string]string{ - "la lecture de restore.json": "Read-Snapshot", - "la restauration des réglages": "Restore-SystemSettings", - "la suppression de la tâche": "schtasks /delete", - "le retrait du service": "service uninstall", - } { - if !strings.Contains(uninstaller, needle) { - t.Errorf("uninstall.ps1 ne fait pas %s (« %s » absent)", what, needle) - } - } - // The data survive unless --purge, and the sentence that says so must be there: a - // volunteer who reads « données supprimées » on an uninstall that kept them would - // export a journal they still have. - if !strings.Contains(uninstaller, "Purge") || !strings.Contains(uninstaller, "CONSERVÉES") { - t.Error("uninstall.ps1 ne dit pas que les données sont conservées sans -Purge") - } - // Les stratégies de navigation vivent dans la ruche du COMPTE DU POSTE, que la - // désinstallation conserve par défaut. Les laisser derrière soi, c'est laisser un compte - // Windows dont le navigateur ne peut plus ouvrir qu'une adresse que plus rien ne sert - // (ADR-056). - for _, needle := range []string{`Software\Policies\Microsoft\Edge`, "HKEY_USERS"} { - if !strings.Contains(uninstaller, needle) { - t.Errorf("uninstall.ps1 ne retire pas les stratégies de navigation du kiosque "+ - "(« %s » absent) : le compte conservé garde un navigateur verrouillé", needle) - } - } -} - -// TestTheInstallersRefuseToRunWithoutAdministrator keeps a half-installed station from -// existing. -// -// A script that started writing and stopped in the middle leaves a station in a state -// nobody can describe: an account with no ACL, a service with no task, an automatic logon -// pointing at an account that cannot write its own database. -func TestTheInstallersRefuseToRunWithoutAdministrator(t *testing.T) { - for _, name := range []string{"install.ps1", "update.ps1", "uninstall.ps1", "harden.ps1"} { - script := readFile(t, filepath.Join("windows", name)) - if !strings.Contains(script, "Test-Administrator") { - t.Errorf("%s ne vérifie pas qu'il est lancé en administrateur", name) - } - } - for _, name := range []string{"install.sh", "update.sh", "uninstall.sh", "bootstrap.sh"} { - script := readFile(t, filepath.Join("linux", name)) - if !strings.Contains(script, "id -u") { - t.Errorf("%s ne vérifie pas qu'il est lancé en root", name) - } - } -} - -// TestNoShellScriptExitsOnATestThatIsSimplyFalse guards a trap `sh -n` cannot see, and -// that a Saturday-morning installation would find instead. -// -// Under `set -e`, a standalone `[ … ] && commande` whose TEST is false returns a non-zero -// status, and the shell exits. It reads like « fais ceci si », it behaves like « arrête-toi -// si ce n'est pas le cas ». It was really in install.sh: an optional file that is not -// shipped — flv_demo.csv — aborted the installation half-way, silently. `if … then … fi` -// says the same thing and cannot do that. -// -// `|| true` at the end of the line is the documented way out, because it makes the status -// of the whole list zero. -func TestNoShellScriptExitsOnATestThatIsSimplyFalse(t *testing.T) { - scripts, err := filepath.Glob(filepath.Join("linux", "*.sh")) - if err != nil || len(scripts) == 0 { - t.Fatalf("aucun script shell trouvé : %v", err) - } - andList := regexp.MustCompile(`^\s*(\[|command\s|test\s).*&&`) - - for _, script := range scripts { - source := readFile(t, script) - if !strings.Contains(source, "set -e") { - continue - } - for number, line := range strings.Split(codeOnly(source), "\n") { - trimmed := strings.TrimSpace(line) - if !andList.MatchString(trimmed) || strings.HasPrefix(trimmed, "if ") { - continue - } - if strings.HasSuffix(trimmed, "|| true") || strings.HasSuffix(trimmed, "|| :") { - continue - } - t.Errorf("%s ligne %d : sous « set -e », un ET dont le test est FAUX fait sortir "+ - "le script — écrivez « if … then … fi »\n %s", script, number+1, trimmed) - } - } -} - -// TestNoLinuxArtifactCarriesAWindowsLineEnding guards the most spectacular way this whole -// directory can fail, and it failed exactly that way once. -// -// A shell script written on Windows carries CRLF. On Debian, `./install.sh` then answers -// « bad interpreter: /bin/sh^M » — the carriage return is part of the interpreter's name. -// A udev rule with CRLF creates no symlink. And nothing about either failure points at the -// line endings. -// -// start.bat is the one file that keeps CRLF, and deliberately: it is read by cmd.exe. -func TestNoLinuxArtifactCarriesAWindowsLineEnding(t *testing.T) { - entries, err := filepath.Glob(filepath.Join("linux", "*")) - if err != nil || len(entries) == 0 { - t.Fatalf("aucun fichier dans deploy/linux : %v", err) - } - for _, path := range entries { - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("lecture de %s : %v", path, err) - } - if index := bytes.IndexByte(raw, '\r'); index >= 0 { - line := 1 + bytes.Count(raw[:index], []byte("\n")) - t.Errorf("%s ligne %d : retour chariot Windows. Un script en CRLF répond "+ - "« bad interpreter: /bin/sh^M » sur Debian, et rien dans ce message ne parle "+ - "de fins de ligne", path, line) - } - } - - // The Windows batch file is the mirror image: cmd.exe is the one interpreter that has - // ever cared about the difference in the other direction. - batch, err := os.ReadFile(filepath.Join("windows", "start.bat")) - if err != nil { - t.Fatalf("lecture de start.bat : %v", err) - } - if !bytes.Contains(batch, []byte("\r\n")) { - t.Error("start.bat est en LF : cmd.exe est le seul interpréteur du lot à préférer CRLF") - } -} - -// TestTheShellScriptsAreValidAccordingToTheShell runs `sh -n` when a shell is available. -func TestTheShellScriptsAreValidAccordingToTheShell(t *testing.T) { - shell, err := exec.LookPath("sh") - if err != nil { - t.Skip("aucun sh sur cette machine : les scripts Linux ne peuvent pas être analysés ici") - } - scripts, err := filepath.Glob(filepath.Join("linux", "*.sh")) - if err != nil || len(scripts) == 0 { - t.Fatalf("aucun script shell trouvé : %v", err) - } - for _, script := range scripts { - output, err := exec.Command(shell, "-n", script).CombinedOutput() - if err != nil { - t.Errorf("%s ne s'analyse pas : %v\n%s", script, err, output) - continue - } - t.Logf("%s : syntaxe correcte", script) - } -} - -// TestTheDeliveredArchiveHasEverythingSection17_2Lists is the packaging half: a volunteer -// copies one archive, and every file §17.2 names has to be in it. -// -// The archive is built by `make dist`, which this test does not run — building three -// targets takes a minute. What it checks is the SOURCE of each member: the file exists in -// the repository, or the Makefile knows how to produce it. -func TestTheDeliveredArchiveHasEverythingSection17_2Lists(t *testing.T) { - makefile := readFile(t, filepath.Join("..", "Makefile")) - for what, needle := range map[string]string{ - "les scripts et les unités de deploy/": "deploy/", - "la notice d'installation": "INSTALLATION.md", - "le guide de dépannage": "TROUBLESHOOTING.md", - "les empreintes des fichiers": "SHA256SUMS", - "la configuration livrée": "config-lacagette.json", - "la licence et les composants tiers": "THIRD-PARTY.md", - } { - if !strings.Contains(makefile, needle) { - t.Errorf("la cible release du Makefile n'emporte pas %s (« %s » absent), que §17.2 liste", - what, needle) - } - } - // The delivered configuration is PRODUCED by the binary and not copied: §17.2 says - // « SANS le bloc matériel », and a straight copy of the development file would ship - // the COM8 and the SATO WS408_2 of this machine — two values no station of the fleet - // may inherit, and which would break the fingerprint comparison of §15.5. - if !strings.Contains(makefile, "config export") { - t.Error("la cible release recopie config-lacagette.json au lieu de l'EXPORTER : " + - "l'archive emporterait le port série et la file d'impression du poste de développement") - } - for _, path := range []string{ - filepath.Join("windows", "install.ps1"), - filepath.Join("windows", "uninstall.ps1"), - filepath.Join("windows", "update.ps1"), - filepath.Join("windows", "harden.ps1"), - filepath.Join("windows", "openscale-kiosk.xml"), - filepath.Join("windows", "start.bat"), - filepath.Join("windows", "common.ps1"), - filepath.Join("linux", "openscale.service"), - filepath.Join("linux", "openscale-kiosk.service"), - filepath.Join("linux", "99-openscale.rules"), - filepath.Join("linux", "49-openscale-reboot.rules"), - filepath.Join("linux", "install.sh"), - filepath.Join("linux", "update.sh"), - filepath.Join("linux", "uninstall.sh"), - filepath.Join("linux", "bootstrap.sh"), - filepath.Join("..", "INSTALLATION.md"), - filepath.Join("..", "TROUBLESHOOTING.md"), - filepath.Join("..", "testdata", "config-lacagette.json"), - } { - if _, err := os.Stat(path); err != nil { - t.Errorf("%s manque au livrable : %v", path, err) - } - } -} - -// TestTheDocumentationIsWrittenForAVolunteer checks what can be checked about prose: that -// the two documents start from what somebody SEES. -// -// TROUBLESHOOTING.md has to be navigable by symptom — « l'écran est noir », « ça -// n'imprime plus », « les prix sont faux » — because a volunteer does not know the codes. -// The codes come after, as a way of confirming. -func TestTheDocumentationIsWrittenForAVolunteer(t *testing.T) { - troubleshooting := readFile(t, filepath.Join("..", "TROUBLESHOOTING.md")) - for _, symptom := range []string{ - "écran est noir", "n'imprime plus", "prix", "balance", "catalogue", - } { - if !strings.Contains(strings.ToLower(troubleshooting), strings.ToLower(symptom)) { - t.Errorf("TROUBLESHOOTING.md ne parle pas du symptôme « %s »", symptom) - } - } - // The first heading a reader meets must be a symptom, not a code: a document that - // opens on ERR-SCL-02 is a document written for whoever wrote the code. - firstHeading := "" - for _, line := range strings.Split(troubleshooting, "\n") { - if strings.HasPrefix(line, "## ") { - firstHeading = line - break - } - } - if regexp.MustCompile(`ERR-[A-Z]+-\d+`).MatchString(firstHeading) { - t.Errorf("le premier titre de TROUBLESHOOTING.md est un code (%q) : un bénévole voit un "+ - "symptôme, pas un code", firstHeading) - } - - installation := readFile(t, filepath.Join("..", "INSTALLATION.md")) - for _, step := range []string{ - "install.ps1", "redémarr", "empreinte", "15 minutes", "SmartScreen", - } { - if !strings.Contains(strings.ToLower(installation), strings.ToLower(step)) { - t.Errorf("INSTALLATION.md ne parle pas de « %s »", step) - } - } -} - -// TestTheFifteenMinutesAreCountedAndNotClaimed keeps the promise of §15.5 measurable. -// -// « Un bénévole installe un poste seul en 15 minutes » is the criterion of L8. A document -// that asserted it without counting would be a document that discovers on site that it -// takes forty. INSTALLATION.md therefore carries a table of steps with a duration each, -// and the sum has to be stated. -func TestTheFifteenMinutesAreCountedAndNotClaimed(t *testing.T) { - installation := readFile(t, filepath.Join("..", "INSTALLATION.md")) - minutes := regexp.MustCompile(`(?m)^\|.*?\|\s*(\d+)\s*(?:min|minutes?)\b`).FindAllStringSubmatch(installation, -1) - if len(minutes) < 5 { - t.Fatalf("INSTALLATION.md ne chiffre que %d étapes : les 15 minutes seraient une "+ - "affirmation, pas un compte", len(minutes)) - } - total := 0 - for _, match := range minutes { - value, err := strconv.Atoi(match[1]) - if err != nil { - t.Fatalf("durée illisible %q", match[1]) - } - total += value - } - if !strings.Contains(installation, fmt.Sprintf("%d minutes", total)) && - !strings.Contains(installation, fmt.Sprintf("%d min", total)) { - t.Errorf("les étapes totalisent %d minutes, et ce total n'est écrit nulle part dans "+ - "INSTALLATION.md : le lecteur ne peut pas vérifier la promesse", total) - } - t.Logf("les étapes chiffrées de INSTALLATION.md totalisent %d minutes", total) -} - -// --- update.ps1 comme CONTRAT, et non plus comme script qu'on lit ------------------- - -// TestTheUpdaterTakesEveryParameterTheStationPasses freezes the contract between -// internal/platform and the script. -// -// A parameter renamed on one side and not the other is a swap that never starts, -// and nothing else in this repository would catch it: the station hands these six -// on a command line, and PowerShell binds what it recognises and ignores the rest. -func TestTheUpdaterTakesEveryParameterTheStationPasses(t *testing.T) { - updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) - for _, parameter := range []string{ - "$Source", "$InstallDir", "$DataRoot", "$OutcomePath", "$LogPath", - } { - if !strings.Contains(updater, parameter) { - t.Errorf("update.ps1 ne déclare pas le paramètre %s", parameter) - } - } -} - -// TestTheUpdaterReportsOnAllFourOfItsExits is what lets the screen tell « failed, -// rolled back, the station works » from « failed, the station is dead ». The two -// do not ask the same thing of a volunteer: the first calls nobody. -func TestTheUpdaterReportsOnAllFourOfItsExits(t *testing.T) { - updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) - for _, code := range []string{"exit 10", "exit 11", "exit 12"} { - if !strings.Contains(updater, code) { - t.Errorf("update.ps1 ne sort jamais par « %s »", code) - } - } - for _, status := range []string{ - "succeeded", "rolled-back", "rolled-back-unhealthy", "not-started", - } { - if !strings.Contains(updater, status) { - t.Errorf("update.ps1 n'écrit jamais le statut %q", status) - } - } - if !strings.Contains(updater, "function Write-Outcome") { - t.Error("update.ps1 n'a pas de fonction unique d'écriture du compte rendu : " + - "quatre écritures dispersées, c'est trois occasions d'en oublier une") - } -} - -// TestTheOutcomeCarriesEveryFieldTheStationReads freezes the JSON keys against -// update.Outcome. The station reads this file at its NEXT START, when the process -// that could have read an exit code has been dead for a minute. -func TestTheOutcomeCarriesEveryFieldTheStationReads(t *testing.T) { - updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) - for _, key := range []string{ - "status", "exit_code", "from", "to", "reason", "backup", - "database_backups", "finished_at", - } { - if !strings.Contains(updater, key) { - t.Errorf("le compte rendu ne porte pas la clé %q", key) - } - } -} - -// TestTheUpdaterBringsTheClientScreenBack is the defect this work uncovered. -// -// Stop-OpenScaleBinaryHolders ends the kiosk task, openscale-kiosk.xml carries a -// LogonTrigger AND NOTHING ELSE, and nobody restarted it: neither install.ps1 nor -// update.ps1. The client screen stayed black until somebody logged on. -// -// It never showed because a human who updates a station ends up rebooting it. A -// volunteer who touches a button on the administration screen does not -- they -// look at the client screen within the minute. -func TestTheUpdaterBringsTheClientScreenBack(t *testing.T) { - common := codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))) - if !strings.Contains(common, "function Start-OpenScaleKiosk") { - t.Fatal("common.ps1 ne porte pas la relance de l'écran client") - } - if !strings.Contains(common, "schtasks /run") { - t.Error("la relance n'appelle pas schtasks /run") - } - - updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) - if !strings.Contains(updater, "Start-OpenScaleKiosk") { - t.Fatal("update.ps1 ne relance jamais l'écran client") - } - // The installer stops the kiosk too, and for the same reason -- it replaces the - // binary the task is running. Re-running install.ps1 on a working station is - // what TROUBLESHOOTING.md recommends, so it must not leave a black screen. - if !strings.Contains(codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))), - "Start-OpenScaleKiosk") { - t.Error("install.ps1 ne relance pas l'écran client qu'il vient d'arrêter") - } -} - -// TestTheClientScreenComesBackOnTheFailurePathsToo. -// -// A rollback that leaves the client screen black is a breakdown created by the -// repair: the station serves again, the customer sees nothing, and the volunteer -// concludes the update destroyed the poste. -func TestTheClientScreenComesBackOnTheFailurePathsToo(t *testing.T) { - updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) - restarts := strings.Count(updater, "Start-OpenScaleKiosk") - // Four exits: succeeded, rolled-back, rolled-back-unhealthy, not-started. The - // last two not-started paths share one call, hence three at least. - if restarts < 3 { - t.Fatalf("%d relance(s) de l'écran client dans update.ps1 : les chemins d'échec "+ - "n'en ont pas", restarts) - } - failure := updater[strings.Index(updater, "if ($failure)"):] - if !strings.Contains(failure, "Start-OpenScaleKiosk") { - t.Error("le chemin d'échec ne relance pas l'écran client") - } -} - -// TestUpdateScriptsMigrateTheConfigurationAfterTheHealthCheck: both scripts roll the -// previous binary back when the station does not answer, and a previous binary reading an -// already-migrated file would lose what the migration carried. So the call comes AFTER the -// rollback verdict, never before -- and, more precisely than "after the health check" alone, -// after the rollback BLOCK itself: a call placed right past the check but still inside the -// block that can restore the previous binary would run before that block has finished -// deciding whether to restore anything. -func TestUpdateScriptsMigrateTheConfigurationAfterTheHealthCheck(t *testing.T) { - for _, c := range []struct{ path, health, rollback string }{ - // The rollback marker is the line each script reaches only once it has restored the - // previous binary: `rolled-back` for PowerShell, the restoring `install` for shell. - {filepath.Join("windows", "update.ps1"), "Test-StationHealth", "Write-Outcome -Status 'rolled-back'"}, - {filepath.Join("linux", "update.sh"), "healthy", `install -m 0755 "$BACKUP" "$BINARY"`}, - } { - t.Run(c.path, func(t *testing.T) { - // codeOnly, so a comment naming "config migrate" to explain the placement is not - // mistaken for the call it explains. - body := codeOnly(readFile(t, c.path)) - migrate := strings.Index(body, "config migrate") - if migrate < 0 { - t.Fatalf("%s n'appelle pas « config migrate »", c.path) - } - health := strings.Index(body, c.health) - if health < 0 || migrate < health { - t.Error("« config migrate » vient avant le contrôle de santé : " + - "un retour arrière relirait un fichier déjà migré") - } - rollback := strings.Index(body, c.rollback) - if rollback < 0 { - t.Fatalf("%s : le repère du bloc de retour arrière est introuvable, ce test ne "+ - "prouve plus rien", c.path) - } - if migrate < rollback { - t.Error("« config migrate » vient avant la fin du bloc de retour arrière : " + - "un binaire restauré relirait un fichier déjà migré") - } - }) - } -} diff --git a/deploy/harness_test.go b/deploy/harness_test.go new file mode 100644 index 0000000..9993aa4 --- /dev/null +++ b/deploy/harness_test.go @@ -0,0 +1,227 @@ +package deploy + +import ( + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// What every test file of this package reads with: the artefact readers — a unit path, a +// file rendered as text, the PROSE stripped out of a script, a systemd directive — and the +// PowerShell runner that writes a throwaway script and then runs it for real. +// +// codeOnly carries a test of its own: the shipped scripts comment at length on what they +// forbid themselves to do, and a reader that took that header for code would turn a whole +// suite red on scripts that work. + +// unitPath is one shipped unit. +func unitPath(name string) string { return filepath.Join("linux", name) } + +// byteOrderMark is what every shipped .ps1 starts with, and it is NOT optional. +// +// Windows PowerShell 5.1 reads a UTF-8 file with no BOM as ANSI, which turns every +// accented character of these scripts into mojibake — « compilé » becomes « compil├® » in +// the installation log. The marker is therefore part of the delivery, and it is the +// READER here that has to know it: without this, the first line is "<#" instead of +// "<#", codeOnly never enters block-comment mode, and the prose of every .SYNOPSIS is +// searched as if it were code. +const byteOrderMark = "\ufeff" + +// readFile reads a shipped file, and fails the test rather than the installer. +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + return strings.TrimPrefix(string(raw), byteOrderMark) +} + +// codeOnly strips the comments out of a script or a unit file. +// +// It exists because the naive search is worse than no search: every file here EXPLAINS in +// a comment what it must not do — « jamais /readyz », « dans l'ordre inverse, icacls +// échoue » — and a test that read those comments as code would forbid the very sentences +// that keep the next reader from reintroducing the bug. +// +// `#` covers both shells, systemd units and PowerShell line comments; `<# … #>` is the +// PowerShell block comment, which is where the .SYNOPSIS of each script lives. +func codeOnly(script string) string { + var out strings.Builder + inBlock := false + for _, line := range strings.Split(script, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case inBlock: + if strings.Contains(trimmed, "#>") { + inBlock = false + } + out.WriteString("\n") + continue + case strings.HasPrefix(trimmed, "<#"): + inBlock = !strings.Contains(trimmed, "#>") + out.WriteString("\n") + continue + case strings.HasPrefix(trimmed, "#"): + out.WriteString("\n") + continue + } + // A trailing comment on a line of code: keep the code, drop the comment. The `#` + // of a PowerShell string would be lost too, and no line here has one. + if index := strings.Index(line, " #"); index >= 0 { + line = line[:index] + } + out.WriteString(line) + out.WriteString("\n") + } + return out.String() +} + +// TestTheProseOfAScriptIsNeverReadAsCode is the regression of the byte order mark. +// +// The two tests it protects — the order of the steps of install.ps1 and the « jamais +// /readyz » of update.ps1 — both search for a word every script names in its own header in +// order to explain why it must NOT do it. Reading that header as code makes them fail on +// the shipped files, which is a red suite that accuses working scripts. +func TestTheProseOfAScriptIsNeverReadAsCode(t *testing.T) { + // Every shipped .ps1 carries the marker, and a run that no longer found one would mean + // somebody « fixed » the encoding and broke the accents of a whole parc. + for _, name := range []string{"install.ps1", "update.ps1", "common.ps1", "harden.ps1"} { + raw, err := os.ReadFile(filepath.Join("windows", name)) + if err != nil { + t.Fatalf("lecture de %s : %v", name, err) + } + if !strings.HasPrefix(string(raw), byteOrderMark) { + t.Errorf("%s ne commence plus par la marque UTF-8 : PowerShell 5.1 lira ses "+ + "accents en ANSI", name) + } + } + + // And the reader hands the text over WITHOUT it, which is what puts « <# » back at the + // start of the first line where codeOnly looks for it. + for _, name := range []string{"install.ps1", "update.ps1"} { + text := readFile(t, filepath.Join("windows", name)) + if strings.HasPrefix(text, byteOrderMark) { + t.Errorf("%s est lu avec sa marque UTF-8 : le bloc d'en-tête sera lu comme du code", name) + } + if strings.Contains(codeOnly(text), ".SYNOPSIS") { + t.Errorf("le bloc d'en-tête de %s survit à codeOnly", name) + } + } +} + +// directive reads one systemd directive out of a unit file. +// +// It reads the LAST occurrence, which is what systemd does for most directives: a unit +// that set Restart= twice would be judged here on the line that does not apply. +func directive(unit, name string) (string, bool) { + value, found := "", false + for _, line := range strings.Split(unit, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || !strings.HasPrefix(line, name+"=") { + continue + } + value, found = strings.TrimSpace(strings.TrimPrefix(line, name+"=")), true + } + return value, found +} + +// powershellPath finds a PowerShell able to dot-source common.ps1. +// powershellPaths returns EVERY PowerShell installed on this machine, and not the first one. +// +// The plural is the whole point, and it is what v0.1 cost. The singular version tried +// « pwsh » first and CI runs on ubuntu-latest, so these scripts had never once been read by +// the shell they are written for — common.ps1 says so in its own header: « le poste sur +// lequel ces scripts tournent n'a que 5.1 ». Running under every shell present costs one +// subtest on Linux, where only pwsh exists, and finds on Windows what no Linux runner can. +func powershellPaths(t *testing.T) []string { + t.Helper() + var found []string + for _, candidate := range []string{"pwsh", "powershell"} { + if path, err := exec.LookPath(candidate); err == nil { + found = append(found, path) + } + } + if len(found) == 0 { + t.Skip("ni pwsh ni powershell : les scripts ne peuvent pas être analysés sur cette machine") + } + return found +} + +// runPowerShell runs a script and returns its output. +func runPowerShell(t *testing.T, shell, script string) (string, error) { + t.Helper() + command := exec.Command(shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", script) + output, err := command.CombinedOutput() + return string(output), err +} + +// writeScript writes a test harness the way a .ps1 has to be written: with the mark. +// +// The harnesses below carry French, and a test bench that broke on its own accents would +// accuse the script it is exercising. It obeys the rule it enforces — see +// TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds. +func writeScript(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, append(append([]byte{}, utf8Mark...), body...), 0o644); err != nil { + t.Fatalf("écriture du banc %s : %v", path, err) + } +} + +// utf8Mark is the byte order mark, EF BB BF. +var utf8Mark = []byte{0xEF, 0xBB, 0xBF} + +// powerShellScripts lists every PowerShell script of the repository. +// +// The whole repository is walked rather than deploy/windows: make.ps1 lives at the root and +// carries the same traps as the installers. +func powerShellScripts(t *testing.T) []string { + t.Helper() + var scripts []string + walk := func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + // dist and bin are git-ignored: what they hold is build OUTPUT, not source. A + // `make release` left in place puts a copy of the scripts there, and a test + // would accuse a file that is not in the repository. .claude holds git + // worktrees, which are whole checkouts of OTHER branches: a test walking them + // reports twice, and blames this tree for what another one carries. + switch entry.Name() { + case ".git", ".claude", "node_modules", "dist", "bin": + return fs.SkipDir + } + return nil + } + switch strings.ToLower(filepath.Ext(path)) { + case ".ps1", ".psm1": + scripts = append(scripts, path) + } + return nil + } + if err := filepath.WalkDir("..", walk); err != nil { + t.Fatalf("parcours du dépôt : %v", err) + } + if len(scripts) == 0 { + t.Fatal("aucun script PowerShell trouvé dans le dépôt : ce test ne prouve plus rien") + } + return scripts +} + +// quoteForPowerShell renders a list of paths as a PowerShell array literal. +func quoteForPowerShell(paths []string) string { + quoted := make([]string, 0, len(paths)) + for _, path := range paths { + absolute, err := filepath.Abs(path) + if err != nil { + absolute = path + } + quoted = append(quoted, "'"+strings.ReplaceAll(absolute, "'", "''")+"'") + } + return strings.Join(quoted, ", ") +} diff --git a/deploy/installer_test.go b/deploy/installer_test.go new file mode 100644 index 0000000..fab1a25 --- /dev/null +++ b/deploy/installer_test.go @@ -0,0 +1,435 @@ +package deploy + +import ( + "path/filepath" + "regexp" + "strings" + "testing" + + "openscale/internal/platform" +) + +// The installers read as PROCEDURES: the subcommands they call and the binary must carry, +// the order of the steps of §15.2, the door they leave open towards the administration, +// and what the uninstall puts back. Their failure mode is silent — the task registers, the +// service starts, and nothing runs — which is exactly why they are checked here rather +// than on a station. + +// --- What the scripts and the binary have to agree on ------------------------------- + +// TestTheScriptsCallOnlySubcommandsThisBinaryHas is the drift guard that matters most, +// because its failure mode is silent: `schtasks` records the task, the service registers, +// and nothing runs. +func TestTheScriptsCallOnlySubcommandsThisBinaryHas(t *testing.T) { + dispatched := subcommandsOfTheBinary(t) + // What the shipped scripts invoke, read out of the scripts themselves. + invoked := map[string][]string{ + filepath.Join("windows", "install.ps1"): {"service", "config", "doctor"}, + filepath.Join("windows", "update.ps1"): {"service"}, + filepath.Join("windows", "uninstall.ps1"): {"service"}, + filepath.Join("windows", "start.bat"): {"serve", "kiosk"}, + filepath.Join("linux", "install.sh"): {"config", "doctor"}, + filepath.Join("linux", "update.sh"): {"config"}, + } + for path, subcommands := range invoked { + script := readFile(t, path) + for _, subcommand := range subcommands { + if !dispatched[subcommand] { + t.Errorf("%s appelle « openscale %s », que main.go ne connaît pas", path, subcommand) + } + if !strings.Contains(script, subcommand) { + t.Errorf("%s ne contient plus « %s » : ce test a cessé de vérifier quoi que ce soit", path, subcommand) + } + } + } + // The task XML and the kiosk unit launch one subcommand each, and both are named in + // files the tests above already read. + if !dispatched["kiosk"] { + t.Error("la sous-commande kiosk n'existe pas : ni la tâche planifiée ni l'unité du kiosque ne lanceraient quoi que ce soit") + } +} + +// subcommandsOfTheBinary reads what main.go dispatches, so that renaming a subcommand +// breaks this test instead of an installation. +func subcommandsOfTheBinary(t *testing.T) map[string]bool { + t.Helper() + source := readFile(t, filepath.Join("..", "cmd", "openscale", "main.go")) + found := make(map[string]bool) + for _, match := range regexp.MustCompile(`case "([a-z-]+)"`).FindAllStringSubmatch(source, -1) { + found[match[1]] = true + } + if len(found) < 5 { + t.Fatalf("seulement %d sous-commandes trouvées dans main.go : la lecture est fausse", len(found)) + } + return found +} + +// TestTheScriptsAndTheBinaryAgreeOnTheNames keeps one station from being called two +// things. +func TestTheScriptsAndTheBinaryAgreeOnTheNames(t *testing.T) { + common := readFile(t, filepath.Join("windows", "common.ps1")) + if !strings.Contains(common, "$script:ServiceName = '"+platform.ServiceName+"'") { + t.Errorf("common.ps1 ne nomme pas le service %q : sc.exe et le binaire ne parleraient pas "+ + "du même service", platform.ServiceName) + } + // The Windows data root the Go code spells, and the one the installer creates. + root := filepath.Dir(platform.DefaultDataDir()) + if goos := platform.DefaultConfigPath(); strings.Contains(goos, `\`) { + if !strings.Contains(common, filepath.Base(root)) { + t.Errorf("common.ps1 ne crée pas %s, où le binaire lit sa configuration", root) + } + } + // The Linux units, against the same authority. + unit := readFile(t, unitPath("openscale.service")) + if !strings.Contains(unit, "/usr/local/bin/openscale") { + t.Error("l'unité ne lance pas /usr/local/bin/openscale, que install.sh installe") + } +} + +// TestAPilotStationIsToldHowToStart is the regression of a station installed on 01/08/2026. +// +// The closing screen of the installer promised EVERYBODY a station that « revient SEUL » +// after a reboot, and asked for that reboot as the compulsory acceptance. A pilot station +// does no such thing, by construction: its service is installed with --start demand, which +// is exactly what leaves the Access application relaunchable in two minutes. The operator +// was left in front of a station that was installed, correct, and that nothing anywhere +// said how to switch on — neither the installer, nor INSTALLATION.md, nor TROUBLESHOOTING. +// +// So the promise belongs to the branch that keeps it, and the other branch owes the four +// gestures of a pilot station instead. +func TestAPilotStationIsToldHowToStart(t *testing.T) { + // codeOnly and not the raw text: the comment that guards this branch QUOTES the promise + // it exists to forbid, and a test reading it would accuse the very sentence that keeps + // the next reader from putting the defect back. + script := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + + // From the closing banner and not from the top of the file: `$startMode = if ($Pilot)` + // decides the service start mode a hundred lines earlier, and a search that stopped + // there would read the whole installer as if it were the message. + banner := strings.Index(script, "IL RESTE TROIS CHOSES") + if banner < 0 { + t.Fatal("install.ps1 n'affiche plus son message de fin : ce test ne prouve plus rien") + } + branch := strings.Index(script[banner:], "if ($Pilot) {") + if branch < 0 { + t.Fatal("install.ps1 ne distingue plus le mode pilote dans son message de fin") + } + branch += banner + end := strings.Index(script[branch:], "\nelse {") + if end < 0 { + t.Fatal("la branche pilote du message de fin n'a pas d'autre branche") + } + pilot := script[branch : branch+end] + + // The gestures a pilot station lives on. `service start` is the one whose absence was + // the whole defect; `stop` is what gives the machine back to Access, and without it the + // pilot mode has no way out. + for what, needle := range map[string]string{ + "le démarrage du service": "service start", + "l'arrêt du service": "service stop", + "l'ouverture de l'écran": "kiosk", + "les raccourcis du Bureau": "Bureau", + "le chemin complet du binaire": "$($paths.Binary)", + } { + if !strings.Contains(pilot, needle) { + t.Errorf("le message de fin d'un poste PILOTE ne dit pas %s (« %s » absent)", + what, needle) + } + } + + // And the promise stays where it holds. The needle is the promise as the production + // branch words it, not the word « seul »: the pilot branch has to be free to say that + // the station does NOT come back on its own, and that the client screen recovers by + // itself once the service is up — two true sentences that a blunter rule would forbid. + if strings.Contains(pilot, "revient SEUL") { + t.Error("le message de fin promet à un poste PILOTE qu'il revient SEUL : son service " + + "est en démarrage « demand », il ne reviendra pas") + } +} + +// TestThePilotShortcutsLeaveWhenTheyStopMeaningAnything. +// +// Two buttons on the public desktop are two promises. One that survives a reinstallation in +// production would switch off a station nobody must switch off; one that survives the +// uninstaller would launch a binary that has just been deleted. Set-PilotShortcuts is +// therefore called in BOTH modes of the installer — the removal is what the false branch +// does — and once more by the uninstaller. +func TestThePilotShortcutsLeaveWhenTheyStopMeaningAnything(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + call := regexp.MustCompile(`Set-PilotShortcuts\s+-Pilot\s+\(\[bool\]\$Pilot\)`) + if !call.MatchString(installer) { + t.Error("install.ps1 n'appelle pas Set-PilotShortcuts avec les DEUX modes : réinstaller " + + "en production un poste qui était en pilote y laisserait ses deux raccourcis") + } + + remover := codeOnly(readFile(t, filepath.Join("windows", "uninstall.ps1"))) + if !strings.Contains(remover, "Set-PilotShortcuts -Pilot $false") { + t.Error("uninstall.ps1 ne retire pas les raccourcis du Bureau : ils lanceraient un " + + "binaire supprimé") + } + + // The elevation flag is a byte of the file, and nothing else sets it: WScript.Shell has + // no property for it. Losing this line turns « Démarrer le poste » into an access + // denied in front of a volunteer. + common := codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))) + if !strings.Contains(common, "-bor 0x20") { + t.Error("common.ps1 ne pose plus le drapeau d'élévation de l'octet 0x15 : les deux " + + "raccourcis répondront « accès refusé »") + } +} + +// TestEveryNativeCallOfTheInstallerIsGuarded is the sentence §15.2 puts in a comment box: +// « $ErrorActionPreference = 'Stop' DOES NOT CATCH a native executable ». +// +// icacls, schtasks, powercfg and the binary itself can fail silently and let the script run +// to completion while announcing a successful install. Every one of them must be followed +// by a check. +func TestEveryNativeCallOfTheInstallerIsGuarded(t *testing.T) { + installer := readFile(t, filepath.Join("windows", "install.ps1")) + // codeOnly keeps the line numbering, so the two views can be read side by side: the + // stripped one to find the calls, the original one to find the exemption comments. + original := strings.Split(installer, "\n") + lines := strings.Split(codeOnly(installer), "\n") + + natives := regexp.MustCompile(`^\s*(icacls|schtasks|powercfg|sc\.exe|& \$paths\.Binary)`) + guard := regexp.MustCompile(`Assert-Success|LASTEXITCODE`) + + for index, line := range lines { + if !natives.MatchString(line) { + continue + } + // An exemption is written AT THE CALL SITE, in French, and it is the only way out. + // `openscale doctor` returns non-zero when a control is red — that is its whole job + // — and aborting the installation on it would hide the diagnosis it was called to + // print. + if index < len(original) && strings.Contains(original[index], "non gardé") { + continue + } + // The guard is on one of the next few lines: some calls are followed by a + // continuation or by the assignment of their output. + guarded := false + for lookahead := index + 1; lookahead < len(lines) && lookahead <= index+4; lookahead++ { + if guard.MatchString(lines[lookahead]) { + guarded = true + break + } + } + if !guarded { + t.Errorf("install.ps1 ligne %d : appel natif sans contrôle d'erreur — "+ + "$ErrorActionPreference = 'Stop' NE LE RATTRAPE PAS (§15.2)\n %s", + index+1, strings.TrimSpace(line)) + } + } +} + +// TestTheInstallerDoesTheStepsOfSection15_2InOrder guards the one ordering mistake §15.2 +// marks with a star. +// +// The ACL of step 2 NAMES the account of step 1. In the reverse order icacls fails on a +// non-existent principal, the failure goes uncaught — it is a native executable — and the +// ACL described as mandatory is never applied. The station then starts once, cannot write +// its database, and the reason is three steps upstream. +func TestTheInstallerDoesTheStepsOfSection15_2InOrder(t *testing.T) { + // The COMMENTS are stripped first, and that is not a detail: the header of install.ps1 + // explains this very ordering trap, so the word « icacls » appears in prose long before + // the call. A test that read the explanation would forbid explaining. + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + positions := map[string]int{ + "sauvegarde": strings.Index(installer, "Get-SystemSettings"), + "compte": strings.Index(installer, "New-LocalUser"), + "acl": strings.Index(installer, "icacls"), + // ★ L'arrêt AVANT la copie, et c'est un ordre payé cher. Un poste déjà installé + // exécute son propre binaire — le service, et la tâche du kiosque — et chacun tient + // le fichier ouvert. Sans cet arrêt, Copy-Item échoue avec « le processus ne peut + // pas accéder au fichier », et l'installeur ne rate QUE les postes qui marchent : + // exactement ceux sur lesquels TROUBLESHOOTING.md et doctor demandent de le relancer. + // L'idempotence annoncée dans l'en-tête d'install.ps1 tient à cette ligne-ci. + "arrêt": strings.Index(installer, "Stop-OpenScaleBinaryHolders"), + "binaire": strings.Index(installer, "Copy-Item -Path $source"), + "session auto": strings.Index(installer, "AutoAdminLogon"), + "service": strings.Index(installer, "service install"), + "tâche": strings.Index(installer, "schtasks /create"), + "fiche": strings.Index(installer, "Write-InstallSheet"), + } + for name, position := range positions { + if position < 0 { + t.Fatalf("l'étape « %s » est absente de install.ps1", name) + } + } + order := []string{ + "sauvegarde", "compte", "acl", "arrêt", "binaire", "session auto", "service", + "tâche", "fiche"} + for i := 1; i < len(order); i++ { + if positions[order[i-1]] >= positions[order[i]] { + t.Fatalf("« %s » vient après « %s » dans install.ps1 : §15.2 fixe l'ordre inverse", + order[i-1], order[i]) + } + } +} + +// TestTheInstallerLeavesAWayIntoTheAdministration is the hole a whole install had. +// +// §11.5 ships a configuration WITHOUT secrets, so a station comes out of install.ps1 with +// no administration password: the login form answers 409, `PUT /admin/api/config` answers +// 401, and the expert pages are unreachable — on a station whose configuration is +// incomplete BY DESIGN and has to be finished from those very pages. §14.4 closes it with +// eight characters « générés à l'installation, imprimés sur la fiche », and this is where +// they are generated. +func TestTheInstallerLeavesAWayIntoTheAdministration(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + for what, needle := range map[string]string{ + "le tirage du code de secours": "config recovery-code", + "son contrôle": "Assert-Success 'openscale config recovery-code'", + "sa remise à la fiche": "-RecoveryCode $recoveryCode", + "la lecture de l'empreinte en place": "recovery_code_hash", + } { + if !strings.Contains(installer, needle) { + t.Errorf("install.ps1 ne fait pas %s (« %s » absent)", what, needle) + } + } + // Le code en clair ne part JAMAIS dans install.log : ce journal reste sur le poste, + // la fiche part au classeur. + for _, line := range strings.Split(installer, "\n") { + if strings.Contains(line, "Write-Step") && strings.Contains(line, "$recoveryCode") { + t.Errorf("install.ps1 écrit le code de secours dans le journal : %s", + strings.TrimSpace(line)) + } + } +} + +// TestAReinstallLeavesTheSheetInTheBinderTrue guards the rule install.ps1 already applies +// to the recovery code, three steps further down, and used to break for the Windows +// password: « la fiche déjà rangée dans le classeur doit rester vraie ». +// +// The old line reset the account password on EVERY run. Relaunching install.ps1 is what +// TROUBLESHOOTING.md and `openscale doctor` recommend on a station whose automatic logon +// is gone — so the recommended gesture silently invalidated every sheet already filed, and +// the twenty random characters on them are the only way back into the Windows session. +func TestAReinstallLeavesTheSheetInTheBinderTrue(t *testing.T) { + installer := codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))) + for what, needle := range map[string]string{ + "la décision de renouveler ou non": "Resolve-AccountPassword", + "le mot de passe choisi par l'équipe": "$AccountPassword", + "la relecture du mot de passe en place": "Get-RegistryValue $script:WinlogonKey 'DefaultPassword'", + "le contrôle qu'il ouvre encore le compte": "Test-LocalCredential", + "la remise à la fiche de ce qui a changé": "-PasswordChanged", + } { + if !strings.Contains(installer, needle) { + t.Errorf("install.ps1 ne fait pas %s (« %s » absent)", what, needle) + } + } + // Set-LocalUser -Password reste possible — un poste dont personne ne connaît plus le + // mot de passe doit pouvoir en recevoir un —, mais JAMAIS inconditionnellement. + lines := strings.Split(installer, "\n") + for number, line := range lines { + if !strings.Contains(line, "Set-LocalUser") || !strings.Contains(line, "-Password") { + continue + } + guarded := false + for lookback := number; lookback >= 0 && lookback >= number-6; lookback-- { + if strings.Contains(lines[lookback], "Change") { + guarded = true + break + } + } + if !guarded { + t.Errorf("install.ps1 ligne %d : le mot de passe du compte est réécrit sans condition, "+ + "donc toute fiche déjà classée devient fausse\n %s", number+1, strings.TrimSpace(line)) + } + } + // Le plancher du mot de passe choisi est DÉCLARÉ, et il est délibérément plus bas que + // celui du mot de passe d'administration : le premier ouvre une session sans droits sur + // un poste en libre-service, le second donne le droit de changer le poste. Le banc + // PowerShell lit la constante et vérifie qu'elle tient ; ce qui est gardé ici, c'est + // qu'elle existe — sans elle, ce banc ne mesurerait rien. + common := readFile(t, filepath.Join("windows", "common.ps1")) + if !regexp.MustCompile(`\$script:MinimumPasswordLength = \d+`).MatchString(common) { + t.Fatal("common.ps1 ne déclare plus $script:MinimumPasswordLength : -AccountPassword " + + "n'aurait plus de plancher, et le banc PowerShell n'aurait plus rien à lire") + } +} + +// TestTheUpdaterVerifiesHealthzAndRestoresOnFailure reads update.ps1 for the four things +// §15.5 requires of it. +func TestTheUpdaterVerifiesHealthzAndRestoresOnFailure(t *testing.T) { + updater := readFile(t, filepath.Join("windows", "update.ps1")) + for what, needle := range map[string]string{ + // « service stop » ne suffit pas à nommer cette exigence, et c'est la correction : + // la tâche du kiosque exécute le MÊME binaire, donc arrêter le service seul laissait + // le fichier verrouillé. Le mot cherché est celui du geste complet. + "l'arrêt de TOUT ce qui tient le binaire": "Stop-OpenScaleBinaryHolders", + "la sauvegarde horodatée du binaire": "Backup-File", + "la vérification de /healthz": "Test-StationHealth", + "la restauration automatique": "Restore-File", + "la copie de base à remettre à la main": "openscale.db.before-", + } { + if !strings.Contains(updater, needle) { + t.Errorf("update.ps1 ne fait pas %s (« %s » absent)", what, needle) + } + } + if strings.Contains(codeOnly(updater), "readyz") { + t.Error("update.ps1 lit /readyz : une imprimante sans papier ferait restaurer la " + + "version précédente d'un poste parfaitement sain (§15.5)") + } + for _, script := range []string{ + filepath.Join("linux", "update.sh"), filepath.Join("linux", "install.sh"), + filepath.Join("windows", "common.ps1"), + } { + if strings.Contains(codeOnly(readFile(t, script)), "readyz") { + t.Errorf("%s lit /readyz", script) + } + } +} + +// TestTheUninstallerPutsBackWhatTheInstallerOverwrote is important-15: without it the +// switch is irreversible and going back to the Access application impossible. +func TestTheUninstallerPutsBackWhatTheInstallerOverwrote(t *testing.T) { + uninstaller := readFile(t, filepath.Join("windows", "uninstall.ps1")) + for what, needle := range map[string]string{ + "la lecture de restore.json": "Read-Snapshot", + "la restauration des réglages": "Restore-SystemSettings", + "la suppression de la tâche": "schtasks /delete", + "le retrait du service": "service uninstall", + } { + if !strings.Contains(uninstaller, needle) { + t.Errorf("uninstall.ps1 ne fait pas %s (« %s » absent)", what, needle) + } + } + // The data survive unless --purge, and the sentence that says so must be there: a + // volunteer who reads « données supprimées » on an uninstall that kept them would + // export a journal they still have. + if !strings.Contains(uninstaller, "Purge") || !strings.Contains(uninstaller, "CONSERVÉES") { + t.Error("uninstall.ps1 ne dit pas que les données sont conservées sans -Purge") + } + // Les stratégies de navigation vivent dans la ruche du COMPTE DU POSTE, que la + // désinstallation conserve par défaut. Les laisser derrière soi, c'est laisser un compte + // Windows dont le navigateur ne peut plus ouvrir qu'une adresse que plus rien ne sert + // (ADR-056). + for _, needle := range []string{`Software\Policies\Microsoft\Edge`, "HKEY_USERS"} { + if !strings.Contains(uninstaller, needle) { + t.Errorf("uninstall.ps1 ne retire pas les stratégies de navigation du kiosque "+ + "(« %s » absent) : le compte conservé garde un navigateur verrouillé", needle) + } + } +} + +// TestTheInstallersRefuseToRunWithoutAdministrator keeps a half-installed station from +// existing. +// +// A script that started writing and stopped in the middle leaves a station in a state +// nobody can describe: an account with no ACL, a service with no task, an automatic logon +// pointing at an account that cannot write its own database. +func TestTheInstallersRefuseToRunWithoutAdministrator(t *testing.T) { + for _, name := range []string{"install.ps1", "update.ps1", "uninstall.ps1", "harden.ps1"} { + script := readFile(t, filepath.Join("windows", name)) + if !strings.Contains(script, "Test-Administrator") { + t.Errorf("%s ne vérifie pas qu'il est lancé en administrateur", name) + } + } + for _, name := range []string{"install.sh", "update.sh", "uninstall.sh", "bootstrap.sh"} { + script := readFile(t, filepath.Join("linux", name)) + if !strings.Contains(script, "id -u") { + t.Errorf("%s ne vérifie pas qu'il est lancé en root", name) + } + } +} diff --git a/deploy/linux_test.go b/deploy/linux_test.go new file mode 100644 index 0000000..1a9a63e --- /dev/null +++ b/deploy/linux_test.go @@ -0,0 +1,285 @@ +package deploy + +import ( + "bytes" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "openscale/internal/station" +) + +// The Linux artefacts of §15.3: the two systemd units, the polkit rule that lets the +// station reboot the machine, and the udev rule. Each is judged on what systemd, polkit and +// udev really make of it — never on what the file looks like it says. + +// --- The systemd units -------------------------------------------------------------- + +// environmentComplaints are the systemd-analyze lines that describe the MACHINE and not +// the unit: an ExecStart pointing at a binary this machine has not installed. +// +// It is the only complaint this test forgives, and the reason is that forgiving nothing +// would make the test runnable only on a station where OpenScale is ALREADY installed — +// where it would prove nothing new. Every other complaint still fails. +var environmentComplaints = regexp.MustCompile(`(?m)^.*(is not executable|does not exist).*$\r?\n?`) + +// TestTheUnitIsValidAccordingToSystemdItself runs systemd-analyze when the machine has +// one, which is the only authority on whether systemd will accept a unit. +// +// It skips on Windows, where there is no systemd to ask. The tests below cover what +// matters even there, because a unit is also a document read by whoever debugs the +// station at 8 a.m. +func TestTheUnitIsValidAccordingToSystemdItself(t *testing.T) { + analyze, err := exec.LookPath("systemd-analyze") + if err != nil { + t.Skip("systemd-analyze absent : les directives sont vérifiées par le test suivant") + } + output, err := exec.Command(analyze, "verify", + unitPath("openscale.service"), unitPath("openscale-kiosk.service")).CombinedOutput() + // systemd-analyze checks that ExecStart points at something EXECUTABLE, which on a + // CI runner it never is: nothing is installed there. That complaint is about the + // MACHINE, not about the unit, and failing on it would mean this test can only run + // on a station that is already installed — where it proves nothing new. Every other + // complaint is real and still fails. + if err != nil { + remaining := environmentComplaints.ReplaceAll(output, nil) + if len(bytes.TrimSpace(remaining)) > 0 { + t.Fatalf("systemd-analyze verify refuse les unités : %v\n%s", err, remaining) + } + t.Logf("systemd-analyze ne trouve pas les binaires, ce qui est normal ici :\n%s", output) + return + } + if len(output) > 0 { + t.Logf("systemd-analyze verify a des remarques :\n%s", output) + } +} + +// TestTheStopTimeoutFollowsTheMeasuredShutdownBudget is the §13.4 fix, guarded where it +// can actually drift. +// +// The bug is worth restating: TimeoutStopSec was written 20 s against a shutdown whose +// real budget was 20 s, systemd sent a SIGKILL at the very instant the shutdown was +// finishing, and update.ps1 failed intermittently on a station where nothing was wrong. +// The unit therefore may not carry a round number somebody liked — it has to be at least +// 1.5 × the sum of the budgets the code actually spends, and RAISING one of those budgets +// in Go has to turn this test red. +func TestTheStopTimeoutFollowsTheMeasuredShutdownBudget(t *testing.T) { + unit := readFile(t, unitPath("openscale.service")) + value, found := directive(unit, "TimeoutStopSec") + if !found { + t.Fatal("openscale.service ne déclare pas TimeoutStopSec : systemd appliquerait son défaut de 90 s") + } + seconds, err := strconv.Atoi(strings.TrimSuffix(value, "s")) + if err != nil { + t.Fatalf("TimeoutStopSec=%q illisible : %v", value, err) + } + + internal := station.ShutdownBudget() + required := internal * 3 / 2 + if got := time.Duration(seconds) * time.Second; got < required { + t.Fatalf("TimeoutStopSec=%s, or l'arrêt peut dépenser %s de budgets bornés et §13.4 "+ + "demande au moins 1,5 × (%s) : systemd enverrait un SIGKILL au moment où l'arrêt "+ + "s'achève, et update.ps1 échouerait par intermittence sur un poste sain", + got, internal, required) + } +} + +// TestTheWatchdogIsFedByHealthzAndNothingElse guards the most important rule of §15.3. +// +// A watchdog fed by the health of a device restarts a station when a roll of labels runs +// out, and that station loses a customer's weighing to go and fetch one. The unit asks for +// a watchdog; what feeds it is the liveness of the Hub loop, through /healthz, and the word +// readyz must not appear anywhere in either unit. +func TestTheWatchdogIsFedByHealthzAndNothingElse(t *testing.T) { + unit := readFile(t, unitPath("openscale.service")) + if _, found := directive(unit, "WatchdogSec"); !found { + t.Fatal("openscale.service ne déclare aucun WatchdogSec : une boucle du Hub bloquée ne serait jamais reprise") + } + if kind, _ := directive(unit, "Type"); kind != "notify" { + t.Fatalf("Type=%q : un chien de garde exige Type=notify, sinon systemd n'attend aucun message", kind) + } + if access, _ := directive(unit, "NotifyAccess"); access != "main" { + t.Fatalf("NotifyAccess=%q, attendu main", access) + } + for _, name := range []string{"openscale.service", "openscale-kiosk.service"} { + if strings.Contains(codeOnly(readFile(t, unitPath(name))), "readyz") { + t.Errorf("%s lit /readyz : rien d'automatique ne doit le lire (§14.5, §15.3)", name) + } + } +} + +// TestTheUnitStartsOnAStationWithNoMountPoint is the simplification §15.3 insists on. +// +// Under ProtectSystem=strict a ReadWritePaths= pointing at a path that does not exist makes +// the unit FAIL to start, and a RequiresMountsFor= naming an absent mount adds a dependency +// nothing satisfies. Either one contradicts guiding principle 7 — « le poste démarre +// toujours » — for a deployment mode the document does not even ship. +func TestTheUnitStartsOnAStationWithNoMountPoint(t *testing.T) { + unit := readFile(t, unitPath("openscale.service")) + if _, found := directive(unit, "RequiresMountsFor"); found { + t.Fatal("RequiresMountsFor= dans l'unité : personne n'écrit ça pour lire un fichier de 10 ko (§15.3)") + } + writable, found := directive(unit, "ReadWritePaths") + if !found { + t.Fatal("ProtectSystem=strict sans ReadWritePaths : le poste ne pourrait pas écrire sa base") + } + if strings.Contains(writable, "/srv/") { + t.Fatalf("ReadWritePaths=%q nomme un point de montage : sous ProtectSystem=strict, "+ + "un chemin inexistant fait ÉCHOUER le démarrage", writable) + } + // The three directories the station really writes into, and the two the Go code + // spells for this platform. + for _, required := range []string{"/var/lib/openscale", "/etc/openscale"} { + if !strings.Contains(writable, required) { + t.Errorf("ReadWritePaths=%q n'autorise pas %s", writable, required) + } + } + if strings.Contains(unit, "ProtectSystem=strict") && !strings.Contains(writable, "/var/log") { + t.Errorf("ReadWritePaths=%q n'autorise aucun répertoire de journal", writable) + } +} + +// TestTheUnitsAgreeWithTheGoCodeOnEveryPath keeps the two halves of one station from +// pointing at two different directories. +// +// internal/platform/paths.go is the ONLY place in the Go code allowed to spell a default +// path (§11.1). A unit that named another one would give the service a configuration file +// the administration screen does not write to, and nobody would see it until a volunteer +// changed a setting that had no effect. +func TestTheUnitsAgreeWithTheGoCodeOnEveryPath(t *testing.T) { + unit := readFile(t, unitPath("openscale.service")) + writable, _ := directive(unit, "ReadWritePaths") + + // The Go defaults, read on the platform the unit is for. Windows paths would be a + // meaningless comparison, so what is compared is the Linux constants the code + // carries, which is what these tests can see from here. + for _, path := range []string{"/etc/openscale", "/var/lib/openscale"} { + if !strings.Contains(writable, path) { + t.Errorf("l'unité n'autorise pas %s, que le code Go nomme comme emplacement par défaut", path) + } + } + start, found := directive(unit, "ExecStart") + if !found || !strings.HasSuffix(start, " serve") { + t.Fatalf("ExecStart=%q : l'unité doit lancer la sous-commande serve et rien d'autre", start) + } + if user, _ := directive(unit, "User"); user == "root" { + t.Error("User=root : le poste tourne sous un compte sans privilèges") + } +} + +// TestTheKioskUnitIsWantedByAThingThatExists is the trap §15.3 names outright: +// systemd.special(7) discourages graphical-session.target in a WantedBy=, it is only ever +// activated by a session manager, and on a minimal station the unit would never start. +func TestTheKioskUnitIsWantedByAThingThatExists(t *testing.T) { + unit := readFile(t, unitPath("openscale-kiosk.service")) + wanted, found := directive(unit, "WantedBy") + if !found { + t.Fatal("l'unité du kiosque n'a pas de WantedBy : « systemctl enable » n'aurait rien à activer") + } + if strings.Contains(wanted, "graphical-session.target") { + t.Fatal("WantedBy=graphical-session.target : sur un poste minimal, l'unité ne démarrerait JAMAIS (§15.3)") + } + if wanted != "multi-user.target" { + t.Fatalf("WantedBy=%q, attendu multi-user.target", wanted) + } + if start, _ := directive(unit, "ExecStart"); !strings.Contains(start, "cage") || + !strings.HasSuffix(start, "openscale kiosk") { + t.Fatalf("ExecStart=%q : le kiosque est « cage -d -- openscale kiosk » (§15.3)", start) + } + if pam, _ := directive(unit, "PAMName"); pam != "login" { + t.Fatalf("PAMName=%q, attendu login : sans vraie session, cage ne trouve ni clavier ni GPU", pam) + } + if tty, _ := directive(unit, "TTYPath"); tty != "/dev/tty1" { + t.Fatalf("TTYPath=%q, attendu /dev/tty1", tty) + } + // A kiosk that REQUIRED the service would leave a black screen on a station whose + // configuration is broken — exactly the station somebody needs to reach the + // administration screen of (§11.3). + if _, found := directive(unit, "Requires"); found { + t.Error("Requires= sur le service : un poste dont le service refuse de démarrer doit " + + "quand même afficher quelque chose (principe directeur 7)") + } +} + +// TestTheRebootRuleGrantsOneActionToOneAccount. +// +// This file is a privilege, shipped in an installer and applied by root on a machine +// nobody audits afterwards. What bounds it is written here rather than left to a review +// that will not happen: one action, one account, and NOT the power-off — a station +// switched off from the screen does not switch itself back on, and nothing offers it. +func TestTheRebootRuleGrantsOneActionToOneAccount(t *testing.T) { + rule := readFile(t, filepath.Join("linux", "49-openscale-reboot.rules")) + + if !strings.Contains(rule, "org.freedesktop.login1.reboot") { + t.Fatal("la règle n'accorde pas le redémarrage : le bouton sera refusé sur tout poste Linux") + } + if !strings.Contains(rule, "subject.user === 'openscale'") { + t.Error("la règle ne se limite pas au compte du service") + } + for _, forbidden := range []string{"power-off", "ignore-inhibit", "multiple-sessions"} { + // The comments name these three to say they are EXCLUDED, so only the code is + // searched: a test reading the whole file would fail on its own documentation. + if strings.Contains(rulesCode(rule), forbidden) { + t.Errorf("la règle accorde aussi %q, qui n'a jamais été demandé", forbidden) + } + } +} + +// rulesCode strips the comments of a polkit rule, so that what it SAYS is not read as +// what it does. +func rulesCode(rule string) string { + var code strings.Builder + for _, line := range strings.Split(rule, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + code.WriteString(line) + code.WriteString("\n") + } + return code.String() +} + +// TestInstallPosesTheRebootRule: without it the button answers « accès refusé » on a +// station where everything else works, which is the failure nobody would diagnose. +func TestInstallPosesTheRebootRule(t *testing.T) { + script := readFile(t, filepath.Join("linux", "install.sh")) + if !strings.Contains(script, "49-openscale-reboot.rules") { + t.Fatal("install.sh ne pose pas la règle polkit") + } + if !strings.Contains(script, "/etc/polkit-1/rules.d") { + t.Error("install.sh ne nomme pas le répertoire où polkit lit ses règles") + } + removal := readFile(t, filepath.Join("linux", "uninstall.sh")) + if !strings.Contains(removal, "49-openscale-reboot.rules") { + t.Error("uninstall.sh laisse la règle polkit derrière lui : un privilège survit au poste") + } +} + +// TestTheUdevRuleDoesNotInventAVendorIdentifier holds the line §15.3 draws: « les +// idVendor sont relevés par lsusb sur site — on ne les invente pas ». +// +// A rule carrying a made-up identifier creates no symlink and sends somebody looking for +// an hour. The printer rule is therefore shipped COMMENTED, with the procedure to fill it +// in, and the placeholder must not be live. +func TestTheUdevRuleDoesNotInventAVendorIdentifier(t *testing.T) { + rules := readFile(t, filepath.Join("linux", "99-openscale.rules")) + for number, line := range strings.Split(rules, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, "XXXX") { + t.Fatalf("ligne %d : une règle udev active porte un identifiant inventé (XXXX)", number+1) + } + } + if !strings.Contains(rules, `SYMLINK+="openscale-serial`) { + t.Error("aucun symlink stable pour le port série : /dev/ttyUSB0 devient ttyUSB1 après un rebranchement") + } + if !strings.Contains(rules, "lsusb") { + t.Error("la règle de l'imprimante ne dit pas comment relever ses identifiants") + } +} diff --git a/deploy/powershell_test.go b/deploy/powershell_test.go new file mode 100644 index 0000000..ad60d15 --- /dev/null +++ b/deploy/powershell_test.go @@ -0,0 +1,277 @@ +package deploy + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// PowerShell taken as a LANGUAGE and not as prose: a dot-sourced constant that lands on a +// parameter of its caller, a constant silently reassigned, the byte-order mark PowerShell +// 5.1 needs in order to read an accent, and the fact that every script parses. These are +// the faults that only show at run time, on the station, on a Saturday morning. + +// TestNoDotSourcedConstantLandsOnAParameterOfItsCaller is the second half of the same trap, +// and the one no single file shows. +// +// A dot-source runs the sourced file IN THE CALLER'S SCOPE, and the parameters of a script +// live in that script's scope. common.ps1 sets `$script:InstallDir` and `$script:DataRoot`; +// bootstrap.ps1 declares `-InstallDir` and `-DataRoot`. Loading the first therefore +// REPLACED what the operator had asked with the factory locations — measured on a bench: +// `-InstallDir D:\OpenScale` comes back out as `C:\Program Files\OpenScale`, and the three +// branches that choose the paths always take the first. Nothing warned, and the station was +// installed somewhere else than where it had been asked to go. +// +// Renaming a parameter is not an option: -InstallDir and -DataRoot are the public names of +// two options, and TestTheInstallerDeclaresEveryParameterTheBootstrapPasses holds bootstrap +// and installer in step. What is asked is therefore put out of reach BEFORE the dot-source, +// under a name common.ps1 does not know — and what this test checks is exactly that: past +// the dot-source, the parameter is EMPTIED, so reading it is the defect. A rule about where +// the value is read survives a rename; one about how it is saved would not. +func TestNoDotSourcedConstantLandsOnAParameterOfItsCaller(t *testing.T) { + shared := map[string]bool{} + constant := regexp.MustCompile(`^\$script:(\w+)\s*=[^=]`) + for _, line := range strings.Split(codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))), "\n") { + if match := constant.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + shared[strings.ToLower(match[1])] = true + } + } + if len(shared) == 0 { + t.Fatal("common.ps1 ne pose plus aucune variable de script : ce test ne prouve plus rien") + } + + // `\$nom` cannot match inside `$requestedNom` nor behind the `-Nom` of a call: the + // dollar sign has to touch the name. + readers := map[string]*regexp.Regexp{} + for name := range shared { + readers[name] = regexp.MustCompile(`(?i)\$` + regexp.QuoteMeta(name) + `\b`) + } + + for _, script := range []string{"bootstrap.ps1", "install.ps1", "update.ps1", "uninstall.ps1", "harden.ps1"} { + lines := strings.Split(codeOnly(readFile(t, filepath.Join("windows", script))), "\n") + source := -1 + for number, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), ". (") && strings.Contains(line, "common.ps1") { + source = number + } + } + if source < 0 { + t.Errorf("%s ne charge plus common.ps1 : ce test ne prouve plus rien pour lui", script) + continue + } + + for number, line := range lines[source+1:] { + for name, reader := range readers { + if reader.MatchString(line) && !strings.Contains(strings.ToLower(line), "$script:"+name) { + t.Errorf("%s, ligne %d : $%s est lu APRÈS le point-source de common.ps1, "+ + "qui vient de l'écraser avec la valeur d'usine — ce que l'opérateur a "+ + "demandé se met à l'abri avant.\n %s", + script, source+number+2, name, strings.TrimSpace(line)) + } + } + } + } +} + +// TestNoScriptConstantIsSilentlyReassigned is the regression of v1.1, and it is a trap +// PowerShell lays rather than a typo somebody made. +// +// Variable names are case-INSENSITIVE, and an unqualified assignment written at the top +// level of a script writes into the SCRIPT scope. `$checksumAsset = $release.assets | …` +// was therefore not a new variable at all: it overwrote `$script:ChecksumAsset`, the +// constant holding the NAME of that asset. Three lines later, `Join-Path $workspace +// $script:ChecksumAsset` built a path out of a stringified object — +// « …\Temp\openscale-v1.1\@{url=https:\\api.github.com\…; id=497915905; …} » — and +// Invoke-WebRequest answered « Le format du chemin d'accès donné n'est pas pris en charge », +// naming neither the variable nor the line that emptied it. No station could get past the +// fingerprint check, and nothing in deploy/ saw it: these tests read the scripts, they do +// not run them against an API. +// +// The rule is one a reader can hold in their head: a constant of the header is written +// ONCE. No script here has any reason to reassign one, so a second assignment — whatever +// its case, whatever its scope prefix — is the defect and not a style. +func TestNoScriptConstantIsSilentlyReassigned(t *testing.T) { + // Both patterns are anchored on the START of the statement, and that is what separates + // an assignment from a parameter default: `param([string]$DataRoot = $script:DataRoot)` + // declares a LOCAL and shadows nothing, whereas the defect is a line that opens on the + // variable it is about to empty. + declaration := regexp.MustCompile(`^\$script:(\w+)\s*=[^=]`) + + for _, script := range powerShellScripts(t) { + lines := strings.Split(codeOnly(readFile(t, script)), "\n") + + // `\$nom` cannot match inside `$script:nom`: what follows the dollar sign there is + // « script: ». Case-insensitive, because PowerShell is — that is the whole trap. + clobbers := map[string]*regexp.Regexp{} + declared := map[string]int{} + for number, line := range lines { + for _, match := range declaration.FindAllStringSubmatch(strings.TrimSpace(line), -1) { + name := strings.ToLower(match[1]) + declared[name] = number + 1 + clobbers[name] = regexp.MustCompile(`(?i)^\$` + regexp.QuoteMeta(name) + `\s*=[^=]`) + } + } + + for number, line := range lines { + for name, clobber := range clobbers { + if clobber.MatchString(strings.TrimSpace(line)) { + t.Errorf("%s, ligne %d : cette affectation écrase $script:%s, la constante "+ + "déclarée ligne %d — les noms de variables PowerShell sont insensibles "+ + "à la casse, et à la racine d'un script une affectation non qualifiée "+ + "écrit dans la portée du script.\n %s", + script, number+1, name, declared[name], strings.TrimSpace(line)) + } + } + } + } +} + +// TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds is the encoding contract, +// and it exists because v0.1 shipped without it. +// +// Windows PowerShell 5.1 — the ONLY PowerShell on a station, and the one a right-click +// « Exécuter avec PowerShell » starts — decodes a .ps1 with no mark as ANSI. PowerShell 7 +// assumes UTF-8. The two therefore read every accent in these scripts differently, and one +// sequence is fatal rather than merely ugly: « — » is E2 80 94 in UTF-8, which CP1252 reads +// as « — » whose last character is U+201D, a closing DOUBLE QUOTE for the PowerShell +// parser. The string literal ends on the dash, the rest of the line becomes code, and the +// installer stops parsing. That is what v0.1 did on the machine it was written for: five +// scripts, thirteen parse errors, and not one line executed. +// +// The rule is « all of them » and not « those with an accent », because a script that is +// ASCII today gets its first French message tomorrow, and whoever writes that message will +// not be thinking about byte order marks. +// +// The whole repository is walked rather than deploy/windows: make.ps1 lives at the root and +// carries the same trap. +func TestEveryPowerShellScriptCarriesTheMarkWindowsPowerShellNeeds(t *testing.T) { + for _, script := range powerShellScripts(t) { + // bootstrap.ps1 est la seule exception, et c'est la règle INVERSE plutôt qu'une + // absence de règle : c'est le seul .ps1 que personne ne lit sur un disque — « irm … + // | iex » le donne au parseur comme un FLUX, où la marque se colle au « <# » de son + // en-tête et fait lire tout le fichier comme du code. Il ne porte donc pas la + // marque, et pas d'accent dans son code non plus, ce qui est exactement ce qui rend + // une relecture en CP1252 sans effet. Les deux moitiés sont tenues par + // TestTheBootstrapIsReadAsAStreamSoItCarriesNeitherMarkNorAccent. + if filepath.Base(script) == bootstrapPath { + continue + } + raw, err := os.ReadFile(script) + if err != nil { + t.Errorf("lecture de %s : %v", script, err) + continue + } + if bytes.HasPrefix(raw, utf8Mark) { + continue + } + t.Errorf("%s n'a pas de marque d'ordre des octets (EF BB BF) : Windows PowerShell 5.1 "+ + "le lira en ANSI.\n%s", script, whatFiveOneWillRead(raw)) + } +} + +// whatFiveOneWillRead shows the first line a mark-less file would lose, as CP1252 reads it. +// +// The failure message names the damage instead of citing three magic bytes: whoever adds a +// script sees the line that will break and why, not a constant to silence. +func whatFiveOneWillRead(raw []byte) string { + for number, line := range bytes.Split(raw, []byte("\n")) { + decoded, differs := decodeCP1252(line) + if !differs { + continue + } + return fmt.Sprintf(" ligne %d, écrite en UTF-8 : %s\n"+ + " ligne %d, telle que 5.1 la lira : %s", + number+1, strings.TrimRight(string(line), "\r"), + number+1, strings.TrimRight(decoded, "\r")) + } + return " (ce fichier est en ASCII pur, donc il survivrait aujourd'hui — la règle vaut " + + "pour tous, parce qu'il ne le restera pas)" +} + +// cp1252Extras is the 0x80–0x9F range of CP1252, the only place it differs from Latin-1. +// +// COPIED from the Windows code page table, never derived. Five positions are unassigned +// (0x81, 0x8D, 0x8F, 0x90, 0x9D) and Windows maps them to the control character of the same +// value; they are written out here so the table has thirty-two entries and no hole to +// misread. 0x94 is U+201D, the one that ends a string literal, and 0x97 is the em dash it +// comes from. +var cp1252Extras = [32]rune{ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, + 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +} + +// decodeCP1252 reads bytes the way Windows PowerShell 5.1 reads a script with no mark, and +// says whether that reading differs from the UTF-8 one. +func decodeCP1252(raw []byte) (string, bool) { + var text strings.Builder + differs := false + for _, b := range raw { + switch { + case b < 0x80: + text.WriteByte(b) + case b < 0xA0: + text.WriteRune(cp1252Extras[b-0x80]) + differs = true + default: + text.WriteRune(rune(b)) + differs = true + } + } + return text.String(), differs +} + +// TestEveryPowerShellScriptParses is the syntactic check: a script with a typo in it is a +// station half-installed, and the typo is found by whoever runs it as administrator on a +// Saturday morning. +// +// It uses the PowerShell parser itself rather than a heuristic, and it checks the four +// scripts plus the shared file — under EVERY PowerShell installed, because the encoding +// defect above is invisible to PowerShell 7 and fatal to 5.1. +func TestEveryPowerShellScriptParses(t *testing.T) { + scripts, err := filepath.Glob(filepath.Join("windows", "*.ps1")) + if err != nil || len(scripts) == 0 { + t.Fatalf("aucun script PowerShell trouvé : %v", err) + } + + body := `$ErrorActionPreference = 'Stop' +$failed = 0 +foreach ($path in $args) { } +$scripts = @(` + quoteForPowerShell(scripts) + `) +foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $script), [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = 1 + Write-Output "FAUTE $script" + foreach ($e in $errors) { Write-Output (" ligne {0} : {1}" -f $e.Extent.StartLineNumber, $e.Message) } + } else { + Write-Output "OK $script" + } +} +exit $failed +` + for _, shell := range powershellPaths(t) { + t.Run(filepath.Base(shell), func(t *testing.T) { + harness := filepath.Join(t.TempDir(), "parse.ps1") + writeScript(t, harness, body) + output, err := runPowerShell(t, shell, harness) + if err != nil { + t.Fatalf("un script PowerShell ne s'analyse pas sous %s :\n%s", shell, output) + } + for _, script := range scripts { + if !strings.Contains(output, "OK "+script) && !strings.Contains(output, filepath.Base(script)) { + t.Errorf("%s n'a pas été analysé :\n%s", script, output) + } + } + t.Logf("%s", strings.TrimSpace(output)) + }) + } +} diff --git a/deploy/release_workflow_test.go b/deploy/release_workflow_test.go index 19e68ef..421e545 100644 --- a/deploy/release_workflow_test.go +++ b/deploy/release_workflow_test.go @@ -28,7 +28,7 @@ const releaseWorkflow = "../.github/workflows/release.yml" // ICI » — so a naive search finds the word in the explanation and passes even after the // directive itself has been deleted. That is exactly what happened while writing this // file: removing fetch-depth: 0 turned nothing red, because the comment mentioning it was -// still there. The sibling tests of deploy_test.go had already met the trap. +// still there. The other script tests of this package had already met the trap. func readWorkflow(t *testing.T) string { t.Helper() raw, err := os.ReadFile(filepath.Clean(releaseWorkflow)) diff --git a/deploy/shell_test.go b/deploy/shell_test.go new file mode 100644 index 0000000..6c8170b --- /dev/null +++ b/deploy/shell_test.go @@ -0,0 +1,111 @@ +package deploy + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The shell scripts: none may exit on a test that is simply FALSE, no Linux artefact may +// carry a Windows line ending, and every one of them must be valid to the shell itself. The +// three failures look alike from a distance — the script stops without a word — and are +// repaired differently. + +// TestNoShellScriptExitsOnATestThatIsSimplyFalse guards a trap `sh -n` cannot see, and +// that a Saturday-morning installation would find instead. +// +// Under `set -e`, a standalone `[ … ] && commande` whose TEST is false returns a non-zero +// status, and the shell exits. It reads like « fais ceci si », it behaves like « arrête-toi +// si ce n'est pas le cas ». It was really in install.sh: an optional file that is not +// shipped — flv_demo.csv — aborted the installation half-way, silently. `if … then … fi` +// says the same thing and cannot do that. +// +// `|| true` at the end of the line is the documented way out, because it makes the status +// of the whole list zero. +func TestNoShellScriptExitsOnATestThatIsSimplyFalse(t *testing.T) { + scripts, err := filepath.Glob(filepath.Join("linux", "*.sh")) + if err != nil || len(scripts) == 0 { + t.Fatalf("aucun script shell trouvé : %v", err) + } + andList := regexp.MustCompile(`^\s*(\[|command\s|test\s).*&&`) + + for _, script := range scripts { + source := readFile(t, script) + if !strings.Contains(source, "set -e") { + continue + } + for number, line := range strings.Split(codeOnly(source), "\n") { + trimmed := strings.TrimSpace(line) + if !andList.MatchString(trimmed) || strings.HasPrefix(trimmed, "if ") { + continue + } + if strings.HasSuffix(trimmed, "|| true") || strings.HasSuffix(trimmed, "|| :") { + continue + } + t.Errorf("%s ligne %d : sous « set -e », un ET dont le test est FAUX fait sortir "+ + "le script — écrivez « if … then … fi »\n %s", script, number+1, trimmed) + } + } +} + +// TestNoLinuxArtifactCarriesAWindowsLineEnding guards the most spectacular way this whole +// directory can fail, and it failed exactly that way once. +// +// A shell script written on Windows carries CRLF. On Debian, `./install.sh` then answers +// « bad interpreter: /bin/sh^M » — the carriage return is part of the interpreter's name. +// A udev rule with CRLF creates no symlink. And nothing about either failure points at the +// line endings. +// +// start.bat is the one file that keeps CRLF, and deliberately: it is read by cmd.exe. +func TestNoLinuxArtifactCarriesAWindowsLineEnding(t *testing.T) { + entries, err := filepath.Glob(filepath.Join("linux", "*")) + if err != nil || len(entries) == 0 { + t.Fatalf("aucun fichier dans deploy/linux : %v", err) + } + for _, path := range entries { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("lecture de %s : %v", path, err) + } + if index := bytes.IndexByte(raw, '\r'); index >= 0 { + line := 1 + bytes.Count(raw[:index], []byte("\n")) + t.Errorf("%s ligne %d : retour chariot Windows. Un script en CRLF répond "+ + "« bad interpreter: /bin/sh^M » sur Debian, et rien dans ce message ne parle "+ + "de fins de ligne", path, line) + } + } + + // The Windows batch file is the mirror image: cmd.exe is the one interpreter that has + // ever cared about the difference in the other direction. + batch, err := os.ReadFile(filepath.Join("windows", "start.bat")) + if err != nil { + t.Fatalf("lecture de start.bat : %v", err) + } + if !bytes.Contains(batch, []byte("\r\n")) { + t.Error("start.bat est en LF : cmd.exe est le seul interpréteur du lot à préférer CRLF") + } +} + +// TestTheShellScriptsAreValidAccordingToTheShell runs `sh -n` when a shell is available. +func TestTheShellScriptsAreValidAccordingToTheShell(t *testing.T) { + shell, err := exec.LookPath("sh") + if err != nil { + t.Skip("aucun sh sur cette machine : les scripts Linux ne peuvent pas être analysés ici") + } + scripts, err := filepath.Glob(filepath.Join("linux", "*.sh")) + if err != nil || len(scripts) == 0 { + t.Fatalf("aucun script shell trouvé : %v", err) + } + for _, script := range scripts { + output, err := exec.Command(shell, "-n", script).CombinedOutput() + if err != nil { + t.Errorf("%s ne s'analyse pas : %v\n%s", script, err, output) + continue + } + t.Logf("%s : syntaxe correcte", script) + } +} diff --git a/deploy/update_contract_test.go b/deploy/update_contract_test.go new file mode 100644 index 0000000..3bf6393 --- /dev/null +++ b/deploy/update_contract_test.go @@ -0,0 +1,161 @@ +package deploy + +import ( + "path/filepath" + "strings" + "testing" +) + +// update.ps1 read as a CONTRACT and not as a script: the six parameters the station passes +// it on the command line, its four exits, the fields the station reads back in its report, +// and the fact that the client screen comes back — including on the failure paths. +// PowerShell binds what it recognises and ignores the rest, so nothing else in this +// repository would catch a parameter that disappeared. + +// --- update.ps1 as a CONTRACT, no longer as a script somebody reads ----------------- + +// TestTheUpdaterTakesEveryParameterTheStationPasses freezes the contract between +// internal/platform and the script. +// +// A parameter renamed on one side and not the other is a swap that never starts, +// and nothing else in this repository would catch it: the station hands these six +// on a command line, and PowerShell binds what it recognises and ignores the rest. +func TestTheUpdaterTakesEveryParameterTheStationPasses(t *testing.T) { + updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) + for _, parameter := range []string{ + "$Source", "$InstallDir", "$DataRoot", "$OutcomePath", "$LogPath", + } { + if !strings.Contains(updater, parameter) { + t.Errorf("update.ps1 ne déclare pas le paramètre %s", parameter) + } + } +} + +// TestTheUpdaterReportsOnAllFourOfItsExits is what lets the screen tell « failed, +// rolled back, the station works » from « failed, the station is dead ». The two +// do not ask the same thing of a volunteer: the first calls nobody. +func TestTheUpdaterReportsOnAllFourOfItsExits(t *testing.T) { + updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) + for _, code := range []string{"exit 10", "exit 11", "exit 12"} { + if !strings.Contains(updater, code) { + t.Errorf("update.ps1 ne sort jamais par « %s »", code) + } + } + for _, status := range []string{ + "succeeded", "rolled-back", "rolled-back-unhealthy", "not-started", + } { + if !strings.Contains(updater, status) { + t.Errorf("update.ps1 n'écrit jamais le statut %q", status) + } + } + if !strings.Contains(updater, "function Write-Outcome") { + t.Error("update.ps1 n'a pas de fonction unique d'écriture du compte rendu : " + + "quatre écritures dispersées, c'est trois occasions d'en oublier une") + } +} + +// TestTheOutcomeCarriesEveryFieldTheStationReads freezes the JSON keys against +// update.Outcome. The station reads this file at its NEXT START, when the process +// that could have read an exit code has been dead for a minute. +func TestTheOutcomeCarriesEveryFieldTheStationReads(t *testing.T) { + updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) + for _, key := range []string{ + "status", "exit_code", "from", "to", "reason", "backup", + "database_backups", "finished_at", + } { + if !strings.Contains(updater, key) { + t.Errorf("le compte rendu ne porte pas la clé %q", key) + } + } +} + +// TestTheUpdaterBringsTheClientScreenBack is the defect this work uncovered. +// +// Stop-OpenScaleBinaryHolders ends the kiosk task, openscale-kiosk.xml carries a +// LogonTrigger AND NOTHING ELSE, and nobody restarted it: neither install.ps1 nor +// update.ps1. The client screen stayed black until somebody logged on. +// +// It never showed because a human who updates a station ends up rebooting it. A +// volunteer who touches a button on the administration screen does not -- they +// look at the client screen within the minute. +func TestTheUpdaterBringsTheClientScreenBack(t *testing.T) { + common := codeOnly(readFile(t, filepath.Join("windows", "common.ps1"))) + if !strings.Contains(common, "function Start-OpenScaleKiosk") { + t.Fatal("common.ps1 ne porte pas la relance de l'écran client") + } + if !strings.Contains(common, "schtasks /run") { + t.Error("la relance n'appelle pas schtasks /run") + } + + updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) + if !strings.Contains(updater, "Start-OpenScaleKiosk") { + t.Fatal("update.ps1 ne relance jamais l'écran client") + } + // The installer stops the kiosk too, and for the same reason -- it replaces the + // binary the task is running. Re-running install.ps1 on a working station is + // what TROUBLESHOOTING.md recommends, so it must not leave a black screen. + if !strings.Contains(codeOnly(readFile(t, filepath.Join("windows", "install.ps1"))), + "Start-OpenScaleKiosk") { + t.Error("install.ps1 ne relance pas l'écran client qu'il vient d'arrêter") + } +} + +// TestTheClientScreenComesBackOnTheFailurePathsToo. +// +// A rollback that leaves the client screen black is a breakdown created by the +// repair: the station serves again, the customer sees nothing, and the volunteer +// concludes the update destroyed the poste. +func TestTheClientScreenComesBackOnTheFailurePathsToo(t *testing.T) { + updater := codeOnly(readFile(t, filepath.Join("windows", "update.ps1"))) + restarts := strings.Count(updater, "Start-OpenScaleKiosk") + // Four exits: succeeded, rolled-back, rolled-back-unhealthy, not-started. The + // last two not-started paths share one call, hence three at least. + if restarts < 3 { + t.Fatalf("%d relance(s) de l'écran client dans update.ps1 : les chemins d'échec "+ + "n'en ont pas", restarts) + } + failure := updater[strings.Index(updater, "if ($failure)"):] + if !strings.Contains(failure, "Start-OpenScaleKiosk") { + t.Error("le chemin d'échec ne relance pas l'écran client") + } +} + +// TestUpdateScriptsMigrateTheConfigurationAfterTheHealthCheck: both scripts roll the +// previous binary back when the station does not answer, and a previous binary reading an +// already-migrated file would lose what the migration carried. So the call comes AFTER the +// rollback verdict, never before -- and, more precisely than "after the health check" alone, +// after the rollback BLOCK itself: a call placed right past the check but still inside the +// block that can restore the previous binary would run before that block has finished +// deciding whether to restore anything. +func TestUpdateScriptsMigrateTheConfigurationAfterTheHealthCheck(t *testing.T) { + for _, c := range []struct{ path, health, rollback string }{ + // The rollback marker is the line each script reaches only once it has restored the + // previous binary: `rolled-back` for PowerShell, the restoring `install` for shell. + {filepath.Join("windows", "update.ps1"), "Test-StationHealth", "Write-Outcome -Status 'rolled-back'"}, + {filepath.Join("linux", "update.sh"), "healthy", `install -m 0755 "$BACKUP" "$BINARY"`}, + } { + t.Run(c.path, func(t *testing.T) { + // codeOnly, so a comment naming "config migrate" to explain the placement is not + // mistaken for the call it explains. + body := codeOnly(readFile(t, c.path)) + migrate := strings.Index(body, "config migrate") + if migrate < 0 { + t.Fatalf("%s n'appelle pas « config migrate »", c.path) + } + health := strings.Index(body, c.health) + if health < 0 || migrate < health { + t.Error("« config migrate » vient avant le contrôle de santé : " + + "un retour arrière relirait un fichier déjà migré") + } + rollback := strings.Index(body, c.rollback) + if rollback < 0 { + t.Fatalf("%s : le repère du bloc de retour arrière est introuvable, ce test ne "+ + "prouve plus rien", c.path) + } + if migrate < rollback { + t.Error("« config migrate » vient avant la fin du bloc de retour arrière : " + + "un binaire restauré relirait un fichier déjà migré") + } + }) + } +} diff --git a/docs/02-architecture.md b/docs/02-architecture.md index e813cb4..6d8d354 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -2102,7 +2102,7 @@ L'auto-test `barcode-frame` et l'auto-test `character-table` **disparaissent** : ### 9.1 La boucle série, écrite une fois ```go -// internal/scale/serial/loop.go — 95% of a serial driver's code. +// internal/scale/serial/options.go — the package holds 95% of a serial driver's code. // Options holds the link settings of a serial scale plus the single decoder // that varies from one model to the next. @@ -3027,7 +3027,7 @@ flowchart TD **Le reste des options de driver voyage**, et ce défaut est délibéré : une option de réglage est partagée par les quatre postes jusqu'à preuve du contraire, et la preuve -s'écrit dans `stationSpecificOptions` (`internal/domain/config.go`). Le décalage +s'écrit dans `stationSpecificOptions` (`internal/domain/redact.go`). Le décalage d'étiquette est le cas qui a tranché — la notice promet depuis toujours qu'il voyage avec la configuration clonée, et il partait avec `printer.options`. @@ -3590,7 +3590,7 @@ flowchart TD ``` ```go -// internal/station/hub.go +// internal/station/loop.go func (h *Hub) run(ctx context.Context) { defer close(h.done) @@ -4819,10 +4819,10 @@ Pipeline : `npm ci && npm run build` **avant** `go` (`//go:embed all:dist`) · ` | Module budgété | Ce qui s'est passé | Où c'est écrit | Forme du refus | |---|---|---|---| -| `github.com/alexbrainman/printer` | sept appels `syscall` vers `winspool.drv`, liés paresseusement | `internal/printing/transport/winspool_windows.go:18` | surface trop petite | -| `github.com/go-pdf/fpdf` | cinq objets PDF, une table d'offsets, un trailer | `internal/printing/pdf.go:11` | surface trop petite | -| `github.com/kardianos/service` | `golang.org/x/sys/windows/svc` était déjà une dépendance du module | `internal/platform/service_windows.go:22` | redondante | -| `github.com/oklog/ulid/v2` | le front frappe la clé d'idempotence au `pointerdown` ; `deriveJobID` est une fonction pure, sans entropie ni horloge, et n'a donc jamais à en générer une | `internal/domain/machine.go:1651`, `web/src/lib/ulid.ts` | sans objet | +| `github.com/alexbrainman/printer` | sept appels `syscall` vers `winspool.drv`, liés paresseusement | `internal/printing/transport/winspool_windows.go` | surface trop petite | +| `github.com/go-pdf/fpdf` | cinq objets PDF, une table d'offsets, un trailer | `internal/printing/pdf.go` | surface trop petite | +| `github.com/kardianos/service` | `golang.org/x/sys/windows/svc` était déjà une dépendance du module | `internal/platform/service_windows.go` | redondante | +| `github.com/oklog/ulid/v2` | le front frappe la clé d'idempotence au `pointerdown` ; `deriveJobID` est une fonction pure, sans entropie ni horloge, et n'a donc jamais à en générer une | `deriveJobID` (`internal/domain/reading.go`), `web/src/lib/ulid.ts` | sans objet | Le quatrième est le plus instructif : **aucune ligne de code maison n'a remplacé `oklog/ulid`**. C'est une décision de conception qui a fait disparaître le besoin. La meilleure dépendance est celle qu'une décision d'architecture supprime. diff --git a/internal/catalog/assemble_test.go b/internal/catalog/assemble_test.go index 37ff6e1..c675c8e 100644 --- a/internal/catalog/assemble_test.go +++ b/internal/catalog/assemble_test.go @@ -1,13 +1,9 @@ package catalog_test import ( - "bytes" "encoding/json" "errors" "fmt" - "image" - "image/color" - "image/png" "io" "strings" "testing" @@ -22,6 +18,13 @@ import ( // point of the seam ADR-052 draws. Before it, every one of the rules below lived in // csvodoo and was proved by CSV fixtures — so a second format would have had to prove // them again, or, far more likely, would have re-implemented them differently. +// +// What Assemble DECIDES, and what a source may therefore never decide for itself: an +// unreadable row is counted and the reading carries on, a failed READ refuses the whole +// batch, a twice-used identifier is set aside and named, and a catalog mostly +// unreadable is refused outright by the absolute guard of §10.4. +// +// The photos of an assembly are in photos_test.go. // scripted is a RowReader a test writes out step by step. type scripted struct { @@ -283,126 +286,6 @@ func TestAFindingAboutTheWholeStreamTravelsWithTheFirstRow(t *testing.T) { t.Fatalf("la remarque sur l'en-tête est perdue : %+v", batch.Findings) } -// --- Les photos, qui sont les règles de §10.7 et non celles d'un format ---------- - -// TestTheSamePhotoOnTwoProductsIsOneFile. -// -// The sha IS the address, which is what turns 181 rows carrying a photo into 165 files -// written — and what makes a re-import write nothing at all (§10.7). -func TestTheSamePhotoOnTwoProductsIsOneFile(t *testing.T) { - shared := pngOf(t, 8, 8) - rows := manyGoodRows(t, 10) - rows[0].Image, rows[1].Image = shared, shared - sink := newCollecting() - - batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ - KeepPhotos: true, Images: sink}) - if err != nil { - t.Fatalf("Assemble : %v", err) - } - if len(batch.Images) != 1 { - t.Fatalf("%d image(s) dans le lot, attendu 1", len(batch.Images)) - } - if len(sink.put) != 1 { - t.Fatalf("%d empreinte(s) écrite(s), attendu 1", len(sink.put)) - } - for sha, times := range sink.put { - if times != 1 { - t.Errorf("la photo %s a été écrite %d fois", sha[:8], times) - } - } - if batch.Products[0].ImageSHA == "" || batch.Products[0].ImageSHA != batch.Products[1].ImageSHA { - t.Errorf("les deux produits ne partagent pas l'adresse : %q et %q", - batch.Products[0].ImageSHA, batch.Products[1].ImageSHA) - } -} - -// TestAPhotoRefusedLosesItsPhotoAndNeverItsProduct: les deux refus de §10.7 sont NON -// BLOQUANTS — le produit garde sa tuile dans les deux cas. -func TestAPhotoRefusedLosesItsPhotoAndNeverItsProduct(t *testing.T) { - for _, c := range []struct { - name string - image []byte - ceiling int - want string - }{ - {"au-delà du plafond", pngOf(t, 64, 64), 64, domain.FindingImageTooLarge}, - {"en-tête d'aucun format accepté", []byte("ceci n'est pas une image"), 0, - domain.FindingImageInvalid}, - } { - t.Run(c.name, func(t *testing.T) { - rows := manyGoodRows(t, 10) - rows[0].Image = c.image - batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ - KeepPhotos: true, MaxImageSize: c.ceiling}) - if err != nil { - t.Fatalf("Assemble : %v", err) - } - if len(batch.Products) != 10 || - batch.Products[0].Qualification != domain.Weighable { - t.Fatal("le produit a perdu sa tuile en même temps que sa photo") - } - if batch.Products[0].ImageSHA != "" || len(batch.Images) != 0 { - t.Fatal("une photo refusée a été adressée quand même") - } - if !hasCode(batch.Findings, c.want) { - t.Errorf("le refus n'est pas classé %s : %v", c.want, codesOf(batch.Findings)) - } - }) - } -} - -// TestAStationThatKeepsNoPhotoOpensNone: `catalog.images.source` à autre chose que les -// photos de la source, et rien n'est décodé ni écrit. -func TestAStationThatKeepsNoPhotoOpensNone(t *testing.T) { - rows := manyGoodRows(t, 10) - rows[0].Image = pngOf(t, 8, 8) - sink := newCollecting() - - batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ - KeepPhotos: false, Images: sink}) - if err != nil { - t.Fatalf("Assemble : %v", err) - } - if len(batch.Images) != 0 || len(sink.put) != 0 || batch.Products[0].ImageSHA != "" { - t.Fatal("une photo a été retenue sur un poste qui n'en garde aucune") - } -} - -// TestASinkThatRefusesLeavesTheProductWithoutItsPhoto: un disque plein dégrade le -// confort, jamais le service (principe 6 de §4). -func TestASinkThatRefusesLeavesTheProductWithoutItsPhoto(t *testing.T) { - rows := manyGoodRows(t, 10) - rows[0].Image = pngOf(t, 8, 8) - - batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ - KeepPhotos: true, Images: &collecting{put: map[string]int{}, refuse: true}}) - if err != nil { - t.Fatalf("Assemble : %v", err) - } - if len(batch.Products) != 10 || batch.Products[0].ImageSHA != "" { - t.Fatal("le produit a suivi le sort de sa photo") - } - if !hasCode(batch.Findings, domain.FindingImageInvalid) { - t.Errorf("le refus du puits n'est pas signalé : %v", codesOf(batch.Findings)) - } -} - -// TestANilSinkCountsThePhotosAndKeepsNone: c'est exactement ce que veut une lecture à -// blanc du rapport d'import (§10.3 bis). -func TestANilSinkCountsThePhotosAndKeepsNone(t *testing.T) { - rows := manyGoodRows(t, 10) - rows[0].Image = pngOf(t, 8, 8) - - batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{KeepPhotos: true}) - if err != nil { - t.Fatalf("Assemble : %v", err) - } - if len(batch.Images) != 1 || batch.Products[0].ImageSHA == "" { - t.Fatal("un puits nul doit compter la photo et l'adresser sans l'écrire") - } -} - // --- Ce que les options par défaut valent ---------------------------------------- // TestABareOptionsIsAUsableAssembler: un appelant qui ne remplit rien reçoit les gardes @@ -477,29 +360,3 @@ func codesOf(findings []domain.Finding) []string { } return out } - -// pngOf builds a PNG of the requested size, so that a test about a CEILING carries a -// real image rather than bytes that would be refused for their header instead. -func pngOf(t *testing.T, width, height int) []byte { - t.Helper() - canvas := image.NewRGBA(image.Rect(0, 0, width, height)) - // Pseudo-random noise, and both words matter. NOISE, because PNG squeezes a regular - // picture down to almost nothing and a test about a size ceiling would never reach - // it. PSEUDO-random, from a generator seeded here, because a test whose subject - // varies from one run to the next is a test that fails on somebody else's machine. - seed := uint32(1) - next := func() uint8 { - seed = seed*1664525 + 1013904223 - return uint8(seed >> 24) - } - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - canvas.Set(x, y, color.RGBA{R: next(), G: next(), B: next(), A: 255}) - } - } - var encoded bytes.Buffer - if err := png.Encode(&encoded, canvas); err != nil { - t.Fatalf("encodage du PNG d'essai : %v", err) - } - return encoded.Bytes() -} diff --git a/internal/catalog/csvodoo/image.go b/internal/catalog/csvodoo/image.go index 7faf7eb..3e062e4 100644 --- a/internal/catalog/csvodoo/image.go +++ b/internal/catalog/csvodoo/image.go @@ -22,11 +22,11 @@ import ( // not after three megabytes have been allocated. It deliberately stops at max+1 rather // than at max — that extra byte is what lets the assembler tell « exactly at the // ceiling » from « past it » and name the ceiling in the finding. -func unwrap(encoded string, max int) ([]byte, error) { +func unwrap(encoded string, ceiling int) ([]byte, error) { decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded)) var decoded bytes.Buffer - decoded.Grow(min(len(encoded)*3/4+3, max+1)) - if _, err := io.Copy(&decoded, io.LimitReader(decoder, int64(max)+1)); err != nil { + decoded.Grow(min(len(encoded)*3/4+3, ceiling+1)) + if _, err := io.Copy(&decoded, io.LimitReader(decoder, int64(ceiling)+1)); err != nil { return nil, fmt.Errorf("le champ image n'est pas du base64 lisible (%v)", err) } return decoded.Bytes(), nil diff --git a/internal/catalog/example/rows.go b/internal/catalog/example/rows.go index 5f29c23..9fe470e 100644 --- a/internal/catalog/example/rows.go +++ b/internal/catalog/example/rows.go @@ -117,7 +117,9 @@ func (r *rowReader) Next() (catalog.Row, []domain.Finding, error) { // The array is finished. What follows it still has to be walked, because that is // where `next_page` lives in an answer that puts its products first. if err := r.finishPage(); err != nil { - r.closeBody() + // The close error is explicitly ignored: it is the READ error that travels + // up, and overwriting it with a close failure would lose the cause. + _ = r.closeBody() return catalog.Row{}, nil, err } // Closing the answer BEFORE asking for the next page is what keeps one connection @@ -161,7 +163,8 @@ func (r *rowReader) open(number int) error { // materialised as a slice: decoding the object whole would put the entire page — and // its photos — in memory before the first row came out. if err := r.seekProducts(); err != nil { - r.closeBody() + // Same reason as above: the cause that travels up is the one from the read. + _ = r.closeBody() return err } return nil @@ -302,11 +305,11 @@ func (r *rowReader) closeBody() error { // claiming three megabytes is refused after 256 kB have been read, not after three // megabytes have been allocated. The extra byte is what lets the assembler tell « exactly // at the ceiling » from « past it » and name the ceiling in a sentence a volunteer reads. -func unwrapPhoto(encoded string, max int) ([]byte, error) { +func unwrapPhoto(encoded string, ceiling int) ([]byte, error) { decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encoded)) var decoded bytes.Buffer - decoded.Grow(min(len(encoded)*3/4+3, max+1)) - if _, err := io.Copy(&decoded, io.LimitReader(decoder, int64(max)+1)); err != nil { + decoded.Grow(min(len(encoded)*3/4+3, ceiling+1)) + if _, err := io.Copy(&decoded, io.LimitReader(decoder, int64(ceiling)+1)); err != nil { return nil, fmt.Errorf("le champ photo n'est pas du base64 lisible (%v)", err) } return decoded.Bytes(), nil diff --git a/internal/catalog/localdrop/acknowledge_test.go b/internal/catalog/localdrop/acknowledge_test.go new file mode 100644 index 0000000..21f8935 --- /dev/null +++ b/internal/catalog/localdrop/acknowledge_test.go @@ -0,0 +1,184 @@ +package localdrop + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// THE DELETION IS THE ACKNOWLEDGEMENT (§10.1, ADR-004), and it comes LAST: the file is +// still there while the batch is being applied, so a crash in between loses nothing. +// What is asserted here is the order — archive, then reason, then remove — and the two +// ways it can go wrong without becoming a quarantine: a file nobody can delete, and an +// acknowledgement with no copy in flight. + +// TestAcknowledgingArchivesThenRemoves — and the removal IS the acknowledgement +// (ADR-004, §10.1-7). +func TestAcknowledgingArchivesThenRemoves(t *testing.T) { + source, _ := station(t, "") + drop(t, source, aCatalog) + source.poll(context.Background()) + batch, err := source.poll(context.Background()) + if err != nil || batch == nil { + t.Fatalf("lecture : %v", err) + } + + if err := source.Acknowledge(context.Background(), batch, + ports.BatchResult{Result: domain.ImportApplied}); err != nil { + t.Fatalf("acquittement : %v", err) + } + if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("le fichier source est toujours là : l'acquittement EST la suppression") + } + + names := archives(t, source) + if len(names) != 1 || names[0] != "flv_2-2026-07-24T15-38-12.csv" { + t.Fatalf("archives %v, attendu flv_2-2026-07-24T15-38-12.csv", names) + } + kept, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(source.Path())), "archives", names[0])) + if err != nil { + t.Fatalf("lecture de l'archive : %v", err) + } + if string(kept) != aCatalog { + t.Error("l'archive ne porte pas les octets qui ont été analysés") + } +} + +// TestARejectedBatchLeavesAReasonNextToItsCopy is the `.reason.txt` of failure test 9. +func TestARejectedBatchLeavesAReasonNextToItsCopy(t *testing.T) { + source, _ := station(t, "") + drop(t, source, aCatalog) + source.poll(context.Background()) + batch, _ := source.poll(context.Background()) + + err := source.Acknowledge(context.Background(), batch, ports.BatchResult{ + Result: domain.ImportRejected, Code: "ERR-CAT-03", + Reason: "42 % de produits pesables en moins que la veille", + }) + if err != nil { + t.Fatalf("acquittement : %v", err) + } + names := archives(t, source) + if len(names) != 2 { + t.Fatalf("archives %v, attendu la copie ET son motif", names) + } + reason, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(source.Path())), + "archives", "flv_2-2026-07-24T15-38-12.reason.txt")) + if err != nil { + t.Fatalf("lecture du motif : %v", err) + } + for _, expected := range []string{"ERR-CAT-03", "pesables en moins", "2026-07-24"} { + if !strings.Contains(string(reason), expected) { + t.Errorf("le motif ne contient pas %q : %s", expected, reason) + } + } + // A refused batch is acknowledged all the same: leaving the file would re-offer + // the same refused content every five seconds. + if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { + t.Error("le fichier d'un lot refusé est resté en place") + } +} + +// TestAFileThatCannotBeDeletedIsAmberAndNotQuarantined is failure test 11. +func TestAFileThatCannotBeDeletedIsAmberAndNotQuarantined(t *testing.T) { + source, _ := station(t, "") + source.remove = func(string) error { return os.ErrPermission } + drop(t, source, aCatalog) + source.poll(context.Background()) + batch, _ := source.poll(context.Background()) + + err := source.Acknowledge(context.Background(), batch, + ports.BatchResult{Result: domain.ImportApplied}) + if !errors.Is(err, catalog.ErrNotAcknowledged) { + t.Fatalf("erreur %v, attendu ERR-CAT-05", err) + } + if !errors.Is(err, os.ErrPermission) { + t.Errorf("l'erreur ne porte pas sa cause : %v", err) + } + if !strings.Contains(err.Error(), source.directory) { + t.Errorf("le message ne nomme pas le répertoire fautif : %v", err) + } + // It is a failure of the ACKNOWLEDGEMENT, never of the content: the catalog this + // file carried is in service, and nothing is quarantined. + if errors.Is(err, catalog.ErrContent) { + t.Error("un fichier non supprimable est compté comme un échec de contenu") + } + if names := archives(t, source); len(names) != 1 { + t.Errorf("archives %v : la copie doit exister même si la source survit", names) + } +} + +// TestAnUnusableFileIsSetAsideWithItsReasonAndRemoved is failure test 9 on the source +// side: three drops of the same broken content must not spin the watcher. +func TestAnUnusableFileIsSetAsideWithItsReasonAndRemoved(t *testing.T) { + source, _ := station(t, "") + for attempt := 1; attempt <= 3; attempt++ { + drop(t, source, "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") + source.poll(context.Background()) + batch, err := source.poll(context.Background()) + if batch != nil { + t.Fatalf("essai %d : un contenu inexploitable a produit un lot", attempt) + } + if !errors.Is(err, catalog.ErrContent) { + t.Fatalf("essai %d : erreur %v, attendu ERR-CAT-03", attempt, err) + } + if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("essai %d : le fichier refusé est resté ; la scrutation le relirait "+ + "toutes les cinq secondes", attempt) + } + } + // Three copies and three reasons, so somebody can see it happened three times. + if names := archives(t, source); len(names) != 6 { + t.Errorf("archives %v, attendu trois copies et trois motifs", names) + } +} + +// TestTheArchiveIsBounded on both criteria (§11.2: max_archives, archive_days). +func TestTheArchiveIsBounded(t *testing.T) { + source, clock := station(t, `{"max_archives": 3}`) + for i := 0; i < 5; i++ { + drop(t, source, aCatalog) + source.poll(context.Background()) + batch, err := source.poll(context.Background()) + if err != nil || batch == nil { + t.Fatalf("import %d : %v", i, err) + } + if err := source.Acknowledge(context.Background(), batch, + ports.BatchResult{Result: domain.ImportApplied}); err != nil { + t.Fatalf("acquittement %d : %v", i, err) + } + clock.Advance(time.Minute) + } + names := archives(t, source) + if len(names) != 3 { + t.Fatalf("%d archives conservées, attendu 3 : %v", len(names), names) + } + // The three most RECENT ones: the name carries the instant. + if names[0] != "flv_2-2026-07-24T15-40-12.csv" { + t.Errorf("la plus ancienne conservée est %q", names[0]) + } +} + +// TestAnAcknowledgementWithNoCopyInFlightStillRemovesTheFile. +// +// It is the state after a restart: the process read nothing, and the file of an +// import that was applied before the crash must still be able to leave. +func TestAnAcknowledgementWithNoCopyInFlightStillRemovesTheFile(t *testing.T) { + source, _ := station(t, "") + drop(t, source, aCatalog) + if err := source.Acknowledge(context.Background(), &ports.Batch{ID: "sha"}, + ports.BatchResult{Result: domain.ImportApplied}); err != nil { + t.Fatalf("acquittement : %v", err) + } + if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { + t.Error("le fichier est resté") + } +} diff --git a/internal/catalog/localdrop/build_test.go b/internal/catalog/localdrop/build_test.go new file mode 100644 index 0000000..9a3fbf5 --- /dev/null +++ b/internal/catalog/localdrop/build_test.go @@ -0,0 +1,289 @@ +package localdrop + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/fake" +) + +// What a configuration BUILDS, and what it refuses to build: the directory this source +// owns and creates itself, the one it is pointed at and must not own, the descriptor +// the administration screen generates its form from, and the factory the registry +// reaches it through. + +// TestTheDescriptorMatchesTheShippedConfiguration: the schema this source declares is +// what Config.Validate checks catalog.options against (control 9). +func TestTheDescriptorMatchesTheShippedConfiguration(t *testing.T) { + descriptor := Descriptor() + if descriptor.ID != domain.CatalogSourceLocalDrop || descriptor.Label == "" || descriptor.New == nil { + t.Fatalf("descripteur incomplet : %+v", descriptor) + } + for _, forbidden := range []string{"url", "username", "password"} { + for _, option := range descriptor.Options { + if option.Key == forbidden { + t.Errorf("le dépôt local déclare %q : un répertoire qu'on possède n'a "+ + "aucun secret à porter (§10.1)", forbidden) + } + } + } +} + +// TestASourceRefusesToBeBuiltWithoutWhatItNeeds. +// +// Both refusals are composition mistakes with no operator input in them, and both are +// worth a sentence rather than a nil pointer three seconds later. +func TestASourceRefusesToBeBuiltWithoutWhatItNeeds(t *testing.T) { + for _, c := range []struct { + what string + config catalog.SourceConfig + says string + }{ + {"sans horloge", catalog.SourceConfig{DataDir: t.TempDir()}, "horloge"}, + {"sans répertoire de données", + catalog.SourceConfig{Clock: fake.NewClock(t0)}, "répertoire de données"}, + } { + if _, err := New(c.config); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s : %v", c.what, err) + } + } +} + +// TestADropDirectoryThatCannotBeCreatedIsNamed: an installation whose data directory +// is a file is a mistake somebody has to be told about, in the terms of the path. +func TestADropDirectoryThatCannotBeCreatedIsNamed(t *testing.T) { + root := t.TempDir() + inTheWay := filepath.Join(root, "catalog") + if err := os.WriteFile(inTheWay, []byte("un fichier là où un répertoire est attendu"), 0o644); err != nil { + t.Fatalf("préparation : %v", err) + } + _, err := New(catalog.SourceConfig{ + StationNumber: 2, DataDir: root, Clock: fake.NewClock(t0), + }) + if err == nil { + t.Fatal("la source a été construite sur un répertoire impossible") + } + if !strings.Contains(err.Error(), inTheWay) { + t.Errorf("le message ne nomme pas le chemin fautif : %v", err) + } +} + +// TestAnUnreadableDropDirectoryIsSaidAndTheWatchGoesOn: a share that blinks is not a +// reason to stop watching (§10.1). +func TestAnUnreadableDropDirectoryIsSaidAndTheWatchGoesOn(t *testing.T) { + source, _ := station(t, "") + journal := &recorder{} + source.log = journal + // A DIRECTORY where the file is expected: os.Stat succeeds, os.Open succeeds and + // the read fails — the same shape as a share that answers half way. + if err := os.Mkdir(source.Path(), 0o755); err != nil { + t.Fatalf("préparation : %v", err) + } + source.poll(context.Background()) + batch, err := source.poll(context.Background()) + if batch != nil { + t.Fatalf("un répertoire a été lu comme un catalogue : %v", batch) + } + if err == nil { + t.Fatal("la lecture d'un répertoire n'a rien signalé") + } + // It was set aside like any unusable content, with its reason, and the watch is + // usable afterwards: the real file lands later and is read. + if names := archives(t, source); len(names) == 0 { + t.Error("rien n'a été mis de côté") + } + drop(t, source, aCatalog) + source.poll(context.Background()) + if batch, err := source.poll(context.Background()); batch == nil { + t.Fatalf("la scrutation ne s'est pas remise : %v", err) + } +} + +// TestTheTechnicalLogIsNeverNil: no driver checks for one (ADR-013). +func TestTheTechnicalLogIsNeverNil(t *testing.T) { + source, _ := station(t, "") + if source.log == nil { + t.Fatal("la source a gardé un journal nil") + } + source.log.Technical("info", "catalog", "", "message", "détail") +} + +// TestTheFactoryOfTheRegistryBuildsAWatchingSource: the descriptor is what +// cmd/openscale/drivers.go registers, so the one line of §5.2 is exercised here. +func TestTheFactoryOfTheRegistryBuildsAWatchingSource(t *testing.T) { + journal := &recorder{} + built, err := Descriptor().New(catalog.SourceConfig{ + Catalog: domain.CatalogConfig{FallbackCategory: "other"}, + StationNumber: 4, + DataDir: t.TempDir(), + Clock: fake.NewClock(t0), + Log: journal, + }) + if err != nil { + t.Fatalf("construction par la fabrique : %v", err) + } + defer built.Close() + + source, ok := built.(*Source) + if !ok { + t.Fatalf("la fabrique a rendu un %T", built) + } + if filepath.Base(source.Path()) != "flv_4.csv" { + t.Errorf("le poste 4 surveille %q", filepath.Base(source.Path())) + } + if source.log != journal { + t.Error("le journal technique injecté n'a pas été retenu") + } +} + +// TestAnEmptyDirectoryOptionKeepsTheStationDirectory is the shipped case: nothing in +// catalog.options, and the source watches the directory the service owns and creates. +func TestAnEmptyDirectoryOptionKeepsTheStationDirectory(t *testing.T) { + data := t.TempDir() + got, owned := Directory(catalog.SourceConfig{DataDir: data}) + want := filepath.Join(data, "catalog", "incoming") + if got != want { + t.Errorf("répertoire = %q, attendu %q", got, want) + } + if !owned { + t.Error("le répertoire par défaut appartient au service : il le crée lui-même") + } +} + +// TestANamedDirectoryIsWatchedAndNotOwned: somebody named a directory, so the service +// watches it and does NOT create it. A typo would otherwise build a tree nobody watches. +func TestANamedDirectoryIsWatchedAndNotOwned(t *testing.T) { + chosen := t.TempDir() + c := catalog.SourceConfig{ + DataDir: t.TempDir(), + Catalog: domain.CatalogConfig{Options: driverOptions(t, + `{"directory":`+strconv.Quote(chosen)+`}`)}, + } + got, owned := Directory(c) + if got != filepath.Clean(chosen) { + t.Errorf("répertoire = %q, attendu %q", got, filepath.Clean(chosen)) + } + if owned { + t.Error("un répertoire nommé par un humain n'appartient pas au service") + } +} + +// TestABlankDirectoryOptionIsNoDirectoryAtAll: a field somebody opened and left with a +// space in it must not send the station watching " ". +func TestABlankDirectoryOptionIsNoDirectoryAtAll(t *testing.T) { + data := t.TempDir() + c := catalog.SourceConfig{ + DataDir: data, + Catalog: domain.CatalogConfig{Options: driverOptions(t, `{"directory":" "}`)}, + } + got, owned := Directory(c) + if got != filepath.Join(data, "catalog", "incoming") || !owned { + t.Errorf("un champ blanc doit valoir le répertoire du poste, obtenu %q (owned=%v)", got, owned) + } +} + +// TestANamedDirectoryThatIsAbsentIsRefusedAtBuild: New does not create it, and says so +// rather than watching a path that will never receive anything. +func TestANamedDirectoryThatIsAbsentIsRefusedAtBuild(t *testing.T) { + absent := filepath.Join(t.TempDir(), "jamais-monte") + _, err := New(catalog.SourceConfig{ + DataDir: t.TempDir(), + Clock: fake.NewClock(t0), + Catalog: domain.CatalogConfig{Options: driverOptions(t, + `{"directory":`+strconv.Quote(absent)+`}`)}, + }) + if err == nil { + t.Fatal("un répertoire nommé et absent doit être refusé, pas créé") + } + if _, statErr := os.Stat(absent); statErr == nil { + t.Error("le service a créé un répertoire qu'un humain avait nommé") + } +} + +// TestANamedDirectoryIsTheOneTheStationReallyWatches closes the loop between the option +// and the file: a directory that is merely remembered and never watched would read right +// on the administration screen and receive nothing for ever. +func TestANamedDirectoryIsTheOneTheStationReallyWatches(t *testing.T) { + chosen := t.TempDir() + source, err := New(catalog.SourceConfig{ + StationNumber: 2, + DataDir: t.TempDir(), + Clock: fake.NewClock(t0), + Catalog: domain.CatalogConfig{ + Options: driverOptions(t, `{"directory":`+strconv.Quote(chosen)+`}`), + Images: domain.ImagesConfig{Source: domain.ImageSourceCSV}, + FallbackCategory: "other", + }, + }) + if err != nil { + t.Fatalf("construction de la source : %v", err) + } + t.Cleanup(func() { source.Close() }) + + if want := filepath.Join(chosen, "flv_2.csv"); source.Path() != want { + t.Fatalf("le poste surveille %q, attendu %q", source.Path(), want) + } + drop(t, source, aCatalog) + source.poll(context.Background()) + if batch, err := source.poll(context.Background()); batch == nil { + t.Fatalf("le fichier déposé dans le répertoire nommé n'a pas été lu : %v", err) + } +} + +// TestTheDescriptorDeclaresTheDropDirectory: an option the schema does not carry is +// refused by control 9 long before it could ever be honoured, and an option whose USE the +// schema does not carry is one the drop probe of control 46 will never look at. +func TestTheDescriptorDeclaresTheDropDirectory(t *testing.T) { + for _, option := range Descriptor().Options { + if option.Key == DirectoryOption { + if option.Kind != domain.OptionText { + t.Errorf("le répertoire est déclaré %q, attendu du texte", option.Kind) + } + if option.Use != domain.UseDropDirectory { + t.Errorf("le répertoire n'est pas déclaré comme répertoire de dépôt : "+ + "les contrôles 39 et 46 lisent cet usage, et rien d'autre ne leur dit "+ + "que %q nomme un répertoire", DirectoryOption) + } + return + } + } + t.Errorf("le descripteur ne déclare pas %q", DirectoryOption) +} + +// TestTheDomainActsOnTheUseThisPackageDeclares. +// +// The tie between the two sides USED to be control 47, which spelled `directory` inside +// internal/domain: one key written twice, and a third source that could not be added +// without editing the domain. The tie is now the SCHEMA — this package says which of its +// keys names a drop directory, and the controls act on that declaration without knowing +// any source by name (ADR-052). +// +// So what has to be proved is no longer that two spellings match, but that the +// declaration REACHES the control: an HTTP host typed into the drop path is refused +// (control 39, important-11) on a registry that carries nothing but this package's own +// descriptor. +func TestTheDomainActsOnTheUseThisPackageDeclares(t *testing.T) { + registry := catalog.NewRegistry() + registry.Register(Descriptor()) + registries := domain.Registries{CatalogSources: registry.Descriptors()} + + config := domain.Config{Catalog: domain.CatalogConfig{ + Type: domain.CatalogSourceLocalDrop, + Options: driverOptions(t, + `{"`+DirectoryOption+`":`+strconv.Quote("https://dav.example.org/partage")+`}`), + }} + + for _, fault := range config.Validate(registries) { + if fault.Field == "catalog.options."+DirectoryOption { + return + } + } + t.Fatalf("un hôte HTTP derrière %q n'est pas refusé : l'usage déclaré par ce paquet "+ + "n'atteint pas le contrôle 39", DirectoryOption) +} diff --git a/internal/catalog/localdrop/localdrop.go b/internal/catalog/localdrop/localdrop.go index ac8e239..88f304e 100644 --- a/internal/catalog/localdrop/localdrop.go +++ b/internal/catalog/localdrop/localdrop.go @@ -13,11 +13,8 @@ package localdrop import ( - "context" "errors" "fmt" - "io" - "io/fs" "os" "path/filepath" "strings" @@ -30,6 +27,12 @@ import ( "openscale/internal/station/ports" ) +// This file is what a configuration BUILDS: the shipped values of §11.2, the +// directory option, the Source and its fields, what New creates and refuses, and the +// Descriptor the administration screen generates its form from. What the station then +// ASKS of that source is in source.go — the same split as the WebDAV sibling, so the +// two read side by side. + // The shipped values of §11.2, used when catalog.options does not carry the key. const ( defaultPollInterval = 5 * time.Second @@ -178,234 +181,6 @@ func logOf(c catalog.SourceConfig) ports.TechnicalLog { return c.Log } -// Name reports the registry key of this source. -func (s *Source) Name() string { return domain.CatalogSourceLocalDrop } - -// Describe reports the wording the administration screen shows permanently: the -// active source and the path it watches (§10.1). -func (s *Source) Describe() string { - return fmt.Sprintf("dépôt local, %s dans %s", s.fileName, s.directory) -} - -// Path reports the file this station watches for. -func (s *Source) Path() string { return filepath.Join(s.directory, s.fileName) } - -// Next blocks until a whole catalog is available, or until ctx is done. -// -// It does NOT touch the file: reading and acknowledging are separate, because a crash -// between the two must not lose an update for good and without a trace. -func (s *Source) Next(ctx context.Context) (*ports.Batch, error) { - tick, stop := s.clock.Ticker(s.interval) - defer stop() - for { - batch, err := s.poll(ctx) - if err != nil { - return nil, err - } - if batch != nil { - return batch, nil - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-tick: - case <-s.wake: - // Somebody pressed « Recharger le catalogue » or dropped a file on the - // screen. The poll below is the SAME one the tick performs — same stability - // rule, same parser, same acknowledgement — because a button that read a - // file the watcher would have refused would be a second import path. - } - } -} - -// Wake asks the watch to poll NOW rather than at the next tick. -// -// It is what makes « Recharger le catalogue » (§14.4) do something on a station whose -// poll interval is five seconds, and it is what makes the drag-and-drop of a CSV take -// service in a second instead of in ten. It changes NOTHING about how the file is read. -func (s *Source) Wake() { - select { - case s.wake <- struct{}{}: - default: - // A poll is already asked for. Two are the same request. - } -} - -// poll looks once, and reads only a file that has stopped moving. -func (s *Source) poll(ctx context.Context) (*ports.Batch, error) { - if s.isClosed() { - return nil, nil - } - info, err := os.Stat(s.Path()) - switch { - case errors.Is(err, fs.ErrNotExist): - s.stability.Forget() - return nil, nil - case err != nil: - // A share that blinks is not a reason to stop watching: the loop keeps - // polling and the operator is told. - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-03", - "Répertoire de dépôt illisible.", err.Error()) - s.stability.Forget() - return nil, nil - } - if !s.stability.Observe(catalog.Stamp{Size: info.Size(), Modified: info.ModTime()}) { - return nil, nil - } - return s.read(ctx) -} - -// read parses the file and keeps a copy of the very bytes it parsed. -// -// A file whose CONTENT is unusable is set aside HERE — archived with its reason, then -// removed — and the failure is reported. Leaving it in place would re-read the same -// broken file every five seconds forever, which is the one behaviour a watcher must -// never have (§10.5, failure test 9). -func (s *Source) read(ctx context.Context) (*ports.Batch, error) { - // A copy still in flight means the previous batch was never acknowledged — a file - // nobody could delete, read again five seconds later. It is thrown away rather than - // left behind: keeping it would hold an open handle per reading, and half a file in - // the archive directory is worse than no file at all, because somebody would - // eventually re-import it. - s.take().Discard() - - file, err := os.Open(s.Path()) - if err != nil { - s.stability.Forget() - return nil, fmt.Errorf("localdrop : ouverture de %s : %w", s.Path(), err) - } - defer file.Close() - - pending, err := s.archive.Begin(s.fileName) - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue impossible.", err.Error()) - } - - options := s.parse - options.Now = s.clock.Now() - batch, err := csvodoo.Parse(io.TeeReader(file, pending), options) - if err != nil { - file.Close() - s.refuse(ctx, pending, err) - return nil, err - } - s.keep(pending) - return batch, nil -} - -// keep stores the copy in flight, or throws it away when the source was closed while -// the file was being parsed — the shutdown landing in the middle of a reading. -func (s *Source) keep(pending *catalog.Pending) { - s.mu.Lock() - if s.closed { - s.mu.Unlock() - pending.Discard() - return - } - s.pending = pending - s.mu.Unlock() -} - -// take removes the copy in flight and hands it over, so that exactly one caller ever -// commits or discards it. -func (s *Source) take() *catalog.Pending { - s.mu.Lock() - defer s.mu.Unlock() - pending := s.pending - s.pending = nil - return pending -} - -// isClosed reports a source that has been shut down. -func (s *Source) isClosed() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.closed -} - -// refuse archives a file nothing could be made of, says why next to it, counts the -// failure against its CONTENT, and removes it from the drop directory. -// -// The removal is what stops the watcher re-reading the same broken file every five -// seconds for ever; the count is what turns the third refusal of the same content into -// the red light of §10.5, and the copy plus its .reason.txt are what a volunteer opens -// afterwards to find out what happened without a database. -func (s *Source) refuse(ctx context.Context, pending *catalog.Pending, cause error) { - s.stability.Forget() - archived, err := pending.Commit() - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue refusé impossible.", err.Error()) - } - if err := s.archive.Explain(archived, "ERR-CAT-03", cause.Error()); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Motif du refus non écrit.", err.Error()) - } - entry, counted := s.quarantine.Count(ctx, cause) - if err := s.remove(s.Path()); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Fichier de catalogue refusé non supprimé.", err.Error()) - return - } - // The light only goes red once the SAME CONTENT has been refused often enough: a - // producer who fixes the file and drops it again must not find a station that has - // already given up on it. - level := domain.LevelWarn - if counted && entry.FailureCount >= s.quarantine.Threshold() { - level = domain.LevelError - } - s.log.Technical(level, "catalog", "ERR-CAT-03", - "Catalogue refusé, fichier mis de côté.", archived) -} - -// Acknowledge names the archived copy and THEN removes the source file. -// -// The removal is the acknowledgement (ADR-004). It is a copy followed by a remove and -// never an os.Rename: between a network share and the local disk, Rename fails with -// ERROR_NOT_SAME_DEVICE / EXDEV, which would leave the file in place and loop the -// import for ever (§10.1). -func (s *Source) Acknowledge(_ context.Context, batch *ports.Batch, result ports.BatchResult) error { - pending := s.take() - s.stability.Forget() - - archived, err := pending.Commit() - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue impossible.", err.Error()) - } - if result.Result == domain.ImportRejected || result.Result == domain.ImportFailed { - if err := s.archive.Explain(archived, result.Code, result.Reason); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Motif du refus non écrit.", err.Error()) - } - } - - if err := s.remove(s.Path()); err != nil { - // ERR-CAT-05, amber, and it quarantines NOTHING: the catalog this file - // carried is in service. Naming the account is what makes the message - // actionable on a Windows service (§10.5). - return fmt.Errorf("%w : droits en écriture manquants sur %s (lot %s) : %w", - catalog.ErrNotAcknowledged, s.directory, batch.ID, err) - } - s.log.Technical(domain.LevelInfo, "catalog", "", - "Catalogue acquitté, fichier supprimé.", archived) - return nil -} - -// Close stops watching. It is idempotent, and it throws away a copy in flight rather -// than leaving half a file in the archive directory. -func (s *Source) Close() error { - s.mu.Lock() - s.closed = true - pending := s.pending - s.pending = nil - s.mu.Unlock() - - pending.Discard() - return nil -} - // Descriptor is what the administration screen builds its form from, and what // Config.Validate checks catalog.options against (control 9). // diff --git a/internal/catalog/localdrop/localdrop_test.go b/internal/catalog/localdrop/localdrop_test.go index 7fe066d..69d6c5c 100644 --- a/internal/catalog/localdrop/localdrop_test.go +++ b/internal/catalog/localdrop/localdrop_test.go @@ -2,14 +2,10 @@ package localdrop import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" - "fmt" "os" "path/filepath" - "strconv" "strings" "testing" "time" @@ -23,6 +19,15 @@ import ( // The tests of this file are INTERNAL to the package for one reason, and it is named // on the `remove` field: « the file could not be deleted » has no portable // reproduction, so the removal is a seam. +// +// The bench every test of this package drops a file into, and the rule that decides +// WHEN that file is read: a catalog is offered once it has stopped moving, and never +// before. Next blocks on the injected clock, ends with its context, and a source that +// was closed opens no further copy. +// +// What happens AFTER the batch is acknowledged is in acknowledge_test.go, what a +// refused content is counted as in quarantine_test.go, and what a configuration builds +// in build_test.go. // t0 is the instant the fake clock starts at. Nothing here reads a wall clock. var t0 = time.Date(2026, 7, 24, 15, 38, 12, 0, time.UTC) @@ -203,153 +208,6 @@ func TestStablePollsBelowTwoIsRaisedToTwo(t *testing.T) { } } -// TestAcknowledgingArchivesThenRemoves — and the removal IS the acknowledgement -// (ADR-004, §10.1-7). -func TestAcknowledgingArchivesThenRemoves(t *testing.T) { - source, _ := station(t, "") - drop(t, source, aCatalog) - source.poll(context.Background()) - batch, err := source.poll(context.Background()) - if err != nil || batch == nil { - t.Fatalf("lecture : %v", err) - } - - if err := source.Acknowledge(context.Background(), batch, - ports.BatchResult{Result: domain.ImportApplied}); err != nil { - t.Fatalf("acquittement : %v", err) - } - if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { - t.Errorf("le fichier source est toujours là : l'acquittement EST la suppression") - } - - names := archives(t, source) - if len(names) != 1 || names[0] != "flv_2-2026-07-24T15-38-12.csv" { - t.Fatalf("archives %v, attendu flv_2-2026-07-24T15-38-12.csv", names) - } - kept, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(source.Path())), "archives", names[0])) - if err != nil { - t.Fatalf("lecture de l'archive : %v", err) - } - if string(kept) != aCatalog { - t.Error("l'archive ne porte pas les octets qui ont été analysés") - } -} - -// TestARejectedBatchLeavesAReasonNextToItsCopy is the `.reason.txt` of failure test 9. -func TestARejectedBatchLeavesAReasonNextToItsCopy(t *testing.T) { - source, _ := station(t, "") - drop(t, source, aCatalog) - source.poll(context.Background()) - batch, _ := source.poll(context.Background()) - - err := source.Acknowledge(context.Background(), batch, ports.BatchResult{ - Result: domain.ImportRejected, Code: "ERR-CAT-03", - Reason: "42 % de produits pesables en moins que la veille", - }) - if err != nil { - t.Fatalf("acquittement : %v", err) - } - names := archives(t, source) - if len(names) != 2 { - t.Fatalf("archives %v, attendu la copie ET son motif", names) - } - reason, err := os.ReadFile(filepath.Join(filepath.Dir(filepath.Dir(source.Path())), - "archives", "flv_2-2026-07-24T15-38-12.reason.txt")) - if err != nil { - t.Fatalf("lecture du motif : %v", err) - } - for _, expected := range []string{"ERR-CAT-03", "pesables en moins", "2026-07-24"} { - if !strings.Contains(string(reason), expected) { - t.Errorf("le motif ne contient pas %q : %s", expected, reason) - } - } - // A refused batch is acknowledged all the same: leaving the file would re-offer - // the same refused content every five seconds. - if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { - t.Error("le fichier d'un lot refusé est resté en place") - } -} - -// TestAFileThatCannotBeDeletedIsAmberAndNotQuarantined is failure test 11. -func TestAFileThatCannotBeDeletedIsAmberAndNotQuarantined(t *testing.T) { - source, _ := station(t, "") - source.remove = func(string) error { return os.ErrPermission } - drop(t, source, aCatalog) - source.poll(context.Background()) - batch, _ := source.poll(context.Background()) - - err := source.Acknowledge(context.Background(), batch, - ports.BatchResult{Result: domain.ImportApplied}) - if !errors.Is(err, catalog.ErrNotAcknowledged) { - t.Fatalf("erreur %v, attendu ERR-CAT-05", err) - } - if !errors.Is(err, os.ErrPermission) { - t.Errorf("l'erreur ne porte pas sa cause : %v", err) - } - if !strings.Contains(err.Error(), source.directory) { - t.Errorf("le message ne nomme pas le répertoire fautif : %v", err) - } - // It is a failure of the ACKNOWLEDGEMENT, never of the content: the catalog this - // file carried is in service, and nothing is quarantined. - if errors.Is(err, catalog.ErrContent) { - t.Error("un fichier non supprimable est compté comme un échec de contenu") - } - if names := archives(t, source); len(names) != 1 { - t.Errorf("archives %v : la copie doit exister même si la source survit", names) - } -} - -// TestAnUnusableFileIsSetAsideWithItsReasonAndRemoved is failure test 9 on the source -// side: three drops of the same broken content must not spin the watcher. -func TestAnUnusableFileIsSetAsideWithItsReasonAndRemoved(t *testing.T) { - source, _ := station(t, "") - for attempt := 1; attempt <= 3; attempt++ { - drop(t, source, "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") - source.poll(context.Background()) - batch, err := source.poll(context.Background()) - if batch != nil { - t.Fatalf("essai %d : un contenu inexploitable a produit un lot", attempt) - } - if !errors.Is(err, catalog.ErrContent) { - t.Fatalf("essai %d : erreur %v, attendu ERR-CAT-03", attempt, err) - } - if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("essai %d : le fichier refusé est resté ; la scrutation le relirait "+ - "toutes les cinq secondes", attempt) - } - } - // Three copies and three reasons, so somebody can see it happened three times. - if names := archives(t, source); len(names) != 6 { - t.Errorf("archives %v, attendu trois copies et trois motifs", names) - } -} - -// TestTheArchiveIsBounded on both criteria (§11.2: max_archives, archive_days). -func TestTheArchiveIsBounded(t *testing.T) { - source, clock := station(t, `{"max_archives": 3}`) - for i := 0; i < 5; i++ { - drop(t, source, aCatalog) - source.poll(context.Background()) - batch, err := source.poll(context.Background()) - if err != nil || batch == nil { - t.Fatalf("import %d : %v", i, err) - } - if err := source.Acknowledge(context.Background(), batch, - ports.BatchResult{Result: domain.ImportApplied}); err != nil { - t.Fatalf("acquittement %d : %v", i, err) - } - clock.Advance(time.Minute) - } - names := archives(t, source) - if len(names) != 3 { - t.Fatalf("%d archives conservées, attendu 3 : %v", len(names), names) - } - // The three most RECENT ones: the name carries the instant. - if names[0] != "flv_2-2026-07-24T15-40-12.csv" { - t.Errorf("la plus ancienne conservée est %q", names[0]) - } -} - // TestNextBlocksOnTheInjectedClockAndReturnsTheBatch — no sleep anywhere, and the // whole scenario runs in microseconds (§16.4). func TestNextBlocksOnTheInjectedClockAndReturnsTheBatch(t *testing.T) { @@ -435,118 +293,6 @@ func TestClosingDiscardsACopyInFlight(t *testing.T) { } } -// TestTheDescriptorMatchesTheShippedConfiguration: the schema this source declares is -// what Config.Validate checks catalog.options against (control 9). -func TestTheDescriptorMatchesTheShippedConfiguration(t *testing.T) { - descriptor := Descriptor() - if descriptor.ID != domain.CatalogSourceLocalDrop || descriptor.Label == "" || descriptor.New == nil { - t.Fatalf("descripteur incomplet : %+v", descriptor) - } - for _, forbidden := range []string{"url", "username", "password"} { - for _, option := range descriptor.Options { - if option.Key == forbidden { - t.Errorf("le dépôt local déclare %q : un répertoire qu'on possède n'a "+ - "aucun secret à porter (§10.1)", forbidden) - } - } - } -} - -// TestASourceRefusesToBeBuiltWithoutWhatItNeeds. -// -// Both refusals are composition mistakes with no operator input in them, and both are -// worth a sentence rather than a nil pointer three seconds later. -func TestASourceRefusesToBeBuiltWithoutWhatItNeeds(t *testing.T) { - for _, c := range []struct { - what string - config catalog.SourceConfig - says string - }{ - {"sans horloge", catalog.SourceConfig{DataDir: t.TempDir()}, "horloge"}, - {"sans répertoire de données", - catalog.SourceConfig{Clock: fake.NewClock(t0)}, "répertoire de données"}, - } { - if _, err := New(c.config); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s : %v", c.what, err) - } - } -} - -// TestADropDirectoryThatCannotBeCreatedIsNamed: an installation whose data directory -// is a file is a mistake somebody has to be told about, in the terms of the path. -func TestADropDirectoryThatCannotBeCreatedIsNamed(t *testing.T) { - root := t.TempDir() - inTheWay := filepath.Join(root, "catalog") - if err := os.WriteFile(inTheWay, []byte("un fichier là où un répertoire est attendu"), 0o644); err != nil { - t.Fatalf("préparation : %v", err) - } - _, err := New(catalog.SourceConfig{ - StationNumber: 2, DataDir: root, Clock: fake.NewClock(t0), - }) - if err == nil { - t.Fatal("la source a été construite sur un répertoire impossible") - } - if !strings.Contains(err.Error(), inTheWay) { - t.Errorf("le message ne nomme pas le chemin fautif : %v", err) - } -} - -// TestAnUnreadableDropDirectoryIsSaidAndTheWatchGoesOn: a share that blinks is not a -// reason to stop watching (§10.1). -func TestAnUnreadableDropDirectoryIsSaidAndTheWatchGoesOn(t *testing.T) { - source, _ := station(t, "") - journal := &recorder{} - source.log = journal - // A DIRECTORY where the file is expected: os.Stat succeeds, os.Open succeeds and - // the read fails — the same shape as a share that answers half way. - if err := os.Mkdir(source.Path(), 0o755); err != nil { - t.Fatalf("préparation : %v", err) - } - source.poll(context.Background()) - batch, err := source.poll(context.Background()) - if batch != nil { - t.Fatalf("un répertoire a été lu comme un catalogue : %v", batch) - } - if err == nil { - t.Fatal("la lecture d'un répertoire n'a rien signalé") - } - // It was set aside like any unusable content, with its reason, and the watch is - // usable afterwards: the real file lands later and is read. - if names := archives(t, source); len(names) == 0 { - t.Error("rien n'a été mis de côté") - } - drop(t, source, aCatalog) - source.poll(context.Background()) - if batch, err := source.poll(context.Background()); batch == nil { - t.Fatalf("la scrutation ne s'est pas remise : %v", err) - } -} - -// TestTheTechnicalLogIsNeverNil: no driver checks for one (ADR-013). -func TestTheTechnicalLogIsNeverNil(t *testing.T) { - source, _ := station(t, "") - if source.log == nil { - t.Fatal("la source a gardé un journal nil") - } - source.log.Technical("info", "catalog", "", "message", "détail") -} - -// TestAnAcknowledgementWithNoCopyInFlightStillRemovesTheFile. -// -// It is the state after a restart: the process read nothing, and the file of an -// import that was applied before the crash must still be able to leave. -func TestAnAcknowledgementWithNoCopyInFlightStillRemovesTheFile(t *testing.T) { - source, _ := station(t, "") - drop(t, source, aCatalog) - if err := source.Acknowledge(context.Background(), &ports.Batch{ID: "sha"}, - ports.BatchResult{Result: domain.ImportApplied}); err != nil { - t.Fatalf("acquittement : %v", err) - } - if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { - t.Error("le fichier est resté") - } -} - // recorder keeps what the source said, so a test can read a level and a code. type recorder struct { entries []entry @@ -560,193 +306,6 @@ func (r *recorder) Technical(level, source, code, message, detail string) { r.entries = append(r.entries, entry{level, source, code, message, detail}) } -// TestTheFactoryOfTheRegistryBuildsAWatchingSource: the descriptor is what -// cmd/openscale/drivers.go registers, so the one line of §5.2 is exercised here. -func TestTheFactoryOfTheRegistryBuildsAWatchingSource(t *testing.T) { - journal := &recorder{} - built, err := Descriptor().New(catalog.SourceConfig{ - Catalog: domain.CatalogConfig{FallbackCategory: "other"}, - StationNumber: 4, - DataDir: t.TempDir(), - Clock: fake.NewClock(t0), - Log: journal, - }) - if err != nil { - t.Fatalf("construction par la fabrique : %v", err) - } - defer built.Close() - - source, ok := built.(*Source) - if !ok { - t.Fatalf("la fabrique a rendu un %T", built) - } - if filepath.Base(source.Path()) != "flv_4.csv" { - t.Errorf("le poste 4 surveille %q", filepath.Base(source.Path())) - } - if source.log != journal { - t.Error("le journal technique injecté n'a pas été retenu") - } -} - -// TestNextSurfacesAContentFailureSoThatItIsJournalled: the station logs ERR-CAT-03 -// and calls Next again — the file is already gone, so it does not spin (§10.5). -func TestNextSurfacesAContentFailureSoThatItIsJournalled(t *testing.T) { - source, clock := station(t, "") - drop(t, source, "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") - - done := make(chan error, 1) - go func() { - _, err := source.Next(context.Background()) - done <- err - }() - - deadline := time.After(2 * time.Second) - for { - select { - case err := <-done: - if !errors.Is(err, catalog.ErrContent) { - t.Fatalf("Next a rendu %v, attendu ERR-CAT-03", err) - } - if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { - t.Error("le fichier refusé est resté : la scrutation suivante le relirait") - } - return - case <-deadline: - t.Fatal("Next n'a rien rendu") - default: - clock.Advance(5 * time.Second) - } - } -} - -// book is the quarantine table reduced to the two calls §10.5 makes of it. -// -// A map rather than the real SQLite one, because what is under test here is WHICH -// failures reach the counter — the arithmetic is exercised against the real table by -// failure test 9, end to end. -type book struct { - counted map[string]int - codes map[string]string -} - -func newBook() *book { - return &book{counted: map[string]int{}, codes: map[string]string{}} -} - -// RecordContentFailure counts one failure against a content. -func (b *book) RecordContentFailure(_ context.Context, sha, code, reason string) (domain.QuarantineEntry, error) { - b.counted[sha]++ - b.codes[sha] = code - return domain.QuarantineEntry{SHA256: sha, FailureCount: b.counted[sha], Code: code, Reason: reason}, nil -} - -// Quarantine reports what stands against a content. -func (b *book) Quarantine(_ context.Context, sha string) (domain.QuarantineEntry, error) { - count, seen := b.counted[sha] - if !seen { - return domain.QuarantineEntry{}, errors.New("contenu jamais refusé") - } - return domain.QuarantineEntry{SHA256: sha, FailureCount: count, Code: b.codes[sha]}, nil -} - -// counting builds a source whose refusals are counted, at station number 2. -func counting(t *testing.T, options string) (*Source, *book, *recorder) { - t.Helper() - ledger, journal := newBook(), &recorder{} - source, err := New(catalog.SourceConfig{ - Catalog: domain.CatalogConfig{ - Options: driverOptions(t, options), - Images: domain.ImagesConfig{Source: domain.ImageSourceCSV}, - FallbackCategory: "other", - }, - StationNumber: 2, - DataDir: t.TempDir(), - Clock: fake.NewClock(t0), - Log: journal, - Quarantine: ledger, - }) - if err != nil { - t.Fatalf("construction de la source : %v", err) - } - t.Cleanup(func() { source.Close() }) - return source, ledger, journal -} - -// TestARefusedContentIsCountedUnderItsOwnDigest. -// -// The quarantine of §10.5 is indexed by sha256, so the refusal has to carry the digest -// of what it refused: three drops of the same broken content must reach three, and the -// name of the file — identical every night — must play no part in it. -func TestARefusedContentIsCountedUnderItsOwnDigest(t *testing.T) { - source, ledger, journal := counting(t, "") - broken := "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n" - sum := sha256.Sum256([]byte(broken)) - sha := hex.EncodeToString(sum[:]) - - for attempt := 1; attempt <= 3; attempt++ { - drop(t, source, broken) - source.poll(context.Background()) - if _, err := source.poll(context.Background()); !errors.Is(err, catalog.ErrContent) { - t.Fatalf("essai %d : erreur %v", attempt, err) - } - if ledger.counted[sha] != attempt { - t.Fatalf("essai %d : %d échec(s) comptés sous %s", attempt, ledger.counted[sha], sha) - } - } - if ledger.codes[sha] != "ERR-CAT-03" { - t.Errorf("code %q, attendu ERR-CAT-03", ledger.codes[sha]) - } - if len(ledger.counted) != 1 { - t.Errorf("%d contenus comptés, attendu un seul : le compteur suit les octets, "+ - "pas le nom du fichier", len(ledger.counted)) - } - // The light goes red on the third refusal and not before: a producer who fixes the - // file after one bad export must not find a station that has already given up. - levels := make([]string, 0, 3) - for _, e := range journal.entries { - if e.code == "ERR-CAT-03" && e.message == "Catalogue refusé, fichier mis de côté." { - levels = append(levels, e.level) - } - } - want := []string{domain.LevelWarn, domain.LevelWarn, domain.LevelError} - if len(levels) != 3 || levels[0] != want[0] || levels[1] != want[1] || levels[2] != want[2] { - t.Errorf("niveaux %v, attendu %v", levels, want) - } -} - -// TestAFileThatCannotBeDeletedReachesNoCounter is the second half of the trap of §16.2 -// line 11, and this is where the REAL Acknowledge runs. -// -// The removal is the only thing injected, because no portable file system produces a -// file that can be read and not deleted. Everything else — the parse, the archive, the -// error, the counter that must stay at zero — is the shipped code. -func TestAFileThatCannotBeDeletedReachesNoCounter(t *testing.T) { - source, ledger, journal := counting(t, "") - source.remove = func(string) error { return os.ErrPermission } - drop(t, source, aCatalog) - source.poll(context.Background()) - batch, err := source.poll(context.Background()) - if err != nil || batch == nil { - t.Fatalf("lecture : %v", err) - } - - err = source.Acknowledge(context.Background(), batch, - ports.BatchResult{Result: domain.ImportApplied}) - if !errors.Is(err, catalog.ErrNotAcknowledged) { - t.Fatalf("erreur %v, attendu ERR-CAT-05", err) - } - if len(ledger.counted) != 0 { - t.Fatalf("%d contenu(s) comptés : une suppression impossible n'est PAS un échec "+ - "de contenu, et un feu rouge qui se déclenche à tort est le pire ennemi de "+ - "l'exploitation", len(ledger.counted)) - } - for _, e := range journal.entries { - if e.code == "ERR-CAT-03" { - t.Errorf("ERR-CAT-03 journalisé pour un fichier parfaitement lisible : %+v", e) - } - } -} - // TestReadingTwiceWithoutAnAcknowledgementLeavesNothingBehind. // // A file nobody could delete is read again five seconds later, for ever. Each reading @@ -797,231 +356,3 @@ func TestAClosedSourceOpensNoFurtherCopy(t *testing.T) { t.Errorf("le fichier a été touché par une source fermée : %v", err) } } - -// truncated is a file cut off in mid-flight: eight products, then two lines that are -// not products at all. Eight readable out of ten is 80 %, under the 90 % of §10.4a. -func truncated() string { - file := "\"id\";\"nom\";\"code-barre\";\"prix\";\"categorie\";\"unite\";\"image\"\r\n" - for i := 0; i < 8; i++ { - file += fmt.Sprintf("\"%d\";\"LENTILLES VERTES %d\";\"0493171000007\";\"7.89\";\"V\";\"kg\";\"\"\r\n", - 20+i, i) - } - return file + "\"90\";\"COUPE EN PLEIN VOL\"\r\n\"91\";\"COUPE AUSSI\"\r\n" -} - -// TestAFileRefusedByTheAbsoluteGuardIsCountedToo. -// -// A file can be refused for two very different reasons — it does not parse at all, or -// too few of its lines are products — and BOTH are content failures of §10.5. The -// second one only shows up after the whole file has been read, so it is the one whose -// digest is easiest to lose; without it the same truncated export could be re-dropped -// for ever without the counter ever reaching three. -func TestAFileRefusedByTheAbsoluteGuardIsCountedToo(t *testing.T) { - source, ledger, _ := counting(t, "") - content := truncated() - sum := sha256.Sum256([]byte(content)) - sha := hex.EncodeToString(sum[:]) - - drop(t, source, content) - source.poll(context.Background()) - _, err := source.poll(context.Background()) - if !errors.Is(err, catalog.ErrContent) { - t.Fatalf("erreur %v, attendu un échec de contenu", err) - } - if !strings.Contains(err.Error(), "illisible") { - t.Errorf("le motif ne dit pas ce qui manque : %v", err) - } - if ledger.counted[sha] != 1 { - t.Fatalf("compté %v, attendu un échec sous %s : un lot refusé par le garde "+ - "ABSOLU est un échec de contenu comme un autre", ledger.counted, sha) - } -} - -// TestAFileRefusedBeforeItsFirstRowIsCountedToo closes the last hole of §10.5. -// -// A refusal can happen before a single product has been read — an empty file, or one -// past the ceiling of §10.1 — and those are the ones whose digest is easiest to lose, -// because there is no batch to hang it on. Without them the same unusable file could be -// re-dropped for ever without the counter ever reaching three. -// -// The digest of a file past the ceiling covers what was READ and not the whole file, -// which is what catalog.ContentError says it is: it still identifies the thing that -// keeps coming back, which is all §10.5 asks of it. -func TestAFileRefusedBeforeItsFirstRowIsCountedToo(t *testing.T) { - for _, c := range []struct { - what string - options string - content string - read int - says string - }{ - {"fichier vide", "", "", 0, "vide"}, - {"fichier au-delà du plafond", `{"max_file_size_mb": 1}`, - strings.Repeat("x", 1<<20+64), 1<<20 + 1, "plafond"}, - } { - t.Run(c.what, func(t *testing.T) { - source, ledger, _ := counting(t, c.options) - sum := sha256.Sum256([]byte(c.content)[:c.read]) - sha := hex.EncodeToString(sum[:]) - - drop(t, source, c.content) - source.poll(context.Background()) - _, err := source.poll(context.Background()) - if !errors.Is(err, catalog.ErrContent) { - t.Fatalf("erreur %v, attendu un échec de contenu", err) - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("le motif ne dit pas %q : %v", c.says, err) - } - if ledger.counted[sha] != 1 { - t.Fatalf("compté %v, attendu un échec sous %s", ledger.counted, sha) - } - }) - } -} - -// TestAnEmptyDirectoryOptionKeepsTheStationDirectory is the shipped case: nothing in -// catalog.options, and the source watches the directory the service owns and creates. -func TestAnEmptyDirectoryOptionKeepsTheStationDirectory(t *testing.T) { - data := t.TempDir() - got, owned := Directory(catalog.SourceConfig{DataDir: data}) - want := filepath.Join(data, "catalog", "incoming") - if got != want { - t.Errorf("répertoire = %q, attendu %q", got, want) - } - if !owned { - t.Error("le répertoire par défaut appartient au service : il le crée lui-même") - } -} - -// TestANamedDirectoryIsWatchedAndNotOwned: somebody named a directory, so the service -// watches it and does NOT create it. A typo would otherwise build a tree nobody watches. -func TestANamedDirectoryIsWatchedAndNotOwned(t *testing.T) { - chosen := t.TempDir() - c := catalog.SourceConfig{ - DataDir: t.TempDir(), - Catalog: domain.CatalogConfig{Options: driverOptions(t, - `{"directory":`+strconv.Quote(chosen)+`}`)}, - } - got, owned := Directory(c) - if got != filepath.Clean(chosen) { - t.Errorf("répertoire = %q, attendu %q", got, filepath.Clean(chosen)) - } - if owned { - t.Error("un répertoire nommé par un humain n'appartient pas au service") - } -} - -// TestABlankDirectoryOptionIsNoDirectoryAtAll: a field somebody opened and left with a -// space in it must not send the station watching " ". -func TestABlankDirectoryOptionIsNoDirectoryAtAll(t *testing.T) { - data := t.TempDir() - c := catalog.SourceConfig{ - DataDir: data, - Catalog: domain.CatalogConfig{Options: driverOptions(t, `{"directory":" "}`)}, - } - got, owned := Directory(c) - if got != filepath.Join(data, "catalog", "incoming") || !owned { - t.Errorf("un champ blanc doit valoir le répertoire du poste, obtenu %q (owned=%v)", got, owned) - } -} - -// TestANamedDirectoryThatIsAbsentIsRefusedAtBuild: New does not create it, and says so -// rather than watching a path that will never receive anything. -func TestANamedDirectoryThatIsAbsentIsRefusedAtBuild(t *testing.T) { - absent := filepath.Join(t.TempDir(), "jamais-monte") - _, err := New(catalog.SourceConfig{ - DataDir: t.TempDir(), - Clock: fake.NewClock(t0), - Catalog: domain.CatalogConfig{Options: driverOptions(t, - `{"directory":`+strconv.Quote(absent)+`}`)}, - }) - if err == nil { - t.Fatal("un répertoire nommé et absent doit être refusé, pas créé") - } - if _, statErr := os.Stat(absent); statErr == nil { - t.Error("le service a créé un répertoire qu'un humain avait nommé") - } -} - -// TestANamedDirectoryIsTheOneTheStationReallyWatches closes the loop between the option -// and the file: a directory that is merely remembered and never watched would read right -// on the administration screen and receive nothing for ever. -func TestANamedDirectoryIsTheOneTheStationReallyWatches(t *testing.T) { - chosen := t.TempDir() - source, err := New(catalog.SourceConfig{ - StationNumber: 2, - DataDir: t.TempDir(), - Clock: fake.NewClock(t0), - Catalog: domain.CatalogConfig{ - Options: driverOptions(t, `{"directory":`+strconv.Quote(chosen)+`}`), - Images: domain.ImagesConfig{Source: domain.ImageSourceCSV}, - FallbackCategory: "other", - }, - }) - if err != nil { - t.Fatalf("construction de la source : %v", err) - } - t.Cleanup(func() { source.Close() }) - - if want := filepath.Join(chosen, "flv_2.csv"); source.Path() != want { - t.Fatalf("le poste surveille %q, attendu %q", source.Path(), want) - } - drop(t, source, aCatalog) - source.poll(context.Background()) - if batch, err := source.poll(context.Background()); batch == nil { - t.Fatalf("le fichier déposé dans le répertoire nommé n'a pas été lu : %v", err) - } -} - -// TestTheDescriptorDeclaresTheDropDirectory: an option the schema does not carry is -// refused by control 9 long before it could ever be honoured, and an option whose USE the -// schema does not carry is one the drop probe of control 46 will never look at. -func TestTheDescriptorDeclaresTheDropDirectory(t *testing.T) { - for _, option := range Descriptor().Options { - if option.Key == DirectoryOption { - if option.Kind != domain.OptionText { - t.Errorf("le répertoire est déclaré %q, attendu du texte", option.Kind) - } - if option.Use != domain.UseDropDirectory { - t.Errorf("le répertoire n'est pas déclaré comme répertoire de dépôt : "+ - "les contrôles 39 et 46 lisent cet usage, et rien d'autre ne leur dit "+ - "que %q nomme un répertoire", DirectoryOption) - } - return - } - } - t.Errorf("le descripteur ne déclare pas %q", DirectoryOption) -} - -// TestTheDomainActsOnTheUseThisPackageDeclares. -// -// The tie between the two sides USED to be control 47, which spelled `directory` inside -// internal/domain: one key written twice, and a third source that could not be added -// without editing the domain. The tie is now the SCHEMA — this package says which of its -// keys names a drop directory, and the controls act on that declaration without knowing -// any source by name (ADR-052). -// -// So what has to be proved is no longer that two spellings match, but that the -// declaration REACHES the control: an HTTP host typed into the drop path is refused -// (control 39, important-11) on a registry that carries nothing but this package's own -// descriptor. -func TestTheDomainActsOnTheUseThisPackageDeclares(t *testing.T) { - registry := catalog.NewRegistry() - registry.Register(Descriptor()) - registries := domain.Registries{CatalogSources: registry.Descriptors()} - - config := domain.Config{Catalog: domain.CatalogConfig{ - Type: domain.CatalogSourceLocalDrop, - Options: driverOptions(t, - `{"`+DirectoryOption+`":`+strconv.Quote("https://dav.example.org/partage")+`}`), - }} - - for _, fault := range config.Validate(registries) { - if fault.Field == "catalog.options."+DirectoryOption { - return - } - } - t.Fatalf("un hôte HTTP derrière %q n'est pas refusé : l'usage déclaré par ce paquet "+ - "n'atteint pas le contrôle 39", DirectoryOption) -} diff --git a/internal/catalog/localdrop/quarantine_test.go b/internal/catalog/localdrop/quarantine_test.go new file mode 100644 index 0000000..e57cc08 --- /dev/null +++ b/internal/catalog/localdrop/quarantine_test.go @@ -0,0 +1,264 @@ +package localdrop + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// A content this source could not turn into rows is counted under ITS OWN DIGEST, so +// that the same broken file arriving twice is one entry and not two. What is asserted +// here is the boundary: a failure of the CONTENT reaches the ledger, a failure of the +// FILE SYSTEM never does. + +// TestNextSurfacesAContentFailureSoThatItIsJournalled: the station logs ERR-CAT-03 +// and calls Next again — the file is already gone, so it does not spin (§10.5). +func TestNextSurfacesAContentFailureSoThatItIsJournalled(t *testing.T) { + source, clock := station(t, "") + drop(t, source, "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") + + done := make(chan error, 1) + go func() { + _, err := source.Next(context.Background()) + done <- err + }() + + deadline := time.After(2 * time.Second) + for { + select { + case err := <-done: + if !errors.Is(err, catalog.ErrContent) { + t.Fatalf("Next a rendu %v, attendu ERR-CAT-03", err) + } + if _, err := os.Stat(source.Path()); !errors.Is(err, os.ErrNotExist) { + t.Error("le fichier refusé est resté : la scrutation suivante le relirait") + } + return + case <-deadline: + t.Fatal("Next n'a rien rendu") + default: + clock.Advance(5 * time.Second) + } + } +} + +// book is the quarantine table reduced to the two calls §10.5 makes of it. +// +// A map rather than the real SQLite one, because what is under test here is WHICH +// failures reach the counter — the arithmetic is exercised against the real table by +// failure test 9, end to end. +type book struct { + counted map[string]int + codes map[string]string +} + +func newBook() *book { + return &book{counted: map[string]int{}, codes: map[string]string{}} +} + +// RecordContentFailure counts one failure against a content. +func (b *book) RecordContentFailure(_ context.Context, sha, code, reason string) (domain.QuarantineEntry, error) { + b.counted[sha]++ + b.codes[sha] = code + return domain.QuarantineEntry{SHA256: sha, FailureCount: b.counted[sha], Code: code, Reason: reason}, nil +} + +// Quarantine reports what stands against a content. +func (b *book) Quarantine(_ context.Context, sha string) (domain.QuarantineEntry, error) { + count, seen := b.counted[sha] + if !seen { + return domain.QuarantineEntry{}, errors.New("contenu jamais refusé") + } + return domain.QuarantineEntry{SHA256: sha, FailureCount: count, Code: b.codes[sha]}, nil +} + +// counting builds a source whose refusals are counted, at station number 2. +func counting(t *testing.T, options string) (*Source, *book, *recorder) { + t.Helper() + ledger, journal := newBook(), &recorder{} + source, err := New(catalog.SourceConfig{ + Catalog: domain.CatalogConfig{ + Options: driverOptions(t, options), + Images: domain.ImagesConfig{Source: domain.ImageSourceCSV}, + FallbackCategory: "other", + }, + StationNumber: 2, + DataDir: t.TempDir(), + Clock: fake.NewClock(t0), + Log: journal, + Quarantine: ledger, + }) + if err != nil { + t.Fatalf("construction de la source : %v", err) + } + t.Cleanup(func() { source.Close() }) + return source, ledger, journal +} + +// TestARefusedContentIsCountedUnderItsOwnDigest. +// +// The quarantine of §10.5 is indexed by sha256, so the refusal has to carry the digest +// of what it refused: three drops of the same broken content must reach three, and the +// name of the file — identical every night — must play no part in it. +func TestARefusedContentIsCountedUnderItsOwnDigest(t *testing.T) { + source, ledger, journal := counting(t, "") + broken := "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n" + sum := sha256.Sum256([]byte(broken)) + sha := hex.EncodeToString(sum[:]) + + for attempt := 1; attempt <= 3; attempt++ { + drop(t, source, broken) + source.poll(context.Background()) + if _, err := source.poll(context.Background()); !errors.Is(err, catalog.ErrContent) { + t.Fatalf("essai %d : erreur %v", attempt, err) + } + if ledger.counted[sha] != attempt { + t.Fatalf("essai %d : %d échec(s) comptés sous %s", attempt, ledger.counted[sha], sha) + } + } + if ledger.codes[sha] != "ERR-CAT-03" { + t.Errorf("code %q, attendu ERR-CAT-03", ledger.codes[sha]) + } + if len(ledger.counted) != 1 { + t.Errorf("%d contenus comptés, attendu un seul : le compteur suit les octets, "+ + "pas le nom du fichier", len(ledger.counted)) + } + // The light goes red on the third refusal and not before: a producer who fixes the + // file after one bad export must not find a station that has already given up. + levels := make([]string, 0, 3) + for _, e := range journal.entries { + if e.code == "ERR-CAT-03" && e.message == "Catalogue refusé, fichier mis de côté." { + levels = append(levels, e.level) + } + } + want := []string{domain.LevelWarn, domain.LevelWarn, domain.LevelError} + if len(levels) != 3 || levels[0] != want[0] || levels[1] != want[1] || levels[2] != want[2] { + t.Errorf("niveaux %v, attendu %v", levels, want) + } +} + +// TestAFileThatCannotBeDeletedReachesNoCounter is the second half of the trap of §16.2 +// line 11, and this is where the REAL Acknowledge runs. +// +// The removal is the only thing injected, because no portable file system produces a +// file that can be read and not deleted. Everything else — the parse, the archive, the +// error, the counter that must stay at zero — is the shipped code. +func TestAFileThatCannotBeDeletedReachesNoCounter(t *testing.T) { + source, ledger, journal := counting(t, "") + source.remove = func(string) error { return os.ErrPermission } + drop(t, source, aCatalog) + source.poll(context.Background()) + batch, err := source.poll(context.Background()) + if err != nil || batch == nil { + t.Fatalf("lecture : %v", err) + } + + err = source.Acknowledge(context.Background(), batch, + ports.BatchResult{Result: domain.ImportApplied}) + if !errors.Is(err, catalog.ErrNotAcknowledged) { + t.Fatalf("erreur %v, attendu ERR-CAT-05", err) + } + if len(ledger.counted) != 0 { + t.Fatalf("%d contenu(s) comptés : une suppression impossible n'est PAS un échec "+ + "de contenu, et un feu rouge qui se déclenche à tort est le pire ennemi de "+ + "l'exploitation", len(ledger.counted)) + } + for _, e := range journal.entries { + if e.code == "ERR-CAT-03" { + t.Errorf("ERR-CAT-03 journalisé pour un fichier parfaitement lisible : %+v", e) + } + } +} + +// truncated is a file cut off in mid-flight: eight products, then two lines that are +// not products at all. Eight readable out of ten is 80 %, under the 90 % of §10.4a. +func truncated() string { + file := "\"id\";\"nom\";\"code-barre\";\"prix\";\"categorie\";\"unite\";\"image\"\r\n" + for i := 0; i < 8; i++ { + file += fmt.Sprintf("\"%d\";\"LENTILLES VERTES %d\";\"0493171000007\";\"7.89\";\"V\";\"kg\";\"\"\r\n", + 20+i, i) + } + return file + "\"90\";\"COUPE EN PLEIN VOL\"\r\n\"91\";\"COUPE AUSSI\"\r\n" +} + +// TestAFileRefusedByTheAbsoluteGuardIsCountedToo. +// +// A file can be refused for two very different reasons — it does not parse at all, or +// too few of its lines are products — and BOTH are content failures of §10.5. The +// second one only shows up after the whole file has been read, so it is the one whose +// digest is easiest to lose; without it the same truncated export could be re-dropped +// for ever without the counter ever reaching three. +func TestAFileRefusedByTheAbsoluteGuardIsCountedToo(t *testing.T) { + source, ledger, _ := counting(t, "") + content := truncated() + sum := sha256.Sum256([]byte(content)) + sha := hex.EncodeToString(sum[:]) + + drop(t, source, content) + source.poll(context.Background()) + _, err := source.poll(context.Background()) + if !errors.Is(err, catalog.ErrContent) { + t.Fatalf("erreur %v, attendu un échec de contenu", err) + } + if !strings.Contains(err.Error(), "illisible") { + t.Errorf("le motif ne dit pas ce qui manque : %v", err) + } + if ledger.counted[sha] != 1 { + t.Fatalf("compté %v, attendu un échec sous %s : un lot refusé par le garde "+ + "ABSOLU est un échec de contenu comme un autre", ledger.counted, sha) + } +} + +// TestAFileRefusedBeforeItsFirstRowIsCountedToo closes the last hole of §10.5. +// +// A refusal can happen before a single product has been read — an empty file, or one +// past the ceiling of §10.1 — and those are the ones whose digest is easiest to lose, +// because there is no batch to hang it on. Without them the same unusable file could be +// re-dropped for ever without the counter ever reaching three. +// +// The digest of a file past the ceiling covers what was READ and not the whole file, +// which is what catalog.ContentError says it is: it still identifies the thing that +// keeps coming back, which is all §10.5 asks of it. +func TestAFileRefusedBeforeItsFirstRowIsCountedToo(t *testing.T) { + for _, c := range []struct { + what string + options string + content string + read int + says string + }{ + {"fichier vide", "", "", 0, "vide"}, + {"fichier au-delà du plafond", `{"max_file_size_mb": 1}`, + strings.Repeat("x", 1<<20+64), 1<<20 + 1, "plafond"}, + } { + t.Run(c.what, func(t *testing.T) { + source, ledger, _ := counting(t, c.options) + sum := sha256.Sum256([]byte(c.content)[:c.read]) + sha := hex.EncodeToString(sum[:]) + + drop(t, source, c.content) + source.poll(context.Background()) + _, err := source.poll(context.Background()) + if !errors.Is(err, catalog.ErrContent) { + t.Fatalf("erreur %v, attendu un échec de contenu", err) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("le motif ne dit pas %q : %v", c.says, err) + } + if ledger.counted[sha] != 1 { + t.Fatalf("compté %v, attendu un échec sous %s", ledger.counted, sha) + } + }) + } +} diff --git a/internal/catalog/localdrop/source.go b/internal/catalog/localdrop/source.go new file mode 100644 index 0000000..6712cae --- /dev/null +++ b/internal/catalog/localdrop/source.go @@ -0,0 +1,252 @@ +package localdrop + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "openscale/internal/catalog" + "openscale/internal/catalog/csvodoo" + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is the ports.CatalogSource contract as the station sees it: a batch +// offered only once the file has stopped moving, an acknowledgement that archives and +// THEN deletes — the deletion IS the acknowledgement — and the bookkeeping of the copy +// in flight. +// +// Nothing it decides belongs to catalog.Assemble: a source offers bytes, it never +// qualifies a catalog. + +// Name reports the registry key of this source. +func (s *Source) Name() string { return domain.CatalogSourceLocalDrop } + +// Describe reports the wording the administration screen shows permanently: the +// active source and the path it watches (§10.1). +func (s *Source) Describe() string { + return fmt.Sprintf("dépôt local, %s dans %s", s.fileName, s.directory) +} + +// Path reports the file this station watches for. +func (s *Source) Path() string { return filepath.Join(s.directory, s.fileName) } + +// Next blocks until a whole catalog is available, or until ctx is done. +// +// It does NOT touch the file: reading and acknowledging are separate, because a crash +// between the two must not lose an update for good and without a trace. +func (s *Source) Next(ctx context.Context) (*ports.Batch, error) { + tick, stop := s.clock.Ticker(s.interval) + defer stop() + for { + batch, err := s.poll(ctx) + if err != nil { + return nil, err + } + if batch != nil { + return batch, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-tick: + case <-s.wake: + // Somebody pressed « Recharger le catalogue » or dropped a file on the + // screen. The poll below is the SAME one the tick performs — same stability + // rule, same parser, same acknowledgement — because a button that read a + // file the watcher would have refused would be a second import path. + } + } +} + +// Wake asks the watch to poll NOW rather than at the next tick. +// +// It is what makes « Recharger le catalogue » (§14.4) do something on a station whose +// poll interval is five seconds, and it is what makes the drag-and-drop of a CSV take +// service in a second instead of in ten. It changes NOTHING about how the file is read. +func (s *Source) Wake() { + select { + case s.wake <- struct{}{}: + default: + // A poll is already asked for. Two are the same request. + } +} + +// poll looks once, and reads only a file that has stopped moving. +func (s *Source) poll(ctx context.Context) (*ports.Batch, error) { + if s.isClosed() { + return nil, nil + } + info, err := os.Stat(s.Path()) + switch { + case errors.Is(err, fs.ErrNotExist): + s.stability.Forget() + return nil, nil + case err != nil: + // A share that blinks is not a reason to stop watching: the loop keeps + // polling and the operator is told. + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-03", + "Répertoire de dépôt illisible.", err.Error()) + s.stability.Forget() + return nil, nil + } + if !s.stability.Observe(catalog.Stamp{Size: info.Size(), Modified: info.ModTime()}) { + return nil, nil + } + return s.read(ctx) +} + +// read parses the file and keeps a copy of the very bytes it parsed. +// +// A file whose CONTENT is unusable is set aside HERE — archived with its reason, then +// removed — and the failure is reported. Leaving it in place would re-read the same +// broken file every five seconds forever, which is the one behaviour a watcher must +// never have (§10.5, failure test 9). +func (s *Source) read(ctx context.Context) (*ports.Batch, error) { + // A copy still in flight means the previous batch was never acknowledged — a file + // nobody could delete, read again five seconds later. It is thrown away rather than + // left behind: keeping it would hold an open handle per reading, and half a file in + // the archive directory is worse than no file at all, because somebody would + // eventually re-import it. + s.take().Discard() + + file, err := os.Open(s.Path()) + if err != nil { + s.stability.Forget() + return nil, fmt.Errorf("localdrop : ouverture de %s : %w", s.Path(), err) + } + defer file.Close() + + pending, err := s.archive.Begin(s.fileName) + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue impossible.", err.Error()) + } + + options := s.parse + options.Now = s.clock.Now() + batch, err := csvodoo.Parse(io.TeeReader(file, pending), options) + if err != nil { + file.Close() + s.refuse(ctx, pending, err) + return nil, err + } + s.keep(pending) + return batch, nil +} + +// keep stores the copy in flight, or throws it away when the source was closed while +// the file was being parsed — the shutdown landing in the middle of a reading. +func (s *Source) keep(pending *catalog.Pending) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + pending.Discard() + return + } + s.pending = pending + s.mu.Unlock() +} + +// take removes the copy in flight and hands it over, so that exactly one caller ever +// commits or discards it. +func (s *Source) take() *catalog.Pending { + s.mu.Lock() + defer s.mu.Unlock() + pending := s.pending + s.pending = nil + return pending +} + +// isClosed reports a source that has been shut down. +func (s *Source) isClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +// refuse archives a file nothing could be made of, says why next to it, counts the +// failure against its CONTENT, and removes it from the drop directory. +// +// The removal is what stops the watcher re-reading the same broken file every five +// seconds for ever; the count is what turns the third refusal of the same content into +// the red light of §10.5, and the copy plus its .reason.txt are what a volunteer opens +// afterwards to find out what happened without a database. +func (s *Source) refuse(ctx context.Context, pending *catalog.Pending, cause error) { + s.stability.Forget() + archived, err := pending.Commit() + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue refusé impossible.", err.Error()) + } + if err := s.archive.Explain(archived, "ERR-CAT-03", cause.Error()); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Motif du refus non écrit.", err.Error()) + } + entry, counted := s.quarantine.Count(ctx, cause) + if err := s.remove(s.Path()); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Fichier de catalogue refusé non supprimé.", err.Error()) + return + } + // The light only goes red once the SAME CONTENT has been refused often enough: a + // producer who fixes the file and drops it again must not find a station that has + // already given up on it. + level := domain.LevelWarn + if counted && entry.FailureCount >= s.quarantine.Threshold() { + level = domain.LevelError + } + s.log.Technical(level, "catalog", "ERR-CAT-03", + "Catalogue refusé, fichier mis de côté.", archived) +} + +// Acknowledge names the archived copy and THEN removes the source file. +// +// The removal is the acknowledgement (ADR-004). It is a copy followed by a remove and +// never an os.Rename: between a network share and the local disk, Rename fails with +// ERROR_NOT_SAME_DEVICE / EXDEV, which would leave the file in place and loop the +// import for ever (§10.1). +func (s *Source) Acknowledge(_ context.Context, batch *ports.Batch, result ports.BatchResult) error { + pending := s.take() + s.stability.Forget() + + archived, err := pending.Commit() + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue impossible.", err.Error()) + } + if result.Result == domain.ImportRejected || result.Result == domain.ImportFailed { + if err := s.archive.Explain(archived, result.Code, result.Reason); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Motif du refus non écrit.", err.Error()) + } + } + + if err := s.remove(s.Path()); err != nil { + // ERR-CAT-05, amber, and it quarantines NOTHING: the catalog this file + // carried is in service. Naming the account is what makes the message + // actionable on a Windows service (§10.5). + return fmt.Errorf("%w : droits en écriture manquants sur %s (lot %s) : %w", + catalog.ErrNotAcknowledged, s.directory, batch.ID, err) + } + s.log.Technical(domain.LevelInfo, "catalog", "", + "Catalogue acquitté, fichier supprimé.", archived) + return nil +} + +// Close stops watching. It is idempotent, and it throws away a copy in flight rather +// than leaving half a file in the archive directory. +func (s *Source) Close() error { + s.mu.Lock() + s.closed = true + pending := s.pending + s.pending = nil + s.mu.Unlock() + + pending.Discard() + return nil +} diff --git a/internal/catalog/photo.go b/internal/catalog/photo.go index 92fac62..3d67683 100644 --- a/internal/catalog/photo.go +++ b/internal/catalog/photo.go @@ -16,6 +16,8 @@ import ( _ "image/jpeg" _ "image/png" + // Le quatrième format, qui ne vient pas de la bibliothèque standard : les exports de + // l'ERP en portent, et sans cet enregistrement leur en-tête ne serait lu par personne. _ "golang.org/x/image/bmp" "openscale/internal/domain" @@ -69,11 +71,11 @@ func (f photoFault) Error() string { return f.why } // everything it found: that is what refuses a field claiming three megabytes after 256 kB // have been read instead of after three megabytes have been allocated. The check here is // the second half of that guard, and it is the one that names the ceiling. -func describePhoto(content []byte, max int, seenAt time.Time) (domain.Image, error) { - if len(content) > max { +func describePhoto(content []byte, ceiling int, seenAt time.Time) (domain.Image, error) { + if len(content) > ceiling { return domain.Image{}, photoFault{tooLarge: true, why: fmt.Sprintf( "elle dépasse le plafond de %d ko une fois décodée, quand la plus grosse du "+ - "catalogue de référence en pèse 12", max>>10)} + "catalogue de référence en pèse 12", ceiling>>10)} } format, recognised := sniff(content) diff --git a/internal/catalog/photos_test.go b/internal/catalog/photos_test.go new file mode 100644 index 0000000..a29f390 --- /dev/null +++ b/internal/catalog/photos_test.go @@ -0,0 +1,163 @@ +package catalog_test + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" + + "openscale/internal/catalog" + "openscale/internal/domain" +) + +// The photos of an assembly, and the one rule that governs all of them: A PHOTO IS +// NEVER WORTH A PRODUCT. The same image on two products is one file, a photo the sink +// refuses leaves the product without its photo and never without its place in the grid, +// and a station configured to keep none opens none at all. + +// --- Les photos, qui sont les règles de §10.7 et non celles d'un format ---------- + +// TestTheSamePhotoOnTwoProductsIsOneFile. +// +// The sha IS the address, which is what turns 181 rows carrying a photo into 165 files +// written — and what makes a re-import write nothing at all (§10.7). +func TestTheSamePhotoOnTwoProductsIsOneFile(t *testing.T) { + shared := pngOf(t, 8, 8) + rows := manyGoodRows(t, 10) + rows[0].Image, rows[1].Image = shared, shared + sink := newCollecting() + + batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ + KeepPhotos: true, Images: sink}) + if err != nil { + t.Fatalf("Assemble : %v", err) + } + if len(batch.Images) != 1 { + t.Fatalf("%d image(s) dans le lot, attendu 1", len(batch.Images)) + } + if len(sink.put) != 1 { + t.Fatalf("%d empreinte(s) écrite(s), attendu 1", len(sink.put)) + } + for sha, times := range sink.put { + if times != 1 { + t.Errorf("la photo %s a été écrite %d fois", sha[:8], times) + } + } + if batch.Products[0].ImageSHA == "" || batch.Products[0].ImageSHA != batch.Products[1].ImageSHA { + t.Errorf("les deux produits ne partagent pas l'adresse : %q et %q", + batch.Products[0].ImageSHA, batch.Products[1].ImageSHA) + } +} + +// TestAPhotoRefusedLosesItsPhotoAndNeverItsProduct: les deux refus de §10.7 sont NON +// BLOQUANTS — le produit garde sa tuile dans les deux cas. +func TestAPhotoRefusedLosesItsPhotoAndNeverItsProduct(t *testing.T) { + for _, c := range []struct { + name string + image []byte + ceiling int + want string + }{ + {"au-delà du plafond", pngOf(t, 64, 64), 64, domain.FindingImageTooLarge}, + {"en-tête d'aucun format accepté", []byte("ceci n'est pas une image"), 0, + domain.FindingImageInvalid}, + } { + t.Run(c.name, func(t *testing.T) { + rows := manyGoodRows(t, 10) + rows[0].Image = c.image + batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ + KeepPhotos: true, MaxImageSize: c.ceiling}) + if err != nil { + t.Fatalf("Assemble : %v", err) + } + if len(batch.Products) != 10 || + batch.Products[0].Qualification != domain.Weighable { + t.Fatal("le produit a perdu sa tuile en même temps que sa photo") + } + if batch.Products[0].ImageSHA != "" || len(batch.Images) != 0 { + t.Fatal("une photo refusée a été adressée quand même") + } + if !hasCode(batch.Findings, c.want) { + t.Errorf("le refus n'est pas classé %s : %v", c.want, codesOf(batch.Findings)) + } + }) + } +} + +// TestAStationThatKeepsNoPhotoOpensNone: `catalog.images.source` à autre chose que les +// photos de la source, et rien n'est décodé ni écrit. +func TestAStationThatKeepsNoPhotoOpensNone(t *testing.T) { + rows := manyGoodRows(t, 10) + rows[0].Image = pngOf(t, 8, 8) + sink := newCollecting() + + batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ + KeepPhotos: false, Images: sink}) + if err != nil { + t.Fatalf("Assemble : %v", err) + } + if len(batch.Images) != 0 || len(sink.put) != 0 || batch.Products[0].ImageSHA != "" { + t.Fatal("une photo a été retenue sur un poste qui n'en garde aucune") + } +} + +// TestASinkThatRefusesLeavesTheProductWithoutItsPhoto: un disque plein dégrade le +// confort, jamais le service (principe 6 de §4). +func TestASinkThatRefusesLeavesTheProductWithoutItsPhoto(t *testing.T) { + rows := manyGoodRows(t, 10) + rows[0].Image = pngOf(t, 8, 8) + + batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{ + KeepPhotos: true, Images: &collecting{put: map[string]int{}, refuse: true}}) + if err != nil { + t.Fatalf("Assemble : %v", err) + } + if len(batch.Products) != 10 || batch.Products[0].ImageSHA != "" { + t.Fatal("le produit a suivi le sort de sa photo") + } + if !hasCode(batch.Findings, domain.FindingImageInvalid) { + t.Errorf("le refus du puits n'est pas signalé : %v", codesOf(batch.Findings)) + } +} + +// TestANilSinkCountsThePhotosAndKeepsNone: c'est exactement ce que veut une lecture à +// blanc du rapport d'import (§10.3 bis). +func TestANilSinkCountsThePhotosAndKeepsNone(t *testing.T) { + rows := manyGoodRows(t, 10) + rows[0].Image = pngOf(t, 8, 8) + + batch, err := catalog.Assemble(reads(rows...), catalog.AssembleOptions{KeepPhotos: true}) + if err != nil { + t.Fatalf("Assemble : %v", err) + } + if len(batch.Images) != 1 || batch.Products[0].ImageSHA == "" { + t.Fatal("un puits nul doit compter la photo et l'adresser sans l'écrire") + } +} + +// pngOf builds a PNG of the requested size, so that a test about a CEILING carries a +// real image rather than bytes that would be refused for their header instead. +func pngOf(t *testing.T, width, height int) []byte { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + // Pseudo-random noise, and both words matter. NOISE, because PNG squeezes a regular + // picture down to almost nothing and a test about a size ceiling would never reach + // it. PSEUDO-random, from a generator seeded here, because a test whose subject + // varies from one run to the next is a test that fails on somebody else's machine. + seed := uint32(1) + next := func() uint8 { + seed = seed*1664525 + 1013904223 + return uint8(seed >> 24) + } + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + canvas.Set(x, y, color.RGBA{R: next(), G: next(), B: next(), A: 255}) + } + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, canvas); err != nil { + t.Fatalf("encodage du PNG d'essai : %v", err) + } + return encoded.Bytes() +} diff --git a/internal/catalog/webdav/dav.go b/internal/catalog/webdav/dav.go new file mode 100644 index 0000000..1598101 --- /dev/null +++ b/internal/catalog/webdav/dav.go @@ -0,0 +1,213 @@ +package webdav + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "openscale/internal/catalog" + "openscale/internal/catalog/csvodoo" + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is WebDAV on the wire: the three verbs of §10.1 — PROPFIND for the size +// and the date, GET to read, DELETE as the acknowledgement — the one request helper +// that carries the credentials and refuses a redirection off the declared host, and +// the XML a listing comes back as. + +// propfind asks for the size and the date of the watched file. +// +// Depth 1 on the FOLDER rather than Depth 0 on the file, because that is the request +// a WebDAV server always answers the same way, and because a 404 on a folder listing +// tells an operator something a 404 on a file does not: the path is wrong. +func (s *Source) propfind(ctx context.Context) (catalog.Stamp, bool, error) { + const body = `` + + `` + + `` + + `` + + response, err := s.do(ctx, "PROPFIND", s.folder, strings.NewReader(body), func(r *http.Request) { + r.Header.Set("Depth", "1") + r.Header.Set("Content-Type", "application/xml; charset=utf-8") + }) + if err != nil { + return catalog.Stamp{}, false, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusMultiStatus && response.StatusCode != http.StatusOK { + return catalog.Stamp{}, false, fmt.Errorf("PROPFIND %s : %s", s.folder, response.Status) + } + + var listing multistatus + if err := xml.NewDecoder(io.LimitReader(response.Body, maxListingBytes)).Decode(&listing); err != nil { + return catalog.Stamp{}, false, fmt.Errorf("réponse PROPFIND illisible : %w", err) + } + return listing.find(s.fileName) +} + +// get downloads the file and parses it as it arrives. +func (s *Source) get(ctx context.Context) (*ports.Batch, error) { + // A copy still in flight means the previous batch was never acknowledged — a file + // the share would not let us DELETE, downloaded again five seconds later. It is + // thrown away rather than left behind: keeping it would hold an open handle per + // download, and half a file in the archive directory is worse than no file at all. + s.take().Discard() + + response, err := s.do(ctx, http.MethodGet, s.file, nil, func(r *http.Request) { + // identity: a compressed body would make the byte count of the import record + // and the ceiling of §10.1 measure two different things. + r.Header.Set("Accept-Encoding", "identity") + }) + if err != nil { + s.unreachable(err) + return nil, nil + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + s.unreachable(fmt.Errorf("GET %s : %s", s.file, response.Status)) + return nil, nil + } + + pending, err := s.archive.Begin(s.fileName) + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue impossible.", err.Error()) + } + options := s.parse + options.Now = s.clock.Now() + batch, err := csvodoo.Parse(io.TeeReader(response.Body, pending), options) + if err != nil { + s.refuse(ctx, pending, err) + return nil, err + } + s.keep(pending) + return batch, nil +} + +// delete removes the file from the share. A file already gone is a success: the +// acknowledgement has taken place, whoever performed it. +func (s *Source) delete(ctx context.Context) error { + response, err := s.do(ctx, http.MethodDelete, s.file, nil, nil) + if err != nil { + return err + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxListingBytes)) + switch response.StatusCode { + case http.StatusOK, http.StatusNoContent, http.StatusAccepted, http.StatusNotFound: + return nil + } + return fmt.Errorf("DELETE %s : %s", s.file, response.Status) +} + +// do issues one request, bounded by a budget measured on the INJECTED clock. +// +// That is what makes a test of a hanging share instantaneous instead of two minutes: +// http.Client.Timeout would read the wall clock (§16.4). +func (s *Source) do(ctx context.Context, method string, target *url.URL, body io.Reader, + decorate func(*http.Request)) (*http.Response, error) { + ctx, cancel := ports.WithBudget(ctx, s.clock, bodyBudget) + request, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + cancel() + return nil, err + } + if s.username != "" { + request.SetBasicAuth(s.username, s.password) + } + if decorate != nil { + decorate(request) + } + response, err := s.client.Do(request) + if err != nil { + cancel() + return nil, err + } + // The budget covers the BODY as well, so it is released when the body is closed + // and not when the headers arrive. + response.Body = &closingBody{ReadCloser: response.Body, release: cancel} + return response, nil +} + +// closingBody releases the budget of a request when its body is closed. +type closingBody struct { + io.ReadCloser + release context.CancelFunc +} + +// Close closes the body and releases the budget, once. +func (b *closingBody) Close() error { + err := b.ReadCloser.Close() + if b.release != nil { + b.release() + b.release = nil + } + return err +} + +// maxListingBytes bounds a PROPFIND answer. A directory listing that does not fit in +// a megabyte is not a directory listing. +const maxListingBytes = 1 << 20 + +// multistatus is the answer of a PROPFIND, reduced to the two properties asked for. +type multistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []davResponse `xml:"DAV: response"` +} + +// davResponse is one entry of the listing. +type davResponse struct { + Href string `xml:"DAV: href"` + Propstat []davStatus `xml:"DAV: propstat"` +} + +// davStatus is one property block of one entry. +type davStatus struct { + Status string `xml:"DAV: status"` + ContentLength string `xml:"DAV: prop>getcontentlength"` + LastModified string `xml:"DAV: prop>getlastmodified"` +} + +// find reports the size and the date of one file of the listing. +// +// The comparison is on the LAST SEGMENT of the href and never on the whole path: a +// server is free to answer with an absolute path, a relative one or an escaped one, +// and the file name is the only part all three agree on. +func (m multistatus) find(fileName string) (catalog.Stamp, bool, error) { + for _, entry := range m.Responses { + href := entry.Href + if unescaped, err := url.PathUnescape(href); err == nil { + href = unescaped + } + if path.Base(strings.TrimSuffix(href, "/")) != fileName { + continue + } + for _, property := range entry.Propstat { + if property.ContentLength == "" { + continue + } + size, err := strconv.ParseInt(strings.TrimSpace(property.ContentLength), 10, 64) + if err != nil { + return catalog.Stamp{}, false, fmt.Errorf( + "taille annoncée %q pour %s", property.ContentLength, fileName) + } + modified, err := http.ParseTime(strings.TrimSpace(property.LastModified)) + if err != nil { + // A share that does not date its files is not a reason to refuse the + // catalog: the size alone still makes the stability rule work, it + // just makes it slightly weaker. + modified = time.Time{} + } + return catalog.Stamp{Size: size, Modified: modified}, true, nil + } + } + return catalog.Stamp{}, false, nil +} diff --git a/internal/catalog/webdav/dav_test.go b/internal/catalog/webdav/dav_test.go new file mode 100644 index 0000000..82439d3 --- /dev/null +++ b/internal/catalog/webdav/dav_test.go @@ -0,0 +1,102 @@ +package webdav + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// What the wire REFUSES. The credentials of this source travel on every request, so a +// redirection is not a detail of transport: one that leaves the declared host would +// hand them to another machine, and one that drops TLS would hand them to the network. +// Both are refused, and the refusal names what happened. + +// TestARedirectionOffTheDeclaredHostIsRefused (§10.1). +func TestARedirectionOffTheDeclaredHostIsRefused(t *testing.T) { + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, aCatalog) + })) + defer elsewhere.Close() + + // The share answers the PROPFIND normally, then sends the GET somewhere else. + remote := &share{content: aCatalog, present: true, modified: t0} + redirecting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + http.Redirect(w, r, elsewhere.URL+"/flv_2.csv", http.StatusFound) + return + } + remote.ServeHTTP(w, r) + })) + defer redirecting.Close() + + source, _, _ := station(t, remote, map[string]any{"url": redirecting.URL + "/catalogue/"}) + journal := &recorder{} + source.log = journal + ctx := context.Background() + + source.poll(ctx) + batch, err := source.poll(ctx) + if batch != nil || err != nil { + t.Fatalf("une redirection hors hôte a produit %v / %v", batch, err) + } + if len(journal.entries) == 0 || !strings.Contains(journal.entries[0].detail, "hors de l'hôte") { + t.Errorf("la redirection n'a pas été refusée en nommant l'hôte : %+v", journal.entries) + } +} + +// TestARedirectionMayNotDropTLS: the account of an https share never travels in clear. +// +// The rule is exercised THROUGH THE CLIENT'S OWN CheckRedirect and not through a server, +// because reproducing it end to end would mean an httptest TLS server, its self-signed +// certificate injected into a transport this package deliberately does not let anybody +// configure — a lot of scaffolding to observe one comparison. What matters is that the hop +// stays on the DECLARED host, which is what makes it invisible to the check above: net/http +// keeps the Authorization header on a same-host redirection. +func TestARedirectionMayNotDropTLS(t *testing.T) { + const host = "dav.example.org:8001" + client := newClient("https", host) + + hop := func(target string) error { + request, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + t.Fatalf("requête %s : %v", target, err) + } + origin, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête d'origine : %v", err) + } + return client.CheckRedirect(request, []*http.Request{origin}) + } + + // The hole this test closes: same host, TLS dropped. + err := hop("http://" + host + "/depots/flv_2.csv") + if err == nil { + t.Fatal("une redirection https → http sur l'hôte déclaré a été acceptée") + } + if !strings.Contains(err.Error(), "en clair") { + t.Errorf("le refus ne dit pas que le compte partirait en clair : %v", err) + } + + // And what must keep working: the same host, still in TLS. + if err := hop("https://" + host + "/autre/flv_2.csv"); err != nil { + t.Errorf("une redirection https → https sur l'hôte déclaré a été refusée : %v", err) + } + + // A share DECLARED in http is not silently upgraded, and not refused either: the + // declared scheme is a floor, so a redirection towards TLS is worth following. + plain := newClient("http", host) + request, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête : %v", err) + } + origin, err := http.NewRequest(http.MethodGet, "http://"+host+"/depots/flv_2.csv", nil) + if err != nil { + t.Fatalf("requête d'origine : %v", err) + } + if err := plain.CheckRedirect(request, []*http.Request{origin}); err != nil { + t.Errorf("une redirection http → https a été refusée : %v", err) + } +} diff --git a/internal/catalog/webdav/source.go b/internal/catalog/webdav/source.go new file mode 100644 index 0000000..c2bcae7 --- /dev/null +++ b/internal/catalog/webdav/source.go @@ -0,0 +1,212 @@ +package webdav + +import ( + "context" + "fmt" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is the ports.CatalogSource contract as the station sees it: a batch +// offered only once the remote file has stopped moving, an acknowledgement that +// archives locally and deletes remotely, and the bookkeeping of the copy in flight. +// +// The rule it enforces is the local drop's, spelled in HTTP — and nothing it decides +// belongs to catalog.Assemble: a source offers bytes, it never qualifies a catalog. + +// Name reports the registry key of this source. +func (s *Source) Name() string { return domain.CatalogSourceWebDAV } + +// Describe reports what the dashboard shows permanently: the source, the URL watched +// and the account used, which is the difference between this source and the local one +// (§10.1). +func (s *Source) Describe() string { + if s.username == "" { + return fmt.Sprintf("WebDAV, %s (sans compte)", s.file) + } + return fmt.Sprintf("WebDAV, %s (compte %s)", s.file, s.username) +} + +// Next blocks until a whole catalog is available, or until ctx is done. +func (s *Source) Next(ctx context.Context) (*ports.Batch, error) { + tick, stop := s.clock.Ticker(s.interval) + defer stop() + for { + batch, err := s.poll(ctx) + if err != nil { + return nil, err + } + if batch != nil { + return batch, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-tick: + case <-s.wake: + // « Recharger le catalogue » was pressed. The poll below is the SAME one the + // tick performs, share credentials and stability rule included. + } + } +} + +// Wake asks the watch to poll NOW rather than at the next tick (§14.4). +func (s *Source) Wake() { + select { + case s.wake <- struct{}{}: + default: + // A poll is already asked for. Two are the same request. + } +} + +// poll asks the share what it holds, and reads only a file that has stopped moving. +// +// A share that does not answer is NOT an error of this function: returning one would +// send the watcher round the loop with no delay at all. It is logged, counted, and +// the next poll tries again — which is what a station with a flaky network needs. +func (s *Source) poll(ctx context.Context) (*ports.Batch, error) { + if s.isClosed() { + return nil, nil + } + stamp, found, err := s.propfind(ctx) + switch { + case err != nil: + s.unreachable(err) + return nil, nil + case !found: + s.failures = 0 + s.stability.Forget() + return nil, nil + } + s.failures = 0 + if !s.stability.Observe(stamp) { + return nil, nil + } + return s.get(ctx) +} + +// unreachable reports a share that did not answer, and raises the level on the third +// consecutive failure. +func (s *Source) unreachable(err error) { + s.failures++ + s.stability.Forget() + level := domain.LevelWarn + if s.failures >= attemptsBeforeAlarm { + level = domain.LevelError + } + s.log.Technical(level, "catalog", "ERR-CAT-03", + fmt.Sprintf("Partage de catalogue injoignable (%d essai(s) consécutif(s)).", s.failures), + err.Error()) +} + +// keep stores the copy in flight, or throws it away when the source was closed while +// the body was being parsed — the shutdown landing in the middle of a download. +func (s *Source) keep(pending *catalog.Pending) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + pending.Discard() + return + } + s.pending = pending + s.mu.Unlock() +} + +// take removes the copy in flight and hands it over, so that exactly one caller ever +// commits or discards it. +func (s *Source) take() *catalog.Pending { + s.mu.Lock() + defer s.mu.Unlock() + pending := s.pending + s.pending = nil + return pending +} + +// isClosed reports a source that has been shut down. +func (s *Source) isClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +// refuse sets aside a file nothing could be made of, counts the failure against its +// CONTENT, and deletes it from the share. +// +// Deleting it is what stops the watcher re-reading the same broken content every five +// seconds for ever. The copy and its reason stay locally (failure test 9), and the +// count is what turns the third refusal of the same content into a red light (§10.5). +func (s *Source) refuse(ctx context.Context, pending *catalog.Pending, cause error) { + s.stability.Forget() + archived, err := pending.Commit() + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue refusé impossible.", err.Error()) + } + if err := s.archive.Explain(archived, "ERR-CAT-03", cause.Error()); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Motif du refus non écrit.", err.Error()) + } + entry, counted := s.quarantine.Count(ctx, cause) + if err := s.delete(ctx); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Fichier de catalogue refusé non supprimé.", err.Error()) + return + } + level := domain.LevelWarn + if counted && entry.FailureCount >= s.quarantine.Threshold() { + level = domain.LevelError + } + s.log.Technical(level, "catalog", "ERR-CAT-03", + "Catalogue refusé, fichier mis de côté.", archived) +} + +// Acknowledge names the local copy and THEN deletes the remote file. +// +// The DELETE is the acknowledgement, exactly as the os.Remove is for the local drop +// (ADR-004). +func (s *Source) Acknowledge(ctx context.Context, batch *ports.Batch, result ports.BatchResult) error { + pending := s.take() + s.stability.Forget() + + archived, err := pending.Commit() + if err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Archive du catalogue impossible.", err.Error()) + } + if result.Result == domain.ImportRejected || result.Result == domain.ImportFailed { + if err := s.archive.Explain(archived, result.Code, result.Reason); err != nil { + s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Motif du refus non écrit.", err.Error()) + } + } + if err := s.delete(ctx); err != nil { + return fmt.Errorf("%w : le compte %s n'a pas pu supprimer %s (lot %s) : %w", + catalog.ErrNotAcknowledged, s.account(), s.file, batch.ID, err) + } + s.log.Technical(domain.LevelInfo, "catalog", "", + "Catalogue acquitté, fichier supprimé du partage.", archived) + return nil +} + +// account is the wording of the user a message names, or an honest absence. +func (s *Source) account() string { + if s.username == "" { + return "anonyme" + } + return s.username +} + +// Close stops watching and throws away a copy in flight. +func (s *Source) Close() error { + s.mu.Lock() + s.closed = true + pending := s.pending + s.pending = nil + s.mu.Unlock() + + pending.Discard() + s.client.CloseIdleConnections() + return nil +} diff --git a/internal/catalog/webdav/source_test.go b/internal/catalog/webdav/source_test.go new file mode 100644 index 0000000..22e85ba --- /dev/null +++ b/internal/catalog/webdav/source_test.go @@ -0,0 +1,314 @@ +package webdav + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// The ports.CatalogSource contract over HTTP: a remote file read only once it has +// stopped moving — size AND date — an acknowledgement that archives locally and THEN +// deletes remotely, and a share that does not answer, which is logged and counted and +// never sends the watch round the loop with no delay. + +// TestTheFileIsReadOnceItHasStoppedMoving: the same stability rule as the local drop, +// on the size and the date the PROPFIND reports (§10.1). +func TestTheFileIsReadOnceItHasStoppedMoving(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + if batch, err := source.poll(ctx); batch != nil || err != nil { + t.Fatalf("lu dès la première scrutation : %v / %v", batch, err) + } + batch, err := source.poll(ctx) + if err != nil || batch == nil { + t.Fatalf("seconde scrutation : %v / %v", batch, err) + } + if remote.gets != 1 { + t.Errorf("%d GET, attendu 1 : rien n'est téléchargé avant la stabilité", remote.gets) + } + if remote.deletes != 0 { + t.Error("le fichier a été supprimé avant que le lot soit appliqué") + } + report := catalog.Summarize(batch) + if report.RowsRead != 2 || report.Weighable != 2 { + t.Errorf("inventaire %s", report) + } + if batch.Source != domain.CatalogSourceWebDAV { + t.Errorf("provenance %q", batch.Source) + } +} + +// TestAGrowingRemoteFileIsNotRead: the size announced changes between two PROPFINDs. +func TestAGrowingRemoteFileIsNotRead(t *testing.T) { + remote := &share{content: aCatalog[:60], present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + source.poll(ctx) + remote.content = aCatalog[:120] + if batch, _ := source.poll(ctx); batch != nil { + t.Fatal("un fichier dont la taille a changé a été téléchargé") + } + remote.content = aCatalog + if batch, _ := source.poll(ctx); batch != nil { + t.Fatal("un fichier dont la taille a encore changé a été téléchargé") + } + batch, err := source.poll(ctx) + if err != nil || batch == nil { + t.Fatalf("le fichier immobile n'a pas été lu : %v / %v", batch, err) + } + if len(batch.Products) != 2 { + t.Errorf("%d produits : le fichier ENTIER devait être lu", len(batch.Products)) + } +} + +// TestTheDateAloneMovingIsEnoughToWait: a producer that rewrites the same number of +// bytes is the case a size-only check would miss. +func TestTheDateAloneMovingIsEnoughToWait(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + source.poll(ctx) + remote.modified = t0.Add(time.Minute) + if batch, _ := source.poll(ctx); batch != nil { + t.Fatal("un fichier réécrit à la même taille a été téléchargé") + } +} + +// TestAcknowledgingArchivesLocallyThenDeletesRemotely (ADR-004). +func TestAcknowledgingArchivesLocallyThenDeletesRemotely(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, map[string]any{"username": "balance", "password": "secret"}) + ctx := context.Background() + + source.poll(ctx) + batch, err := source.poll(ctx) + if err != nil || batch == nil { + t.Fatalf("lecture : %v", err) + } + if remote.authorization == "" { + t.Error("le compte déclaré n'a pas été présenté au partage") + } + + if err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}); err != nil { + t.Fatalf("acquittement : %v", err) + } + if remote.deletes != 1 || remote.present { + t.Errorf("%d DELETE, présent = %v : la suppression EST l'acquittement", + remote.deletes, remote.present) + } + names := archives(t, source) + if len(names) != 1 || names[0] != "flv_2-2026-07-24T15-38-12.csv" { + t.Fatalf("archives %v", names) + } + kept, err := os.ReadFile(filepath.Join(source.archive.Directory(), names[0])) + if err != nil || string(kept) != aCatalog { + t.Errorf("l'archive locale ne porte pas les octets analysés (%v)", err) + } +} + +// TestAShareThatRefusesTheDeleteIsAmberAndNotQuarantined is failure test 11 over +// HTTP: the catalog is in service, only the acknowledgement failed. +func TestAShareThatRefusesTheDeleteIsAmberAndNotQuarantined(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, map[string]any{"username": "balance"}) + ctx := context.Background() + + source.poll(ctx) + batch, _ := source.poll(ctx) + remote.status = http.StatusForbidden + + err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}) + if !errors.Is(err, catalog.ErrNotAcknowledged) { + t.Fatalf("erreur %v, attendu ERR-CAT-05", err) + } + if errors.Is(err, catalog.ErrContent) { + t.Error("un DELETE refusé est compté comme un échec de contenu") + } + if !strings.Contains(err.Error(), "balance") { + t.Errorf("le message ne nomme pas le compte : %v", err) + } + if names := archives(t, source); len(names) != 1 { + t.Errorf("archives %v : la copie locale doit exister quand même", names) + } +} + +// TestAnUnreachableShareIsLoggedAndNeverSpins: returning an error on every poll would +// send the watcher round the loop with no delay at all. +func TestAnUnreachableShareIsLoggedAndNeverSpins(t *testing.T) { + remote := &share{status: http.StatusInternalServerError} + source, _, _ := station(t, remote, nil) + journal := &recorder{} + source.log = journal + ctx := context.Background() + + for i := 0; i < 3; i++ { + batch, err := source.poll(ctx) + if batch != nil || err != nil { + t.Fatalf("scrutation %d : %v / %v", i, batch, err) + } + } + if len(journal.entries) != 3 { + t.Fatalf("%d entrées journalisées, attendu 3", len(journal.entries)) + } + if journal.entries[0].level != domain.LevelWarn || journal.entries[2].level != domain.LevelError { + t.Errorf("niveaux %q puis %q : le troisième échec consécutif monte d'un cran", + journal.entries[0].level, journal.entries[2].level) + } + for _, entry := range journal.entries { + if entry.code != "ERR-CAT-03" { + t.Errorf("code %q", entry.code) + } + } +} + +// TestAnAbsentRemoteFileIsNothingToDo, which is the ordinary state of the share. +func TestAnAbsentRemoteFileIsNothingToDo(t *testing.T) { + remote := &share{present: false} + source, _, _ := station(t, remote, nil) + for i := 0; i < 3; i++ { + if batch, err := source.poll(context.Background()); batch != nil || err != nil { + t.Fatalf("scrutation %d : %v / %v", i, batch, err) + } + } + if remote.gets != 0 { + t.Errorf("%d GET sur un partage vide", remote.gets) + } +} + +// TestAnUnusableRemoteFileIsSetAsideAndDeleted is failure test 9 over HTTP. +func TestAnUnusableRemoteFileIsSetAsideAndDeleted(t *testing.T) { + remote := &share{content: "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE\"\r\n", + present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + source.poll(ctx) + batch, err := source.poll(ctx) + if batch != nil { + t.Fatal("un contenu inexploitable a produit un lot") + } + if !errors.Is(err, catalog.ErrContent) { + t.Fatalf("erreur %v, attendu ERR-CAT-03", err) + } + if remote.present || remote.deletes != 1 { + t.Errorf("le fichier refusé n'a pas été retiré du partage (%d DELETE)", remote.deletes) + } + names := archives(t, source) + if len(names) != 2 { + t.Fatalf("archives %v, attendu la copie ET son motif", names) + } + reason, err := os.ReadFile(filepath.Join(source.archive.Directory(), "flv_2-2026-07-24T15-38-12.reason.txt")) + if err != nil { + t.Fatalf("lecture du motif : %v", err) + } + if !strings.Contains(string(reason), "ERR-CAT-03") { + t.Errorf("motif : %s", reason) + } +} + +// TestNextStopsWithItsContext, on the injected clock and with no sleep (§16.4). +func TestNextStopsWithItsContext(t *testing.T) { + remote := &share{present: false} + source, clock, _ := station(t, remote, nil) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + _, err := source.Next(ctx) + done <- err + }() + cancel() + + deadline := time.After(2 * time.Second) + for { + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Next a rendu %v", err) + } + return + case <-deadline: + t.Fatal("Next n'est pas sorti à l'annulation de son contexte") + default: + clock.Advance(5 * time.Second) + } + } +} + +// TestAListingThatSaysNothingUsableIsNotAFileToRead. +// +// A size that is not a number is a share that answers something else; a file with no +// date at all still works, with a slightly weaker stability rule — refusing it would +// mean refusing a catalog because a server does not send `getlastmodified`. +func TestAListingThatSaysNothingUsableIsNotAFileToRead(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, nil) + journal := &recorder{} + source.log = journal + ctx := context.Background() + + remote.brokenLength = true + if batch, err := source.poll(ctx); batch != nil || err != nil { + t.Fatalf("une taille illisible a produit %v / %v", batch, err) + } + if len(journal.entries) != 1 || !strings.Contains(journal.entries[0].detail, "taille annoncée") { + t.Errorf("la taille illisible n'a pas été nommée : %+v", journal.entries) + } + + remote.brokenLength, remote.noDate = false, true + source.poll(ctx) + batch, err := source.poll(ctx) + if err != nil || batch == nil { + t.Fatalf("un partage sans date a empêché la lecture : %v / %v", batch, err) + } +} + +// TestDeletingAFileSomebodyElseAlreadyRemovedIsASuccess: the acknowledgement has +// taken place, whoever performed it. +func TestDeletingAFileSomebodyElseAlreadyRemovedIsASuccess(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + source.poll(ctx) + batch, _ := source.poll(ctx) + remote.present = false // somebody else got there first + + if err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}); err != nil { + t.Fatalf("acquittement d'un fichier déjà parti : %v", err) + } +} + +// TestCloseIsIdempotentAndReleasesTheCopyInFlight. +func TestCloseIsIdempotentAndReleasesTheCopyInFlight(t *testing.T) { + remote := &share{content: aCatalog, present: true, modified: t0} + source, _, _ := station(t, remote, nil) + ctx := context.Background() + + source.poll(ctx) + if _, err := source.poll(ctx); err != nil { + t.Fatalf("lecture : %v", err) + } + if err := source.Close(); err != nil { + t.Fatalf("fermeture : %v", err) + } + if names := archives(t, source); len(names) != 0 { + t.Errorf("archives %v, attendu aucune : la copie en vol est jetée", names) + } + if err := source.Close(); err != nil { + t.Errorf("seconde fermeture : %v", err) + } +} diff --git a/internal/catalog/webdav/webdav.go b/internal/catalog/webdav/webdav.go index af554fd..c6fe332 100644 --- a/internal/catalog/webdav/webdav.go +++ b/internal/catalog/webdav/webdav.go @@ -14,17 +14,12 @@ package webdav import ( - "context" - "encoding/xml" "errors" "fmt" - "io" "net" "net/http" "net/url" - "path" "path/filepath" - "strconv" "strings" "sync" "time" @@ -35,6 +30,12 @@ import ( "openscale/internal/station/ports" ) +// This file is what a configuration BUILDS: the timeouts and the shipped values of +// §11.2, the Source and its fields, the URL and the HTTP client New derives from +// catalog.options, and the Descriptor the administration screen generates its form +// from. What the station then ASKS of that source is in source.go, and what goes on +// the wire in dav.go. + // The two explicit timeouts of §10.1. They bound the network and nothing else: no // business decision rests on them. const ( @@ -217,390 +218,6 @@ func logOf(c catalog.SourceConfig) ports.TechnicalLog { return c.Log } -// Name reports the registry key of this source. -func (s *Source) Name() string { return domain.CatalogSourceWebDAV } - -// Describe reports what the dashboard shows permanently: the source, the URL watched -// and the account used, which is the difference between this source and the local one -// (§10.1). -func (s *Source) Describe() string { - if s.username == "" { - return fmt.Sprintf("WebDAV, %s (sans compte)", s.file) - } - return fmt.Sprintf("WebDAV, %s (compte %s)", s.file, s.username) -} - -// Next blocks until a whole catalog is available, or until ctx is done. -func (s *Source) Next(ctx context.Context) (*ports.Batch, error) { - tick, stop := s.clock.Ticker(s.interval) - defer stop() - for { - batch, err := s.poll(ctx) - if err != nil { - return nil, err - } - if batch != nil { - return batch, nil - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-tick: - case <-s.wake: - // « Recharger le catalogue » was pressed. The poll below is the SAME one the - // tick performs, share credentials and stability rule included. - } - } -} - -// Wake asks the watch to poll NOW rather than at the next tick (§14.4). -func (s *Source) Wake() { - select { - case s.wake <- struct{}{}: - default: - // A poll is already asked for. Two are the same request. - } -} - -// poll asks the share what it holds, and reads only a file that has stopped moving. -// -// A share that does not answer is NOT an error of this function: returning one would -// send the watcher round the loop with no delay at all. It is logged, counted, and -// the next poll tries again — which is what a station with a flaky network needs. -func (s *Source) poll(ctx context.Context) (*ports.Batch, error) { - if s.isClosed() { - return nil, nil - } - stamp, found, err := s.propfind(ctx) - switch { - case err != nil: - s.unreachable(err) - return nil, nil - case !found: - s.failures = 0 - s.stability.Forget() - return nil, nil - } - s.failures = 0 - if !s.stability.Observe(stamp) { - return nil, nil - } - return s.get(ctx) -} - -// unreachable reports a share that did not answer, and raises the level on the third -// consecutive failure. -func (s *Source) unreachable(err error) { - s.failures++ - s.stability.Forget() - level := domain.LevelWarn - if s.failures >= attemptsBeforeAlarm { - level = domain.LevelError - } - s.log.Technical(level, "catalog", "ERR-CAT-03", - fmt.Sprintf("Partage de catalogue injoignable (%d essai(s) consécutif(s)).", s.failures), - err.Error()) -} - -// propfind asks for the size and the date of the watched file. -// -// Depth 1 on the FOLDER rather than Depth 0 on the file, because that is the request -// a WebDAV server always answers the same way, and because a 404 on a folder listing -// tells an operator something a 404 on a file does not: the path is wrong. -func (s *Source) propfind(ctx context.Context) (catalog.Stamp, bool, error) { - const body = `` + - `` + - `` + - `` - - response, err := s.do(ctx, "PROPFIND", s.folder, strings.NewReader(body), func(r *http.Request) { - r.Header.Set("Depth", "1") - r.Header.Set("Content-Type", "application/xml; charset=utf-8") - }) - if err != nil { - return catalog.Stamp{}, false, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusMultiStatus && response.StatusCode != http.StatusOK { - return catalog.Stamp{}, false, fmt.Errorf("PROPFIND %s : %s", s.folder, response.Status) - } - - var listing multistatus - if err := xml.NewDecoder(io.LimitReader(response.Body, maxListingBytes)).Decode(&listing); err != nil { - return catalog.Stamp{}, false, fmt.Errorf("réponse PROPFIND illisible : %w", err) - } - return listing.find(s.fileName) -} - -// get downloads the file and parses it as it arrives. -func (s *Source) get(ctx context.Context) (*ports.Batch, error) { - // A copy still in flight means the previous batch was never acknowledged — a file - // the share would not let us DELETE, downloaded again five seconds later. It is - // thrown away rather than left behind: keeping it would hold an open handle per - // download, and half a file in the archive directory is worse than no file at all. - s.take().Discard() - - response, err := s.do(ctx, http.MethodGet, s.file, nil, func(r *http.Request) { - // identity: a compressed body would make the byte count of the import record - // and the ceiling of §10.1 measure two different things. - r.Header.Set("Accept-Encoding", "identity") - }) - if err != nil { - s.unreachable(err) - return nil, nil - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - s.unreachable(fmt.Errorf("GET %s : %s", s.file, response.Status)) - return nil, nil - } - - pending, err := s.archive.Begin(s.fileName) - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue impossible.", err.Error()) - } - options := s.parse - options.Now = s.clock.Now() - batch, err := csvodoo.Parse(io.TeeReader(response.Body, pending), options) - if err != nil { - s.refuse(ctx, pending, err) - return nil, err - } - s.keep(pending) - return batch, nil -} - -// keep stores the copy in flight, or throws it away when the source was closed while -// the body was being parsed — the shutdown landing in the middle of a download. -func (s *Source) keep(pending *catalog.Pending) { - s.mu.Lock() - if s.closed { - s.mu.Unlock() - pending.Discard() - return - } - s.pending = pending - s.mu.Unlock() -} - -// take removes the copy in flight and hands it over, so that exactly one caller ever -// commits or discards it. -func (s *Source) take() *catalog.Pending { - s.mu.Lock() - defer s.mu.Unlock() - pending := s.pending - s.pending = nil - return pending -} - -// isClosed reports a source that has been shut down. -func (s *Source) isClosed() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.closed -} - -// refuse sets aside a file nothing could be made of, counts the failure against its -// CONTENT, and deletes it from the share. -// -// Deleting it is what stops the watcher re-reading the same broken content every five -// seconds for ever. The copy and its reason stay locally (failure test 9), and the -// count is what turns the third refusal of the same content into a red light (§10.5). -func (s *Source) refuse(ctx context.Context, pending *catalog.Pending, cause error) { - s.stability.Forget() - archived, err := pending.Commit() - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue refusé impossible.", err.Error()) - } - if err := s.archive.Explain(archived, "ERR-CAT-03", cause.Error()); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Motif du refus non écrit.", err.Error()) - } - entry, counted := s.quarantine.Count(ctx, cause) - if err := s.delete(ctx); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Fichier de catalogue refusé non supprimé.", err.Error()) - return - } - level := domain.LevelWarn - if counted && entry.FailureCount >= s.quarantine.Threshold() { - level = domain.LevelError - } - s.log.Technical(level, "catalog", "ERR-CAT-03", - "Catalogue refusé, fichier mis de côté.", archived) -} - -// Acknowledge names the local copy and THEN deletes the remote file. -// -// The DELETE is the acknowledgement, exactly as the os.Remove is for the local drop -// (ADR-004). -func (s *Source) Acknowledge(ctx context.Context, batch *ports.Batch, result ports.BatchResult) error { - pending := s.take() - s.stability.Forget() - - archived, err := pending.Commit() - if err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Archive du catalogue impossible.", err.Error()) - } - if result.Result == domain.ImportRejected || result.Result == domain.ImportFailed { - if err := s.archive.Explain(archived, result.Code, result.Reason); err != nil { - s.log.Technical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Motif du refus non écrit.", err.Error()) - } - } - if err := s.delete(ctx); err != nil { - return fmt.Errorf("%w : le compte %s n'a pas pu supprimer %s (lot %s) : %w", - catalog.ErrNotAcknowledged, s.account(), s.file, batch.ID, err) - } - s.log.Technical(domain.LevelInfo, "catalog", "", - "Catalogue acquitté, fichier supprimé du partage.", archived) - return nil -} - -// account is the wording of the user a message names, or an honest absence. -func (s *Source) account() string { - if s.username == "" { - return "anonyme" - } - return s.username -} - -// delete removes the file from the share. A file already gone is a success: the -// acknowledgement has taken place, whoever performed it. -func (s *Source) delete(ctx context.Context) error { - response, err := s.do(ctx, http.MethodDelete, s.file, nil, nil) - if err != nil { - return err - } - defer response.Body.Close() - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxListingBytes)) - switch response.StatusCode { - case http.StatusOK, http.StatusNoContent, http.StatusAccepted, http.StatusNotFound: - return nil - } - return fmt.Errorf("DELETE %s : %s", s.file, response.Status) -} - -// do issues one request, bounded by a budget measured on the INJECTED clock. -// -// That is what makes a test of a hanging share instantaneous instead of two minutes: -// http.Client.Timeout would read the wall clock (§16.4). -func (s *Source) do(ctx context.Context, method string, target *url.URL, body io.Reader, - decorate func(*http.Request)) (*http.Response, error) { - ctx, cancel := ports.WithBudget(ctx, s.clock, bodyBudget) - request, err := http.NewRequestWithContext(ctx, method, target.String(), body) - if err != nil { - cancel() - return nil, err - } - if s.username != "" { - request.SetBasicAuth(s.username, s.password) - } - if decorate != nil { - decorate(request) - } - response, err := s.client.Do(request) - if err != nil { - cancel() - return nil, err - } - // The budget covers the BODY as well, so it is released when the body is closed - // and not when the headers arrive. - response.Body = &closingBody{ReadCloser: response.Body, release: cancel} - return response, nil -} - -// closingBody releases the budget of a request when its body is closed. -type closingBody struct { - io.ReadCloser - release context.CancelFunc -} - -// Close closes the body and releases the budget, once. -func (b *closingBody) Close() error { - err := b.ReadCloser.Close() - if b.release != nil { - b.release() - b.release = nil - } - return err -} - -// Close stops watching and throws away a copy in flight. -func (s *Source) Close() error { - s.mu.Lock() - s.closed = true - pending := s.pending - s.pending = nil - s.mu.Unlock() - - pending.Discard() - s.client.CloseIdleConnections() - return nil -} - -// maxListingBytes bounds a PROPFIND answer. A directory listing that does not fit in -// a megabyte is not a directory listing. -const maxListingBytes = 1 << 20 - -// multistatus is the answer of a PROPFIND, reduced to the two properties asked for. -type multistatus struct { - XMLName xml.Name `xml:"DAV: multistatus"` - Responses []davResponse `xml:"DAV: response"` -} - -// davResponse is one entry of the listing. -type davResponse struct { - Href string `xml:"DAV: href"` - Propstat []davStatus `xml:"DAV: propstat"` -} - -// davStatus is one property block of one entry. -type davStatus struct { - Status string `xml:"DAV: status"` - ContentLength string `xml:"DAV: prop>getcontentlength"` - LastModified string `xml:"DAV: prop>getlastmodified"` -} - -// find reports the size and the date of one file of the listing. -// -// The comparison is on the LAST SEGMENT of the href and never on the whole path: a -// server is free to answer with an absolute path, a relative one or an escaped one, -// and the file name is the only part all three agree on. -func (m multistatus) find(fileName string) (catalog.Stamp, bool, error) { - for _, entry := range m.Responses { - href := entry.Href - if unescaped, err := url.PathUnescape(href); err == nil { - href = unescaped - } - if path.Base(strings.TrimSuffix(href, "/")) != fileName { - continue - } - for _, property := range entry.Propstat { - if property.ContentLength == "" { - continue - } - size, err := strconv.ParseInt(strings.TrimSpace(property.ContentLength), 10, 64) - if err != nil { - return catalog.Stamp{}, false, fmt.Errorf( - "taille annoncée %q pour %s", property.ContentLength, fileName) - } - modified, err := http.ParseTime(strings.TrimSpace(property.LastModified)) - if err != nil { - // A share that does not date its files is not a reason to refuse the - // catalog: the size alone still makes the stability rule work, it - // just makes it slightly weaker. - modified = time.Time{} - } - return catalog.Stamp{Size: size, Modified: modified}, true, nil - } - } - return catalog.Stamp{}, false, nil -} - // Descriptor is what the administration screen builds its form from, and what // Config.Validate checks catalog.options against (control 9). // diff --git a/internal/catalog/webdav/webdav_test.go b/internal/catalog/webdav/webdav_test.go index b5306e5..358e099 100644 --- a/internal/catalog/webdav/webdav_test.go +++ b/internal/catalog/webdav/webdav_test.go @@ -1,14 +1,11 @@ package webdav import ( - "context" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "testing" "time" @@ -16,9 +13,16 @@ import ( "openscale/internal/catalog" "openscale/internal/domain" "openscale/internal/fake" - "openscale/internal/station/ports" ) +// The share every test of this package is driven against — a real HTTP server +// answering PROPFIND, GET and DELETE — and what a configuration BUILDS out of it: a +// URL that must be HTTP, the account a dashboard names, the descriptor that is the only +// one carrying a secret, and the factory the registry reaches it through. +// +// What the station asks of the source is in source_test.go; what the wire refuses is in +// dav_test.go. + // t0 is the instant the fake clock starts at. var t0 = time.Date(2026, 7, 24, 15, 38, 12, 0, time.UTC) @@ -214,321 +218,6 @@ func TestTheDashboardNamesTheSourceTheURLAndTheAccount(t *testing.T) { } } -// TestTheFileIsReadOnceItHasStoppedMoving: the same stability rule as the local drop, -// on the size and the date the PROPFIND reports (§10.1). -func TestTheFileIsReadOnceItHasStoppedMoving(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - if batch, err := source.poll(ctx); batch != nil || err != nil { - t.Fatalf("lu dès la première scrutation : %v / %v", batch, err) - } - batch, err := source.poll(ctx) - if err != nil || batch == nil { - t.Fatalf("seconde scrutation : %v / %v", batch, err) - } - if remote.gets != 1 { - t.Errorf("%d GET, attendu 1 : rien n'est téléchargé avant la stabilité", remote.gets) - } - if remote.deletes != 0 { - t.Error("le fichier a été supprimé avant que le lot soit appliqué") - } - report := catalog.Summarize(batch) - if report.RowsRead != 2 || report.Weighable != 2 { - t.Errorf("inventaire %s", report) - } - if batch.Source != domain.CatalogSourceWebDAV { - t.Errorf("provenance %q", batch.Source) - } -} - -// TestAGrowingRemoteFileIsNotRead: the size announced changes between two PROPFINDs. -func TestAGrowingRemoteFileIsNotRead(t *testing.T) { - remote := &share{content: aCatalog[:60], present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - source.poll(ctx) - remote.content = aCatalog[:120] - if batch, _ := source.poll(ctx); batch != nil { - t.Fatal("un fichier dont la taille a changé a été téléchargé") - } - remote.content = aCatalog - if batch, _ := source.poll(ctx); batch != nil { - t.Fatal("un fichier dont la taille a encore changé a été téléchargé") - } - batch, err := source.poll(ctx) - if err != nil || batch == nil { - t.Fatalf("le fichier immobile n'a pas été lu : %v / %v", batch, err) - } - if len(batch.Products) != 2 { - t.Errorf("%d produits : le fichier ENTIER devait être lu", len(batch.Products)) - } -} - -// TestTheDateAloneMovingIsEnoughToWait: a producer that rewrites the same number of -// bytes is the case a size-only check would miss. -func TestTheDateAloneMovingIsEnoughToWait(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - source.poll(ctx) - remote.modified = t0.Add(time.Minute) - if batch, _ := source.poll(ctx); batch != nil { - t.Fatal("un fichier réécrit à la même taille a été téléchargé") - } -} - -// TestAcknowledgingArchivesLocallyThenDeletesRemotely (ADR-004). -func TestAcknowledgingArchivesLocallyThenDeletesRemotely(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, map[string]any{"username": "balance", "password": "secret"}) - ctx := context.Background() - - source.poll(ctx) - batch, err := source.poll(ctx) - if err != nil || batch == nil { - t.Fatalf("lecture : %v", err) - } - if remote.authorization == "" { - t.Error("le compte déclaré n'a pas été présenté au partage") - } - - if err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}); err != nil { - t.Fatalf("acquittement : %v", err) - } - if remote.deletes != 1 || remote.present { - t.Errorf("%d DELETE, présent = %v : la suppression EST l'acquittement", - remote.deletes, remote.present) - } - names := archives(t, source) - if len(names) != 1 || names[0] != "flv_2-2026-07-24T15-38-12.csv" { - t.Fatalf("archives %v", names) - } - kept, err := os.ReadFile(filepath.Join(source.archive.Directory(), names[0])) - if err != nil || string(kept) != aCatalog { - t.Errorf("l'archive locale ne porte pas les octets analysés (%v)", err) - } -} - -// TestAShareThatRefusesTheDeleteIsAmberAndNotQuarantined is failure test 11 over -// HTTP: the catalog is in service, only the acknowledgement failed. -func TestAShareThatRefusesTheDeleteIsAmberAndNotQuarantined(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, map[string]any{"username": "balance"}) - ctx := context.Background() - - source.poll(ctx) - batch, _ := source.poll(ctx) - remote.status = http.StatusForbidden - - err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}) - if !errors.Is(err, catalog.ErrNotAcknowledged) { - t.Fatalf("erreur %v, attendu ERR-CAT-05", err) - } - if errors.Is(err, catalog.ErrContent) { - t.Error("un DELETE refusé est compté comme un échec de contenu") - } - if !strings.Contains(err.Error(), "balance") { - t.Errorf("le message ne nomme pas le compte : %v", err) - } - if names := archives(t, source); len(names) != 1 { - t.Errorf("archives %v : la copie locale doit exister quand même", names) - } -} - -// TestAnUnreachableShareIsLoggedAndNeverSpins: returning an error on every poll would -// send the watcher round the loop with no delay at all. -func TestAnUnreachableShareIsLoggedAndNeverSpins(t *testing.T) { - remote := &share{status: http.StatusInternalServerError} - source, _, _ := station(t, remote, nil) - journal := &recorder{} - source.log = journal - ctx := context.Background() - - for i := 0; i < 3; i++ { - batch, err := source.poll(ctx) - if batch != nil || err != nil { - t.Fatalf("scrutation %d : %v / %v", i, batch, err) - } - } - if len(journal.entries) != 3 { - t.Fatalf("%d entrées journalisées, attendu 3", len(journal.entries)) - } - if journal.entries[0].level != domain.LevelWarn || journal.entries[2].level != domain.LevelError { - t.Errorf("niveaux %q puis %q : le troisième échec consécutif monte d'un cran", - journal.entries[0].level, journal.entries[2].level) - } - for _, entry := range journal.entries { - if entry.code != "ERR-CAT-03" { - t.Errorf("code %q", entry.code) - } - } -} - -// TestAnAbsentRemoteFileIsNothingToDo, which is the ordinary state of the share. -func TestAnAbsentRemoteFileIsNothingToDo(t *testing.T) { - remote := &share{present: false} - source, _, _ := station(t, remote, nil) - for i := 0; i < 3; i++ { - if batch, err := source.poll(context.Background()); batch != nil || err != nil { - t.Fatalf("scrutation %d : %v / %v", i, batch, err) - } - } - if remote.gets != 0 { - t.Errorf("%d GET sur un partage vide", remote.gets) - } -} - -// TestAnUnusableRemoteFileIsSetAsideAndDeleted is failure test 9 over HTTP. -func TestAnUnusableRemoteFileIsSetAsideAndDeleted(t *testing.T) { - remote := &share{content: "\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE\"\r\n", - present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - source.poll(ctx) - batch, err := source.poll(ctx) - if batch != nil { - t.Fatal("un contenu inexploitable a produit un lot") - } - if !errors.Is(err, catalog.ErrContent) { - t.Fatalf("erreur %v, attendu ERR-CAT-03", err) - } - if remote.present || remote.deletes != 1 { - t.Errorf("le fichier refusé n'a pas été retiré du partage (%d DELETE)", remote.deletes) - } - names := archives(t, source) - if len(names) != 2 { - t.Fatalf("archives %v, attendu la copie ET son motif", names) - } - reason, err := os.ReadFile(filepath.Join(source.archive.Directory(), "flv_2-2026-07-24T15-38-12.reason.txt")) - if err != nil { - t.Fatalf("lecture du motif : %v", err) - } - if !strings.Contains(string(reason), "ERR-CAT-03") { - t.Errorf("motif : %s", reason) - } -} - -// TestARedirectionOffTheDeclaredHostIsRefused (§10.1). -func TestARedirectionOffTheDeclaredHostIsRefused(t *testing.T) { - elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, aCatalog) - })) - defer elsewhere.Close() - - // The share answers the PROPFIND normally, then sends the GET somewhere else. - remote := &share{content: aCatalog, present: true, modified: t0} - redirecting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - http.Redirect(w, r, elsewhere.URL+"/flv_2.csv", http.StatusFound) - return - } - remote.ServeHTTP(w, r) - })) - defer redirecting.Close() - - source, _, _ := station(t, remote, map[string]any{"url": redirecting.URL + "/catalogue/"}) - journal := &recorder{} - source.log = journal - ctx := context.Background() - - source.poll(ctx) - batch, err := source.poll(ctx) - if batch != nil || err != nil { - t.Fatalf("une redirection hors hôte a produit %v / %v", batch, err) - } - if len(journal.entries) == 0 || !strings.Contains(journal.entries[0].detail, "hors de l'hôte") { - t.Errorf("la redirection n'a pas été refusée en nommant l'hôte : %+v", journal.entries) - } -} - -// TestARedirectionMayNotDropTLS: the account of an https share never travels in clear. -// -// The rule is exercised THROUGH THE CLIENT'S OWN CheckRedirect and not through a server, -// because reproducing it end to end would mean an httptest TLS server, its self-signed -// certificate injected into a transport this package deliberately does not let anybody -// configure — a lot of scaffolding to observe one comparison. What matters is that the hop -// stays on the DECLARED host, which is what makes it invisible to the check above: net/http -// keeps the Authorization header on a same-host redirection. -func TestARedirectionMayNotDropTLS(t *testing.T) { - const host = "dav.example.org:8001" - client := newClient("https", host) - - hop := func(target string) error { - request, err := http.NewRequest(http.MethodGet, target, nil) - if err != nil { - t.Fatalf("requête %s : %v", target, err) - } - origin, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) - if err != nil { - t.Fatalf("requête d'origine : %v", err) - } - return client.CheckRedirect(request, []*http.Request{origin}) - } - - // The hole this test closes: same host, TLS dropped. - err := hop("http://" + host + "/depots/flv_2.csv") - if err == nil { - t.Fatal("une redirection https → http sur l'hôte déclaré a été acceptée") - } - if !strings.Contains(err.Error(), "en clair") { - t.Errorf("le refus ne dit pas que le compte partirait en clair : %v", err) - } - - // And what must keep working: the same host, still in TLS. - if err := hop("https://" + host + "/autre/flv_2.csv"); err != nil { - t.Errorf("une redirection https → https sur l'hôte déclaré a été refusée : %v", err) - } - - // A share DECLARED in http is not silently upgraded, and not refused either: the - // declared scheme is a floor, so a redirection towards TLS is worth following. - plain := newClient("http", host) - request, err := http.NewRequest(http.MethodGet, "https://"+host+"/depots/flv_2.csv", nil) - if err != nil { - t.Fatalf("requête : %v", err) - } - origin, err := http.NewRequest(http.MethodGet, "http://"+host+"/depots/flv_2.csv", nil) - if err != nil { - t.Fatalf("requête d'origine : %v", err) - } - if err := plain.CheckRedirect(request, []*http.Request{origin}); err != nil { - t.Errorf("une redirection http → https a été refusée : %v", err) - } -} - -// TestNextStopsWithItsContext, on the injected clock and with no sleep (§16.4). -func TestNextStopsWithItsContext(t *testing.T) { - remote := &share{present: false} - source, clock, _ := station(t, remote, nil) - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan error, 1) - go func() { - _, err := source.Next(ctx) - done <- err - }() - cancel() - - deadline := time.After(2 * time.Second) - for { - select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("Next a rendu %v", err) - } - return - case <-deadline: - t.Fatal("Next n'est pas sorti à l'annulation de son contexte") - default: - clock.Advance(5 * time.Second) - } - } -} - // TestTheDescriptorIsTheOnlyOneCarryingASecret (§10.1, control 41). func TestTheDescriptorIsTheOnlyOneCarryingASecret(t *testing.T) { descriptor := Descriptor() @@ -609,68 +298,3 @@ func TestTheDeclaredOptionsAreHonoured(t *testing.T) { t.Errorf("stable_polls = %d", source.stability.Polls()) } } - -// TestAListingThatSaysNothingUsableIsNotAFileToRead. -// -// A size that is not a number is a share that answers something else; a file with no -// date at all still works, with a slightly weaker stability rule — refusing it would -// mean refusing a catalog because a server does not send `getlastmodified`. -func TestAListingThatSaysNothingUsableIsNotAFileToRead(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, nil) - journal := &recorder{} - source.log = journal - ctx := context.Background() - - remote.brokenLength = true - if batch, err := source.poll(ctx); batch != nil || err != nil { - t.Fatalf("une taille illisible a produit %v / %v", batch, err) - } - if len(journal.entries) != 1 || !strings.Contains(journal.entries[0].detail, "taille annoncée") { - t.Errorf("la taille illisible n'a pas été nommée : %+v", journal.entries) - } - - remote.brokenLength, remote.noDate = false, true - source.poll(ctx) - batch, err := source.poll(ctx) - if err != nil || batch == nil { - t.Fatalf("un partage sans date a empêché la lecture : %v / %v", batch, err) - } -} - -// TestDeletingAFileSomebodyElseAlreadyRemovedIsASuccess: the acknowledgement has -// taken place, whoever performed it. -func TestDeletingAFileSomebodyElseAlreadyRemovedIsASuccess(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - source.poll(ctx) - batch, _ := source.poll(ctx) - remote.present = false // somebody else got there first - - if err := source.Acknowledge(ctx, batch, ports.BatchResult{Result: domain.ImportApplied}); err != nil { - t.Fatalf("acquittement d'un fichier déjà parti : %v", err) - } -} - -// TestCloseIsIdempotentAndReleasesTheCopyInFlight. -func TestCloseIsIdempotentAndReleasesTheCopyInFlight(t *testing.T) { - remote := &share{content: aCatalog, present: true, modified: t0} - source, _, _ := station(t, remote, nil) - ctx := context.Background() - - source.poll(ctx) - if _, err := source.poll(ctx); err != nil { - t.Fatalf("lecture : %v", err) - } - if err := source.Close(); err != nil { - t.Fatalf("fermeture : %v", err) - } - if names := archives(t, source); len(names) != 0 { - t.Errorf("archives %v, attendu aucune : la copie en vol est jetée", names) - } - if err := source.Close(); err != nil { - t.Errorf("seconde fermeture : %v", err) - } -} diff --git a/internal/diag/archive.go b/internal/diag/archive.go index 0cc5892..ee53012 100644 --- a/internal/diag/archive.go +++ b/internal/diag/archive.go @@ -3,15 +3,12 @@ package diag import ( "archive/zip" "context" - "encoding/csv" - "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" - "strconv" "strings" "time" @@ -32,6 +29,10 @@ import ( // exist — those are the mornings somebody presses the button. Each member records its // own failure in errors.txt and the archive is still valid, still readable, still // complete enough to work from. +// +// This file decides WHAT the archive holds and in what quantities. What each member LOOKS +// LIKE is in archive_members.go, and HOW one is written — scrubbed, stamped, or recorded as +// a failure — is in archive_writer.go, which is where the second rule above is enforced. // The quantities of §15.4, quoted from the document. const ( @@ -279,323 +280,3 @@ func readBounded(path string, limit int64) ([]byte, error) { defer file.Close() return io.ReadAll(io.LimitReader(file, limit)) } - -// --- The members ------------------------------------------------------------ - -// systemInfoMember is the « version + OS + uptime » of §15.4, plus the fingerprint a -// support call compares across the four stations of a fleet. -type systemInfoMember struct { - Version string `json:"version"` - Commit string `json:"commit"` - BuildDate string `json:"build_date"` - System SystemInfo `json:"system"` - Station int `json:"station"` - StationName string `json:"station_name"` - Coop string `json:"coop"` - Fingerprint string `json:"config_fingerprint"` - ConfigPath string `json:"config_path"` - DataDir string `json:"data_dir"` - // ServiceReached says whether the running station answered, so that a reader knows - // whether the device state in this archive is current or absent. - ServiceReached bool `json:"service_reached"` - ServiceDetail string `json:"service_detail,omitempty"` - // ServiceVersion is the version the RUNNING station reports, which may differ from the - // binary that produced this archive — §14.5 designs for exactly that. - ServiceVersion string `json:"service_version,omitempty"` -} - -// systemMember builds the identity member. -func systemMember(report Report, health Health, healthErr error) systemInfoMember { - out := systemInfoMember{ - Version: report.Version, Commit: report.Commit, BuildDate: report.BuildDate, - System: report.System, Station: report.Station, StationName: report.StationName, - Coop: report.Coop, Fingerprint: report.Fingerprint, - ConfigPath: report.ConfigPath, DataDir: report.DataDir, - ServiceReached: healthErr == nil, - } - if healthErr != nil { - out.ServiceDetail = healthErr.Error() - return out - } - out.ServiceVersion = health.Version - return out -} - -// reportText renders the doctor report, and never fails: an archive whose first member is -// missing because a writer returned an error is an archive nobody can start reading. -func reportText(report Report) string { - out := &strings.Builder{} - if err := report.WriteText(out); err != nil { - return "le rapport n'a pas pu être rendu : " + err.Error() - } - return out.String() -} - -// readme says what this archive is and, above all, what it does NOT contain. -// -// The second half is the important one: whoever receives it must not go looking for a -// password in it, and whoever sends it must be able to read, in French, that they are not -// publishing their cooperative's WebDAV address. -func readme(report Report) string { - out := &strings.Builder{} - fmt.Fprintf(out, "Fichier de diagnostic OpenScale\n") - fmt.Fprintf(out, "%s\n\n", strings.Repeat("=", 30)) - fmt.Fprintf(out, "Produit le %s pour le %s.\n", report.At.Format(clockLayout), report.stationLine()) - fmt.Fprintf(out, "Version %s.\n\n", report.versionLine()) - fmt.Fprintf(out, "%s\n\n", report.summaryLine()) - - fmt.Fprintf(out, "Ce que contient cette archive\n") - for _, line := range []string{ - "doctor.txt · doctor.json les contrôles, avec la consigne de chacun", - "system.json version, système, temps de fonctionnement, empreinte", - "config.redacted.json la configuration du poste, SANS AUCUN SECRET", - "health.json ce que le service répondait au moment de l'archive", - "weighings.csv les 200 dernières pesées", - "technical.csv les 500 derniers événements techniques", - "imports.csv les 20 derniers imports de catalogue", - "catalog.json l'inventaire du catalogue en service", - "frames.txt les 30 dernières trames de balance", - "labels/ les dernières étiquettes capturées, s'il y en a", - "errors.txt ce qui n'a pas pu être rassemblé, et pourquoi", - } { - fmt.Fprintf(out, " %s\n", line) - } - - fmt.Fprintf(out, "\nCe qu'elle ne contient pas, et ne contiendra jamais\n") - fmt.Fprintf(out, " Aucun mot de passe, aucune empreinte de mot de passe, aucun code de\n") - fmt.Fprintf(out, " secours, et aucune adresse privée. Les valeurs concernées sont\n") - fmt.Fprintf(out, " remplacées par %s, y compris dans les journaux : un message d'erreur\n", Marker) - fmt.Fprintf(out, " qui citait une adresse a été nettoyé lui aussi.\n") - fmt.Fprintf(out, " Vous pouvez envoyer ce fichier sans le relire.\n") - return out.String() -} - -// configFailure names why the configuration is not in the archive. -func configFailure(loaded loadedConfig) error { - switch { - case !loaded.Present: - return fmt.Errorf("le fichier de configuration n'a pas pu être lu : %w", loaded.Err) - case !loaded.Parsed: - return fmt.Errorf("le fichier de configuration n'est pas un JSON exploitable : %w", loaded.Err) - } - return errors.New("la configuration n'a pas pu être caviardée") -} - -// weighingHeader is the header of weighings.csv. -// -// It carries the RAW FRAME, which is the living corpus of §15.4: any frame that caused an -// unexplained refusal becomes a permanent test, and it can only do so if it left the shop. -var weighingHeader = []string{ - "occurred_at", "station", "job_id", "product_id", "product_name", "reference", "mode", - "gross_g", "tare_g", "net_g", "quantity", "barcode", "source", "stability", "rate_ms", - "result", "detail", "duration_ms", "frame", -} - -// weighingRows renders the journal page. -func weighingRows(page []domain.Weighing) [][]string { - out := make([][]string, 0, len(page)) - for _, row := range page { - out = append(out, []string{ - stamp(row.OccurredAt), strconv.Itoa(row.Station), row.JobID, - row.ProductID, row.ProductName, string(row.Reference), row.Mode.String(), - strconv.FormatInt(int64(row.GrossWeight), 10), - strconv.FormatInt(int64(row.Tare), 10), - strconv.FormatInt(int64(row.NetWeight), 10), - strconv.Itoa(row.Quantity), string(row.Barcode), row.Source, - row.Stability.String(), strconv.Itoa(row.RateMS), - row.Result, row.Detail, strconv.Itoa(row.DurationMS), row.Frame, - }) - } - return out -} - -// technicalHeader is the header of technical.csv. -var technicalHeader = []string{"occurred_at", "level", "source", "code", "message", "detail"} - -// technicalRows renders the technical journal. -func technicalRows(lines []TechnicalEntry) [][]string { - out := make([][]string, 0, len(lines)) - for _, line := range lines { - out = append(out, []string{ - stamp(line.OccurredAt), line.Level, line.Source, line.Code, line.Message, line.Detail, - }) - } - return out -} - -// importHeader is the header of imports.csv, and it is written the way §14.4 reads the -// inventory out loud: received, weighable, not weighable, anomalies. Never « en erreur ». -var importHeader = []string{ - "occurred_at", "source", "file_name", "result", "code", "reason", - "rows_read", "unreadable_rows", "weighable", "not_weighable", "anomalies", - "unit_mismatches", "images_decoded", "images_rejected", "products_withdrawn", "duration_ms", -} - -// importRows renders the import history. -func importRows(list []domain.Import) [][]string { - out := make([][]string, 0, len(list)) - for _, record := range list { - out = append(out, []string{ - stamp(record.OccurredAt), record.Source, record.FileName, - record.Result, record.Code, record.Reason, - strconv.Itoa(record.RowsRead), strconv.Itoa(record.UnreadableRows), - strconv.Itoa(record.Weighable), strconv.Itoa(record.NotWeighable), - strconv.Itoa(record.Anomalies), strconv.Itoa(record.UnitMismatches), - strconv.Itoa(record.ImagesDecoded), strconv.Itoa(record.ImagesRejected), - strconv.Itoa(record.ProductsWithdrawn), strconv.Itoa(record.DurationMS), - }) - } - return out -} - -// framesMember is the last raw frames, newest first, one per line. -// -// They come from the journal and not from a second capture: a frame that produced a weighing -// is a frame the station really received, timestamped, and joinable back to the weighing it -// explains. An empty frame is skipped rather than written as a blank line — a manual entry -// has no frame, and a blank line in this file would look like a frame nobody decoded. -func framesMember(page []domain.Weighing) string { - out := &strings.Builder{} - fmt.Fprintf(out, "# Les %d dernières trames de balance, la plus récente d'abord (§15.4).\n", archivedFrames) - fmt.Fprintf(out, "# Format : horodate · identifiant de pesée · trame brute.\n") - fmt.Fprintf(out, "# Toute trame ayant provoqué un refus inexpliqué devient un test permanent :\n") - fmt.Fprintf(out, "# elle se rejoue avec `openscale replay`, sans balance et sans se déplacer.\n\n") - - written := 0 - for _, row := range page { - if row.Frame == "" || written >= archivedFrames { - continue - } - fmt.Fprintf(out, "%s\t%s\t%s\n", stamp(row.OccurredAt), row.JobID, row.Frame) - written++ - } - if written == 0 { - fmt.Fprintf(out, "(aucune trame : ce poste n'a pesé qu'à la main, ou n'a pas encore pesé)\n") - } - return out.String() -} - -// stamp is how the archive spells an instant: UTC, RFC 3339, fixed width. -// -// UTC and not local time, unlike the terminal report: a CSV is opened in a spreadsheet -// months later, possibly in another timezone, and an instant with an offset that varies -// between summer and winter cannot be sorted as text. -func stamp(at time.Time) string { - if at.IsZero() { - return "" - } - return at.UTC().Format(time.RFC3339Nano) -} - -// --- The writer ------------------------------------------------------------- - -// memberWriter adds members to the archive, scrubbing every text one, and collects what -// went wrong instead of giving up. -type memberWriter struct { - zip *zip.Writer - clean *scrubber - clock interface{ Now() time.Time } - notes []string -} - -// text adds one text member, scrubbed. -func (m *memberWriter) text(name, content string) { - m.raw(name, []byte(m.clean.Clean(content))) -} - -// bytes adds one text member that is already a byte slice, scrubbed. -func (m *memberWriter) bytes(name string, content []byte) { - m.raw(name, m.clean.CleanBytes(content)) -} - -// json adds one member as indented JSON, scrubbed. -func (m *memberWriter) json(name string, value any) { - raw, err := json.MarshalIndent(value, "", " ") - if err != nil { - m.fail(name, err) - return - } - m.bytes(name, raw) -} - -// csv adds one member as a semicolon-separated CSV with a UTF-8 BOM, scrubbed. -// -// A semicolon and a BOM for the reason internal/web already gives: this file is opened in -// the spreadsheet of a French Windows, where a comma-separated file lands in one column. It -// is the same trade-off the producer's own export makes (§10.2). -func (m *memberWriter) csv(name string, header []string, rows [][]string) { - out := &strings.Builder{} - out.Write([]byte{0xEF, 0xBB, 0xBF}) - writer := csv.NewWriter(out) - writer.Comma = ';' - _ = writer.Write(header) - for _, row := range rows { - _ = writer.Write(row) - } - writer.Flush() - if err := writer.Error(); err != nil { - m.fail(name, err) - return - } - m.text(name, out.String()) -} - -// raw adds one member verbatim, WITHOUT scrubbing. It is for binary content only. -func (m *memberWriter) raw(name string, content []byte) { - entry, err := m.zip.CreateHeader(&zip.FileHeader{ - Name: name, - Method: zip.Deflate, - Modified: m.now(), - }) - if err != nil { - m.note(name, "membre non créé : "+err.Error()) - return - } - if _, err := entry.Write(content); err != nil { - m.note(name, "membre incomplet : "+err.Error()) - } -} - -// fail records that one member could not be built, and writes the reason where the reader -// will find it. -func (m *memberWriter) fail(name string, err error) { - m.note(name, err.Error()) -} - -// note records one line for errors.txt. -func (m *memberWriter) note(name, message string) { - m.notes = append(m.notes, name+" : "+message) -} - -// errorsMember writes what could not be gathered. -// -// It is written EVEN WHEN EMPTY, and that is the point: a reader who finds errors.txt saying -// « rien à signaler » knows the archive is complete, whereas a missing file could mean -// either « nothing failed » or « the archive was truncated ». -func (m *memberWriter) errorsMember() { - out := &strings.Builder{} - fmt.Fprintf(out, "# Ce qui n'a pas pu être rassemblé dans cette archive.\n") - fmt.Fprintf(out, "# Une archive incomplète reste utile : c'est justement les matins où quelque\n") - fmt.Fprintf(out, "# chose est cassé qu'on appuie sur ce bouton.\n\n") - if len(m.notes) == 0 { - fmt.Fprintf(out, "rien à signaler : tous les membres ont été écrits.\n") - } - for _, note := range m.notes { - fmt.Fprintf(out, "%s\n", note) - } - // Written through raw and scrubbed by hand: adding a member from inside the member - // writer must not be able to append to m.notes while it is being rendered. - m.raw("errors.txt", m.clean.CleanBytes([]byte(out.String()))) -} - -// now is the instant stamped on every member, read from the INJECTED clock. -// -// Every member carries the SAME instant, which is what makes an archive reproducible in a -// test: a member stamped from the wall clock would make two archives of one frozen station -// differ. -func (m *memberWriter) now() time.Time { - if m.clock == nil { - return time.Time{} - } - return m.clock.Now() -} diff --git a/internal/diag/archive_harness_test.go b/internal/diag/archive_harness_test.go new file mode 100644 index 0000000..89a4e0b --- /dev/null +++ b/internal/diag/archive_harness_test.go @@ -0,0 +1,254 @@ +package diag + +import ( + "archive/zip" + "bytes" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "openscale/internal/domain" +) + +// What makes the archive checkable: a station whose configuration carries REAL secrets and +// whose journal carries them again, a journal that answers or fails, and the readers that +// reopen the archive once it is produced. Nothing here asserts anything; the tests of +// archive_test.go are what do that. + +// --- The bench -------------------------------------------------------------- + +// archiveBench is a station whose configuration carries real secrets and whose journal +// carries them again, the way a failing WebDAV source really does. +type archiveBench struct { + *bench + journal *fakeJournal + journalFails bool + labels string + // damage runs on the configuration FILE once it is written and before the doctor reads + // it. It exists for the one case the bench cannot express through domain.Config: a + // document a station really carries and this binary cannot decode -- which no valid Go + // value can produce. + damage func(path string) +} + +// newArchiveBench builds it. +func newArchiveBench(t *testing.T) *archiveBench { + t.Helper() + base := newBench(t) + base.tweak(func(cfg *domain.Config) { + cfg.Catalog.Type = domain.CatalogSourceWebDAV + cfg.Catalog.Options = domain.DriverOptions{ + "url": json.RawMessage(`"` + secretWebDAVURL + `"`), + "username": json.RawMessage(`"balance"`), + "password": json.RawMessage(`"` + secretWebDAVPassword + `"`), + } + }) + + out := &archiveBench{bench: base, journal: newFakeJournal(), labels: filepath.Join(t.TempDir(), "labels")} + writeLabelFixtures(t, out.labels) + + // The payload the running station answered, secrets included. internal/web copies the + // last technical lines onto the dashboard, and a WebDAV failure puts the address there. + base.service.health.Raw = []byte(fmt.Sprintf( + `{"version":"1.0.0-test","events":[{"code":"ERR-CAT-01","message":"La source du catalogue `+ + `n'a pas pu être ouverte.","detail":%q}]}`, `Get "`+secretWebDAVURL+`": dial tcp: lookup `+secretWebDAVHost)) + return out +} + +// build produces the archive and opens it. +func (b *archiveBench) build() *zip.Reader { + b.t.Helper() + b.writeConfig() + if b.damage != nil { + b.damage(b.configPath) + } + doctor, err := New(b.options()) + if err != nil { + b.t.Fatalf("construction du doctor : %v", err) + } + journal := Journal(b.journal) + if b.journalFails { + journal = failingJournal{} + } + bundle, err := NewBundle(doctor, journal, b.labels) + if err != nil { + b.t.Fatalf("construction de l'archive : %v", err) + } + + out := &bytes.Buffer{} + if err := bundle.Diagnostic(context.Background(), out); err != nil { + b.t.Fatalf("écriture de l'archive : %v", err) + } + archive, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + if err != nil { + b.t.Fatalf("archive illisible : %v", err) + } + return archive +} + +// writeLabelFixtures drops more captured labels than the archive keeps, with increasing +// modification times, so that « the last five » is a real selection and not the whole +// directory. +func writeLabelFixtures(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("préparation du répertoire d'étiquettes : %v", err) + } + for i := 1; i <= 7; i++ { + for _, extension := range []string{".sbpl", ".png"} { + name := filepath.Join(dir, fmt.Sprintf("label-%02d%s", i, extension)) + if err := os.WriteFile(name, []byte(fmt.Sprintf("étiquette %d", i)), 0o644); err != nil { + t.Fatalf("écriture de %s : %v", name, err) + } + when := benchEpoch.Add(time.Duration(i) * time.Minute) + if err := os.Chtimes(name, when, when); err != nil { + t.Fatalf("horodatage de %s : %v", name, err) + } + } + } +} + +// --- The journal double ----------------------------------------------------- + +// fakeJournal is a station base that answers a fixed page. +type fakeJournal struct { + weighings []domain.Weighing + technical []TechnicalEntry + imports []domain.Import + counts CatalogCounts +} + +// newFakeJournal returns a base with something in every table, secrets included where a +// real one would carry them. +func newFakeJournal() *fakeJournal { + return &fakeJournal{ + weighings: []domain.Weighing{ + {ID: 2, OccurredAt: benchEpoch, Station: 2, JobID: "01J8Z", ProductID: "12345", + ProductName: "POMME GOLDEN", NetWeight: 1236, Result: "ok", + Frame: `\x02+0001236 g\x03`}, + {ID: 1, OccurredAt: benchEpoch.Add(-time.Minute), Station: 2, JobID: "01J8Y", + ProductID: "12346", ProductName: "CAROTTE", NetWeight: 800, Result: "ok", + Source: "manual_entry"}, + }, + technical: []TechnicalEntry{ + {OccurredAt: benchEpoch, Level: "error", Source: "catalog", Code: "ERR-CAT-01", + Message: "La source du catalogue n'a pas pu être ouverte.", + Detail: `Get "` + secretWebDAVURL + `": dial tcp: lookup ` + secretWebDAVHost}, + {OccurredAt: benchEpoch.Add(-time.Hour), Level: "info", Source: "printer", + Message: "Rouleau déclaré changé depuis l'écran de dépannage."}, + }, + imports: []domain.Import{ + {ID: 4, OccurredAt: benchEpoch, Source: domain.CatalogSourceWebDAV, FileName: "flv_2.csv", + Result: domain.ImportApplied, RowsRead: 355, Weighable: 331, NotWeighable: 8, + Anomalies: 16, UnitMismatches: 1}, + }, + counts: CatalogCounts{Products: 355, Weighable: 331, Withdrawn: 2, + ByCategory: map[string]int{"fruits": 120, "vegetables": 160, "bulk": 51}}, + } +} + +// technicalDetail is the detail line the leak test asserts on. +func (j *fakeJournal) technicalDetail() string { return j.technical[0].Detail } + +func (j *fakeJournal) Weighings(_ context.Context, limit int) ([]domain.Weighing, error) { + return capped(j.weighings, limit), nil +} + +func (j *fakeJournal) TechnicalEntries(_ context.Context, limit int) ([]TechnicalEntry, error) { + return capped(j.technical, limit), nil +} + +func (j *fakeJournal) Imports(_ context.Context, limit int) ([]domain.Import, error) { + return capped(j.imports, limit), nil +} + +func (j *fakeJournal) CatalogCounts(context.Context) (CatalogCounts, error) { + return j.counts, nil +} + +// capped truncates a page the way a real query would. +func capped[T any](page []T, limit int) []T { + if limit > 0 && len(page) > limit { + return page[:limit] + } + return page +} + +// failingJournal is a base that answers nothing, which is what a corrupt one does. +type failingJournal struct{} + +var errNoJournal = errors.New("base illisible : contrôle d'intégrité en échec") + +func (failingJournal) Weighings(context.Context, int) ([]domain.Weighing, error) { + return nil, errNoJournal +} + +func (failingJournal) TechnicalEntries(context.Context, int) ([]TechnicalEntry, error) { + return nil, errNoJournal +} + +func (failingJournal) Imports(context.Context, int) ([]domain.Import, error) { + return nil, errNoJournal +} + +func (failingJournal) CatalogCounts(context.Context) (CatalogCounts, error) { + return CatalogCounts{}, errNoJournal +} + +// --- Reading the archive ---------------------------------------------------- + +// readMember decompresses one member. +func readMember(t *testing.T, member *zip.File) []byte { + t.Helper() + reader, err := member.Open() + if err != nil { + t.Fatalf("%s : %v", member.Name, err) + } + defer reader.Close() + content, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("%s : %v", member.Name, err) + } + return content +} + +// readNamed decompresses the member with that name. +func readNamed(t *testing.T, archive *zip.Reader, name string) []byte { + t.Helper() + for _, member := range archive.File { + if member.Name == name { + return readMember(t, member) + } + } + t.Fatalf("l'archive ne porte aucun membre %q", name) + return nil +} + +// readCSV parses one CSV member, BOM and semicolon included. +func readCSV(t *testing.T, archive *zip.Reader, name string) [][]string { + t.Helper() + content := readNamed(t, archive, name) + content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) + reader := csv.NewReader(bytes.NewReader(content)) + reader.Comma = ';' + rows, err := reader.ReadAll() + if err != nil { + t.Fatalf("%s : %v", name, err) + } + return rows +} + +// quoteAround shows the leak in its context, so that a failing test names the member AND the +// line that has to be fixed. +func quoteAround(content []byte, secret string) string { + at := bytes.Index(content, []byte(secret)) + from, to := max(0, at-60), min(len(content), at+len(secret)+60) + return "…" + string(content[from:to]) + "…" +} diff --git a/internal/diag/archive_members.go b/internal/diag/archive_members.go new file mode 100644 index 0000000..2b88ca0 --- /dev/null +++ b/internal/diag/archive_members.go @@ -0,0 +1,221 @@ +package diag + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "openscale/internal/domain" +) + +// This file renders the members of diagnostic.zip: the two a human opens first — README.txt +// and the identity of the station — and the four tables that come out of the base. Nothing +// here writes into the archive; it hands strings and rows to the writer, which is what makes +// every one of these testable without a zip. + +// systemInfoMember is the « version + OS + uptime » of §15.4, plus the fingerprint a +// support call compares across the four stations of a fleet. +type systemInfoMember struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"build_date"` + System SystemInfo `json:"system"` + Station int `json:"station"` + StationName string `json:"station_name"` + Coop string `json:"coop"` + Fingerprint string `json:"config_fingerprint"` + ConfigPath string `json:"config_path"` + DataDir string `json:"data_dir"` + // ServiceReached says whether the running station answered, so that a reader knows + // whether the device state in this archive is current or absent. + ServiceReached bool `json:"service_reached"` + ServiceDetail string `json:"service_detail,omitempty"` + // ServiceVersion is the version the RUNNING station reports, which may differ from the + // binary that produced this archive — §14.5 designs for exactly that. + ServiceVersion string `json:"service_version,omitempty"` +} + +// systemMember builds the identity member. +func systemMember(report Report, health Health, healthErr error) systemInfoMember { + out := systemInfoMember{ + Version: report.Version, Commit: report.Commit, BuildDate: report.BuildDate, + System: report.System, Station: report.Station, StationName: report.StationName, + Coop: report.Coop, Fingerprint: report.Fingerprint, + ConfigPath: report.ConfigPath, DataDir: report.DataDir, + ServiceReached: healthErr == nil, + } + if healthErr != nil { + out.ServiceDetail = healthErr.Error() + return out + } + out.ServiceVersion = health.Version + return out +} + +// reportText renders the doctor report, and never fails: an archive whose first member is +// missing because a writer returned an error is an archive nobody can start reading. +func reportText(report Report) string { + out := &strings.Builder{} + if err := report.WriteText(out); err != nil { + return "le rapport n'a pas pu être rendu : " + err.Error() + } + return out.String() +} + +// readme says what this archive is and, above all, what it does NOT contain. +// +// The second half is the important one: whoever receives it must not go looking for a +// password in it, and whoever sends it must be able to read, in French, that they are not +// publishing their cooperative's WebDAV address. +func readme(report Report) string { + out := &strings.Builder{} + fmt.Fprintf(out, "Fichier de diagnostic OpenScale\n") + fmt.Fprintf(out, "%s\n\n", strings.Repeat("=", 30)) + fmt.Fprintf(out, "Produit le %s pour le %s.\n", report.At.Format(clockLayout), report.stationLine()) + fmt.Fprintf(out, "Version %s.\n\n", report.versionLine()) + fmt.Fprintf(out, "%s\n\n", report.summaryLine()) + + fmt.Fprintf(out, "Ce que contient cette archive\n") + for _, line := range []string{ + "doctor.txt · doctor.json les contrôles, avec la consigne de chacun", + "system.json version, système, temps de fonctionnement, empreinte", + "config.redacted.json la configuration du poste, SANS AUCUN SECRET", + "health.json ce que le service répondait au moment de l'archive", + "weighings.csv les 200 dernières pesées", + "technical.csv les 500 derniers événements techniques", + "imports.csv les 20 derniers imports de catalogue", + "catalog.json l'inventaire du catalogue en service", + "frames.txt les 30 dernières trames de balance", + "labels/ les dernières étiquettes capturées, s'il y en a", + "errors.txt ce qui n'a pas pu être rassemblé, et pourquoi", + } { + fmt.Fprintf(out, " %s\n", line) + } + + fmt.Fprintf(out, "\nCe qu'elle ne contient pas, et ne contiendra jamais\n") + fmt.Fprintf(out, " Aucun mot de passe, aucune empreinte de mot de passe, aucun code de\n") + fmt.Fprintf(out, " secours, et aucune adresse privée. Les valeurs concernées sont\n") + fmt.Fprintf(out, " remplacées par %s, y compris dans les journaux : un message d'erreur\n", Marker) + fmt.Fprintf(out, " qui citait une adresse a été nettoyé lui aussi.\n") + fmt.Fprintf(out, " Vous pouvez envoyer ce fichier sans le relire.\n") + return out.String() +} + +// configFailure names why the configuration is not in the archive. +func configFailure(loaded loadedConfig) error { + switch { + case !loaded.Present: + return fmt.Errorf("le fichier de configuration n'a pas pu être lu : %w", loaded.Err) + case !loaded.Parsed: + return fmt.Errorf("le fichier de configuration n'est pas un JSON exploitable : %w", loaded.Err) + } + return errors.New("la configuration n'a pas pu être caviardée") +} + +// weighingHeader is the header of weighings.csv. +// +// It carries the RAW FRAME, which is the living corpus of §15.4: any frame that caused an +// unexplained refusal becomes a permanent test, and it can only do so if it left the shop. +var weighingHeader = []string{ + "occurred_at", "station", "job_id", "product_id", "product_name", "reference", "mode", + "gross_g", "tare_g", "net_g", "quantity", "barcode", "source", "stability", "rate_ms", + "result", "detail", "duration_ms", "frame", +} + +// weighingRows renders the journal page. +func weighingRows(page []domain.Weighing) [][]string { + out := make([][]string, 0, len(page)) + for _, row := range page { + out = append(out, []string{ + stamp(row.OccurredAt), strconv.Itoa(row.Station), row.JobID, + row.ProductID, row.ProductName, string(row.Reference), row.Mode.String(), + strconv.FormatInt(int64(row.GrossWeight), 10), + strconv.FormatInt(int64(row.Tare), 10), + strconv.FormatInt(int64(row.NetWeight), 10), + strconv.Itoa(row.Quantity), string(row.Barcode), row.Source, + row.Stability.String(), strconv.Itoa(row.RateMS), + row.Result, row.Detail, strconv.Itoa(row.DurationMS), row.Frame, + }) + } + return out +} + +// technicalHeader is the header of technical.csv. +var technicalHeader = []string{"occurred_at", "level", "source", "code", "message", "detail"} + +// technicalRows renders the technical journal. +func technicalRows(lines []TechnicalEntry) [][]string { + out := make([][]string, 0, len(lines)) + for _, line := range lines { + out = append(out, []string{ + stamp(line.OccurredAt), line.Level, line.Source, line.Code, line.Message, line.Detail, + }) + } + return out +} + +// importHeader is the header of imports.csv, and it is written the way §14.4 reads the +// inventory out loud: received, weighable, not weighable, anomalies. Never « en erreur ». +var importHeader = []string{ + "occurred_at", "source", "file_name", "result", "code", "reason", + "rows_read", "unreadable_rows", "weighable", "not_weighable", "anomalies", + "unit_mismatches", "images_decoded", "images_rejected", "products_withdrawn", "duration_ms", +} + +// importRows renders the import history. +func importRows(list []domain.Import) [][]string { + out := make([][]string, 0, len(list)) + for _, record := range list { + out = append(out, []string{ + stamp(record.OccurredAt), record.Source, record.FileName, + record.Result, record.Code, record.Reason, + strconv.Itoa(record.RowsRead), strconv.Itoa(record.UnreadableRows), + strconv.Itoa(record.Weighable), strconv.Itoa(record.NotWeighable), + strconv.Itoa(record.Anomalies), strconv.Itoa(record.UnitMismatches), + strconv.Itoa(record.ImagesDecoded), strconv.Itoa(record.ImagesRejected), + strconv.Itoa(record.ProductsWithdrawn), strconv.Itoa(record.DurationMS), + }) + } + return out +} + +// framesMember is the last raw frames, newest first, one per line. +// +// They come from the journal and not from a second capture: a frame that produced a weighing +// is a frame the station really received, timestamped, and joinable back to the weighing it +// explains. An empty frame is skipped rather than written as a blank line — a manual entry +// has no frame, and a blank line in this file would look like a frame nobody decoded. +func framesMember(page []domain.Weighing) string { + out := &strings.Builder{} + fmt.Fprintf(out, "# Les %d dernières trames de balance, la plus récente d'abord (§15.4).\n", archivedFrames) + fmt.Fprintf(out, "# Format : horodate · identifiant de pesée · trame brute.\n") + fmt.Fprintf(out, "# Toute trame ayant provoqué un refus inexpliqué devient un test permanent :\n") + fmt.Fprintf(out, "# elle se rejoue avec `openscale replay`, sans balance et sans se déplacer.\n\n") + + written := 0 + for _, row := range page { + if row.Frame == "" || written >= archivedFrames { + continue + } + fmt.Fprintf(out, "%s\t%s\t%s\n", stamp(row.OccurredAt), row.JobID, row.Frame) + written++ + } + if written == 0 { + fmt.Fprintf(out, "(aucune trame : ce poste n'a pesé qu'à la main, ou n'a pas encore pesé)\n") + } + return out.String() +} + +// stamp is how the archive spells an instant: UTC, RFC 3339, fixed width. +// +// UTC and not local time, unlike the terminal report: a CSV is opened in a spreadsheet +// months later, possibly in another timezone, and an instant with an offset that varies +// between summer and winter cannot be sorted as text. +func stamp(at time.Time) string { + if at.IsZero() { + return "" + } + return at.UTC().Format(time.RFC3339Nano) +} diff --git a/internal/diag/archive_test.go b/internal/diag/archive_test.go index 22c96c1..23187b0 100644 --- a/internal/diag/archive_test.go +++ b/internal/diag/archive_test.go @@ -1,23 +1,18 @@ package diag import ( - "archive/zip" "bytes" - "context" - "encoding/csv" "encoding/json" "errors" - "fmt" - "io" - "os" - "path/filepath" "strings" "testing" - "time" - - "openscale/internal/domain" ) +// What diagnostic.zip has to hold, and above all what it must NEVER carry away. The +// assertions do not read the code and do not trust the redaction: they produce the archive, +// open it, decompress every member and look for the values themselves. The bench, the +// journal double and the archive readers are in archive_harness_test.go. + // The secrets this station carries. They are the ones a real installation has: the two // argon2id strings of the delivered configuration file, a WebDAV password, and a private // address with the credentials embedded in it — the form net/http quotes verbatim in an @@ -281,228 +276,3 @@ func TestTheArchiveNamesWhatItDoesNotContain(t *testing.T) { } } } - -// --- The bench -------------------------------------------------------------- - -// archiveBench is a station whose configuration carries real secrets and whose journal -// carries them again, the way a failing WebDAV source really does. -type archiveBench struct { - *bench - journal *fakeJournal - journalFails bool - labels string - // damage runs on the configuration FILE once it is written and before the doctor reads - // it. It exists for the one case the bench cannot express through domain.Config: a - // document a station really carries and this binary cannot decode -- which no valid Go - // value can produce. - damage func(path string) -} - -// newArchiveBench builds it. -func newArchiveBench(t *testing.T) *archiveBench { - t.Helper() - base := newBench(t) - base.tweak(func(cfg *domain.Config) { - cfg.Catalog.Type = domain.CatalogSourceWebDAV - cfg.Catalog.Options = domain.DriverOptions{ - "url": json.RawMessage(`"` + secretWebDAVURL + `"`), - "username": json.RawMessage(`"balance"`), - "password": json.RawMessage(`"` + secretWebDAVPassword + `"`), - } - }) - - out := &archiveBench{bench: base, journal: newFakeJournal(), labels: filepath.Join(t.TempDir(), "labels")} - writeLabelFixtures(t, out.labels) - - // The payload the running station answered, secrets included. internal/web copies the - // last technical lines onto the dashboard, and a WebDAV failure puts the address there. - base.service.health.Raw = []byte(fmt.Sprintf( - `{"version":"1.0.0-test","events":[{"code":"ERR-CAT-01","message":"La source du catalogue `+ - `n'a pas pu être ouverte.","detail":%q}]}`, `Get "`+secretWebDAVURL+`": dial tcp: lookup `+secretWebDAVHost)) - return out -} - -// build produces the archive and opens it. -func (b *archiveBench) build() *zip.Reader { - b.t.Helper() - b.writeConfig() - if b.damage != nil { - b.damage(b.configPath) - } - doctor, err := New(b.options()) - if err != nil { - b.t.Fatalf("construction du doctor : %v", err) - } - journal := Journal(b.journal) - if b.journalFails { - journal = failingJournal{} - } - bundle, err := NewBundle(doctor, journal, b.labels) - if err != nil { - b.t.Fatalf("construction de l'archive : %v", err) - } - - out := &bytes.Buffer{} - if err := bundle.Diagnostic(context.Background(), out); err != nil { - b.t.Fatalf("écriture de l'archive : %v", err) - } - archive, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - if err != nil { - b.t.Fatalf("archive illisible : %v", err) - } - return archive -} - -// writeLabelFixtures drops more captured labels than the archive keeps, with increasing -// modification times, so that « the last five » is a real selection and not the whole -// directory. -func writeLabelFixtures(t *testing.T, dir string) { - t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("préparation du répertoire d'étiquettes : %v", err) - } - for i := 1; i <= 7; i++ { - for _, extension := range []string{".sbpl", ".png"} { - name := filepath.Join(dir, fmt.Sprintf("label-%02d%s", i, extension)) - if err := os.WriteFile(name, []byte(fmt.Sprintf("étiquette %d", i)), 0o644); err != nil { - t.Fatalf("écriture de %s : %v", name, err) - } - when := benchEpoch.Add(time.Duration(i) * time.Minute) - if err := os.Chtimes(name, when, when); err != nil { - t.Fatalf("horodatage de %s : %v", name, err) - } - } - } -} - -// --- The journal double ----------------------------------------------------- - -// fakeJournal is a station base that answers a fixed page. -type fakeJournal struct { - weighings []domain.Weighing - technical []TechnicalEntry - imports []domain.Import - counts CatalogCounts -} - -// newFakeJournal returns a base with something in every table, secrets included where a -// real one would carry them. -func newFakeJournal() *fakeJournal { - return &fakeJournal{ - weighings: []domain.Weighing{ - {ID: 2, OccurredAt: benchEpoch, Station: 2, JobID: "01J8Z", ProductID: "12345", - ProductName: "POMME GOLDEN", NetWeight: 1236, Result: "ok", - Frame: `\x02+0001236 g\x03`}, - {ID: 1, OccurredAt: benchEpoch.Add(-time.Minute), Station: 2, JobID: "01J8Y", - ProductID: "12346", ProductName: "CAROTTE", NetWeight: 800, Result: "ok", - Source: "manual_entry"}, - }, - technical: []TechnicalEntry{ - {OccurredAt: benchEpoch, Level: "error", Source: "catalog", Code: "ERR-CAT-01", - Message: "La source du catalogue n'a pas pu être ouverte.", - Detail: `Get "` + secretWebDAVURL + `": dial tcp: lookup ` + secretWebDAVHost}, - {OccurredAt: benchEpoch.Add(-time.Hour), Level: "info", Source: "printer", - Message: "Rouleau déclaré changé depuis l'écran de dépannage."}, - }, - imports: []domain.Import{ - {ID: 4, OccurredAt: benchEpoch, Source: domain.CatalogSourceWebDAV, FileName: "flv_2.csv", - Result: domain.ImportApplied, RowsRead: 355, Weighable: 331, NotWeighable: 8, - Anomalies: 16, UnitMismatches: 1}, - }, - counts: CatalogCounts{Products: 355, Weighable: 331, Withdrawn: 2, - ByCategory: map[string]int{"fruits": 120, "vegetables": 160, "bulk": 51}}, - } -} - -// technicalDetail is the detail line the leak test asserts on. -func (j *fakeJournal) technicalDetail() string { return j.technical[0].Detail } - -func (j *fakeJournal) Weighings(_ context.Context, limit int) ([]domain.Weighing, error) { - return capped(j.weighings, limit), nil -} -func (j *fakeJournal) TechnicalEntries(_ context.Context, limit int) ([]TechnicalEntry, error) { - return capped(j.technical, limit), nil -} -func (j *fakeJournal) Imports(_ context.Context, limit int) ([]domain.Import, error) { - return capped(j.imports, limit), nil -} -func (j *fakeJournal) CatalogCounts(context.Context) (CatalogCounts, error) { - return j.counts, nil -} - -// capped truncates a page the way a real query would. -func capped[T any](page []T, limit int) []T { - if limit > 0 && len(page) > limit { - return page[:limit] - } - return page -} - -// failingJournal is a base that answers nothing, which is what a corrupt one does. -type failingJournal struct{} - -var errNoJournal = errors.New("base illisible : contrôle d'intégrité en échec") - -func (failingJournal) Weighings(context.Context, int) ([]domain.Weighing, error) { - return nil, errNoJournal -} -func (failingJournal) TechnicalEntries(context.Context, int) ([]TechnicalEntry, error) { - return nil, errNoJournal -} -func (failingJournal) Imports(context.Context, int) ([]domain.Import, error) { - return nil, errNoJournal -} -func (failingJournal) CatalogCounts(context.Context) (CatalogCounts, error) { - return CatalogCounts{}, errNoJournal -} - -// --- Reading the archive ---------------------------------------------------- - -// readMember decompresses one member. -func readMember(t *testing.T, member *zip.File) []byte { - t.Helper() - reader, err := member.Open() - if err != nil { - t.Fatalf("%s : %v", member.Name, err) - } - defer reader.Close() - content, err := io.ReadAll(reader) - if err != nil { - t.Fatalf("%s : %v", member.Name, err) - } - return content -} - -// readNamed decompresses the member with that name. -func readNamed(t *testing.T, archive *zip.Reader, name string) []byte { - t.Helper() - for _, member := range archive.File { - if member.Name == name { - return readMember(t, member) - } - } - t.Fatalf("l'archive ne porte aucun membre %q", name) - return nil -} - -// readCSV parses one CSV member, BOM and semicolon included. -func readCSV(t *testing.T, archive *zip.Reader, name string) [][]string { - t.Helper() - content := readNamed(t, archive, name) - content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) - reader := csv.NewReader(bytes.NewReader(content)) - reader.Comma = ';' - rows, err := reader.ReadAll() - if err != nil { - t.Fatalf("%s : %v", name, err) - } - return rows -} - -// quoteAround shows the leak in its context, so that a failing test names the member AND the -// line that has to be fixed. -func quoteAround(content []byte, secret string) string { - at := bytes.Index(content, []byte(secret)) - from, to := max(0, at-60), min(len(content), at+len(secret)+60) - return "…" + string(content[from:to]) + "…" -} diff --git a/internal/diag/archive_writer.go b/internal/diag/archive_writer.go new file mode 100644 index 0000000..bf5cffe --- /dev/null +++ b/internal/diag/archive_writer.go @@ -0,0 +1,127 @@ +package diag + +import ( + "archive/zip" + "encoding/csv" + "encoding/json" + "fmt" + "strings" + "time" +) + +// This file is how one member gets into diagnostic.zip: scrubbed unless it is binary, +// stamped on the injected clock, and — when it cannot be built at all — recorded in +// errors.txt instead of aborting the archive. It is the enforcement point of the second +// rule of archive.go, and it is deliberately the only thing in this package that touches +// the zip writer. + +// memberWriter adds members to the archive, scrubbing every text one, and collects what +// went wrong instead of giving up. +type memberWriter struct { + zip *zip.Writer + clean *scrubber + clock interface{ Now() time.Time } + notes []string +} + +// text adds one text member, scrubbed. +func (m *memberWriter) text(name, content string) { + m.raw(name, []byte(m.clean.Clean(content))) +} + +// bytes adds one text member that is already a byte slice, scrubbed. +func (m *memberWriter) bytes(name string, content []byte) { + m.raw(name, m.clean.CleanBytes(content)) +} + +// json adds one member as indented JSON, scrubbed. +func (m *memberWriter) json(name string, value any) { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + m.fail(name, err) + return + } + m.bytes(name, raw) +} + +// csv adds one member as a semicolon-separated CSV with a UTF-8 BOM, scrubbed. +// +// A semicolon and a BOM for the reason internal/web already gives: this file is opened in +// the spreadsheet of a French Windows, where a comma-separated file lands in one column. It +// is the same trade-off the producer's own export makes (§10.2). +func (m *memberWriter) csv(name string, header []string, rows [][]string) { + out := &strings.Builder{} + out.Write([]byte{0xEF, 0xBB, 0xBF}) + writer := csv.NewWriter(out) + writer.Comma = ';' + _ = writer.Write(header) + for _, row := range rows { + _ = writer.Write(row) + } + writer.Flush() + if err := writer.Error(); err != nil { + m.fail(name, err) + return + } + m.text(name, out.String()) +} + +// raw adds one member verbatim, WITHOUT scrubbing. It is for binary content only. +func (m *memberWriter) raw(name string, content []byte) { + entry, err := m.zip.CreateHeader(&zip.FileHeader{ + Name: name, + Method: zip.Deflate, + Modified: m.now(), + }) + if err != nil { + m.note(name, "membre non créé : "+err.Error()) + return + } + if _, err := entry.Write(content); err != nil { + m.note(name, "membre incomplet : "+err.Error()) + } +} + +// fail records that one member could not be built, and writes the reason where the reader +// will find it. +func (m *memberWriter) fail(name string, err error) { + m.note(name, err.Error()) +} + +// note records one line for errors.txt. +func (m *memberWriter) note(name, message string) { + m.notes = append(m.notes, name+" : "+message) +} + +// errorsMember writes what could not be gathered. +// +// It is written EVEN WHEN EMPTY, and that is the point: a reader who finds errors.txt saying +// « rien à signaler » knows the archive is complete, whereas a missing file could mean +// either « nothing failed » or « the archive was truncated ». +func (m *memberWriter) errorsMember() { + out := &strings.Builder{} + fmt.Fprintf(out, "# Ce qui n'a pas pu être rassemblé dans cette archive.\n") + fmt.Fprintf(out, "# Une archive incomplète reste utile : c'est justement les matins où quelque\n") + fmt.Fprintf(out, "# chose est cassé qu'on appuie sur ce bouton.\n\n") + if len(m.notes) == 0 { + fmt.Fprintf(out, "rien à signaler : tous les membres ont été écrits.\n") + } + for _, note := range m.notes { + fmt.Fprintf(out, "%s\n", note) + } + // Written through raw and scrubbed by hand: adding a member from inside the member + // writer must not be able to append to m.notes while it is being rendered. + m.raw("errors.txt", m.clean.CleanBytes([]byte(out.String()))) +} + +// now is the instant stamped on every member, read from the INJECTED clock. +// +// Every member carries the SAME instant, which is what makes an archive reproducible in a +// test: a member stamped from the wall clock would make two archives of one frozen station +// differ. +func (m *memberWriter) now() time.Time { + if m.clock == nil { + return time.Time{} + } + return m.clock.Now() +} diff --git a/internal/diag/doctor.go b/internal/diag/doctor.go index 8a38a81..e388485 100644 --- a/internal/diag/doctor.go +++ b/internal/diag/doctor.go @@ -4,16 +4,18 @@ import ( "context" "errors" "fmt" - "os" - "runtime" - "strings" - "time" "openscale/internal/domain" "openscale/internal/platform" "openscale/internal/station/ports" ) +// This file is the doctor itself and nothing else: what it is given, what it opens and +// reads ONCE, the order it puts the controls in, and the handful of things every family of +// controls uses. The controls live beside it, one file per family — doctor_service.go, +// doctor_storage.go, doctor_devices.go, doctor_config.go, doctor_system.go — and Run below +// is the only place their order is declared. + // Database is the station base as the two database controls read it. // // Declared HERE, on the consumer's side: *store.DB satisfies it as it stands, and a test @@ -210,6 +212,14 @@ func (d *Doctor) askTheService(ctx context.Context) (Health, error) { return d.o.Service.Health(ctx) } +// liveness asks /healthz, and only to tell « held by us » from « held by something else ». +func (d *Doctor) liveness(ctx context.Context) (Liveness, error) { + if d.o.Service == nil { + return Liveness{}, ErrServiceSilent + } + return d.o.Service.Liveness(ctx) +} + // openBase opens the station base for the length of the run. func (d *Doctor) openBase() (Database, error) { if d.o.OpenDatabase == nil { @@ -265,1025 +275,7 @@ func wholeDocumentFault(faults []domain.Fault) *domain.Fault { return nil } -// --- 1. The service --------------------------------------------------------- - -func (d *Doctor) checkService(ctx context.Context) Control { - control := Control{ID: ControlService, Checked: "Service OpenScale présent et démarré"} - state, err := d.o.Machine.Service(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, "le gestionnaire de services n'a pas répondu : "+err.Error() - control.Remedy = "Relancez cette commande depuis une invite ADMINISTRATEUR : l'état d'un " + - "service n'est pas lisible par tout le monde." - case !state.Determined: - control.Status, control.Observed = StatusUnknown, "ce système n'expose pas de gestionnaire de services interrogeable" - control.Remedy = "Vérifiez à la main que le poste est lancé, puis ouvrez http://" + - "127.0.0.1:8080/ sur l'écran : si la page s'affiche, le service tourne." - case !state.Known: - control.Status = StatusFail - control.Observed = fmt.Sprintf("aucun service « %s » n'est déclaré sur ce poste", state.Name) - control.Remedy = serviceInstallRemedy() - case !state.Running: - control.Status = StatusFail - control.Observed = fmt.Sprintf("le service « %s » est installé mais arrêté (%s)", state.Name, state.Detail) - // THIS is the sentence the L8 criterion asks for: doctor diagnoses a service that - // will not start AND SAYS WHY — by naming the four controls that carry the reason. - control.Remedy = serviceStartRemedy() - case !state.Automatic: - control.Status = StatusWarn - control.Observed = fmt.Sprintf("le service « %s » tourne, et son démarrage n'est pas automatique (%s)", - state.Name, state.Detail) - control.Remedy = "Après une coupure de courant, ce poste ne redémarrera pas seul. Passez-le " + - "en démarrage automatique : sc config OpenScale start= auto\n" + - "Si ce poste est le poste pilote, c'est voulu (§18, lot L9) et il n'y a rien à faire." - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("le service « %s » tourne, démarrage automatique (%s)", - state.Name, state.Detail) - } - return control -} - -// serviceInstallRemedy is the instruction for a service the manager has never heard of. -func serviceInstallRemedy() string { - if runtime.GOOS == "windows" { - return "Relancez install.ps1 en administrateur (§15.2), ou installez le service à la main :\n" + - `"C:\Program Files\OpenScale\openscale.exe" service install` + "\n" + - "puis sc config OpenScale start= auto" - } - return "Installez l'unité : systemctl enable --now openscale.service (§15.3)." -} - -// serviceStartRemedy names WHERE the reason for a failed start is written. -func serviceStartRemedy() string { - command := "systemctl start openscale.service" - logs := "journalctl -u openscale.service -n 50" - if runtime.GOOS == "windows" { - command = "sc start OpenScale" - logs = `le fichier C:\ProgramData\OpenScale\data\logs\openscale.log` - } - return "Démarrez-le : " + command + "\nS'il s'arrête aussitôt, la raison est dans l'un des " + - "contrôles 6, 7, 8 ou 10 ci-dessous — adresse d'écoute déjà prise, configuration " + - "illisible, base inutilisable, port série absent — et le détail est dans " + logs + "." -} - -// --- 2. The kiosk task ------------------------------------------------------ - -func (d *Doctor) checkKioskTask(ctx context.Context) Control { - control := Control{ID: ControlKioskTask, Checked: "Tâche du kiosque présente"} - state, err := d.o.Machine.KioskTask(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, "la tâche du kiosque n'a pas pu être interrogée : "+err.Error() - control.Remedy = "Relancez cette commande depuis une invite ADMINISTRATEUR : le dossier " + - "des tâches planifiées n'est pas lisible par tout le monde, et « je n'ai pas pu " + - "regarder » n'est pas « la tâche est absente ».\n" + - "Tant que ce contrôle est INCONNU, ne réinstallez rien." - case !state.Determined: - control.Status, control.Observed = StatusUnknown, "ce système n'expose pas de planificateur interrogeable" - control.Remedy = "Vérifiez à la main qu'un navigateur en plein écran s'ouvre à l'ouverture de session." - case !state.Known: - control.Status = StatusFail - control.Observed = fmt.Sprintf("aucune tâche « %s » n'est déclarée", state.Name) - control.Remedy = kioskInstallRemedy() - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("la tâche « %s » est déclarée (%s)", state.Name, or(state.Detail, "état non lu")) - } - return control -} - -// kioskInstallRemedy is the instruction for a missing kiosk task. -// -// It says what the ABSENCE costs, because a volunteer reading « tâche absente » has no -// way of knowing that the service can be perfectly healthy while the screen stays black. -func kioskInstallRemedy() string { - if runtime.GOOS == "windows" { - return "Sans elle, le service tourne mais l'écran client ne s'ouvre jamais. Relancez " + - "install.ps1 en administrateur (§15.2), ou recréez la tâche :\n" + - `schtasks /create /tn "OpenScale-Kiosk" /xml openscale-kiosk.xml /f` - } - return "Sans elle, le service tourne mais l'écran client ne s'ouvre jamais. Activez l'unité " + - "du kiosque : systemctl enable --now openscale-kiosk.service (§15.3)." -} - -// --- 3. Unattended restart -------------------------------------------------- - -// codeUnattendedRestart is ERR-SYS-08, and §14.4 allocates it to exactly this fact. -const codeUnattendedRestart = "ERR-SYS-08" - -func (d *Doctor) checkUnattendedRestart(ctx context.Context) Control { - return UnattendedRestartControl(ctx, d.o.Machine) -} - -// UnattendedRestartControl is control 3, and it is EXPORTED because §14.4 puts the same -// verdict on the administration dashboard (bloquant-7). -// -// One function for the two readers. A volunteer reading « redémarrage sans intervention : -// NON CONFIGURÉ » on the screen and whoever reads `doctor.txt` an hour later are looking -// at the same registry key, and two implementations of the same three conditions would -// eventually tell them two different things about it. -func UnattendedRestartControl(ctx context.Context, machine Machine) Control { - control := Control{ID: ControlUnattendedRestart, - Checked: "Redémarrage sans intervention configuré (OUI / NON)"} - state, err := machine.AutoLogon(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, "la configuration du redémarrage n'a pas pu être lue : "+err.Error() - control.Remedy = "Relancez cette commande depuis une invite administrateur." - case !state.Determined: - control.Status, control.Observed = StatusUnknown, "ce système ne dit pas si la session s'ouvre seule" - control.Remedy = "Faites la recette de §15.5 : redémarrez la machine et vérifiez que le poste " + - "revient SEUL sur l'écran client, sans que personne tape de mot de passe." - case !state.Enabled: - control.Status, control.Code = StatusFail, codeUnattendedRestart - control.Observed = "NON : après une coupure de courant, ce poste restera sur l'écran de " + - "connexion et personne dans l'équipe du samedi n'a le mot de passe. " + state.Detail - control.Remedy = unattendedRestartRemedy() - case state.Expected != "" && !strings.EqualFold(state.Account, state.Expected): - control.Status, control.Code = StatusFail, codeUnattendedRestart - control.Observed = fmt.Sprintf("la session s'ouvre seule pour le compte « %s », alors que le "+ - "kiosque tourne sous « %s » : ce n'est pas la session qui lance l'écran client", - state.Account, state.Expected) - control.Remedy = unattendedRestartRemedy() - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("OUI, pour le compte « %s »", or(state.Account, "non nommé")) - } - return control -} - -// unattendedRestartRemedy is the instruction of bloquant-7, recipe included. -// -// The recipe is part of the remedy and not an extra: the previous plan wrote the registry -// key and told a human to finish the job, which was done once and NEVER VERIFIED AGAIN. -func unattendedRestartRemedy() string { - if runtime.GOOS == "windows" { - return "Relancez install.ps1 en administrateur — c'est son étape 3 (§15.2) — puis refaites " + - "la recette obligatoire de §15.5 : REDÉMARREZ la machine et cochez « le poste est " + - "revenu seul sur l'écran client »." - } - return "Activez les deux unités (systemctl enable openscale.service openscale-kiosk.service), " + - "puis refaites la recette de §15.5 : redémarrez la machine et vérifiez que le poste " + - "revient seul sur l'écran client." -} - -// --- 4. The data directory -------------------------------------------------- - -func (d *Doctor) checkDataDirectory() Control { - control := Control{ID: ControlDataDirectory, - Checked: "Droits d'écriture sur le répertoire de données"} - if d.o.DataDir == "" { - control.Status, control.Observed = StatusFail, "aucun répertoire de données n'a été désigné" - control.Remedy = "Relancez la commande avec --data , ou renseignez OPENSCALE_DATA (§11.1)." - return control - } - if err := probeWritable(d.o.DataDir); err != nil { - control.Status = StatusFail - control.Observed = fmt.Sprintf("impossible d'écrire dans %s : %v", d.o.DataDir, err) - control.Remedy = writableRemedy(d.o.DataDir) - return control - } - control.Status = StatusPass - control.Observed = fmt.Sprintf("%s est accessible en écriture", d.o.DataDir) - return control -} - -// probeWritable proves the directory is writable BY WRITING. -// -// Reading the permission bits would answer a different question: on Windows an ACL that -// looks right can still be shadowed by an inherited deny, and on Linux a full or -// read-only mount grants the bits and refuses the write. The only honest test of « can -// this be written » is a write, and it removes what it wrote. -func probeWritable(dir string) error { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - probe, err := os.CreateTemp(dir, "doctor-*.tmp") - if err != nil { - return err - } - name := probe.Name() - // The bytes matter: creating a file can succeed on a full volume, and only the write - // then fails. A station whose disk is full must not be reported as writable. - _, writeErr := probe.Write([]byte("openscale doctor\n")) - closeErr := probe.Close() - removeErr := os.Remove(name) - return errors.Join(writeErr, closeErr, removeErr) -} - -// writableRemedy names the command that fixes the rights, per system. -func writableRemedy(dir string) string { - if runtime.GOOS == "windows" { - return "Rendez le répertoire inscriptible au compte du service. En administrateur :\n" + - `icacls "` + dir + `" /grant "SYSTEM:(OI)(CI)F" /T` + "\n" + - "puis accordez la modification au compte du kiosque (§15.2, étape 2). Si le disque " + - "est plein, c'est le contrôle 5 qui le dit." - } - return "Rendez le répertoire inscriptible au compte du service :\n" + - "install -d -o openscale -g openscale " + dir + "\n" + - "et vérifiez qu'il figure dans ReadWritePaths= de l'unité (§15.3)." -} - -// --- 5. Disk space ---------------------------------------------------------- - -// codeDiskFull is ERR-SYS-05, which §15.4 allocates to a full disk and to the weighings -// it leaves unjournalled. -const codeDiskFull = "ERR-SYS-05" - -func (d *Doctor) checkDiskSpace(loaded loadedConfig) Control { - control := Control{ID: ControlDiskSpace, Checked: "Espace disque du répertoire de données"} - if d.o.DataDir == "" { - control.Status, control.Observed = StatusUnknown, "aucun répertoire de données n'a été désigné" - control.Remedy = "Relancez la commande avec --data (§11.1)." - return control - } - space, err := d.o.Machine.FreeSpace(d.o.DataDir) - if err != nil || !space.Determined { - control.Status = StatusUnknown - control.Observed = "l'espace libre du volume n'a pas pu être mesuré" + detailSuffix(err) - control.Remedy = "Regardez l'espace libre du volume qui porte " + d.o.DataDir + - " avec l'explorateur de fichiers ou avec df." - return control - } - - free, total := megabytes(space.FreeBytes), megabytes(space.TotalBytes) - threshold := int64(loaded.Config.Maintenance.DiskAlertMB) - control.Observed = fmt.Sprintf("%d Mo libres sur %d Mo", free, total) - switch { - case free <= 0: - control.Status, control.Code = StatusFail, codeDiskFull - control.Observed += " — le volume est plein : les pesées sortiront encore, et elles ne seront plus journalisées" - control.Remedy = "Libérez de l'espace sur le volume qui porte " + d.o.DataDir + ". Les " + - "copies de sauvegarde de la base (openscale.db.before-v… et .backup-…) sont les " + - "plus gros fichiers qu'on puisse retirer sans rien perdre du journal." - case threshold > 0 && free < threshold: - control.Status, control.Code = StatusWarn, codeDiskFull - control.Observed += fmt.Sprintf(" — sous le seuil d'alerte de %d Mo (maintenance.disk_alert_mb)", threshold) - control.Remedy = "Libérez de l'espace avant que le journal ne cesse d'être écrit. Le seuil " + - "lui-même se règle dans maintenance.disk_alert_mb." - case threshold <= 0: - control.Status = StatusPass - control.Observed += " — aucun seuil d'alerte n'est déclaré (maintenance.disk_alert_mb)" - default: - control.Status = StatusPass - control.Observed += fmt.Sprintf(" — seuil d'alerte %d Mo", threshold) - } - return control -} - -// --- 6. The listening address ----------------------------------------------- - -// The two codes §13.4 allocates to a listening address that cannot be taken. -const ( - codeAnotherInstance = "ERR-SYS-01" - codeCannotListen = "ERR-SYS-02" -) - -func (d *Doctor) checkListenAddress(ctx context.Context, loaded loadedConfig) Control { - control := Control{ID: ControlListenAddress, - Checked: "Adresse d'écoute libre, ou tenue par ce poste"} - address := loaded.Config.Network.Listen - if address == "" { - control.Status, control.Observed = StatusFail, "aucune adresse d'écoute n'est déclarée (network.listen)" - control.Remedy = "Renseignez network.listen dans " + or(d.o.ConfigPath, "la configuration") + - ", par exemple 127.0.0.1:8080, puis redémarrez le service." - return control - } - - state, err := d.o.Machine.CanListen(ctx, address) - if err != nil || !state.Determined { - control.Status = StatusUnknown - control.Observed = fmt.Sprintf("l'adresse %s n'a pas pu être testée%s", address, detailSuffix(err)) - control.Remedy = "Vérifiez que network.listen s'écrit hôte:port, par exemple 127.0.0.1:8080." - return control - } - if state.Bindable { - control.Status = StatusPass - control.Observed = fmt.Sprintf("%s est libre : le service pourra la prendre", address) - return control - } - - // The socket IS the single-instance lock (§13.4). An address that refuses a bind AND - // answers our own /healthz is held by this very station, which is the nominal case - // when the service is running — and not a fault to report. - if live, err := d.liveness(ctx); err == nil && live.IsOpenScale() { - control.Status = StatusPass - control.Observed = fmt.Sprintf("%s est tenue par ce poste : /healthz répond (budget %d ms)", - address, live.BudgetMS) - return control - } - control.Status, control.Code = StatusFail, codeCannotListen - control.Observed = fmt.Sprintf("%s est déjà prise, et ce qui la tient n'est pas OpenScale : %s", - address, or(state.Detail, "le bind a été refusé")) - control.Remedy = "Deux cas, et un seul geste pour les distinguer. Si un autre programme écoute " + - "sur ce port, changez network.listen — 127.0.0.1:8081 par exemple. Si c'est une " + - "instance d'OpenScale restée en vie (" + codeAnotherInstance + "), arrêtez le service " + - "avant d'en lancer un second." - return control -} - -// liveness asks /healthz, and only to tell « held by us » from « held by something else ». -func (d *Doctor) liveness(ctx context.Context) (Liveness, error) { - if d.o.Service == nil { - return Liveness{}, ErrServiceSilent - } - return d.o.Service.Liveness(ctx) -} - -// --- 7. The configuration --------------------------------------------------- - -// codeFactoryConfig is ERR-CFG-01: the station runs on the neutral profile because its -// configuration did not pass (§11.3). -const codeFactoryConfig = "ERR-CFG-01" - -func (d *Doctor) checkConfiguration(loaded loadedConfig) Control { - control := Control{ID: ControlConfiguration, Checked: "Configuration valide"} - switch { - case !loaded.Present: - control.Status = StatusFail - control.Observed = fmt.Sprintf("le fichier %s ne peut pas être lu : %v", - or(d.o.ConfigPath, "de configuration"), loaded.Err) - control.Remedy = "Le service ne démarrera pas sans lui. Vérifiez le chemin (--config, " + - "OPENSCALE_CONFIG, ou l'emplacement par défaut de §11.1) et les droits de lecture. " + - "Si le fichier a disparu, restaurez-en une des cinq versions rangées à côté de lui " + - "(config.json.1 à .5)." - return control - case !loaded.Parsed: - control.Status, control.Code = StatusFail, codeFactoryConfig - control.Observed = fmt.Sprintf("%s n'est pas un JSON exploitable (%v) — le poste tourne "+ - "quand même, en configuration d'usine, et ne calcule aucun prix ; l'écran "+ - "d'administration répond", d.o.ConfigPath, loaded.Err) - control.Remedy = "Corrigez la faute de syntaxe — c'est presque toujours une virgule en " + - "trop avant une accolade — ou restaurez config.json.1, la version précédente " + - "rangée à côté du fichier (§11.4)." - return control - case len(loaded.Faults) > 0: - control.Status, control.Code = StatusFail, codeFactoryConfig - control.Observed = fmt.Sprintf("%d faute(s) — le poste démarre en configuration d'usine et ne "+ - "calcule aucun prix. %s", len(loaded.Faults), faultSummary(loaded.Faults)) - control.Remedy = "Corrigez les fautes ci-dessus dans " + d.o.ConfigPath + ", ou restaurez une " + - "version précédente depuis l'écran d'administration (§11.4). " + - "`openscale config validate " + d.o.ConfigPath + "` les liste TOUTES, d'un coup." - return control - } - - // A configuration with no fault is only FULLY checked when this command was given the - // registries the file names its drivers in: §11.3 validates the form without them, and - // announcing « aucune faute » on a half-checked file would be a claim nobody made. - if missing := unknownDrivers(loaded.Config, d.o.Registries); len(missing) > 0 { - control.Status = StatusUnknown - control.Observed = fmt.Sprintf("aucune faute de forme, et les drivers nommés par le fichier "+ - "n'ont pas pu être vérifiés faute de registre : %s", strings.Join(missing, " · ")) - control.Remedy = "Relancez `openscale config validate " + d.o.ConfigPath + "` : la commande de " + - "§15.1 porte les registres de ce binaire et liste toutes les fautes d'un coup." - return control - } - - // A station with no administration password WEIGHS — that is the whole point of not - // making it a fault (ADR-033) — but nothing else would say so, and « rien ne le dit » - // is exactly how a station ended up locked out of its own settings: the delivered - // file carried a placeholder hash, `config validate` declared it sound, and the - // installation sheet went out with dotted lines. This is a WARNING and never a - // failure: the way in exists, it is the recovery code, and saying where it is written - // is more use to a volunteer than a red line. - if loaded.Config.Admin.PasswordHash == "" { - control.Status = StatusWarn - control.Observed = "aucune faute, et aucun mot de passe d'administration n'est posé : " + - "les réglages s'ouvrent en lecture, mais rien ne peut être enregistré" - control.Remedy = "Posez-en un depuis l'écran d'administration, avec le code de secours " + - "de la fiche d'installation, ou en ligne de commande : `openscale config password " + - d.o.ConfigPath + "`." - return control - } - - if retired := loaded.Config.Retired(); len(retired) > 0 { - control.Status = StatusWarn - control.Observed = fmt.Sprintf("aucune faute, et %d clé(s) retirée(s) traînent encore dans le "+ - "fichier : %s", len(retired), strings.Join(retired, ", ")) - control.Remedy = "Lancez d'abord « openscale config migrate " + d.o.ConfigPath + " » : il migre " + - "tout seul ce qui se convertit, et détaille pourquoi il refuse le reste. Ce qu'il refuse ne " + - "se devine pas ; retirez ces lignes-là à la main du fichier, puis relancez la migration (§11.2)." - return control - } - - // The schema version, because "this station's file was rewritten by the update" and - // "this station's file is only being read as if it were" are two different states, and - // diagnostic.zip is where somebody decides which one they are looking at. It is placed - // LAST among the warnings and never among the faults: the station already runs on the - // migrated form, in memory, so an out-of-date FILE is at most something to catch up on - // — and it must never bury the two warnings above, which both call for action sooner - // (no way in at all, or lines nobody can explain). - // - // A note is not automatically "behind, and migrate catches it up": migrateConfig - // refuses to write ANYTHING while a single note is MigrationRefused (cmd/openscale/ - // config.go), so promising a rewrite on the strength of len(notes) alone would be - // wrong exactly when it matters — a refused note is never routine. One refusal in - // particular is not even an old file: a note on domain.SchemaVersionKey is what a - // ROLLED-BACK station looks like from here, written by a binary NEWER than this one, - // and it earns its own sentence rather than being folded into "des changements". - if notes := loaded.MigrationNotes; len(notes) > 0 { - control.Status = StatusWarn - var refused []domain.MigrationNote - var rolledBack *domain.MigrationNote - for i := range notes { - if notes[i].Action != domain.MigrationRefused { - continue - } - refused = append(refused, notes[i]) - if notes[i].Key == domain.SchemaVersionKey { - rolledBack = ¬es[i] - } - } - - switch { - case rolledBack != nil: - control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s : %s", - loaded.Config.Fingerprint(), d.o.ConfigPath, rolledBack.Message) - control.Remedy = "Ce n'est pas un fichier en retard : cherchez pourquoi ce poste tourne " + - "sur un binaire plus ancien qu'il ne l'a fait — les journaux de mise à jour " + - "(update.ps1 ou update.sh) sur CE poste disent ce qui a échoué. « openscale config " + - "migrate " + d.o.ConfigPath + " » ne réécrira rien tant que ce fichier vient d'un " + - "binaire plus récent." - // Unreachable TODAY, and kept because it is the right welcome for the first refusal - // that is not one of retiredKeys. Every refusal this binary can produce on a key - // other than `version` LEAVES THAT KEY IN THE DOCUMENT -- that is what a refusal - // consists of (ADR-058) -- so Config.Retired() finds it and the branch above returns - // first. Only `version` reaches a refusal with nothing left behind, and it has its - // own case, right above this one. - case len(refused) > 0: - control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s porte %d changement(s) "+ - "que ce binaire ne convertit pas — « openscale config migrate » les nommera, chacun "+ - "avec sa raison, mais n'écrira RIEN tant qu'ils y restent", - loaded.Config.Fingerprint(), d.o.ConfigPath, len(refused)) - control.Remedy = "Lancez « openscale config migrate " + d.o.ConfigPath + " » pour lire la " + - "raison de chaque point refusé, tranchez-les à la main, puis relancez la commande : " + - "elle n'écrit le fichier qu'une fois qu'il n'y en a plus." - default: - control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s n'est pas encore au schéma %d "+ - "que ce binaire écrit (%d changement(s) en attente) — « openscale config migrate » le "+ - "réécrit (le poste tourne déjà sur la forme à jour, en mémoire)", - loaded.Config.Fingerprint(), d.o.ConfigPath, domain.CurrentSchemaVersion, len(notes)) - control.Remedy = "« openscale config migrate " + d.o.ConfigPath + " » réécrit le fichier sur " + - "cette forme ; rien ne presse, le poste fonctionne déjà normalement." - } - return control - } - - control.Status = StatusPass - control.Observed = fmt.Sprintf("aucune faute ; empreinte %s", loaded.Config.Fingerprint()) - return control -} - -// unknownDrivers names the drivers the file declares and the registries do not carry. -// -// It is what tells « the configuration is valid » from « the configuration has no fault this -// command was able to look for ». The scale is only checked when the station declares one: -// scale.type is legitimately empty on a station that has no scale (§11.2). -func unknownDrivers(cfg domain.Config, reg domain.Registries) []string { - var missing []string - declared := []struct { - field string - value string - known []string - check bool - }{ - {"scale.type", cfg.Scale.Type, reg.ScaleTypes(), cfg.Scale.Present}, - {"printer.type", cfg.Printer.Type, reg.PrinterTypes(), true}, - {"catalog.type", cfg.Catalog.Type, reg.CatalogSourceNames(), cfg.Catalog.Type != ""}, - } - for _, entry := range declared { - if !entry.check || known(entry.known, entry.value) { - continue - } - missing = append(missing, entry.field) - } - return missing -} - -// known reports whether value is in list. -func known(list []string, value string) bool { - for _, candidate := range list { - if candidate == value { - return true - } - } - return false -} - -// faultsQuoted is how many faults the one-line summary names before deferring to -// `openscale config validate`. Three: enough to recognise the block that is wrong, short -// enough to stay on a terminal line a volunteer reads out over the telephone. -const faultsQuoted = 3 - -// faultSummary names the first faults and says how many were left out. -func faultSummary(faults []domain.Fault) string { - quoted := faults - if len(quoted) > faultsQuoted { - quoted = quoted[:faultsQuoted] - } - parts := make([]string, 0, len(quoted)) - for _, fault := range quoted { - // faultLine and not Fault.String: the message of a fault about a sensitive field - // quotes the offending VALUE, and this sentence travels into diagnostic.zip. - parts = append(parts, faultLine(fault)) - } - out := strings.Join(parts, " · ") - if len(faults) > len(quoted) { - out += fmt.Sprintf(" · et %d autre(s)", len(faults)-len(quoted)) - } - return out -} - -// --- 8. The database -------------------------------------------------------- - -const codeDatabaseUnusable = "ERR-DB-01" - -func (d *Doctor) checkDatabase(ctx context.Context, base Database, openErr error) Control { - control := Control{ID: ControlDatabase, Checked: "Base ouvrable et contrôle d'intégrité"} - if base == nil { - control.Status = StatusFail - control.Code, control.Observed = classifyDatabaseFailure(openErr) - control.Remedy = "Le service ne démarrera pas sans la base. Vérifiez les droits du " + - "répertoire de données (contrôle 4) et l'espace disque (contrôle 5). Si le fichier " + - "est endommagé, restaurez la copie la plus récente rangée à côté de lui — " + - "openscale.db.backup-… ou openscale.db.before-v… — et redémarrez le service (§15.5)." - return control - } - if err := base.IntegrityCheck(ctx); err != nil { - control.Status, control.Code = StatusFail, codeDatabaseUnusable - control.Observed = fmt.Sprintf("%s s'ouvre, et son contrôle d'intégrité échoue : %v", base.Path(), err) - control.Remedy = "Ne réparez rien à la main. Arrêtez le service, renommez le fichier, " + - "restaurez la copie la plus récente (openscale.db.backup-… ou openscale.db.before-v…), " + - "redémarrez le service (§15.5). Gardez le fichier endommagé : il porte les pesées " + - "que la copie n'a pas." - return control - } - control.Status = StatusPass - control.Observed = fmt.Sprintf("%s s'ouvre et son contrôle d'intégrité passe", base.Path()) - return control -} - -// classifyDatabaseFailure reports the code and the sentence of a refusal to open. -func classifyDatabaseFailure(err error) (code, observed string) { - if err == nil { - return codeDatabaseUnusable, "la base n'a pas pu être ouverte, sans raison rapportée" - } - var failure *DatabaseFailure - if errors.As(err, &failure) && failure.Code != "" { - return failure.Code, failure.Message - } - return codeDatabaseUnusable, "la base n'a pas pu être ouverte : " + err.Error() -} - -// --- 9. Migrations ---------------------------------------------------------- - -const codeSchemaFromNewerVersion = "ERR-DB-02" - -func (d *Doctor) checkMigrations(base Database, openErr error) Control { - control := Control{ID: ControlMigrations, Checked: "Migrations à jour"} - if base == nil { - control.Status = StatusUnknown - control.Observed = "la version du schéma n'est pas lisible parce que la base ne s'ouvre pas" + - detailSuffix(openErr) - control.Remedy = "Réglez d'abord le contrôle 8 : la version du schéma se lit dans la base." - return control - } - applied, err := base.SchemaVersion() - if err != nil { - control.Status, control.Code = StatusFail, codeDatabaseUnusable - control.Observed = "la version du schéma n'a pas pu être lue : " + err.Error() - control.Remedy = "La base s'ouvre et ne répond pas : traitez-la comme endommagée et " + - "restaurez la copie la plus récente (§15.5)." - return control - } - switch { - case d.o.Migrations <= 0: - control.Status = StatusUnknown - control.Observed = fmt.Sprintf("schéma %d appliqué ; le nombre de migrations que porte ce "+ - "binaire n'a pas été fourni à cette commande", applied) - control.Remedy = "Rien à faire sur le poste : c'est cette commande qui n'a pas été câblée " + - "complètement. Signalez-le." - case applied > d.o.Migrations: - control.Status, control.Code = StatusFail, codeSchemaFromNewerVersion - control.Observed = fmt.Sprintf("la base est au schéma %d et ce binaire n'en connaît que %d : "+ - "elle a été créée par une version plus récente", applied, d.o.Migrations) - control.Remedy = "Mettez l'application à jour sur ce poste. Si vous venez au contraire de " + - "revenir en arrière volontairement, restaurez AUSSI la copie " + - "openscale.db.before-v… correspondante : les migrations ne redescendent pas (§12.5)." - case applied < d.o.Migrations: - control.Status, control.Code = StatusFail, codeDatabaseUnusable - control.Observed = fmt.Sprintf("la base est au schéma %d alors que ce binaire en porte %d : "+ - "les migrations n'ont pas été appliquées", applied, d.o.Migrations) - control.Remedy = "Les migrations s'appliquent au démarrage du service. Démarrez-le " + - "(contrôle 1) et relisez ce contrôle ; s'il reste rouge, la base est en lecture " + - "seule ou le disque est plein (contrôles 4 et 5)." - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("schéma %d, à jour", applied) - } - return control -} - -// --- 10. The serial port ---------------------------------------------------- - -const codePortUnavailable = "ERR-SCL-03" - -// optionPort is the key a SERIAL scale.options carries the port name under (§11.2). -// -// It is a literal and it is allowed to be one, now that the control runs only for a -// protocol that declares itself on a serial port: `port` is the key -// internal/scale/serial declares in its own option schema, and it exists exactly when -// this control applies. What it may no longer do — and did — is assume that every scale -// of every protocol is reached through it. -const optionPort = "port" - -// scaleEndpoint reports what kind of access point the protocol id names is reached on, -// as the driver itself declared it, and whether this binary knows that protocol at all. -// -// An UNKNOWN protocol is not answered with a guess: control 8 already reports a -// scale.type no driver of this binary carries, and this control then does what it always -// did rather than adding a second, differently worded verdict on the same fault. -func (d *Doctor) scaleEndpoint(id string) (string, bool) { - for _, descriptor := range d.o.Registries.Scales { - if descriptor.ID == id { - return descriptor.Endpoint, true - } - } - return "", false -} - -func (d *Doctor) checkSerialPort(ctx context.Context, loaded loadedConfig) Control { - control := Control{ID: ControlSerialPort, Checked: "Port série présent et ouvrable"} - if !loaded.Config.Scale.Present { - // The explicit declaration of §11.2, which turns the light OFF instead of leaving - // it red. It is not a fault and must not be reported as one. - control.Status = StatusNotApplicable - control.Observed = "ce poste est déclaré sans balance (scale.present = false) : la saisie du " + - "poids à la main est le mode nominal" - return control - } - if endpoint, known := d.scaleEndpoint(loaded.Config.Scale.Type); known && - endpoint != domain.EndpointSerialPort { - // A protocol that is not reached through a serial port has no scale.options.port, - // and this control would report a missing key as a fault on a station that is - // perfectly configured. The light goes OFF, like the one of a station with no - // scale, and says why. - control.Status = StatusNotApplicable - control.Observed = fmt.Sprintf("le protocole %s ne passe pas par un port série : "+ - "il n'y a pas de scale.options.port à vérifier sur ce poste", loaded.Config.Scale.Type) - return control - } - declared, _ := loaded.Config.Scale.Options.Text(optionPort) - if declared == "" { - control.Status, control.Code = StatusFail, codePortUnavailable - control.Observed = "aucun port n'est déclaré (scale.options.port) alors que ce poste annonce une balance" - control.Remedy = "Ouvrez la page Matériel et lancez « Détecter automatiquement » : " + - "la détection ouvre chaque port, applique les parseurs et annonce celui qui répond. " + - "Ou déclarez scale.present = false si ce poste n'a réellement pas de balance." - return control - } - - list, err := d.o.Machine.SerialPorts(ctx) - if err != nil { - control.Status = StatusUnknown - control.Observed = fmt.Sprintf("le port %s est déclaré, et les ports du poste n'ont pas pu "+ - "être énumérés : %v", declared, err) - control.Remedy = "Relancez la commande depuis une invite administrateur, puis vérifiez le " + - "câble de la balance." - return control - } - if !containsPort(list, declared) { - control.Status, control.Code = StatusFail, codePortUnavailable - control.Observed = fmt.Sprintf("le port %s est déclaré et n'existe pas sur ce poste. %s", - declared, portListSentence(list)) - control.Remedy = "Rebranchez le câble de la balance, puis relancez la commande. Si le port a " + - "changé de nom — c'est ce qui arrive après un rebranchement — corrigez " + - "scale.options.port, ou lancez « Détecter automatiquement » depuis Réglages " + - "avancés → Matériel. Vérifiez aussi le contrôle 15 : la suspension USB sélective " + - "fait disparaître un adaptateur USB-série." - return control - } - - if err := d.o.Machine.OpenSerialPort(ctx, declared); err != nil { - // A port that is enumerated but refuses to open is EXCLUSIVE and held — which is - // what a running service looks like from here, and a success rather than a fault. - if live, liveErr := d.liveness(ctx); liveErr == nil && live.IsOpenScale() { - control.Status = StatusPass - control.Observed = fmt.Sprintf("le port %s existe et il est tenu par le service en cours : "+ - "un port série est exclusif, c'est le résultat attendu quand le poste tourne", declared) - return control - } - control.Status, control.Code = StatusFail, codePortUnavailable - control.Observed = fmt.Sprintf("le port %s existe et ne s'ouvre pas : %v", declared, err) - control.Remedy = "Un port série est exclusif. Fermez ce qui le tient — un autre programme, " + - "une fenêtre de terminal série restée ouverte — puis relancez la commande. Si " + - "personne ne le tient, c'est un droit qui manque : le compte du service doit " + - "appartenir au groupe dialout sous Linux (§15.3)." - return control - } - control.Status = StatusPass - control.Observed = fmt.Sprintf("le port %s existe et s'ouvre", declared) - return control -} - -// containsPort reports whether the declared name was enumerated. -// -// The comparison is case-insensitive because Windows spells the same port COM8 and com8, -// and a control that refused com8 would send somebody looking for a cable that is plugged -// in. -func containsPort(list []PortInfo, name string) bool { - for _, port := range list { - if strings.EqualFold(port.Name, name) { - return true - } - } - return false -} - -// portListSentence names what WAS enumerated, which is the half of the remedy a volunteer -// can act on. -func portListSentence(list []PortInfo) string { - if len(list) == 0 { - return "Aucun port série n'est visible sur ce poste." - } - names := make([]string, 0, len(list)) - for _, port := range list { - names = append(names, port.String()) - } - return "Ports visibles : " + strings.Join(names, " · ") + "." -} - -// --- 11. The print queue, from the service's context ------------------------ - -const codePrinterUnreachable = "ERR-PRN-01" - -func (d *Doctor) checkPrintQueue(ctx context.Context, loaded loadedConfig, health Health, healthErr error) Control { - control := Control{ID: ControlPrintQueue, - Checked: "File d'impression visible depuis le contexte du service"} - if healthErr != nil { - control.Status = StatusUnknown - control.Observed = "le service ne répond pas, et lui seul peut répondre : une file " + - "« installée pour l'utilisateur » est invisible du service tout en étant parfaitement " + - "visible d'ici. " + d.localQueues(ctx) - control.Remedy = "Démarrez le service (contrôle 1), puis relancez openscale doctor. Ce " + - "contrôle interroge le service exprès : le tester avec les droits de l'opérateur " + - "répondrait à une autre question (§15.2, important-11)." - return control - } - - configured, _ := loaded.Config.Printer.Options.Text("queue") - switch health.State.Printer.Health { - case "faulted": - control.Status, control.Code = StatusFail, codePrinterUnreachable - control.Observed = fmt.Sprintf("le service ne peut pas imprimer : %s. %s", - or(health.State.Printer.Detail, "aucun détail"), configuredQueueSentence(configured)) - control.Remedy = "Sous Windows, la file doit être installée en imprimante LOCALE MACHINE : " + - "une file « installée pour l'utilisateur » est invisible depuis le service, et c'est " + - "la panne la plus fréquente à l'installation (§15.2). " + d.localQueues(ctx) + - "\nEn attendant, l'écran de dépannage propose « Imprimer sur l'imprimante du poste N »." - case "consumable": - control.Status = StatusWarn - control.Observed = "le service imprime, et le rouleau arrive en fin de vie : " + - or(health.State.Printer.Detail, "aucun détail") - control.Remedy = "Changez le rouleau, puis touchez « J'ai changé le rouleau » sur l'écran " + - "de dépannage — c'est ce bouton qui remet le compteur à zéro (§8.5)." - case "unknown": - control.Status = StatusPass - control.Observed = "le service atteint l'imprimante ; celle-ci ne sait pas dire ce qu'elle a " + - "— les octets partent, rien ne revient. C'est la réponse honnête d'un transport " + - "unidirectionnel, pas une panne. " + configuredQueueSentence(configured) - case "ready": - control.Status = StatusPass - control.Observed = "le service voit l'imprimante et elle n'a rien à signaler. " + - configuredQueueSentence(configured) - default: - control.Status = StatusUnknown - control.Observed = fmt.Sprintf("le service annonce un état d'imprimante que cette version ne "+ - "connaît pas : %q", health.State.Printer.Health) - control.Remedy = "Les deux binaires ne sont pas de la même version. Mettez ce poste à jour, " + - "puis relancez la commande." - } - return control -} - -// configuredQueueSentence names what the configuration asks for. -func configuredQueueSentence(queue string) string { - if queue == "" { - return "Aucune file n'est nommée dans printer.options.queue." - } - return fmt.Sprintf("File configurée : « %s ».", queue) -} - -// localQueues names the queues visible from THIS process, labelled as such. -// -// The label is not decoration: presenting the operator's list as the service's viewpoint -// is the exact mistake important-11 is about, and the list is only ever useful as the -// second half of a remedy. -func (d *Doctor) localQueues(ctx context.Context) string { - list, err := d.o.Machine.PrintQueues(ctx) - if err != nil { - return "Les files visibles depuis cette session n'ont pas pu être énumérées : " + err.Error() + "." - } - if len(list) == 0 { - return "Aucune file d'impression n'est visible depuis cette session." - } - names := make([]string, 0, len(list)) - for _, queue := range list { - name := queue.Name - if queue.Default { - name += " (par défaut)" - } - names = append(names, name) - } - return "Files visibles depuis cette session — pas depuis celle du service : " + - strings.Join(names, " · ") + "." -} - -// --- 12. The observed scale cadence ----------------------------------------- - -const codeScaleLost = "ERR-SCL-02" - -func (d *Doctor) checkScaleRate(loaded loadedConfig, health Health, healthErr error) Control { - control := Control{ID: ControlScaleRate, Checked: "Cadence de la balance réellement observée"} - if !loaded.Config.Scale.Present { - control.Status = StatusNotApplicable - control.Observed = "ce poste est déclaré sans balance (scale.present = false)" - return control - } - if healthErr != nil { - control.Status = StatusUnknown - control.Observed = "le service ne répond pas : la cadence est ce que le poste a MESURÉ sur " + - "les soixante-quatre derniers intervalles, elle ne se déduit d'aucun fichier" - control.Remedy = "Démarrez le service (contrôle 1), laissez-le recevoir quelques trames, " + - "puis relancez openscale doctor." - return control - } - - scale := health.State.Scale - switch { - case !scale.Connected: - control.Status, control.Code = StatusFail, codeScaleLost - control.Observed = "le service n'a plus de balance : le port était ouvert et il s'est tu" - control.Remedy = "Vérifiez le câble et l'alimentation de la balance, puis rebranchez : le " + - "poste revient à l'état nominal seul. En attendant, l'écran client propose la saisie " + - "du poids à la main." - case scale.Observations == 0: - control.Status = StatusUnknown - control.Observed = "le service tient le port et n'a encore reçu aucune trame" - control.Remedy = "Posez quelque chose sur le plateau, attendez trois secondes, puis " + - "relancez la commande. Si rien n'arrive, vérifiez le débit et la parité déclarés " + - "dans scale.options contre ceux affichés sur la balance." - case scale.TooSlow: - // The alert condition itself is computed by the station, once, and read here: - // expiry_factor × median above the ceiling (§6.5, ADR-005). Two implementations of - // one rule is how the two of them come to disagree. - control.Status = StatusWarn - control.Observed = fmt.Sprintf("la balance émet une mesure toutes les %d ms, et le poids est "+ - "considéré périmé AVANT l'arrivée de la mesure suivante", scale.MedianMS) - control.Remedy = "Le poids s'affichera puis disparaîtra sans raison visible. Vérifiez le " + - "câble, puis la cadence d'émission réglée sur la balance elle-même : c'est un " + - "réglage de l'appareil, pas du poste." - case scale.Provisional: - control.Status = StatusWarn - control.Observed = fmt.Sprintf("cadence PROVISOIRE de %d ms sur %d intervalle(s) : moins de "+ - "huit ont été observés, la valeur affichée est celle que le driver déclare, pas une mesure", - scale.MedianMS, scale.Observations) - control.Remedy = "Laissez le poste recevoir des trames quelques secondes, puis relancez la " + - "commande : le chiffre deviendra une mesure." - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("une mesure toutes les %d ms, médiane mesurée sur %d intervalles", - scale.MedianMS, scale.Observations) - } - return control -} - -// --- 13. The catalog source, as the service sees it ------------------------- - -const codeCatalogSource = "ERR-CAT-01" - -func (d *Doctor) checkCatalogSource(loaded loadedConfig, health Health, healthErr error) Control { - control := Control{ID: ControlCatalogSource, - Checked: "Source du catalogue accessible telle que le service la voit"} - if healthErr != nil { - control.Status = StatusUnknown - control.Observed = "le service ne répond pas, et lui seul voit la source avec SES droits. " + - d.declaredSourceSentence(loaded) - control.Remedy = "Démarrez le service (contrôle 1), puis relancez openscale doctor. Ce " + - "contrôle passe par le service exprès : vérifier le répertoire avec les droits de " + - "l'opérateur répondrait à une autre question (§15.4)." - return control - } - - weighable := health.State.CatalogCount - last := health.Catalog - switch { - case last == nil && weighable == 0: - control.Status, control.Code = StatusWarn, codeCatalogSource - control.Observed = "le service n'a encore appliqué aucun catalogue et ne sert aucun produit " + - "pesable. " + d.declaredSourceSentence(loaded) - control.Remedy = catalogArrivalRemedy(loaded) - case last != nil && last.Result == domain.ImportRejected: - control.Status, control.Code = StatusWarn, or(last.Code, codeCatalogSource) - control.Observed = fmt.Sprintf("le dernier fichier lu a été REFUSÉ (%s, %s) : %s. Le catalogue "+ - "précédent reste en service, %d produits pesables", - last.Source, or(last.FileName, "sans nom"), or(last.Reason, "sans motif"), weighable) - control.Remedy = "Le poste continue de peser avec le catalogue précédent : rien n'est perdu. " + - "Ouvrez la page Catalogue : les lignes fautives y sont nommées, avec leur " + - "numéro de ligne dans le CSV, et c'est cette liste-là qu'il faut envoyer au producteur." - case last != nil && last.Result == domain.ImportFailed: - control.Status, control.Code = StatusWarn, or(last.Code, codeCatalogSource) - control.Observed = fmt.Sprintf("le dernier import a échoué (%s, %s) : %s. %d produits pesables "+ - "restent en service", last.Source, or(last.FileName, "sans nom"), - or(last.Reason, "sans motif"), weighable) - control.Remedy = "Regardez le journal technique de l'écran d'administration : un échec " + - "d'import est un problème d'accès ou de droits sur la source, pas de contenu. " + - d.declaredSourceSentence(loaded) - case weighable == 0: - control.Status, control.Code = StatusWarn, codeCatalogSource - control.Observed = fmt.Sprintf("le dernier import a réussi (%s, %s) et le service ne sert "+ - "aucun produit pesable", last.Source, or(last.FileName, "sans nom")) - control.Remedy = "La grille du client est vide. Vérifiez sur la page Catalogue " + - "que les produits reçus portent bien un code-barres commençant par 0493 à 0499 : " + - "c'est le préfixe qui décide si un produit se pèse." - default: - control.Status = StatusPass - control.Observed = fmt.Sprintf("%d produits pesables en service ; dernier fichier appliqué : "+ - "%s via %s (%d lignes lues, %d anomalies)", weighable, or(last.FileName, "sans nom"), - last.Source, last.RowsRead, last.Anomalies) - } - return control -} - -// declaredSourceSentence names the source the FILE declares, labelled as declared. -// -// It never claims the directory was tested: that claim belongs to the service, and this -// sentence exists precisely for the case where the service cannot make it. -func (d *Doctor) declaredSourceSentence(loaded loadedConfig) string { - kind := loaded.Config.Catalog.Type - if kind == "" { - return "Aucune source n'est déclarée dans catalog.type." - } - if kind == domain.CatalogSourceWebDAV { - // The URL is deliberately NOT quoted here: this sentence travels into - // diagnostic.zip, and §15.4 wants that archive free of anything private. - return "Source déclarée : webdav (l'adresse n'est pas reproduite ici)." - } - return fmt.Sprintf("Source déclarée : %s ; le service crée lui-même son répertoire de dépôt "+ - "sous %s.", kind, or(d.o.DataDir, "le répertoire de données")) -} - -// catalogArrivalRemedy names the file the station is waiting for. -// -// The name DERIVES from station.number and is never written by hand: §14.4 makes that a -// rule, because two declarations of one fact is the failure the legacy application died -// of. -func catalogArrivalRemedy(loaded loadedConfig) string { - expected := "flv_.csv" - if loaded.Config.Station.Number > 0 { - expected = fmt.Sprintf("flv_%d.csv", loaded.Config.Station.Number) - } - return "La grille du client est vide et affiche « Catalogue vide ». Faites déposer " + expected + - " par le producteur, ou glissez un CSV dans l'écran de dépannage → « Importer un " + - "catalogue » : c'est le même parseur et la même qualification." -} - -// --- 14. The system clock --------------------------------------------------- - -const codeClockJump = "ERR-SYS-07" - -func (d *Doctor) checkSystemClock(loaded loadedConfig) Control { - control := Control{ID: ControlSystemClock, Checked: "Horloge système cohérente"} - now := d.o.Clock.Now() - control.Observed = "heure du poste : " + now.Format(clockLayout) - - built, builtKnown := parseBuildDate(d.o.BuildDate) - written := loaded.Config.ModifiedAt - - switch { - case builtKnown && now.Before(built): - control.Status, control.Code = StatusFail, codeClockJump - control.Observed += fmt.Sprintf(" — antérieure à la date de compilation du binaire (%s) : "+ - "l'horloge de ce poste est fausse", built.Format(clockLayout)) - control.Remedy = clockRemedy() - case !written.IsZero() && now.Before(written): - control.Status, control.Code = StatusFail, codeClockJump - control.Observed += fmt.Sprintf(" — antérieure à la date d'écriture de la configuration (%s) : "+ - "l'horloge a reculé", written.Format(clockLayout)) - control.Remedy = clockRemedy() - case !builtKnown: - control.Status = StatusUnknown - control.Observed += " — ce binaire ne porte pas sa date de compilation, il n'y a donc rien à comparer" - control.Remedy = "Rien à faire sur le poste. Ce binaire a été construit sans le Makefile, " + - "qui injecte la version, le commit et la date : reconstruisez-le avec `make build` " + - "pour que ce contrôle puisse conclure." - default: - control.Status = StatusPass - control.Observed += fmt.Sprintf(", postérieure à la compilation du binaire (%s)", built.Format(clockLayout)) - } - return control -} +// --- Shared ----------------------------------------------------------------- // clockLayout is how this report spells an instant: local time, seconds, and the offset. // @@ -1291,187 +283,6 @@ func (d *Doctor) checkSystemClock(loaded loadedConfig) Control { // by somebody reconciling a weighing journal against a till. const clockLayout = "2006-01-02 15:04:05 -07:00" -// clockRemedy is the instruction for a clock that is wrong. -// -// A timestamped journal is only worth anything for reconciliation with the till if the -// hour is right, and no NTP dependency is guaranteed on an offline station (§15.4). -func clockRemedy() string { - return "Remettez l'heure du poste à la bonne date : un journal de pesées horodaté ne vaut " + - "rien pour le rapprochement avec la caisse si l'heure est fausse, et le poste n'a " + - "aucune garantie de serveur de temps puisqu'il est hors ligne. Vérifiez aussi la pile " + - "de la carte mère : une heure qui revient toujours à la même date après une coupure, " + - "c'est elle." -} - -// parseBuildDate reads the instant the linker injected. -// -// The Makefile injects `git log -1 --format=%cI`, which is RFC 3339. A plain `go build` -// injects "unknown", and saying so is the honest answer — a control that treated an -// unparsable date as the zero instant would report every station's clock as being in the -// future. -func parseBuildDate(value string) (time.Time, bool) { - if value == "" || value == "unknown" { - return time.Time{}, false - } - built, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, false - } - return built, true -} - -// --- 16. The right to restart the machine ----------------------------------- - -// checkRebootPermission is the sixteenth control: may this station restart the computer? -// -// It exists because the answer is INVISIBLE until somebody needs it. Under Linux the -// service runs as `openscale` and polkit stands between it and the right, so a station -// missing its rule works perfectly — right up to the evening a volunteer is facing a -// frozen kiosk, touches the one button that would have saved them, and watches a -// countdown expire on nothing. -func (d *Doctor) checkRebootPermission(ctx context.Context) Control { - control := Control{ID: ControlRebootPermission, - Checked: "Droit de redémarrer l'ordinateur depuis l'écran"} - state, err := d.o.Machine.RebootPermission(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, - "le droit de redémarrer n'a pas pu être établi : "+err.Error() - control.Remedy = "Vérifiez à la main que /etc/polkit-1/rules.d porte la règle " + - "49-openscale-reboot.rules, ou relancez « sudo ./install.sh »." - case !state.Applicable: - control.Status = StatusNotApplicable - control.Observed = "ce système ne sait pas redémarrer depuis l'écran (§15.3), " + - "il n'y a donc aucun droit à vérifier" - case state.Allowed: - control.Status, control.Observed = StatusPass, state.Detail - default: - control.Status, control.Code = StatusFail, codeRebootRefused - control.Observed = "NON : " + state.Detail + ". Le bouton « Redémarrer l'ordinateur » " + - "répondra « accès refusé », et il ne le dira qu'au moment où quelqu'un en a besoin." - control.Remedy = "Relancez « sudo ./install.sh » depuis deploy/linux : il pose la " + - "règle polkit qui autorise le compte du poste à redémarrer l'ordinateur, et rien d'autre." - } - return control -} - -// codeRebootRefused is ERR-SYS-12, and internal/web allocates it to the same fact: the -// machine was asked to restart and said no. -const codeRebootRefused = "ERR-SYS-12" - -// --- 17. The client screen cannot leave the application --------------------- - -// checkNavigationLock is the seventeenth control, and it is the only one that reports a -// station where EVERYTHING ELSE IS GREEN. -// -// The panne, in full: a right click on the administration screen — the one surface where -// the context menu is deliberately left alive, so that « Copier » works on an error a -// volunteer is reading over the telephone — offers « Rechercher sur le web ». One click, -// and the kiosk window is on a search engine. No address bar, no back button, and the -// browser is perfectly alive: the service answers, the task is running, the window is full -// screen, and the poste sells nothing. It happened on a real station on 31/07/2026. -// -// What it reads is the belt, not the guarantee. The braces are the supervisor's watch over -// the attached client screen, which brings the poste back inside AbsenceGrace whatever the -// browser did with these keys — which is why an unreadable answer here is amber and never -// red. -func (d *Doctor) checkNavigationLock(ctx context.Context) Control { - control := Control{ID: ControlNavigationLock, - Checked: "Écran client verrouillé sur l'application"} - state, err := d.o.Machine.NavigationLock(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, - "les stratégies de navigation n'ont pas pu être lues : "+err.Error() - control.Remedy = navigationLockRemedy - case !state.Applicable: - control.Status = StatusNotApplicable - control.Observed = "sur ce système, l'écran client tourne sous un compositeur " + - "mono-application (§15.3) et la stratégie du navigateur appartient à " + - "l'installeur, pas au compte du poste" - case !state.Determined: - control.Status, control.Observed = StatusUnknown, state.Detail - control.Remedy = navigationLockRemedy - case state.Locked: - control.Status = StatusPass - control.Observed = "compte « " + state.Account + " » : " + state.Detail + - " Un clic droit ne peut plus emmener le poste hors de l'application." - default: - control.Status, control.Code = StatusFail, codeNavigationOpen - control.Observed = "compte « " + state.Account + " » : " + state.Detail + - " Le navigateur peut être emmené hors de l'application — un clic droit, " + - "« Rechercher sur le web », et il n'y a ni barre d'adresse ni bouton retour " + - "pour revenir." - control.Remedy = navigationLockRemedy - } - return control -} - -// navigationLockRemedy is one gesture, and it is the same one for the three branches that -// carry it: the policies are posed by the kiosk at every logon, so making them exist again -// is making the kiosk start again. -const navigationLockRemedy = "Fermez puis rouvrez la session du poste — le kiosque pose " + - "ses stratégies à chaque ouverture. Si le contrôle reste rouge, le journal " + - "kiosk.log dit sur quelle clé il a échoué." - -// codeNavigationOpen is ERR-KSK-03: the kiosk window can be taken out of the application. -// -// A code of its own, and the third of the kiosk family: ERR-KSK-02 says « l'affichage -// n'arrive pas à rester ouvert », which is the opposite failure, and reading one for the -// other over the telephone sends a volunteer to look at a browser that crashes when the -// browser is doing fine. -const codeNavigationOpen = "ERR-KSK-03" - -// --- 15. Sleep and USB selective suspend ------------------------------------ - -func (d *Doctor) checkPowerSettings(ctx context.Context) Control { - control := Control{ID: ControlPowerSettings, - Checked: "Veille et suspension USB sélective désactivées"} - state, err := d.o.Machine.Power(ctx) - switch { - case err != nil: - control.Status, control.Observed = StatusUnknown, "les réglages d'énergie n'ont pas pu être lus : "+err.Error() - control.Remedy = "Relancez cette commande depuis une invite administrateur." - case !state.Applicable: - control.Status = StatusNotApplicable - control.Observed = "la procédure d'installation de ce système n'écrit aucun réglage " + - "d'énergie (§15.3), il n'y a donc rien à comparer" - case !state.Determined: - control.Status, control.Observed = StatusUnknown, "les réglages d'énergie n'ont pas pu être établis : "+state.Detail - control.Remedy = "Vérifiez à la main, dans le plan d'alimentation actif, que la mise en " + - "veille, l'extinction de l'écran et la « suspension sélective USB » sont toutes sur " + - "« jamais » ou « désactivé » (§15.2, étape 5)." - case !state.USBSelectiveSuspendDisabled: - control.Status = StatusFail - control.Observed = "la suspension USB sélective est ACTIVE. " + state.Detail - control.Remedy = "C'est la cause de la moitié des « la balance ne répond plus » sur un " + - "adaptateur USB-série, et elle ne figure dans aucune procédure d'installation " + - "standard. En administrateur :\n" + usbSuspendCommand() + - "\nOu relancez install.ps1, dont c'est l'étape 5 (§15.2)." - case !state.SleepDisabled: - control.Status = StatusFail - control.Observed = "la mise en veille ou l'extinction de l'écran est ACTIVE. " + state.Detail - control.Remedy = "Un poste en libre-service ne doit ni s'endormir ni éteindre son écran. " + - "En administrateur :\npowercfg /change monitor-timeout-ac 0\n" + - "powercfg /change standby-timeout-ac 0\npowercfg /change hibernate-timeout-ac 0" - default: - control.Status = StatusPass - control.Observed = "veille, extinction d'écran et suspension USB sélective sont toutes désactivées" - } - return control -} - -// usbSuspendCommand is the command of §15.2, GUIDs included, quoted from the document. -// -// The two GUIDs are NOT derived and NOT guessed: they are copied from install.ps1 in -// §15.2, which is the only place this project has them from. -func usbSuspendCommand() string { - return "powercfg /setacvalueindex SCHEME_CURRENT " + usbSubgroupGUID + " " + usbSuspendGUID + " 0\n" + - "powercfg /setactive SCHEME_CURRENT" -} - -// --- Shared ----------------------------------------------------------------- - // detailSuffix appends the technical tail of an error, or nothing. func detailSuffix(err error) string { if err == nil { diff --git a/internal/diag/doctor_config.go b/internal/diag/doctor_config.go new file mode 100644 index 0000000..794f3bd --- /dev/null +++ b/internal/diag/doctor_config.go @@ -0,0 +1,307 @@ +package diag + +import ( + "fmt" + "strings" + + "openscale/internal/domain" +) + +// This file carries the two controls about what a station was TOLD: the configuration +// file, and the catalog source that file declares. They are together because they share +// one rule — a fault here is never a fault of the machine, and every remedy names the +// screen or the command that rewrites the document rather than a cable to check. + +// --- 7. The configuration --------------------------------------------------- + +// codeFactoryConfig is ERR-CFG-01: the station runs on the neutral profile because its +// configuration did not pass (§11.3). +const codeFactoryConfig = "ERR-CFG-01" + +func (d *Doctor) checkConfiguration(loaded loadedConfig) Control { + control := Control{ID: ControlConfiguration, Checked: "Configuration valide"} + switch { + case !loaded.Present: + control.Status = StatusFail + control.Observed = fmt.Sprintf("le fichier %s ne peut pas être lu : %v", + or(d.o.ConfigPath, "de configuration"), loaded.Err) + control.Remedy = "Le service ne démarrera pas sans lui. Vérifiez le chemin (--config, " + + "OPENSCALE_CONFIG, ou l'emplacement par défaut de §11.1) et les droits de lecture. " + + "Si le fichier a disparu, restaurez-en une des cinq versions rangées à côté de lui " + + "(config.json.1 à .5)." + return control + case !loaded.Parsed: + control.Status, control.Code = StatusFail, codeFactoryConfig + control.Observed = fmt.Sprintf("%s n'est pas un JSON exploitable (%v) — le poste tourne "+ + "quand même, en configuration d'usine, et ne calcule aucun prix ; l'écran "+ + "d'administration répond", d.o.ConfigPath, loaded.Err) + control.Remedy = "Corrigez la faute de syntaxe — c'est presque toujours une virgule en " + + "trop avant une accolade — ou restaurez config.json.1, la version précédente " + + "rangée à côté du fichier (§11.4)." + return control + case len(loaded.Faults) > 0: + control.Status, control.Code = StatusFail, codeFactoryConfig + control.Observed = fmt.Sprintf("%d faute(s) — le poste démarre en configuration d'usine et ne "+ + "calcule aucun prix. %s", len(loaded.Faults), faultSummary(loaded.Faults)) + control.Remedy = "Corrigez les fautes ci-dessus dans " + d.o.ConfigPath + ", ou restaurez une " + + "version précédente depuis l'écran d'administration (§11.4). " + + "`openscale config validate " + d.o.ConfigPath + "` les liste TOUTES, d'un coup." + return control + } + + // A configuration with no fault is only FULLY checked when this command was given the + // registries the file names its drivers in: §11.3 validates the form without them, and + // announcing « aucune faute » on a half-checked file would be a claim nobody made. + if missing := unknownDrivers(loaded.Config, d.o.Registries); len(missing) > 0 { + control.Status = StatusUnknown + control.Observed = fmt.Sprintf("aucune faute de forme, et les drivers nommés par le fichier "+ + "n'ont pas pu être vérifiés faute de registre : %s", strings.Join(missing, " · ")) + control.Remedy = "Relancez `openscale config validate " + d.o.ConfigPath + "` : la commande de " + + "§15.1 porte les registres de ce binaire et liste toutes les fautes d'un coup." + return control + } + + // A station with no administration password WEIGHS — that is the whole point of not + // making it a fault (ADR-033) — but nothing else would say so, and « rien ne le dit » + // is exactly how a station ended up locked out of its own settings: the delivered + // file carried a placeholder hash, `config validate` declared it sound, and the + // installation sheet went out with dotted lines. This is a WARNING and never a + // failure: the way in exists, it is the recovery code, and saying where it is written + // is more use to a volunteer than a red line. + if loaded.Config.Admin.PasswordHash == "" { + control.Status = StatusWarn + control.Observed = "aucune faute, et aucun mot de passe d'administration n'est posé : " + + "les réglages s'ouvrent en lecture, mais rien ne peut être enregistré" + control.Remedy = "Posez-en un depuis l'écran d'administration, avec le code de secours " + + "de la fiche d'installation, ou en ligne de commande : `openscale config password " + + d.o.ConfigPath + "`." + return control + } + + if retired := loaded.Config.Retired(); len(retired) > 0 { + control.Status = StatusWarn + control.Observed = fmt.Sprintf("aucune faute, et %d clé(s) retirée(s) traînent encore dans le "+ + "fichier : %s", len(retired), strings.Join(retired, ", ")) + control.Remedy = "Lancez d'abord « openscale config migrate " + d.o.ConfigPath + " » : il migre " + + "tout seul ce qui se convertit, et détaille pourquoi il refuse le reste. Ce qu'il refuse ne " + + "se devine pas ; retirez ces lignes-là à la main du fichier, puis relancez la migration (§11.2)." + return control + } + + // The schema version, because "this station's file was rewritten by the update" and + // "this station's file is only being read as if it were" are two different states, and + // diagnostic.zip is where somebody decides which one they are looking at. It is placed + // LAST among the warnings and never among the faults: the station already runs on the + // migrated form, in memory, so an out-of-date FILE is at most something to catch up on + // — and it must never bury the two warnings above, which both call for action sooner + // (no way in at all, or lines nobody can explain). + // + // A note is not automatically "behind, and migrate catches it up": migrateConfig + // refuses to write ANYTHING while a single note is MigrationRefused (cmd/openscale/ + // config.go), so promising a rewrite on the strength of len(notes) alone would be + // wrong exactly when it matters — a refused note is never routine. One refusal in + // particular is not even an old file: a note on domain.SchemaVersionKey is what a + // ROLLED-BACK station looks like from here, written by a binary NEWER than this one, + // and it earns its own sentence rather than being folded into "des changements". + if notes := loaded.MigrationNotes; len(notes) > 0 { + control.Status = StatusWarn + var refused []domain.MigrationNote + var rolledBack *domain.MigrationNote + for i := range notes { + if notes[i].Action != domain.MigrationRefused { + continue + } + refused = append(refused, notes[i]) + if notes[i].Key == domain.SchemaVersionKey { + rolledBack = ¬es[i] + } + } + + switch { + case rolledBack != nil: + control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s : %s", + loaded.Config.Fingerprint(), d.o.ConfigPath, rolledBack.Message) + control.Remedy = "Ce n'est pas un fichier en retard : cherchez pourquoi ce poste tourne " + + "sur un binaire plus ancien qu'il ne l'a fait — les journaux de mise à jour " + + "(update.ps1 ou update.sh) sur CE poste disent ce qui a échoué. « openscale config " + + "migrate " + d.o.ConfigPath + " » ne réécrira rien tant que ce fichier vient d'un " + + "binaire plus récent." + // Unreachable TODAY, and kept because it is the right welcome for the first refusal + // that is not one of retiredKeys. Every refusal this binary can produce on a key + // other than `version` LEAVES THAT KEY IN THE DOCUMENT -- that is what a refusal + // consists of (ADR-058) -- so Config.Retired() finds it and the branch above returns + // first. Only `version` reaches a refusal with nothing left behind, and it has its + // own case, right above this one. + case len(refused) > 0: + control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s porte %d changement(s) "+ + "que ce binaire ne convertit pas — « openscale config migrate » les nommera, chacun "+ + "avec sa raison, mais n'écrira RIEN tant qu'ils y restent", + loaded.Config.Fingerprint(), d.o.ConfigPath, len(refused)) + control.Remedy = "Lancez « openscale config migrate " + d.o.ConfigPath + " » pour lire la " + + "raison de chaque point refusé, tranchez-les à la main, puis relancez la commande : " + + "elle n'écrit le fichier qu'une fois qu'il n'y en a plus." + default: + control.Observed = fmt.Sprintf("aucune faute ; empreinte %s ; %s n'est pas encore au schéma %d "+ + "que ce binaire écrit (%d changement(s) en attente) — « openscale config migrate » le "+ + "réécrit (le poste tourne déjà sur la forme à jour, en mémoire)", + loaded.Config.Fingerprint(), d.o.ConfigPath, domain.CurrentSchemaVersion, len(notes)) + control.Remedy = "« openscale config migrate " + d.o.ConfigPath + " » réécrit le fichier sur " + + "cette forme ; rien ne presse, le poste fonctionne déjà normalement." + } + return control + } + + control.Status = StatusPass + control.Observed = fmt.Sprintf("aucune faute ; empreinte %s", loaded.Config.Fingerprint()) + return control +} + +// unknownDrivers names the drivers the file declares and the registries do not carry. +// +// It is what tells « the configuration is valid » from « the configuration has no fault this +// command was able to look for ». The scale is only checked when the station declares one: +// scale.type is legitimately empty on a station that has no scale (§11.2). +func unknownDrivers(cfg domain.Config, reg domain.Registries) []string { + var missing []string + declared := []struct { + field string + value string + known []string + check bool + }{ + {"scale.type", cfg.Scale.Type, reg.ScaleTypes(), cfg.Scale.Present}, + {"printer.type", cfg.Printer.Type, reg.PrinterTypes(), true}, + {"catalog.type", cfg.Catalog.Type, reg.CatalogSourceNames(), cfg.Catalog.Type != ""}, + } + for _, entry := range declared { + if !entry.check || known(entry.known, entry.value) { + continue + } + missing = append(missing, entry.field) + } + return missing +} + +// known reports whether value is in list. +func known(list []string, value string) bool { + for _, candidate := range list { + if candidate == value { + return true + } + } + return false +} + +// faultsQuoted is how many faults the one-line summary names before deferring to +// `openscale config validate`. Three: enough to recognise the block that is wrong, short +// enough to stay on a terminal line a volunteer reads out over the telephone. +const faultsQuoted = 3 + +// faultSummary names the first faults and says how many were left out. +func faultSummary(faults []domain.Fault) string { + quoted := faults + if len(quoted) > faultsQuoted { + quoted = quoted[:faultsQuoted] + } + parts := make([]string, 0, len(quoted)) + for _, fault := range quoted { + // faultLine and not Fault.String: the message of a fault about a sensitive field + // quotes the offending VALUE, and this sentence travels into diagnostic.zip. + parts = append(parts, faultLine(fault)) + } + out := strings.Join(parts, " · ") + if len(faults) > len(quoted) { + out += fmt.Sprintf(" · et %d autre(s)", len(faults)-len(quoted)) + } + return out +} + +// --- 13. The catalog source, as the service sees it ------------------------- + +const codeCatalogSource = "ERR-CAT-01" + +func (d *Doctor) checkCatalogSource(loaded loadedConfig, health Health, healthErr error) Control { + control := Control{ID: ControlCatalogSource, + Checked: "Source du catalogue accessible telle que le service la voit"} + if healthErr != nil { + control.Status = StatusUnknown + control.Observed = "le service ne répond pas, et lui seul voit la source avec SES droits. " + + d.declaredSourceSentence(loaded) + control.Remedy = "Démarrez le service (contrôle 1), puis relancez openscale doctor. Ce " + + "contrôle passe par le service exprès : vérifier le répertoire avec les droits de " + + "l'opérateur répondrait à une autre question (§15.4)." + return control + } + + weighable := health.State.CatalogCount + last := health.Catalog + switch { + case last == nil && weighable == 0: + control.Status, control.Code = StatusWarn, codeCatalogSource + control.Observed = "le service n'a encore appliqué aucun catalogue et ne sert aucun produit " + + "pesable. " + d.declaredSourceSentence(loaded) + control.Remedy = catalogArrivalRemedy(loaded) + case last != nil && last.Result == domain.ImportRejected: + control.Status, control.Code = StatusWarn, or(last.Code, codeCatalogSource) + control.Observed = fmt.Sprintf("le dernier fichier lu a été REFUSÉ (%s, %s) : %s. Le catalogue "+ + "précédent reste en service, %d produits pesables", + last.Source, or(last.FileName, "sans nom"), or(last.Reason, "sans motif"), weighable) + control.Remedy = "Le poste continue de peser avec le catalogue précédent : rien n'est perdu. " + + "Ouvrez la page Catalogue : les lignes fautives y sont nommées, avec leur " + + "numéro de ligne dans le CSV, et c'est cette liste-là qu'il faut envoyer au producteur." + case last != nil && last.Result == domain.ImportFailed: + control.Status, control.Code = StatusWarn, or(last.Code, codeCatalogSource) + control.Observed = fmt.Sprintf("le dernier import a échoué (%s, %s) : %s. %d produits pesables "+ + "restent en service", last.Source, or(last.FileName, "sans nom"), + or(last.Reason, "sans motif"), weighable) + control.Remedy = "Regardez le journal technique de l'écran d'administration : un échec " + + "d'import est un problème d'accès ou de droits sur la source, pas de contenu. " + + d.declaredSourceSentence(loaded) + case weighable == 0: + control.Status, control.Code = StatusWarn, codeCatalogSource + control.Observed = fmt.Sprintf("le dernier import a réussi (%s, %s) et le service ne sert "+ + "aucun produit pesable", last.Source, or(last.FileName, "sans nom")) + control.Remedy = "La grille du client est vide. Vérifiez sur la page Catalogue " + + "que les produits reçus portent bien un code-barres commençant par 0493 à 0499 : " + + "c'est le préfixe qui décide si un produit se pèse." + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("%d produits pesables en service ; dernier fichier appliqué : "+ + "%s via %s (%d lignes lues, %d anomalies)", weighable, or(last.FileName, "sans nom"), + last.Source, last.RowsRead, last.Anomalies) + } + return control +} + +// declaredSourceSentence names the source the FILE declares, labelled as declared. +// +// It never claims the directory was tested: that claim belongs to the service, and this +// sentence exists precisely for the case where the service cannot make it. +func (d *Doctor) declaredSourceSentence(loaded loadedConfig) string { + kind := loaded.Config.Catalog.Type + if kind == "" { + return "Aucune source n'est déclarée dans catalog.type." + } + if kind == domain.CatalogSourceWebDAV { + // The URL is deliberately NOT quoted here: this sentence travels into + // diagnostic.zip, and §15.4 wants that archive free of anything private. + return "Source déclarée : webdav (l'adresse n'est pas reproduite ici)." + } + return fmt.Sprintf("Source déclarée : %s ; le service crée lui-même son répertoire de dépôt "+ + "sous %s.", kind, or(d.o.DataDir, "le répertoire de données")) +} + +// catalogArrivalRemedy names the file the station is waiting for. +// +// The name DERIVES from station.number and is never written by hand: §14.4 makes that a +// rule, because two declarations of one fact is the failure the legacy application died +// of. +func catalogArrivalRemedy(loaded loadedConfig) string { + expected := "flv_.csv" + if loaded.Config.Station.Number > 0 { + expected = fmt.Sprintf("flv_%d.csv", loaded.Config.Station.Number) + } + return "La grille du client est vide et affiche « Catalogue vide ». Faites déposer " + expected + + " par le producteur, ou glissez un CSV dans l'écran de dépannage → « Importer un " + + "catalogue » : c'est le même parseur et la même qualification." +} diff --git a/internal/diag/doctor_config_test.go b/internal/diag/doctor_config_test.go new file mode 100644 index 0000000..ae9a71e --- /dev/null +++ b/internal/diag/doctor_config_test.go @@ -0,0 +1,214 @@ +package diag + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/domain" +) + +// The tests of doctor_config.go: the configuration file and the catalog source it +// declares. They separate what §11.3 insists on separating — a file nobody could READ from +// a file we understood and can list the faults of — because the two carry different +// remedies, and one of them must never publish the cooperative's WebDAV address. + +// --- 7. The configuration --------------------------------------------------- + +func TestAnInvalidConfigurationIsErrCfg01AndDefersToTheValidateCommand(t *testing.T) { + b := newBench(t) + b.tweak(func(cfg *domain.Config) { cfg.Station.Number = 0; cfg.Network.Listen = "pas-une-adresse" }) + + found := control(t, b.run(), ControlConfiguration) + if found.Status != StatusFail || found.Code != "ERR-CFG-01" { + t.Fatalf("configuration invalide : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "config validate") { + t.Errorf("la consigne doit renvoyer sur la commande qui liste TOUTES les fautes :\n%s", found.Remedy) + } + if !strings.Contains(found.Observed, "configuration d'usine") { + t.Errorf("le constat doit dire ce que le poste fera : démarrer en configuration d'usine :\n%s", + found.Observed) + } +} + +func TestAConfigurationThatIsNotJSONIsADifferentRemedyFromAnInvalidOne(t *testing.T) { + b := newBench(t) + b.writeConfig() + if err := os.WriteFile(b.configPath, []byte("{ \"station\": { \"number\": 2, } }"), 0o644); err != nil { + t.Fatalf("écriture du fichier cassé : %v", err) + } + doctor, err := New(b.options()) + if err != nil { + t.Fatalf("construction du doctor : %v", err) + } + report := doctor.Run(context.Background()) + if err := report.Validate(); err != nil { + t.Fatalf("le rapport se contredit : %v", err) + } + + found := control(t, report, ControlConfiguration) + if found.Status != StatusFail { + t.Fatalf("JSON cassé : %s", found.Status) + } + if !strings.Contains(found.Remedy, "config.json.1") { + t.Errorf("la consigne doit mener à la version précédente, pas à un écran qui réécrirait "+ + "un fichier qu'on n'a pas compris :\n%s", found.Remedy) + } +} + +func TestAMissingConfigurationFileIsNamedAsMissing(t *testing.T) { + b := newBench(t) + b.configPath = filepath.Join(t.TempDir(), "absent.json") + doctor, err := New(b.options()) + if err != nil { + t.Fatalf("construction du doctor : %v", err) + } + report := doctor.Run(context.Background()) + + found := control(t, report, ControlConfiguration) + if found.Status != StatusFail { + t.Fatalf("fichier absent : %s", found.Status) + } + if report.Station != 0 || !strings.Contains(reportHead(t, report), "poste non identifié") { + t.Errorf("un rapport sans configuration ne doit pas se présenter comme le poste 0") + } +} + +// runConfigurationControlOn writes raw as config.json on a bench whose registries name +// every driver the neutral profile declares — printer preview AND catalog local_drop — +// and returns the report's control « configuration ». +// +// Without the catalog registry, `unknownDrivers` (doctor.go) always finds catalog.type +// unverifiable and the control never gets past INCONNU : none of the tests of this +// section could otherwise reach a WARN or a PASS, only the neighbouring FAIL cases can, +// which is why they never needed this helper. +func runConfigurationControlOn(t *testing.T, raw string) Control { + t.Helper() + b := newBench(t) + b.registries.CatalogSources = []domain.DriverDescriptor{ + {ID: domain.CatalogSourceLocalDrop, Label: "Répertoire de dépôt"}, + } + if err := os.WriteFile(b.configPath, []byte(raw), 0o644); err != nil { + t.Fatalf("écriture du fichier de configuration : %v", err) + } + doctor, err := New(b.options()) + if err != nil { + t.Fatalf("construction du doctor : %v", err) + } + report := doctor.Run(context.Background()) + return control(t, report, ControlConfiguration) +} + +// TestConfigurationControlNamesTheSchemaVersion: whoever opens diagnostic.zip has to be +// able to tell a station whose file this binary rewrote from one whose file it only read. +// +// The document carries ui.tile_size, a key Migrate actually TRANSLATES (retireTileSize), +// and not just an old "version" number: stampSchemaVersion bumps the number in silence +// when nothing else needed changing, so a file that names no legacy key at all produces +// no migration note and would never exercise this control. +func TestConfigurationControlNamesTheSchemaVersion(t *testing.T) { + raw := `{"version":1,"station":{"number":2},"ui":{"tile_size":"large"},` + + `"admin":{"password_hash":"` + benchPasswordHash + `"}}` + found := runConfigurationControlOn(t, raw) + + if found.Status != StatusWarn { + t.Fatalf("fichier au schéma précédent : %s — %s", found.Status, found.Observed) + } + if !strings.Contains(found.Observed, "schéma") { + t.Errorf("le contrôle ne nomme pas la version du schéma : %q", found.Observed) + } + if !strings.Contains(found.Observed, "openscale config migrate") { + t.Errorf("le contrôle ne dit pas quoi lancer : %q", found.Observed) + } +} + +// TestConfigurationControlNamesARolledBackStationWithoutPromisingARewrite covers the +// station update.ps1 / update.sh rolled back on its own after a failed update : its +// config.json was written by a NEWER binary, so stampSchemaVersion refuses to touch the +// version field and reports it (domain.SchemaVersionKey). That refusal reaches this +// control with an EMPTY Config.Retired() — "version" is not in domain's retiredKeys — so +// it is never caught by the fault cascade above, and the control has to tell it apart +// from an ordinary file that is merely behind : it is not behind, and « openscale config +// migrate » will not write it, because migrateConfig refuses to write ANYTHING while a +// single note is refused. +func TestConfigurationControlNamesARolledBackStationWithoutPromisingARewrite(t *testing.T) { + raw := `{"version":3,"station":{"number":2},"admin":{"password_hash":"` + benchPasswordHash + `"}}` + found := runConfigurationControlOn(t, raw) + + if found.Status != StatusWarn { + t.Fatalf("fichier écrit par un binaire plus récent : %s — %s", found.Status, found.Observed) + } + if strings.Contains(found.Observed, "en attente") || + strings.Contains(found.Observed, "n'est pas encore au schéma") { + t.Errorf("le contrôle dit que le fichier est EN RETARD, alors qu'il est en AVANCE : %q", + found.Observed) + } + if !strings.Contains(found.Observed, "plus récente") { + t.Errorf("le contrôle ne dit pas que le fichier vient d'un binaire plus récent : %q", + found.Observed) + } + if strings.Contains(found.Remedy, "réécrit le fichier") { + t.Errorf("le remède promet une réécriture que « config migrate » va refuser : %q", found.Remedy) + } +} + +// --- 13. The catalog source ------------------------------------------------- + +func TestAnEmptyCatalogNamesTheFileTheStationIsWaitingFor(t *testing.T) { + b := newBench(t) + b.service.health.State.CatalogCount = 0 + b.service.health.Catalog = nil + + found := control(t, b.run(), ControlCatalogSource) + if found.Status != StatusWarn { + t.Fatalf("catalogue vide : %s, attendu ATTENTION", found.Status) + } + // The name DERIVES from station.number, and is never written by hand (§14.4). + if !strings.Contains(found.Remedy, "flv_2.csv") { + t.Errorf("la consigne doit nommer le fichier attendu, dérivé du numéro de poste :\n%s", + found.Remedy) + } +} + +func TestARejectedCatalogSaysTheStationKeepsWeighing(t *testing.T) { + b := newBench(t) + b.service.health.Catalog.Result = domain.ImportRejected + b.service.health.Catalog.Code = "ERR-CAT-03" + b.service.health.Catalog.Reason = "ligne 28, clé de contrôle fausse" + + found := control(t, b.run(), ControlCatalogSource) + if found.Status != StatusWarn || found.Code != "ERR-CAT-03" { + t.Fatalf("catalogue refusé : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "rien n'est perdu") { + t.Errorf("la consigne doit rassurer : le catalogue précédent reste en service :\n%s", found.Remedy) + } + if !strings.Contains(found.Remedy, "producteur") { + t.Errorf("la consigne doit dire à qui envoyer les lignes fautives :\n%s", found.Remedy) + } +} + +func TestTheCatalogControlNeverPublishesTheWebDAVAddress(t *testing.T) { + b := newBench(t) + b.tweak(func(cfg *domain.Config) { + cfg.Catalog.Type = domain.CatalogSourceWebDAV + cfg.Catalog.Options = domain.DriverOptions{ + "url": json.RawMessage(`"https://dav.example.org/balance"`), + "username": json.RawMessage(`"balance"`), + } + }) + b.service.silence() + + found := control(t, b.run(), ControlCatalogSource) + if strings.Contains(found.Observed+found.Remedy, "example.org") { + t.Fatalf("l'adresse privée de la source ne doit pas voyager dans un rapport que "+ + "diagnostic.zip emporte :\n%s\n%s", found.Observed, found.Remedy) + } + if !strings.Contains(found.Observed, "webdav") { + t.Errorf("le constat doit tout de même nommer le type de source :\n%s", found.Observed) + } +} diff --git a/internal/diag/doctor_devices.go b/internal/diag/doctor_devices.go new file mode 100644 index 0000000..6bd58d4 --- /dev/null +++ b/internal/diag/doctor_devices.go @@ -0,0 +1,289 @@ +package diag + +import ( + "context" + "fmt" + "strings" + + "openscale/internal/domain" +) + +// This file carries the three controls about the devices a station weighs and prints +// with: the serial port, the print queue and the cadence the scale is actually observed +// at. Two of them ask the RUNNING SERVICE rather than the host — a queue « installée pour +// l'utilisateur » is invisible from the service while being perfectly visible from here, +// and a cadence is a measurement nothing on disk carries (important-11, §15.4). + +// --- 10. The serial port ---------------------------------------------------- + +const codePortUnavailable = "ERR-SCL-03" + +// optionPort is the key a SERIAL scale.options carries the port name under (§11.2). +// +// It is a literal and it is allowed to be one, now that the control runs only for a +// protocol that declares itself on a serial port: `port` is the key +// internal/scale/serial declares in its own option schema, and it exists exactly when +// this control applies. What it may no longer do — and did — is assume that every scale +// of every protocol is reached through it. +const optionPort = "port" + +// scaleEndpoint reports what kind of access point the protocol id names is reached on, +// as the driver itself declared it, and whether this binary knows that protocol at all. +// +// An UNKNOWN protocol is not answered with a guess: control 8 already reports a +// scale.type no driver of this binary carries, and this control then does what it always +// did rather than adding a second, differently worded verdict on the same fault. +func (d *Doctor) scaleEndpoint(id string) (string, bool) { + for _, descriptor := range d.o.Registries.Scales { + if descriptor.ID == id { + return descriptor.Endpoint, true + } + } + return "", false +} + +func (d *Doctor) checkSerialPort(ctx context.Context, loaded loadedConfig) Control { + control := Control{ID: ControlSerialPort, Checked: "Port série présent et ouvrable"} + if !loaded.Config.Scale.Present { + // The explicit declaration of §11.2, which turns the light OFF instead of leaving + // it red. It is not a fault and must not be reported as one. + control.Status = StatusNotApplicable + control.Observed = "ce poste est déclaré sans balance (scale.present = false) : la saisie du " + + "poids à la main est le mode nominal" + return control + } + if endpoint, known := d.scaleEndpoint(loaded.Config.Scale.Type); known && + endpoint != domain.EndpointSerialPort { + // A protocol that is not reached through a serial port has no scale.options.port, + // and this control would report a missing key as a fault on a station that is + // perfectly configured. The light goes OFF, like the one of a station with no + // scale, and says why. + control.Status = StatusNotApplicable + control.Observed = fmt.Sprintf("le protocole %s ne passe pas par un port série : "+ + "il n'y a pas de scale.options.port à vérifier sur ce poste", loaded.Config.Scale.Type) + return control + } + declared, _ := loaded.Config.Scale.Options.Text(optionPort) + if declared == "" { + control.Status, control.Code = StatusFail, codePortUnavailable + control.Observed = "aucun port n'est déclaré (scale.options.port) alors que ce poste annonce une balance" + control.Remedy = "Ouvrez la page Matériel et lancez « Détecter automatiquement » : " + + "la détection ouvre chaque port, applique les parseurs et annonce celui qui répond. " + + "Ou déclarez scale.present = false si ce poste n'a réellement pas de balance." + return control + } + + list, err := d.o.Machine.SerialPorts(ctx) + if err != nil { + control.Status = StatusUnknown + control.Observed = fmt.Sprintf("le port %s est déclaré, et les ports du poste n'ont pas pu "+ + "être énumérés : %v", declared, err) + control.Remedy = "Relancez la commande depuis une invite administrateur, puis vérifiez le " + + "câble de la balance." + return control + } + if !containsPort(list, declared) { + control.Status, control.Code = StatusFail, codePortUnavailable + control.Observed = fmt.Sprintf("le port %s est déclaré et n'existe pas sur ce poste. %s", + declared, portListSentence(list)) + control.Remedy = "Rebranchez le câble de la balance, puis relancez la commande. Si le port a " + + "changé de nom — c'est ce qui arrive après un rebranchement — corrigez " + + "scale.options.port, ou lancez « Détecter automatiquement » depuis Réglages " + + "avancés → Matériel. Vérifiez aussi le contrôle 15 : la suspension USB sélective " + + "fait disparaître un adaptateur USB-série." + return control + } + + if err := d.o.Machine.OpenSerialPort(ctx, declared); err != nil { + // A port that is enumerated but refuses to open is EXCLUSIVE and held — which is + // what a running service looks like from here, and a success rather than a fault. + if live, liveErr := d.liveness(ctx); liveErr == nil && live.IsOpenScale() { + control.Status = StatusPass + control.Observed = fmt.Sprintf("le port %s existe et il est tenu par le service en cours : "+ + "un port série est exclusif, c'est le résultat attendu quand le poste tourne", declared) + return control + } + control.Status, control.Code = StatusFail, codePortUnavailable + control.Observed = fmt.Sprintf("le port %s existe et ne s'ouvre pas : %v", declared, err) + control.Remedy = "Un port série est exclusif. Fermez ce qui le tient — un autre programme, " + + "une fenêtre de terminal série restée ouverte — puis relancez la commande. Si " + + "personne ne le tient, c'est un droit qui manque : le compte du service doit " + + "appartenir au groupe dialout sous Linux (§15.3)." + return control + } + control.Status = StatusPass + control.Observed = fmt.Sprintf("le port %s existe et s'ouvre", declared) + return control +} + +// containsPort reports whether the declared name was enumerated. +// +// The comparison is case-insensitive because Windows spells the same port COM8 and com8, +// and a control that refused com8 would send somebody looking for a cable that is plugged +// in. +func containsPort(list []PortInfo, name string) bool { + for _, port := range list { + if strings.EqualFold(port.Name, name) { + return true + } + } + return false +} + +// portListSentence names what WAS enumerated, which is the half of the remedy a volunteer +// can act on. +func portListSentence(list []PortInfo) string { + if len(list) == 0 { + return "Aucun port série n'est visible sur ce poste." + } + names := make([]string, 0, len(list)) + for _, port := range list { + names = append(names, port.String()) + } + return "Ports visibles : " + strings.Join(names, " · ") + "." +} + +// --- 11. The print queue, from the service's context ------------------------ + +const codePrinterUnreachable = "ERR-PRN-01" + +func (d *Doctor) checkPrintQueue(ctx context.Context, loaded loadedConfig, health Health, healthErr error) Control { + control := Control{ID: ControlPrintQueue, + Checked: "File d'impression visible depuis le contexte du service"} + if healthErr != nil { + control.Status = StatusUnknown + control.Observed = "le service ne répond pas, et lui seul peut répondre : une file " + + "« installée pour l'utilisateur » est invisible du service tout en étant parfaitement " + + "visible d'ici. " + d.localQueues(ctx) + control.Remedy = "Démarrez le service (contrôle 1), puis relancez openscale doctor. Ce " + + "contrôle interroge le service exprès : le tester avec les droits de l'opérateur " + + "répondrait à une autre question (§15.2, important-11)." + return control + } + + configured, _ := loaded.Config.Printer.Options.Text("queue") + switch health.State.Printer.Health { + case "faulted": + control.Status, control.Code = StatusFail, codePrinterUnreachable + control.Observed = fmt.Sprintf("le service ne peut pas imprimer : %s. %s", + or(health.State.Printer.Detail, "aucun détail"), configuredQueueSentence(configured)) + control.Remedy = "Sous Windows, la file doit être installée en imprimante LOCALE MACHINE : " + + "une file « installée pour l'utilisateur » est invisible depuis le service, et c'est " + + "la panne la plus fréquente à l'installation (§15.2). " + d.localQueues(ctx) + + "\nEn attendant, l'écran de dépannage propose « Imprimer sur l'imprimante du poste N »." + case "consumable": + control.Status = StatusWarn + control.Observed = "le service imprime, et le rouleau arrive en fin de vie : " + + or(health.State.Printer.Detail, "aucun détail") + control.Remedy = "Changez le rouleau, puis touchez « J'ai changé le rouleau » sur l'écran " + + "de dépannage — c'est ce bouton qui remet le compteur à zéro (§8.5)." + case "unknown": + control.Status = StatusPass + control.Observed = "le service atteint l'imprimante ; celle-ci ne sait pas dire ce qu'elle a " + + "— les octets partent, rien ne revient. C'est la réponse honnête d'un transport " + + "unidirectionnel, pas une panne. " + configuredQueueSentence(configured) + case "ready": + control.Status = StatusPass + control.Observed = "le service voit l'imprimante et elle n'a rien à signaler. " + + configuredQueueSentence(configured) + default: + control.Status = StatusUnknown + control.Observed = fmt.Sprintf("le service annonce un état d'imprimante que cette version ne "+ + "connaît pas : %q", health.State.Printer.Health) + control.Remedy = "Les deux binaires ne sont pas de la même version. Mettez ce poste à jour, " + + "puis relancez la commande." + } + return control +} + +// configuredQueueSentence names what the configuration asks for. +func configuredQueueSentence(queue string) string { + if queue == "" { + return "Aucune file n'est nommée dans printer.options.queue." + } + return fmt.Sprintf("File configurée : « %s ».", queue) +} + +// localQueues names the queues visible from THIS process, labelled as such. +// +// The label is not decoration: presenting the operator's list as the service's viewpoint +// is the exact mistake important-11 is about, and the list is only ever useful as the +// second half of a remedy. +func (d *Doctor) localQueues(ctx context.Context) string { + list, err := d.o.Machine.PrintQueues(ctx) + if err != nil { + return "Les files visibles depuis cette session n'ont pas pu être énumérées : " + err.Error() + "." + } + if len(list) == 0 { + return "Aucune file d'impression n'est visible depuis cette session." + } + names := make([]string, 0, len(list)) + for _, queue := range list { + name := queue.Name + if queue.Default { + name += " (par défaut)" + } + names = append(names, name) + } + return "Files visibles depuis cette session — pas depuis celle du service : " + + strings.Join(names, " · ") + "." +} + +// --- 12. The observed scale cadence ----------------------------------------- + +const codeScaleLost = "ERR-SCL-02" + +func (d *Doctor) checkScaleRate(loaded loadedConfig, health Health, healthErr error) Control { + control := Control{ID: ControlScaleRate, Checked: "Cadence de la balance réellement observée"} + if !loaded.Config.Scale.Present { + control.Status = StatusNotApplicable + control.Observed = "ce poste est déclaré sans balance (scale.present = false)" + return control + } + if healthErr != nil { + control.Status = StatusUnknown + control.Observed = "le service ne répond pas : la cadence est ce que le poste a MESURÉ sur " + + "les soixante-quatre derniers intervalles, elle ne se déduit d'aucun fichier" + control.Remedy = "Démarrez le service (contrôle 1), laissez-le recevoir quelques trames, " + + "puis relancez openscale doctor." + return control + } + + scale := health.State.Scale + switch { + case !scale.Connected: + control.Status, control.Code = StatusFail, codeScaleLost + control.Observed = "le service n'a plus de balance : le port était ouvert et il s'est tu" + control.Remedy = "Vérifiez le câble et l'alimentation de la balance, puis rebranchez : le " + + "poste revient à l'état nominal seul. En attendant, l'écran client propose la saisie " + + "du poids à la main." + case scale.Observations == 0: + control.Status = StatusUnknown + control.Observed = "le service tient le port et n'a encore reçu aucune trame" + control.Remedy = "Posez quelque chose sur le plateau, attendez trois secondes, puis " + + "relancez la commande. Si rien n'arrive, vérifiez le débit et la parité déclarés " + + "dans scale.options contre ceux affichés sur la balance." + case scale.TooSlow: + // The alert condition itself is computed by the station, once, and read here: + // expiry_factor × median above the ceiling (§6.5, ADR-005). Two implementations of + // one rule is how the two of them come to disagree. + control.Status = StatusWarn + control.Observed = fmt.Sprintf("la balance émet une mesure toutes les %d ms, et le poids est "+ + "considéré périmé AVANT l'arrivée de la mesure suivante", scale.MedianMS) + control.Remedy = "Le poids s'affichera puis disparaîtra sans raison visible. Vérifiez le " + + "câble, puis la cadence d'émission réglée sur la balance elle-même : c'est un " + + "réglage de l'appareil, pas du poste." + case scale.Provisional: + control.Status = StatusWarn + control.Observed = fmt.Sprintf("cadence PROVISOIRE de %d ms sur %d intervalle(s) : moins de "+ + "huit ont été observés, la valeur affichée est celle que le driver déclare, pas une mesure", + scale.MedianMS, scale.Observations) + control.Remedy = "Laissez le poste recevoir des trames quelques secondes, puis relancez la " + + "commande : le chiffre deviendra une mesure." + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("une mesure toutes les %d ms, médiane mesurée sur %d intervalles", + scale.MedianMS, scale.Observations) + } + return control +} diff --git a/internal/diag/doctor_devices_test.go b/internal/diag/doctor_devices_test.go new file mode 100644 index 0000000..168add2 --- /dev/null +++ b/internal/diag/doctor_devices_test.go @@ -0,0 +1,217 @@ +package diag + +import ( + "errors" + "strings" + "testing" + + "openscale/internal/domain" +) + +// The tests of doctor_devices.go: the serial port, the print queue and the observed +// cadence. Two of the three are judged by the RUNNING SERVICE and never by the operator, +// so their tests silence the service as often as they answer for it — that distinction is +// the fault important-11 is about, and it is asserted here. + +// --- 10. The serial port ---------------------------------------------------- + +func TestAStationWithoutAScaleIsNotIll(t *testing.T) { + report := newBench(t).run() + + for _, id := range []string{ControlSerialPort, ControlScaleRate} { + found := control(t, report, id) + if found.Status != StatusNotApplicable { + t.Errorf("%s : %s, attendu SANS OBJET — scale.present = false éteint le feu au lieu de "+ + "le laisser rouge (§11.2)", id, found.Status) + } + if found.Remedy != "" { + t.Errorf("%s : un contrôle sans objet n'a rien à prescrire :\n%s", id, found.Remedy) + } + } +} + +func TestADeclaredPortThatDoesNotExistNamesTheOnesThatDo(t *testing.T) { + b := newBench(t).withScale() + b.machine.serialPorts = []PortInfo{{Name: "COM3", Description: "Prolific USB-to-Serial"}} + + found := control(t, b.run(), ControlSerialPort) + if found.Status != StatusFail || found.Code != "ERR-SCL-03" { + t.Fatalf("port absent : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Observed, "COM3") { + t.Errorf("le constat doit nommer les ports visibles :\n%s", found.Observed) + } + // §15.2 says selective USB suspend causes half the scale disconnects on a USB-serial + // adapter, and a port that vanished is exactly what it looks like. + if !strings.Contains(found.Remedy, "15") { + t.Errorf("la consigne devrait renvoyer au contrôle de la suspension USB :\n%s", found.Remedy) + } +} + +func TestAPortHeldByTheRunningServiceIsGreenBecauseAPortIsExclusive(t *testing.T) { + b := newBench(t).withScale() + b.machine.openPortErr = errors.New("Access is denied.") + + found := control(t, b.run(), ControlSerialPort) + if found.Status != StatusPass { + t.Fatalf("port tenu par le service : %s, attendu OK — %s", found.Status, found.Observed) + } + if !strings.Contains(found.Observed, "exclusif") { + t.Errorf("le constat doit expliquer pourquoi un refus d'ouverture est ici un succès :\n%s", + found.Observed) + } +} + +func TestAPortNobodyHoldsAndThatWillNotOpenIsRed(t *testing.T) { + b := newBench(t).withScale() + b.machine.openPortErr = errors.New("permission denied") + b.service.silence() + + found := control(t, b.run(), ControlSerialPort) + if found.Status != StatusFail || found.Code != "ERR-SCL-03" { + t.Fatalf("port non ouvrable : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "dialout") { + t.Errorf("la consigne doit citer le droit qui manque le plus souvent :\n%s", found.Remedy) + } +} + +func TestAStationThatAnnouncesAScaleWithoutAPortIsRed(t *testing.T) { + b := newBench(t) + b.tweak(func(cfg *domain.Config) { + cfg.Scale.Present = true + cfg.Scale.Type = "gram-xfoc-rs" + }) + + found := control(t, b.run(), ControlSerialPort) + if found.Status != StatusFail { + t.Fatalf("aucun port déclaré : %s", found.Status) + } + if !strings.Contains(found.Remedy, "Détecter automatiquement") { + t.Errorf("la consigne doit renvoyer sur la détection, qui est ce qui répond à « y a-t-il "+ + "une balance ? » :\n%s", found.Remedy) + } +} + +// --- 11. The print queue ---------------------------------------------------- + +func TestThePrintQueueIsJudgedByTheServiceAndNeverByTheOperator(t *testing.T) { + b := newBench(t) + b.service.silence() + + found := control(t, b.run(), ControlPrintQueue) + if found.Status != StatusUnknown { + t.Fatalf("service muet : %s, attendu INCONNU", found.Status) + } + // important-11: a queue « installed for the user » is visible from here and invisible + // from session 0. Answering with the operator's list would answer another question. + if !strings.Contains(found.Observed, "utilisateur") { + t.Errorf("le constat doit dire pourquoi le service seul peut répondre :\n%s", found.Observed) + } + if !strings.Contains(found.Observed, "SATO WS408_2") { + t.Errorf("les files visibles d'ici sont utiles comme indice, et doivent apparaître :\n%s", + found.Observed) + } +} + +func TestAPrinterTheServiceCannotReachNamesTheLocalMachineRule(t *testing.T) { + b := newBench(t) + b.service.health.State.Printer.Health = "faulted" + b.service.health.State.Printer.Detail = "file introuvable" + + found := control(t, b.run(), ControlPrintQueue) + if found.Status != StatusFail || found.Code != "ERR-PRN-01" { + t.Fatalf("imprimante injoignable : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "LOCALE MACHINE") { + t.Errorf("la consigne doit nommer la panne la plus fréquente à l'installation :\n%s", found.Remedy) + } + if !strings.Contains(found.Remedy, "poste N") { + t.Errorf("la consigne devrait proposer l'imprimante de secours en attendant :\n%s", found.Remedy) + } +} + +func TestAOneWayTransportThatSaysNothingIsNotAFault(t *testing.T) { + b := newBench(t) + b.service.health.State.Printer.Health = "unknown" + + found := control(t, b.run(), ControlPrintQueue) + if found.Status != StatusPass { + t.Fatalf("statut inconnu : %s, attendu OK — c'est la réponse honnête d'un transport "+ + "unidirectionnel (A5, ADR-007)", found.Status) + } +} + +func TestARollNearingItsEndIsAmberAndNamesTheButton(t *testing.T) { + b := newBench(t) + b.service.health.State.Printer.Health = "consumable" + b.service.health.State.Printer.Detail = "environ 100 étiquettes restantes" + + found := control(t, b.run(), ControlPrintQueue) + if found.Status != StatusWarn { + t.Fatalf("rouleau en fin de vie : %s, attendu ATTENTION", found.Status) + } + if !strings.Contains(found.Remedy, "J'ai changé le rouleau") { + t.Errorf("la consigne doit nommer le bouton qui remet le compteur à zéro :\n%s", found.Remedy) + } +} + +// --- 12. The observed cadence ----------------------------------------------- + +func TestACadenceTooSlowIsAmberAndExplainsTheSymptom(t *testing.T) { + b := newBench(t).withScale() + b.service.health.State.Scale.MedianMS = 2400 + b.service.health.State.Scale.TooSlow = true + + found := control(t, b.run(), ControlScaleRate) + if found.Status != StatusWarn { + t.Fatalf("cadence trop lente : %s, attendu ATTENTION (§15.4 : feu orange)", found.Status) + } + if !strings.Contains(found.Observed, "2400") { + t.Errorf("le constat doit citer la cadence mesurée :\n%s", found.Observed) + } + if !strings.Contains(found.Observed, "périmé") { + t.Errorf("le constat doit dire la conséquence : le poids est périmé avant la mesure "+ + "suivante :\n%s", found.Observed) + } +} + +func TestAProvisionalCadenceIsNeverPresentedAsAMeasurement(t *testing.T) { + b := newBench(t).withScale() + b.service.health.State.Scale.Observations = 3 + b.service.health.State.Scale.Provisional = true + + found := control(t, b.run(), ControlScaleRate) + if found.Status != StatusWarn { + t.Fatalf("cadence provisoire : %s, attendu ATTENTION", found.Status) + } + if !strings.Contains(found.Observed, "PROVISOIRE") { + t.Errorf("le constat doit dire que ce n'est pas une mesure :\n%s", found.Observed) + } +} + +func TestAScaleThatWentSilentIsErrScl02(t *testing.T) { + b := newBench(t).withScale() + b.service.health.State.Scale.Connected = false + + found := control(t, b.run(), ControlScaleRate) + if found.Status != StatusFail || found.Code != "ERR-SCL-02" { + t.Fatalf("balance perdue : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "saisie du poids à la main") { + t.Errorf("la consigne doit dire que le poste sert encore, à la main :\n%s", found.Remedy) + } +} + +func TestAPortHeldWithNoFrameYetIsUnknownAndNotAFault(t *testing.T) { + b := newBench(t).withScale() + b.service.health.State.Scale.Observations = 0 + + found := control(t, b.run(), ControlScaleRate) + if found.Status != StatusUnknown { + t.Fatalf("aucune trame : %s, attendu INCONNU", found.Status) + } + if !strings.Contains(found.Remedy, "plateau") { + t.Errorf("la consigne doit dire le geste qui produit une trame :\n%s", found.Remedy) + } +} diff --git a/internal/diag/doctor_service.go b/internal/diag/doctor_service.go new file mode 100644 index 0000000..89b8519 --- /dev/null +++ b/internal/diag/doctor_service.go @@ -0,0 +1,165 @@ +package diag + +import ( + "context" + "fmt" + "runtime" +) + +// This file carries the three controls that answer « ce poste est-il debout ? » : the +// service, the scheduled task that opens the client screen, and the address the service +// listens on. They are the three things §15.2 and §15.3 install, and the three a station +// that sells nothing is missing one of. + +// --- 1. The service --------------------------------------------------------- + +func (d *Doctor) checkService(ctx context.Context) Control { + control := Control{ID: ControlService, Checked: "Service OpenScale présent et démarré"} + state, err := d.o.Machine.Service(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, "le gestionnaire de services n'a pas répondu : "+err.Error() + control.Remedy = "Relancez cette commande depuis une invite ADMINISTRATEUR : l'état d'un " + + "service n'est pas lisible par tout le monde." + case !state.Determined: + control.Status, control.Observed = StatusUnknown, "ce système n'expose pas de gestionnaire de services interrogeable" + control.Remedy = "Vérifiez à la main que le poste est lancé, puis ouvrez http://" + + "127.0.0.1:8080/ sur l'écran : si la page s'affiche, le service tourne." + case !state.Known: + control.Status = StatusFail + control.Observed = fmt.Sprintf("aucun service « %s » n'est déclaré sur ce poste", state.Name) + control.Remedy = serviceInstallRemedy() + case !state.Running: + control.Status = StatusFail + control.Observed = fmt.Sprintf("le service « %s » est installé mais arrêté (%s)", state.Name, state.Detail) + // THIS is the sentence the L8 criterion asks for: doctor diagnoses a service that + // will not start AND SAYS WHY — by naming the four controls that carry the reason. + control.Remedy = serviceStartRemedy() + case !state.Automatic: + control.Status = StatusWarn + control.Observed = fmt.Sprintf("le service « %s » tourne, et son démarrage n'est pas automatique (%s)", + state.Name, state.Detail) + control.Remedy = "Après une coupure de courant, ce poste ne redémarrera pas seul. Passez-le " + + "en démarrage automatique : sc config OpenScale start= auto\n" + + "Si ce poste est le poste pilote, c'est voulu (§18, lot L9) et il n'y a rien à faire." + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("le service « %s » tourne, démarrage automatique (%s)", + state.Name, state.Detail) + } + return control +} + +// serviceInstallRemedy is the instruction for a service the manager has never heard of. +func serviceInstallRemedy() string { + if runtime.GOOS == "windows" { + return "Relancez install.ps1 en administrateur (§15.2), ou installez le service à la main :\n" + + `"C:\Program Files\OpenScale\openscale.exe" service install` + "\n" + + "puis sc config OpenScale start= auto" + } + return "Installez l'unité : systemctl enable --now openscale.service (§15.3)." +} + +// serviceStartRemedy names WHERE the reason for a failed start is written. +func serviceStartRemedy() string { + command := "systemctl start openscale.service" + logs := "journalctl -u openscale.service -n 50" + if runtime.GOOS == "windows" { + command = "sc start OpenScale" + logs = `le fichier C:\ProgramData\OpenScale\data\logs\openscale.log` + } + return "Démarrez-le : " + command + "\nS'il s'arrête aussitôt, la raison est dans l'un des " + + "contrôles 6, 7, 8 ou 10 ci-dessous — adresse d'écoute déjà prise, configuration " + + "illisible, base inutilisable, port série absent — et le détail est dans " + logs + "." +} + +// --- 2. The kiosk task ------------------------------------------------------ + +func (d *Doctor) checkKioskTask(ctx context.Context) Control { + control := Control{ID: ControlKioskTask, Checked: "Tâche du kiosque présente"} + state, err := d.o.Machine.KioskTask(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, "la tâche du kiosque n'a pas pu être interrogée : "+err.Error() + control.Remedy = "Relancez cette commande depuis une invite ADMINISTRATEUR : le dossier " + + "des tâches planifiées n'est pas lisible par tout le monde, et « je n'ai pas pu " + + "regarder » n'est pas « la tâche est absente ».\n" + + "Tant que ce contrôle est INCONNU, ne réinstallez rien." + case !state.Determined: + control.Status, control.Observed = StatusUnknown, "ce système n'expose pas de planificateur interrogeable" + control.Remedy = "Vérifiez à la main qu'un navigateur en plein écran s'ouvre à l'ouverture de session." + case !state.Known: + control.Status = StatusFail + control.Observed = fmt.Sprintf("aucune tâche « %s » n'est déclarée", state.Name) + control.Remedy = kioskInstallRemedy() + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("la tâche « %s » est déclarée (%s)", state.Name, or(state.Detail, "état non lu")) + } + return control +} + +// kioskInstallRemedy is the instruction for a missing kiosk task. +// +// It says what the ABSENCE costs, because a volunteer reading « tâche absente » has no +// way of knowing that the service can be perfectly healthy while the screen stays black. +func kioskInstallRemedy() string { + if runtime.GOOS == "windows" { + return "Sans elle, le service tourne mais l'écran client ne s'ouvre jamais. Relancez " + + "install.ps1 en administrateur (§15.2), ou recréez la tâche :\n" + + `schtasks /create /tn "OpenScale-Kiosk" /xml openscale-kiosk.xml /f` + } + return "Sans elle, le service tourne mais l'écran client ne s'ouvre jamais. Activez l'unité " + + "du kiosque : systemctl enable --now openscale-kiosk.service (§15.3)." +} + +// --- 6. The listening address ----------------------------------------------- + +// The two codes §13.4 allocates to a listening address that cannot be taken. +const ( + codeAnotherInstance = "ERR-SYS-01" + codeCannotListen = "ERR-SYS-02" +) + +func (d *Doctor) checkListenAddress(ctx context.Context, loaded loadedConfig) Control { + control := Control{ID: ControlListenAddress, + Checked: "Adresse d'écoute libre, ou tenue par ce poste"} + address := loaded.Config.Network.Listen + if address == "" { + control.Status, control.Observed = StatusFail, "aucune adresse d'écoute n'est déclarée (network.listen)" + control.Remedy = "Renseignez network.listen dans " + or(d.o.ConfigPath, "la configuration") + + ", par exemple 127.0.0.1:8080, puis redémarrez le service." + return control + } + + state, err := d.o.Machine.CanListen(ctx, address) + if err != nil || !state.Determined { + control.Status = StatusUnknown + control.Observed = fmt.Sprintf("l'adresse %s n'a pas pu être testée%s", address, detailSuffix(err)) + control.Remedy = "Vérifiez que network.listen s'écrit hôte:port, par exemple 127.0.0.1:8080." + return control + } + if state.Bindable { + control.Status = StatusPass + control.Observed = fmt.Sprintf("%s est libre : le service pourra la prendre", address) + return control + } + + // The socket IS the single-instance lock (§13.4). An address that refuses a bind AND + // answers our own /healthz is held by this very station, which is the nominal case + // when the service is running — and not a fault to report. + if live, err := d.liveness(ctx); err == nil && live.IsOpenScale() { + control.Status = StatusPass + control.Observed = fmt.Sprintf("%s est tenue par ce poste : /healthz répond (budget %d ms)", + address, live.BudgetMS) + return control + } + control.Status, control.Code = StatusFail, codeCannotListen + control.Observed = fmt.Sprintf("%s est déjà prise, et ce qui la tient n'est pas OpenScale : %s", + address, or(state.Detail, "le bind a été refusé")) + control.Remedy = "Deux cas, et un seul geste pour les distinguer. Si un autre programme écoute " + + "sur ce port, changez network.listen — 127.0.0.1:8081 par exemple. Si c'est une " + + "instance d'OpenScale restée en vie (" + codeAnotherInstance + "), arrêtez le service " + + "avant d'en lancer un second." + return control +} diff --git a/internal/diag/doctor_service_test.go b/internal/diag/doctor_service_test.go new file mode 100644 index 0000000..1685070 --- /dev/null +++ b/internal/diag/doctor_service_test.go @@ -0,0 +1,109 @@ +package diag + +import ( + "strings" + "testing" +) + +// The tests of doctor_service.go: the three controls that answer « ce poste est-il +// debout ? » — the service, the kiosk task, and the address it listens on. Each is +// exercised green and red, and every red case asserts the remedy as well as the verdict. +// The bench and the doubles are in harness_test.go. + +// --- 1. The service --------------------------------------------------------- + +func TestAServiceThatWillNotStartIsToldWhereTheReasonIsWritten(t *testing.T) { + b := newBench(t) + b.machine.service.Running = false + b.machine.service.Detail = "STOPPED" + + found := control(t, b.run(), ControlService) + if found.Status != StatusFail { + t.Fatalf("service arrêté : %s", found.Status) + } + // The criterion of §18 for lot L8: doctor diagnoses a service that will not start and + // says WHY. It cannot know the reason itself, so it names the controls that carry it. + for _, want := range []string{"6", "7", "8", "10"} { + if !strings.Contains(found.Remedy, want) { + t.Errorf("la consigne ne renvoie pas au contrôle %s :\n%s", want, found.Remedy) + } + } +} + +func TestAnUninstalledServiceIsADifferentRemedyFromAStoppedOne(t *testing.T) { + b := newBench(t) + b.machine.service = ServiceState{Name: "OpenScale", Determined: true} + + found := control(t, b.run(), ControlService) + if found.Status != StatusFail { + t.Fatalf("service inconnu : %s", found.Status) + } + if strings.Contains(found.Remedy, "sc start") || strings.Contains(found.Remedy, "systemctl start") { + t.Errorf("on ne démarre pas un service qui n'existe pas :\n%s", found.Remedy) + } + // Case-INSENSITIVE, and that is the fix: the Windows remedy names install.ps1, the + // Linux one opens with « Installez l'unité ». A case-sensitive search passed on + // Windows and failed on Linux against a remedy that was perfectly correct. + if !strings.Contains(strings.ToLower(found.Remedy), "install") { + t.Errorf("la consigne devrait mener à l'installation :\n%s", found.Remedy) + } +} + +func TestAServiceInManualStartIsAmberAndNamesThePilotPeriod(t *testing.T) { + b := newBench(t) + b.machine.service.Automatic = false + + found := control(t, b.run(), ControlService) + if found.Status != StatusWarn { + t.Fatalf("démarrage manuel : %s, attendu ATTENTION — c'est ce que le lot pilote installe", found.Status) + } + if !strings.Contains(found.Remedy, "L9") { + t.Errorf("la consigne devrait dire que le poste pilote est un cas voulu :\n%s", found.Remedy) + } +} + +// --- 2. The kiosk task ------------------------------------------------------ + +func TestAMissingKioskTaskSaysWhatItsAbsenceCosts(t *testing.T) { + b := newBench(t) + b.machine.kiosk = ServiceState{Name: "OpenScale-Kiosk", Determined: true} + + found := control(t, b.run(), ControlKioskTask) + if found.Status != StatusFail { + t.Fatalf("tâche absente : %s", found.Status) + } + // A volunteer reading « tâche absente » has no way of knowing the service can be + // perfectly healthy while the screen stays black. The remedy says it. + if !strings.Contains(found.Remedy, "écran client") { + t.Errorf("la consigne ne dit pas ce que l'absence coûte :\n%s", found.Remedy) + } +} + +// --- 6. The listening address ----------------------------------------------- + +func TestAnAddressHeldByOurOwnServiceIsGreen(t *testing.T) { + b := newBench(t) + // The socket IS the single-instance lock (§13.4): a running station cannot bind its own + // address, and that is the nominal case rather than a fault. + b.machine.listen.Bindable = false + + found := control(t, b.run(), ControlListenAddress) + if found.Status != StatusPass { + t.Fatalf("adresse tenue par le poste : %s, attendu OK — %s", found.Status, found.Observed) + } +} + +func TestAnAddressHeldBySomethingElseIsRedAndSeparatesTheTwoCases(t *testing.T) { + b := newBench(t) + b.machine.listen.Bindable = false + b.machine.listen.Detail = "bind: address already in use" + b.service.silence() + + found := control(t, b.run(), ControlListenAddress) + if found.Status != StatusFail || found.Code != "ERR-SYS-02" { + t.Fatalf("adresse prise : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "ERR-SYS-01") { + t.Errorf("la consigne doit distinguer l'autre programme de l'autre instance :\n%s", found.Remedy) + } +} diff --git a/internal/diag/doctor_storage.go b/internal/diag/doctor_storage.go new file mode 100644 index 0000000..553741d --- /dev/null +++ b/internal/diag/doctor_storage.go @@ -0,0 +1,215 @@ +package diag + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" +) + +// This file carries the four controls that stand between a station and the place it +// writes: the rights on the data directory, the room left on its volume, the base itself, +// and the schema that base is at. They are ordered the way a failure travels — a directory +// nobody can write is a base nobody can open — and each remedy names the control above it +// rather than repeating its diagnosis. + +// noDataDirectoryObserved is the fact the two directory controls report in the same words. +// +// They differ on the VERDICT and not on what was found: writing nowhere is a failure, +// measuring nowhere is merely unknowable. +const noDataDirectoryObserved = "aucun répertoire de données n'a été désigné" + +// --- 4. The data directory -------------------------------------------------- + +func (d *Doctor) checkDataDirectory() Control { + control := Control{ID: ControlDataDirectory, + Checked: "Droits d'écriture sur le répertoire de données"} + if d.o.DataDir == "" { + control.Status, control.Observed = StatusFail, noDataDirectoryObserved + control.Remedy = "Relancez la commande avec --data , ou renseignez OPENSCALE_DATA (§11.1)." + return control + } + if err := probeWritable(d.o.DataDir); err != nil { + control.Status = StatusFail + control.Observed = fmt.Sprintf("impossible d'écrire dans %s : %v", d.o.DataDir, err) + control.Remedy = writableRemedy(d.o.DataDir) + return control + } + control.Status = StatusPass + control.Observed = fmt.Sprintf("%s est accessible en écriture", d.o.DataDir) + return control +} + +// probeWritable proves the directory is writable BY WRITING. +// +// Reading the permission bits would answer a different question: on Windows an ACL that +// looks right can still be shadowed by an inherited deny, and on Linux a full or +// read-only mount grants the bits and refuses the write. The only honest test of « can +// this be written » is a write, and it removes what it wrote. +func probeWritable(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + probe, err := os.CreateTemp(dir, "doctor-*.tmp") + if err != nil { + return err + } + name := probe.Name() + // The bytes matter: creating a file can succeed on a full volume, and only the write + // then fails. A station whose disk is full must not be reported as writable. + _, writeErr := probe.Write([]byte("openscale doctor\n")) + closeErr := probe.Close() + removeErr := os.Remove(name) + return errors.Join(writeErr, closeErr, removeErr) +} + +// writableRemedy names the command that fixes the rights, per system. +func writableRemedy(dir string) string { + if runtime.GOOS == "windows" { + return "Rendez le répertoire inscriptible au compte du service. En administrateur :\n" + + `icacls "` + dir + `" /grant "SYSTEM:(OI)(CI)F" /T` + "\n" + + "puis accordez la modification au compte du kiosque (§15.2, étape 2). Si le disque " + + "est plein, c'est le contrôle 5 qui le dit." + } + return "Rendez le répertoire inscriptible au compte du service :\n" + + "install -d -o openscale -g openscale " + dir + "\n" + + "et vérifiez qu'il figure dans ReadWritePaths= de l'unité (§15.3)." +} + +// --- 5. Disk space ---------------------------------------------------------- + +// codeDiskFull is ERR-SYS-05, which §15.4 allocates to a full disk and to the weighings +// it leaves unjournalled. +const codeDiskFull = "ERR-SYS-05" + +func (d *Doctor) checkDiskSpace(loaded loadedConfig) Control { + control := Control{ID: ControlDiskSpace, Checked: "Espace disque du répertoire de données"} + if d.o.DataDir == "" { + control.Status, control.Observed = StatusUnknown, noDataDirectoryObserved + control.Remedy = "Relancez la commande avec --data (§11.1)." + return control + } + space, err := d.o.Machine.FreeSpace(d.o.DataDir) + if err != nil || !space.Determined { + control.Status = StatusUnknown + control.Observed = "l'espace libre du volume n'a pas pu être mesuré" + detailSuffix(err) + control.Remedy = "Regardez l'espace libre du volume qui porte " + d.o.DataDir + + " avec l'explorateur de fichiers ou avec df." + return control + } + + free, total := megabytes(space.FreeBytes), megabytes(space.TotalBytes) + threshold := int64(loaded.Config.Maintenance.DiskAlertMB) + control.Observed = fmt.Sprintf("%d Mo libres sur %d Mo", free, total) + switch { + case free <= 0: + control.Status, control.Code = StatusFail, codeDiskFull + control.Observed += " — le volume est plein : les pesées sortiront encore, et elles ne seront plus journalisées" + control.Remedy = "Libérez de l'espace sur le volume qui porte " + d.o.DataDir + ". Les " + + "copies de sauvegarde de la base (openscale.db.before-v… et .backup-…) sont les " + + "plus gros fichiers qu'on puisse retirer sans rien perdre du journal." + case threshold > 0 && free < threshold: + control.Status, control.Code = StatusWarn, codeDiskFull + control.Observed += fmt.Sprintf(" — sous le seuil d'alerte de %d Mo (maintenance.disk_alert_mb)", threshold) + control.Remedy = "Libérez de l'espace avant que le journal ne cesse d'être écrit. Le seuil " + + "lui-même se règle dans maintenance.disk_alert_mb." + case threshold <= 0: + control.Status = StatusPass + control.Observed += " — aucun seuil d'alerte n'est déclaré (maintenance.disk_alert_mb)" + default: + control.Status = StatusPass + control.Observed += fmt.Sprintf(" — seuil d'alerte %d Mo", threshold) + } + return control +} + +// --- 8. The database -------------------------------------------------------- + +const codeDatabaseUnusable = "ERR-DB-01" + +func (d *Doctor) checkDatabase(ctx context.Context, base Database, openErr error) Control { + control := Control{ID: ControlDatabase, Checked: "Base ouvrable et contrôle d'intégrité"} + if base == nil { + control.Status = StatusFail + control.Code, control.Observed = classifyDatabaseFailure(openErr) + control.Remedy = "Le service ne démarrera pas sans la base. Vérifiez les droits du " + + "répertoire de données (contrôle 4) et l'espace disque (contrôle 5). Si le fichier " + + "est endommagé, restaurez la copie la plus récente rangée à côté de lui — " + + "openscale.db.backup-… ou openscale.db.before-v… — et redémarrez le service (§15.5)." + return control + } + if err := base.IntegrityCheck(ctx); err != nil { + control.Status, control.Code = StatusFail, codeDatabaseUnusable + control.Observed = fmt.Sprintf("%s s'ouvre, et son contrôle d'intégrité échoue : %v", base.Path(), err) + control.Remedy = "Ne réparez rien à la main. Arrêtez le service, renommez le fichier, " + + "restaurez la copie la plus récente (openscale.db.backup-… ou openscale.db.before-v…), " + + "redémarrez le service (§15.5). Gardez le fichier endommagé : il porte les pesées " + + "que la copie n'a pas." + return control + } + control.Status = StatusPass + control.Observed = fmt.Sprintf("%s s'ouvre et son contrôle d'intégrité passe", base.Path()) + return control +} + +// classifyDatabaseFailure reports the code and the sentence of a refusal to open. +func classifyDatabaseFailure(err error) (code, observed string) { + if err == nil { + return codeDatabaseUnusable, "la base n'a pas pu être ouverte, sans raison rapportée" + } + var failure *DatabaseFailure + if errors.As(err, &failure) && failure.Code != "" { + return failure.Code, failure.Message + } + return codeDatabaseUnusable, "la base n'a pas pu être ouverte : " + err.Error() +} + +// --- 9. Migrations ---------------------------------------------------------- + +const codeSchemaFromNewerVersion = "ERR-DB-02" + +func (d *Doctor) checkMigrations(base Database, openErr error) Control { + control := Control{ID: ControlMigrations, Checked: "Migrations à jour"} + if base == nil { + control.Status = StatusUnknown + control.Observed = "la version du schéma n'est pas lisible parce que la base ne s'ouvre pas" + + detailSuffix(openErr) + control.Remedy = "Réglez d'abord le contrôle 8 : la version du schéma se lit dans la base." + return control + } + applied, err := base.SchemaVersion() + if err != nil { + control.Status, control.Code = StatusFail, codeDatabaseUnusable + control.Observed = "la version du schéma n'a pas pu être lue : " + err.Error() + control.Remedy = "La base s'ouvre et ne répond pas : traitez-la comme endommagée et " + + "restaurez la copie la plus récente (§15.5)." + return control + } + switch { + case d.o.Migrations <= 0: + control.Status = StatusUnknown + control.Observed = fmt.Sprintf("schéma %d appliqué ; le nombre de migrations que porte ce "+ + "binaire n'a pas été fourni à cette commande", applied) + control.Remedy = "Rien à faire sur le poste : c'est cette commande qui n'a pas été câblée " + + "complètement. Signalez-le." + case applied > d.o.Migrations: + control.Status, control.Code = StatusFail, codeSchemaFromNewerVersion + control.Observed = fmt.Sprintf("la base est au schéma %d et ce binaire n'en connaît que %d : "+ + "elle a été créée par une version plus récente", applied, d.o.Migrations) + control.Remedy = "Mettez l'application à jour sur ce poste. Si vous venez au contraire de " + + "revenir en arrière volontairement, restaurez AUSSI la copie " + + "openscale.db.before-v… correspondante : les migrations ne redescendent pas (§12.5)." + case applied < d.o.Migrations: + control.Status, control.Code = StatusFail, codeDatabaseUnusable + control.Observed = fmt.Sprintf("la base est au schéma %d alors que ce binaire en porte %d : "+ + "les migrations n'ont pas été appliquées", applied, d.o.Migrations) + control.Remedy = "Les migrations s'appliquent au démarrage du service. Démarrez-le " + + "(contrôle 1) et relisez ce contrôle ; s'il reste rouge, la base est en lecture " + + "seule ou le disque est plein (contrôles 4 et 5)." + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("schéma %d, à jour", applied) + } + return control +} diff --git a/internal/diag/doctor_storage_test.go b/internal/diag/doctor_storage_test.go new file mode 100644 index 0000000..a15cd52 --- /dev/null +++ b/internal/diag/doctor_storage_test.go @@ -0,0 +1,169 @@ +package diag + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// The tests of doctor_storage.go: the four controls that stand between a station and the +// place it writes — the rights on the data directory, the room left on its volume, the +// base, and the schema that base is at. The write-rights case writes for real, on a real +// temporary directory: a double that pretended to would test nothing. + +// --- 4. The data directory -------------------------------------------------- + +func TestADataDirectoryThatCannotBeWrittenIsProvedByWriting(t *testing.T) { + b := newBench(t) + // A regular FILE where a directory is expected: MkdirAll refuses it on every system, + // including Windows, where marking a directory read-only does not stop a write. + blocker := filepath.Join(b.dataDir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("préparation du bloqueur : %v", err) + } + b.dataDir = filepath.Join(blocker, "data") + + found := control(t, b.run(), ControlDataDirectory) + if found.Status != StatusFail { + t.Fatalf("répertoire inutilisable : %s", found.Status) + } + if !strings.Contains(found.Remedy, b.dataDir) { + t.Errorf("la consigne doit nommer le répertoire :\n%s", found.Remedy) + } +} + +// --- 5. Disk space ---------------------------------------------------------- + +func TestAFullDiskIsRedAndALowDiskIsAmber(t *testing.T) { + full := newBench(t) + full.machine.space.FreeBytes = 0 + found := control(t, full.run(), ControlDiskSpace) + if found.Status != StatusFail || found.Code != "ERR-SYS-05" { + t.Fatalf("disque plein : %s / %q, attendu ÉCHEC / ERR-SYS-05", found.Status, found.Code) + } + if !strings.Contains(found.Observed, "journalisées") { + t.Errorf("le constat doit dire ce qu'un disque plein coûte : les pesées sortent et ne sont "+ + "plus journalisées (ADR-013) :\n%s", found.Observed) + } + + low := newBench(t) + // The threshold is the one the configuration declares, and nothing else: 200 Mo in the + // neutral profile. 32 Mo is under it, and the volume is not full. + low.machine.space.FreeBytes = 32 << 20 + found = control(t, low.run(), ControlDiskSpace) + if found.Status != StatusWarn { + t.Fatalf("sous le seuil d'alerte : %s, attendu ATTENTION", found.Status) + } + if !strings.Contains(found.Observed, "200 Mo") { + t.Errorf("le constat doit citer le seuil déclaré :\n%s", found.Observed) + } +} + +func TestASpaceThatCouldNotBeMeasuredIsNeverReportedAsZero(t *testing.T) { + b := newBench(t) + b.machine.space = FreeSpace{} + b.machine.spaceErr = errors.New("volume inaccessible") + + found := control(t, b.run(), ControlDiskSpace) + if found.Status != StatusUnknown { + t.Fatalf("mesure impossible : %s, attendu INCONNU — un chiffre que personne n'a mesuré "+ + "enverrait quelqu'un supprimer des fichiers", found.Status) + } + if strings.Contains(found.Observed, "0 Mo") { + t.Errorf("le constat ne doit citer aucun chiffre :\n%s", found.Observed) + } +} + +// --- 8. The database -------------------------------------------------------- + +func TestABaseThatWillNotOpenNamesItsCode(t *testing.T) { + b := newBench(t) + b.openErr = &DatabaseFailure{Code: "ERR-DB-01", + Message: "ouverture de openscale.db impossible : accès refusé"} + + report := b.run() + found := control(t, report, ControlDatabase) + if found.Status != StatusFail || found.Code != "ERR-DB-01" { + t.Fatalf("base fermée : %s / %q", found.Status, found.Code) + } + // The migration control cannot conclude without the base, and it says so rather than + // accusing the schema. + migrations := control(t, report, ControlMigrations) + if migrations.Status != StatusUnknown { + t.Errorf("migrations sans base : %s, attendu INCONNU", migrations.Status) + } + if !strings.Contains(migrations.Remedy, "contrôle 8") { + t.Errorf("la consigne doit renvoyer au contrôle qui bloque :\n%s", migrations.Remedy) + } +} + +func TestADamagedBaseIsNeverRepairedByThisCommand(t *testing.T) { + b := newBench(t) + b.base.integrityErr = errors.New("row 12 missing from index products_by_category") + + found := control(t, b.run(), ControlDatabase) + if found.Status != StatusFail { + t.Fatalf("base endommagée : %s", found.Status) + } + if !strings.Contains(found.Remedy, "restaurez") || !strings.Contains(found.Remedy, "Gardez") { + t.Errorf("la consigne doit dire de restaurer ET de garder le fichier endommagé, qui porte "+ + "les pesées que la copie n'a pas :\n%s", found.Remedy) + } +} + +func TestTheBaseIsGivenBackAtTheEndOfTheRun(t *testing.T) { + b := newBench(t) + b.run() + if !b.base.closed { + t.Error("la base n'a pas été refermée : le SERVICE la possède le reste du temps") + } +} + +// --- 9. Migrations ---------------------------------------------------------- + +func TestABaseFromANewerVersionIsErrDb02AndSaysToUpdateTheBinary(t *testing.T) { + b := newBench(t) + b.base.schema = 9 + b.migrations = 1 + + found := control(t, b.run(), ControlMigrations) + if found.Status != StatusFail || found.Code != "ERR-DB-02" { + t.Fatalf("schéma plus récent : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Observed, "9") || !strings.Contains(found.Observed, "1") { + t.Errorf("le constat doit citer LES DEUX numéros :\n%s", found.Observed) + } + if !strings.Contains(found.Remedy, "before-v") { + t.Errorf("la consigne doit dire qu'un retour arrière restaure aussi la copie, puisque les "+ + "migrations ne redescendent pas :\n%s", found.Remedy) + } +} + +func TestMigrationsNotAppliedPointAtTheServiceThatAppliesThem(t *testing.T) { + b := newBench(t) + b.base.schema = 0 + b.migrations = 3 + + found := control(t, b.run(), ControlMigrations) + if found.Status != StatusFail { + t.Fatalf("migrations en retard : %s", found.Status) + } + if !strings.Contains(found.Remedy, "démarrage") && !strings.Contains(found.Remedy, "Démarrez") { + t.Errorf("la consigne doit dire que les migrations s'appliquent au démarrage :\n%s", found.Remedy) + } +} + +func TestAMigrationCountNobodySuppliedIsNotGuessed(t *testing.T) { + b := newBench(t) + b.migrations = 0 + + found := control(t, b.run(), ControlMigrations) + if found.Status != StatusUnknown { + t.Fatalf("nombre de migrations non fourni : %s, attendu INCONNU", found.Status) + } + if !strings.Contains(found.Remedy, "Signalez") { + t.Errorf("la consigne doit dire qu'il n'y a rien à faire sur le poste :\n%s", found.Remedy) + } +} diff --git a/internal/diag/doctor_system.go b/internal/diag/doctor_system.go new file mode 100644 index 0000000..a9139d5 --- /dev/null +++ b/internal/diag/doctor_system.go @@ -0,0 +1,296 @@ +package diag + +import ( + "context" + "fmt" + "runtime" + "strings" + "time" +) + +// This file carries the five controls about the machine UNDER the station: does it come +// back on its own after a power cut, is its clock believable, does it stay awake, may it +// restart itself, and can its client screen be taken out of the application. They share a +// property none of the other controls has — a station can fail every one of them and still +// look perfectly healthy, right up to the morning the answer is needed. + +// elevatedPromptRemedy is the one gesture two of these controls ask for when the READ +// itself failed. Neither the registry of the automatic logon nor the power plan is legible +// without elevation, and « je n'ai pas pu regarder » has the same answer in both cases. +const elevatedPromptRemedy = "Relancez cette commande depuis une invite administrateur." + +// --- 3. Unattended restart -------------------------------------------------- + +// codeUnattendedRestart is ERR-SYS-08, and §14.4 allocates it to exactly this fact. +const codeUnattendedRestart = "ERR-SYS-08" + +func (d *Doctor) checkUnattendedRestart(ctx context.Context) Control { + return UnattendedRestartControl(ctx, d.o.Machine) +} + +// UnattendedRestartControl is control 3, and it is EXPORTED because §14.4 puts the same +// verdict on the administration dashboard (bloquant-7). +// +// One function for the two readers. A volunteer reading « redémarrage sans intervention : +// NON CONFIGURÉ » on the screen and whoever reads `doctor.txt` an hour later are looking +// at the same registry key, and two implementations of the same three conditions would +// eventually tell them two different things about it. +func UnattendedRestartControl(ctx context.Context, machine Machine) Control { + control := Control{ID: ControlUnattendedRestart, + Checked: "Redémarrage sans intervention configuré (OUI / NON)"} + state, err := machine.AutoLogon(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, "la configuration du redémarrage n'a pas pu être lue : "+err.Error() + control.Remedy = elevatedPromptRemedy + case !state.Determined: + control.Status, control.Observed = StatusUnknown, "ce système ne dit pas si la session s'ouvre seule" + control.Remedy = "Faites la recette de §15.5 : redémarrez la machine et vérifiez que le poste " + + "revient SEUL sur l'écran client, sans que personne tape de mot de passe." + case !state.Enabled: + control.Status, control.Code = StatusFail, codeUnattendedRestart + control.Observed = "NON : après une coupure de courant, ce poste restera sur l'écran de " + + "connexion et personne dans l'équipe du samedi n'a le mot de passe. " + state.Detail + control.Remedy = unattendedRestartRemedy() + case state.Expected != "" && !strings.EqualFold(state.Account, state.Expected): + control.Status, control.Code = StatusFail, codeUnattendedRestart + control.Observed = fmt.Sprintf("la session s'ouvre seule pour le compte « %s », alors que le "+ + "kiosque tourne sous « %s » : ce n'est pas la session qui lance l'écran client", + state.Account, state.Expected) + control.Remedy = unattendedRestartRemedy() + default: + control.Status = StatusPass + control.Observed = fmt.Sprintf("OUI, pour le compte « %s »", or(state.Account, "non nommé")) + } + return control +} + +// unattendedRestartRemedy is the instruction of bloquant-7, recipe included. +// +// The recipe is part of the remedy and not an extra: the previous plan wrote the registry +// key and told a human to finish the job, which was done once and NEVER VERIFIED AGAIN. +func unattendedRestartRemedy() string { + if runtime.GOOS == "windows" { + return "Relancez install.ps1 en administrateur — c'est son étape 3 (§15.2) — puis refaites " + + "la recette obligatoire de §15.5 : REDÉMARREZ la machine et cochez « le poste est " + + "revenu seul sur l'écran client »." + } + return "Activez les deux unités (systemctl enable openscale.service openscale-kiosk.service), " + + "puis refaites la recette de §15.5 : redémarrez la machine et vérifiez que le poste " + + "revient seul sur l'écran client." +} + +// --- 14. The system clock --------------------------------------------------- + +const codeClockJump = "ERR-SYS-07" + +func (d *Doctor) checkSystemClock(loaded loadedConfig) Control { + control := Control{ID: ControlSystemClock, Checked: "Horloge système cohérente"} + now := d.o.Clock.Now() + control.Observed = "heure du poste : " + now.Format(clockLayout) + + built, builtKnown := parseBuildDate(d.o.BuildDate) + written := loaded.Config.ModifiedAt + + switch { + case builtKnown && now.Before(built): + control.Status, control.Code = StatusFail, codeClockJump + control.Observed += fmt.Sprintf(" — antérieure à la date de compilation du binaire (%s) : "+ + "l'horloge de ce poste est fausse", built.Format(clockLayout)) + control.Remedy = clockRemedy() + case !written.IsZero() && now.Before(written): + control.Status, control.Code = StatusFail, codeClockJump + control.Observed += fmt.Sprintf(" — antérieure à la date d'écriture de la configuration (%s) : "+ + "l'horloge a reculé", written.Format(clockLayout)) + control.Remedy = clockRemedy() + case !builtKnown: + control.Status = StatusUnknown + control.Observed += " — ce binaire ne porte pas sa date de compilation, il n'y a donc rien à comparer" + control.Remedy = "Rien à faire sur le poste. Ce binaire a été construit sans le Makefile, " + + "qui injecte la version, le commit et la date : reconstruisez-le avec `make build` " + + "pour que ce contrôle puisse conclure." + default: + control.Status = StatusPass + control.Observed += fmt.Sprintf(", postérieure à la compilation du binaire (%s)", built.Format(clockLayout)) + } + return control +} + +// clockRemedy is the instruction for a clock that is wrong. +// +// A timestamped journal is only worth anything for reconciliation with the till if the +// hour is right, and no NTP dependency is guaranteed on an offline station (§15.4). +func clockRemedy() string { + return "Remettez l'heure du poste à la bonne date : un journal de pesées horodaté ne vaut " + + "rien pour le rapprochement avec la caisse si l'heure est fausse, et le poste n'a " + + "aucune garantie de serveur de temps puisqu'il est hors ligne. Vérifiez aussi la pile " + + "de la carte mère : une heure qui revient toujours à la même date après une coupure, " + + "c'est elle." +} + +// parseBuildDate reads the instant the linker injected. +// +// The Makefile injects `git log -1 --format=%cI`, which is RFC 3339. A plain `go build` +// injects "unknown", and saying so is the honest answer — a control that treated an +// unparsable date as the zero instant would report every station's clock as being in the +// future. +func parseBuildDate(value string) (time.Time, bool) { + if value == "" || value == "unknown" { + return time.Time{}, false + } + built, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, false + } + return built, true +} + +// --- 15. Sleep and USB selective suspend ------------------------------------ + +func (d *Doctor) checkPowerSettings(ctx context.Context) Control { + control := Control{ID: ControlPowerSettings, + Checked: "Veille et suspension USB sélective désactivées"} + state, err := d.o.Machine.Power(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, "les réglages d'énergie n'ont pas pu être lus : "+err.Error() + control.Remedy = elevatedPromptRemedy + case !state.Applicable: + control.Status = StatusNotApplicable + control.Observed = "la procédure d'installation de ce système n'écrit aucun réglage " + + "d'énergie (§15.3), il n'y a donc rien à comparer" + case !state.Determined: + control.Status, control.Observed = StatusUnknown, "les réglages d'énergie n'ont pas pu être établis : "+state.Detail + control.Remedy = "Vérifiez à la main, dans le plan d'alimentation actif, que la mise en " + + "veille, l'extinction de l'écran et la « suspension sélective USB » sont toutes sur " + + "« jamais » ou « désactivé » (§15.2, étape 5)." + case !state.USBSelectiveSuspendDisabled: + control.Status = StatusFail + control.Observed = "la suspension USB sélective est ACTIVE. " + state.Detail + control.Remedy = "C'est la cause de la moitié des « la balance ne répond plus » sur un " + + "adaptateur USB-série, et elle ne figure dans aucune procédure d'installation " + + "standard. En administrateur :\n" + usbSuspendCommand() + + "\nOu relancez install.ps1, dont c'est l'étape 5 (§15.2)." + case !state.SleepDisabled: + control.Status = StatusFail + control.Observed = "la mise en veille ou l'extinction de l'écran est ACTIVE. " + state.Detail + control.Remedy = "Un poste en libre-service ne doit ni s'endormir ni éteindre son écran. " + + "En administrateur :\npowercfg /change monitor-timeout-ac 0\n" + + "powercfg /change standby-timeout-ac 0\npowercfg /change hibernate-timeout-ac 0" + default: + control.Status = StatusPass + control.Observed = "veille, extinction d'écran et suspension USB sélective sont toutes désactivées" + } + return control +} + +// usbSuspendCommand is the command of §15.2, GUIDs included, quoted from the document. +// +// The two GUIDs are NOT derived and NOT guessed: they are copied from install.ps1 in +// §15.2, which is the only place this project has them from. +func usbSuspendCommand() string { + return "powercfg /setacvalueindex SCHEME_CURRENT " + usbSubgroupGUID + " " + usbSuspendGUID + " 0\n" + + "powercfg /setactive SCHEME_CURRENT" +} + +// --- 16. The right to restart the machine ----------------------------------- + +// checkRebootPermission is the sixteenth control: may this station restart the computer? +// +// It exists because the answer is INVISIBLE until somebody needs it. Under Linux the +// service runs as `openscale` and polkit stands between it and the right, so a station +// missing its rule works perfectly — right up to the evening a volunteer is facing a +// frozen kiosk, touches the one button that would have saved them, and watches a +// countdown expire on nothing. +func (d *Doctor) checkRebootPermission(ctx context.Context) Control { + control := Control{ID: ControlRebootPermission, + Checked: "Droit de redémarrer l'ordinateur depuis l'écran"} + state, err := d.o.Machine.RebootPermission(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, + "le droit de redémarrer n'a pas pu être établi : "+err.Error() + control.Remedy = "Vérifiez à la main que /etc/polkit-1/rules.d porte la règle " + + "49-openscale-reboot.rules, ou relancez « sudo ./install.sh »." + case !state.Applicable: + control.Status = StatusNotApplicable + control.Observed = "ce système ne sait pas redémarrer depuis l'écran (§15.3), " + + "il n'y a donc aucun droit à vérifier" + case state.Allowed: + control.Status, control.Observed = StatusPass, state.Detail + default: + control.Status, control.Code = StatusFail, codeRebootRefused + control.Observed = "NON : " + state.Detail + ". Le bouton « Redémarrer l'ordinateur » " + + "répondra « accès refusé », et il ne le dira qu'au moment où quelqu'un en a besoin." + control.Remedy = "Relancez « sudo ./install.sh » depuis deploy/linux : il pose la " + + "règle polkit qui autorise le compte du poste à redémarrer l'ordinateur, et rien d'autre." + } + return control +} + +// codeRebootRefused is ERR-SYS-12, and internal/web allocates it to the same fact: the +// machine was asked to restart and said no. +const codeRebootRefused = "ERR-SYS-12" + +// --- 17. The client screen cannot leave the application --------------------- + +// checkNavigationLock is the seventeenth control, and it is the only one that reports a +// station where EVERYTHING ELSE IS GREEN. +// +// The panne, in full: a right click on the administration screen — the one surface where +// the context menu is deliberately left alive, so that « Copier » works on an error a +// volunteer is reading over the telephone — offers « Rechercher sur le web ». One click, +// and the kiosk window is on a search engine. No address bar, no back button, and the +// browser is perfectly alive: the service answers, the task is running, the window is full +// screen, and the poste sells nothing. It happened on a real station on 31/07/2026. +// +// What it reads is the belt, not the guarantee. The braces are the supervisor's watch over +// the attached client screen, which brings the poste back inside AbsenceGrace whatever the +// browser did with these keys — which is why an unreadable answer here is amber and never +// red. +func (d *Doctor) checkNavigationLock(ctx context.Context) Control { + control := Control{ID: ControlNavigationLock, + Checked: "Écran client verrouillé sur l'application"} + state, err := d.o.Machine.NavigationLock(ctx) + switch { + case err != nil: + control.Status, control.Observed = StatusUnknown, + "les stratégies de navigation n'ont pas pu être lues : "+err.Error() + control.Remedy = navigationLockRemedy + case !state.Applicable: + control.Status = StatusNotApplicable + control.Observed = "sur ce système, l'écran client tourne sous un compositeur " + + "mono-application (§15.3) et la stratégie du navigateur appartient à " + + "l'installeur, pas au compte du poste" + case !state.Determined: + control.Status, control.Observed = StatusUnknown, state.Detail + control.Remedy = navigationLockRemedy + case state.Locked: + control.Status = StatusPass + control.Observed = "compte « " + state.Account + " » : " + state.Detail + + " Un clic droit ne peut plus emmener le poste hors de l'application." + default: + control.Status, control.Code = StatusFail, codeNavigationOpen + control.Observed = "compte « " + state.Account + " » : " + state.Detail + + " Le navigateur peut être emmené hors de l'application — un clic droit, " + + "« Rechercher sur le web », et il n'y a ni barre d'adresse ni bouton retour " + + "pour revenir." + control.Remedy = navigationLockRemedy + } + return control +} + +// navigationLockRemedy is one gesture, and it is the same one for the three branches that +// carry it: the policies are posed by the kiosk at every logon, so making them exist again +// is making the kiosk start again. +const navigationLockRemedy = "Fermez puis rouvrez la session du poste — le kiosque pose " + + "ses stratégies à chaque ouverture. Si le contrôle reste rouge, le journal " + + "kiosk.log dit sur quelle clé il a échoué." + +// codeNavigationOpen is ERR-KSK-03: the kiosk window can be taken out of the application. +// +// A code of its own, and the third of the kiosk family: ERR-KSK-02 says « l'affichage +// n'arrive pas à rester ouvert », which is the opposite failure, and reading one for the +// other over the telephone sends a volunteer to look at a browser that crashes when the +// browser is doing fine. +const codeNavigationOpen = "ERR-KSK-03" diff --git a/internal/diag/doctor_system_test.go b/internal/diag/doctor_system_test.go new file mode 100644 index 0000000..ffc6aa3 --- /dev/null +++ b/internal/diag/doctor_system_test.go @@ -0,0 +1,292 @@ +package diag + +import ( + "context" + "runtime" + "strings" + "testing" + "time" + + "openscale/internal/domain" +) + +// The tests of doctor_system.go: the unattended restart, the clock, the power plan, the +// right to restart the machine, and the lock that keeps the client screen inside the +// application. Every one of them names a station that looks perfectly healthy from +// everywhere else — which is why each red case asserts the sentence and not only the +// verdict. + +// --- 3. The unattended restart ---------------------------------------------- + +func TestAnUnconfiguredUnattendedRestartIsErrSys08AndDemandsTheRecipe(t *testing.T) { + b := newBench(t) + b.machine.autoLogon.Enabled = false + + found := control(t, b.run(), ControlUnattendedRestart) + if found.Status != StatusFail || found.Code != "ERR-SYS-08" { + t.Fatalf("session non automatique : %s / %q, attendu ÉCHEC / ERR-SYS-08", found.Status, found.Code) + } + // bloquant-7: the previous plan wrote the key and told a human to finish the job, which + // was done once and never verified again. The recipe IS the remedy. + // §15.5 — the recipe — is demanded on BOTH platforms; the file that carries it is + // not the same. Requiring install.ps1 everywhere failed on Linux against a remedy + // that correctly says « systemctl enable », which is why the two were written. + wanted := []string{"15.5", "install.ps1"} + if runtime.GOOS != "windows" { + wanted = []string{"15.5", "systemctl enable"} + } + for _, want := range wanted { + if !strings.Contains(found.Remedy, want) { + t.Errorf("la consigne ne cite pas %q :\n%s", want, found.Remedy) + } + } + if !strings.Contains(found.Observed, "écran de connexion") { + t.Errorf("le constat ne dit pas ce qui se passera après une coupure :\n%s", found.Observed) + } +} + +func TestAnAutoLogonOntoTheWrongAccountIsStillAFailure(t *testing.T) { + b := newBench(t) + b.machine.autoLogon.Account = "administrateur" + + found := control(t, b.run(), ControlUnattendedRestart) + if found.Status != StatusFail { + t.Fatalf("compte inattendu : %s, attendu ÉCHEC — la session qui s'ouvre n'est pas celle du kiosque", + found.Status) + } + if !strings.Contains(found.Observed, "administrateur") || !strings.Contains(found.Observed, "openscale") { + t.Errorf("le constat doit nommer les DEUX comptes :\n%s", found.Observed) + } +} + +func TestAKioskAccountThatCannotBeNamedIsNotAnAccusation(t *testing.T) { + b := newBench(t) + // What a station answers when the scheduler normalised the task's principal to a SID: + // the autologon is on, and the account the kiosk runs as cannot be named. Failing here + // would report, on a station that works, a misconfiguration nobody can act on — and + // volunteers who learn to ignore the orange stop reading the control that matters. + // + // The guard this pins is `state.Expected != ""` in unattendedRestartControl; removing it + // turns every unknown back into an accusation. + b.machine.autoLogon.Expected = "" + + found := control(t, b.run(), ControlUnattendedRestart) + if found.Status != StatusPass { + t.Fatalf("compte du kiosque impossible à nommer : %s, attendu OK — ne pas savoir n'est pas "+ + "un défaut de configuration", found.Status) + } +} + +// --- 14. The system clock --------------------------------------------------- + +func TestAClockBeforeTheBuildDateIsErrSys07(t *testing.T) { + b := newBench(t) + b.clock.Set(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)) + + found := control(t, b.run(), ControlSystemClock) + if found.Status != StatusFail || found.Code != "ERR-SYS-07" { + t.Fatalf("horloge en arrière : %s / %q", found.Status, found.Code) + } + if !strings.Contains(found.Remedy, "caisse") { + t.Errorf("la consigne doit dire pourquoi l'heure compte : le rapprochement avec la "+ + "caisse :\n%s", found.Remedy) + } + if !strings.Contains(found.Remedy, "pile") { + t.Errorf("la consigne devrait nommer la cause la plus fréquente d'une heure qui revient "+ + "toujours à la même date :\n%s", found.Remedy) + } +} + +func TestAClockBeforeTheConfigurationWasWrittenIsAlsoAJump(t *testing.T) { + b := newBench(t) + // After the build date, so the first branch does not fire, and before the instant the + // configuration file says it was written. + b.tweak(func(cfg *domain.Config) { cfg.ModifiedAt = benchEpoch.Add(48 * time.Hour) }) + + found := control(t, b.run(), ControlSystemClock) + if found.Status != StatusFail || found.Code != "ERR-SYS-07" { + t.Fatalf("horloge antérieure à l'écriture de la configuration : %s / %q", found.Status, found.Code) + } +} + +func TestABinaryWithoutItsBuildDateCannotConclude(t *testing.T) { + b := newBench(t) + options := b.options() + options.BuildDate = "unknown" + b.writeConfig() + doctor, err := New(options) + if err != nil { + t.Fatalf("construction du doctor : %v", err) + } + + found := control(t, doctor.Run(context.Background()), ControlSystemClock) + if found.Status != StatusUnknown { + t.Fatalf("date de compilation inconnue : %s, attendu INCONNU", found.Status) + } + if !strings.Contains(found.Remedy, "make build") { + t.Errorf("la consigne doit dire que c'est le binaire, pas le poste :\n%s", found.Remedy) + } +} + +// --- 15. Sleep and USB selective suspend ------------------------------------ + +func TestSelectiveUSBSuspendIsRedAndCarriesTheExactCommand(t *testing.T) { + b := newBench(t) + b.machine.power.USBSelectiveSuspendDisabled = false + b.machine.power.Detail = "Réglages encore actifs sur secteur — suspension USB sélective : 1." + + found := control(t, b.run(), ControlPowerSettings) + if found.Status != StatusFail { + t.Fatalf("suspension USB active : %s", found.Status) + } + // The two GUIDs come from install.ps1 in §15.2 and from nowhere else. + for _, want := range []string{usbSubgroupGUID, usbSuspendGUID, "setacvalueindex"} { + if !strings.Contains(found.Remedy, want) { + t.Errorf("la consigne doit porter la commande exacte de §15.2 (%s manque) :\n%s", + want, found.Remedy) + } + } + if !strings.Contains(found.Remedy, "moitié") { + t.Errorf("la consigne doit dire ce que ce réglage coûte : la moitié des « la balance ne "+ + "répond plus » :\n%s", found.Remedy) + } +} + +func TestSleepStillEnabledIsRed(t *testing.T) { + b := newBench(t) + b.machine.power.SleepDisabled = false + b.machine.power.Detail = "Réglages encore actifs sur secteur — extinction de l'écran : 600." + + found := control(t, b.run(), ControlPowerSettings) + if found.Status != StatusFail { + t.Fatalf("veille active : %s", found.Status) + } + if !strings.Contains(found.Remedy, "powercfg /change") { + t.Errorf("la consigne doit porter les commandes de §15.2 :\n%s", found.Remedy) + } +} + +func TestASystemWhoseInstallerWritesNoPowerSettingIsNotJudged(t *testing.T) { + b := newBench(t) + b.machine.power = PowerState{Applicable: false} + + found := control(t, b.run(), ControlPowerSettings) + if found.Status != StatusNotApplicable { + t.Fatalf("système sans réglage d'énergie : %s, attendu SANS OBJET — inventer une exigence "+ + "serait pire que ne rien dire", found.Status) + } +} + +// --- 16. The right to restart the machine ----------------------------------- + +// TestAStationThatMayRestartTheMachineIsGreen. +func TestAStationThatMayRestartTheMachineIsGreen(t *testing.T) { + found := control(t, newBench(t).run(), ControlRebootPermission) + if found.Status != StatusPass { + t.Fatalf("statut %s — %s", found.Status, found.Observed) + } +} + +// TestAStationThatMayNOTRestartTheMachineSaysWhatToDo. +// +// This is the state of every Linux station whose polkit rule was never posed, and the +// reason this control exists: the station works perfectly until the evening somebody +// needs the one button it forbids. +func TestAStationThatMayNOTRestartTheMachineSaysWhatToDo(t *testing.T) { + b := newBench(t) + b.machine.reboot = RebootPermissionState{Applicable: true, Allowed: false, + Detail: "/etc/polkit-1/rules.d/49-openscale-reboot.rules est absent"} + + found := control(t, b.run(), ControlRebootPermission) + if found.Status != StatusFail { + t.Fatalf("droit refusé : %s", found.Status) + } + if !strings.Contains(found.Remedy, "install.sh") { + t.Errorf("la consigne ne nomme pas le remède :\n%s", found.Remedy) + } + if found.Code != codeRebootRefused { + t.Errorf("code %q, attendu %q", found.Code, codeRebootRefused) + } +} + +// TestASystemThatCannotRestartAtAllIsNotJudged: inventing a requirement there would be +// worse than saying nothing, which is the rule the power settings already follow. +func TestASystemThatCannotRestartAtAllIsNotJudged(t *testing.T) { + b := newBench(t) + b.machine.reboot = RebootPermissionState{Applicable: false} + + found := control(t, b.run(), ControlRebootPermission) + if found.Status != StatusNotApplicable { + t.Fatalf("système sans redémarrage : %s, attendu SANS OBJET", found.Status) + } + if found.Observed == "" { + t.Error("le contrôle ne dit pas ce qu'il a vu") + } +} + +// --- 17. The client screen cannot leave the application --------------------- + +// TestAStationLockedOnItsApplicationIsGreen. +func TestAStationLockedOnItsApplicationIsGreen(t *testing.T) { + found := control(t, newBench(t).run(), ControlNavigationLock) + if found.Status != StatusPass { + t.Fatalf("statut %s — %s", found.Status, found.Observed) + } + if !strings.Contains(found.Observed, "openscale") { + t.Errorf("le contrôle ne dit pas SOUS QUEL COMPTE il a lu :\n%s", found.Observed) + } +} + +// TestAStationThatCanBeTakenOutOfTheApplicationIsRed est la panne qui laisse tous les +// autres contrôles au vert : le navigateur tourne, le service répond, la fenêtre est en +// plein écran — et ce qu'elle affiche est un moteur de recherche. +func TestAStationThatCanBeTakenOutOfTheApplicationIsRed(t *testing.T) { + b := newBench(t) + b.machine.navigation = NavigationLockState{Applicable: true, Determined: true, + Account: "openscale", Browser: "Microsoft Edge", + Detail: "Microsoft Edge : URLBlocklist = (vide)."} + + found := control(t, b.run(), ControlNavigationLock) + if found.Status != StatusFail { + t.Fatalf("poste non verrouillé : %s", found.Status) + } + if found.Code != codeNavigationOpen { + t.Errorf("code %q, attendu %q", found.Code, codeNavigationOpen) + } + if found.Remedy == "" { + t.Error("le contrôle ne dit pas quoi faire") + } +} + +// TestAHiveThatIsNotMountedIsAmberAndNeverRed : la ruche d'un compte qui n'a pas de session +// ouverte n'est pas montée, et rien ici ne la monte. Accuser un poste sur une question +// qu'on n'a pas pu poser serait pire que de dire qu'on ne sait pas — d'autant que le chien +// de garde du superviseur ramène l'écran quoi qu'il arrive. +func TestAHiveThatIsNotMountedIsAmberAndNeverRed(t *testing.T) { + b := newBench(t) + b.machine.navigation = NavigationLockState{Applicable: true, Determined: false, + Account: "openscale", Detail: "aucune stratégie de navigation sous le compte."} + + found := control(t, b.run(), ControlNavigationLock) + if found.Status != StatusUnknown { + t.Fatalf("question non posée : %s, attendu INCONNU", found.Status) + } + if found.Remedy == "" { + t.Error("le contrôle ne dit pas comment lever le doute") + } +} + +// TestALinuxStationIsNotJudgedOnAPolicyItDoesNotOwn : sous cage, la stratégie appartient à +// l'installeur et au compte root, pas au compte du poste. +func TestALinuxStationIsNotJudgedOnAPolicyItDoesNotOwn(t *testing.T) { + b := newBench(t) + b.machine.navigation = NavigationLockState{Applicable: false} + + found := control(t, b.run(), ControlNavigationLock) + if found.Status != StatusNotApplicable { + t.Fatalf("station Linux : %s, attendu SANS OBJET", found.Status) + } + if found.Observed == "" { + t.Error("le contrôle ne dit pas ce qu'il a vu") + } +} diff --git a/internal/diag/doctor_test.go b/internal/diag/doctor_test.go index 23e4757..5759556 100644 --- a/internal/diag/doctor_test.go +++ b/internal/diag/doctor_test.go @@ -5,8 +5,6 @@ import ( "encoding/json" "errors" "os" - "path/filepath" - "runtime" "strings" "testing" "time" @@ -14,10 +12,16 @@ import ( "openscale/internal/domain" ) -// Every control of §15.4 is exercised TWICE here: once on a station where it comes out -// green, once on a station where it comes out red. The red case asserts two things and not -// one — the verdict, and the fact that the sentence a volunteer reads tells them what to DO. +// Every control of §15.4 is exercised TWICE: once on a station where it comes out green, +// once on a station where it comes out red. The red case asserts two things and not one — +// the verdict, and the fact that the sentence a volunteer reads tells them what to DO. // « Un diagnostic qui dit "échec" sans dire quoi faire n'a rien diagnostiqué. » +// +// This file holds what belongs to the DOCTOR rather than to one control: the shape of the +// report, a doctor built with no collaborator at all, and the fingerprint Run decides to +// show or to withhold. Each family of controls is tested beside its own production file — +// doctor_service_test.go, doctor_storage_test.go, doctor_devices_test.go, +// doctor_config_test.go, doctor_system_test.go. The doubles are in harness_test.go. // --- The shape of the report ------------------------------------------------ @@ -147,867 +151,6 @@ func TestEveryRedControlSaysWhatToDo(t *testing.T) { } } -// --- 1. The service --------------------------------------------------------- - -func TestAServiceThatWillNotStartIsToldWhereTheReasonIsWritten(t *testing.T) { - b := newBench(t) - b.machine.service.Running = false - b.machine.service.Detail = "STOPPED" - - found := control(t, b.run(), ControlService) - if found.Status != StatusFail { - t.Fatalf("service arrêté : %s", found.Status) - } - // The criterion of §18 for lot L8: doctor diagnoses a service that will not start and - // says WHY. It cannot know the reason itself, so it names the controls that carry it. - for _, want := range []string{"6", "7", "8", "10"} { - if !strings.Contains(found.Remedy, want) { - t.Errorf("la consigne ne renvoie pas au contrôle %s :\n%s", want, found.Remedy) - } - } -} - -func TestAnUninstalledServiceIsADifferentRemedyFromAStoppedOne(t *testing.T) { - b := newBench(t) - b.machine.service = ServiceState{Name: "OpenScale", Determined: true} - - found := control(t, b.run(), ControlService) - if found.Status != StatusFail { - t.Fatalf("service inconnu : %s", found.Status) - } - if strings.Contains(found.Remedy, "sc start") || strings.Contains(found.Remedy, "systemctl start") { - t.Errorf("on ne démarre pas un service qui n'existe pas :\n%s", found.Remedy) - } - // Case-INSENSITIVE, and that is the fix: the Windows remedy names install.ps1, the - // Linux one opens with « Installez l'unité ». A case-sensitive search passed on - // Windows and failed on Linux against a remedy that was perfectly correct. - if !strings.Contains(strings.ToLower(found.Remedy), "install") { - t.Errorf("la consigne devrait mener à l'installation :\n%s", found.Remedy) - } -} - -func TestAServiceInManualStartIsAmberAndNamesThePilotPeriod(t *testing.T) { - b := newBench(t) - b.machine.service.Automatic = false - - found := control(t, b.run(), ControlService) - if found.Status != StatusWarn { - t.Fatalf("démarrage manuel : %s, attendu ATTENTION — c'est ce que le lot pilote installe", found.Status) - } - if !strings.Contains(found.Remedy, "L9") { - t.Errorf("la consigne devrait dire que le poste pilote est un cas voulu :\n%s", found.Remedy) - } -} - -// --- 2. The kiosk task ------------------------------------------------------ - -func TestAMissingKioskTaskSaysWhatItsAbsenceCosts(t *testing.T) { - b := newBench(t) - b.machine.kiosk = ServiceState{Name: "OpenScale-Kiosk", Determined: true} - - found := control(t, b.run(), ControlKioskTask) - if found.Status != StatusFail { - t.Fatalf("tâche absente : %s", found.Status) - } - // A volunteer reading « tâche absente » has no way of knowing the service can be - // perfectly healthy while the screen stays black. The remedy says it. - if !strings.Contains(found.Remedy, "écran client") { - t.Errorf("la consigne ne dit pas ce que l'absence coûte :\n%s", found.Remedy) - } -} - -// --- 3. The unattended restart ---------------------------------------------- - -func TestAnUnconfiguredUnattendedRestartIsErrSys08AndDemandsTheRecipe(t *testing.T) { - b := newBench(t) - b.machine.autoLogon.Enabled = false - - found := control(t, b.run(), ControlUnattendedRestart) - if found.Status != StatusFail || found.Code != "ERR-SYS-08" { - t.Fatalf("session non automatique : %s / %q, attendu ÉCHEC / ERR-SYS-08", found.Status, found.Code) - } - // bloquant-7: the previous plan wrote the key and told a human to finish the job, which - // was done once and never verified again. The recipe IS the remedy. - // §15.5 — the recipe — is demanded on BOTH platforms; the file that carries it is - // not the same. Requiring install.ps1 everywhere failed on Linux against a remedy - // that correctly says « systemctl enable », which is why the two were written. - wanted := []string{"15.5", "install.ps1"} - if runtime.GOOS != "windows" { - wanted = []string{"15.5", "systemctl enable"} - } - for _, want := range wanted { - if !strings.Contains(found.Remedy, want) { - t.Errorf("la consigne ne cite pas %q :\n%s", want, found.Remedy) - } - } - if !strings.Contains(found.Observed, "écran de connexion") { - t.Errorf("le constat ne dit pas ce qui se passera après une coupure :\n%s", found.Observed) - } -} - -func TestAnAutoLogonOntoTheWrongAccountIsStillAFailure(t *testing.T) { - b := newBench(t) - b.machine.autoLogon.Account = "administrateur" - - found := control(t, b.run(), ControlUnattendedRestart) - if found.Status != StatusFail { - t.Fatalf("compte inattendu : %s, attendu ÉCHEC — la session qui s'ouvre n'est pas celle du kiosque", - found.Status) - } - if !strings.Contains(found.Observed, "administrateur") || !strings.Contains(found.Observed, "openscale") { - t.Errorf("le constat doit nommer les DEUX comptes :\n%s", found.Observed) - } -} - -func TestAKioskAccountThatCannotBeNamedIsNotAnAccusation(t *testing.T) { - b := newBench(t) - // What a station answers when the scheduler normalised the task's principal to a SID: - // the autologon is on, and the account the kiosk runs as cannot be named. Failing here - // would report, on a station that works, a misconfiguration nobody can act on — and - // volunteers who learn to ignore the orange stop reading the control that matters. - // - // The guard this pins is `state.Expected != ""` in unattendedRestartControl; removing it - // turns every unknown back into an accusation. - b.machine.autoLogon.Expected = "" - - found := control(t, b.run(), ControlUnattendedRestart) - if found.Status != StatusPass { - t.Fatalf("compte du kiosque impossible à nommer : %s, attendu OK — ne pas savoir n'est pas "+ - "un défaut de configuration", found.Status) - } -} - -// --- 4. The data directory -------------------------------------------------- - -func TestADataDirectoryThatCannotBeWrittenIsProvedByWriting(t *testing.T) { - b := newBench(t) - // A regular FILE where a directory is expected: MkdirAll refuses it on every system, - // including Windows, where marking a directory read-only does not stop a write. - blocker := filepath.Join(b.dataDir, "blocker") - if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { - t.Fatalf("préparation du bloqueur : %v", err) - } - b.dataDir = filepath.Join(blocker, "data") - - found := control(t, b.run(), ControlDataDirectory) - if found.Status != StatusFail { - t.Fatalf("répertoire inutilisable : %s", found.Status) - } - if !strings.Contains(found.Remedy, b.dataDir) { - t.Errorf("la consigne doit nommer le répertoire :\n%s", found.Remedy) - } -} - -// --- 5. Disk space ---------------------------------------------------------- - -func TestAFullDiskIsRedAndALowDiskIsAmber(t *testing.T) { - full := newBench(t) - full.machine.space.FreeBytes = 0 - found := control(t, full.run(), ControlDiskSpace) - if found.Status != StatusFail || found.Code != "ERR-SYS-05" { - t.Fatalf("disque plein : %s / %q, attendu ÉCHEC / ERR-SYS-05", found.Status, found.Code) - } - if !strings.Contains(found.Observed, "journalisées") { - t.Errorf("le constat doit dire ce qu'un disque plein coûte : les pesées sortent et ne sont "+ - "plus journalisées (ADR-013) :\n%s", found.Observed) - } - - low := newBench(t) - // The threshold is the one the configuration declares, and nothing else: 200 Mo in the - // neutral profile. 32 Mo is under it, and the volume is not full. - low.machine.space.FreeBytes = 32 << 20 - found = control(t, low.run(), ControlDiskSpace) - if found.Status != StatusWarn { - t.Fatalf("sous le seuil d'alerte : %s, attendu ATTENTION", found.Status) - } - if !strings.Contains(found.Observed, "200 Mo") { - t.Errorf("le constat doit citer le seuil déclaré :\n%s", found.Observed) - } -} - -func TestASpaceThatCouldNotBeMeasuredIsNeverReportedAsZero(t *testing.T) { - b := newBench(t) - b.machine.space = FreeSpace{} - b.machine.spaceErr = errors.New("volume inaccessible") - - found := control(t, b.run(), ControlDiskSpace) - if found.Status != StatusUnknown { - t.Fatalf("mesure impossible : %s, attendu INCONNU — un chiffre que personne n'a mesuré "+ - "enverrait quelqu'un supprimer des fichiers", found.Status) - } - if strings.Contains(found.Observed, "0 Mo") { - t.Errorf("le constat ne doit citer aucun chiffre :\n%s", found.Observed) - } -} - -// --- 6. The listening address ----------------------------------------------- - -func TestAnAddressHeldByOurOwnServiceIsGreen(t *testing.T) { - b := newBench(t) - // The socket IS the single-instance lock (§13.4): a running station cannot bind its own - // address, and that is the nominal case rather than a fault. - b.machine.listen.Bindable = false - - found := control(t, b.run(), ControlListenAddress) - if found.Status != StatusPass { - t.Fatalf("adresse tenue par le poste : %s, attendu OK — %s", found.Status, found.Observed) - } -} - -func TestAnAddressHeldBySomethingElseIsRedAndSeparatesTheTwoCases(t *testing.T) { - b := newBench(t) - b.machine.listen.Bindable = false - b.machine.listen.Detail = "bind: address already in use" - b.service.silence() - - found := control(t, b.run(), ControlListenAddress) - if found.Status != StatusFail || found.Code != "ERR-SYS-02" { - t.Fatalf("adresse prise : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "ERR-SYS-01") { - t.Errorf("la consigne doit distinguer l'autre programme de l'autre instance :\n%s", found.Remedy) - } -} - -// --- 7. The configuration --------------------------------------------------- - -func TestAnInvalidConfigurationIsErrCfg01AndDefersToTheValidateCommand(t *testing.T) { - b := newBench(t) - b.tweak(func(cfg *domain.Config) { cfg.Station.Number = 0; cfg.Network.Listen = "pas-une-adresse" }) - - found := control(t, b.run(), ControlConfiguration) - if found.Status != StatusFail || found.Code != "ERR-CFG-01" { - t.Fatalf("configuration invalide : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "config validate") { - t.Errorf("la consigne doit renvoyer sur la commande qui liste TOUTES les fautes :\n%s", found.Remedy) - } - if !strings.Contains(found.Observed, "configuration d'usine") { - t.Errorf("le constat doit dire ce que le poste fera : démarrer en configuration d'usine :\n%s", - found.Observed) - } -} - -func TestAConfigurationThatIsNotJSONIsADifferentRemedyFromAnInvalidOne(t *testing.T) { - b := newBench(t) - b.writeConfig() - if err := os.WriteFile(b.configPath, []byte("{ \"station\": { \"number\": 2, } }"), 0o644); err != nil { - t.Fatalf("écriture du fichier cassé : %v", err) - } - doctor, err := New(b.options()) - if err != nil { - t.Fatalf("construction du doctor : %v", err) - } - report := doctor.Run(context.Background()) - if err := report.Validate(); err != nil { - t.Fatalf("le rapport se contredit : %v", err) - } - - found := control(t, report, ControlConfiguration) - if found.Status != StatusFail { - t.Fatalf("JSON cassé : %s", found.Status) - } - if !strings.Contains(found.Remedy, "config.json.1") { - t.Errorf("la consigne doit mener à la version précédente, pas à un écran qui réécrirait "+ - "un fichier qu'on n'a pas compris :\n%s", found.Remedy) - } -} - -func TestAMissingConfigurationFileIsNamedAsMissing(t *testing.T) { - b := newBench(t) - b.configPath = filepath.Join(t.TempDir(), "absent.json") - doctor, err := New(b.options()) - if err != nil { - t.Fatalf("construction du doctor : %v", err) - } - report := doctor.Run(context.Background()) - - found := control(t, report, ControlConfiguration) - if found.Status != StatusFail { - t.Fatalf("fichier absent : %s", found.Status) - } - if report.Station != 0 || !strings.Contains(reportHead(t, report), "poste non identifié") { - t.Errorf("un rapport sans configuration ne doit pas se présenter comme le poste 0") - } -} - -// runConfigurationControlOn writes raw as config.json on a bench whose registries name -// every driver the neutral profile declares — printer preview AND catalog local_drop — -// and returns the report's control « configuration ». -// -// Without the catalog registry, `unknownDrivers` (doctor.go) always finds catalog.type -// unverifiable and the control never gets past INCONNU : none of the tests of this -// section could otherwise reach a WARN or a PASS, only the neighbouring FAIL cases can, -// which is why they never needed this helper. -func runConfigurationControlOn(t *testing.T, raw string) Control { - t.Helper() - b := newBench(t) - b.registries.CatalogSources = []domain.DriverDescriptor{ - {ID: domain.CatalogSourceLocalDrop, Label: "Répertoire de dépôt"}, - } - if err := os.WriteFile(b.configPath, []byte(raw), 0o644); err != nil { - t.Fatalf("écriture du fichier de configuration : %v", err) - } - doctor, err := New(b.options()) - if err != nil { - t.Fatalf("construction du doctor : %v", err) - } - report := doctor.Run(context.Background()) - return control(t, report, ControlConfiguration) -} - -// TestConfigurationControlNamesTheSchemaVersion: whoever opens diagnostic.zip has to be -// able to tell a station whose file this binary rewrote from one whose file it only read. -// -// The document carries ui.tile_size, a key Migrate actually TRANSLATES (retireTileSize), -// and not just an old "version" number: stampSchemaVersion bumps the number in silence -// when nothing else needed changing, so a file that names no legacy key at all produces -// no migration note and would never exercise this control. -func TestConfigurationControlNamesTheSchemaVersion(t *testing.T) { - raw := `{"version":1,"station":{"number":2},"ui":{"tile_size":"large"},` + - `"admin":{"password_hash":"` + benchPasswordHash + `"}}` - found := runConfigurationControlOn(t, raw) - - if found.Status != StatusWarn { - t.Fatalf("fichier au schéma précédent : %s — %s", found.Status, found.Observed) - } - if !strings.Contains(found.Observed, "schéma") { - t.Errorf("le contrôle ne nomme pas la version du schéma : %q", found.Observed) - } - if !strings.Contains(found.Observed, "openscale config migrate") { - t.Errorf("le contrôle ne dit pas quoi lancer : %q", found.Observed) - } -} - -// TestConfigurationControlNamesARolledBackStationWithoutPromisingARewrite covers the -// station update.ps1 / update.sh rolled back on its own after a failed update : its -// config.json was written by a NEWER binary, so stampSchemaVersion refuses to touch the -// version field and reports it (domain.SchemaVersionKey). That refusal reaches this -// control with an EMPTY Config.Retired() — "version" is not in domain's retiredKeys — so -// it is never caught by the fault cascade above, and the control has to tell it apart -// from an ordinary file that is merely behind : it is not behind, and « openscale config -// migrate » will not write it, because migrateConfig refuses to write ANYTHING while a -// single note is refused. -func TestConfigurationControlNamesARolledBackStationWithoutPromisingARewrite(t *testing.T) { - raw := `{"version":3,"station":{"number":2},"admin":{"password_hash":"` + benchPasswordHash + `"}}` - found := runConfigurationControlOn(t, raw) - - if found.Status != StatusWarn { - t.Fatalf("fichier écrit par un binaire plus récent : %s — %s", found.Status, found.Observed) - } - if strings.Contains(found.Observed, "en attente") || - strings.Contains(found.Observed, "n'est pas encore au schéma") { - t.Errorf("le contrôle dit que le fichier est EN RETARD, alors qu'il est en AVANCE : %q", - found.Observed) - } - if !strings.Contains(found.Observed, "plus récente") { - t.Errorf("le contrôle ne dit pas que le fichier vient d'un binaire plus récent : %q", - found.Observed) - } - if strings.Contains(found.Remedy, "réécrit le fichier") { - t.Errorf("le remède promet une réécriture que « config migrate » va refuser : %q", found.Remedy) - } -} - -// --- 8. The database -------------------------------------------------------- - -func TestABaseThatWillNotOpenNamesItsCode(t *testing.T) { - b := newBench(t) - b.openErr = &DatabaseFailure{Code: "ERR-DB-01", - Message: "ouverture de openscale.db impossible : accès refusé"} - - report := b.run() - found := control(t, report, ControlDatabase) - if found.Status != StatusFail || found.Code != "ERR-DB-01" { - t.Fatalf("base fermée : %s / %q", found.Status, found.Code) - } - // The migration control cannot conclude without the base, and it says so rather than - // accusing the schema. - migrations := control(t, report, ControlMigrations) - if migrations.Status != StatusUnknown { - t.Errorf("migrations sans base : %s, attendu INCONNU", migrations.Status) - } - if !strings.Contains(migrations.Remedy, "contrôle 8") { - t.Errorf("la consigne doit renvoyer au contrôle qui bloque :\n%s", migrations.Remedy) - } -} - -func TestADamagedBaseIsNeverRepairedByThisCommand(t *testing.T) { - b := newBench(t) - b.base.integrityErr = errors.New("row 12 missing from index products_by_category") - - found := control(t, b.run(), ControlDatabase) - if found.Status != StatusFail { - t.Fatalf("base endommagée : %s", found.Status) - } - if !strings.Contains(found.Remedy, "restaurez") || !strings.Contains(found.Remedy, "Gardez") { - t.Errorf("la consigne doit dire de restaurer ET de garder le fichier endommagé, qui porte "+ - "les pesées que la copie n'a pas :\n%s", found.Remedy) - } -} - -func TestTheBaseIsGivenBackAtTheEndOfTheRun(t *testing.T) { - b := newBench(t) - b.run() - if !b.base.closed { - t.Error("la base n'a pas été refermée : le SERVICE la possède le reste du temps") - } -} - -// --- 9. Migrations ---------------------------------------------------------- - -func TestABaseFromANewerVersionIsErrDb02AndSaysToUpdateTheBinary(t *testing.T) { - b := newBench(t) - b.base.schema = 9 - b.migrations = 1 - - found := control(t, b.run(), ControlMigrations) - if found.Status != StatusFail || found.Code != "ERR-DB-02" { - t.Fatalf("schéma plus récent : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Observed, "9") || !strings.Contains(found.Observed, "1") { - t.Errorf("le constat doit citer LES DEUX numéros :\n%s", found.Observed) - } - if !strings.Contains(found.Remedy, "before-v") { - t.Errorf("la consigne doit dire qu'un retour arrière restaure aussi la copie, puisque les "+ - "migrations ne redescendent pas :\n%s", found.Remedy) - } -} - -func TestMigrationsNotAppliedPointAtTheServiceThatAppliesThem(t *testing.T) { - b := newBench(t) - b.base.schema = 0 - b.migrations = 3 - - found := control(t, b.run(), ControlMigrations) - if found.Status != StatusFail { - t.Fatalf("migrations en retard : %s", found.Status) - } - if !strings.Contains(found.Remedy, "démarrage") && !strings.Contains(found.Remedy, "Démarrez") { - t.Errorf("la consigne doit dire que les migrations s'appliquent au démarrage :\n%s", found.Remedy) - } -} - -func TestAMigrationCountNobodySuppliedIsNotGuessed(t *testing.T) { - b := newBench(t) - b.migrations = 0 - - found := control(t, b.run(), ControlMigrations) - if found.Status != StatusUnknown { - t.Fatalf("nombre de migrations non fourni : %s, attendu INCONNU", found.Status) - } - if !strings.Contains(found.Remedy, "Signalez") { - t.Errorf("la consigne doit dire qu'il n'y a rien à faire sur le poste :\n%s", found.Remedy) - } -} - -// --- 10. The serial port ---------------------------------------------------- - -func TestAStationWithoutAScaleIsNotIll(t *testing.T) { - report := newBench(t).run() - - for _, id := range []string{ControlSerialPort, ControlScaleRate} { - found := control(t, report, id) - if found.Status != StatusNotApplicable { - t.Errorf("%s : %s, attendu SANS OBJET — scale.present = false éteint le feu au lieu de "+ - "le laisser rouge (§11.2)", id, found.Status) - } - if found.Remedy != "" { - t.Errorf("%s : un contrôle sans objet n'a rien à prescrire :\n%s", id, found.Remedy) - } - } -} - -func TestADeclaredPortThatDoesNotExistNamesTheOnesThatDo(t *testing.T) { - b := newBench(t).withScale() - b.machine.serialPorts = []PortInfo{{Name: "COM3", Description: "Prolific USB-to-Serial"}} - - found := control(t, b.run(), ControlSerialPort) - if found.Status != StatusFail || found.Code != "ERR-SCL-03" { - t.Fatalf("port absent : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Observed, "COM3") { - t.Errorf("le constat doit nommer les ports visibles :\n%s", found.Observed) - } - // §15.2 says selective USB suspend causes half the scale disconnects on a USB-serial - // adapter, and a port that vanished is exactly what it looks like. - if !strings.Contains(found.Remedy, "15") { - t.Errorf("la consigne devrait renvoyer au contrôle de la suspension USB :\n%s", found.Remedy) - } -} - -func TestAPortHeldByTheRunningServiceIsGreenBecauseAPortIsExclusive(t *testing.T) { - b := newBench(t).withScale() - b.machine.openPortErr = errors.New("Access is denied.") - - found := control(t, b.run(), ControlSerialPort) - if found.Status != StatusPass { - t.Fatalf("port tenu par le service : %s, attendu OK — %s", found.Status, found.Observed) - } - if !strings.Contains(found.Observed, "exclusif") { - t.Errorf("le constat doit expliquer pourquoi un refus d'ouverture est ici un succès :\n%s", - found.Observed) - } -} - -func TestAPortNobodyHoldsAndThatWillNotOpenIsRed(t *testing.T) { - b := newBench(t).withScale() - b.machine.openPortErr = errors.New("permission denied") - b.service.silence() - - found := control(t, b.run(), ControlSerialPort) - if found.Status != StatusFail || found.Code != "ERR-SCL-03" { - t.Fatalf("port non ouvrable : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "dialout") { - t.Errorf("la consigne doit citer le droit qui manque le plus souvent :\n%s", found.Remedy) - } -} - -func TestAStationThatAnnouncesAScaleWithoutAPortIsRed(t *testing.T) { - b := newBench(t) - b.tweak(func(cfg *domain.Config) { - cfg.Scale.Present = true - cfg.Scale.Type = "gram-xfoc-rs" - }) - - found := control(t, b.run(), ControlSerialPort) - if found.Status != StatusFail { - t.Fatalf("aucun port déclaré : %s", found.Status) - } - if !strings.Contains(found.Remedy, "Détecter automatiquement") { - t.Errorf("la consigne doit renvoyer sur la détection, qui est ce qui répond à « y a-t-il "+ - "une balance ? » :\n%s", found.Remedy) - } -} - -// --- 11. The print queue ---------------------------------------------------- - -func TestThePrintQueueIsJudgedByTheServiceAndNeverByTheOperator(t *testing.T) { - b := newBench(t) - b.service.silence() - - found := control(t, b.run(), ControlPrintQueue) - if found.Status != StatusUnknown { - t.Fatalf("service muet : %s, attendu INCONNU", found.Status) - } - // important-11: a queue « installed for the user » is visible from here and invisible - // from session 0. Answering with the operator's list would answer another question. - if !strings.Contains(found.Observed, "utilisateur") { - t.Errorf("le constat doit dire pourquoi le service seul peut répondre :\n%s", found.Observed) - } - if !strings.Contains(found.Observed, "SATO WS408_2") { - t.Errorf("les files visibles d'ici sont utiles comme indice, et doivent apparaître :\n%s", - found.Observed) - } -} - -func TestAPrinterTheServiceCannotReachNamesTheLocalMachineRule(t *testing.T) { - b := newBench(t) - b.service.health.State.Printer.Health = "faulted" - b.service.health.State.Printer.Detail = "file introuvable" - - found := control(t, b.run(), ControlPrintQueue) - if found.Status != StatusFail || found.Code != "ERR-PRN-01" { - t.Fatalf("imprimante injoignable : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "LOCALE MACHINE") { - t.Errorf("la consigne doit nommer la panne la plus fréquente à l'installation :\n%s", found.Remedy) - } - if !strings.Contains(found.Remedy, "poste N") { - t.Errorf("la consigne devrait proposer l'imprimante de secours en attendant :\n%s", found.Remedy) - } -} - -func TestAOneWayTransportThatSaysNothingIsNotAFault(t *testing.T) { - b := newBench(t) - b.service.health.State.Printer.Health = "unknown" - - found := control(t, b.run(), ControlPrintQueue) - if found.Status != StatusPass { - t.Fatalf("statut inconnu : %s, attendu OK — c'est la réponse honnête d'un transport "+ - "unidirectionnel (A5, ADR-007)", found.Status) - } -} - -func TestARollNearingItsEndIsAmberAndNamesTheButton(t *testing.T) { - b := newBench(t) - b.service.health.State.Printer.Health = "consumable" - b.service.health.State.Printer.Detail = "environ 100 étiquettes restantes" - - found := control(t, b.run(), ControlPrintQueue) - if found.Status != StatusWarn { - t.Fatalf("rouleau en fin de vie : %s, attendu ATTENTION", found.Status) - } - if !strings.Contains(found.Remedy, "J'ai changé le rouleau") { - t.Errorf("la consigne doit nommer le bouton qui remet le compteur à zéro :\n%s", found.Remedy) - } -} - -// --- 12. The observed cadence ----------------------------------------------- - -func TestACadenceTooSlowIsAmberAndExplainsTheSymptom(t *testing.T) { - b := newBench(t).withScale() - b.service.health.State.Scale.MedianMS = 2400 - b.service.health.State.Scale.TooSlow = true - - found := control(t, b.run(), ControlScaleRate) - if found.Status != StatusWarn { - t.Fatalf("cadence trop lente : %s, attendu ATTENTION (§15.4 : feu orange)", found.Status) - } - if !strings.Contains(found.Observed, "2400") { - t.Errorf("le constat doit citer la cadence mesurée :\n%s", found.Observed) - } - if !strings.Contains(found.Observed, "périmé") { - t.Errorf("le constat doit dire la conséquence : le poids est périmé avant la mesure "+ - "suivante :\n%s", found.Observed) - } -} - -func TestAProvisionalCadenceIsNeverPresentedAsAMeasurement(t *testing.T) { - b := newBench(t).withScale() - b.service.health.State.Scale.Observations = 3 - b.service.health.State.Scale.Provisional = true - - found := control(t, b.run(), ControlScaleRate) - if found.Status != StatusWarn { - t.Fatalf("cadence provisoire : %s, attendu ATTENTION", found.Status) - } - if !strings.Contains(found.Observed, "PROVISOIRE") { - t.Errorf("le constat doit dire que ce n'est pas une mesure :\n%s", found.Observed) - } -} - -func TestAScaleThatWentSilentIsErrScl02(t *testing.T) { - b := newBench(t).withScale() - b.service.health.State.Scale.Connected = false - - found := control(t, b.run(), ControlScaleRate) - if found.Status != StatusFail || found.Code != "ERR-SCL-02" { - t.Fatalf("balance perdue : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "saisie du poids à la main") { - t.Errorf("la consigne doit dire que le poste sert encore, à la main :\n%s", found.Remedy) - } -} - -func TestAPortHeldWithNoFrameYetIsUnknownAndNotAFault(t *testing.T) { - b := newBench(t).withScale() - b.service.health.State.Scale.Observations = 0 - - found := control(t, b.run(), ControlScaleRate) - if found.Status != StatusUnknown { - t.Fatalf("aucune trame : %s, attendu INCONNU", found.Status) - } - if !strings.Contains(found.Remedy, "plateau") { - t.Errorf("la consigne doit dire le geste qui produit une trame :\n%s", found.Remedy) - } -} - -// --- 13. The catalog source ------------------------------------------------- - -func TestAnEmptyCatalogNamesTheFileTheStationIsWaitingFor(t *testing.T) { - b := newBench(t) - b.service.health.State.CatalogCount = 0 - b.service.health.Catalog = nil - - found := control(t, b.run(), ControlCatalogSource) - if found.Status != StatusWarn { - t.Fatalf("catalogue vide : %s, attendu ATTENTION", found.Status) - } - // The name DERIVES from station.number, and is never written by hand (§14.4). - if !strings.Contains(found.Remedy, "flv_2.csv") { - t.Errorf("la consigne doit nommer le fichier attendu, dérivé du numéro de poste :\n%s", - found.Remedy) - } -} - -func TestARejectedCatalogSaysTheStationKeepsWeighing(t *testing.T) { - b := newBench(t) - b.service.health.Catalog.Result = domain.ImportRejected - b.service.health.Catalog.Code = "ERR-CAT-03" - b.service.health.Catalog.Reason = "ligne 28, clé de contrôle fausse" - - found := control(t, b.run(), ControlCatalogSource) - if found.Status != StatusWarn || found.Code != "ERR-CAT-03" { - t.Fatalf("catalogue refusé : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "rien n'est perdu") { - t.Errorf("la consigne doit rassurer : le catalogue précédent reste en service :\n%s", found.Remedy) - } - if !strings.Contains(found.Remedy, "producteur") { - t.Errorf("la consigne doit dire à qui envoyer les lignes fautives :\n%s", found.Remedy) - } -} - -func TestTheCatalogControlNeverPublishesTheWebDAVAddress(t *testing.T) { - b := newBench(t) - b.tweak(func(cfg *domain.Config) { - cfg.Catalog.Type = domain.CatalogSourceWebDAV - cfg.Catalog.Options = domain.DriverOptions{ - "url": json.RawMessage(`"https://dav.example.org/balance"`), - "username": json.RawMessage(`"balance"`), - } - }) - b.service.silence() - - found := control(t, b.run(), ControlCatalogSource) - if strings.Contains(found.Observed+found.Remedy, "example.org") { - t.Fatalf("l'adresse privée de la source ne doit pas voyager dans un rapport que "+ - "diagnostic.zip emporte :\n%s\n%s", found.Observed, found.Remedy) - } - if !strings.Contains(found.Observed, "webdav") { - t.Errorf("le constat doit tout de même nommer le type de source :\n%s", found.Observed) - } -} - -// --- 14. The system clock --------------------------------------------------- - -func TestAClockBeforeTheBuildDateIsErrSys07(t *testing.T) { - b := newBench(t) - b.clock.Set(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)) - - found := control(t, b.run(), ControlSystemClock) - if found.Status != StatusFail || found.Code != "ERR-SYS-07" { - t.Fatalf("horloge en arrière : %s / %q", found.Status, found.Code) - } - if !strings.Contains(found.Remedy, "caisse") { - t.Errorf("la consigne doit dire pourquoi l'heure compte : le rapprochement avec la "+ - "caisse :\n%s", found.Remedy) - } - if !strings.Contains(found.Remedy, "pile") { - t.Errorf("la consigne devrait nommer la cause la plus fréquente d'une heure qui revient "+ - "toujours à la même date :\n%s", found.Remedy) - } -} - -func TestAClockBeforeTheConfigurationWasWrittenIsAlsoAJump(t *testing.T) { - b := newBench(t) - // After the build date, so the first branch does not fire, and before the instant the - // configuration file says it was written. - b.tweak(func(cfg *domain.Config) { cfg.ModifiedAt = benchEpoch.Add(48 * time.Hour) }) - - found := control(t, b.run(), ControlSystemClock) - if found.Status != StatusFail || found.Code != "ERR-SYS-07" { - t.Fatalf("horloge antérieure à l'écriture de la configuration : %s / %q", found.Status, found.Code) - } -} - -func TestABinaryWithoutItsBuildDateCannotConclude(t *testing.T) { - b := newBench(t) - options := b.options() - options.BuildDate = "unknown" - b.writeConfig() - doctor, err := New(options) - if err != nil { - t.Fatalf("construction du doctor : %v", err) - } - - found := control(t, doctor.Run(context.Background()), ControlSystemClock) - if found.Status != StatusUnknown { - t.Fatalf("date de compilation inconnue : %s, attendu INCONNU", found.Status) - } - if !strings.Contains(found.Remedy, "make build") { - t.Errorf("la consigne doit dire que c'est le binaire, pas le poste :\n%s", found.Remedy) - } -} - -// --- 15. Sleep and USB selective suspend ------------------------------------ - -func TestSelectiveUSBSuspendIsRedAndCarriesTheExactCommand(t *testing.T) { - b := newBench(t) - b.machine.power.USBSelectiveSuspendDisabled = false - b.machine.power.Detail = "Réglages encore actifs sur secteur — suspension USB sélective : 1." - - found := control(t, b.run(), ControlPowerSettings) - if found.Status != StatusFail { - t.Fatalf("suspension USB active : %s", found.Status) - } - // The two GUIDs come from install.ps1 in §15.2 and from nowhere else. - for _, want := range []string{usbSubgroupGUID, usbSuspendGUID, "setacvalueindex"} { - if !strings.Contains(found.Remedy, want) { - t.Errorf("la consigne doit porter la commande exacte de §15.2 (%s manque) :\n%s", - want, found.Remedy) - } - } - if !strings.Contains(found.Remedy, "moitié") { - t.Errorf("la consigne doit dire ce que ce réglage coûte : la moitié des « la balance ne "+ - "répond plus » :\n%s", found.Remedy) - } -} - -func TestSleepStillEnabledIsRed(t *testing.T) { - b := newBench(t) - b.machine.power.SleepDisabled = false - b.machine.power.Detail = "Réglages encore actifs sur secteur — extinction de l'écran : 600." - - found := control(t, b.run(), ControlPowerSettings) - if found.Status != StatusFail { - t.Fatalf("veille active : %s", found.Status) - } - if !strings.Contains(found.Remedy, "powercfg /change") { - t.Errorf("la consigne doit porter les commandes de §15.2 :\n%s", found.Remedy) - } -} - -func TestASystemWhoseInstallerWritesNoPowerSettingIsNotJudged(t *testing.T) { - b := newBench(t) - b.machine.power = PowerState{Applicable: false} - - found := control(t, b.run(), ControlPowerSettings) - if found.Status != StatusNotApplicable { - t.Fatalf("système sans réglage d'énergie : %s, attendu SANS OBJET — inventer une exigence "+ - "serait pire que ne rien dire", found.Status) - } -} - -// --- 16. The right to restart the machine ----------------------------------- - -// TestAStationThatMayRestartTheMachineIsGreen. -func TestAStationThatMayRestartTheMachineIsGreen(t *testing.T) { - found := control(t, newBench(t).run(), ControlRebootPermission) - if found.Status != StatusPass { - t.Fatalf("statut %s — %s", found.Status, found.Observed) - } -} - -// TestAStationThatMayNOTRestartTheMachineSaysWhatToDo. -// -// This is the state of every Linux station whose polkit rule was never posed, and the -// reason this control exists: the station works perfectly until the evening somebody -// needs the one button it forbids. -func TestAStationThatMayNOTRestartTheMachineSaysWhatToDo(t *testing.T) { - b := newBench(t) - b.machine.reboot = RebootPermissionState{Applicable: true, Allowed: false, - Detail: "/etc/polkit-1/rules.d/49-openscale-reboot.rules est absent"} - - found := control(t, b.run(), ControlRebootPermission) - if found.Status != StatusFail { - t.Fatalf("droit refusé : %s", found.Status) - } - if !strings.Contains(found.Remedy, "install.sh") { - t.Errorf("la consigne ne nomme pas le remède :\n%s", found.Remedy) - } - if found.Code != codeRebootRefused { - t.Errorf("code %q, attendu %q", found.Code, codeRebootRefused) - } -} - -// TestASystemThatCannotRestartAtAllIsNotJudged: inventing a requirement there would be -// worse than saying nothing, which is the rule the power settings already follow. -func TestASystemThatCannotRestartAtAllIsNotJudged(t *testing.T) { - b := newBench(t) - b.machine.reboot = RebootPermissionState{Applicable: false} - - found := control(t, b.run(), ControlRebootPermission) - if found.Status != StatusNotApplicable { - t.Fatalf("système sans redémarrage : %s, attendu SANS OBJET", found.Status) - } - if found.Observed == "" { - t.Error("le contrôle ne dit pas ce qu'il a vu") - } -} - // --- The whole report ------------------------------------------------------- func TestADoctorWithNoCollaboratorAtAllStillProducesEveryLine(t *testing.T) { @@ -1035,83 +178,6 @@ func TestADoctorRefusesToBeBuiltWithoutAClock(t *testing.T) { } } -// reportHead renders the report and returns its first line. -func reportHead(t *testing.T, report Report) string { - t.Helper() - out := &strings.Builder{} - if err := report.WriteText(out); err != nil { - t.Fatalf("rendu du rapport : %v", err) - } - return out.String() -} - -// --- 17. The client screen cannot leave the application --------------------- - -// TestAStationLockedOnItsApplicationIsGreen. -func TestAStationLockedOnItsApplicationIsGreen(t *testing.T) { - found := control(t, newBench(t).run(), ControlNavigationLock) - if found.Status != StatusPass { - t.Fatalf("statut %s — %s", found.Status, found.Observed) - } - if !strings.Contains(found.Observed, "openscale") { - t.Errorf("le contrôle ne dit pas SOUS QUEL COMPTE il a lu :\n%s", found.Observed) - } -} - -// TestAStationThatCanBeTakenOutOfTheApplicationIsRed est la panne qui laisse tous les -// autres contrôles au vert : le navigateur tourne, le service répond, la fenêtre est en -// plein écran — et ce qu'elle affiche est un moteur de recherche. -func TestAStationThatCanBeTakenOutOfTheApplicationIsRed(t *testing.T) { - b := newBench(t) - b.machine.navigation = NavigationLockState{Applicable: true, Determined: true, - Account: "openscale", Browser: "Microsoft Edge", - Detail: "Microsoft Edge : URLBlocklist = (vide)."} - - found := control(t, b.run(), ControlNavigationLock) - if found.Status != StatusFail { - t.Fatalf("poste non verrouillé : %s", found.Status) - } - if found.Code != codeNavigationOpen { - t.Errorf("code %q, attendu %q", found.Code, codeNavigationOpen) - } - if found.Remedy == "" { - t.Error("le contrôle ne dit pas quoi faire") - } -} - -// TestAHiveThatIsNotMountedIsAmberAndNeverRed : la ruche d'un compte qui n'a pas de session -// ouverte n'est pas montée, et rien ici ne la monte. Accuser un poste sur une question -// qu'on n'a pas pu poser serait pire que de dire qu'on ne sait pas — d'autant que le chien -// de garde du superviseur ramène l'écran quoi qu'il arrive. -func TestAHiveThatIsNotMountedIsAmberAndNeverRed(t *testing.T) { - b := newBench(t) - b.machine.navigation = NavigationLockState{Applicable: true, Determined: false, - Account: "openscale", Detail: "aucune stratégie de navigation sous le compte."} - - found := control(t, b.run(), ControlNavigationLock) - if found.Status != StatusUnknown { - t.Fatalf("question non posée : %s, attendu INCONNU", found.Status) - } - if found.Remedy == "" { - t.Error("le contrôle ne dit pas comment lever le doute") - } -} - -// TestALinuxStationIsNotJudgedOnAPolicyItDoesNotOwn : sous cage, la stratégie appartient à -// l'installeur et au compte root, pas au compte du poste. -func TestALinuxStationIsNotJudgedOnAPolicyItDoesNotOwn(t *testing.T) { - b := newBench(t) - b.machine.navigation = NavigationLockState{Applicable: false} - - found := control(t, b.run(), ControlNavigationLock) - if found.Status != StatusNotApplicable { - t.Fatalf("station Linux : %s, attendu SANS OBJET", found.Status) - } - if found.Observed == "" { - t.Error("le contrôle ne dit pas ce qu'il a vu") - } -} - // TestTheReportShowsNoFingerprintWhenABlockWasSubstituted is the rule ConfigStore.Versions // already holds, on the other document support reads: « elle est inconnue, pas inventée » // (§14.4). diff --git a/internal/diag/harness_test.go b/internal/diag/harness_test.go index 4b895e9..0808251 100644 --- a/internal/diag/harness_test.go +++ b/internal/diag/harness_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -304,3 +305,13 @@ func (d *fakeDatabase) Path() string { return d.path } func (d *fakeDatabase) SchemaVersion() (int, error) { return d.schema, d.schemaErr } func (d *fakeDatabase) IntegrityCheck(context.Context) error { return d.integrityErr } func (d *fakeDatabase) Close() error { d.closed = true; return nil } + +// reportHead renders the report and returns its first line. +func reportHead(t *testing.T, report Report) string { + t.Helper() + out := &strings.Builder{} + if err := report.WriteText(out); err != nil { + t.Fatalf("rendu du rapport : %v", err) + } + return out.String() +} diff --git a/internal/diag/probes.go b/internal/diag/probes.go index f142f08..aea538f 100644 --- a/internal/diag/probes.go +++ b/internal/diag/probes.go @@ -3,18 +3,15 @@ package diag import ( "context" "errors" - "fmt" "net" "os" "os/exec" "runtime" - "strconv" "strings" "time" serialport "go.bug.st/serial" - "openscale/internal/kiosk" "openscale/internal/platform" "openscale/internal/station/ports" ) @@ -36,18 +33,13 @@ import ( // that disagreed about which ports exist would be the worst possible answer to « le port // déclaré existe-t-il ? ». The VOLUME is still read here, in the two build-tagged files // beside this one; it belongs in internal/platform and moving it is a file move. - -// The two GUIDs of §15.2, step 5, copied from install.ps1. // -// They are NOT derived, NOT guessed and NOT looked up: the document contains the exact -// line `powercfg /setacvalueindex SCHEME_CURRENT 2a737441-… 48e6b7a6-… 0`, and these are -// its two arguments. The subgroup is the USB settings, the setting is the selective -// suspend — which §15.2 says causes half the « la balance ne répond plus » on a USB-serial -// adapter. -const ( - usbSubgroupGUID = "2a737441-1930-4402-8d77-b2bebba308a3" - usbSuspendGUID = "48e6b7a6-50f5-4782-a5d4-53bb8f07e226" -) +// What stays HERE is the seam, the names the installers write, and the questions that need +// no output parsed at all: the serial ports, the listening socket, the volume, the print +// queues and the system itself. The three questions that DO come back as localised text are +// beside it, one file per subject — probes_service_manager.go for the service and the kiosk +// task, probes_account.go for the registry of the station account, probes_power.go for the +// power plan. // The names §15.2 and §15.3 install, and the only names this diagnosis looks for. const ( @@ -118,520 +110,6 @@ func NewMachine(clk ports.Clock) Machine { return hostMachine{run: execRunner{cl // use. func newMachineWith(run Runner) Machine { return hostMachine{run: run} } -// --- The service manager ---------------------------------------------------- - -// Service reports what the service manager says about `openscale serve`. -func (m hostMachine) Service(ctx context.Context) (ServiceState, error) { - switch runtime.GOOS { - case "windows": - query, _ := m.run.Run(ctx, "sc.exe", "query", windowsServiceName) - config, _ := m.run.Run(ctx, "sc.exe", "qc", windowsServiceName) - return parseWindowsService(windowsServiceName, query, config), nil - case "linux": - active, _ := m.run.Run(ctx, "systemctl", "is-active", linuxServiceUnit) - enabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxServiceUnit) - return parseSystemdUnit(linuxServiceUnit, active, enabled), nil - } - return ServiceState{Name: windowsServiceName}, nil -} - -// KioskTask reports the scheduled task of §15.2 or the kiosk unit of §15.3. -func (m hostMachine) KioskTask(ctx context.Context) (ServiceState, error) { - switch runtime.GOOS { - case "windows": - out, err := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask) - return kioskTaskState(windowsKioskTask, out, err != nil, sessionIsElevated()) - case "linux": - active, _ := m.run.Run(ctx, "systemctl", "is-active", linuxKioskUnit) - enabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxKioskUnit) - return parseSystemdUnit(linuxKioskUnit, active, enabled), nil - } - return ServiceState{Name: windowsKioskTask}, nil -} - -// kioskTaskState reads what schtasks answered, knowing whether we had the RIGHT to look. -// -// schtasks exits 1 for « Erreur : Accès refusé. » as well as for « Erreur : Le fichier -// spécifié est introuvable. », and both messages are localised: neither the code nor the -// text separates the two. MEASURED on Windows 10, where the folder of tasks is unreadable -// without elevation — so `openscale doctor` run from an ordinary prompt, which is how a -// volunteer runs it, announced « aucune tâche OpenScale-Kiosk n'est déclarée » about a -// station whose task was there, and told them to reinstall it. parseWindowsService refuses -// that exact mistake a few lines below; this is what makes the kiosk check refuse it too. -// -// Elevated, a failure IS an answer: we could look, and it is not there. Otherwise the -// honest answer is « je ne sais pas », returned as an error so the caller asks for an -// elevated prompt instead of handing out a remedy. -func kioskTaskState(name, out string, failed, elevated bool) (ServiceState, error) { - if !failed { - return ServiceState{Name: name, Known: true, Determined: true, Detail: firstLine(out)}, nil - } - if !elevated { - return ServiceState{Name: name}, fmt.Errorf( - "le dossier des tâches n'est pas lisible sans élévation, et schtasks ne distingue "+ - "pas « absente » de « accès refusé » : %s", firstLine(out)) - } - return ServiceState{Name: name, Determined: true, Detail: firstLine(out)}, nil -} - -// parseWindowsService reads the output of `sc query` and `sc qc`. -// -// It matches on the ENGLISH tokens of the two lines, and only on them: STATE, RUNNING, -// START_TYPE, AUTO_START. Everything else sc.exe prints is localised and arrives in the -// console code page, which is exactly why no verdict may rest on it. Error 1060 is -// « the specified service does not exist », and it is the one failure that is a different -// remedy from « installed but stopped ». -func parseWindowsService(name, query, config string) ServiceState { - state := ServiceState{Name: name, Determined: true} - if strings.Contains(query, "1060") { - return state - } - word := tokenAfter(query, "STATE") - if word == "" { - // sc.exe answered something this parser does not recognise. Claiming the service - // is absent would send somebody reinstalling it; claiming it runs would be worse. - state.Determined = false - state.Detail = firstLine(query) - return state - } - state.Known = true - state.Running = word == "RUNNING" - state.Detail = word - if startType := startTypeOf(config); startType != "" { - state.Automatic = strings.HasPrefix(startType, "AUTO_START") - state.Detail = word + ", " + startType - } - return state -} - -// startTypeOf returns the WORDS of the START_TYPE line of `sc qc`, without the code. -// -// tokenAfter cannot be used here, and this function exists because it was. tokenAfter -// returns the LAST word of the line — right for « STATE : 4 RUNNING », wrong for -// « START_TYPE : 2 AUTO_START (DELAYED) », whose last word is « (DELAYED) ». The -// prefix test below then read false, and doctor warned that the service was not automatic. -// -// It is not a rare shape: internal/platform/service_windows.go sets DelayedAutoStart ON -// PURPOSE, so that the disks, the network stack and the print spooler come up first. Every -// station installed with « --start auto » was therefore told to run -// « sc config OpenScale start= auto » — which is exactly what it already was. -// -// The numeric code is dropped for the reason tokenAfter gives: the word is the same in -// every locale, the number is one lookup table away from being wrong. -func startTypeOf(config string) string { - for _, line := range strings.Split(config, "\n") { - if !strings.Contains(line, "START_TYPE") { - continue - } - _, value, found := strings.Cut(line, ":") - if !found { - continue - } - words := make([]string, 0, 2) - for _, field := range strings.Fields(value) { - if field[0] >= '0' && field[0] <= '9' { - continue - } - words = append(words, field) - } - return strings.Join(words, " ") - } - return "" -} - -// tokenAfter returns the last word of the first line that carries key. -// -// `sc query` prints « STATE : 4 RUNNING », so the token that carries the -// meaning is the last one on the line. The numeric code is deliberately ignored: the word -// is the same in every locale and the number is one lookup table away from being wrong. -// -// It is NOT usable on START_TYPE, whose line can end on a parenthesis — see startTypeOf. -func tokenAfter(output, key string) string { - for _, line := range strings.Split(output, "\n") { - if !strings.Contains(line, key) { - continue - } - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) == 0 { - continue - } - last := fields[len(fields)-1] - if last == ":" || last == key { - return "" - } - return last - } - return "" -} - -// parseSystemdUnit reads `systemctl is-active` and `systemctl is-enabled`. -// -// systemctl exits non-zero for « inactive » and for « disabled », which are perfectly -// ordinary answers, so the exit code carries nothing and the WORD carries everything. -// « not-found » is what an unknown unit answers, and it is the only case that means the -// unit was never installed. -func parseSystemdUnit(name, active, enabled string) ServiceState { - activeWord, enabledWord := firstLine(active), firstLine(enabled) - state := ServiceState{Name: name, Determined: true, Detail: activeWord + ", " + enabledWord} - if activeWord == "" && enabledWord == "" { - state.Determined = false - return state - } - if enabledWord == "not-found" || activeWord == "not-found" { - return state - } - state.Known = true - state.Running = activeWord == "active" - state.Automatic = enabledWord == "enabled" || enabledWord == "enabled-runtime" - return state -} - -// --- The unattended restart ------------------------------------------------- - -// winlogonKey is where Windows keeps the automatic logon, and where §15.2 step 3 writes -// it. -const winlogonKey = `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon` - -// AutoLogon reports the three conditions of bloquant-7. -func (m hostMachine) AutoLogon(ctx context.Context) (AutoLogonState, error) { - switch runtime.GOOS { - case "windows": - enabled, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "AutoAdminLogon") - account, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "DefaultUserName") - taskXML, _ := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask, "/xml", "ONE") - state := parseAutoLogon(enabled, account) - state.Expected = parseTaskUserID(taskXML) - return state, nil - case "linux": - // The Linux equivalent named by §14.4: both units enabled. There is no session to - // open on a station running cage — the kiosk unit IS the session. - serviceEnabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxServiceUnit) - kioskEnabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxKioskUnit) - return parseLinuxUnattendedRestart(serviceEnabled, kioskEnabled), nil - } - return AutoLogonState{}, nil -} - -// parseAutoLogon reads the two `reg query` outputs. -// -// It matches on the value TYPE — REG_SZ, REG_DWORD — which reg.exe never localises, and -// takes the token after it. The alternative, matching the value name, breaks the day -// somebody has a DefaultUserName under a differently-cased key. -func parseAutoLogon(enabled, account string) AutoLogonState { - state := AutoLogonState{} - value, found := registryValue(enabled, "AutoAdminLogon") - if !found { - // The key exists on every Windows; a query that returns nothing at all means the - // query itself did not run — no reg.exe, or no rights. - state.Detail = "la valeur AutoAdminLogon n'a pas pu être lue." - return state - } - state.Determined = true - state.Enabled = value == "1" - state.Detail = "AutoAdminLogon = " + or(value, "(vide)") + "." - if name, ok := registryValue(account, "DefaultUserName"); ok { - state.Account = name - } - return state -} - -// registryValue extracts the data of one value from `reg query` output. -// -// The line looks like « AutoAdminLogon REG_SZ 1 ». A value whose data is EMPTY -// prints nothing after the type, and that is a legitimate reading: found is true, the data -// is empty, and « AutoAdminLogon vide » is not « AutoAdminLogon = 1 ». -func registryValue(output, name string) (string, bool) { - for _, line := range strings.Split(output, "\n") { - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) < 2 || !strings.EqualFold(fields[0], name) { - continue - } - if !strings.HasPrefix(fields[1], "REG_") { - continue - } - return strings.Join(fields[2:], " "), true - } - return "", false -} - -// parseTaskUserID extracts the OF THE PRINCIPAL from the XML of the kiosk task. -// -// The XML is the one output of schtasks that is NOT localised, which is why the account is -// read from there and not from the `/fo LIST /v` listing. Knowing which account the kiosk -// runs as is what turns « l'ouverture de session est active » into « elle est active POUR -// LE BON COMPTE » — an autologon onto another account leaves the client screen closed just -// as surely as no autologon at all. -// -// ★ THE PRINCIPAL, NOT THE FIRST OF THE DOCUMENT. openscale-kiosk.xml carries two: -// the trigger's, which says WHICH LOGON wakes the task, and the principal's, which says -// UNDER WHICH ACCOUNT it runs. Only the second answers the question this control asks — and -// the scheduler normalises the first to a SID when it registers the task, so reading it -// compared a SID against DefaultUserName and accused a healthy station. -func parseTaskUserID(xml string) string { - const open, close = "", "" - if i := strings.Index(xml, ""); i >= 0 { - xml = xml[i:] - } - start := strings.Index(xml, open) - if start < 0 { - return "" - } - rest := xml[start+len(open):] - end := strings.Index(rest, close) - if end < 0 { - return "" - } - // A task XML spells the account as DOMAIN\user or as COMPUTER\user; the registry - // spells DefaultUserName without the domain, and comparing the two forms would report - // a mismatch that does not exist. - account := strings.TrimSpace(rest[:end]) - if i := strings.LastIndex(account, `\`); i >= 0 { - account = account[i+1:] - } - // A principal normalised to a SID is not comparable to DefaultUserName, which is - // spelled « openscale ». Nothing is known then, and NOT KNOWING IS THE ANSWER: doctor - // guards its mismatch branch with Expected != "", so an empty result reports the - // unattended restart on the strength of AutoAdminLogon alone rather than accusing a - // station of running the kiosk under an account nobody can name. - if strings.HasPrefix(account, "S-1-") { - return "" - } - return account -} - -// parseLinuxUnattendedRestart is §14.4's Linux equivalent: both units enabled. -func parseLinuxUnattendedRestart(service, kiosk string) AutoLogonState { - serviceWord, kioskWord := firstLine(service), firstLine(kiosk) - state := AutoLogonState{Detail: fmt.Sprintf("%s : %s ; %s : %s", - linuxServiceUnit, or(serviceWord, "?"), linuxKioskUnit, or(kioskWord, "?"))} - if serviceWord == "" && kioskWord == "" { - return state - } - state.Determined = true - state.Enabled = serviceWord == "enabled" && kioskWord == "enabled" - return state -} - -// --- The power plan --------------------------------------------------------- - -// powerSetting is one setting §15.2 step 5 turns off, with the sentence that names it. -type powerSetting struct { - // subgroup and setting are powercfg arguments: either its own documented aliases, or - // the two GUIDs §15.2 spells out. - subgroup string - setting string - // label is FRENCH and names the setting the way a volunteer would recognise it in the - // power plan window. - label string -} - -// sleepSettings are the three timeouts §15.2 sets to zero with `powercfg /change`. -// -// The arguments are powercfg's OWN aliases and not GUIDs: SUB_SLEEP, STANDBYIDLE, -// SUB_VIDEO, VIDEOIDLE and HIBERNATEIDLE are names the tool accepts and prints, so nothing -// here is a number this project had to find somewhere. -var sleepSettings = []powerSetting{ - {"SUB_SLEEP", "STANDBYIDLE", "mise en veille"}, - {"SUB_SLEEP", "HIBERNATEIDLE", "mise en veille prolongée"}, - {"SUB_VIDEO", "VIDEOIDLE", "extinction de l'écran"}, -} - -// RebootPermission reports whether this station may restart the machine. -// -// The three answers are three platforms, and the middle one is why this question exists: -// under Linux the service runs as `openscale` and polkit stands between it and the right, -// so a station missing its rule works perfectly — right up to the evening a volunteer is -// facing a frozen kiosk and touches the one button that would have saved them. -func (hostMachine) RebootPermission(context.Context) (RebootPermissionState, error) { - allowed, detail := rebootPermission() - if detail == "" { - return RebootPermissionState{Applicable: false}, nil - } - return RebootPermissionState{Allowed: allowed, Detail: detail, Applicable: true}, nil -} - -// --- The navigation lock ---------------------------------------------------- - -// profileListKey is where Windows records which SID owns which profile directory, and it -// is the only way from « openscale » to « S-1-5-21-…-1001 » that does not need a Windows -// API call this package has no other reason to make. -const profileListKey = `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList` - -// NavigationLock reports whether the browser of the station account is held on the client -// screen. -// -// ★ IT READS ANOTHER ACCOUNT'S HIVE, AND THAT IS THE WHOLE DIFFICULTY. The policies are -// posed by the kiosk under HKEY_CURRENT_USER — its own, which is the station account's — -// and `openscale doctor` is typed by a technician logged on as somebody else. Reading the -// caller's own HKCU would report on the technician's browser and call a wide-open station -// green, which is exactly the mistake the kiosk task's principal cost once already. -// -// The hive of an account that is not logged on is NOT mounted, and nothing here loads it: -// mounting another user's registry from a diagnosis would be a write on a machine somebody -// asked a question of. Not knowing is then the answer, and it carries the gesture that -// resolves it. -func (m hostMachine) NavigationLock(ctx context.Context) (NavigationLockState, error) { - if runtime.GOOS != "windows" { - // §15.3 poses the Linux policy as a root-owned file, from install.sh. There is no - // per-account hive to read, and inventing a requirement here would be worse than - // saying nothing. - return NavigationLockState{}, nil - } - state := NavigationLockState{Applicable: true} - state.Account = m.kioskAccount(ctx) - if state.Account == "" { - state.Detail = "le compte qui ouvre l'écran client n'a pas pu être nommé." - return state, nil - } - - profiles, _ := m.run.Run(ctx, "reg.exe", "query", profileListKey, "/s", "/v", "ProfileImagePath") - sid := profileSID(profiles, state.Account) - if sid == "" { - state.Detail = "aucun profil Windows au nom de « " + state.Account + - " » : ce compte n'a encore jamais ouvert de session sur ce poste." - return state, nil - } - - for _, vendor := range kiosk.PolicyVendors { - output, _ := m.run.Run(ctx, "reg.exe", "query", - `HKU\`+sid+`\`+vendor.Root+`\URLBlocklist`, "/v", "1") - value, found := registryValue(output, "1") - if !found { - continue - } - state.Determined, state.Browser = true, vendor.Label - state.Locked = value == "*" - state.Detail = vendor.Label + " : URLBlocklist = " + or(value, "(vide)") + "." - return state, nil - } - state.Detail = "aucune stratégie de navigation sous le compte « " + state.Account + - " » — soit le kiosque ne les a jamais posées, soit sa session n'est pas ouverte " + - "et sa ruche n'est pas montée." - return state, nil -} - -// kioskAccount names the account the client screen runs under. -// -// The task's principal first, because it is what ACTUALLY runs the kiosk; DefaultUserName -// second, because a station whose task was registered with a SID leaves the principal -// unreadable (parseTaskUserID) and the autologon still names the account §15.2 installed. -func (m hostMachine) kioskAccount(ctx context.Context) string { - taskXML, _ := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask, "/xml", "ONE") - if account := parseTaskUserID(taskXML); account != "" { - return account - } - output, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "DefaultUserName") - account, _ := registryValue(output, "DefaultUserName") - return account -} - -// profileSID finds the SID whose profile directory carries this account name. -// -// The listing pairs a key line — which ENDS with the SID — with the ProfileImagePath -// underneath it, so the SID is remembered until a path answers. Matching on the last -// segment of the path and not on the whole of it is what survives a station whose profiles -// are not under C:\Users. -func profileSID(output, account string) string { - sid := "" - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "HKEY_") { - sid = trimmed[strings.LastIndex(trimmed, `\`)+1:] - continue - } - path, found := registryValue(trimmed, "ProfileImagePath") - if !found || sid == "" { - continue - } - if strings.EqualFold(path[strings.LastIndex(path, `\`)+1:], account) { - return sid - } - } - return "" -} - -// Power reports the sleep and USB selective suspend settings. -func (m hostMachine) Power(ctx context.Context) (PowerState, error) { - if runtime.GOOS != "windows" { - // §15.3 installs cage, seatd and udev rules, and writes no power setting at all. - // Reporting a verdict about a Linux power plan the installer never touches would - // be inventing a requirement. - return PowerState{Applicable: false}, nil - } - state := PowerState{Applicable: true, Determined: true, - SleepDisabled: true, USBSelectiveSuspendDisabled: true} - var awake []string - - for _, setting := range append(sleepSettings, - powerSetting{usbSubgroupGUID, usbSuspendGUID, "suspension USB sélective"}) { - out, err := m.run.Run(ctx, "powercfg.exe", "/query", "SCHEME_CURRENT", setting.subgroup, setting.setting) - value, ok := parsePowerIndex(out) - if err != nil || !ok { - state.Determined = false - state.Detail = fmt.Sprintf("le réglage « %s » n'a pas pu être lu", setting.label) - return state, nil - } - if value == 0 { - continue - } - awake = append(awake, fmt.Sprintf("%s : %d", setting.label, value)) - if setting.setting == usbSuspendGUID { - state.USBSelectiveSuspendDisabled = false - continue - } - state.SleepDisabled = false - } - if len(awake) > 0 { - state.Detail = "Réglages encore actifs sur secteur — " + strings.Join(awake, " · ") + "." - } - return state, nil -} - -// parsePowerIndex reads the ON-MAINS setting index out of `powercfg /query` output. -// -// # Why it reads no label -// -// powercfg localises every label it prints — « Current AC Power Setting Index » becomes -// « Index du paramètre d'alimentation sur secteur actuel » on a French Windows — so a parser -// that matched on words would work on the developer's machine and fail on every station in -// the shop. What is NOT localised is the shape: the values are hexadecimal, spelled 0x…, and -// the two CURRENT indices are the last two lines of the block, mains first. -// -// # Why the last two and not the first -// -// A range setting (the sleep timeouts) prints its bounds first — minimum, maximum, -// increment — and only then the two current indices; an enumerated setting (the USB -// selective suspend) prints its possible values with UNPREFIXED indices, so they are not -// picked up at all. Taking the first 0x value would read the minimum of a range and report -// every station's sleep timeout as zero, which is the wrong answer in the dangerous -// direction: it would announce « veille désactivée » on a station that falls asleep. -// -// A block with a single value is a setting that has no battery variant, and that value is -// the mains one. -func parsePowerIndex(output string) (uint64, bool) { - var values []uint64 - for _, line := range strings.Split(output, "\n") { - for _, field := range strings.Fields(line) { - hex, found := strings.CutPrefix(strings.ToLower(field), "0x") - if !found { - continue - } - value, err := strconv.ParseUint(hex, 16, 64) - if err != nil { - continue - } - values = append(values, value) - } - } - switch len(values) { - case 0: - return 0, false - case 1: - return values[0], true - } - return values[len(values)-2], true -} - // --- Serial ports ----------------------------------------------------------- // SerialPorts enumerates the serial ports with their USB description. diff --git a/internal/diag/probes_account.go b/internal/diag/probes_account.go new file mode 100644 index 0000000..a97f751 --- /dev/null +++ b/internal/diag/probes_account.go @@ -0,0 +1,241 @@ +package diag + +import ( + "context" + "fmt" + "runtime" + "strings" + + "openscale/internal/kiosk" +) + +// This file reads the registry of the account that opens the CLIENT SCREEN, and answers +// the two questions that hang on it: does the session open on its own after a power cut, +// and is the browser of that session held on the application. Both are about somebody +// else's hive — the technician typing `openscale doctor` is logged on as a different +// account — which is what makes them the two hardest questions of the diagnosis and why +// their parsers live together. + +// winlogonKey is where Windows keeps the automatic logon, and where §15.2 step 3 writes +// it. +const winlogonKey = `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon` + +// AutoLogon reports the three conditions of bloquant-7. +func (m hostMachine) AutoLogon(ctx context.Context) (AutoLogonState, error) { + switch runtime.GOOS { + case "windows": + enabled, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "AutoAdminLogon") + account, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "DefaultUserName") + taskXML, _ := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask, "/xml", "ONE") + state := parseAutoLogon(enabled, account) + state.Expected = parseTaskUserID(taskXML) + return state, nil + case "linux": + // The Linux equivalent named by §14.4: both units enabled. There is no session to + // open on a station running cage — the kiosk unit IS the session. + serviceEnabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxServiceUnit) + kioskEnabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxKioskUnit) + return parseLinuxUnattendedRestart(serviceEnabled, kioskEnabled), nil + } + return AutoLogonState{}, nil +} + +// parseAutoLogon reads the two `reg query` outputs. +// +// It matches on the value TYPE — REG_SZ, REG_DWORD — which reg.exe never localises, and +// takes the token after it. The alternative, matching the value name, breaks the day +// somebody has a DefaultUserName under a differently-cased key. +func parseAutoLogon(enabled, account string) AutoLogonState { + state := AutoLogonState{} + value, found := registryValue(enabled, "AutoAdminLogon") + if !found { + // The key exists on every Windows; a query that returns nothing at all means the + // query itself did not run — no reg.exe, or no rights. + state.Detail = "la valeur AutoAdminLogon n'a pas pu être lue." + return state + } + state.Determined = true + state.Enabled = value == "1" + state.Detail = "AutoAdminLogon = " + or(value, "(vide)") + "." + if name, ok := registryValue(account, "DefaultUserName"); ok { + state.Account = name + } + return state +} + +// registryValue extracts the data of one value from `reg query` output. +// +// The line looks like « AutoAdminLogon REG_SZ 1 ». A value whose data is EMPTY +// prints nothing after the type, and that is a legitimate reading: found is true, the data +// is empty, and « AutoAdminLogon vide » is not « AutoAdminLogon = 1 ». +func registryValue(output, name string) (string, bool) { + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 || !strings.EqualFold(fields[0], name) { + continue + } + if !strings.HasPrefix(fields[1], "REG_") { + continue + } + return strings.Join(fields[2:], " "), true + } + return "", false +} + +// parseTaskUserID extracts the OF THE PRINCIPAL from the XML of the kiosk task. +// +// The XML is the one output of schtasks that is NOT localised, which is why the account is +// read from there and not from the `/fo LIST /v` listing. Knowing which account the kiosk +// runs as is what turns « l'ouverture de session est active » into « elle est active POUR +// LE BON COMPTE » — an autologon onto another account leaves the client screen closed just +// as surely as no autologon at all. +// +// ★ THE PRINCIPAL, NOT THE FIRST OF THE DOCUMENT. openscale-kiosk.xml carries two: +// the trigger's, which says WHICH LOGON wakes the task, and the principal's, which says +// UNDER WHICH ACCOUNT it runs. Only the second answers the question this control asks — and +// the scheduler normalises the first to a SID when it registers the task, so reading it +// compared a SID against DefaultUserName and accused a healthy station. +func parseTaskUserID(xml string) string { + const openTag, closeTag = "", "" + if i := strings.Index(xml, ""); i >= 0 { + xml = xml[i:] + } + start := strings.Index(xml, openTag) + if start < 0 { + return "" + } + rest := xml[start+len(openTag):] + end := strings.Index(rest, closeTag) + if end < 0 { + return "" + } + // A task XML spells the account as DOMAIN\user or as COMPUTER\user; the registry + // spells DefaultUserName without the domain, and comparing the two forms would report + // a mismatch that does not exist. + account := strings.TrimSpace(rest[:end]) + if i := strings.LastIndex(account, `\`); i >= 0 { + account = account[i+1:] + } + // A principal normalised to a SID is not comparable to DefaultUserName, which is + // spelled « openscale ». Nothing is known then, and NOT KNOWING IS THE ANSWER: doctor + // guards its mismatch branch with Expected != "", so an empty result reports the + // unattended restart on the strength of AutoAdminLogon alone rather than accusing a + // station of running the kiosk under an account nobody can name. + if strings.HasPrefix(account, "S-1-") { + return "" + } + return account +} + +// parseLinuxUnattendedRestart is §14.4's Linux equivalent: both units enabled. +func parseLinuxUnattendedRestart(service, kiosk string) AutoLogonState { + serviceWord, kioskWord := firstLine(service), firstLine(kiosk) + state := AutoLogonState{Detail: fmt.Sprintf("%s : %s ; %s : %s", + linuxServiceUnit, or(serviceWord, "?"), linuxKioskUnit, or(kioskWord, "?"))} + if serviceWord == "" && kioskWord == "" { + return state + } + state.Determined = true + state.Enabled = serviceWord == "enabled" && kioskWord == "enabled" + return state +} + +// --- The navigation lock ---------------------------------------------------- + +// profileListKey is where Windows records which SID owns which profile directory, and it +// is the only way from « openscale » to « S-1-5-21-…-1001 » that does not need a Windows +// API call this package has no other reason to make. +const profileListKey = `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList` + +// NavigationLock reports whether the browser of the station account is held on the client +// screen. +// +// ★ IT READS ANOTHER ACCOUNT'S HIVE, AND THAT IS THE WHOLE DIFFICULTY. The policies are +// posed by the kiosk under HKEY_CURRENT_USER — its own, which is the station account's — +// and `openscale doctor` is typed by a technician logged on as somebody else. Reading the +// caller's own HKCU would report on the technician's browser and call a wide-open station +// green, which is exactly the mistake the kiosk task's principal cost once already. +// +// The hive of an account that is not logged on is NOT mounted, and nothing here loads it: +// mounting another user's registry from a diagnosis would be a write on a machine somebody +// asked a question of. Not knowing is then the answer, and it carries the gesture that +// resolves it. +func (m hostMachine) NavigationLock(ctx context.Context) (NavigationLockState, error) { + if runtime.GOOS != "windows" { + // §15.3 poses the Linux policy as a root-owned file, from install.sh. There is no + // per-account hive to read, and inventing a requirement here would be worse than + // saying nothing. + return NavigationLockState{}, nil + } + state := NavigationLockState{Applicable: true} + state.Account = m.kioskAccount(ctx) + if state.Account == "" { + state.Detail = "le compte qui ouvre l'écran client n'a pas pu être nommé." + return state, nil + } + + profiles, _ := m.run.Run(ctx, "reg.exe", "query", profileListKey, "/s", "/v", "ProfileImagePath") + sid := profileSID(profiles, state.Account) + if sid == "" { + state.Detail = "aucun profil Windows au nom de « " + state.Account + + " » : ce compte n'a encore jamais ouvert de session sur ce poste." + return state, nil + } + + for _, vendor := range kiosk.PolicyVendors { + output, _ := m.run.Run(ctx, "reg.exe", "query", + `HKU\`+sid+`\`+vendor.Root+`\URLBlocklist`, "/v", "1") + value, found := registryValue(output, "1") + if !found { + continue + } + state.Determined, state.Browser = true, vendor.Label + state.Locked = value == "*" + state.Detail = vendor.Label + " : URLBlocklist = " + or(value, "(vide)") + "." + return state, nil + } + state.Detail = "aucune stratégie de navigation sous le compte « " + state.Account + + " » — soit le kiosque ne les a jamais posées, soit sa session n'est pas ouverte " + + "et sa ruche n'est pas montée." + return state, nil +} + +// kioskAccount names the account the client screen runs under. +// +// The task's principal first, because it is what ACTUALLY runs the kiosk; DefaultUserName +// second, because a station whose task was registered with a SID leaves the principal +// unreadable (parseTaskUserID) and the autologon still names the account §15.2 installed. +func (m hostMachine) kioskAccount(ctx context.Context) string { + taskXML, _ := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask, "/xml", "ONE") + if account := parseTaskUserID(taskXML); account != "" { + return account + } + output, _ := m.run.Run(ctx, "reg.exe", "query", winlogonKey, "/v", "DefaultUserName") + account, _ := registryValue(output, "DefaultUserName") + return account +} + +// profileSID finds the SID whose profile directory carries this account name. +// +// The listing pairs a key line — which ENDS with the SID — with the ProfileImagePath +// underneath it, so the SID is remembered until a path answers. Matching on the last +// segment of the path and not on the whole of it is what survives a station whose profiles +// are not under C:\Users. +func profileSID(output, account string) string { + sid := "" + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "HKEY_") { + sid = trimmed[strings.LastIndex(trimmed, `\`)+1:] + continue + } + path, found := registryValue(trimmed, "ProfileImagePath") + if !found || sid == "" { + continue + } + if strings.EqualFold(path[strings.LastIndex(path, `\`)+1:], account) { + return sid + } + } + return "" +} diff --git a/internal/diag/probes_account_test.go b/internal/diag/probes_account_test.go new file mode 100644 index 0000000..9107bb6 --- /dev/null +++ b/internal/diag/probes_account_test.go @@ -0,0 +1,185 @@ +package diag + +import ( + "strings" + "testing" +) + +// The tests of probes_account.go: the hive of the account that opens the client screen. Two +// traps are held here permanently — the principal of the task is not its trigger, and a SID +// is not an account name — because each of them has already accused a healthy station. + +// --- The unattended restart ------------------------------------------------- + +func TestAutoLogonIsReadFromTheValueTypeAndNotFromTheLabel(t *testing.T) { + enabled := ` +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon + AutoAdminLogon REG_SZ 1 +` + account := ` +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon + DefaultUserName REG_SZ openscale +` + state := parseAutoLogon(enabled, account) + if !state.Determined || !state.Enabled { + t.Fatalf("AutoAdminLogon = 1 mal lu : %+v", state) + } + if state.Account != "openscale" { + t.Errorf("compte lu %q, attendu openscale", state.Account) + } +} + +func TestAutoLogonSetToZeroIsNotConfigured(t *testing.T) { + state := parseAutoLogon(" AutoAdminLogon REG_SZ 0\n", "") + if !state.Determined { + t.Fatal("la valeur a été lue : la question a bien été posée") + } + if state.Enabled { + t.Error("AutoAdminLogon = 0 lu comme configuré") + } + if !strings.Contains(state.Detail, "0") { + t.Errorf("le détail doit citer la valeur lue : %q", state.Detail) + } +} + +func TestAnEmptyAutoLogonValueIsReadAsEmptyAndNotAsAbsent(t *testing.T) { + // reg.exe prints nothing after the type when the data is empty. « AutoAdminLogon vide » + // is not « AutoAdminLogon introuvable », and the two have different remedies. + value, found := registryValue(" AutoAdminLogon REG_SZ\n", "AutoAdminLogon") + if !found { + t.Fatal("une valeur vide a bien été trouvée") + } + if value != "" { + t.Errorf("valeur %q, attendue vide", value) + } +} + +func TestAQueryThatReturnedNothingIsNotAnAnswer(t *testing.T) { + state := parseAutoLogon("", "") + if state.Determined { + t.Fatal("la clé Winlogon existe sur tout Windows : une réponse vide signifie que la " + + "requête n'a pas tourné, pas que l'ouverture de session n'est pas configurée") + } +} + +func TestTheKioskAccountIsReadFromTheTaskXMLBecauseItIsTheOnlyPartNotLocalised(t *testing.T) { + xml := ` + + + + PESEE-2\openscale + InteractiveToken + + +` + // The domain prefix is dropped: the registry spells DefaultUserName without it, and + // comparing the two forms would report a mismatch that does not exist. + if account := parseTaskUserID(xml); account != "openscale" { + t.Errorf("compte du kiosque lu %q, attendu openscale", account) + } + if account := parseTaskUserID("la tâche n'existe pas"); account != "" { + t.Errorf("aucune tâche : compte %q, attendu vide", account) + } +} + +func TestTheKioskAccountIsReadFromThePrincipalAndNotFromTheTrigger(t *testing.T) { + // The XML Windows hands back is NOT the one install.ps1 wrote: the scheduler + // normalises the trigger's UserId to a SID. Reading the FIRST of the document + // therefore read the trigger — a SID that can never equal « openscale » — and doctor + // accused a healthy station of opening its session onto the wrong account. Observed on + // the station, 31/07/2026. + // + // The is the one that answers the question the control asks: it says under + // which account the task RUNS. The trigger only says which logon wakes it. + xml := ` + + + + true + S-1-5-21-1004336348-1177238915-682003330-1001 + PT5S + + + + + PESEE-2\openscale + InteractiveToken + + +` + if account := parseTaskUserID(xml); account != "openscale" { + t.Errorf("compte du kiosque lu %q, attendu openscale", account) + } +} + +func TestASIDIsNotAnAccountNameAndIsNotComparedToOne(t *testing.T) { + // The scheduler may normalise the PRINCIPAL to a SID too. There is nothing to compare + // then: DefaultUserName is spelled « openscale », and a SID is never equal to it. The + // honest answer is « je ne sais pas », which doctor already handles — its mismatch + // branch is guarded by Expected != "". Answering the SID instead turns an unknown into + // an accusation, which is the defect this whole change removes. + xml := ` + + + + S-1-5-21-1004336348-1177238915-682003330-1001 + InteractiveToken + + +` + if account := parseTaskUserID(xml); account != "" { + t.Errorf("compte du kiosque lu %q, attendu vide", account) + } +} + +func TestLinuxUnattendedRestartDemandsBothUnits(t *testing.T) { + state := parseLinuxUnattendedRestart("enabled\n", "enabled\n") + if !state.Determined || !state.Enabled { + t.Fatalf("les deux unités activées : %+v", state) + } + // The service alone is not enough: it weighs, and nothing opens the client screen. + state = parseLinuxUnattendedRestart("enabled\n", "disabled\n") + if state.Enabled { + t.Error("le service seul ne ramène pas le poste sur l'écran client") + } + if !strings.Contains(state.Detail, linuxKioskUnit) { + t.Errorf("le détail doit nommer l'unité fautive : %q", state.Detail) + } +} + +// TestTheProfileOfTheStationAccountIsFoundByItsDirectory : c'est le seul chemin de +// « openscale » vers « S-1-5-21-…-1001 » qui ne demande pas un appel Windows que ce paquet +// n'a aucune autre raison de faire — et c'est ce SID qui dit sous quelle ruche relire les +// stratégies du kiosque. +func TestTheProfileOfTheStationAccountIsFoundByItsDirectory(t *testing.T) { + const listing = ` +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-18 + ProfileImagePath REG_EXPAND_SZ %systemroot%\system32\config\systemprofile + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-11-22-33-1001 + ProfileImagePath REG_EXPAND_SZ C:\Users\Fab + +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-11-22-33-1004 + ProfileImagePath REG_EXPAND_SZ C:\Users\openscale +` + if sid := profileSID(listing, "openscale"); sid != "S-1-5-21-11-22-33-1004" { + t.Fatalf("SID du compte du poste = %q", sid) + } + // Lire le profil d'un autre compte, c'est rendre vert un poste grand ouvert : la + // stratégie du technicien n'est pas celle du poste. + if sid := profileSID(listing, "Fab"); sid != "S-1-5-21-11-22-33-1001" { + t.Fatalf("SID d'un autre compte = %q", sid) + } +} + +// TestAnAccountWithNoProfileYieldsNoSID : un compte créé et jamais ouvert n'a pas de +// profil. Deviner un SID à ce moment-là ferait relire la ruche de quelqu'un d'autre. +func TestAnAccountWithNoProfileYieldsNoSID(t *testing.T) { + const listing = ` +HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-18 + ProfileImagePath REG_EXPAND_SZ %systemroot%\system32\config\systemprofile +` + if sid := profileSID(listing, "openscale"); sid != "" { + t.Fatalf("SID %q inventé pour un compte sans profil", sid) + } +} diff --git a/internal/diag/probes_power.go b/internal/diag/probes_power.go new file mode 100644 index 0000000..b11f591 --- /dev/null +++ b/internal/diag/probes_power.go @@ -0,0 +1,145 @@ +package diag + +import ( + "context" + "fmt" + "runtime" + "strconv" + "strings" +) + +// This file reads what the machine does with its own power: the settings §15.2 step 5 +// turns off, and the right to restart the computer at all. They belong together because +// both are invisible until the morning they cost something — a USB adapter suspended out +// from under the scale, or a « Redémarrer l'ordinateur » button that answers « accès +// refusé » to a volunteer standing in front of a frozen kiosk. + +// The two GUIDs of §15.2, step 5, copied from install.ps1. +// +// They are NOT derived, NOT guessed and NOT looked up: the document contains the exact +// line `powercfg /setacvalueindex SCHEME_CURRENT 2a737441-… 48e6b7a6-… 0`, and these are +// its two arguments. The subgroup is the USB settings, the setting is the selective +// suspend — which §15.2 says causes half the « la balance ne répond plus » on a USB-serial +// adapter. +const ( + usbSubgroupGUID = "2a737441-1930-4402-8d77-b2bebba308a3" + usbSuspendGUID = "48e6b7a6-50f5-4782-a5d4-53bb8f07e226" +) + +// powerSetting is one setting §15.2 step 5 turns off, with the sentence that names it. +type powerSetting struct { + // subgroup and setting are powercfg arguments: either its own documented aliases, or + // the two GUIDs §15.2 spells out. + subgroup string + setting string + // label is FRENCH and names the setting the way a volunteer would recognise it in the + // power plan window. + label string +} + +// sleepSettings are the three timeouts §15.2 sets to zero with `powercfg /change`. +// +// The arguments are powercfg's OWN aliases and not GUIDs: SUB_SLEEP, STANDBYIDLE, +// SUB_VIDEO, VIDEOIDLE and HIBERNATEIDLE are names the tool accepts and prints, so nothing +// here is a number this project had to find somewhere. +var sleepSettings = []powerSetting{ + {"SUB_SLEEP", "STANDBYIDLE", "mise en veille"}, + {"SUB_SLEEP", "HIBERNATEIDLE", "mise en veille prolongée"}, + {"SUB_VIDEO", "VIDEOIDLE", "extinction de l'écran"}, +} + +// Power reports the sleep and USB selective suspend settings. +func (m hostMachine) Power(ctx context.Context) (PowerState, error) { + if runtime.GOOS != "windows" { + // §15.3 installs cage, seatd and udev rules, and writes no power setting at all. + // Reporting a verdict about a Linux power plan the installer never touches would + // be inventing a requirement. + return PowerState{Applicable: false}, nil + } + state := PowerState{Applicable: true, Determined: true, + SleepDisabled: true, USBSelectiveSuspendDisabled: true} + var awake []string + + for _, setting := range append(sleepSettings, + powerSetting{usbSubgroupGUID, usbSuspendGUID, "suspension USB sélective"}) { + out, err := m.run.Run(ctx, "powercfg.exe", "/query", "SCHEME_CURRENT", setting.subgroup, setting.setting) + value, ok := parsePowerIndex(out) + if err != nil || !ok { + state.Determined = false + state.Detail = fmt.Sprintf("le réglage « %s » n'a pas pu être lu", setting.label) + return state, nil + } + if value == 0 { + continue + } + awake = append(awake, fmt.Sprintf("%s : %d", setting.label, value)) + if setting.setting == usbSuspendGUID { + state.USBSelectiveSuspendDisabled = false + continue + } + state.SleepDisabled = false + } + if len(awake) > 0 { + state.Detail = "Réglages encore actifs sur secteur — " + strings.Join(awake, " · ") + "." + } + return state, nil +} + +// parsePowerIndex reads the ON-MAINS setting index out of `powercfg /query` output. +// +// # Why it reads no label +// +// powercfg localises every label it prints — « Current AC Power Setting Index » becomes +// « Index du paramètre d'alimentation sur secteur actuel » on a French Windows — so a parser +// that matched on words would work on the developer's machine and fail on every station in +// the shop. What is NOT localised is the shape: the values are hexadecimal, spelled 0x…, and +// the two CURRENT indices are the last two lines of the block, mains first. +// +// # Why the last two and not the first +// +// A range setting (the sleep timeouts) prints its bounds first — minimum, maximum, +// increment — and only then the two current indices; an enumerated setting (the USB +// selective suspend) prints its possible values with UNPREFIXED indices, so they are not +// picked up at all. Taking the first 0x value would read the minimum of a range and report +// every station's sleep timeout as zero, which is the wrong answer in the dangerous +// direction: it would announce « veille désactivée » on a station that falls asleep. +// +// A block with a single value is a setting that has no battery variant, and that value is +// the mains one. +func parsePowerIndex(output string) (uint64, bool) { + var values []uint64 + for _, line := range strings.Split(output, "\n") { + for _, field := range strings.Fields(line) { + hex, found := strings.CutPrefix(strings.ToLower(field), "0x") + if !found { + continue + } + value, err := strconv.ParseUint(hex, 16, 64) + if err != nil { + continue + } + values = append(values, value) + } + } + switch len(values) { + case 0: + return 0, false + case 1: + return values[0], true + } + return values[len(values)-2], true +} + +// RebootPermission reports whether this station may restart the machine. +// +// The three answers are three platforms, and the middle one is why this question exists: +// under Linux the service runs as `openscale` and polkit stands between it and the right, +// so a station missing its rule works perfectly — right up to the evening a volunteer is +// facing a frozen kiosk and touches the one button that would have saved them. +func (hostMachine) RebootPermission(context.Context) (RebootPermissionState, error) { + allowed, detail := rebootPermission() + if detail == "" { + return RebootPermissionState{Applicable: false}, nil + } + return RebootPermissionState{Allowed: allowed, Detail: detail, Applicable: true}, nil +} diff --git a/internal/diag/probes_power_test.go b/internal/diag/probes_power_test.go new file mode 100644 index 0000000..2db7e6c --- /dev/null +++ b/internal/diag/probes_power_test.go @@ -0,0 +1,97 @@ +package diag + +import ( + "context" + "errors" + "runtime" + "testing" +) + +// The tests of probes_power.go: powercfg localises every one of its labels, so the index is +// read off the SHAPE — hexadecimal values, the last two of the block, mains first. Reading +// the first one would report every station as « veille désactivée », in the dangerous +// direction. + +// --- The power plan --------------------------------------------------------- + +func TestThePowerIndexIsReadFromARangeSettingWithoutBeingFooledByItsBounds(t *testing.T) { + // Real `powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE` output, French Windows. + // The bounds are printed FIRST: a parser that took the first 0x value would read the + // minimum and announce « veille désactivée » on a station that falls asleep. + output := ` +GUID du mode de gestion de l'alimentation : 381b4222-f694-41f0-9685-ff5bb260df2e (Équilibré) + GUID de sous-groupe d'alimentation : 238c9fa8-0aad-41ed-83f4-97be242c8f20 (Mise en veille) + GUID de paramètre d'alimentation : 29f6c1db-86da-48c5-9fdb-f2b67b1f44da (Mettre en veille après) + Valeur minimale possible : 0x00000000 + Valeur maximale possible : 0xffffffff + Incrément possible : 0x00000001 + Unités possibles : Secondes + Index du paramètre d'alimentation sur secteur actuel : 0x00000384 + Index du paramètre d'alimentation sur batterie actuel : 0x000000f0 +` + value, ok := parsePowerIndex(output) + if !ok { + t.Fatal("la sortie porte bien un index sur secteur") + } + if value != 0x384 { + t.Errorf("index sur secteur lu %#x, attendu 0x384 — les bornes de la plage ont été prises "+ + "pour la valeur courante", value) + } +} + +func TestThePowerIndexIsReadFromAnEnumeratedSetting(t *testing.T) { + // The USB selective suspend, whose possible values are printed with UNPREFIXED indices + // and are therefore not picked up at all. + output := ` + GUID de sous-groupe d'alimentation : ` + usbSubgroupGUID + ` (Paramètres USB) + GUID de paramètre d'alimentation : ` + usbSuspendGUID + ` (Paramètre de la suspension sélective USB) + Index du paramètre possible : 000 + Nom convivial du paramètre possible : Désactivé + Index du paramètre possible : 001 + Nom convivial du paramètre possible : Activé + Index du paramètre d'alimentation sur secteur actuel : 0x00000001 + Index du paramètre d'alimentation sur batterie actuel : 0x00000001 +` + value, ok := parsePowerIndex(output) + if !ok || value != 1 { + t.Fatalf("suspension USB active lue %#x / %v", value, ok) + } +} + +func TestASettingWithNoHexadecimalValueIsNotRead(t *testing.T) { + if _, ok := parsePowerIndex("Le nom de paramètre spécifié est introuvable."); ok { + t.Fatal("une sortie sans index ne doit pas rendre une valeur : ce serait un chiffre que " + + "personne n'a mesuré") + } +} + +// refusingRunner is a runner on which every command fails, which is what a machine without +// sc.exe, systemctl or powercfg looks like. +type refusingRunner struct{} + +func (refusingRunner) Run(context.Context, string, ...string) (string, error) { + return "", errors.New("commande introuvable") +} + +func TestThePowerControlIsSkippedWhereTheInstallerWritesNoPowerSetting(t *testing.T) { + state, err := newMachineWith(refusingRunner{}).Power(context.Background()) + if err != nil { + t.Fatalf("lecture des réglages d'énergie : %v", err) + } + if runtime.GOOS != "windows" { + // §15.3 installs cage, seatd and udev rules, and writes no power setting at all. + if state.Applicable { + t.Error("§15.3 n'écrit aucun réglage d'énergie : inventer une exigence serait pire " + + "que ne rien dire") + } + return + } + // On Windows the question APPLIES and the command failed, so the honest answer is + // « applicable, et non établi » — never « tout est désactivé ». + if !state.Applicable { + t.Error("§15.2 écrit ces réglages : la question s'applique sous Windows") + } + if state.Determined { + t.Error("powercfg a échoué : le verdict ne peut pas être établi") + } +} diff --git a/internal/diag/probes_service_manager.go b/internal/diag/probes_service_manager.go new file mode 100644 index 0000000..896646f --- /dev/null +++ b/internal/diag/probes_service_manager.go @@ -0,0 +1,179 @@ +package diag + +import ( + "context" + "fmt" + "runtime" + "strings" +) + +// This file asks the service manager the two questions §15.2 and §15.3 install an answer +// to: is `openscale serve` declared and running, and is the kiosk task or unit there. The +// invocations are three lines each; the rest is the parsing, which is where every verdict +// is actually decided and which needs no Windows to be tested. + +// Service reports what the service manager says about `openscale serve`. +func (m hostMachine) Service(ctx context.Context) (ServiceState, error) { + switch runtime.GOOS { + case "windows": + query, _ := m.run.Run(ctx, "sc.exe", "query", windowsServiceName) + config, _ := m.run.Run(ctx, "sc.exe", "qc", windowsServiceName) + return parseWindowsService(windowsServiceName, query, config), nil + case "linux": + active, _ := m.run.Run(ctx, "systemctl", "is-active", linuxServiceUnit) + enabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxServiceUnit) + return parseSystemdUnit(linuxServiceUnit, active, enabled), nil + } + return ServiceState{Name: windowsServiceName}, nil +} + +// KioskTask reports the scheduled task of §15.2 or the kiosk unit of §15.3. +func (m hostMachine) KioskTask(ctx context.Context) (ServiceState, error) { + switch runtime.GOOS { + case "windows": + out, err := m.run.Run(ctx, "schtasks.exe", "/query", "/tn", windowsKioskTask) + return kioskTaskState(windowsKioskTask, out, err != nil, sessionIsElevated()) + case "linux": + active, _ := m.run.Run(ctx, "systemctl", "is-active", linuxKioskUnit) + enabled, _ := m.run.Run(ctx, "systemctl", "is-enabled", linuxKioskUnit) + return parseSystemdUnit(linuxKioskUnit, active, enabled), nil + } + return ServiceState{Name: windowsKioskTask}, nil +} + +// kioskTaskState reads what schtasks answered, knowing whether we had the RIGHT to look. +// +// schtasks exits 1 for « Erreur : Accès refusé. » as well as for « Erreur : Le fichier +// spécifié est introuvable. », and both messages are localised: neither the code nor the +// text separates the two. MEASURED on Windows 10, where the folder of tasks is unreadable +// without elevation — so `openscale doctor` run from an ordinary prompt, which is how a +// volunteer runs it, announced « aucune tâche OpenScale-Kiosk n'est déclarée » about a +// station whose task was there, and told them to reinstall it. parseWindowsService refuses +// that exact mistake a few lines below; this is what makes the kiosk check refuse it too. +// +// Elevated, a failure IS an answer: we could look, and it is not there. Otherwise the +// honest answer is « je ne sais pas », returned as an error so the caller asks for an +// elevated prompt instead of handing out a remedy. +func kioskTaskState(name, out string, failed, elevated bool) (ServiceState, error) { + if !failed { + return ServiceState{Name: name, Known: true, Determined: true, Detail: firstLine(out)}, nil + } + if !elevated { + return ServiceState{Name: name}, fmt.Errorf( + "le dossier des tâches n'est pas lisible sans élévation, et schtasks ne distingue "+ + "pas « absente » de « accès refusé » : %s", firstLine(out)) + } + return ServiceState{Name: name, Determined: true, Detail: firstLine(out)}, nil +} + +// parseWindowsService reads the output of `sc query` and `sc qc`. +// +// It matches on the ENGLISH tokens of the two lines, and only on them: STATE, RUNNING, +// START_TYPE, AUTO_START. Everything else sc.exe prints is localised and arrives in the +// console code page, which is exactly why no verdict may rest on it. Error 1060 is +// « the specified service does not exist », and it is the one failure that is a different +// remedy from « installed but stopped ». +func parseWindowsService(name, query, config string) ServiceState { + state := ServiceState{Name: name, Determined: true} + if strings.Contains(query, "1060") { + return state + } + word := tokenAfter(query, "STATE") + if word == "" { + // sc.exe answered something this parser does not recognise. Claiming the service + // is absent would send somebody reinstalling it; claiming it runs would be worse. + state.Determined = false + state.Detail = firstLine(query) + return state + } + state.Known = true + state.Running = word == "RUNNING" + state.Detail = word + if startType := startTypeOf(config); startType != "" { + state.Automatic = strings.HasPrefix(startType, "AUTO_START") + state.Detail = word + ", " + startType + } + return state +} + +// startTypeOf returns the WORDS of the START_TYPE line of `sc qc`, without the code. +// +// tokenAfter cannot be used here, and this function exists because it was. tokenAfter +// returns the LAST word of the line — right for « STATE : 4 RUNNING », wrong for +// « START_TYPE : 2 AUTO_START (DELAYED) », whose last word is « (DELAYED) ». The +// prefix test below then read false, and doctor warned that the service was not automatic. +// +// It is not a rare shape: internal/platform/service_windows.go sets DelayedAutoStart ON +// PURPOSE, so that the disks, the network stack and the print spooler come up first. Every +// station installed with « --start auto » was therefore told to run +// « sc config OpenScale start= auto » — which is exactly what it already was. +// +// The numeric code is dropped for the reason tokenAfter gives: the word is the same in +// every locale, the number is one lookup table away from being wrong. +func startTypeOf(config string) string { + for _, line := range strings.Split(config, "\n") { + if !strings.Contains(line, "START_TYPE") { + continue + } + _, value, found := strings.Cut(line, ":") + if !found { + continue + } + words := make([]string, 0, 2) + for _, field := range strings.Fields(value) { + if field[0] >= '0' && field[0] <= '9' { + continue + } + words = append(words, field) + } + return strings.Join(words, " ") + } + return "" +} + +// tokenAfter returns the last word of the first line that carries key. +// +// `sc query` prints « STATE : 4 RUNNING », so the token that carries the +// meaning is the last one on the line. The numeric code is deliberately ignored: the word +// is the same in every locale and the number is one lookup table away from being wrong. +// +// It is NOT usable on START_TYPE, whose line can end on a parenthesis — see startTypeOf. +func tokenAfter(output, key string) string { + for _, line := range strings.Split(output, "\n") { + if !strings.Contains(line, key) { + continue + } + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 { + continue + } + last := fields[len(fields)-1] + if last == ":" || last == key { + return "" + } + return last + } + return "" +} + +// parseSystemdUnit reads `systemctl is-active` and `systemctl is-enabled`. +// +// systemctl exits non-zero for « inactive » and for « disabled », which are perfectly +// ordinary answers, so the exit code carries nothing and the WORD carries everything. +// « not-found » is what an unknown unit answers, and it is the only case that means the +// unit was never installed. +func parseSystemdUnit(name, active, enabled string) ServiceState { + activeWord, enabledWord := firstLine(active), firstLine(enabled) + state := ServiceState{Name: name, Determined: true, Detail: activeWord + ", " + enabledWord} + if activeWord == "" && enabledWord == "" { + state.Determined = false + return state + } + if enabledWord == "not-found" || activeWord == "not-found" { + return state + } + state.Known = true + state.Running = activeWord == "active" + state.Automatic = enabledWord == "enabled" || enabledWord == "enabled-runtime" + return state +} diff --git a/internal/diag/probes_service_manager_test.go b/internal/diag/probes_service_manager_test.go new file mode 100644 index 0000000..51f8af1 --- /dev/null +++ b/internal/diag/probes_service_manager_test.go @@ -0,0 +1,169 @@ +package diag + +import ( + "strings" + "testing" +) + +// The tests of probes_service_manager.go: what sc.exe, schtasks.exe and systemctl really +// answer, including on a FRENCH Windows — that is what the shop has, and that is what any +// parser leaning on a label breaks on. + +// --- The service manager ---------------------------------------------------- + +func TestAWindowsServiceIsReadFromTheEnglishTokensOnly(t *testing.T) { + // Real `sc query` output on a French Windows: the labels are English, the surrounding + // prose is not, and the numeric codes are the one thing that must NOT be trusted. + running := ` +SERVICE_NAME: OpenScale + TYPE : 10 WIN32_OWN_PROCESS + STATE : 4 RUNNING + (STOPPABLE, PAUSABLE, ACCEPTS_SHUTDOWN) + WIN32_EXIT_CODE : 0 (0x0) + CHECKPOINT : 0x0 +` + auto := ` +[SC] QueryServiceConfig réussi(e) + +SERVICE_NAME: OpenScale + TYPE : 10 WIN32_OWN_PROCESS + START_TYPE : 2 AUTO_START + BINARY_PATH_NAME : "C:\Program Files\OpenScale\openscale.exe" serve +` + state := parseWindowsService("OpenScale", running, auto) + if !state.Determined || !state.Known || !state.Running || !state.Automatic { + t.Fatalf("service démarré et automatique mal lu : %+v", state) + } + if !strings.Contains(state.Detail, "RUNNING") || !strings.Contains(state.Detail, "AUTO_START") { + t.Errorf("le détail doit reprendre les deux mots verbatim : %q", state.Detail) + } +} + +func TestAStoppedServiceIsKnownAndNotRunning(t *testing.T) { + stopped := " STATE : 1 STOPPED\n" + demand := " START_TYPE : 3 DEMAND_START\n" + + state := parseWindowsService("OpenScale", stopped, demand) + switch { + case !state.Known: + t.Error("un service arrêté est installé : le remède n'est pas de l'installer") + case state.Running: + t.Error("STOPPED lu comme démarré") + case state.Automatic: + t.Error("DEMAND_START lu comme automatique") + } +} + +func TestErrorTenSixtyIsAServiceThatWasNeverInstalled(t *testing.T) { + // The message is localised; the number is not, and it is the one thing that separates + // « installed but stopped » from « never installed » — two different remedies. + absent := `[SC] OpenSevice ÉCHEC 1060 : + +Le service spécifié n'existe pas en tant que service installé. +` + state := parseWindowsService("OpenScale", absent, "") + if !state.Determined { + t.Fatal("un service absent est une réponse, pas une absence de réponse") + } + if state.Known { + t.Error("l'erreur 1060 signifie que le service n'existe pas") + } +} + +// TestADelayedAutomaticStartIsStillAutomatic covers the shape the product's OWN installer +// produces, and the one this corpus was missing. +// +// internal/platform/service_windows.go sets DelayedAutoStart on purpose, so that the disks, +// the network stack and the print spooler come up first. `sc qc` then prints +// « START_TYPE : 2 AUTO_START (DELAYED) », whose LAST word is « (DELAYED) » — and a +// parser reading the last word declared the service not automatic. Every station installed +// with « --start auto » was warned to set the very setting it already had. The two lines +// below are copied from a station installed by install.ps1. +func TestADelayedAutomaticStartIsStillAutomatic(t *testing.T) { + running := " STATE : 4 RUNNING \n" + delayed := " START_TYPE : 2 AUTO_START (DELAYED)\n" + + state := parseWindowsService("OpenScale", running, delayed) + if !state.Automatic { + t.Error("AUTO_START (DELAYED) lu comme non automatique : ce poste redémarre pourtant seul") + } + if state.Detail != "RUNNING, AUTO_START (DELAYED)" { + t.Errorf("le détail doit porter les deux mots, pas la parenthèse seule : %q", state.Detail) + } +} + +// TestAnUnreadableTaskFolderIsNotAnAbsentTask is what v0.1 got wrong on a real station. +// +// schtasks exits 1 for « Erreur : Accès refusé. » as well as for « Erreur : Le fichier +// spécifié est introuvable. », and both messages are localised. Unelevated — which is how a +// volunteer runs `openscale doctor` — the two cannot be told apart, and answering +// « absente » sends somebody reinstalling a station whose task is right there. +func TestAnUnreadableTaskFolderIsNotAnAbsentTask(t *testing.T) { + state, err := kioskTaskState("OpenScale-Kiosk", "Erreur : Accès refusé.\n", true, false) + if err == nil { + t.Fatal("un refus d'accès rendu comme une réponse : doctor conclurait « tâche absente » " + + "et enverrait relancer install.ps1") + } + if state.Determined { + t.Error("l'état se dit déterminé alors que rien n'a pu être lu") + } + + // Elevated, the SAME failure IS an answer: we could look, and it is not there. + state, err = kioskTaskState("OpenScale-Kiosk", + "Erreur : Le fichier spécifié est introuvable.\n", true, true) + if err != nil { + t.Fatalf("en session élevée, un échec de schtasks est une réponse : %v", err) + } + if !state.Determined || state.Known { + t.Errorf("tâche réellement absente mal rendue : %+v", state) + } + + // And a task that answers is simply there, elevation or not. + state, err = kioskTaskState("OpenScale-Kiosk", "Dossier: \\\nOpenScale-Kiosk N/A Prêt\n", false, false) + if err != nil || !state.Determined || !state.Known { + t.Errorf("tâche présente mal rendue : %+v (%v)", state, err) + } +} + +func TestOutputThisParserDoesNotRecogniseIsNotTurnedIntoAVerdict(t *testing.T) { + state := parseWindowsService("OpenScale", "sc.exe n'est pas reconnu comme commande", "") + if state.Determined { + t.Fatal("une sortie incompréhensible doit rendre « je ne sais pas » : annoncer un service " + + "absent enverrait quelqu'un le réinstaller, annoncer qu'il tourne serait pire") + } +} + +func TestASystemdUnitIsReadFromTheWordAndNotFromTheExitCode(t *testing.T) { + // systemctl exits non-zero for « inactive » and for « disabled », which are ordinary + // answers. The word carries everything. + state := parseSystemdUnit(linuxServiceUnit, "active\n", "enabled\n") + if !state.Determined || !state.Known || !state.Running || !state.Automatic { + t.Fatalf("unité active et activée mal lue : %+v", state) + } + + state = parseSystemdUnit(linuxServiceUnit, "inactive\n", "disabled\n") + if !state.Known || state.Running || state.Automatic { + t.Fatalf("unité installée mais arrêtée mal lue : %+v", state) + } + + state = parseSystemdUnit(linuxServiceUnit, "inactive\n", "not-found\n") + if state.Known { + t.Error("« not-found » est la seule réponse qui signifie que l'unité n'a jamais été installée") + } + + if state := parseSystemdUnit(linuxServiceUnit, "", ""); state.Determined { + t.Error("systemctl muet n'est pas une unité absente : c'est une question qui n'a pas été posée") + } +} + +func TestTheTokenOfALineIsTheLastWordAndNeverTheCode(t *testing.T) { + if got := tokenAfter(" STATE : 4 RUNNING", "STATE"); got != "RUNNING" { + t.Errorf("token %q, attendu RUNNING", got) + } + if got := tokenAfter(" STATE :", "STATE"); got != "" { + t.Errorf("une ligne sans valeur doit rendre vide, obtenu %q", got) + } + if got := tokenAfter("rien à voir", "STATE"); got != "" { + t.Errorf("une sortie sans la clé doit rendre vide, obtenu %q", got) + } +} diff --git a/internal/diag/probes_test.go b/internal/diag/probes_test.go index aab5ccd..85be309 100644 --- a/internal/diag/probes_test.go +++ b/internal/diag/probes_test.go @@ -2,9 +2,6 @@ package diag import ( "context" - "errors" - "runtime" - "strings" "testing" "time" ) @@ -14,381 +11,8 @@ import ( // functions with real output — including a FRENCH Windows, which is what the shop has and // what every locale-dependent parser fails on. -// --- The service manager ---------------------------------------------------- - -func TestAWindowsServiceIsReadFromTheEnglishTokensOnly(t *testing.T) { - // Real `sc query` output on a French Windows: the labels are English, the surrounding - // prose is not, and the numeric codes are the one thing that must NOT be trusted. - running := ` -SERVICE_NAME: OpenScale - TYPE : 10 WIN32_OWN_PROCESS - STATE : 4 RUNNING - (STOPPABLE, PAUSABLE, ACCEPTS_SHUTDOWN) - WIN32_EXIT_CODE : 0 (0x0) - CHECKPOINT : 0x0 -` - auto := ` -[SC] QueryServiceConfig réussi(e) - -SERVICE_NAME: OpenScale - TYPE : 10 WIN32_OWN_PROCESS - START_TYPE : 2 AUTO_START - BINARY_PATH_NAME : "C:\Program Files\OpenScale\openscale.exe" serve -` - state := parseWindowsService("OpenScale", running, auto) - if !state.Determined || !state.Known || !state.Running || !state.Automatic { - t.Fatalf("service démarré et automatique mal lu : %+v", state) - } - if !strings.Contains(state.Detail, "RUNNING") || !strings.Contains(state.Detail, "AUTO_START") { - t.Errorf("le détail doit reprendre les deux mots verbatim : %q", state.Detail) - } -} - -func TestAStoppedServiceIsKnownAndNotRunning(t *testing.T) { - stopped := " STATE : 1 STOPPED\n" - demand := " START_TYPE : 3 DEMAND_START\n" - - state := parseWindowsService("OpenScale", stopped, demand) - switch { - case !state.Known: - t.Error("un service arrêté est installé : le remède n'est pas de l'installer") - case state.Running: - t.Error("STOPPED lu comme démarré") - case state.Automatic: - t.Error("DEMAND_START lu comme automatique") - } -} - -func TestErrorTenSixtyIsAServiceThatWasNeverInstalled(t *testing.T) { - // The message is localised; the number is not, and it is the one thing that separates - // « installed but stopped » from « never installed » — two different remedies. - absent := `[SC] OpenSevice ÉCHEC 1060 : - -Le service spécifié n'existe pas en tant que service installé. -` - state := parseWindowsService("OpenScale", absent, "") - if !state.Determined { - t.Fatal("un service absent est une réponse, pas une absence de réponse") - } - if state.Known { - t.Error("l'erreur 1060 signifie que le service n'existe pas") - } -} - -// TestADelayedAutomaticStartIsStillAutomatic covers the shape the product's OWN installer -// produces, and the one this corpus was missing. -// -// internal/platform/service_windows.go sets DelayedAutoStart on purpose, so that the disks, -// the network stack and the print spooler come up first. `sc qc` then prints -// « START_TYPE : 2 AUTO_START (DELAYED) », whose LAST word is « (DELAYED) » — and a -// parser reading the last word declared the service not automatic. Every station installed -// with « --start auto » was warned to set the very setting it already had. The two lines -// below are copied from a station installed by install.ps1. -func TestADelayedAutomaticStartIsStillAutomatic(t *testing.T) { - running := " STATE : 4 RUNNING \n" - delayed := " START_TYPE : 2 AUTO_START (DELAYED)\n" - - state := parseWindowsService("OpenScale", running, delayed) - if !state.Automatic { - t.Error("AUTO_START (DELAYED) lu comme non automatique : ce poste redémarre pourtant seul") - } - if state.Detail != "RUNNING, AUTO_START (DELAYED)" { - t.Errorf("le détail doit porter les deux mots, pas la parenthèse seule : %q", state.Detail) - } -} - -// TestAnUnreadableTaskFolderIsNotAnAbsentTask is what v0.1 got wrong on a real station. -// -// schtasks exits 1 for « Erreur : Accès refusé. » as well as for « Erreur : Le fichier -// spécifié est introuvable. », and both messages are localised. Unelevated — which is how a -// volunteer runs `openscale doctor` — the two cannot be told apart, and answering -// « absente » sends somebody reinstalling a station whose task is right there. -func TestAnUnreadableTaskFolderIsNotAnAbsentTask(t *testing.T) { - state, err := kioskTaskState("OpenScale-Kiosk", "Erreur : Accès refusé.\n", true, false) - if err == nil { - t.Fatal("un refus d'accès rendu comme une réponse : doctor conclurait « tâche absente » " + - "et enverrait relancer install.ps1") - } - if state.Determined { - t.Error("l'état se dit déterminé alors que rien n'a pu être lu") - } - - // Elevated, the SAME failure IS an answer: we could look, and it is not there. - state, err = kioskTaskState("OpenScale-Kiosk", - "Erreur : Le fichier spécifié est introuvable.\n", true, true) - if err != nil { - t.Fatalf("en session élevée, un échec de schtasks est une réponse : %v", err) - } - if !state.Determined || state.Known { - t.Errorf("tâche réellement absente mal rendue : %+v", state) - } - - // And a task that answers is simply there, elevation or not. - state, err = kioskTaskState("OpenScale-Kiosk", "Dossier: \\\nOpenScale-Kiosk N/A Prêt\n", false, false) - if err != nil || !state.Determined || !state.Known { - t.Errorf("tâche présente mal rendue : %+v (%v)", state, err) - } -} - -func TestOutputThisParserDoesNotRecogniseIsNotTurnedIntoAVerdict(t *testing.T) { - state := parseWindowsService("OpenScale", "sc.exe n'est pas reconnu comme commande", "") - if state.Determined { - t.Fatal("une sortie incompréhensible doit rendre « je ne sais pas » : annoncer un service " + - "absent enverrait quelqu'un le réinstaller, annoncer qu'il tourne serait pire") - } -} - -func TestASystemdUnitIsReadFromTheWordAndNotFromTheExitCode(t *testing.T) { - // systemctl exits non-zero for « inactive » and for « disabled », which are ordinary - // answers. The word carries everything. - state := parseSystemdUnit(linuxServiceUnit, "active\n", "enabled\n") - if !state.Determined || !state.Known || !state.Running || !state.Automatic { - t.Fatalf("unité active et activée mal lue : %+v", state) - } - - state = parseSystemdUnit(linuxServiceUnit, "inactive\n", "disabled\n") - if !state.Known || state.Running || state.Automatic { - t.Fatalf("unité installée mais arrêtée mal lue : %+v", state) - } - - state = parseSystemdUnit(linuxServiceUnit, "inactive\n", "not-found\n") - if state.Known { - t.Error("« not-found » est la seule réponse qui signifie que l'unité n'a jamais été installée") - } - - if state := parseSystemdUnit(linuxServiceUnit, "", ""); state.Determined { - t.Error("systemctl muet n'est pas une unité absente : c'est une question qui n'a pas été posée") - } -} - -// --- The unattended restart ------------------------------------------------- - -func TestAutoLogonIsReadFromTheValueTypeAndNotFromTheLabel(t *testing.T) { - enabled := ` -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon - AutoAdminLogon REG_SZ 1 -` - account := ` -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon - DefaultUserName REG_SZ openscale -` - state := parseAutoLogon(enabled, account) - if !state.Determined || !state.Enabled { - t.Fatalf("AutoAdminLogon = 1 mal lu : %+v", state) - } - if state.Account != "openscale" { - t.Errorf("compte lu %q, attendu openscale", state.Account) - } -} - -func TestAutoLogonSetToZeroIsNotConfigured(t *testing.T) { - state := parseAutoLogon(" AutoAdminLogon REG_SZ 0\n", "") - if !state.Determined { - t.Fatal("la valeur a été lue : la question a bien été posée") - } - if state.Enabled { - t.Error("AutoAdminLogon = 0 lu comme configuré") - } - if !strings.Contains(state.Detail, "0") { - t.Errorf("le détail doit citer la valeur lue : %q", state.Detail) - } -} - -func TestAnEmptyAutoLogonValueIsReadAsEmptyAndNotAsAbsent(t *testing.T) { - // reg.exe prints nothing after the type when the data is empty. « AutoAdminLogon vide » - // is not « AutoAdminLogon introuvable », and the two have different remedies. - value, found := registryValue(" AutoAdminLogon REG_SZ\n", "AutoAdminLogon") - if !found { - t.Fatal("une valeur vide a bien été trouvée") - } - if value != "" { - t.Errorf("valeur %q, attendue vide", value) - } -} - -func TestAQueryThatReturnedNothingIsNotAnAnswer(t *testing.T) { - state := parseAutoLogon("", "") - if state.Determined { - t.Fatal("la clé Winlogon existe sur tout Windows : une réponse vide signifie que la " + - "requête n'a pas tourné, pas que l'ouverture de session n'est pas configurée") - } -} - -func TestTheKioskAccountIsReadFromTheTaskXMLBecauseItIsTheOnlyPartNotLocalised(t *testing.T) { - xml := ` - - - - PESEE-2\openscale - InteractiveToken - - -` - // The domain prefix is dropped: the registry spells DefaultUserName without it, and - // comparing the two forms would report a mismatch that does not exist. - if account := parseTaskUserID(xml); account != "openscale" { - t.Errorf("compte du kiosque lu %q, attendu openscale", account) - } - if account := parseTaskUserID("la tâche n'existe pas"); account != "" { - t.Errorf("aucune tâche : compte %q, attendu vide", account) - } -} - -func TestTheKioskAccountIsReadFromThePrincipalAndNotFromTheTrigger(t *testing.T) { - // The XML Windows hands back is NOT the one install.ps1 wrote: the scheduler - // normalises the trigger's UserId to a SID. Reading the FIRST of the document - // therefore read the trigger — a SID that can never equal « openscale » — and doctor - // accused a healthy station of opening its session onto the wrong account. Observed on - // the station, 31/07/2026. - // - // The is the one that answers the question the control asks: it says under - // which account the task RUNS. The trigger only says which logon wakes it. - xml := ` - - - - true - S-1-5-21-1004336348-1177238915-682003330-1001 - PT5S - - - - - PESEE-2\openscale - InteractiveToken - - -` - if account := parseTaskUserID(xml); account != "openscale" { - t.Errorf("compte du kiosque lu %q, attendu openscale", account) - } -} - -func TestASIDIsNotAnAccountNameAndIsNotComparedToOne(t *testing.T) { - // The scheduler may normalise the PRINCIPAL to a SID too. There is nothing to compare - // then: DefaultUserName is spelled « openscale », and a SID is never equal to it. The - // honest answer is « je ne sais pas », which doctor already handles — its mismatch - // branch is guarded by Expected != "". Answering the SID instead turns an unknown into - // an accusation, which is the defect this whole change removes. - xml := ` - - - - S-1-5-21-1004336348-1177238915-682003330-1001 - InteractiveToken - - -` - if account := parseTaskUserID(xml); account != "" { - t.Errorf("compte du kiosque lu %q, attendu vide", account) - } -} - -func TestLinuxUnattendedRestartDemandsBothUnits(t *testing.T) { - state := parseLinuxUnattendedRestart("enabled\n", "enabled\n") - if !state.Determined || !state.Enabled { - t.Fatalf("les deux unités activées : %+v", state) - } - // The service alone is not enough: it weighs, and nothing opens the client screen. - state = parseLinuxUnattendedRestart("enabled\n", "disabled\n") - if state.Enabled { - t.Error("le service seul ne ramène pas le poste sur l'écran client") - } - if !strings.Contains(state.Detail, linuxKioskUnit) { - t.Errorf("le détail doit nommer l'unité fautive : %q", state.Detail) - } -} - -// --- The power plan --------------------------------------------------------- - -func TestThePowerIndexIsReadFromARangeSettingWithoutBeingFooledByItsBounds(t *testing.T) { - // Real `powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE` output, French Windows. - // The bounds are printed FIRST: a parser that took the first 0x value would read the - // minimum and announce « veille désactivée » on a station that falls asleep. - output := ` -GUID du mode de gestion de l'alimentation : 381b4222-f694-41f0-9685-ff5bb260df2e (Équilibré) - GUID de sous-groupe d'alimentation : 238c9fa8-0aad-41ed-83f4-97be242c8f20 (Mise en veille) - GUID de paramètre d'alimentation : 29f6c1db-86da-48c5-9fdb-f2b67b1f44da (Mettre en veille après) - Valeur minimale possible : 0x00000000 - Valeur maximale possible : 0xffffffff - Incrément possible : 0x00000001 - Unités possibles : Secondes - Index du paramètre d'alimentation sur secteur actuel : 0x00000384 - Index du paramètre d'alimentation sur batterie actuel : 0x000000f0 -` - value, ok := parsePowerIndex(output) - if !ok { - t.Fatal("la sortie porte bien un index sur secteur") - } - if value != 0x384 { - t.Errorf("index sur secteur lu %#x, attendu 0x384 — les bornes de la plage ont été prises "+ - "pour la valeur courante", value) - } -} - -func TestThePowerIndexIsReadFromAnEnumeratedSetting(t *testing.T) { - // The USB selective suspend, whose possible values are printed with UNPREFIXED indices - // and are therefore not picked up at all. - output := ` - GUID de sous-groupe d'alimentation : ` + usbSubgroupGUID + ` (Paramètres USB) - GUID de paramètre d'alimentation : ` + usbSuspendGUID + ` (Paramètre de la suspension sélective USB) - Index du paramètre possible : 000 - Nom convivial du paramètre possible : Désactivé - Index du paramètre possible : 001 - Nom convivial du paramètre possible : Activé - Index du paramètre d'alimentation sur secteur actuel : 0x00000001 - Index du paramètre d'alimentation sur batterie actuel : 0x00000001 -` - value, ok := parsePowerIndex(output) - if !ok || value != 1 { - t.Fatalf("suspension USB active lue %#x / %v", value, ok) - } -} - -func TestASettingWithNoHexadecimalValueIsNotRead(t *testing.T) { - if _, ok := parsePowerIndex("Le nom de paramètre spécifié est introuvable."); ok { - t.Fatal("une sortie sans index ne doit pas rendre une valeur : ce serait un chiffre que " + - "personne n'a mesuré") - } -} - -func TestThePowerControlIsSkippedWhereTheInstallerWritesNoPowerSetting(t *testing.T) { - state, err := newMachineWith(refusingRunner{}).Power(context.Background()) - if err != nil { - t.Fatalf("lecture des réglages d'énergie : %v", err) - } - if runtime.GOOS != "windows" { - // §15.3 installs cage, seatd and udev rules, and writes no power setting at all. - if state.Applicable { - t.Error("§15.3 n'écrit aucun réglage d'énergie : inventer une exigence serait pire " + - "que ne rien dire") - } - return - } - // On Windows the question APPLIES and the command failed, so the honest answer is - // « applicable, et non établi » — never « tout est désactivé ». - if !state.Applicable { - t.Error("§15.2 écrit ces réglages : la question s'applique sous Windows") - } - if state.Determined { - t.Error("powercfg a échoué : le verdict ne peut pas être établi") - } -} - // --- Small readers ---------------------------------------------------------- -func TestTheTokenOfALineIsTheLastWordAndNeverTheCode(t *testing.T) { - if got := tokenAfter(" STATE : 4 RUNNING", "STATE"); got != "RUNNING" { - t.Errorf("token %q, attendu RUNNING", got) - } - if got := tokenAfter(" STATE :", "STATE"); got != "" { - t.Errorf("une ligne sans valeur doit rendre vide, obtenu %q", got) - } - if got := tokenAfter("rien à voir", "STATE"); got != "" { - t.Errorf("une sortie sans la clé doit rendre vide, obtenu %q", got) - } -} - func TestTheFirstLineIsTrimmedBecauseWindowsEndsItsLinesWithTwoCharacters(t *testing.T) { if got := firstLine("\r\n active\r\nenabled\r\n"); got != "active" { t.Errorf("première ligne %q : un mot comparé contre « active\\r » ne correspond à rien", got) @@ -440,48 +64,3 @@ func TestASilentMachineAnswersNothingAndClaimsNothing(t *testing.T) { t.Error("ouvrir un port sans couche système doit échouer explicitement") } } - -// refusingRunner is a runner on which every command fails, which is what a machine without -// sc.exe, systemctl or powercfg looks like. -type refusingRunner struct{} - -func (refusingRunner) Run(context.Context, string, ...string) (string, error) { - return "", errors.New("commande introuvable") -} - -// TestTheProfileOfTheStationAccountIsFoundByItsDirectory : c'est le seul chemin de -// « openscale » vers « S-1-5-21-…-1001 » qui ne demande pas un appel Windows que ce paquet -// n'a aucune autre raison de faire — et c'est ce SID qui dit sous quelle ruche relire les -// stratégies du kiosque. -func TestTheProfileOfTheStationAccountIsFoundByItsDirectory(t *testing.T) { - const listing = ` -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-18 - ProfileImagePath REG_EXPAND_SZ %systemroot%\system32\config\systemprofile - -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-11-22-33-1001 - ProfileImagePath REG_EXPAND_SZ C:\Users\Fab - -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-11-22-33-1004 - ProfileImagePath REG_EXPAND_SZ C:\Users\openscale -` - if sid := profileSID(listing, "openscale"); sid != "S-1-5-21-11-22-33-1004" { - t.Fatalf("SID du compte du poste = %q", sid) - } - // Lire le profil d'un autre compte, c'est rendre vert un poste grand ouvert : la - // stratégie du technicien n'est pas celle du poste. - if sid := profileSID(listing, "Fab"); sid != "S-1-5-21-11-22-33-1001" { - t.Fatalf("SID d'un autre compte = %q", sid) - } -} - -// TestAnAccountWithNoProfileYieldsNoSID : un compte créé et jamais ouvert n'a pas de -// profil. Deviner un SID à ce moment-là ferait relire la ruche de quelqu'un d'autre. -func TestAnAccountWithNoProfileYieldsNoSID(t *testing.T) { - const listing = ` -HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-18 - ProfileImagePath REG_EXPAND_SZ %systemroot%\system32\config\systemprofile -` - if sid := profileSID(listing, "openscale"); sid != "" { - t.Fatalf("SID %q inventé pour un compte sans profil", sid) - } -} diff --git a/internal/domain/canonical.go b/internal/domain/canonical.go new file mode 100644 index 0000000..3c23749 --- /dev/null +++ b/internal/domain/canonical.go @@ -0,0 +1,139 @@ +package domain + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" +) + +// This file holds the ONE spelling of a JSON value: keys sorted, no whitespace, +// whole numbers in plain decimal. +// +// It exists for the fingerprint (fingerprint.go), which is the only thing that +// needs two semantically identical configurations to produce the same bytes -- but +// nothing here knows about a configuration, and that is deliberate: canonicalising +// is a property of JSON, not of what the JSON happens to describe. + +// CanonicalJSON returns the canonical JSON of a value: keys sorted, no whitespace, +// whole numbers in plain decimal. +// +// Canonical and not merely compact, and that is the point of §11.4: two +// configurations that are semantically identical but serialised with a different +// key order must NOT cut the serial port in the middle of a service. Whole numbers +// are re-emitted in decimal so that 9600 and 9.6e3 -- two spellings of the same +// baud rate -- cannot produce two fingerprints. +func CanonicalJSON(value any) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var generic any + if err := decoder.Decode(&generic); err != nil { + return nil, err + } + var buffer bytes.Buffer + if err := writeCanonical(&buffer, generic); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +// writeCanonical writes one decoded JSON value in canonical form. +func writeCanonical(buffer *bytes.Buffer, value any) error { + switch typed := value.(type) { + case nil: + buffer.WriteString("null") + case bool: + if typed { + buffer.WriteString("true") + } else { + buffer.WriteString("false") + } + case json.Number: + buffer.WriteString(canonicalNumber(typed)) + case string: + writeJSONString(buffer, typed) + case []any: + buffer.WriteByte('[') + for i, item := range typed { + if i > 0 { + buffer.WriteByte(',') + } + if err := writeCanonical(buffer, item); err != nil { + return err + } + } + buffer.WriteByte(']') + case map[string]any: + buffer.WriteByte('{') + for i, key := range sortedKeys(typed) { + if i > 0 { + buffer.WriteByte(',') + } + writeJSONString(buffer, key) + buffer.WriteByte(':') + if err := writeCanonical(buffer, typed[key]); err != nil { + return err + } + } + buffer.WriteByte('}') + default: + return fmt.Errorf("domain: valeur JSON non canonisable de type %T", value) + } + return nil +} + +// writeJSONString writes one quoted JSON string. +// +// encoding/json cannot fail on a string -- invalid UTF-8 is replaced by U+FFFD, not +// rejected -- so there is no error to propagate, and no unreachable branch is left +// in a function the fingerprint of every configuration goes through. +func writeJSONString(buffer *bytes.Buffer, value string) { + encoded, _ := json.Marshal(value) + buffer.Write(encoded) +} + +// exactIntegerFloat is 2^53, beyond which a float64 no longer holds every integer. +const exactIntegerFloat = 1 << 53 + +// canonicalNumber reports the one spelling of a JSON number. +// +// It exists so that 9600, 9.6e3 and 0.10 cannot produce three fingerprints of one +// configuration. The float64 detour is a canonicalisation of BYTES and never carries +// a quantity -- a mass, a price and a length are integers in this application, and +// the detour is refused past 2^53 rather than silently losing a digit. +func canonicalNumber(number json.Number) string { + if whole, err := strconv.ParseInt(number.String(), 10, 64); err == nil { + return strconv.FormatInt(whole, 10) + } + value, err := number.Float64() + if err != nil { + return number.String() + } + if value <= -exactIntegerFloat || value >= exactIntegerFloat { + // Too big to be re-spelled without dropping a digit: the original wins. + return number.String() + } + if value == float64(int64(value)) { + return strconv.FormatInt(int64(value), 10) + } + return strconv.FormatFloat(value, 'g', -1, 64) +} + +// sortedKeys reports the keys of a map in a stable order, which is what makes both +// the canonical JSON and the sequence of faults reproducible. +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for key := range m { + out = append(out, key) + } + sort.Strings(out) + return out +} diff --git a/internal/domain/canonical_test.go b/internal/domain/canonical_test.go new file mode 100644 index 0000000..f85c6bc --- /dev/null +++ b/internal/domain/canonical_test.go @@ -0,0 +1,99 @@ +// This file holds the ONE spelling of a JSON value: sorted keys, no whitespace, +// and a number that cannot be written two ways. +// +// 9600 and 9.6e3 are the same baud rate, and a fingerprint that told them apart +// would cut the serial port of a station whose file was merely reformatted. + +package domain + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestCanonicalJSONSortsKeysAndDropsWhitespace(t *testing.T) { + canonical, err := CanonicalJSON(json.RawMessage(`{ "b": 1, "a": [ 2, { "d": 3, "c": 4 } ] }`)) + if err != nil { + t.Fatalf("canonisation : %v", err) + } + const wanted = `{"a":[2,{"c":4,"d":3}],"b":1}` + if string(canonical) != wanted { + t.Fatalf("canonique = %s, attendu %s", canonical, wanted) + } +} + +// TestCanonicalJSONNormalisesTheSpellingOfANumber keeps 9600 and 9.6e3 -- two +// spellings of the same baud rate -- from producing two fingerprints, and does the +// same for 0.10 against 0.1. +func TestCanonicalJSONNormalisesTheSpellingOfANumber(t *testing.T) { + cases := [][2]string{ + {`{"baud":9600}`, `{"baud":9.6e3}`}, + {`{"min_readable_ratio":0.9}`, `{"min_readable_ratio":0.90}`}, + {`{"stop":1}`, `{"stop":1.0}`}, + } + for _, pair := range cases { + first, err := CanonicalJSON(json.RawMessage(pair[0])) + if err != nil { + t.Fatalf("canonisation de %s : %v", pair[0], err) + } + second, err := CanonicalJSON(json.RawMessage(pair[1])) + if err != nil { + t.Fatalf("canonisation de %s : %v", pair[1], err) + } + if string(first) != string(second) { + t.Errorf("%s et %s se canonisent en %s et %s", pair[0], pair[1], first, second) + } + } + + // Past 2^53 the float detour is refused rather than losing a digit: a big number + // keeps its own spelling. + big, err := CanonicalJSON(json.RawMessage(`{"n":123456789012345678901}`)) + if err != nil { + t.Fatalf("canonisation : %v", err) + } + if !strings.Contains(string(big), "123456789012345678901") { + t.Errorf("un entier hors int64 doit rester tel quel, obtenu %s", big) + } +} + +func TestCanonicalJSONHandlesEveryJSONShape(t *testing.T) { + canonical, err := CanonicalJSON(json.RawMessage(`{"z":null,"y":true,"x":false,"w":[],"v":{},"u":"é\""}`)) + if err != nil { + t.Fatalf("canonisation : %v", err) + } + const wanted = `{"u":"é\"","v":{},"w":[],"x":false,"y":true,"z":null}` + if string(canonical) != wanted { + t.Fatalf("canonique = %s, attendu %s", canonical, wanted) + } +} + +func TestCanonicalJSONRefusesWhatCannotBeSerialised(t *testing.T) { + if _, err := CanonicalJSON(make(chan int)); err == nil { + t.Error("une valeur non sérialisable doit être une erreur") + } + // The fingerprint of an unserialisable block is a VISIBLE marker: eight characters + // that merely look like a fingerprint would be worse than none. + if got := BlockFingerprint(make(chan int)); got != strings.Repeat("?", fingerprintLength) { + t.Errorf("empreinte = %q, attendu un marqueur visible", got) + } + var buffer bytes.Buffer + if err := writeCanonical(&buffer, 3.5); err == nil { + t.Error("un type hors du jeu JSON décodé doit être une erreur") + } + // And the refusal propagates from inside an array and from inside an object, + // rather than writing half a document and reporting success. + if err := writeCanonical(&buffer, []any{3.5}); err == nil { + t.Error("le refus doit remonter depuis un tableau") + } + if err := writeCanonical(&buffer, map[string]any{"n": 3.5}); err == nil { + t.Error("le refus doit remonter depuis un objet") + } +} + +func TestCanonicalNumberKeepsWhatItCannotRespell(t *testing.T) { + if got := canonicalNumber(json.Number("pas un nombre")); got != "pas un nombre" { + t.Errorf("canonicalNumber = %q, la valeur d'origine doit primer", got) + } +} diff --git a/internal/domain/config.go b/internal/domain/config.go index 7eb667f..fc119cd 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -1,22 +1,9 @@ package domain -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "net" - "net/url" - "regexp" - "sort" - "strconv" - "strings" - "time" -) +import "time" -// This file owns the SCHEMA of config.json and its 48 validation controls. +// This file owns the SCHEMA of config.json: the fourteen blocks a station declares, +// and the closed lists of values they name. // // One JSON file, which is also the export format (§11.1): encoding/json serialises // the very structure the administration screen edits, so "clone a station" is a @@ -24,9 +11,10 @@ import ( // copy it onto a USB stick, and the process must start and show an administration // screen even when the database is corrupt. // -// Nothing here reads a clock, opens a file or a socket: Validate is pure, and the -// two questions a pure function cannot answer -- "does this path exist?", "is this -// print queue really enumerated?" -- arrive through Registries. +// Nothing here reads a clock, opens a file or a socket: the 48 controls that judge +// these types are pure (validate.go), and the two questions a pure function cannot +// answer -- "does this path exist?", "is this print queue really enumerated?" -- +// arrive through Registries (options.go). // The three printer drivers, all selectable through printer.type (§8.1). // @@ -87,77 +75,6 @@ const ( ImageSourceNone = "none" ) -// fingerprintLength is how many hexadecimal characters the dashboard shows. -// -// Eight is what makes "do the four stations display the same string?" a check -// anybody can do by eye -- which the 227 _Poste1..4 columns of the legacy -// application never allowed. -const fingerprintLength = 8 - -// retiredKeys are the keys control 20 REFUSES outright, each with the reason §11.2 -// gives for its removal. -// -// Two families, and refusing rather than ignoring is the whole point of both. -// The first six used to declare a piece of the numbering plan from a file; the -// plan is now a CONSTANT OF THE BINARY indexed by prefix and self-checked at -// start-up (ADR-028), because a field that changes the MEANING of the code the -// till reads is not a setting, it is an external contract. The last two are the -// rational coefficient ADR-034 replaced by a percentage: encoding/json drops -// what no field claims, so an old file would decode in silence with every -// discount at zero -- and every member would pay the full price with nothing to -// say why. -var retiredKeys = map[string]string{ - "weight_decimals": "les décimales du poids sont déclarées par le plan compilé, indexé par préfixe (ADR-028)", - "units_field_width": "la largeur du champ des unités est déclarée par le plan compilé, indexé par préfixe (ADR-028)", - "weight_prefix": "les préfixes au poids sont déclarés par le plan compilé (0493 à 0498), jamais par un fichier", - "unit_prefix": "le préfixe à l'unité est déclaré par le plan compilé (0499), jamais par un fichier", - "content": "ce que transporte la charge utile est déclaré par le plan compilé, jamais par un fichier", - "rules_by_prefix": "la table de règles par préfixe est remplacée par le plan compilé, auto-contrôlé au démarrage", - "coef_num": "la remise d'un tarif se déclare en pourcentage : discount_percent, au dixième de point (ADR-034)", - "coef_den": "la remise d'un tarif se déclare en pourcentage : discount_percent, il n'y a plus de dénominateur (ADR-034)", - "tile_size": "la densité de la grille s'adapte en continu à l'écran (clamp CSS), il n'y a plus de palier à choisir (ADR-035, remplace ADR-031) ; ce qui se règle désormais est le nombre de colonnes, ui.grid_columns, un entier (ADR-057)", -} - -// RetiredKeyReason reports why the key at the end of a dotted path -- "barcode.weight_decimals", -// exactly as scanRetired and Config.Retired name it -- was retired, in the French an -// operator reads. -// -// It exists so that a reason written ONCE in retiredKeys is read everywhere a refusal is -// shown, instead of being copied a second time by whoever writes the next one: control 20 -// below and `openscale config migrate` (cmd/openscale/config.go) both name a key this -// binary will not convert, and they have to say the SAME thing, word for word, or a -// volunteer comparing the two would read them as two different problems. -// -// The extraction is the one control 20 already did before this function existed: the last -// segment of the path, because retiredKeys is indexed by the bare key and not by where it -// was found. A path this binary never retired -- unreachable through Config.Retired, which -// only ever names a key of retiredKeys -- reports that plainly rather than an empty string, -// which would truncate whatever sentence names it. -func RetiredKeyReason(path string) string { - key := path[strings.LastIndexByte(path, '.')+1:] - if reason, known := retiredKeys[key]; known { - return reason - } - return "clé retirée dont la raison n'est plus documentée" -} - -// retiredScaleTypes are the two values that LEFT the scale enumeration (§9.3), -// each with the reason it left. -// -// The previous version mixed two protocols, a DEGRADED MODE and a TEST TOOL in one -// drop-down list shown to a volunteer. The same state was then reachable through -// three doors -- a configuration value, an automatic fallback, a troubleshooting -// button -- which made the only question that matters on a bad morning undecidable: -// why is this station in manual entry? Refusing the two values is what keeps the -// three questions separate. -var retiredScaleTypes = map[string]string{ - SourceManual: "« manual » est un ÉTAT, pas un protocole : un poste sans balance se déclare avec scale.present = false, et la saisie à la main s'autorise avec manual_entry_allowed", - SourceReplay: "« replay » est un outil de diagnostic (openscale capture / openscale replay, bouton « Rejouer cette trame »), il n'a rien à faire dans la liste du matériel de pesée", -} - -// serialTransports are the transport names control 42 refuses for a printer. -var serialTransports = []string{"serial", "rs232", "rs-232", "com"} - // Config is the whole configuration of a station, and it is the file on disk. type Config struct { // Version is the schema version of the FILE, not the version of the binary, and @@ -458,1951 +375,3 @@ const DefaultUpdateRepository = "lostmind84/OpenScale" type UpdateConfig struct { Repository string `json:"repository"` } - -// repositoryShape is control 48: an owner and a repository, nothing else. No -// scheme, no host, no dots that climb, no third segment. -var repositoryShape = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,39}/[A-Za-z0-9_.-]{1,100}$`) - -// --- JSON, for the three domain types that predate the configuration file ------ -// -// Their codecs live here rather than beside them on purpose: safeguard.go says what -// a THRESHOLD is, quantity.go what a ROUNDING is, product.go what a CATEGORY is. -// How a file spells them is the business of the file, and the key names of §11.2 -// then live in exactly one place. - -// limitsJSON is the on-file shape of WeighingLimits. -type limitsJSON struct { - EmptyMax Grams `json:"empty_max_g"` - BasketCheckEnabled bool `json:"basket_check_enabled"` - BasketMin Grams `json:"basket_min_g"` - BasketMax Grams `json:"basket_max_g"` - MinWeight Grams `json:"min_weight_g"` - MaxWeight Grams `json:"max_weight_g"` - MaxTare Grams `json:"max_tare_g"` - MinUnits int `json:"min_units"` - MaxUnits int `json:"max_units"` - MaxAmount Cents `json:"max_amount_cents"` -} - -// MarshalJSON writes the thresholds under the key names of §11.2. -func (l WeighingLimits) MarshalJSON() ([]byte, error) { - return json.Marshal(limitsJSON{ - EmptyMax: l.EmptyMax, BasketCheckEnabled: l.BasketCheckEnabled, - BasketMin: l.BasketMin, BasketMax: l.BasketMax, - MinWeight: l.MinWeight, MaxWeight: l.MaxWeight, MaxTare: l.MaxTare, - MinUnits: l.MinUnits, MaxUnits: l.MaxUnits, MaxAmount: l.MaxAmount, - }) -} - -// UnmarshalJSON reads the thresholds, keeping whatever the block does not name. -// -// Keeping rather than zeroing is what makes the field-by-field merge of an import -// (§11.5) behave: a partial block overlays the target instead of erasing it. -func (l *WeighingLimits) UnmarshalJSON(raw []byte) error { - on := limitsJSON{ - EmptyMax: l.EmptyMax, BasketCheckEnabled: l.BasketCheckEnabled, - BasketMin: l.BasketMin, BasketMax: l.BasketMax, - MinWeight: l.MinWeight, MaxWeight: l.MaxWeight, MaxTare: l.MaxTare, - MinUnits: l.MinUnits, MaxUnits: l.MaxUnits, MaxAmount: l.MaxAmount, - } - if err := json.Unmarshal(raw, &on); err != nil { - return err - } - *l = WeighingLimits{ - EmptyMax: on.EmptyMax, BasketCheckEnabled: on.BasketCheckEnabled, - BasketMin: on.BasketMin, BasketMax: on.BasketMax, - MinWeight: on.MinWeight, MaxWeight: on.MaxWeight, MaxTare: on.MaxTare, - MinUnits: on.MinUnits, MaxUnits: on.MaxUnits, MaxAmount: on.MaxAmount, - } - return nil -} - -// categoryJSON is the on-file shape of Category. -type categoryJSON struct { - Code string `json:"code"` - Label string `json:"label"` - Rank int `json:"rank"` - Color string `json:"color"` - Visible bool `json:"visible"` -} - -// MarshalJSON writes a category under the key names of §11.2. -func (c Category) MarshalJSON() ([]byte, error) { - return json.Marshal(categoryJSON{c.Code, c.Label, c.Rank, c.Color, c.Visible}) -} - -// UnmarshalJSON reads a category, keeping whatever the object does not name. -func (c *Category) UnmarshalJSON(raw []byte) error { - on := categoryJSON{c.Code, c.Label, c.Rank, c.Color, c.Visible} - if err := json.Unmarshal(raw, &on); err != nil { - return err - } - *c = Category{on.Code, on.Label, on.Rank, on.Color, on.Visible} - return nil -} - -// roundingSpellings maps the configuration wording of a policy to the policy. -var roundingSpellings = map[string]RoundingPolicy{ - "half_up": RoundHalfUp, - "truncate": RoundTowardZero, - "half_even": RoundHalfToEven, -} - -// RoundingSpellings reports the three admissible spellings of a rounding policy, -// in a stable order, so that a fault and an admin drop-down list name the same -// three values. -func RoundingSpellings() []string { return []string{"half_up", "truncate", "half_even"} } - -// MarshalJSON writes the policy as the word config.json uses. -func (p RoundingPolicy) MarshalJSON() ([]byte, error) { return json.Marshal(p.String()) } - -// UnmarshalJSON reads one of the three words of RoundingSpellings. -// -// An unknown word is an ERROR and not a fault, so the configuration never holds a -// policy nobody declared: Divide would silently truncate, and a station would -// under-charge by a cent for months without anyone able to name why. §11.4 turns -// this into the 400 Bad Request of step 1, and the error names the three values. -func (p *RoundingPolicy) UnmarshalJSON(raw []byte) error { - var word string - if err := json.Unmarshal(raw, &word); err != nil { - return fmt.Errorf("domain: un arrondi est un mot parmi %s : %w", - strings.Join(RoundingSpellings(), ", "), err) - } - policy, ok := roundingSpellings[word] - if !ok { - return fmt.Errorf("domain: arrondi inconnu %q, valeurs admises : %s", - word, strings.Join(RoundingSpellings(), ", ")) - } - *p = policy - return nil -} - -// UnmarshalJSON reads the configuration and remembers the retired keys it carried. -// -// The scan happens HERE and not in Validate because Validate only sees a Go -// structure, in which a retired key cannot exist: encoding/json drops what no field -// claims. Control 20 has to refuse the FILE, so the file is what gets read. -func (c *Config) UnmarshalJSON(raw []byte) error { - // The generic pass FIRST, so that "this is not JSON" and "this JSON puts a word - // where a number belongs" are two distinct errors rather than one. - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var document any - if err := decoder.Decode(&document); err != nil { - return err - } - // An alias, otherwise this method calls itself. - type alias Config - shadow := alias(*c) - if err := json.Unmarshal(raw, &shadow); err != nil { - return err - } - *c = Config(shadow) - // A file that names no repository -- one written before this block existed, - // or one that carries it empty -- runs on the default. Refusing here would put - // a station out of service over a field nobody meant to set. - if c.Update.Repository == "" { - c.Update.Repository = DefaultUpdateRepository - } - c.retired = nil - scanRetired("", document, &c.retired) - return nil -} - -// scanRetired appends the dotted path of every retired key of a decoded document. -func scanRetired(prefix string, value any, out *[]string) { - switch typed := value.(type) { - case map[string]any: - for _, key := range sortedKeys(typed) { - path := key - if prefix != "" { - path = prefix + "." + key - } - if _, retired := retiredKeys[key]; retired { - *out = append(*out, path) - } - scanRetired(path, typed[key], out) - } - case []any: - for i, item := range typed { - scanRetired(fmt.Sprintf("%s[%d]", prefix, i), item, out) - } - } -} - -// --- Driver options ------------------------------------------------------------ - -// DriverOptions is the driver-specific half of a hardware or catalog block. -// -// It stays UNTYPED on purpose: the administration screen generates its form from -// the schema the driver DECLARES (§9.3), so adding a scale model must not mean -// adding a Go field here. The values are kept as raw JSON rather than as `any` -// because decoding into `any` turns every number into a float64, and no float -// carries a quantity in this application. -type DriverOptions map[string]json.RawMessage - -// Text reports a string option, and whether it is present and really a string. -func (o DriverOptions) Text(key string) (string, bool) { - raw, ok := o[key] - if !ok { - return "", false - } - var value string - if json.Unmarshal(raw, &value) != nil { - return "", false - } - return value, true -} - -// Int reports a whole-number option, and whether it is present and really whole. -func (o DriverOptions) Int(key string) (int64, bool) { - number, ok := jsonNumber(o[key]) - if !ok { - return 0, false - } - value, err := strconv.ParseInt(number.String(), 10, 64) - if err != nil { - return 0, false - } - return value, true -} - -// jsonNumber decodes a raw value as a JSON number, refusing a QUOTED one. -// -// The refusal is deliberate: encoding/json happily reads a quoted numeric literal -// into a json.Number, so `"baud": "9600"` would pass silently. A configuration that -// spells a baud rate as text has a type error, and the driver form is what must say -// so -- the admin screen offers a numeric field, and a file that came from somewhere -// else has to be told. -func jsonNumber(raw json.RawMessage) (json.Number, bool) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || trimmed[0] == '"' { - return "", false - } - var number json.Number - if json.Unmarshal(trimmed, &number) != nil { - return "", false - } - return number, true -} - -// Ratio reports a fractional option, and whether it is present and numeric. -// -// The only floats a configuration carries are RATIOS -- min_readable_ratio, -// max_weighable_drop -- and never a mass, a price or a length. -func (o DriverOptions) Ratio(key string) (float64, bool) { - number, ok := jsonNumber(o[key]) - if !ok { - return 0, false - } - value, err := number.Float64() - if err != nil { - return 0, false - } - return value, true -} - -// Bool reports a boolean option, and whether it is present and really a boolean. -func (o DriverOptions) Bool(key string) (bool, bool) { - raw, ok := o[key] - if !ok { - return false, false - } - var value bool - if json.Unmarshal(raw, &value) != nil { - return false, false - } - return value, true -} - -// Group reports a nested option object, such as printer.options.fallback. -func (o DriverOptions) Group(key string) (DriverOptions, bool) { - raw, ok := o[key] - if !ok { - return nil, false - } - var value DriverOptions - if json.Unmarshal(raw, &value) != nil { - return nil, false - } - return value, true -} - -// Has reports whether the option is present, whatever its value. -func (o DriverOptions) Has(key string) bool { - _, ok := o[key] - return ok -} - -// Keys reports the option names in a stable order, so that two runs of the -// validation produce the faults in the same sequence. -func (o DriverOptions) Keys() []string { return sortedKeys(o) } - -// WithText returns the same options with one key set to a string value. -// -// It never touches the receiver, for the reason clone exists: a DriverOptions is a MAP, -// so a copy of a Config shares it with the configuration the station is running on, and -// writing through one of them would change the other. -func (o DriverOptions) WithText(key, value string) DriverOptions { - next := o.clone() - if next == nil { - next = make(DriverOptions, 1) - } - // json.Marshal of a string cannot fail: it escapes what it must, and replaces what is - // not valid UTF-8 rather than refusing it. - raw, _ := json.Marshal(value) - next[key] = raw - return next -} - -// clone returns a shallow copy, so that Export can strip a secret without reaching -// into the configuration the station is running on. -func (o DriverOptions) clone() DriverOptions { - if o == nil { - return nil - } - out := make(DriverOptions, len(o)) - for key, value := range o { - out[key] = value - } - return out -} - -// --- Registries ---------------------------------------------------------------- - -// OptionKind names the shape one driver option accepts. -type OptionKind uint8 - -const ( - // OptionText is any string. - OptionText OptionKind = iota - // OptionInt is a whole number, bounded by Min and Max when Max is non-zero. - OptionInt - // OptionBool is a boolean. - OptionBool - // OptionRatio is a fraction between Min and Max expressed in per mille, which is - // how a ratio gets bounded without a float ever entering a declaration. - OptionRatio - // OptionEnum is one of Values. - OptionEnum - // OptionHostPort is a host:port pair. - OptionHostPort - // OptionURL is an absolute http or https URL. - OptionURL - // OptionGroup is a nested object whose own schema is Options. - OptionGroup -) - -// String reports the kind the way a fault names it, in French. -func (k OptionKind) String() string { - switch k { - case OptionText: - return "texte" - case OptionInt: - return "nombre entier" - case OptionBool: - return "vrai ou faux" - case OptionRatio: - return "nombre" - case OptionEnum: - return "valeur d'une liste" - case OptionHostPort: - return "hôte:port" - case OptionURL: - return "URL http ou https" - case OptionGroup: - return "objet" - } - return "inconnu" -} - -// OptionSchema declares one option of a driver. -// -// It is what lets the administration screen GENERATE its form and the validation -// check the OPTIONS of a driver instead of only its type name: `port` among the -// enumerated ports, `queue` among the queues REALLY visible, `address` as -// host:port (§11.3). -// OptionUse names what a value DESIGNATES, when knowing that lets a control judge it -// without knowing which driver declared the key. -// -// Kind says what SHAPE a value has — text, a whole number, a web address. Use says what -// it POINTS AT, and only the second lets Config.Validate probe a directory or refuse an -// HTTP host on a key it has never heard of. -// -// It exists because the three controls that did this work were three `if` statements -// naming `local_drop` and `webdav` INSIDE THE DOMAIN: a third catalog source could not -// be added without editing this file, which is the exact opposite of a plug-in point. -// The guards themselves have not moved an inch — what moved is who declares them -// (ADR-052). -type OptionUse uint8 - -const ( - // UseNone is the zero value, and what almost every option declares: the schema says - // nothing beyond the shape of the value. - UseNone OptionUse = iota - // UseDropDirectory is a directory ON THIS MACHINE the service must be able to list, - // write into and delete from — the acknowledgement of §10.1 IS a deletion. - // - // It carries the guard of important-11: a value that names an HTTP(S) host is refused - // outright. A "local" directory reached through an account and a password is the Z: - // drive of the legacy application under another name, and a source that fetches from - // a share is a different source, with a different acknowledgement. - UseDropDirectory -) - -type OptionSchema struct { - Key string - Kind OptionKind - Required bool - // Use is what the value points at, when a control can act on knowing it. Almost every - // option leaves it at UseNone. - Use OptionUse - // Values is the closed list of an enum, and for an option the platform can - // enumerate -- a serial port, a print queue -- the values it REALLY found. An - // empty list means "we could not enumerate": the form is checked, membership is - // not. - Values []string - // Min and Max bound an OptionInt, and an OptionRatio IN PER MILLE. Both zero - // means unbounded. - Min, Max int64 - // Options is the schema of a nested OptionGroup. - Options []OptionSchema -} - -// DriverDescriptor is what validating a configuration needs to know about a -// driver: its registry key, the wording a volunteer reads, and the schema of its -// options. -type DriverDescriptor struct { - // ID is the registry key, the value that goes into the file: "gram-xfoc-plus". - ID string - // Label is what the drop-down list shows, in French: "GRAM XFOC +". - Label string - Options []OptionSchema - // Capabilities is what a PRINTER driver declares about the head it drives, and it - // is what controls 29 and 38 measure a template against. - // - // The zero value is what every other kind of driver leaves here, and also what a - // printer that inks no paper declares: the rules then bear on ReferenceHead, the - // WS408 of the parc. - Capabilities PrinterCapabilities - // SelfTests are the built-in patterns of §8.6 a PRINTER driver honours, by the name - // the troubleshooting route sends: "label", "alignment", "ruler". - // - // Plain strings, and not a type of their own: the catalogue of the three lives in - // internal/printing, which is where their wording, their access level and what each - // print settles are written. What crosses into the domain is WHICH ONES a driver - // honours, so that the administration screen offers no button whose only possible - // answer is a refusal (ADR-025). - // - // Nil means « this binary cannot say », which is the honest answer of a validation - // run with no driver registry at all; an EMPTY slice is the assertion « none ». - SelfTests []string - // DeviceKey is the printer.options key a TRANSPORT descriptor reads to DESIGNATE ITS - // DEVICE: DeviceKeyQueue for winspool, DeviceKeyPath for devfile and file, - // DeviceKeyAddress for tcp. Empty on every other kind of descriptor. - // - // It travels for the reason Endpoint does just below, and it was learnt the same way. - // The Matériel screen carried ONE device field, wired to `queue` whatever the transport - // was; « Rechercher l'imprimante » proposes hosts answering on port 9100, and clicking - // one wrote 192.168.0.43:9100 into printer.options.queue. Nothing refused it — `queue` - // is a key of the driver, and no control ties a key to a transport — so the station - // saved a configuration that could not print, and said so only when the socket was - // opened. - // - // Declaring it here is what lets the screen ask the STATION where to write instead of - // carrying a table of its own: a fifth transport is then one line in a registry, and the - // form follows. - DeviceKey string - // Endpoint is the kind of access point a SCALE driver is reached and recognised on: - // EndpointSerialPort, or empty for a protocol that names none. - // - // It travels with the descriptor for the same reason SelfTests does — a screen and a - // diagnosis must read what the DRIVER declares instead of assuming. `openscale - // doctor` checked « le port série est présent et ouvrable » on every station that - // declares a scale, reading scale.options.port whatever the protocol was: on a scale - // reached any other way that control was a red light on a key that does not exist. - Endpoint string -} - -// The kinds of access point a scale protocol can be reached on, spelled once. -// -// They live in the domain because a descriptor carries them across to `openscale doctor` -// and to the administration screen, and a second spelling on the far side is how a -// declaration and its reader stop meaning the same thing. -const ( - // EndpointSerialPort is one serial port of the machine, as the platform enumerates - // them. - EndpointSerialPort = "serial-port" - // EndpointNone is a protocol that declares no access point of a kind this - // application enumerates: it is chosen by hand and never detected. - EndpointNone = "none" -) - -// PathChecker answers the questions a pure validation cannot: what can this path do -// FROM THE CONTEXT OF THE SERVICE? -// -// It is an interface declared on the consumer side, and a nil one is a legitimate -// state: `openscale config validate` on a laptop cannot know what the service -// account sees. The form is then validated and existence is not. -type PathChecker interface { - // Readable reports nil when the service could read that path. - Readable(path string) error - // Droppable reports nil when the service could create AND DELETE a file there. - // - // Two questions and not one: a catalog is acknowledged by DELETING it (ADR-004), so a - // directory the service may only read would make the same import loop for ever -- - // applied, archived, and still there at the next poll. - Droppable(path string) error -} - -// Registries carries the driver descriptors and the templates a running binary -// knows. -// -// It exists so that the validation can check the OPTIONS of each driver and not -// merely say "unknown type". An EMPTY registry is a legitimate state -- the drivers -// are delivered by later lots, and `openscale config validate` may run outside the -// service: membership is then not checked and the message says so. What is always -// checked is the FORM, and the values that were RETIRED with a written reason. -type Registries struct { - Scales []DriverDescriptor - Printers []DriverDescriptor - Transports []DriverDescriptor - CatalogSources []DriverDescriptor - // Templates is the label layouts this binary can load. Nil means "the templates - // compiled into the binary", which is where they live until L4. - Templates map[string]Template - // Paths probes the filesystem for controls 44 and 46. Nil means "we cannot know". - Paths PathChecker -} - -// ScaleTypes reports the scale protocols a volunteer may choose from. -func (r Registries) ScaleTypes() []string { return descriptorIDs(r.Scales) } - -// PrinterTypes reports the printer drivers a volunteer may choose from. -func (r Registries) PrinterTypes() []string { return descriptorIDs(r.Printers) } - -// TransportNames reports the byte transports a volunteer may choose from. -func (r Registries) TransportNames() []string { return descriptorIDs(r.Transports) } - -// CatalogSourceNames reports the catalog sources a volunteer may choose from. -func (r Registries) CatalogSourceNames() []string { return descriptorIDs(r.CatalogSources) } - -// PrinterHead reports the geometry the driver printer.type names declares about its -// head. -// -// An unknown driver — and an EMPTY registry, which `openscale config validate` on a -// laptop legitimately is — answers a head that declares nothing, and the rules then -// fall back on the label of the parc rather than on nothing at all. -func (r Registries) PrinterHead(id string) PrinterCapabilities { - if descriptor := descriptorByID(r.Printers, id); descriptor != nil { - return descriptor.Capabilities - } - return PrinterCapabilities{} -} - -// TemplateNames reports the label layouts this binary can load, in a stable order. -func (r Registries) TemplateNames() []string { return sortedKeys(r.templates()) } - -// Template returns a layout by name, and whether it exists. -func (r Registries) Template(name string) (Template, bool) { - template, ok := r.templates()[name] - return template, ok -} - -// templates falls back on the layouts compiled into the binary, which is where -// they live until the rendering engine turns them into files (templates.go). -func (r Registries) templates() map[string]Template { - if r.Templates != nil { - return r.Templates - } - return ShippedTemplates() -} - -func descriptorIDs(list []DriverDescriptor) []string { - if len(list) == 0 { - return nil - } - out := make([]string, 0, len(list)) - for _, descriptor := range list { - out = append(out, descriptor.ID) - } - sort.Strings(out) - return out -} - -func descriptorByID(list []DriverDescriptor, id string) *DriverDescriptor { - for i := range list { - if list[i].ID == id { - return &list[i] - } - } - return nil -} - -// optionsUsedAs reports the options a named driver declares for a given use. -// -// It is what lets a control act on WHAT A VALUE POINTS AT without naming a driver: the -// key that carries a drop directory is `directory` in the source shipped today and may be -// anything in the next one, and this file is not entitled to a second copy of that -// decision. An unknown driver yields nothing, which is the honest behaviour of a -// validation run against a registry that does not carry it. -func optionsUsedAs(list []DriverDescriptor, id string, use OptionUse) []OptionSchema { - descriptor := descriptorByID(list, id) - if descriptor == nil { - return nil - } - var out []OptionSchema - for _, schema := range descriptor.Options { - if schema.Use == use { - out = append(out, schema) - } - } - return out -} - -// sourcesFetchingByURL reports the sources that go and GET the catalog from an address. -// -// It is the suggestion control 39 offers when somebody types a web address into a drop -// path: « choose the source that fetches from a share » is only useful if it can say -// which one that is, and reading the schemas answers it for a source that did not exist -// when the control was written. -func sourcesFetchingByURL(list []DriverDescriptor) []string { - var out []string - for _, descriptor := range list { - for _, schema := range descriptor.Options { - if schema.Kind == OptionURL { - out = append(out, descriptor.ID) - break - } - } - } - sort.Strings(out) - return out -} - -// driversDeclaring reports which OTHER drivers of a list declare a given option key. -// -// It turns « option inconnue du driver "webdav" » into « … c'est "local_drop" qui la -// déclare », which is the difference between a refusal and a piece of advice — and it -// does it for every driver family and every key, where the control it replaces knew one -// key and two sources by name. -func driversDeclaring(list []DriverDescriptor, key, except string) []string { - var out []string - for _, descriptor := range list { - if descriptor.ID == except { - continue - } - for _, schema := range descriptor.Options { - if schema.Key == key { - out = append(out, descriptor.ID) - break - } - } - } - sort.Strings(out) - return out -} - -// --- Validation ---------------------------------------------------------------- - -// Validate returns ALL the faults, not the first one: the administration screen is -// used by volunteers, it must report everything at once, in French, with the -// offending field named and, whenever possible, the list of available values in -// Fault.Values. -// -// reg carries the driver descriptors, which is what allows the options of each -// driver to be validated instead of just its type; an empty registry validates the -// form and not the existence. -// -// An invalid configuration NEVER kills the process (§11.3): the server starts in -// "invalid configuration" mode, loads NeutralProfile in memory WITHOUT writing, -// serves this list of faults and shows a full-screen « Poste en configuration -// d'usine (ERR-CFG-01) ». A broken configuration must never produce a black screen. -func (c *Config) Validate(reg Registries) []Fault { - var faults []Fault - fail := func(field, format string, args ...any) { - faults = append(faults, Fault{Field: field, Message: fmt.Sprintf(format, args...)}) - } - failWith := func(field string, values []string, format string, args ...any) { - faults = append(faults, Fault{ - Field: field, Message: fmt.Sprintf(format, args...), Values: values, - }) - } - - // 1. station.number ∈ [1,99]. It is what the watched file name derives from. - if c.Station.Number < 1 || c.Station.Number > 99 { - fail("station.number", "%d hors bornes [1, 99] : c'est de ce numéro que dérive le nom du fichier surveillé, flv_.csv", - c.Station.Number) - } - - // 2. network.listen parseable. - if err := checkHostPort(c.Network.Listen); err != nil { - fail("network.listen", "%q n'est pas une adresse hôte:port valide (%s)", c.Network.Listen, err) - } - - // 3. scale.type known -- EXACTLY the protocols of the registry (§9.3). WHICH - // OPTIONS IT NEEDS IS NOT DECIDED HERE: control 6 asks the schema the chosen - // driver declares. - // - // This control used to demand the literal key `scale.options.port` of every - // station whose scale.present was raised, whatever its scale.type. A driver - // reached by an ADDRESS -- TCP, USB -- was therefore refused before it was ever - // asked, on a key its own schema does not carry, and adding one would have meant - // editing this function: exactly the coupling §5.2 removes. Nothing moves for the - // parc, whose serial drivers declare `port` Required in serial.OptionSchema, and - // the volunteer gains a line -- the field counted DOUBLE, once for this rule and - // once for the schema. - switch { - case c.Scale.Type == "" && c.Scale.Present: - failWith("scale.type", reg.ScaleTypes(), "aucun protocole n'est déclaré alors que le poste déclare une balance") - case c.Scale.Type == "": - // A station that declares it has no scale names no protocol, and that is - // deliberate: the neutral profile must not name a piece of hardware. - default: - if reason, retired := retiredScaleTypes[c.Scale.Type]; retired { - failWith("scale.type", reg.ScaleTypes(), "%q n'est plus une valeur de scale.type : %s", c.Scale.Type, reason) - } else if available := reg.ScaleTypes(); len(available) > 0 && !known(available, c.Scale.Type) { - failWith("scale.type", available, "protocole inconnu %q", c.Scale.Type) - } - } - // 4. printer.type known -- exactly the three registered descriptors, raster by - // default, sbpl and preview (§8.1, §8.2). - if c.Printer.Type == "" { - failWith("printer.type", reg.PrinterTypes(), "aucun driver d'impression n'est déclaré") - } else if available := reg.PrinterTypes(); len(available) > 0 && !known(available, c.Printer.Type) { - failWith("printer.type", available, "driver d'impression inconnu %q", c.Printer.Type) - } - - // 5. catalog.type known. "manual" is NOT a source: the drag and drop of the - // administration screen writes into local_drop (A4, §10.1). - switch { - case c.Catalog.Type == "": - failWith("catalog.type", reg.CatalogSourceNames(), "aucune source de catalogue n'est déclarée") - case c.Catalog.Type == CatalogSourceManual: - failWith("catalog.type", reg.CatalogSourceNames(), - "%q n'est pas une source : le glisser-déposer de l'administration écrit dans %s, et la scrutation fait le reste", - CatalogSourceManual, CatalogSourceLocalDrop) - default: - if available := reg.CatalogSourceNames(); len(available) > 0 && !known(available, c.Catalog.Type) { - failWith("catalog.type", available, "source de catalogue inconnue %q", c.Catalog.Type) - } - } - - // 6. scale.options validated by the schema the scale driver declares. - faults = append(faults, validateOptions("scale.options", c.Scale.Options, - descriptorByID(reg.Scales, c.Scale.Type), reg.Scales)...) - - // 7. printer.options validated by the schema the printer driver declares. - faults = append(faults, validateOptions("printer.options", c.Printer.Options, - descriptorByID(reg.Printers, c.Printer.Type), reg.Printers)...) - - // 8. printer.options.transport is one of the registered transports. - transport, hasTransport := c.Printer.Options.Text("transport") - if hasTransport && transport != "" { - if available := reg.TransportNames(); len(available) > 0 && !known(available, transport) { - failWith("printer.options.transport", available, "transport inconnu %q", transport) - } - } - - // 9. catalog.options validated by the schema the source declares. - faults = append(faults, validateOptions("catalog.options", c.Catalog.Options, - descriptorByID(reg.CatalogSources, c.Catalog.Type), reg.CatalogSources)...) - - // 10. At least one tier. Dual pricing is not a boolean, it is the cardinality of - // the grid (§6.3). - if len(c.Pricing.Tiers) == 0 { - fail("pricing.tiers", "la grille de tarifs est vide : il en faut au moins un") - } - codes := make(map[string]bool, len(c.Pricing.Tiers)) - for i, tier := range c.Pricing.Tiers { - // 11. The tier reference_code names is the catalog price -- the one the - // till charges. Its discount is not a setting, it is zero by - // definition, so a file that gives it one is REFUSED rather than - // quietly obeyed (ADR-034). - if tier.Code == c.Pricing.ReferenceCode && tier.Discount != 0 { - fail(fmt.Sprintf("pricing.tiers[%d].discount_percent", i), - "le tarif de référence est le prix du catalogue : il ne porte pas de remise") - } - // 12. Codes unique: the code is the key of a tier, in the file, on the label - // and in the journal. - if codes[tier.Code] { - fail(fmt.Sprintf("pricing.tiers[%d].code", i), "le code %q est déclaré deux fois", tier.Code) - } - codes[tier.Code] = true - // 13. A discount is a percentage between 0 and 100. A hundred is free, and - // that is a grid a cooperative may legitimately declare. - if tier.Discount < 0 || tier.Discount > FullDiscount { - fail(fmt.Sprintf("pricing.tiers[%d].discount_percent", i), - "%s %% n'est pas une remise entre 0 et 100 %%", tier.Discount) - } - } - tierCodes := make([]string, 0, len(c.Pricing.Tiers)) - for _, tier := range c.Pricing.Tiers { - tierCodes = append(tierCodes, tier.Code) - } - - // 14. primary_code belongs to the grid: it is the price printed LARGE (A7). - if !codes[c.Pricing.PrimaryCode] { - failWith("pricing.primary_code", tierCodes, "%q ne désigne aucun tarif de la grille", c.Pricing.PrimaryCode) - } - // 15. reference_code belongs to the grid: it is the one encoded when the payload - // carries a price, and the till must never under-charge. - if !codes[c.Pricing.ReferenceCode] { - failWith("pricing.reference_code", tierCodes, "%q ne désigne aucun tarif de la grille", c.Pricing.ReferenceCode) - } - // 16. Each secondary code belongs to the grid. - for i, code := range c.Pricing.SecondaryCodes { - if !codes[code] { - failWith(fmt.Sprintf("pricing.secondary_codes[%d]", i), tierCodes, - "%q ne désigne aucun tarif de la grille", code) - } - } - - // 17-19. The internal numbering plan SELF-CHECKS at start-up (§6.2, ADR-028): - // every declared prefix is exactly four digits, 4 + ref + payload + 1 = 13, - // and no prefix is declared twice. init() already panics on a broken plan, - // so these three can only fail in a test that hands over a broken table -- - // which is exactly why they are a function and not inline code: an - // inconsistent plan must stop the process AT START-UP, never at print time. - faults = append(faults, validateNumberingPlan(internalPlan)...) - - // 20. A configuration still carrying a retired key -- numbering plan or pricing - // coefficient -- is REFUSED. - for _, path := range c.retired { - fail(path, "clé supprimée : %s", RetiredKeyReason(path)) - } - - // 21. template.media.dots_per_mm is the SINGLE source of resolution (mineur-3): - // barcode.resolution_dpi is gone, and every geometric rule divides the world - // by this number. - template, templateExists := reg.Template(c.Printer.Template) - resolutionUsable := templateExists && template.Media.DotsPerMM > 0 - if templateExists && !resolutionUsable { - fail("template.media.dots_per_mm", - "le gabarit %q ne déclare aucune résolution utilisable (8 sur une WS408, 12 sur une WS412)", - c.Printer.Template) - } - - // 22. basket_min_g ≤ basket_max_g ≤ 0: the window means "the customer lifted off - // a basket the scale was tared for", so it is NEGATIVE by nature. - if c.Limits.BasketMin > c.Limits.BasketMax || c.Limits.BasketMax > 0 { - fail("limits.basket_min_g", - "la fenêtre du panier (%d ≤ %d ≤ 0) est incohérente : elle décrit un poids négatif", - c.Limits.BasketMin, c.Limits.BasketMax) - } - - // 23. min_weight_g < max_weight_g ≤ 99999. The ceiling is the CAPACITY of the - // NNDDD field of the barcode, not a plausibility threshold. - if c.Limits.MinWeight >= c.Limits.MaxWeight || c.Limits.MaxWeight > MaxWeight { - fail("limits.max_weight_g", - "les bornes de poids (%d < %d ≤ %d) sont incohérentes : %d g est la capacité du champ NNDDD du code-barres", - c.Limits.MinWeight, c.Limits.MaxWeight, MaxWeight, MaxWeight) - } - - // 24. min_units ≤ max_units ≤ 99: two digits in the payload of prefix 0499. - if c.Limits.MinUnits > c.Limits.MaxUnits || c.Limits.MaxUnits > 99 { - fail("limits.max_units", - "les bornes d'unités (%d ≤ %d ≤ 99) sont incohérentes : la charge utile du préfixe à l'unité fait deux chiffres", - c.Limits.MinUnits, c.Limits.MaxUnits) - } - - // 25. max_amount_cents ≤ 99999. - if c.Limits.MaxAmount > 99_999 { - fail("limits.max_amount_cents", "%d dépasse la capacité du champ de prix du code-barres (99 999 centimes)", - c.Limits.MaxAmount) - } - - // 26. timeout_ms > min_duration_ms: a window that expires before it can hold - // would time out every single weighing. - if c.Stability.Timeout <= c.Stability.MinDuration { - fail("stability.timeout_ms", "%s doit dépasser la durée de stabilité exigée (%s)", - c.Stability.Timeout, c.Stability.MinDuration) - } - - // 27. expiry_floor_ms ≥ 1000 and < expiry_ceiling_ms. - if c.Stability.ExpiryFloor < Duration(time.Second) { - fail("stability.expiry_floor_ms", "%s est sous le plancher de 1 s : le poids serait déclaré périmé avant la mesure suivante", - c.Stability.ExpiryFloor) - } - if c.Stability.ExpiryFloor >= c.Stability.ExpiryCeiling { - fail("stability.expiry_ceiling_ms", "%s doit dépasser le plancher de péremption (%s)", - c.Stability.ExpiryCeiling, c.Stability.ExpiryFloor) - } - - // 28. stability.mode and on_timeout in the list (A3). - if !known(stabilityModes(), c.Stability.Mode) { - failWith("stability.mode", stabilityModes(), "mode inconnu %q", c.Stability.Mode) - } - if !known(timeoutActions(), c.Stability.OnTimeout) { - failWith("stability.on_timeout", timeoutActions(), "action inconnue %q", c.Stability.OnTimeout) - } - - // 29. The template EXISTS and Template.Validate() passes -- the nine hard rules - // of §7.5, on the geometry RECOMPOSED with the operator's offsets. - // They bear on the head THE DRIVER DECLARES: held as constants of the core, the - // inked width and height were counted at 8 dots/mm, so a station whose printer - // is not the WS408 of the parc failed this very control at start-up — §11.3 - // puts it out of service — on a template nobody could make it accept. - head := reg.PrinterHead(c.Printer.Type).orReference() - if !templateExists { - failWith("printer.template", reg.TemplateNames(), "gabarit inconnu %q", c.Printer.Template) - } else if resolutionUsable { - shifted := template - shifted.OffsetXDots, _ = intOption(c.Printer.Options, "offset_x") - shifted.OffsetYDots, _ = intOption(c.Printer.Options, "offset_y") - for _, fault := range shifted.ValidateOn(head, len(c.Pricing.Tiers)) { - fault.Field = "printer.template." + fault.Field - faults = append(faults, fault) - } - } - - // 30. journal.max_rows ≥ 100: below that a purge would erase the day's weighings, - // which are the only data of a station that cannot be rebuilt. - if c.Journal.MaxRows < 100 { - fail("journal.max_rows", "%d est sous le plancher de 100 pesées conservées", c.Journal.MaxRows) - } - - // 31. admin.password_hash and admin.recovery_code_hash are USABLE when present. - // - // # Empty is not a fault, and that is a correction - // - // A station is installed WITHOUT a password: §14.4 says the delivered configuration - // is the export of §11.5, "qui ne porte aucun secret", and the first access is the - // recovery code printed on the installation sheet. Refusing an empty field put such - // a station OUT OF SERVICE (§11.3), so it could not weigh either — and weighing is - // the one thing it must do whatever else is wrong. What answers "aucun mot de passe - // n'est posé" is now the administration itself, which offers the recovery code. - // - // # What IS a fault: a hash nothing can match - // - // The delivered file carried « for-the-delivered-configurationg ». It parses, and its - // payload is EXACTLY the 32 bytes argon2id produces — so a length check would not - // have caught it either. It matches no password at all, `config validate` and - // `doctor` both declared it sound, and install.ps1, seeing a non-empty recovery - // field, skipped drawing a real code: the installation sheet went out blank and the - // station was locked out for good. - for _, secret := range []struct{ field, hash string }{ - {"admin.password_hash", c.Admin.PasswordHash}, - {"admin.recovery_code_hash", c.Admin.RecoveryCodeHash}, - } { - switch { - case secret.hash == "": - // Documented state of a station between its installation and its first access. - case !wellFormedArgon2id(secret.hash): - fail(secret.field, "l'empreinte n'est pas une chaîne argon2id de la forme $argon2id$v=19$m=…,t=…,p=…$sel$empreinte") - case !usableArgon2id(secret.hash): - fail(secret.field, "l'empreinte est un remplissage : son corps est du texte, là où argon2id produit des octets tirés au sort — aucun mot de passe ne peut y correspondre") - } - } - - // 32. catalog.fallback_category belongs to the categories. It is what makes "the - // grid is empty because of an unexpected letter" impossible (§10.2 bis). - categoryCodes := make([]string, 0, len(c.Catalog.Categories)) - present := make(map[string]bool, len(c.Catalog.Categories)) - for _, category := range c.Catalog.Categories { - categoryCodes = append(categoryCodes, category.Code) - present[category.Code] = true - } - if !present[c.Catalog.FallbackCategory] { - failWith("catalog.fallback_category", categoryCodes, - "%q ne désigne aucune catégorie : une lettre hors F/L/V/A n'aurait plus où atterrir", - c.Catalog.FallbackCategory) - } - - // 33. Category codes unique. - seen := make(map[string]bool, len(c.Catalog.Categories)) - for i, category := range c.Catalog.Categories { - if seen[category.Code] { - fail(fmt.Sprintf("catalog.categories[%d].code", i), "le code %q est déclaré deux fois", category.Code) - } - seen[category.Code] = true - } - - // 34. min_readable_ratio ∈ [0,1] -- the ABSOLUTE guard, on UNREADABLE rows - // (§10.4a). - if ratio, ok := c.Catalog.Options.Ratio("min_readable_ratio"); ok && (ratio < 0 || ratio > 1) { - fail("catalog.options.min_readable_ratio", "%v hors bornes [0, 1] : c'est une proportion de lignes lisibles", ratio) - } - - // 35. Colours as #RRGGBB. - for i, category := range c.Catalog.Categories { - if !wellFormedColor(category.Color) { - fail(fmt.Sprintf("catalog.categories[%d].color", i), "%q n'est pas une couleur #RRGGBB", category.Color) - } - } - - // 36. poll_interval_s ≥ 1. The stability check needs two consecutive polls, so a - // zero interval would read a file while the producer is still writing it. - if interval, ok := c.Catalog.Options.Int("poll_interval_s"); ok && interval < 1 { - fail("catalog.options.poll_interval_s", "%d est sous le plancher d'une seconde", interval) - } - - // 37. copies: NO LONGER A CONTROL OF ITS OWN. The bound is declared by the driver - // that owns the key and applied by control 7, which checks printer.options - // against the schema THAT driver declares. - // - // Held here, it named a key of a driver the core cannot see, and it was one of - // THREE bounds on one figure: this rule and the option schema said [1, 10], while - // raster.Settings.Validate accepted anything up to the six digits of the - // field. The same number therefore got two different answers depending on whether - // it was checked as a configuration or as a setting, and the disagreement could - // only be found by reading all three. There is now one constant, - // raster.MaxConfiguredCopies, declared beside the other bounds of the manual, and - // nothing moves for the parc. - // - // What is given up is what control 3 gave up on `port`: on an EMPTY registry -- - // `openscale config validate` on a laptop -- the schema check is skipped - // altogether, so the bound is no longer applied at validation time. It is still - // applied where it decides something, at the construction of the driver, and a - // bound that only a printer's own package can state is worth more than one the - // core repeats (§5.2, E1). - - // 38. offset_x/y RECOMPOSED with the geometry of the template (mineur-2): the ±1 - // dot arrows of the admin screen invite that adjustment, so it must be bounded - // by the geometry and not merely by ±99. The message names the admissible - // maximum instead of just saying no. - // The margin is the one THIS head leaves: a bound counted at another pitch would - // refuse an adjustment the printer would have accepted. - if templateExists && resolutionUsable && head.DotsPerMM == template.Media.DotsPerMM { - maxX, maxY := template.MaxOffsetDotsOn(head, len(c.Pricing.Tiers)) - if offset, ok := intOption(c.Printer.Options, "offset_x"); ok && (offset < 0 || offset > maxX) { - fail("printer.options.offset_x", - "%d dots hors bornes [0, %d] pour le gabarit %q : au-delà, le contenu encré sortirait de l'étiquette", - offset, maxX, c.Printer.Template) - } - if offset, ok := intOption(c.Printer.Options, "offset_y"); ok && (offset < 0 || offset > maxY) { - fail("printer.options.offset_y", - "%d dots hors bornes [0, %d] pour le gabarit %q : au-delà, le contenu encré sortirait de l'étiquette", - offset, maxY, c.Printer.Template) - } - } - - // 39. No HTTP(S) host behind a DROP DIRECTORY (important-11). A source that declares - // one watches a directory it can list and delete from; one that demands an account - // and a password is a different source, with a different acknowledgement — and a - // "local" directory reached that way would be the Z: drive of the legacy - // application under another name. - // - // The rule reads the SCHEMA and names no source. It used to be an `if` on - // `local_drop`, which was true only because `local_drop` was the only source that - // watched a directory; it now holds for the next one without this file being - // edited (ADR-052). - // - // Its second half — « local_drop carries neither user nor password » — is GONE - // and not lost: control 9 already refuses a key the chosen source does not - // declare, and it now names the source that does. Two controls for one fact is how - // a third source ends up refused by the one nobody remembered to extend. - for _, schema := range optionsUsedAs(reg.CatalogSources, c.Catalog.Type, UseDropDirectory) { - if value, ok := c.Catalog.Options.Text(schema.Key); ok && isHTTPURL(value) { - failWith("catalog.options."+schema.Key, sourcesFetchingByURL(reg.CatalogSources), - "%q est un hôte HTTP(S) derrière un chemin de dépôt : c'est une source qui va chercher le fichier sur un partage qu'il faut choisir", - value) - } - } - - // 40. max_weighable_drop ∈ [0, 0.5] -- the RELATIVE guard, on WEIGHABLE products - // (§10.4b, important-13). - if drop, ok := c.Catalog.Options.Ratio("max_weighable_drop"); ok && (drop < 0 || drop > 0.5) { - fail("catalog.options.max_weighable_drop", "%v hors bornes [0, 0,5] : c'est une baisse relative du nombre de produits pesables", drop) - } - - // 41. roll_capacity ≥ 50. Below that the 90 % alert would fire on the first - // labels of a fresh roll and teach a volunteer to ignore it. - if capacity, ok := c.Printer.Options.Int("roll_capacity"); ok && capacity < 50 { - fail("printer.options.roll_capacity", "%d est sous le plancher de 50 étiquettes", capacity) - } - - // 42. A SERIAL transport is forbidden for the printer: a label weighs 16 ko, that - // is about 17 s at 9 600 bauds (§8.3). - if hasTransport && known(serialTransports, strings.ToLower(transport)) { - failWith("printer.options.transport", - []string{TransportWinspool, TransportDevfile, TransportTCP, TransportFile}, - "un transport série est interdit pour l'imprimante : une étiquette pèse 16 ko, soit environ 17 s à 9 600 bauds") - } - - // 43. Every price carried by a DELIVERED configuration file verifies - // 0 ≤ price ≤ 999 999 cents -- the third and last imposition of MaxUnitPrice, - // with the DDL (§12.3) and the price rule of §10.3. Since §11.5 it is an - // ORDINARY configuration control, applied to a file like any other; it used to - // validate compiled values, that is, source code (ADR-026). - faults = append(faults, CheckPrice("limits.max_amount_cents", c.Limits.MaxAmount)...) - - // 44. catalog.images.source in the list, and path readable FROM THE CONTEXT OF THE - // SERVICE when the source is image_directory. - if !known(imageSources(), c.Catalog.Images.Source) { - failWith("catalog.images.source", imageSources(), "source d'images inconnue %q", c.Catalog.Images.Source) - } - if c.Catalog.Images.Source == ImageSourceDirectory { - switch { - case c.Catalog.Images.Path == "": - // Empty is legitimate: it means /product_images/, a directory the - // service owns. - case reg.Paths == nil: - // No probe: we validate the form, we cannot validate the existence. - default: - if err := reg.Paths.Readable(c.Catalog.Images.Path); err != nil { - fail("catalog.images.path", "%q n'est pas lisible depuis le contexte du service (%s)", - c.Catalog.Images.Path, err) - } - } - } - - // 45. max_image_size_kb ∈ [16, 4096] AND max_image_size_kb × 1024 ≤ - // max_file_size_mb × 1 048 576: an image cannot be allowed to exceed the file - // that contains it (§10.7). The largest image really observed is 11 kB, the - // real file 527 kB. - imageKB, hasImageKB := c.Catalog.Options.Int("max_image_size_kb") - if hasImageKB && (imageKB < 16 || imageKB > 4096) { - fail("catalog.options.max_image_size_kb", "%d ko hors bornes [16, 4096]", imageKB) - } - if fileMB, ok := c.Catalog.Options.Int("max_file_size_mb"); ok && hasImageKB { - if imageKB*1024 > fileMB*1_048_576 { - fail("catalog.options.max_image_size_kb", - "%d ko dépasse le plafond du fichier qui la contient (%d Mo) : une image ne peut pas être plus grosse que son catalogue", - imageKB, fileMB) - } - } - - // 46. A NAMED drop directory must be one the SERVICE can really work in (§10.1). - // Empty is the shipped case -- /catalog/incoming, which the service owns - // and creates -- so there is nothing to probe. A nil probe means "we cannot - // know": `openscale config validate` on a laptop validates the form and not the - // existence, exactly like control 44 on catalog.images.path. - // - // Like 39 it reads the schema: WHICH key names a directory is the source's - // declaration, and this file has no business holding a second copy of it. - if reg.Paths != nil { - for _, schema := range optionsUsedAs(reg.CatalogSources, c.Catalog.Type, UseDropDirectory) { - directory, ok := c.Catalog.Options.Text(schema.Key) - if !ok { - continue - } - if named := strings.TrimSpace(directory); named != "" { - if err := reg.Paths.Droppable(named); err != nil { - fail("catalog.options."+schema.Key, "%s", err) - } - } - } - } - - // 47. REMOVED, and its number left as a hole the way 37's was (ADR-044): §11.3 names - // its controls by number, so renumbering what follows would falsify every - // reference written elsewhere. - // - // It said « a drop directory means nothing to a WebDAV share ». That was true, and - // it was already what control 9 refuses — a key the chosen source does not declare - // — for every source, present and to come. The only thing 47 added was its - // sentence, and that sentence moved into control 9, which now NAMES the source - // that does declare the key. - - // 48. update.repository is an owner/repo PAIR, never a URL. - // - // This is the only field of the file that says where privileged code will - // come from: the station downloads that repository's release and runs it as - // LocalSystem. Accepting a whole address here would make writing the - // configuration equivalent to running arbitrary code on the four stations. - // The host is compiled in; see UpdateConfig. - if !repositoryShape.MatchString(c.Update.Repository) { - fail("update.repository", - "%q n'est pas un dépôt de la forme propriétaire/projet : ce champ ne prend pas d'adresse web", - c.Update.Repository) - } - - // 49. ui.grid_columns is GridColumnsAutomatic, or a count between MinGridColumns - // and MaxGridColumns. - // - // The fault carries BOTH the range and the meaning of zero, because the two are - // of different natures and only one of them is a number of columns. Somebody who - // writes 1 is asking for a denser grid; if the refusal only named the interval, - // they would read « 1 est hors de [3, 12] » and never learn that the grid they - // had back is written 0 -- which looks, on a file, exactly like « aucune - // colonne ». - if c.UI.GridColumns != GridColumnsAutomatic && - (c.UI.GridColumns < MinGridColumns || c.UI.GridColumns > MaxGridColumns) { - failWith("ui.grid_columns", gridColumnChoices(), - "%d n'est pas un nombre de colonnes que la grille sait montrer", c.UI.GridColumns) - } - - return faults -} - -// CheckPrice reports the fault a price carried by a delivered configuration file -// breaks, or nothing. -// -// It is the SINGLE implementation of control 43, called by Config.Validate and by -// whoever loads the demonstration products and flv_demo.csv: three files, one rule, -// so that MaxUnitPrice cannot be enforced differently in three places. -func CheckPrice(field string, price Cents) []Fault { - if price < 0 || price > MaxUnitPrice { - return []Fault{{ - Field: field, - Message: fmt.Sprintf("%d hors bornes [0, %d] centimes", price, MaxUnitPrice), - }} - } - return nil -} - -// validateNumberingPlan reports the faults of controls 17 to 19 on a numbering -// plan. -// -// It reuses the very check init() runs at start-up, so the two can never diverge: -// what stops the process is what the administration screen would explain. -func validateNumberingPlan(plan map[string]PrefixPlan) []Fault { - if err := validatePlan(plan); err != nil { - return []Fault{{ - Field: "barcode.plan", - Message: fmt.Sprintf("le plan de numérotation interne est incohérent : %s", err), - }} - } - return nil -} - -// validateOptions reports every fault the options of one driver break against the -// schema THE DRIVER DECLARES. -// -// An unregistered driver -- no descriptor at all -- yields no fault: inventing a -// schema for a driver that has not been written yet would be a second source of -// truth for something the driver owns (ADR-025). -// family is the whole list the descriptor was drawn from — every scale, every printer, -// every catalog source this binary carries. It is read for ONE purpose: telling somebody -// which driver declares the key they typed under the wrong one. -func validateOptions(field string, options DriverOptions, descriptor *DriverDescriptor, - family []DriverDescriptor) []Fault { - if descriptor == nil { - return nil - } - var faults []Fault - declared := make(map[string]bool, len(descriptor.Options)) - names := make([]string, 0, len(descriptor.Options)) - for _, schema := range descriptor.Options { - declared[schema.Key] = true - names = append(names, schema.Key) - } - sort.Strings(names) - - for _, schema := range descriptor.Options { - path := field + "." + schema.Key - raw, ok := options[schema.Key] - if !ok || (schema.Required && isEmptyText(raw)) { - if schema.Required { - faults = append(faults, Fault{ - Field: path, - Message: fmt.Sprintf("option exigée par le driver %q", descriptor.ID), - }) - } - continue - } - faults = append(faults, schema.check(path, raw)...) - } - for _, key := range options.Keys() { - if declared[key] { - continue - } - // A key nobody declared is a refusal; a key ANOTHER driver of the same family - // declares is a piece of advice, and it is the one that matters — `directory` - // under a WebDAV share, `username` under a local drop, `queue` under a TCP - // transport are all the same mistake: the right key, the wrong driver. Saying so - // is what the two dedicated controls that used to name `local_drop` and `webdav` - // by hand were really worth (ADR-052). - message := fmt.Sprintf("option inconnue du driver %q", descriptor.ID) - if declaredBy := driversDeclaring(family, key, descriptor.ID); len(declaredBy) > 0 { - message = fmt.Sprintf("%s : c'est %s qui la déclare", message, - quotedList(declaredBy)) - } - faults = append(faults, Fault{Field: field + "." + key, Message: message, Values: names}) - } - return faults -} - -// quotedList spells a list of driver names the way a fault reads it aloud. -func quotedList(names []string) string { - quoted := make([]string, 0, len(names)) - for _, name := range names { - quoted = append(quoted, fmt.Sprintf("%q", name)) - } - if len(quoted) < 2 { - return strings.Join(quoted, "") - } - return strings.Join(quoted[:len(quoted)-1], ", ") + " ou " + quoted[len(quoted)-1] -} - -// isEmptyText reports whether a raw option value is the empty string, which is how a -// file spells a field nobody filled in. -// -// It is what makes a REQUIRED option refuse `"port": ""` the way it refuses a missing -// key: the two are the same thing for whoever is standing in front of the station, and -// the schema check alone would accept the empty string as a perfectly good text value. -// An optional option, on the contrary, is legitimately empty — `address` is empty on -// every station whose transport is winspool. -func isEmptyText(raw json.RawMessage) bool { - value, ok := DriverOptions{"": raw}.Text("") - return ok && value == "" -} - -// check reports the faults one raw value breaks against this schema entry. -func (s OptionSchema) check(field string, raw json.RawMessage) []Fault { - fault := func(format string, args ...any) []Fault { - return []Fault{{Field: field, Message: fmt.Sprintf(format, args...)}} - } - single := DriverOptions{s.Key: raw} - switch s.Kind { - case OptionText: - if _, ok := single.Text(s.Key); !ok { - return fault("attendu : %s", s.Kind) - } - case OptionBool: - if _, ok := single.Bool(s.Key); !ok { - return fault("attendu : %s", s.Kind) - } - case OptionInt: - value, ok := single.Int(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - if s.Max != 0 && (value < s.Min || value > s.Max) { - return fault("%d hors bornes [%d, %d]", value, s.Min, s.Max) - } - case OptionRatio: - value, ok := single.Ratio(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - // The bounds are declared IN PER MILLE, so no float ever enters a - // declaration; the comparison converts once, here. - if s.Max != 0 && (value < float64(s.Min)/1000 || value > float64(s.Max)/1000) { - return fault("%v hors bornes [%v, %v]", value, float64(s.Min)/1000, float64(s.Max)/1000) - } - case OptionEnum: - value, ok := single.Text(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - if len(s.Values) > 0 && !known(s.Values, value) { - return []Fault{{ - Field: field, - Message: fmt.Sprintf("valeur inconnue %q", value), - Values: s.Values, - }} - } - case OptionHostPort: - value, ok := single.Text(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - if value == "" { - return nil // an unused option, such as address when the transport is winspool - } - if err := checkHostPort(value); err != nil { - return fault("%q n'est pas une adresse hôte:port valide (%s)", value, err) - } - case OptionURL: - value, ok := single.Text(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - if value != "" && !isHTTPURL(value) { - return fault("%q n'est pas une URL http ou https absolue", value) - } - case OptionGroup: - nested, ok := single.Group(s.Key) - if !ok { - return fault("attendu : %s", s.Kind) - } - // A nested group has no family: the only driver that could declare its keys is the - // one that declared the group, so there is nobody to point at. - return validateOptions(field, nested, &DriverDescriptor{ID: s.Key, Options: s.Options}, nil) - } - return nil -} - -// stabilityModes reports the two admissible values of stability.mode. -func stabilityModes() []string { return []string{ModeAdvisory, ModeBlocking} } - -// timeoutActions reports the three admissible values of stability.on_timeout. -func timeoutActions() []string { - return []string{OnTimeoutWarnAndPrint, OnTimeoutReject, OnTimeoutManualEntry} -} - -// imageSources reports the three admissible values of catalog.images.source. -func imageSources() []string { - return []string{ImageSourceCSV, ImageSourceDirectory, ImageSourceNone} -} - -// gridColumnChoices reports what control 49 accepts, in the two natures the value -// has. -// -// It says a range in words where the other lists of this file enumerate values, and -// that is the point rather than an oversight: « Automatique » is not one more notch at -// the end of a slider, it is a different kind of answer, and a bare list of eleven -// numbers would spell zero like the other ten. -func gridColumnChoices() []string { - return []string{ - fmt.Sprintf("%d — automatique : la grille suit l'écran, comme aujourd'hui", GridColumnsAutomatic), - fmt.Sprintf("%d à %d — ce nombre de colonnes sur tous les écrans", MinGridColumns, MaxGridColumns), - } -} - -// intOption reads an option that must be a whole number of dots, and reports -// whether it was there and readable. -func intOption(options DriverOptions, key string) (int, bool) { - value, ok := options.Int(key) - return int(value), ok -} - -// CheckListenAddress reports why an address cannot be listened on, and nil when it can. -// -// It is exported so that whoever accepts a listening address from OUTSIDE the file — -// `serve --listen`, and nothing else so far — judges it by the very rule control 2 -// judges network.listen by. A second implementation in the command layer would drift, -// and the station would end up refusing an address its own administration screen -// accepts, or the other way round. -func CheckListenAddress(address string) error { return checkHostPort(address) } - -// checkHostPort reports why an address is not a usable host:port. -func checkHostPort(address string) error { - if address == "" { - return fmt.Errorf("adresse vide") - } - host, port, err := net.SplitHostPort(address) - if err != nil { - return err - } - number, err := strconv.Atoi(port) - if err != nil || number < 1 || number > 65535 { - return fmt.Errorf("port %q hors bornes [1, 65535]", port) - } - // An empty host is legitimate: ":8085" listens on every interface, which is what - // admin_on_lan describes. - _ = host - return nil -} - -// isHTTPURL reports whether a value is an absolute http or https URL. -func isHTTPURL(value string) bool { - parsed, err := url.Parse(value) - if err != nil { - return false - } - return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" -} - -// wellFormedColor reports whether a colour is spelled #RRGGBB. -func wellFormedColor(color string) bool { - if len(color) != 7 || color[0] != '#' { - return false - } - for i := 1; i < len(color); i++ { - c := color[i] - hex := c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' - if !hex { - return false - } - } - return true -} - -// wellFormedArgon2id reports whether a hash is an argon2id PHC string. -// -// The shape is checked, never the cost: raising m, t or p is a legitimate -// hardening, and a validation that froze them would refuse a configuration that is -// SAFER than the one it was written against. -func wellFormedArgon2id(hash string) bool { - parts := strings.Split(hash, "$") - if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { - return false - } - if !strings.HasPrefix(parts[2], "v=") { - return false - } - for _, parameter := range []string{"m=", "t=", "p="} { - if !strings.Contains(parts[3], parameter) { - return false - } - } - return isBase64Raw(parts[4], 8) && isBase64Raw(parts[5], 16) -} - -// usableArgon2id reports whether a hash could have come out of argon2id at all. -// -// Being well formed is not enough, and the delivered configuration is the proof: its -// payload decoded to « for-the-delivered-configurationg », thirty-two bytes of typed -// text where argon2id writes thirty-two bytes drawn at random. What gives a placeholder -// away is therefore not its length but its ALPHABET — thirty-two random bytes are all -// printable ASCII once in 10^14, which is never. -// -// It is not this check that repairs the defect: emptying the field does. This is what -// stops the same gesture from coming back without a sound. -func usableArgon2id(hash string) bool { - parts := strings.Split(hash, "$") - if len(parts) != 6 { - return false - } - key, err := base64.RawStdEncoding.DecodeString(parts[5]) - if err != nil || len(key) == 0 { - return false - } - for _, b := range key { - if b < 0x20 || b > 0x7e { - return true - } - } - return false -} - -// isBase64Raw reports whether s is unpadded base64 of at least minimum characters. -func isBase64Raw(s string, minimum int) bool { - if len(s) < minimum { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || - c == '+' || c == '/' || c == '-' || c == '_' - if !ok { - return false - } - } - return true -} - -// sortedKeys reports the keys of a map in a stable order, which is what makes both -// the canonical JSON and the sequence of faults reproducible. -func sortedKeys[V any](m map[string]V) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for key := range m { - out = append(out, key) - } - sort.Strings(out) - return out -} - -// --- Fingerprint --------------------------------------------------------------- - -// CanonicalJSON returns the canonical JSON of a value: keys sorted, no whitespace, -// whole numbers in plain decimal. -// -// Canonical and not merely compact, and that is the point of §11.4: two -// configurations that are semantically identical but serialised with a different -// key order must NOT cut the serial port in the middle of a service. Whole numbers -// are re-emitted in decimal so that 9600 and 9.6e3 -- two spellings of the same -// baud rate -- cannot produce two fingerprints. -func CanonicalJSON(value any) ([]byte, error) { - encoded, err := json.Marshal(value) - if err != nil { - return nil, err - } - decoder := json.NewDecoder(bytes.NewReader(encoded)) - decoder.UseNumber() - var generic any - if err := decoder.Decode(&generic); err != nil { - return nil, err - } - var buffer bytes.Buffer - if err := writeCanonical(&buffer, generic); err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - -// writeCanonical writes one decoded JSON value in canonical form. -func writeCanonical(buffer *bytes.Buffer, value any) error { - switch typed := value.(type) { - case nil: - buffer.WriteString("null") - case bool: - if typed { - buffer.WriteString("true") - } else { - buffer.WriteString("false") - } - case json.Number: - buffer.WriteString(canonicalNumber(typed)) - case string: - writeJSONString(buffer, typed) - case []any: - buffer.WriteByte('[') - for i, item := range typed { - if i > 0 { - buffer.WriteByte(',') - } - if err := writeCanonical(buffer, item); err != nil { - return err - } - } - buffer.WriteByte(']') - case map[string]any: - buffer.WriteByte('{') - for i, key := range sortedKeys(typed) { - if i > 0 { - buffer.WriteByte(',') - } - writeJSONString(buffer, key) - buffer.WriteByte(':') - if err := writeCanonical(buffer, typed[key]); err != nil { - return err - } - } - buffer.WriteByte('}') - default: - return fmt.Errorf("domain: valeur JSON non canonisable de type %T", value) - } - return nil -} - -// writeJSONString writes one quoted JSON string. -// -// encoding/json cannot fail on a string -- invalid UTF-8 is replaced by U+FFFD, not -// rejected -- so there is no error to propagate, and no unreachable branch is left -// in a function the fingerprint of every configuration goes through. -func writeJSONString(buffer *bytes.Buffer, value string) { - encoded, _ := json.Marshal(value) - buffer.Write(encoded) -} - -// exactIntegerFloat is 2^53, beyond which a float64 no longer holds every integer. -const exactIntegerFloat = 1 << 53 - -// canonicalNumber reports the one spelling of a JSON number. -// -// It exists so that 9600, 9.6e3 and 0.10 cannot produce three fingerprints of one -// configuration. The float64 detour is a canonicalisation of BYTES and never carries -// a quantity -- a mass, a price and a length are integers in this application, and -// the detour is refused past 2^53 rather than silently losing a digit. -func canonicalNumber(number json.Number) string { - if whole, err := strconv.ParseInt(number.String(), 10, 64); err == nil { - return strconv.FormatInt(whole, 10) - } - value, err := number.Float64() - if err != nil { - return number.String() - } - if value <= -exactIntegerFloat || value >= exactIntegerFloat { - // Too big to be re-spelled without dropping a digit: the original wins. - return number.String() - } - if value == float64(int64(value)) { - return strconv.FormatInt(int64(value), 10) - } - return strconv.FormatFloat(value, 'g', -1, 64) -} - -// BlockFingerprint reports the SHA-256 of the canonical JSON of one configuration -// block, as eight hexadecimal characters. -// -// It is what Station.Reload compares to decide whether a block REALLY changed -// (§11.4): a normalised comparison and not reflect.DeepEqual over raw JSON, so that -// a reformatted file does not close the serial port under a customer. -func BlockFingerprint(block any) string { - canonical, err := CanonicalJSON(block) - if err != nil { - return strings.Repeat("?", fingerprintLength) - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:])[:fingerprintLength] -} - -// Fingerprint reports the eight characters the dashboard shows, so that "do the -// four stations display the same string?" is a check anybody can do by eye (§11.5). -// -// IT IS COMPUTED ON THE HARDWARE-FREE VIEW, Export(false), with modified_at and -// _readme cleared -- and it has to be, otherwise it could never do the one job it -// exists for: four stations of one homogeneous fleet differ by their number, their -// name, their COM port and their print queue, and each file was written at a -// different instant. What the figure compares is what MUST be identical: the price -// grid, the safeguards, the template, the categories, the retention -- and, since the -// export stopped dropping the three option maps whole, the label offset, the -// darkness, the speed, the serial settings of the scale and the import guards of the -// catalog. -// -// That second half of the list was never decided HERE, and it must not be: the -// fingerprint FOLLOWS the export, because what two stations must have in common is -// exactly what a clone carries over, and one definition of that is worth more than -// two that drift apart. Widening what travels widens the digest with it -- a station -// whose darkness alone was raised now shows a different string, and it should: it -// does not print like the other three. -func (c *Config) Fingerprint() string { - subject := c.Export(false) - subject.ModifiedAt, subject.Readme = time.Time{}, "" - return BlockFingerprint(subject) -} - -// secretOptionKeys names the option keys whose VALUE never leaves the station, in ANY -// of the three option maps, at ANY depth, in BOTH modes of includeHardware. -// -// It is the list internal/diag/redact.go redacts by, and it is deliberately the SAME -// list: an export and a diagnostic archive are two doors to the outside, and two doors -// must not have two levels of rigour. redact.go owns the reason the match is on the NAME -// and not on a path -- « a driver option added in two years and called `token` is caught -// without anybody remembering to come back here » -- and it now reads this list instead -// of keeping its own copy. The list lives HERE, in the package that depends on nothing, -// because that is the only direction the dependency can go: internal/diag imports -// internal/domain, never the reverse (§5.2). -// -// What is NOT in it, and must not be: `url`. redact.go removes an address because an -// archive is handed to whoever offers to help, and the private host of a cooperative is -// not ours to publish. A catalog URL is not a secret, it designates a SITE -- so it is -// stationSpecificOptions that names it, and a HARDWARE export, which is the backup of one -// station, legitimately keeps it. -var secretOptionKeys = map[string]bool{ - "password": true, - "password_hash": true, - "recovery_code_hash": true, - "passphrase": true, - "secret": true, - "token": true, - "api_key": true, - "apikey": true, - "credential": true, - "credentials": true, - "private_key": true, -} - -// IsSecretOptionKey reports whether a driver option under this name carries a secret. -// -// Exported so that the archive redacts exactly what the export refuses to carry. The -// match is case-insensitive: a file written by hand may well spell `Password`, and a -// secret that leaves because of a capital letter is still a secret that left. -func IsSecretOptionKey(key string) bool { - return secretOptionKeys[strings.ToLower(key)] -} - -// stationSpecificOptions names the driver option keys an export must not carry when -// it is meant to seed ANOTHER station. -// -// Everything else in the three option maps travels, and that default is deliberate: a -// driver option is a setting the parc SHARES until somebody proves otherwise, and the -// proof is written here. Dropping the maps whole was the opposite default, and it made -// INSTALLATION.md lie -- it promises the label offset travels with the cloned -// configuration, and printer.options went out with it. -// -// Two kinds of key are named, and only those two: what designates ONE station (a -// serial port, a Windows queue), and what designates ONE SITE's infrastructure (a -// host, an account, a path). A value that is neither belongs to the parc. -// -// It names KEYS OF A MAP, and it can name nothing else. A site value that lives in a -// TYPED field -- catalog.images.path -- is out of reach of withoutKeys and is dropped -// by Export itself; that is where to look before adding a name here. -// -// It names a key and never a PATH: each list applies to its whole option tree, so a -// serial port under `gateway` and a print queue under `fallback.deeper` go the same way -// as the ones at the first level. The previous version named the group « fallback » in -// the code, which meant one nested object out of all the ones a driver may declare. -var stationSpecificOptions = struct { - scale []string - printer []string - catalog []string -}{ - // COM8 on this station, something else on the next one. - scale: []string{"port"}, - // A Windows queue name differs per machine: the « _2 » of « SATO WS408_2 » is a - // duplicate suffix Windows added, measured on PC-RECEPTION. And `address` is a - // HOST -- 192.168.0.43:9100 on the bench -- which this repository never ships - // (docs/00-donnees-retirees.md). - printer: []string{"queue", "address", "path"}, - // The share and the account belong to one site. The password leaves in NO mode, - // and that is handled before this list, unconditionally. - catalog: []string{"url", "username", "directory"}, -} - -// oneOf reports the membership test of a strip list, in the shape withoutKeys takes. -func oneOf(keys []string) func(string) bool { - return func(key string) bool { return known(keys, key) } -} - -// withoutKeys returns the options minus every key drop names, AT ANY DEPTH. -// -// Depth is the whole point. An option map is free-form -- the administration screen -// builds its form from the schema the DRIVER declares (§9.3) -- so a driver is free to -// nest a gateway, a proxy or a second fallback under any name it invents, and a strip -// that only visited the ground floor let a password walk out from the first. Nothing -// here names a group: only leaf keys are named, and every object is visited. -// -// An absent block stays absent: returning an empty map where there was none would -// turn « ce poste ne déclare pas d'imprimante » into « ce poste déclare une -// imprimante sans rien dedans », which validates differently. -func withoutKeys(options DriverOptions, drop func(key string) bool) DriverOptions { - if options == nil { - return nil - } - out := options.clone() - for key, raw := range options { - if drop(key) { - delete(out, key) - continue - } - if stripped, changed := strippedValue(raw, drop); changed { - out[key] = stripped - } - } - return out -} - -// strippedValue returns one raw option value minus what drop names anywhere inside it, -// and whether anything moved. -// -// The « whether » is not a convenience: re-encoding a value reorders its keys and drops -// the whitespace the file spelled it with, so an untouched value is handed back BYTE FOR -// BYTE instead of being rewritten. A value that does not decode is left alone rather than -// dropped, for the same reason a malformed group used to be: hiding it would send the -// operator looking for a key the file still carries. -// -// It walks a generic tree, and it duplicates fifteen lines of internal/diag's redactTree -// on purpose, because the two cannot be one function. That one REPLACES a value with a -// visible marker and keeps the key, so a reader can tell « ce poste n'a pas de mot de -// passe » from « le mot de passe a été retiré » ; this one DELETES the key, because an -// export is merged field by field into a target (§11.5) and a marker would overwrite the -// target's own secret with the word « [caviardé] ». What the two do share is the thing -// that rots -- the list of names -- and secretOptionKeys is where they share it. -func strippedValue(raw json.RawMessage, drop func(key string) bool) (json.RawMessage, bool) { - // UseNumber, so that a baud rate re-encodes as 9600 and never as 9.6e+03: decoding - // a number into `any` yields a float64, and no float carries a quantity here. - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var node any - if err := decoder.Decode(&node); err != nil { - return raw, false - } - stripped, changed := strippedTree(node, drop) - if !changed { - return raw, false - } - encoded, err := json.Marshal(stripped) - if err != nil { - return raw, false - } - return encoded, true -} - -// strippedTree walks a decoded JSON value and removes every member drop names. -// -// Lists are walked too, and that is not zeal: a driver that declares its mirrors as a -// list of objects puts one set of credentials per entry, and a walk that only knew about -// objects would ship all of them. -func strippedTree(node any, drop func(key string) bool) (any, bool) { - switch value := node.(type) { - case map[string]any: - out := make(map[string]any, len(value)) - changed := false - for key, child := range value { - if drop(key) { - changed = true - continue - } - stripped, touched := strippedTree(child, drop) - out[key] = stripped - changed = changed || touched - } - return out, changed - case []any: - out := make([]any, len(value)) - changed := false - for i, child := range value { - stripped, touched := strippedTree(child, drop) - out[i] = stripped - changed = changed || touched - } - return out, changed - } - return node, false -} - -// Export returns a copy of the configuration fit to leave the station. -// -// With includeHardware false it drops station.number, station.name, network, the -// admin fingerprints, catalog.images.path and the option keys of -// stationSpecificOptions -- a serial port, a print queue, a host, an account, a -// path. What is left is what four stations of one fleet share, and it is what "clone -// a station" copies (§11.5). -// -// NO SECRET EVER LEAVES, whatever includeHardware says: the admin password, and every -// option secretOptionKeys names, in the three option maps and at any depth. On import a -// station without a password runs the "first access" journey, which IMPOSES setting one -// -- an exported password would turn a fleet into four stations sharing one secret -// nobody chose. The promise used to be written as "two secrets" and enforced by one -// delete on one key of one map: a password under scale.options.gateway went out in clear -// text, and so did anything a driver called `token`. -// -// The result is NOT a loadable configuration: without a station number it fails -// control 1. It is meant to be MERGED into a target, field by field, with the diff -// preview of §11.5. -func (c *Config) Export(includeHardware bool) Config { - out := *c - out.retired = nil - out.Admin.PasswordHash = "" - - out.Scale.Options = withoutKeys(c.Scale.Options, IsSecretOptionKey) - out.Printer.Options = withoutKeys(c.Printer.Options, IsSecretOptionKey) - out.Catalog.Options = withoutKeys(c.Catalog.Options, IsSecretOptionKey) - - // Copies, so that a caller editing the export cannot reach into the - // configuration the station is running on. - out.Pricing.Tiers = append([]PriceTier(nil), c.Pricing.Tiers...) - out.Pricing.SecondaryCodes = append([]string(nil), c.Pricing.SecondaryCodes...) - out.Catalog.Categories = append([]Category(nil), c.Catalog.Categories...) - - if includeHardware { - return out - } - out.Station.Number, out.Station.Name = 0, "" - out.Network = NetworkConfig{} - out.Admin.RecoveryCodeHash = "" - out.Scale.Options = withoutKeys(out.Scale.Options, oneOf(stationSpecificOptions.scale)) - out.Printer.Options = withoutKeys(out.Printer.Options, oneOf(stationSpecificOptions.printer)) - out.Catalog.Options = withoutKeys(out.Catalog.Options, oneOf(stationSpecificOptions.catalog)) - // catalog.images.path designates ONE SITE just as catalog.options.url does -- a - // share on the NAS, a letter mapped on this machine -- and it left with the export - // for as long as it existed, because the strip list only knows how to delete a KEY - // and this is a FIELD. images.source stays: "the pictures come with the CSV" is an - // answer the whole fleet shares, and a clone that lost it would fall back on the - // names of the products. - out.Catalog.Images.Path = "" - return out -} - -// Retired reports the dotted paths of the retired keys the file carried, in a -// stable order. -// -// It exists so that the administration screen can say « supprimez ces lignes » -// while pointing at the file, and so that a test can assert on the FILE rather than -// on a structure in which a retired key cannot exist. -func (c *Config) Retired() []string { - return append([]string(nil), c.retired...) -} - -// RetiredKeysError reports that a Config still carries a key control 20 refuses. -// -// It is what ConfigStore.Save returns instead of writing: the struct is about to be -// marshalled, and marshalling is what LAUNDERS the key -- encoding/json already -// dropped it once, at decode, and the field it stood for (a member's discount, for -// coef_num) goes with it. A caller that reaches Save without having checked first -// gets this instead of a file that decodes clean on the very next read. -type RetiredKeysError struct { - // Keys are the dotted paths Config.Retired returned. - Keys []string -} - -// Error names the retired keys. -func (e *RetiredKeysError) Error() string { - return fmt.Sprintf("domain: config still carries retired key(s): %s", strings.Join(e.Keys, ", ")) -} - -// RefuseIfRetired reports a *RetiredKeysError when the configuration still carries a -// key control 20 refuses, and nil otherwise. -// -// It is deliberately narrower than Validate: Validate needs Registries and can fail -// on a print queue this station does not have, which is not a reason to refuse -// WRITING a configuration that was already sitting on disk. This checks the one -// thing that must never reach a file regardless of everything else about it -- and -// it is cheap enough to run on every save, by every caller, including the ones that -// will never think to call Validate first (the recovery route does not: a rescue -// cannot be made to depend on the very validation that put the station out of -// service to begin with). -func (c *Config) RefuseIfRetired() error { - if keys := c.Retired(); len(keys) > 0 { - return &RetiredKeysError{Keys: keys} - } - return nil -} diff --git a/internal/domain/config_json.go b/internal/domain/config_json.go new file mode 100644 index 0000000..aad4e27 --- /dev/null +++ b/internal/domain/config_json.go @@ -0,0 +1,155 @@ +package domain + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// This file holds HOW THE FILE SPELLS the configuration: the codecs of the three +// domain types that predate config.json, and the one Config declares for itself. +// +// Their codecs live here rather than beside the types on purpose: safeguard.go says +// what a THRESHOLD is, quantity.go what a ROUNDING is, product.go what a CATEGORY +// is. How a file spells them is the business of the file, and the key names of §11.2 +// then live in exactly one place. + +// limitsJSON is the on-file shape of WeighingLimits. +type limitsJSON struct { + EmptyMax Grams `json:"empty_max_g"` + BasketCheckEnabled bool `json:"basket_check_enabled"` + BasketMin Grams `json:"basket_min_g"` + BasketMax Grams `json:"basket_max_g"` + MinWeight Grams `json:"min_weight_g"` + MaxWeight Grams `json:"max_weight_g"` + MaxTare Grams `json:"max_tare_g"` + MinUnits int `json:"min_units"` + MaxUnits int `json:"max_units"` + MaxAmount Cents `json:"max_amount_cents"` +} + +// MarshalJSON writes the thresholds under the key names of §11.2. +func (l WeighingLimits) MarshalJSON() ([]byte, error) { + return json.Marshal(limitsJSON{ + EmptyMax: l.EmptyMax, BasketCheckEnabled: l.BasketCheckEnabled, + BasketMin: l.BasketMin, BasketMax: l.BasketMax, + MinWeight: l.MinWeight, MaxWeight: l.MaxWeight, MaxTare: l.MaxTare, + MinUnits: l.MinUnits, MaxUnits: l.MaxUnits, MaxAmount: l.MaxAmount, + }) +} + +// UnmarshalJSON reads the thresholds, keeping whatever the block does not name. +// +// Keeping rather than zeroing is what makes the field-by-field merge of an import +// (§11.5) behave: a partial block overlays the target instead of erasing it. +func (l *WeighingLimits) UnmarshalJSON(raw []byte) error { + on := limitsJSON{ + EmptyMax: l.EmptyMax, BasketCheckEnabled: l.BasketCheckEnabled, + BasketMin: l.BasketMin, BasketMax: l.BasketMax, + MinWeight: l.MinWeight, MaxWeight: l.MaxWeight, MaxTare: l.MaxTare, + MinUnits: l.MinUnits, MaxUnits: l.MaxUnits, MaxAmount: l.MaxAmount, + } + if err := json.Unmarshal(raw, &on); err != nil { + return err + } + *l = WeighingLimits{ + EmptyMax: on.EmptyMax, BasketCheckEnabled: on.BasketCheckEnabled, + BasketMin: on.BasketMin, BasketMax: on.BasketMax, + MinWeight: on.MinWeight, MaxWeight: on.MaxWeight, MaxTare: on.MaxTare, + MinUnits: on.MinUnits, MaxUnits: on.MaxUnits, MaxAmount: on.MaxAmount, + } + return nil +} + +// categoryJSON is the on-file shape of Category. +type categoryJSON struct { + Code string `json:"code"` + Label string `json:"label"` + Rank int `json:"rank"` + Color string `json:"color"` + Visible bool `json:"visible"` +} + +// MarshalJSON writes a category under the key names of §11.2. +func (c Category) MarshalJSON() ([]byte, error) { + return json.Marshal(categoryJSON{c.Code, c.Label, c.Rank, c.Color, c.Visible}) +} + +// UnmarshalJSON reads a category, keeping whatever the object does not name. +func (c *Category) UnmarshalJSON(raw []byte) error { + on := categoryJSON{c.Code, c.Label, c.Rank, c.Color, c.Visible} + if err := json.Unmarshal(raw, &on); err != nil { + return err + } + *c = Category{on.Code, on.Label, on.Rank, on.Color, on.Visible} + return nil +} + +// roundingSpellings maps the configuration wording of a policy to the policy. +var roundingSpellings = map[string]RoundingPolicy{ + "half_up": RoundHalfUp, + "truncate": RoundTowardZero, + "half_even": RoundHalfToEven, +} + +// RoundingSpellings reports the three admissible spellings of a rounding policy, +// in a stable order, so that a fault and an admin drop-down list name the same +// three values. +func RoundingSpellings() []string { return []string{"half_up", "truncate", "half_even"} } + +// MarshalJSON writes the policy as the word config.json uses. +func (p RoundingPolicy) MarshalJSON() ([]byte, error) { return json.Marshal(p.String()) } + +// UnmarshalJSON reads one of the three words of RoundingSpellings. +// +// An unknown word is an ERROR and not a fault, so the configuration never holds a +// policy nobody declared: Divide would silently truncate, and a station would +// under-charge by a cent for months without anyone able to name why. §11.4 turns +// this into the 400 Bad Request of step 1, and the error names the three values. +func (p *RoundingPolicy) UnmarshalJSON(raw []byte) error { + var word string + if err := json.Unmarshal(raw, &word); err != nil { + return fmt.Errorf("domain: un arrondi est un mot parmi %s : %w", + strings.Join(RoundingSpellings(), ", "), err) + } + policy, ok := roundingSpellings[word] + if !ok { + return fmt.Errorf("domain: arrondi inconnu %q, valeurs admises : %s", + word, strings.Join(RoundingSpellings(), ", ")) + } + *p = policy + return nil +} + +// UnmarshalJSON reads the configuration and remembers the retired keys it carried. +// +// The scan happens HERE and not in Validate because Validate only sees a Go +// structure, in which a retired key cannot exist: encoding/json drops what no field +// claims. Control 20 has to refuse the FILE, so the file is what gets read. +func (c *Config) UnmarshalJSON(raw []byte) error { + // The generic pass FIRST, so that "this is not JSON" and "this JSON puts a word + // where a number belongs" are two distinct errors rather than one. + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var document any + if err := decoder.Decode(&document); err != nil { + return err + } + // An alias, otherwise this method calls itself. + type alias Config + shadow := alias(*c) + if err := json.Unmarshal(raw, &shadow); err != nil { + return err + } + *c = Config(shadow) + // A file that names no repository -- one written before this block existed, + // or one that carries it empty -- runs on the default. Refusing here would put + // a station out of service over a field nobody meant to set. + if c.Update.Repository == "" { + c.Update.Repository = DefaultUpdateRepository + } + c.retired = nil + scanRetired("", document, &c.retired) + return nil +} diff --git a/internal/domain/config_json_test.go b/internal/domain/config_json_test.go new file mode 100644 index 0000000..431c22a --- /dev/null +++ b/internal/domain/config_json_test.go @@ -0,0 +1,134 @@ +// This file holds HOW THE FILE SPELLS the configuration: the key names of §11.2, +// the three words a rounding may take, and the difference between a READ ERROR and +// a FAULT. +// +// That difference is the whole point of the last test: a word where a number +// belongs is refused at decode, and never carried as far as the validation. + +package domain + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestConfigRoundTripsThroughJSON(t *testing.T) { + original := loadDelivered(t) + encoded, err := json.Marshal(original) + if err != nil { + t.Fatalf("encodage : %v", err) + } + var reread Config + if err := json.Unmarshal(encoded, &reread); err != nil { + t.Fatalf("décodage : %v", err) + } + if faults := reread.Validate(testRegistries()); len(faults) != 0 { + t.Fatalf("aller-retour JSON invalide :\n%s", strings.Join(fieldsOf(faults), "\n")) + } + if reread.Fingerprint() != original.Fingerprint() { + t.Fatal("un aller-retour JSON ne doit pas changer l'empreinte") + } +} + +// TestLimitsUseTheKeyNamesOfTheDocument guards the bridge between the domain type, +// which carries no tags, and the file, which names its thresholds in grams. +func TestLimitsUseTheKeyNamesOfTheDocument(t *testing.T) { + encoded, err := json.Marshal(WeighingLimits{MinWeight: 10, MaxWeight: 99_999, MaxAmount: 99_999}) + if err != nil { + t.Fatalf("encodage : %v", err) + } + for _, key := range []string{ + "empty_max_g", "basket_check_enabled", "basket_min_g", "basket_max_g", + "min_weight_g", "max_weight_g", "max_tare_g", "min_units", "max_units", + "max_amount_cents", + } { + if !strings.Contains(string(encoded), `"`+key+`"`) { + t.Errorf("clé %q absente de %s", key, encoded) + } + } + var limits WeighingLimits + if err := json.Unmarshal(encoded, &limits); err != nil { + t.Fatalf("décodage : %v", err) + } + if limits.MinWeight != 10 || limits.MaxWeight != 99_999 || limits.MaxAmount != 99_999 { + t.Fatalf("aller-retour = %+v", limits) + } +} + +func TestCategoriesUseTheKeyNamesOfTheDocument(t *testing.T) { + encoded, err := json.Marshal(Category{Code: "fruits", Label: "Fruits", Rank: 1, Color: "#C0392B", Visible: true}) + if err != nil { + t.Fatalf("encodage : %v", err) + } + const wanted = `{"code":"fruits","label":"Fruits","rank":1,"color":"#C0392B","visible":true}` + if string(encoded) != wanted { + t.Fatalf("catégorie = %s, attendu %s", encoded, wanted) + } + var category Category + if err := json.Unmarshal(encoded, &category); err != nil { + t.Fatalf("décodage : %v", err) + } + if category.Code != "fruits" || category.Rank != 1 || !category.Visible { + t.Fatalf("aller-retour = %+v", category) + } +} + +func TestRoundingPolicyIsSpelledLikeTheFile(t *testing.T) { + for word, wanted := range roundingSpellings { + var policy RoundingPolicy + if err := json.Unmarshal([]byte(`"`+word+`"`), &policy); err != nil { + t.Fatalf("décodage de %q : %v", word, err) + } + if policy != wanted { + t.Errorf("%q → %v, attendu %v", word, policy, wanted) + } + encoded, err := json.Marshal(wanted) + if err != nil { + t.Fatalf("encodage : %v", err) + } + if string(encoded) != `"`+word+`"` { + t.Errorf("%v → %s, attendu %q", wanted, encoded, word) + } + } +} + +// TestUnknownRoundingIsAnErrorAndNotASilentTruncation: an unknown word must never +// land in the configuration, because Divide would then silently truncate and a +// station would under-charge by a cent for months. +func TestUnknownRoundingIsAnErrorAndNotASilentTruncation(t *testing.T) { + var policy RoundingPolicy + err := json.Unmarshal([]byte(`"commercial"`), &policy) + if err == nil { + t.Fatal("un arrondi inconnu doit être une erreur de lecture") + } + for _, word := range RoundingSpellings() { + if !strings.Contains(err.Error(), word) { + t.Errorf("le message doit nommer les valeurs admises, %q absent de %q", word, err) + } + } +} + +// TestMalformedBlocksAreReadErrorsAndNotFaults is step 1 of §11.4: what +// json.Unmarshal cannot read is a 400 Bad Request, not a list of faults. +func TestMalformedBlocksAreReadErrorsAndNotFaults(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`pas du json`), &config); err == nil { + t.Error("un fichier illisible doit être une erreur de lecture") + } + if err := json.Unmarshal([]byte(`{"version": "un"}`), &config); err == nil { + t.Error("un type incompatible doit être une erreur de lecture") + } + var limits WeighingLimits + if err := json.Unmarshal([]byte(`{"empty_max_g": "cinq"}`), &limits); err == nil { + t.Error("un seuil en lettres doit être une erreur de lecture") + } + var category Category + if err := json.Unmarshal([]byte(`{"rank": "premier"}`), &category); err == nil { + t.Error("un rang en lettres doit être une erreur de lecture") + } + var policy RoundingPolicy + if err := json.Unmarshal([]byte(`3`), &policy); err == nil { + t.Error("un arrondi numérique doit être une erreur de lecture") + } +} diff --git a/internal/domain/config_test.go b/internal/domain/config_test.go index 3f6c6c1..9c5abcf 100644 --- a/internal/domain/config_test.go +++ b/internal/domain/config_test.go @@ -1,172 +1,21 @@ +// This file holds what the DELIVERED FILE declares, and what a file that stays +// SILENT about a block is worth. +// +// The two questions are one: every key of §11.2 has a documented behaviour when it +// is absent, and a station that refused its own delivered configuration over a new +// key is what made that rule explicit (28/07/2026). + package domain import ( - "bytes" "encoding/base64" "encoding/json" - "errors" - "fmt" "os" "path/filepath" - "strconv" "strings" "testing" - "time" ) -// deliveredConfigPath is the file lot L9 ships and the installer copies. It is read -// from the tests rather than reproduced in Go, because reproducing it would -// reintroduce exactly the second source of truth ADR-026 removes. -var deliveredConfigPath = filepath.Join("..", "..", "testdata", "config-lacagette.json") - -// loadDelivered returns a FRESH copy of the delivered configuration. -// -// Fresh for every case, and it matters: DriverOptions is a map, so a struct copy -// would let one broken case leak its mutation into the next one. -func loadDelivered(t *testing.T) Config { - t.Helper() - raw, err := os.ReadFile(deliveredConfigPath) - if err != nil { - t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) - } - var config Config - if err := json.Unmarshal(raw, &config); err != nil { - t.Fatalf("décodage de %s : %v", deliveredConfigPath, err) - } - return config -} - -// testRegistries declares the drivers the shipped configuration names, with the -// schema each of them would declare. -// -// The bounds of the options a NUMBERED CONTROL already owns -- roll_capacity, the two -// offsets, poll_interval_s, the two ratios, the two size ceilings -- are deliberately -// left open here: the control names the field and the reason, and declaring the same -// bound twice would report one mistake as two faults. -// -// `copies` is the exception, and it is the one option whose bound NO control owns any -// more: it belongs to the driver, raster.MaxConfiguredCopies, and the schema is the only -// place it is stated. What is declared here is that same [1, 10], as the real driver -// declares it -- a bound left open here would make control 7 look toothless on the one -// key it is now solely responsible for. -func testRegistries() Registries { - serial := []OptionSchema{ - {Key: "port", Kind: OptionText, Required: true}, - {Key: "baud", Kind: OptionInt}, - {Key: "bits", Kind: OptionInt}, - {Key: "parity", Kind: OptionEnum, Values: []string{"N", "E", "O"}}, - {Key: "stop", Kind: OptionInt}, - {Key: "backoff_min_ms", Kind: OptionInt}, - {Key: "backoff_max_ms", Kind: OptionInt}, - } - printerOptions := []OptionSchema{ - {Key: "transport", Kind: OptionEnum, Values: []string{ - TransportWinspool, TransportDevfile, TransportTCP, TransportFile}}, - {Key: "queue", Kind: OptionText}, - {Key: "path", Kind: OptionText}, - {Key: "address", Kind: OptionHostPort}, - {Key: "fallback", Kind: OptionGroup, Options: []OptionSchema{ - {Key: "enabled", Kind: OptionBool}, - {Key: "transport", Kind: OptionEnum, Values: []string{ - TransportWinspool, TransportDevfile, TransportTCP, TransportFile}}, - {Key: "queue", Kind: OptionText}, - }}, - {Key: "darkness", Kind: OptionInt}, - {Key: "speed", Kind: OptionInt}, - {Key: "offset_x", Kind: OptionInt}, - {Key: "offset_y", Kind: OptionInt}, - {Key: "invert_bits", Kind: OptionBool}, - {Key: "copies", Kind: OptionInt, Min: 1, Max: 10}, - {Key: "roll_capacity", Kind: OptionInt}, - } - commonCatalog := []OptionSchema{ - {Key: "separator", Kind: OptionText}, - {Key: "poll_interval_s", Kind: OptionInt}, - {Key: "stable_polls", Kind: OptionInt}, - {Key: "max_file_size_mb", Kind: OptionInt}, - {Key: "max_image_size_kb", Kind: OptionInt}, - {Key: "min_readable_ratio", Kind: OptionRatio}, - {Key: "max_weighable_drop", Kind: OptionRatio}, - {Key: "max_archives", Kind: OptionInt}, - {Key: "archive_days", Kind: OptionInt}, - {Key: "failures_before_reject", Kind: OptionInt}, - } - webdav := append([]OptionSchema{ - {Key: "url", Kind: OptionURL, Required: true}, - {Key: "username", Kind: OptionText}, - {Key: "password", Kind: OptionText}, - }, commonCatalog...) - // `directory` belongs to local_drop ALONE, exactly as the real descriptor declares - // it: it is the one source that watches a directory of this machine. Declaring it - // here is what makes control 46 the only voice on that field -- an undeclared key - // would already be refused by control 9, and the case would prove nothing. - localDrop := append([]OptionSchema{ - {Key: "directory", Kind: OptionText, Use: UseDropDirectory}, - }, commonCatalog...) - - return Registries{ - Scales: []DriverDescriptor{ - {ID: "gram-xfoc-rs", Label: "GRAM XFOC RS", Options: serial}, - {ID: "gram-xfoc-plus", Label: "GRAM XFOC +", Options: serial}, - }, - Printers: []DriverDescriptor{ - // The raster driver declares the head of the parc, exactly as - // cmd/openscale does: it is what rules 3 and 4 measure a template against. - // `preview` declares nothing, because it inks no paper. - {ID: PrinterRaster, Label: "Raster", Options: printerOptions, Capabilities: ReferenceHead()}, - {ID: PrinterSBPL, Label: "SBPL", Options: printerOptions, Capabilities: ReferenceHead()}, - {ID: PrinterPreview, Label: "Aperçu"}, - }, - Transports: []DriverDescriptor{ - {ID: TransportWinspool, Label: "file Windows"}, - {ID: TransportDevfile, Label: "nœud d'impression"}, - {ID: TransportTCP, Label: "imprimante réseau"}, - {ID: TransportFile, Label: "fichier"}, - }, - CatalogSources: []DriverDescriptor{ - {ID: CatalogSourceLocalDrop, Label: "répertoire de dépôt", Options: localDrop}, - {ID: CatalogSourceWebDAV, Label: "partage WebDAV", Options: webdav}, - }, - } -} - -// unreadablePaths is the PathChecker of a service that cannot see a path. -type unreadablePaths struct{} - -func (unreadablePaths) Readable(string) error { return fmt.Errorf("accès refusé") } -func (unreadablePaths) Droppable(string) error { return fmt.Errorf("accès refusé") } - -// setOption writes one driver option the way a file would carry it. -func setOption(t *testing.T, options DriverOptions, key string, value any) { - t.Helper() - raw, err := json.Marshal(value) - if err != nil { - t.Fatalf("encodage de l'option %s : %v", key, err) - } - options[key] = raw -} - -// fieldsOf reports the faulty fields, for a failure message that names them all. -func fieldsOf(faults []Fault) []string { - out := make([]string, 0, len(faults)) - for _, fault := range faults { - out = append(out, fault.String()) - } - return out -} - -// findFault returns the first fault on a field, or nil. -func findFault(faults []Fault, field string) *Fault { - for i := range faults { - if faults[i].Field == field { - return &faults[i] - } - } - return nil -} - -// --- The delivered file -------------------------------------------------------- - func TestDeliveredConfigurationValidatesWithoutAFault(t *testing.T) { config := loadDelivered(t) if faults := config.Validate(testRegistries()); len(faults) != 0 { @@ -305,807 +154,6 @@ func TestDeliveredConfigurationCarriesNoRealURL(t *testing.T) { } } -// --- The 47 controls, one broken configuration at a time ------------------------ - -// brokenConfiguration is one wrong configuration and the field the volunteer must -// see named. -type brokenConfiguration struct { - control string - name string - mutate func(*testing.T, *Config) - // registries overrides the drivers and templates, for the two controls that bear - // on a template rather than on a value of the file. - registries func(Registries) Registries - field string -} - -// brokenConfigurations is the corpus of §11.3: at least 26 wrong configurations, -// each of them checking that the RIGHT field is named. -func brokenConfigurations() []brokenConfiguration { - return []brokenConfiguration{ - { - control: "1", name: "numéro de poste hors bornes", - mutate: func(_ *testing.T, c *Config) { c.Station.Number = 0 }, - field: "station.number", - }, { - control: "2", name: "adresse d'écoute illisible", - mutate: func(_ *testing.T, c *Config) { c.Network.Listen = "127.0.0.1" }, - field: "network.listen", - }, { - control: "3", name: "la balance « manual » a quitté l'énumération", - mutate: func(_ *testing.T, c *Config) { c.Scale.Type = SourceManual }, - field: "scale.type", - }, { - control: "3", name: "la balance « replay » a quitté l'énumération", - mutate: func(_ *testing.T, c *Config) { c.Scale.Type = SourceReplay }, - field: "scale.type", - }, { - control: "3", name: "protocole de balance inconnu", - mutate: func(_ *testing.T, c *Config) { c.Scale.Type = "gram-xfoc-turbo" }, - field: "scale.type", - }, { - // Control 6 and no longer 3: the key is named by the schema the GRAM driver - // declares, not by the core. - control: "6", name: "poste avec balance sans port série", - mutate: func(_ *testing.T, c *Config) { delete(c.Scale.Options, "port") }, - field: "scale.options.port", - }, { - control: "4", name: "driver d'impression inconnu", - mutate: func(_ *testing.T, c *Config) { c.Printer.Type = "gdi" }, - field: "printer.type", - }, { - control: "5", name: "« manual » n'est pas une source de catalogue", - mutate: func(_ *testing.T, c *Config) { c.Catalog.Type = CatalogSourceManual }, - field: "catalog.type", - }, { - control: "5", name: "source de catalogue inconnue", - mutate: func(_ *testing.T, c *Config) { c.Catalog.Type = "ftp" }, - field: "catalog.type", - }, { - control: "6", name: "option de balance du mauvais type", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Scale.Options, "baud", "rapide") - }, - field: "scale.options.baud", - }, { - control: "7", name: "option d'imprimante inconnue du driver", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "noircissement", 3) - }, - field: "printer.options.noircissement", - }, { - control: "8", name: "transport inconnu du registre", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "transport", "smb") - }, - field: "printer.options.transport", - }, { - control: "9", name: "url webdav qui n'est pas une URL", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "url", "dav.example.org:8001") - }, - field: "catalog.options.url", - }, { - control: "10", name: "grille de tarifs vide", - mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers = nil }, - field: "pricing.tiers", - }, { - control: "11", name: "une remise sur le tarif de référence", - mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[1].Discount = 200 }, - field: "pricing.tiers[1].discount_percent", - }, { - control: "12", name: "code de tarif déclaré deux fois", - mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[1].Code = c.Pricing.Tiers[0].Code }, - field: "pricing.tiers[1].code", - }, { - control: "13", name: "remise négative", - mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[0].Discount = -1 }, - field: "pricing.tiers[0].discount_percent", - }, { - control: "13", name: "remise au-dessus de 100 %", - mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[0].Discount = FullDiscount + 1 }, - field: "pricing.tiers[0].discount_percent", - }, { - control: "14", name: "primary_code hors grille", - mutate: func(_ *testing.T, c *Config) { c.Pricing.PrimaryCode = "GHOST" }, - field: "pricing.primary_code", - }, { - control: "15", name: "reference_code hors grille", - mutate: func(_ *testing.T, c *Config) { c.Pricing.ReferenceCode = "GHOST" }, - field: "pricing.reference_code", - }, { - control: "16", name: "code secondaire hors grille", - mutate: func(_ *testing.T, c *Config) { c.Pricing.SecondaryCodes = []string{"GHOST"} }, - field: "pricing.secondary_codes[0]", - }, { - control: "21", name: "gabarit sans résolution", - mutate: func(_ *testing.T, c *Config) {}, - registries: func(reg Registries) Registries { - broken := IdenticalTemplate() - broken.Media.DotsPerMM = 0 - reg.Templates = map[string]Template{DefaultTemplateName: broken} - return reg - }, - field: "template.media.dots_per_mm", - }, { - control: "22", name: "fenêtre du panier inversée", - mutate: func(_ *testing.T, c *Config) { c.Limits.BasketMin, c.Limits.BasketMax = -270, -282 }, - field: "limits.basket_min_g", - }, { - control: "22", name: "fenêtre du panier positive", - mutate: func(_ *testing.T, c *Config) { c.Limits.BasketMin, c.Limits.BasketMax = 270, 282 }, - field: "limits.basket_min_g", - }, { - control: "23", name: "poids maximal au-delà de la capacité du champ NNDDD", - mutate: func(_ *testing.T, c *Config) { c.Limits.MaxWeight = 100_000 }, - field: "limits.max_weight_g", - }, { - control: "24", name: "plus de 99 unités", - mutate: func(_ *testing.T, c *Config) { c.Limits.MaxUnits = 100 }, - field: "limits.max_units", - }, { - control: "25", name: "montant maximal au-delà du champ de prix", - mutate: func(_ *testing.T, c *Config) { c.Limits.MaxAmount = 100_000 }, - field: "limits.max_amount_cents", - }, { - control: "26", name: "timeout sous la durée de stabilité exigée", - mutate: func(_ *testing.T, c *Config) { c.Stability.Timeout = Duration(200 * time.Millisecond) }, - field: "stability.timeout_ms", - }, { - control: "27", name: "plancher de péremption sous la seconde", - mutate: func(_ *testing.T, c *Config) { c.Stability.ExpiryFloor = Duration(800 * time.Millisecond) }, - field: "stability.expiry_floor_ms", - }, { - control: "27", name: "plancher de péremption au-dessus du plafond", - mutate: func(_ *testing.T, c *Config) { c.Stability.ExpiryFloor = Duration(6 * time.Second) }, - field: "stability.expiry_ceiling_ms", - }, { - control: "28", name: "mode de stabilité en français", - mutate: func(_ *testing.T, c *Config) { c.Stability.Mode = "informatif" }, - field: "stability.mode", - }, { - control: "28", name: "action de timeout inconnue", - mutate: func(_ *testing.T, c *Config) { c.Stability.OnTimeout = "avertir_et_imprimer" }, - field: "stability.on_timeout", - }, { - control: "29", name: "gabarit inexistant", - mutate: func(_ *testing.T, c *Config) { c.Printer.Template = "weighing_imaginaire" }, - field: "printer.template", - }, { - control: "29", name: "gabarit qui viole les neuf règles dures", - mutate: func(_ *testing.T, c *Config) {}, - registries: func(reg Registries) Registries { - broken := IdenticalTemplate() - // A module below the readability floor: no scanner reads it (rule 9). - broken.Symbol.ModuleMilliDots = 900 - reg.Templates = map[string]Template{DefaultTemplateName: broken} - return reg - }, - field: "printer.template.symbol.module_milli_dots", - }, { - control: "30", name: "journal sous le plancher de 100 pesées", - mutate: func(_ *testing.T, c *Config) { c.Journal.MaxRows = 50 }, - field: "journal.max_rows", - }, { - // Le remplissage RÉEL que la configuration livrée a porté. Il passe la - // vérification de forme, et son corps fait EXACTEMENT les 32 octets - // d'argon2id : seule la nature de ces octets le trahit. - control: "31", name: "empreinte de remplissage, tapée à la main", - mutate: func(_ *testing.T, c *Config) { - c.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" - }, - field: "admin.password_hash", - }, { - control: "31", name: "mot de passe en clair au lieu d'une empreinte argon2id", - mutate: func(_ *testing.T, c *Config) { c.Admin.PasswordHash = "admin" }, - field: "admin.password_hash", - }, { - control: "31", name: "empreinte de code de secours malformée", - mutate: func(_ *testing.T, c *Config) { c.Admin.RecoveryCodeHash = "$argon2id$v=19$sel$empreinte" }, - field: "admin.recovery_code_hash", - }, { - control: "32", name: "catégorie de repli hors liste", - mutate: func(_ *testing.T, c *Config) { c.Catalog.FallbackCategory = "divers" }, - field: "catalog.fallback_category", - }, { - control: "33", name: "code de catégorie déclaré deux fois", - mutate: func(_ *testing.T, c *Config) { c.Catalog.Categories[1].Code = c.Catalog.Categories[0].Code }, - field: "catalog.categories[1].code", - }, { - control: "34", name: "taux de lisibilité au-delà de 1", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "min_readable_ratio", 1.5) - }, - field: "catalog.options.min_readable_ratio", - }, { - control: "35", name: "couleur de catégorie en français", - mutate: func(_ *testing.T, c *Config) { c.Catalog.Categories[0].Color = "rouge" }, - field: "catalog.categories[0].color", - }, { - control: "36", name: "scrutation à zéro seconde", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "poll_interval_s", 0) - }, - field: "catalog.options.poll_interval_s", - }, { - control: "7", name: "onze exemplaires, hors des bornes que le driver déclare", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "copies", 11) - }, - field: "printer.options.copies", - }, { - control: "38", name: "décalage qui sortirait le contenu encré de l'étiquette", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "offset_x", 5) - }, - field: "printer.options.offset_x", - }, { - control: "39", name: "hôte HTTPS derrière un chemin de dépôt", - mutate: func(t *testing.T, c *Config) { - c.Catalog.Type = CatalogSourceLocalDrop - setOption(t, c.Catalog.Options, "url", "https://dav.example.org:8001/") - }, - field: "catalog.options.url", - }, { - control: "39", name: "mot de passe sur un répertoire qu'on possède", - mutate: func(t *testing.T, c *Config) { - c.Catalog.Type = CatalogSourceLocalDrop - delete(c.Catalog.Options, "url") - delete(c.Catalog.Options, "username") - }, - field: "catalog.options.password", - }, { - control: "40", name: "baisse de pesables au-delà de la moitié", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "max_weighable_drop", 0.9) - }, - field: "catalog.options.max_weighable_drop", - }, { - control: "41", name: "rouleau de 20 étiquettes", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "roll_capacity", 20) - }, - field: "printer.options.roll_capacity", - }, { - control: "42", name: "transport série pour l'imprimante", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Printer.Options, "transport", "serial") - }, - field: "printer.options.transport", - }, { - control: "43", name: "prix négatif dans un fichier livré", - mutate: func(_ *testing.T, c *Config) { c.Limits.MaxAmount = -1 }, - field: "limits.max_amount_cents", - }, { - control: "44", name: "source d'images inconnue", - mutate: func(_ *testing.T, c *Config) { c.Catalog.Images.Source = "jpeg" }, - field: "catalog.images.source", - }, { - control: "44", name: "répertoire d'images illisible depuis le service", - mutate: func(_ *testing.T, c *Config) { - c.Catalog.Images.Source = ImageSourceDirectory - c.Catalog.Images.Path = `Z:\photos` - }, - registries: func(reg Registries) Registries { - reg.Paths = unreadablePaths{} - return reg - }, - field: "catalog.images.path", - }, { - control: "45", name: "image plafonnée sous 16 ko", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "max_image_size_kb", 8) - }, - field: "catalog.options.max_image_size_kb", - }, { - control: "45", name: "image autorisée à dépasser le fichier qui la contient", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "max_image_size_kb", 4000) - setOption(t, c.Catalog.Options, "max_file_size_mb", 1) - }, - field: "catalog.options.max_image_size_kb", - }, { - control: "46", name: "répertoire de dépôt hors de portée du service", - mutate: func(t *testing.T, c *Config) { - c.Catalog.Type = CatalogSourceLocalDrop - setOption(t, c.Catalog.Options, "directory", `Z:\catalogue`) - }, - registries: func(reg Registries) Registries { - reg.Paths = unreadablePaths{} - return reg - }, - field: "catalog.options.directory", - }, { - control: "47", name: "répertoire de dépôt derrière un partage WebDAV", - mutate: func(t *testing.T, c *Config) { - setOption(t, c.Catalog.Options, "directory", `D:\catalogue`) - }, - field: "catalog.options.directory", - }, { - control: "48", name: "le dépôt suivi est une adresse web", - mutate: func(_ *testing.T, c *Config) { - c.Update.Repository = "https://github.com/lostmind84/OpenScale" - }, - field: "update.repository", - }, - } -} - -// TestValidateAcceptsAFreeTier is the other edge of check 13 (config.go:914): a -// hundred percent off is a discount a cooperative may legitimately declare, not -// merely the value the "remise au-dessus de 100 %" case in brokenConfigurations -// stops just short of. The zero edge is already pinned by -// TestDeliveredConfigurationValidatesWithoutAFault, whose SOLIDARITY tier carries -// no discount at all. -func TestValidateAcceptsAFreeTier(t *testing.T) { - config := loadDelivered(t) - config.Pricing.Tiers[0].Discount = FullDiscount - if fault := findFault(config.Validate(testRegistries()), "pricing.tiers[0].discount_percent"); fault != nil { - t.Errorf("une remise de 100 %% est refusée : %s", fault.Message) - } -} - -func TestValidateNamesTheRightField(t *testing.T) { - for _, testCase := range brokenConfigurations() { - t.Run("contrôle "+testCase.control+" — "+testCase.name, func(t *testing.T) { - config := loadDelivered(t) - testCase.mutate(t, &config) - registries := testRegistries() - if testCase.registries != nil { - registries = testCase.registries(registries) - } - faults := config.Validate(registries) - if findFault(faults, testCase.field) == nil { - t.Fatalf("aucune faute sur %q ; obtenu :\n%s", - testCase.field, strings.Join(fieldsOf(faults), "\n")) - } - }) - } -} - -// TestTheCorpusCoversTheControls is a guard on the test suite itself: a table that -// quietly shrank would be a validation that quietly stopped being exercised. -// -// Controls 17 to 19 bear on the COMPILED plan and 20 on the RAW file: neither can be -// provoked from a Config structure, so both have their own test and neither belongs -// to this corpus. -// -// 37 is a GAP in the numbering and not a control that stopped being tested: the copy -// count is bounded by the schema the printer driver declares, and the eleven copies that -// used to provoke it are still in the corpus, under control 7. The number is left unused -// rather than reassigned — the numbering is what docs/02-architecture.md §11.3 refers to, -// and a renumbering would silently change what a paragraph names. -func TestTheCorpusCoversTheControls(t *testing.T) { - const wrongConfigurationsFloor = 26 - - corpus := brokenConfigurations() - if len(corpus) < wrongConfigurationsFloor { - t.Fatalf("%d configurations fausses, plancher %d", len(corpus), wrongConfigurationsFloor) - } - covered := map[string]bool{} - for _, testCase := range corpus { - covered[testCase.control] = true - } - for _, control := range []string{ - "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", - "16", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", - "33", "34", "35", "36", "38", "39", "40", "41", "42", "43", "44", "45", - "46", "47", "48", - } { - if !covered[control] { - t.Errorf("le contrôle %s n'a aucune configuration fausse", control) - } - } - for _, control := range []string{"17", "18", "19", "20"} { - if covered[control] { - t.Errorf("le contrôle %s ne se provoque pas depuis une structure Config", control) - } - } -} - -// --- Controls 46 and 47: the drop directory ------------------------------------ - -// TestADirectoryOnWebDAVNamesTheSourceThatWatchesOne was control 47, and it is now -// control 9 doing the same work for every driver family at once. -// -// A key that means nothing for the source declared is a mistake and not a value to ignore -// in silence — that much has not changed. What has changed is who says so: control 47 -// spelled `directory`, `webdav` and `local_drop` by hand inside this package, so no third -// source could be added without editing it. Control 9 reads the SCHEMAS, refuses the key, -// and names whichever driver declares it (ADR-052). -// -// The registries therefore have to be the REAL ones here, where control 47 needed them -// empty to be heard alone: it is the registry that carries the answer now. -func TestADirectoryOnWebDAVNamesTheSourceThatWatchesOne(t *testing.T) { - config := loadDelivered(t) - setOption(t, config.Catalog.Options, "directory", `D:\catalogue`) - - fault := findFault(config.Validate(testRegistries()), "catalog.options.directory") - if fault == nil { - t.Fatal("un répertoire de dépôt déclaré sur webdav doit être refusé") - } - if !strings.Contains(fault.Message, CatalogSourceLocalDrop) { - t.Errorf("le refus ne nomme pas la source qui surveille un répertoire : %s", fault.Message) - } -} - -// TestWithoutAProbeTheFormIsCheckedAndExistenceIsNot: `openscale config validate` on -// a laptop cannot know what the service account sees, and must not invent a refusal. -func TestWithoutAProbeTheFormIsCheckedAndExistenceIsNot(t *testing.T) { - config := loadDelivered(t) - config.Catalog.Type = CatalogSourceLocalDrop - setOption(t, config.Catalog.Options, "directory", `Z:\catalogue`) - - // testRegistries carries no PathChecker, which is the state of a validation run - // outside the service. - if fault := findFault(config.Validate(testRegistries()), "catalog.options.directory"); fault != nil { - t.Fatalf("sans sonde, l'existence n'est pas vérifiée : %s", fault.Message) - } -} - -// TestAnEmptyDirectoryIsNeverProbed: the shipped case names no directory at all, and -// a field somebody opened and left with a space in it names none either. -func TestAnEmptyDirectoryIsNeverProbed(t *testing.T) { - for _, written := range []string{"", " "} { - t.Run(strconv.Quote(written), func(t *testing.T) { - config := loadDelivered(t) - config.Catalog.Type = CatalogSourceLocalDrop - setOption(t, config.Catalog.Options, "directory", written) - registries := testRegistries() - registries.Paths = unreadablePaths{} - - if fault := findFault(config.Validate(registries), "catalog.options.directory"); fault != nil { - t.Fatalf("un répertoire vide est celui du poste, il n'y a rien à sonder : %s", fault.Message) - } - }) - } -} - -// --- Control 20: the retired keys ---------------------------------------------- - -func TestControl20RefusesARetiredPlanKey(t *testing.T) { - raw, err := os.ReadFile(deliveredConfigPath) - if err != nil { - t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) - } - - for _, key := range []string{ - "weight_decimals", "units_field_width", "weight_prefix", - "unit_prefix", "content", "rules_by_prefix", - } { - t.Run(key, func(t *testing.T) { - // The key is injected into the barcode block, which is where every one of - // them used to live. - injected := strings.Replace(string(raw), - `"barcode": { "verify_reference_check_digit": true }`, - `"barcode": { "verify_reference_check_digit": true, "`+key+`": 3 }`, 1) - if injected == string(raw) { - t.Fatal("l'injection n'a rien remplacé : le bloc barcode du fichier livré a changé de forme") - } - var config Config - if err := json.Unmarshal([]byte(injected), &config); err != nil { - t.Fatalf("décodage : %v", err) - } - if got := config.Retired(); len(got) != 1 || got[0] != "barcode."+key { - t.Fatalf("clés supprimées relevées = %v, attendu [barcode.%s]", got, key) - } - faults := config.Validate(testRegistries()) - fault := findFault(faults, "barcode."+key) - if fault == nil { - t.Fatalf("aucune faute sur barcode.%s ; obtenu :\n%s", key, strings.Join(fieldsOf(faults), "\n")) - } - // The message must send the reader back to the compiled plan, otherwise a - // station would keep believing its old width setting applies. - if !strings.Contains(fault.Message, "supprimée") { - t.Errorf("message = %q, il doit dire que la clé est supprimée", fault.Message) - } - }) - } -} - -func TestControl20IgnoresARetiredKeyOutsideTheFile(t *testing.T) { - // A Config built in Go carries none by construction: only a FILE can hold a key - // no field claims. - config := NeutralProfile() - if got := config.Retired(); len(got) != 0 { - t.Fatalf("un profil compilé ne peut porter aucune clé supprimée, obtenu %v", got) - } -} - -// TestOldCoefficientKeysAreRefused is the safety net of ADR-034. encoding/json -// drops what no field claims, so a file of the old format would decode WITHOUT A -// WORD, with every discount at zero: every member would pay the full price, and -// nothing on any screen would say why. Check 20 refuses the file instead. -func TestOldCoefficientKeysAreRefused(t *testing.T) { - for _, key := range []string{"coef_num", "coef_den"} { - raw := []byte(`{"pricing":{"tiers":[{"code":"MEMBER","` + key + `":9}]}}`) - var config Config - if err := json.Unmarshal(raw, &config); err != nil { - t.Fatalf("%s : %v", key, err) - } - retired := config.Retired() - if len(retired) == 0 { - t.Errorf("%s : aucune clé retirée signalée", key) - continue - } - if !strings.Contains(retired[0], key) { - t.Errorf("%s : clé retirée %q, elle doit nommer la clé", key, retired[0]) - } - } -} - -// TestRetiredTileSizeIsRefused covers ADR-035: grid density becomes continuous -// again (clamp() on the front end) and ui.tile_size no longer has any field to -// carry it. A file that still carries it must be refused the way ADR-034 -// refused coef_num, not silently ignored. -func TestRetiredTileSizeIsRefused(t *testing.T) { - raw := []byte(`{"ui":{"tile_size":"medium"}}`) - var config Config - if err := json.Unmarshal(raw, &config); err != nil { - t.Fatalf("décodage : %v", err) - } - retired := config.Retired() - if len(retired) != 1 || retired[0] != "ui.tile_size" { - t.Fatalf("clés retirées = %v, attendu [ui.tile_size]", retired) - } - reason, known := retiredKeys["tile_size"] - if !known || reason == "" { - t.Fatal("tile_size absente de la table des clés retirées, ou sans raison") - } -} - -// TestRetiredCoefficientMessagesPointAtTheNewKey: refusing is only half of it -- -// the message has to say what to write instead, or a volunteer is stuck. -func TestRetiredCoefficientMessagesPointAtTheNewKey(t *testing.T) { - for _, key := range []string{"coef_num", "coef_den"} { - reason, known := retiredKeys[key] - if !known { - t.Errorf("%s absente de la table des clés retirées", key) - continue - } - if !strings.Contains(reason, "discount_percent") { - t.Errorf("%s : message %q, il doit nommer discount_percent", key, reason) - } - } -} - -// TestRefuseIfRetiredNamesTheKeys is the guard ConfigStore.Save calls before writing a -// single byte (ADR-034). It exists because control 20 alone is not enough: Validate -// only runs where a caller remembers to call it, and the recovery route -- the one -// that matters most, because it is a station's only way back in -- never did. -func TestRefuseIfRetiredNamesTheKeys(t *testing.T) { - raw := []byte(`{"pricing":{"tiers":[{"code":"MEMBER","coef_num":9}]}}`) - var config Config - if err := json.Unmarshal(raw, &config); err != nil { - t.Fatalf("décodage : %v", err) - } - - err := config.RefuseIfRetired() - if err == nil { - t.Fatal("une configuration carrying coef_num n'a pas été refusée") - } - var retired *RetiredKeysError - if !errors.As(err, &retired) { - t.Fatalf("l'erreur n'est pas un *RetiredKeysError : %v", err) - } - if len(retired.Keys) != 1 || !strings.Contains(retired.Keys[0], "coef_num") { - t.Fatalf("clés = %v, coef_num attendu", retired.Keys) - } -} - -// TestRefuseIfRetiredAcceptsAConfigBuiltInGo: Retired is filled by UnmarshalJSON -// alone, so a configuration assembled in code -- the neutral profile, or one a test -// builds by hand -- carries none, and nothing legitimate is blocked. -func TestRefuseIfRetiredAcceptsAConfigBuiltInGo(t *testing.T) { - profile := NeutralProfile() - if err := profile.RefuseIfRetired(); err != nil { - t.Fatalf("un profil compilé est refusé : %v", err) - } -} - -// --- Controls 17 to 19: the compiled numbering plan ----------------------------- - -func TestControls17To19OnTheCompiledPlan(t *testing.T) { - if faults := validateNumberingPlan(internalPlan); len(faults) != 0 { - t.Fatalf("le plan livré doit être cohérent, obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) - } - - cases := map[string]map[string]PrefixPlan{ - "préfixe de trois chiffres": { - "049": {"049", ByWeight, 3, 5, 3, " €/kg"}, - }, - "4 + ref + charge + 1 ne fait pas 13": { - "0493": {"0493", ByWeight, 4, 5, 3, " €/kg"}, - }, - "préfixe déclaré sous une autre clé": { - "0493": {"0499", ByWeight, 3, 5, 3, " €/kg"}, - }, - } - for name, plan := range cases { - t.Run(name, func(t *testing.T) { - faults := validateNumberingPlan(plan) - if findFault(faults, "barcode.plan") == nil { - t.Fatalf("aucune faute sur barcode.plan ; obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) - } - }) - } -} - -// --- Everything at once -------------------------------------------------------- - -// TestValidateReportsEveryFaultAtOnce is the property the administration screen -// depends on: a volunteer must not have to fix one line, save, and discover the -// next one. -func TestValidateReportsEveryFaultAtOnce(t *testing.T) { - config := loadDelivered(t) - config.Station.Number = 0 // 1 - config.Network.Listen = "pas une adresse" // 2 - config.Pricing.Tiers[1].Discount = 200 // 11 - config.Pricing.PrimaryCode = "GHOST" // 14 - config.Limits.MaxUnits = 500 // 24 - config.Stability.Mode = "bloquant" // 28 - config.Journal.MaxRows = 1 // 30 - // 31 : un remplissage tapé à la main, qui passe la vérification de forme. - config.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" - config.Catalog.FallbackCategory = "divers" // 32 - config.Catalog.Categories[0].Color = "vert" // 35 - setOption(t, config.Printer.Options, "copies", 99) // 7, sur les bornes du driver - setOption(t, config.Printer.Options, "offset_y", 9) - - faults := config.Validate(testRegistries()) - wanted := []string{ - "station.number", "network.listen", "pricing.tiers[1].discount_percent", - "pricing.primary_code", "limits.max_units", "stability.mode", - "journal.max_rows", "admin.password_hash", "catalog.fallback_category", - "catalog.categories[0].color", "printer.options.copies", "printer.options.offset_y", - } - for _, field := range wanted { - if findFault(faults, field) == nil { - t.Errorf("faute manquante sur %q", field) - } - } - if len(faults) < len(wanted) { - t.Fatalf("%d fautes remontées pour %d erreurs semées :\n%s", - len(faults), len(wanted), strings.Join(fieldsOf(faults), "\n")) - } -} - -// TestFaultsCarryTheAdmissibleValues checks the second half of the contract: when a -// value is wrong and the list of right ones is known, the screen shows the list. -func TestFaultsCarryTheAdmissibleValues(t *testing.T) { - config := loadDelivered(t) - config.Scale.Type = "gram-xfoc-turbo" - config.Stability.OnTimeout = "refuser" - config.Catalog.Images.Source = "jpeg" - - faults := config.Validate(testRegistries()) - for field, wanted := range map[string][]string{ - "scale.type": {"gram-xfoc-plus", "gram-xfoc-rs"}, - "stability.on_timeout": {OnTimeoutWarnAndPrint, OnTimeoutReject, OnTimeoutManualEntry}, - "catalog.images.source": {ImageSourceCSV, ImageSourceDirectory, ImageSourceNone}, - } { - fault := findFault(faults, field) - if fault == nil { - t.Errorf("aucune faute sur %q", field) - continue - } - if len(fault.Values) != len(wanted) { - t.Errorf("%s : valeurs admissibles = %v, attendu %v", field, fault.Values, wanted) - continue - } - for _, value := range wanted { - if !known(fault.Values, value) { - t.Errorf("%s : %q absent des valeurs admissibles %v", field, value, fault.Values) - } - } - } -} - -// TestEmptyRegistriesValidateTheFormNotTheExistence is the behaviour L3 and L5 need -// before a single driver exists. -func TestEmptyRegistriesValidateTheFormNotTheExistence(t *testing.T) { - config := loadDelivered(t) - // A protocol no registry declares: with no registry, nobody can say it is wrong. - config.Scale.Type = "gram-xfoc-turbo" - config.Printer.Type = "gdi" - config.Catalog.Type = "sftp" - - if faults := config.Validate(Registries{}); len(faults) != 0 { - t.Fatalf("un registre vide ne valide que la forme, obtenu :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } - - // The FORM is still validated, and so are the values that were RETIRED with a - // written reason: those do not depend on any registry. - config.Scale.Type = SourceManual - if findFault(config.Validate(Registries{}), "scale.type") == nil { - t.Error("« manual » doit être refusé même sans registre : c'est un état, pas un protocole") - } - config.Scale.Type = "" - config.Printer.Type = "" - faults := config.Validate(Registries{}) - if findFault(faults, "scale.type") == nil { - t.Error("un poste qui déclare une balance doit nommer son protocole") - } - if findFault(faults, "printer.type") == nil { - t.Error("un driver d'impression vide est une faute de forme") - } -} - -// --- Fingerprint --------------------------------------------------------------- - -// TestFingerprintIsStableWhateverTheKeyOrder is the property §11.5 rests on: four -// stations compare eight characters, and a reformatted file must not change them. -func TestFingerprintIsStableWhateverTheKeyOrder(t *testing.T) { - first := loadDelivered(t) - - // Re-serialise and re-read: encoding/json emits the keys in the order of the Go - // fields, which is not the order of the delivered file. - reserialised, err := json.Marshal(first) - if err != nil { - t.Fatalf("encodage : %v", err) - } - var second Config - if err := json.Unmarshal(reserialised, &second); err != nil { - t.Fatalf("décodage : %v", err) - } - if first.Fingerprint() != second.Fingerprint() { - t.Fatalf("empreinte %q après réécriture, %q avant : l'ordre des clés ne doit rien changer", - second.Fingerprint(), first.Fingerprint()) - } - if got := len(first.Fingerprint()); got != fingerprintLength { - t.Fatalf("empreinte de %d caractères, attendu %d", got, fingerprintLength) - } -} - -// TestFingerprintIgnoresWhatDiffersFromStationToStation is what makes a homogeneous -// fleet show ONE string: four stations differ by their number, their name, their COM -// port and their print queue, and each file was written at a different instant. -func TestFingerprintIgnoresWhatDiffersFromStationToStation(t *testing.T) { - station2 := loadDelivered(t) - station3 := loadDelivered(t) - station3.Station.Number = 3 - station3.Station.Name = "Poste 3 — légumes" - station3.ModifiedAt = station2.ModifiedAt.Add(48 * time.Hour) - setOption(t, station3.Scale.Options, "port", "COM3") - setOption(t, station3.Printer.Options, "queue", "SATO WS408_3") - station3.Network.Listen = "127.0.0.1:8086" - station3.Admin.RecoveryCodeHash = "" - - if station2.Fingerprint() != station3.Fingerprint() { - t.Fatalf("empreintes %q et %q : deux postes du même parc doivent afficher la même chaîne", - station2.Fingerprint(), station3.Fingerprint()) - } -} - -// TestFingerprintChangesWhenASharedValueChanges is the other half: a station that -// diverges on something that MUST be identical has to show it. -func TestFingerprintChangesWhenASharedValueChanges(t *testing.T) { - reference := loadDelivered(t) - for name, mutate := range map[string]func(*Config){ - "une remise de tarif": func(c *Config) { c.Pricing.Tiers[0].Discount = 200 }, - "un seuil de panier": func(c *Config) { c.Limits.BasketMin = -300 }, - "le gabarit": func(c *Config) { c.Printer.Template = "weighing_neutral_single" }, - "une catégorie": func(c *Config) { c.Catalog.Categories[0].Visible = false }, - "la rétention du journal": func(c *Config) { c.Journal.MaxDays = 30 }, - // Two stations that disagree here do not show the same grid: one offers fifteen - // tiles the other does not have, and the eight characters have to say so. - "les produits à l'unité montrés": func(c *Config) { c.UI.ShowByUnitProducts = true }, - // Same reason, read from the other side: one station shows seven columns where - // its neighbour follows the screen. Neither is wrong, and a fleet that diverges - // by accident must be able to see it by eye. - "le nombre de colonnes de la grille": func(c *Config) { c.UI.GridColumns = 7 }, - } { - t.Run(name, func(t *testing.T) { - diverging := loadDelivered(t) - mutate(&diverging) - if diverging.Fingerprint() == reference.Fingerprint() { - t.Fatalf("empreinte inchangée (%q) alors que %s a changé", reference.Fingerprint(), name) - } - }) - } -} - // TestAFileSilentAboutTheByUnitProductsHidesThem makes a silent consequence visible. // // UnmarshalJSON applies no default outside update.repository, so a file written before @@ -1159,1187 +207,76 @@ func TestTheDeliveredFilesSayWhatTheyDoOfTheByUnitProducts(t *testing.T) { } } -func TestCanonicalJSONSortsKeysAndDropsWhitespace(t *testing.T) { - canonical, err := CanonicalJSON(json.RawMessage(`{ "b": 1, "a": [ 2, { "d": 3, "c": 4 } ] }`)) +// TestAFileWithoutTheUpdateBlockStillLoads is the symmetric of the defect of +// 28/07/2026, where control 20 made the station refuse its own delivered +// configuration: a file written before this block existed must read back with +// nothing said, and run on the default. +func TestAFileWithoutTheUpdateBlockStillLoads(t *testing.T) { + raw, err := os.ReadFile(deliveredConfigPath) if err != nil { - t.Fatalf("canonisation : %v", err) - } - const wanted = `{"a":[2,{"c":4,"d":3}],"b":1}` - if string(canonical) != wanted { - t.Fatalf("canonique = %s, attendu %s", canonical, wanted) - } -} - -// TestCanonicalJSONNormalisesTheSpellingOfANumber keeps 9600 and 9.6e3 -- two -// spellings of the same baud rate -- from producing two fingerprints, and does the -// same for 0.10 against 0.1. -func TestCanonicalJSONNormalisesTheSpellingOfANumber(t *testing.T) { - cases := [][2]string{ - {`{"baud":9600}`, `{"baud":9.6e3}`}, - {`{"min_readable_ratio":0.9}`, `{"min_readable_ratio":0.90}`}, - {`{"stop":1}`, `{"stop":1.0}`}, + t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) } - for _, pair := range cases { - first, err := CanonicalJSON(json.RawMessage(pair[0])) - if err != nil { - t.Fatalf("canonisation de %s : %v", pair[0], err) - } - second, err := CanonicalJSON(json.RawMessage(pair[1])) - if err != nil { - t.Fatalf("canonisation de %s : %v", pair[1], err) - } - if string(first) != string(second) { - t.Errorf("%s et %s se canonisent en %s et %s", pair[0], pair[1], first, second) - } + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("décodage : %v", err) } - - // Past 2^53 the float detour is refused rather than losing a digit: a big number - // keeps its own spelling. - big, err := CanonicalJSON(json.RawMessage(`{"n":123456789012345678901}`)) + delete(document, "update") + trimmed, err := json.Marshal(document) if err != nil { - t.Fatalf("canonisation : %v", err) - } - if !strings.Contains(string(big), "123456789012345678901") { - t.Errorf("un entier hors int64 doit rester tel quel, obtenu %s", big) + t.Fatalf("encodage : %v", err) } -} - -// TestBlockFingerprintIsWhatReloadCompares checks that a block reserialised with -// another key order does not cut the serial port in the middle of a service. -func TestBlockFingerprintIsWhatReloadCompares(t *testing.T) { - config := loadDelivered(t) - before := BlockFingerprint(config.Scale) - reordered := config.Scale - reordered.Options = DriverOptions{} - for key, value := range config.Scale.Options { - reordered.Options[key] = json.RawMessage(" " + string(value) + " ") + var config Config + if err := json.Unmarshal(trimmed, &config); err != nil { + t.Fatalf("un fichier sans le bloc update ne se relit pas : %v", err) } - if after := BlockFingerprint(reordered); after != before { - t.Fatalf("empreinte de bloc %q puis %q : une réécriture ne doit pas fermer le port série", before, after) + if config.Update.Repository != DefaultUpdateRepository { + t.Errorf("dépôt par défaut = %q, attendu %q", + config.Update.Repository, DefaultUpdateRepository) } - - setOption(t, reordered.Options, "port", "COM3") - if after := BlockFingerprint(reordered); after == before { - t.Fatal("changer le port doit changer l'empreinte du bloc balance") + if fault := findFault(config.Validate(testRegistries()), "update.repository"); fault != nil { + t.Errorf("l'absence du bloc update est traitée comme une faute : %s", fault.Message) } } -// --- Export -------------------------------------------------------------------- - -func TestExportWithoutHardwareDropsWhatBelongsToOneStation(t *testing.T) { - config := loadDelivered(t) - // A local drop names a directory; the delivered file is on webdav, so the key - // has to be put there for the test to have anything to assert on. - setOption(t, config.Catalog.Options, "directory", `C:\ProgramData\OpenScale\data\catalog\incoming`) - setOption(t, config.Printer.Options, "address", "192.168.0.43:9100") - exported := config.Export(false) - - if exported.Station.Number != 0 || exported.Station.Name != "" { - t.Errorf("station = %+v, le numéro et le nom ne s'exportent pas", exported.Station) - } - if exported.Network != (NetworkConfig{}) { - t.Errorf("network = %+v, il ne s'exporte pas", exported.Network) +// TestAnEmptyRepositoryFallsBackRatherThanFailing: a file that carries the block +// but leaves the key empty is the same case as one that omits it. Refusing there +// would put a station out of service over a field nobody meant to set. +func TestAnEmptyRepositoryFallsBackRatherThanFailing(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`{"update":{"repository":""}}`), &config); err != nil { + t.Fatalf("décodage : %v", err) } - if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" { - t.Error("les empreintes admin ne s'exportent pas") + if config.Update.Repository != DefaultUpdateRepository { + t.Errorf("dépôt = %q, attendu le défaut %q", + config.Update.Repository, DefaultUpdateRepository) } +} - gone := []struct { - path string - key string - options DriverOptions +// TestAFileSilentAboutTheGridColumnsIsAutomatic is the keystone of the setting: a +// configuration written BEFORE it existed -- and a cooperative that never touches it +// -- keeps today's grid on every screen, instead of a frozen 5 that would break the +// 4K which showed 10 (ADR-035 stays whole). +func TestAFileSilentAboutTheGridColumnsIsAutomatic(t *testing.T) { + for name, block := range map[string]struct { + ui string + wanted int }{ - {"scale.options.port", "port", exported.Scale.Options}, - {"printer.options.queue", "queue", exported.Printer.Options}, - {"printer.options.address", "address", exported.Printer.Options}, - {"printer.options.path", "path", exported.Printer.Options}, - {"catalog.options.url", "url", exported.Catalog.Options}, - {"catalog.options.username", "username", exported.Catalog.Options}, - {"catalog.options.password", "password", exported.Catalog.Options}, - {"catalog.options.directory", "directory", exported.Catalog.Options}, - } - for _, option := range gone { - if _, present := option.options[option.key]; present { - t.Errorf("%s s'exporte, alors qu'il désigne un poste ou un site", option.path) - } - } - fallback, ok := exported.Printer.Options.Group("fallback") - if !ok { - t.Fatal("printer.options.fallback a disparu de l'export : seules ses clés de repli partent") - } - for _, key := range []string{"queue", "address", "path"} { - if _, present := fallback[key]; present { - t.Errorf("printer.options.fallback.%s s'exporte", key) - } + "clé absente": {ui: `{"language":"fr"}`, wanted: GridColumnsAutomatic}, + "clé à zéro": {ui: `{"language":"fr","grid_columns":0}`, wanted: GridColumnsAutomatic}, + "clé à sept": {ui: `{"language":"fr","grid_columns":7}`, wanted: 7}, + } { + t.Run(name, func(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`{"version":1,"ui":`+block.ui+`}`), &config); err != nil { + t.Fatalf("décodage : %v", err) + } + if config.UI.GridColumns != block.wanted { + t.Fatalf("grid_columns relu à %d, attendu %d", config.UI.GridColumns, block.wanted) + } + }) } - - // The original is untouched: an export is a copy, not a stripping. - if config.Station.Number != 2 { - t.Error("l'export ne doit rien retirer à la configuration en service") - } - if port, _ := config.Scale.Options.Text("port"); port != "COM8" { - t.Error("l'export a retiré le port de la configuration en service") - } - if fallback, ok := config.Printer.Options.Group("fallback"); !ok { - t.Error("l'export a retiré le repli de la configuration en service") - } else if queue, _ := fallback.Text("queue"); queue != "SATO WS408_3" { - t.Error("l'export a retiré la file de repli de la configuration en service") - } -} - -// TestExportWithoutHardwareKeepsWhatTheFleetShares is the reason this lot exists. -// -// INSTALLATION.md promises the next stations that the label offset « voyage avec la -// configuration clonée ». It lives in printer.options, which the export used to drop -// whole, so the promise was false. -func TestExportWithoutHardwareKeepsWhatTheFleetShares(t *testing.T) { - config := loadDelivered(t) - exported := config.Export(false) - - kept := []struct { - path string - key string - options DriverOptions - }{ - {"printer.options.offset_x", "offset_x", exported.Printer.Options}, - {"printer.options.offset_y", "offset_y", exported.Printer.Options}, - {"printer.options.darkness", "darkness", exported.Printer.Options}, - {"printer.options.speed", "speed", exported.Printer.Options}, - {"printer.options.transport", "transport", exported.Printer.Options}, - {"scale.options.baud", "baud", exported.Scale.Options}, - {"scale.options.parity", "parity", exported.Scale.Options}, - {"catalog.options.separator", "separator", exported.Catalog.Options}, - {"catalog.options.poll_interval_s", "poll_interval_s", exported.Catalog.Options}, - {"catalog.options.max_weighable_drop", "max_weighable_drop", exported.Catalog.Options}, - } - for _, option := range kept { - if _, present := option.options[option.key]; !present { - t.Errorf("%s ne voyage pas, alors que les quatre postes le partagent", option.path) - } - } - // The grid, the template and the coop name were already travelling: they must - // keep doing so. - if len(exported.Pricing.Tiers) != 2 || exported.Printer.Template != DefaultTemplateName { - t.Error("la grille de tarifs et le gabarit doivent voyager") - } - if exported.Station.Coop != config.Station.Coop { - t.Error("le nom de la coopérative doit voyager : il est partagé par les quatre postes") - } -} - -func TestExportNeverCarriesAPassword(t *testing.T) { - config := loadDelivered(t) - setOption(t, config.Catalog.Options, "password", "un secret") - - for _, includeHardware := range []bool{false, true} { - exported := config.Export(includeHardware) - if exported.Admin.PasswordHash != "" { - t.Errorf("hardware=%v : le mot de passe admin ne s'exporte jamais, ni haché ni en clair", includeHardware) - } - if secret, ok := exported.Catalog.Options.Text("password"); ok && secret != "" { - t.Errorf("hardware=%v : le mot de passe webdav ne s'exporte jamais", includeHardware) - } - } - // And the station keeps its own. - if secret, _ := config.Catalog.Options.Text("password"); secret != "un secret" { - t.Error("l'export ne doit pas effacer le secret de la configuration en service") - } -} - -// hostileConfig buries a secret and a site value under every shape a driver author -// may legitimately invent, and that Export knows no name for. -// -// Nothing here is exotic: a serial gateway with its own credentials, an HTTP proxy in -// front of the share, a second fallback under the first. The point is that the export -// has never heard of « gateway », « proxy » or « deeper », and must strip them anyway -- -// the same reason internal/diag/redact.go redacts by key name over the whole tree. -func hostileConfig(t *testing.T) Config { - t.Helper() - config := loadDelivered(t) - setOption(t, config.Scale.Options, "gateway", map[string]any{ - "password": "secret-passerelle-balance", - "port": "COM12", - "retries": 3, - }) - setOption(t, config.Catalog.Options, "proxy", map[string]any{ - "token": "secret-jeton-proxy", - "url": "https://proxy.exemple.lan:3128/", - "username": "compte-proxy", - "timeout_s": 5, - }) - setOption(t, config.Printer.Options, "password", "secret-mot-de-passe-imprimante") - - // Two levels down, under the one group name the export used to hard-code: the - // depth a single hard-coded name can never reach. - fallback, ok := config.Printer.Options.Group("fallback") - if !ok { - t.Fatal("la configuration livrée ne porte plus printer.options.fallback") - } - setOption(t, fallback, "deeper", map[string]any{ - "password": "secret-mot-de-passe-repli", - "queue": "SATO WS408_9", - "darkness": 4, - }) - setOption(t, config.Printer.Options, "fallback", fallback) - return config -} - -// TestExportStripsSecretsAtAnyDepth holds the promise the godoc of Export makes. -// -// « TWO SECRETS NEVER LEAVE, whatever includeHardware says » was enforced by a single -// delete on a single key of a single map, so a password one level down walked out in -// clear text. The assertion is on the SERIALISED export and not on a key lookup: what -// leaves the station is bytes, and a test that reads the structure would miss a secret -// hidden under a name it did not think to look up. -func TestExportStripsSecretsAtAnyDepth(t *testing.T) { - config := hostileConfig(t) - setOption(t, config.Catalog.Options, "password", "secret-mot-de-passe-webdav") - - secrets := map[string]string{ - "secret-passerelle-balance": "scale.options.gateway.password", - "secret-jeton-proxy": "catalog.options.proxy.token", - "secret-mot-de-passe-imprimante": "printer.options.password", - "secret-mot-de-passe-repli": "printer.options.fallback.deeper.password", - "secret-mot-de-passe-webdav": "catalog.options.password", - } - for _, includeHardware := range []bool{false, true} { - shipped, err := json.Marshal(config.Export(includeHardware)) - if err != nil { - t.Fatalf("matériel=%v : encodage de l'export : %v", includeHardware, err) - } - for secret, path := range secrets { - if bytes.Contains(shipped, []byte(secret)) { - t.Errorf("matériel=%v : l'export porte le secret de %s (%q), à quelque profondeur qu'on le range", - includeHardware, path, secret) - } - } - } - - // The station keeps its own: an export is a copy, never a stripping. - gateway, ok := config.Scale.Options.Group("gateway") - if !ok { - t.Fatal("l'export a retiré scale.options.gateway de la configuration en service") - } - if secret, _ := gateway.Text("password"); secret != "secret-passerelle-balance" { - t.Error("l'export a retiré un secret imbriqué de la configuration en service") - } -} - -// TestExportStripsStationKeysAtAnyDepth applies the strip list to the whole option -// tree, not to its first floor and to one group called « fallback ». -// -// The default of the lot does not move: a driver option is a setting the parc SHARES -// until stationSpecificOptions proves otherwise. What moves is the REACH of that proof. -func TestExportStripsStationKeysAtAnyDepth(t *testing.T) { - config := hostileConfig(t) - shipped, err := json.Marshal(config.Export(false)) - if err != nil { - t.Fatalf("encodage de l'export : %v", err) - } - stationValues := map[string]string{ - "COM12": "un port série sous scale.options.gateway", - "https://proxy.exemple.lan:3128/": "un hôte sous catalog.options.proxy", - "compte-proxy": "un compte sous catalog.options.proxy", - "SATO WS408_9": "une file d'impression sous printer.options.fallback.deeper", - } - for value, what := range stationValues { - if bytes.Contains(shipped, []byte(value)) { - t.Errorf("l'export porte %s (%q) : il désigne un poste ou un site", what, value) - } - } - - // Only the NAMED keys leave. A group emptied whole would drop what the parc - // shares, which is the defect this lot was opened to repair. - exported := config.Export(false) - gateway, ok := exported.Scale.Options.Group("gateway") - if !ok { - t.Fatal("scale.options.gateway a disparu de l'export : seules ses clés de poste partent") - } - if retries, ok := gateway.Int("retries"); !ok || retries != 3 { - t.Error("scale.options.gateway.retries ne voyage pas, alors que les quatre postes le partagent") - } - proxy, ok := exported.Catalog.Options.Group("proxy") - if !ok { - t.Fatal("catalog.options.proxy a disparu de l'export : seules ses clés de site partent") - } - if timeout, ok := proxy.Int("timeout_s"); !ok || timeout != 5 { - t.Error("catalog.options.proxy.timeout_s ne voyage pas, alors que les quatre postes le partagent") - } - - // An export WITH hardware is the backup of ONE station: its port, its queue and - // its share belong to it, at every depth. - backup, err := json.Marshal(config.Export(true)) - if err != nil { - t.Fatalf("encodage de l'export matériel : %v", err) - } - for value, what := range stationValues { - if !bytes.Contains(backup, []byte(value)) { - t.Errorf("un export matériel est la sauvegarde d'un poste : %s (%q) doit y rester", what, value) - } - } -} - -func TestExportWithHardwareKeepsTheRecoveryCode(t *testing.T) { - // An export WITH hardware is the backup of one station, not the clone template: - // the recovery code of the installation sheet belongs to that backup. - config := loadDelivered(t) - exported := config.Export(true) - if exported.Admin.RecoveryCodeHash != config.Admin.RecoveryCodeHash { - t.Error("un export matériel conserve l'empreinte du code de secours") - } - if port, _ := exported.Scale.Options.Text("port"); port != "COM8" { - t.Error("un export matériel conserve le port de la balance") - } -} - -// --- JSON round trips ---------------------------------------------------------- - -func TestConfigRoundTripsThroughJSON(t *testing.T) { - original := loadDelivered(t) - encoded, err := json.Marshal(original) - if err != nil { - t.Fatalf("encodage : %v", err) - } - var reread Config - if err := json.Unmarshal(encoded, &reread); err != nil { - t.Fatalf("décodage : %v", err) - } - if faults := reread.Validate(testRegistries()); len(faults) != 0 { - t.Fatalf("aller-retour JSON invalide :\n%s", strings.Join(fieldsOf(faults), "\n")) - } - if reread.Fingerprint() != original.Fingerprint() { - t.Fatal("un aller-retour JSON ne doit pas changer l'empreinte") - } -} - -// TestLimitsUseTheKeyNamesOfTheDocument guards the bridge between the domain type, -// which carries no tags, and the file, which names its thresholds in grams. -func TestLimitsUseTheKeyNamesOfTheDocument(t *testing.T) { - encoded, err := json.Marshal(WeighingLimits{MinWeight: 10, MaxWeight: 99_999, MaxAmount: 99_999}) - if err != nil { - t.Fatalf("encodage : %v", err) - } - for _, key := range []string{ - "empty_max_g", "basket_check_enabled", "basket_min_g", "basket_max_g", - "min_weight_g", "max_weight_g", "max_tare_g", "min_units", "max_units", - "max_amount_cents", - } { - if !strings.Contains(string(encoded), `"`+key+`"`) { - t.Errorf("clé %q absente de %s", key, encoded) - } - } - var limits WeighingLimits - if err := json.Unmarshal(encoded, &limits); err != nil { - t.Fatalf("décodage : %v", err) - } - if limits.MinWeight != 10 || limits.MaxWeight != 99_999 || limits.MaxAmount != 99_999 { - t.Fatalf("aller-retour = %+v", limits) - } -} - -func TestCategoriesUseTheKeyNamesOfTheDocument(t *testing.T) { - encoded, err := json.Marshal(Category{Code: "fruits", Label: "Fruits", Rank: 1, Color: "#C0392B", Visible: true}) - if err != nil { - t.Fatalf("encodage : %v", err) - } - const wanted = `{"code":"fruits","label":"Fruits","rank":1,"color":"#C0392B","visible":true}` - if string(encoded) != wanted { - t.Fatalf("catégorie = %s, attendu %s", encoded, wanted) - } - var category Category - if err := json.Unmarshal(encoded, &category); err != nil { - t.Fatalf("décodage : %v", err) - } - if category.Code != "fruits" || category.Rank != 1 || !category.Visible { - t.Fatalf("aller-retour = %+v", category) - } -} - -func TestRoundingPolicyIsSpelledLikeTheFile(t *testing.T) { - for word, wanted := range roundingSpellings { - var policy RoundingPolicy - if err := json.Unmarshal([]byte(`"`+word+`"`), &policy); err != nil { - t.Fatalf("décodage de %q : %v", word, err) - } - if policy != wanted { - t.Errorf("%q → %v, attendu %v", word, policy, wanted) - } - encoded, err := json.Marshal(wanted) - if err != nil { - t.Fatalf("encodage : %v", err) - } - if string(encoded) != `"`+word+`"` { - t.Errorf("%v → %s, attendu %q", wanted, encoded, word) - } - } -} - -// TestUnknownRoundingIsAnErrorAndNotASilentTruncation: an unknown word must never -// land in the configuration, because Divide would then silently truncate and a -// station would under-charge by a cent for months. -func TestUnknownRoundingIsAnErrorAndNotASilentTruncation(t *testing.T) { - var policy RoundingPolicy - err := json.Unmarshal([]byte(`"commercial"`), &policy) - if err == nil { - t.Fatal("un arrondi inconnu doit être une erreur de lecture") - } - for _, word := range RoundingSpellings() { - if !strings.Contains(err.Error(), word) { - t.Errorf("le message doit nommer les valeurs admises, %q absent de %q", word, err) - } - } -} - -// --- Driver options ------------------------------------------------------------ - -func TestDriverOptionsReadTheirValuesWithoutAFloat(t *testing.T) { - options := DriverOptions{} - setOption(t, options, "port", "COM8") - setOption(t, options, "baud", 9600) - setOption(t, options, "invert_bits", false) - setOption(t, options, "min_readable_ratio", 0.9) - setOption(t, options, "fallback", map[string]any{"enabled": true}) - - if value, ok := options.Text("port"); !ok || value != "COM8" { - t.Errorf("port = %q, %v", value, ok) - } - if value, ok := options.Int("baud"); !ok || value != 9600 { - t.Errorf("baud = %d, %v", value, ok) - } - // A baud rate is not a ratio: reading a whole number as one must not silently - // succeed through a float. - if _, ok := options.Int("min_readable_ratio"); ok { - t.Error("0,9 n'est pas un entier") - } - if value, ok := options.Ratio("min_readable_ratio"); !ok || value != 0.9 { - t.Errorf("min_readable_ratio = %v, %v", value, ok) - } - if value, ok := options.Bool("invert_bits"); !ok || value { - t.Errorf("invert_bits = %v, %v", value, ok) - } - if group, ok := options.Group("fallback"); !ok { - t.Error("fallback doit se lire comme un objet") - } else if enabled, ok := group.Bool("enabled"); !ok || !enabled { - t.Error("fallback.enabled doit se lire depuis le groupe") - } - if _, ok := options.Text("absent"); ok { - t.Error("une option absente ne doit pas se lire") - } - if got := options.Keys(); len(got) != 5 || got[0] != "baud" { - t.Errorf("Keys() = %v, il doit être trié", got) - } -} - -func TestNestedOptionGroupIsValidated(t *testing.T) { - config := loadDelivered(t) - fallback, ok := config.Printer.Options.Group("fallback") - if !ok { - t.Fatal("le fichier livré doit porter un groupe fallback") - } - setOption(t, fallback, "transport", "smb") - setOption(t, config.Printer.Options, "fallback", fallback) - - faults := config.Validate(testRegistries()) - // The path names the GROUP as well as the key: "printer.options.transport" and - // "printer.options.fallback.transport" are two different settings, and a volunteer - // must be told which of the two is wrong. - if findFault(faults, "printer.options.fallback.transport") == nil { - t.Fatalf("le transport de secours doit être validé ; obtenu :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// --- Shared helpers ------------------------------------------------------------ - -func TestCheckPriceIsTheThirdImpositionOfMaxUnitPrice(t *testing.T) { - for _, price := range []Cents{0, 1, MaxUnitPrice} { - if faults := CheckPrice("demo.price", price); len(faults) != 0 { - t.Errorf("%d centimes doit passer, obtenu %v", price, fieldsOf(faults)) - } - } - for _, price := range []Cents{-1, MaxUnitPrice + 1} { - faults := CheckPrice("demo.price", price) - if len(faults) != 1 || faults[0].Field != "demo.price" { - t.Errorf("%d centimes doit être refusé, obtenu %v", price, fieldsOf(faults)) - } - } -} - -func TestArgon2idShapeIsCheckedAndTheCostIsNot(t *testing.T) { - // Raising the cost is a legitimate hardening: a validation that froze m, t and p - // would refuse a configuration SAFER than the one it was written against. - hardened := "$argon2id$v=19$m=262144,t=6,p=4$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" - if !wellFormedArgon2id(hardened) { - t.Error("un coût plus élevé doit rester accepté") - } - for _, malformed := range []string{ - "", "admin", "$argon2i$v=19$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", - "$argon2id$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", - "$argon2id$19$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", - "$argon2id$v=19$t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", - "$argon2id$v=19$m=65536,t=3,p=2$sel$empreinte", - } { - if wellFormedArgon2id(malformed) { - t.Errorf("%q ne doit pas passer pour une empreinte argon2id", malformed) - } - } -} - -func TestHostPortAndColourShapes(t *testing.T) { - for _, valid := range []string{"127.0.0.1:8085", ":8085", "[::1]:8085", "poste2.local:8085"} { - if err := checkHostPort(valid); err != nil { - t.Errorf("%q doit être une adresse valide : %v", valid, err) - } - } - for _, invalid := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:99999", "127.0.0.1:http"} { - if err := checkHostPort(invalid); err == nil { - t.Errorf("%q ne doit pas être une adresse valide", invalid) - } - } - for _, valid := range []string{"#C0392B", "#27ae60", "#000000"} { - if !wellFormedColor(valid) { - t.Errorf("%q doit être une couleur valide", valid) - } - } - for _, invalid := range []string{"", "rouge", "#C0392", "#C0392BB", "C0392B", "#GGGGGG"} { - if wellFormedColor(invalid) { - t.Errorf("%q ne doit pas être une couleur valide", invalid) - } - } -} - -func TestRegistriesFallBackOnTheCompiledTemplates(t *testing.T) { - var empty Registries - if _, ok := empty.Template(DefaultTemplateName); !ok { - t.Fatalf("un registre vide doit servir les gabarits compilés, %q absent", DefaultTemplateName) - } - if got := empty.TemplateNames(); len(got) != len(ShippedTemplates()) { - t.Fatalf("gabarits = %v, attendu les %d gabarits livrés", got, len(ShippedTemplates())) - } - if _, ok := empty.Template("weighing_imaginaire"); ok { - t.Error("un gabarit inexistant ne doit pas se résoudre") - } -} - -// --- The option schema, kind by kind ------------------------------------------- - -func TestOptionKindNamesItselfInFrench(t *testing.T) { - for kind, wanted := range map[OptionKind]string{ - OptionText: "texte", - OptionInt: "nombre entier", - OptionBool: "vrai ou faux", - OptionRatio: "nombre", - OptionEnum: "valeur d'une liste", - OptionHostPort: "hôte:port", - OptionURL: "URL http ou https", - OptionGroup: "objet", - OptionKind(99): "inconnu", - } { - if got := kind.String(); got != wanted { - t.Errorf("OptionKind(%d) = %q, attendu %q", kind, got, wanted) - } - } -} - -// TestOptionSchemaChecksEveryKind exercises the schema-driven half of controls 6 to -// 9: the point of Registries is that a driver DECLARES its options and the file is -// checked against that declaration, not against a hard-coded list of key names. -func TestOptionSchemaChecksEveryKind(t *testing.T) { - cases := []struct { - name string - schema OptionSchema - value any - faulty bool - }{ - {"texte", OptionSchema{Key: "queue", Kind: OptionText}, "SATO WS408_1", false}, - {"texte reçoit un nombre", OptionSchema{Key: "queue", Kind: OptionText}, 4, true}, - {"booléen", OptionSchema{Key: "invert_bits", Kind: OptionBool}, true, false}, - {"booléen reçoit un texte", OptionSchema{Key: "invert_bits", Kind: OptionBool}, "oui", true}, - {"entier", OptionSchema{Key: "baud", Kind: OptionInt}, 9600, false}, - {"entier reçoit un texte", OptionSchema{Key: "baud", Kind: OptionInt}, "9600", true}, - {"entier dans ses bornes", OptionSchema{Key: "darkness", Kind: OptionInt, Max: 5}, 3, false}, - {"entier hors bornes", OptionSchema{Key: "darkness", Kind: OptionInt, Max: 5}, 9, true}, - {"ratio", OptionSchema{Key: "ratio", Kind: OptionRatio, Max: 1000}, 0.9, false}, - {"ratio hors bornes", OptionSchema{Key: "ratio", Kind: OptionRatio, Max: 1000}, 1.4, true}, - {"ratio reçoit un texte", OptionSchema{Key: "ratio", Kind: OptionRatio}, "0,9", true}, - {"énumération", OptionSchema{Key: "parity", Kind: OptionEnum, Values: []string{"N", "E"}}, "N", false}, - {"énumération hors liste", OptionSchema{Key: "parity", Kind: OptionEnum, Values: []string{"N", "E"}}, "P", true}, - {"énumération reçoit un nombre", OptionSchema{Key: "parity", Kind: OptionEnum}, 8, true}, - {"hôte:port", OptionSchema{Key: "address", Kind: OptionHostPort}, "192.168.1.40:9100", false}, - {"hôte:port vide, option inutilisée", OptionSchema{Key: "address", Kind: OptionHostPort}, "", false}, - {"hôte:port sans port", OptionSchema{Key: "address", Kind: OptionHostPort}, "192.168.1.40", true}, - {"hôte:port reçoit un nombre", OptionSchema{Key: "address", Kind: OptionHostPort}, 9100, true}, - {"URL", OptionSchema{Key: "url", Kind: OptionURL}, "https://dav.example.org:8001/", false}, - {"URL vide, option inutilisée", OptionSchema{Key: "url", Kind: OptionURL}, "", false}, - {"URL sans schéma", OptionSchema{Key: "url", Kind: OptionURL}, "dav.example.org", true}, - {"URL reçoit un booléen", OptionSchema{Key: "url", Kind: OptionURL}, true, true}, - {"groupe reçoit un nombre", OptionSchema{Key: "fallback", Kind: OptionGroup}, 1, true}, - } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - options := DriverOptions{} - setOption(t, options, testCase.schema.Key, testCase.value) - descriptor := DriverDescriptor{ID: "essai", Options: []OptionSchema{testCase.schema}} - faults := validateOptions("bloc.options", options, &descriptor, nil) - if testCase.faulty && len(faults) == 0 { - t.Fatalf("%v doit être refusé", testCase.value) - } - if !testCase.faulty && len(faults) != 0 { - t.Fatalf("%v doit passer, obtenu :\n%s", testCase.value, strings.Join(fieldsOf(faults), "\n")) - } - }) - } -} - -func TestRequiredOptionIsNamedWhenAbsent(t *testing.T) { - descriptor := DriverDescriptor{ID: "gram-xfoc-plus", Options: []OptionSchema{ - {Key: "port", Kind: OptionText, Required: true}, - {Key: "baud", Kind: OptionInt}, - }} - faults := validateOptions("scale.options", DriverOptions{}, &descriptor, nil) - if findFault(faults, "scale.options.port") == nil { - t.Fatalf("l'option exigée doit être nommée ; obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) - } - // An unregistered driver yields nothing: inventing a schema for a driver nobody - // has written yet would be a second source of truth. - if faults := validateOptions("scale.options", DriverOptions{}, nil, nil); len(faults) != 0 { - t.Fatalf("un driver non enregistré ne produit aucune faute, obtenu %v", fieldsOf(faults)) - } -} - -// TestARequiredOptionLeftEmptyIsAsAbsentAsAMissingKey. -// -// A required option is required to CARRY something. `"port": ""` parses as a text -// value, so the schema check was happy with it and only control 3 — which named the -// key `port` in the core — refused it. Now that the driver's own schema is the single -// voice on the subject, the empty string has to be refused there. -func TestARequiredOptionLeftEmptyIsAsAbsentAsAMissingKey(t *testing.T) { - descriptor := DriverDescriptor{ID: "gram-xfoc-plus", Options: []OptionSchema{ - {Key: "port", Kind: OptionText, Required: true}, - }} - options := DriverOptions{} - setOption(t, options, "port", "") - - faults := validateOptions("scale.options", options, &descriptor, nil) - if findFault(faults, "scale.options.port") == nil { - t.Fatalf("une option exigée laissée vide est acceptée ; obtenu :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// TestAnOptionalOptionMayStayEmpty is the other half: `address` is empty on every -// station whose transport is winspool, and emptiness is how an unused option is -// spelled. -func TestAnOptionalOptionMayStayEmpty(t *testing.T) { - descriptor := DriverDescriptor{ID: "raster", Options: []OptionSchema{ - {Key: "address", Kind: OptionHostPort}, - {Key: "queue", Kind: OptionText}, - }} - options := DriverOptions{} - setOption(t, options, "address", "") - setOption(t, options, "queue", "") - - if faults := validateOptions("printer.options", options, &descriptor, nil); len(faults) != 0 { - t.Fatalf("une option facultative vide est refusée :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// --- Control 3: what a scale needs is what its DRIVER declares ------------------- - -// TestAScaleReachedByAnAddressNeedsNoPortKey. -// -// Control 3 demanded the literal key `scale.options.port` of every station declaring -// a scale, WHATEVER its protocol. A driver reached by an address — TCP, USB — was -// therefore refused before it was ever asked, on a key its own schema does not carry. -// What is required is what the chosen driver declares required, and nothing else. -func TestAScaleReachedByAnAddressNeedsNoPortKey(t *testing.T) { - const overIP = "gram-over-ip" - - config := loadDelivered(t) - config.Scale.Type = overIP - config.Scale.Options = DriverOptions{} - setOption(t, config.Scale.Options, "address", "192.168.1.50:4001") - - registries := testRegistries() - registries.Scales = append(registries.Scales, DriverDescriptor{ - ID: overIP, Label: "GRAM sur IP", - Options: []OptionSchema{{Key: "address", Kind: OptionHostPort, Required: true}}, - }) - - if faults := config.Validate(registries); len(faults) != 0 { - t.Fatalf("un driver atteint par adresse est refusé :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// TestAScaleDriverStillGetsTheOptionsItDeclaresRequired: the same seam in the -// direction that protects the parc. The GRAM declares `port` required in its own -// schema, so a station that does not name one is still refused. -func TestAScaleDriverStillGetsTheOptionsItDeclaresRequired(t *testing.T) { - for _, testCase := range []struct { - name string - mutate func(*testing.T, *Config) - }{ - {"la clé manque", func(_ *testing.T, c *Config) { delete(c.Scale.Options, "port") }}, - {"la clé est vide", func(t *testing.T, c *Config) { setOption(t, c.Scale.Options, "port", "") }}, - } { - t.Run(testCase.name, func(t *testing.T) { - config := loadDelivered(t) - testCase.mutate(t, &config) - - faults := config.Validate(testRegistries()) - var named []Fault - for _, fault := range faults { - if fault.Field == "scale.options.port" { - named = append(named, fault) - } - } - if len(named) == 0 { - t.Fatalf("un poste GRAM sans port est accepté ; obtenu :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } - // ONE line and not two. A volunteer in front of the screen does not count - // broken rules, they count FIELDS TO FILL IN, and `scale.options.port` - // counted double — once for control 3, once for the schema (SUIVI, - // 29/07/2026). - if len(named) != 1 { - t.Errorf("%d fautes sur un seul champ à remplir :\n%s", - len(named), strings.Join(fieldsOf(named), "\n")) - } - // And the remaining line says WHO is asking, which is what tells a - // volunteer that changing the protocol is the other way out. - if !strings.Contains(named[0].Message, config.Scale.Type) { - t.Errorf("le message ne nomme pas le driver qui exige la clé : %q", named[0].Message) - } - }) - } -} - -// --- Control 29: the geometry is the head's, not the core's ---------------------- - -// TestControl29ValidatesTheTemplateOnTheHeadTheDriverDeclares. -// -// The two figures rules 3 and 4 bear on were constants of the core, counted at -// 8 dots/mm. Any station whose printer is not the WS408 of the parc therefore failed -// its own validation AT START-UP — §11.3 puts it out of service — on a template nobody -// could make it accept: at 12 dots/mm the very same label is 420 dots wide. -func TestControl29ValidatesTheTemplateOnTheHeadTheDriverDeclares(t *testing.T) { - config := loadDelivered(t) - finer := twelveDotTemplate() - finer.Name = config.Printer.Template - - registries := testRegistries() - registries.Templates = map[string]Template{finer.Name: finer} - - // On the WS408 the parc runs, the pairing is refused, in French, naming the two - // figures — a volunteer has to know which of the two to change. - fault := findFault(config.Validate(registries), "printer.template.media.dots_per_mm") - if fault == nil { - t.Fatalf("un gabarit mesuré pour une autre tête est accepté ; obtenu :\n%s", - strings.Join(fieldsOf(config.Validate(registries)), "\n")) - } - for _, figure := range []string{"12 dots/mm", "8 dots/mm"} { - if !strings.Contains(fault.Message, figure) { - t.Errorf("le message ne nomme pas %s : %q", figure, fault.Message) - } - } - - // Declare the head that goes with it and the same station validates. - for i := range registries.Printers { - if registries.Printers[i].ID == config.Printer.Type { - registries.Printers[i].Capabilities = ws412Head() - } - } - if faults := config.Validate(registries); len(faults) != 0 { - t.Fatalf("un poste à 12 dots/mm avec un gabarit mesuré pour 12 dots/mm est refusé :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// TestTheDeliveredStationIsValidatedOnTheWS408: the recette criterion of E0 — the -// shipped template and the head of the parc produce EXACTLY the figures they produced -// before, whether the head is declared or left unsaid. -func TestTheDeliveredStationIsValidatedOnTheWS408(t *testing.T) { - config := loadDelivered(t) - - declared := testRegistries() - silent := testRegistries() - for i := range silent.Printers { - silent.Printers[i].Capabilities = PrinterCapabilities{} - } - - if faults := config.Validate(declared); len(faults) != 0 { - t.Fatalf("le poste livré est refusé par la tête qu'il déclare :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } - if faults := config.Validate(silent); len(faults) != 0 { - t.Fatalf("le poste livré est refusé quand aucune tête ne se déclare :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -func TestDriverOptionsRefuseAValueOfTheWrongShape(t *testing.T) { - options := DriverOptions{} - setOption(t, options, "queue", 8) - setOption(t, options, "invert_bits", "faux") - setOption(t, options, "ratio", "0,9") - setOption(t, options, "fallback", 3) - - if _, ok := options.Text("queue"); ok { - t.Error("un nombre ne se lit pas comme un texte") - } - if _, ok := options.Int("invert_bits"); ok { - t.Error("un texte ne se lit pas comme un entier") - } - if _, ok := options.Bool("invert_bits"); ok { - t.Error("un texte ne se lit pas comme un booléen") - } - if _, ok := options.Ratio("ratio"); ok { - t.Error("une virgule décimale n'est pas un nombre JSON") - } - if _, ok := options.Group("fallback"); ok { - t.Error("un nombre ne se lit pas comme un groupe d'options") - } - for _, absent := range []func() bool{ - func() bool { _, ok := options.Bool("absente"); return ok }, - func() bool { _, ok := options.Group("absente"); return ok }, - func() bool { _, ok := options.Ratio("absente"); return ok }, - func() bool { _, ok := options.Int("absente"); return ok }, - } { - if absent() { - t.Error("une option absente ne se lit pas") - } - } - var nothing DriverOptions - if nothing.clone() != nil || nothing.Keys() != nil { - t.Error("des options nulles restent nulles") - } -} - -// --- Malformed files ------------------------------------------------------------ - -// TestMalformedBlocksAreReadErrorsAndNotFaults is step 1 of §11.4: what -// json.Unmarshal cannot read is a 400 Bad Request, not a list of faults. -func TestMalformedBlocksAreReadErrorsAndNotFaults(t *testing.T) { - var config Config - if err := json.Unmarshal([]byte(`pas du json`), &config); err == nil { - t.Error("un fichier illisible doit être une erreur de lecture") - } - if err := json.Unmarshal([]byte(`{"version": "un"}`), &config); err == nil { - t.Error("un type incompatible doit être une erreur de lecture") - } - var limits WeighingLimits - if err := json.Unmarshal([]byte(`{"empty_max_g": "cinq"}`), &limits); err == nil { - t.Error("un seuil en lettres doit être une erreur de lecture") - } - var category Category - if err := json.Unmarshal([]byte(`{"rank": "premier"}`), &category); err == nil { - t.Error("un rang en lettres doit être une erreur de lecture") - } - var policy RoundingPolicy - if err := json.Unmarshal([]byte(`3`), &policy); err == nil { - t.Error("un arrondi numérique doit être une erreur de lecture") - } -} - -func TestNoCatalogSourceDeclaredIsAFault(t *testing.T) { - config := loadDelivered(t) - config.Catalog.Type = "" - if findFault(config.Validate(testRegistries()), "catalog.type") == nil { - t.Fatal("une source de catalogue vide est une faute de forme") - } -} - -// --- Control 44, the two cases where nothing can be probed ---------------------- - -func TestControl44AcceptsWhatItCannotProbe(t *testing.T) { - config := loadDelivered(t) - config.Catalog.Images.Source = ImageSourceDirectory - - // An empty path is legitimate: it means /product_images/, a directory the - // service owns. - if fault := findFault(config.Validate(testRegistries()), "catalog.images.path"); fault != nil { - t.Errorf("un chemin vide est légitime : %s", fault) - } - // A path and no probe: `openscale config validate` on a laptop cannot know what - // the service account sees. - config.Catalog.Images.Path = `D:\photos` - if fault := findFault(config.Validate(testRegistries()), "catalog.images.path"); fault != nil { - t.Errorf("sans sonde, l'existence n'est pas validée : %s", fault) - } -} - -// --- Canonicalisation edges ----------------------------------------------------- - -func TestCanonicalJSONHandlesEveryJSONShape(t *testing.T) { - canonical, err := CanonicalJSON(json.RawMessage(`{"z":null,"y":true,"x":false,"w":[],"v":{},"u":"é\""}`)) - if err != nil { - t.Fatalf("canonisation : %v", err) - } - const wanted = `{"u":"é\"","v":{},"w":[],"x":false,"y":true,"z":null}` - if string(canonical) != wanted { - t.Fatalf("canonique = %s, attendu %s", canonical, wanted) - } -} - -func TestCanonicalJSONRefusesWhatCannotBeSerialised(t *testing.T) { - if _, err := CanonicalJSON(make(chan int)); err == nil { - t.Error("une valeur non sérialisable doit être une erreur") - } - // The fingerprint of an unserialisable block is a VISIBLE marker: eight characters - // that merely look like a fingerprint would be worse than none. - if got := BlockFingerprint(make(chan int)); got != strings.Repeat("?", fingerprintLength) { - t.Errorf("empreinte = %q, attendu un marqueur visible", got) - } - var buffer bytes.Buffer - if err := writeCanonical(&buffer, 3.5); err == nil { - t.Error("un type hors du jeu JSON décodé doit être une erreur") - } - // And the refusal propagates from inside an array and from inside an object, - // rather than writing half a document and reporting success. - if err := writeCanonical(&buffer, []any{3.5}); err == nil { - t.Error("le refus doit remonter depuis un tableau") - } - if err := writeCanonical(&buffer, map[string]any{"n": 3.5}); err == nil { - t.Error("le refus doit remonter depuis un objet") - } -} - -func TestCanonicalNumberKeepsWhatItCannotRespell(t *testing.T) { - if got := canonicalNumber(json.Number("pas un nombre")); got != "pas un nombre" { - t.Errorf("canonicalNumber = %q, la valeur d'origine doit primer", got) - } -} - -func TestIsHTTPURLRefusesAMalformedURL(t *testing.T) { - for _, invalid := range []string{"", "://", "http://[::1", "file:///etc/passwd", "dav.example.org"} { - if isHTTPURL(invalid) { - t.Errorf("%q ne doit pas passer pour une URL http(s)", invalid) - } - } - for _, valid := range []string{"http://poste2.local/", "https://dav.example.org:8001/"} { - if !isHTTPURL(valid) { - t.Errorf("%q doit passer pour une URL http(s)", valid) - } - } -} - -func TestBase64ShapeRefusesAnImpossibleCharacter(t *testing.T) { - if isBase64Raw("sel!!!!!!!!!!", 8) { - t.Error("un point d'exclamation n'est pas du base64") - } - if !isBase64Raw("b3BlbnNjYWxlLXNhbHQxMg", 8) { - t.Error("un sel base64 non paddé doit passer") - } -} - -// --- Control 48: the repository this station follows ----------------------------- - -// TestControl48RefusesAnythingThatIsNotAnOwnerRepoPair is the control that keeps -// « save the configuration » from becoming « run code from anywhere ». -// -// The host lives in the binary. A field that took a whole URL would hand the -// station's LocalSystem process to whoever can write the configuration file -- -// and writing that file is what the administration screen exists to do. -func TestControl48RefusesAnythingThatIsNotAnOwnerRepoPair(t *testing.T) { - for _, wrong := range []string{ - "https://github.com/lostmind84/OpenScale", - "git@github.com:lostmind84/OpenScale.git", - "lostmind84/OpenScale/extra", - "../../etc/passwd", - "lostmind84", - "lostmind84/", - "/OpenScale", - "lost mind/OpenScale", - "lostmind84/Open;Scale", - "lostmind84/Open Scale", - } { - config := loadDelivered(t) - config.Update.Repository = wrong - if findFault(config.Validate(testRegistries()), "update.repository") == nil { - t.Errorf("%q est accepté par le contrôle 48", wrong) - } - } -} - -// TestControl48AcceptsAForkOfTheProject: the code is AGPL, and a cooperative -// following its own fork is the case this field exists for. -func TestControl48AcceptsAForkOfTheProject(t *testing.T) { - for _, right := range []string{ - "lostmind84/OpenScale", - "la-cagette/openscale", - "coop_2/Open.Scale-2", - } { - config := loadDelivered(t) - config.Update.Repository = right - if fault := findFault(config.Validate(testRegistries()), "update.repository"); fault != nil { - t.Errorf("%q est refusé par le contrôle 48 : %s", right, fault.Message) - } - } -} - -// TestAFileWithoutTheUpdateBlockStillLoads is the symmetric of the defect of -// 28/07/2026, where control 20 made the station refuse its own delivered -// configuration: a file written before this block existed must read back with -// nothing said, and run on the default. -func TestAFileWithoutTheUpdateBlockStillLoads(t *testing.T) { - raw, err := os.ReadFile(deliveredConfigPath) - if err != nil { - t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) - } - var document map[string]any - if err := json.Unmarshal(raw, &document); err != nil { - t.Fatalf("décodage : %v", err) - } - delete(document, "update") - trimmed, err := json.Marshal(document) - if err != nil { - t.Fatalf("encodage : %v", err) - } - - var config Config - if err := json.Unmarshal(trimmed, &config); err != nil { - t.Fatalf("un fichier sans le bloc update ne se relit pas : %v", err) - } - if config.Update.Repository != DefaultUpdateRepository { - t.Errorf("dépôt par défaut = %q, attendu %q", - config.Update.Repository, DefaultUpdateRepository) - } - if fault := findFault(config.Validate(testRegistries()), "update.repository"); fault != nil { - t.Errorf("l'absence du bloc update est traitée comme une faute : %s", fault.Message) - } -} - -// TestAnEmptyRepositoryFallsBackRatherThanFailing: a file that carries the block -// but leaves the key empty is the same case as one that omits it. Refusing there -// would put a station out of service over a field nobody meant to set. -func TestAnEmptyRepositoryFallsBackRatherThanFailing(t *testing.T) { - var config Config - if err := json.Unmarshal([]byte(`{"update":{"repository":""}}`), &config); err != nil { - t.Fatalf("décodage : %v", err) - } - if config.Update.Repository != DefaultUpdateRepository { - t.Errorf("dépôt = %q, attendu le défaut %q", - config.Update.Repository, DefaultUpdateRepository) - } -} - -// TestTheFollowedRepositoryEntersTheFingerprint: the four stations of one -// cooperative must follow the same repository, and a divergence has to be visible -// on the eight characters the dashboard shows and a volunteer compares by eye. -func TestTheFollowedRepositoryEntersTheFingerprint(t *testing.T) { - reference := loadDelivered(t) - diverged := loadDelivered(t) - diverged.Update.Repository = "someone-else/OpenScale" - - if reference.Fingerprint() == diverged.Fingerprint() { - t.Fatal("deux postes suivant deux dépôts différents portent la même empreinte") - } -} - -// TestTheFollowedRepositorySurvivesAnExportWithoutHardware: it is a decision of -// the cooperative and not a property of one machine, so cloning a station must -// carry it. -func TestTheFollowedRepositorySurvivesAnExportWithoutHardware(t *testing.T) { - config := loadDelivered(t) - config.Update.Repository = "la-cagette/openscale" - - if got := config.Export(false).Update.Repository; got != "la-cagette/openscale" { - t.Fatalf("dépôt après export sans matériel = %q", got) - } -} - -// --- Control 49: how many columns the grid shows --------------------------------- - -// TestControl49RefusesAColumnCountOutsideTheRange guards the value NOBODY MEANT TO -// WRITE, and nothing more. -// -// The bounds are guard rails and not a calculation: the same N is comfortable on a -// 4K and absurd on a 15", so no pair of bounds can be right for the whole fleet. -// What protects the operator inside them is the administration screen, which shows -// the result before the file is saved -- and the fact that getting it wrong is -// repaired by coming back. -func TestControl49RefusesAColumnCountOutsideTheRange(t *testing.T) { - for _, refused := range []int{-1, 1, MinGridColumns - 1, MaxGridColumns + 1, 100} { - t.Run(strconv.Itoa(refused), func(t *testing.T) { - config := loadDelivered(t) - config.UI.GridColumns = refused - faults := config.Validate(testRegistries()) - if findFault(faults, "ui.grid_columns") == nil { - t.Fatalf("%d est accepté par le contrôle 49 ; obtenu :\n%s", - refused, strings.Join(fieldsOf(faults), "\n")) - } - }) - } -} - -// TestControl49AcceptsAutomaticAndEveryColumnCountOfTheRange: zero is the delivered -// behaviour and 3 to 12 are the whole offer of the administration screen. A control -// that refused one of them would refuse a value the screen itself proposes. -func TestControl49AcceptsAutomaticAndEveryColumnCountOfTheRange(t *testing.T) { - accepted := []int{GridColumnsAutomatic} - for columns := MinGridColumns; columns <= MaxGridColumns; columns++ { - accepted = append(accepted, columns) - } - for _, columns := range accepted { - t.Run(strconv.Itoa(columns), func(t *testing.T) { - config := loadDelivered(t) - config.UI.GridColumns = columns - if fault := findFault(config.Validate(testRegistries()), "ui.grid_columns"); fault != nil { - t.Fatalf("%d est refusé par le contrôle 49 : %s", columns, fault.Message) - } - }) - } -} - -// TestControl49SaysWhatZeroMeans: refusing is only half of it. Somebody who writes -// `1` has to read WHY, and above all that `0` is not « aucune colonne » but the -// automatic grid -- the very value they would have to write to get their screen -// back. -func TestControl49SaysWhatZeroMeans(t *testing.T) { - config := loadDelivered(t) - config.UI.GridColumns = 1 - fault := findFault(config.Validate(testRegistries()), "ui.grid_columns") - if fault == nil { - t.Fatal("1 colonne n'est pas refusée : le reste du contrôle n'a plus d'objet") - } - spelled := strings.Join(fault.Values, " | ") - if !strings.Contains(spelled, "automatique") { - t.Errorf("valeurs = %q, elles doivent dire que 0 est le mode automatique", spelled) - } - for _, bound := range []int{MinGridColumns, MaxGridColumns} { - if !strings.Contains(spelled, strconv.Itoa(bound)) { - t.Errorf("valeurs = %q, elles doivent porter la borne %d", spelled, bound) - } - } -} - -// TestAFileSilentAboutTheGridColumnsIsAutomatic is the keystone of the setting: a -// configuration written BEFORE it existed -- and a cooperative that never touches it -// -- keeps today's grid on every screen, instead of a frozen 5 that would break the -// 4K which showed 10 (ADR-035 stays whole). -func TestAFileSilentAboutTheGridColumnsIsAutomatic(t *testing.T) { - for name, block := range map[string]struct { - ui string - wanted int - }{ - "clé absente": {ui: `{"language":"fr"}`, wanted: GridColumnsAutomatic}, - "clé à zéro": {ui: `{"language":"fr","grid_columns":0}`, wanted: GridColumnsAutomatic}, - "clé à sept": {ui: `{"language":"fr","grid_columns":7}`, wanted: 7}, - } { - t.Run(name, func(t *testing.T) { - var config Config - if err := json.Unmarshal([]byte(`{"version":1,"ui":`+block.ui+`}`), &config); err != nil { - t.Fatalf("décodage : %v", err) - } - if config.UI.GridColumns != block.wanted { - t.Fatalf("grid_columns relu à %d, attendu %d", config.UI.GridColumns, block.wanted) - } - }) - } -} +} // TestTheDeliveredFileNeedNotCarryTheGridColumns states the other half out loud: the // delivered file says nothing, and control 49 has nothing to say about it. This is @@ -2355,37 +292,3 @@ func TestTheDeliveredFileNeedNotCarryTheGridColumns(t *testing.T) { t.Fatalf("le silence du fichier livré est traité comme une faute : %s", fault.Message) } } - -// TestTileSizeStaysRetiredBesideTheColumnSetting is the non-regression of the -// ADR-031 → ADR-035 → ADR-057 round trip. -// -// The reopened question is « combien de produits voir d'un coup », which no screen -// measurement answers; the one ADR-035 closed -- the heterogeneity of the fleet -- -// stays closed, and `clamp()` still answers it, since it remains the default. So the -// new key must be read and the old one must still be REFUSED, in the very same file: -// ui.tile_size never comes back through the side door. -func TestTileSizeStaysRetiredBesideTheColumnSetting(t *testing.T) { - raw := []byte(`{"version":1,"ui":{"tile_size":"medium","grid_columns":7}}`) - var config Config - if err := json.Unmarshal(raw, &config); err != nil { - t.Fatalf("décodage : %v", err) - } - if config.UI.GridColumns != 7 { - t.Fatalf("grid_columns relu à %d, attendu 7", config.UI.GridColumns) - } - if got := config.Retired(); len(got) != 1 || got[0] != "ui.tile_size" { - t.Fatalf("clés retirées = %v, attendu [ui.tile_size]", got) - } - fault := findFault(config.Validate(testRegistries()), "ui.tile_size") - if fault == nil { - t.Fatal("ui.tile_size passe le contrôle 20 dès qu'un réglage de grille est écrit à côté") - } - if !strings.Contains(fault.Message, "supprimée") { - t.Errorf("message = %q, il doit dire que la clé est supprimée", fault.Message) - } - // Refusing is only half of it: a volunteer who wrote tile_size wanted a denser - // grid, and the refusal has to name the key that gives them one. - if !strings.Contains(fault.Message, "grid_columns") { - t.Errorf("message = %q, il doit nommer la clé qui règle désormais la grille", fault.Message) - } -} diff --git a/internal/domain/cycle.go b/internal/domain/cycle.go new file mode 100644 index 0000000..cc5eb14 --- /dev/null +++ b/internal/domain/cycle.go @@ -0,0 +1,229 @@ +package domain + +import "errors" + +// This file holds THE ONE PLACE A WEIGHT IS FROZEN, and everything that follows from +// it: the validation step every weighing state ends on, the refusal it may produce, +// and the reprint, which is the one label that does not come through it. +// +// Invariant 3 of §6.7 lives here: the reading a label is built from is frozen in +// validate and never read again for that cycle. + +// validate is both the entry and the exit of Validating, in one step. +// +// It is written as one function because nothing OUTSIDE the model decides its +// outcome: the safeguards and the price are pure functions of what is already +// frozen. A state the machine would rest in would be a state where a second +// measurement could change the weight under a label about to be printed. +// +// THE WEIGHT IS FROZEN HERE, and never read again for this cycle (invariant 3). +func validate(m Model, w frozenWeight, ctx TransitionContext) (Model, []Effect) { + if m.CurrentProduct == nil { + return m.clear(Idle), nil + } + product := *m.CurrentProduct + + next := m + next.LatchedWeight, next.Source = w.Measurement, w.Source + + prep, err := Prepare(m.prepareInput(product, w, ctx)) + next.Diagnostics = prep.Diagnostics + + switch { + case errors.Is(err, ErrInconsistentTiers): + // Configuration checks 10 to 16 exist to make this unreachable (§11.3). + // Reaching it means the station cannot price ANY product, which is a + // full-screen fault and not one refused weighing. + next.State, next.FaultCode = Faulted, "ERR-CFG-01" + next.Label = nil + return next, []Effect{ + MessageEffect{ + Level: LevelError, Code: "ERR-CFG-01", + Text: "Le poste ne peut pas calculer les prix (ERR-CFG-01). Prévenez un responsable.", + }, + TechnicalLogEffect{ + Level: LevelError, Source: "config", Code: "ERR-CFG-01", + Message: "Grille de tarifs inutilisable.", Detail: err.Error(), + }, + AckEffect{Key: m.IdempotencyKey, Ack: Ack{ + Accepted: false, State: Faulted, Code: "ERR-CFG-01", + Message: "Le poste ne peut pas calculer les prix (ERR-CFG-01). Prévenez un responsable.", + }}, + } + + case err != nil: + // A barcode this product cannot carry: a prefix outside the plan, a + // reserved zone that is not empty, a payload that does not fit, a mode that + // contradicts its own prefix, or an article that has no tile at all. It is + // one PRODUCT that is unusable, so the station keeps serving the others. + return next.reject(prep.Priced, Diagnostic{ + Code: CodeProductWithdrawn, Severity: Blocking, + Message: DefaultMessage(CodeProductWithdrawn), ProductID: product.ID, + }, err.Error(), ctx) + } + + // Prepare's invariant: Label is non-nil exactly when Refusal is nil. + if prep.Refusal != nil { + return next.reject(prep.Priced, *prep.Refusal, "", ctx) + } + + next.State, next.Label = Printing, prep.Label + next.Reprinted = false + return next, []Effect{ + PrintEffect{Label: *prep.Label}, + AckEffect{Key: next.IdempotencyKey, Ack: Ack{ + Accepted: true, State: Printing, JobID: prep.Label.JobID, + }}, + } +} + +// prepareInput names what the machine hands to the single calculation path. +// +// TWO of Prepare's inputs cannot be filled from a TransitionContext, and both are +// worth naming rather than hiding behind a zero value: +// +// Decision stays nil. The human judgement of §10.6 lives in local_decisions, and +// neither TransitionContext nor Catalog carries it -- Product has no Offered field +// and NewCatalog takes no decision table. Nil is the right default (the absence of +// a row is not a refusal), but it means safeguard rule 14 and the light-product +// waiver are UNREACHABLE from the machine today. Closing that needs a field on +// Product or a table on Catalog, neither of which is this file's to add. +// +// StabilityBlocking is stability.mode, which is not quite what Prepare asks for: +// it wants the EFFECTIVE severity, and blocking mode auto-disables itself when +// fewer than min_latch_rate of the weighings settle over five minutes (§6.5). That +// sliding window is held by the Hub -- a pure function has no business remembering +// five minutes of history -- so the fallback cannot be seen from here. +func (m Model) prepareInput(p Product, w frozenWeight, ctx TransitionContext) PrepareInput { + return PrepareInput{ + Product: p, + Measurement: w.Measurement, + Rules: ctx.Cfg.Pricing, + Limits: ctx.Cfg.Limits, + Decision: nil, + MeasurementAge: w.Age, + Expiry: ctx.Expiry, + StabilityBlocking: w.StabilityBlocks, + JobID: m.JobID, + } +} + +// reject records a refused weighing and shows its message. +func (m Model) reject(label Label, blocking Diagnostic, detail string, + ctx TransitionContext) (Model, []Effect) { + next := m + next.State, next.Label = Rejected, nil + record := m.record(label, ResultRejected, rejectDetail(blocking, detail), 0, ctx) + effects := []Effect{ + MessageEffect{ + Level: LevelWarn, Code: blocking.Code, Text: blocking.Message, + Duration: RejectMessageDuration, + }, + RecordEffect{Weighing: record}, + AckEffect{Key: m.IdempotencyKey, Ack: Ack{ + Accepted: false, State: Rejected, + Code: blocking.Code, Message: blocking.Message, + }}, + } + if detail != "" { + effects = append(effects, TechnicalLogEffect{ + Level: LevelWarn, Source: "catalog", Code: "", + Message: "Étiquette impossible pour ce produit.", Detail: detail, + }) + } + return next, effects +} + +// rejectDetail is what the journal keeps about a refusal: the code always, and +// the technical reason when there is one. +func rejectDetail(blocking Diagnostic, detail string) string { + if detail == "" { + return blocking.Code + } + return blocking.Code + ": " + detail +} + +// rejectUnstable is the blocking-mode timeout with on_timeout = reject. +// +// The refusal is stated explicitly rather than left to safeguard rule 6, and the +// difference is real: the latch also fails to hold when every individual frame +// says ST while the mass keeps walking beyond the tolerance. Rule 6 reads the FLAG +// and would let that weighing through, so on_timeout = reject would print exactly +// what the operator asked it not to. +func rejectUnstable(m Model, ctx TransitionContext) (Model, []Effect) { + msr := m.frozen(ctx) + next := m + next.LatchedWeight = msr + return next.reject(m.priced(fromScale(m, ctx), ctx), Diagnostic{ + Code: CodeWeightUnstable, Severity: Blocking, + Message: DefaultMessage(CodeWeightUnstable), + }, "", ctx) +} + +// priced is what the weighing WOULD have cost, for a refusal the safeguards did +// not raise themselves. +// +// "At 8 g this product was refused, and here is what it would have cost" is the +// line an operator reads afterwards, and weighing_lines is mandatory (§12.3). It +// goes through the same single calculation path as everything else, and a label +// that cannot even be priced simply carries no lines. +func (m Model) priced(w frozenWeight, ctx TransitionContext) Label { + if m.CurrentProduct == nil { + return Label{} + } + prep, _ := Prepare(m.prepareInput(*m.CurrentProduct, w, ctx)) + return prep.Priced +} + +// reprint prints the LAST label a second time (§8.5). +// +// It is the one PrintEffect that does not come out of Validating, and that is +// deliberate: the label was validated once, against a weight that was on the +// plate at that moment, and re-validating it would refuse it for +// MEASUREMENT_EXPIRED -- the very code that protects the FIRST print. A reprint is +// an explicitly wanted duplicate of an already validated label, it carries the +// RÉIMPRESSION mention so a cashier sees it, and it is journalled result='reprint'. +// +// One reprint per label, inside reprint_window_s. A window of zero disables +// reprinting, which is the only sensible reading of "how long the bar stays +// active" = 0. +func reprint(m Model, ev ReprintRequested, ctx TransitionContext) (Model, []Effect) { + if m.LastLabel == nil || m.Reprinted { + return m, refuseReprint(m.State) + } + if ev.JobID != "" && ev.JobID != m.LastLabel.JobID { + return m, refuseReprint(m.State) + } + if ctx.Now.Sub(m.LastPrintedAt) > reprintWindow(ctx.Cfg) { + return m, refuseReprint(m.State) + } + + label := *m.LastLabel + label.JobID = deriveJobID(ev.Key, ctx) + next := m + next.State = Printing + next.Label = &label + next.Reprinted = true + next.IdempotencyKey = ev.Key + next.JobID = label.JobID + next.StartedAt = ctx.Now + if next.CurrentProduct == nil { + product := label.Product + next.CurrentProduct = &product + } + return next, []Effect{ + PrintEffect{Label: label, Reprint: true}, + AckEffect{Key: ev.Key, Ack: Ack{ + Accepted: true, State: Printing, JobID: label.JobID, + }}, + } +} + +// refuseReprint answers a reprint that cannot be served, in French. +func refuseReprint(state State) []Effect { + const text = "Cette étiquette ne peut plus être réimprimée." + return []Effect{ + MessageEffect{Level: LevelWarn, Text: text, Duration: RejectMessageDuration}, + AckEffect{Ack: Ack{Accepted: false, State: state, Message: text}}, + } +} diff --git a/internal/domain/cycle_test.go b/internal/domain/cycle_test.go new file mode 100644 index 0000000..5caaaa8 --- /dev/null +++ b/internal/domain/cycle_test.go @@ -0,0 +1,260 @@ +// This file holds the validating step and the reprint -- the two places a +// PrintEffect can come from. +// +// A reprint is the one label that does NOT come out of Validating, and that is +// deliberate: re-validating it would refuse it for MEASUREMENT_EXPIRED, the very +// code that protects the first print. + +package domain + +import ( + "testing" + "time" +) + +// TestTransitionReprintPrintsOnceInsideItsWindow is §8.5: one reprint, marked +// RÉIMPRESSION, journalled result='reprint'. +func TestTransitionReprintPrintsOnceInsideItsWindow(t *testing.T) { + r := nominalCycle(t) + first := r.m.LastLabel.JobID + + effects := r.at(10 * time.Second).send(ReprintRequested{JobID: first, Key: "01J-AGAIN"}) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the reprint printed nothing: %s", r.m.State) + } + if !print.Reprint { + t.Error("the reprint is not marked as one: no RÉIMPRESSION would be printed") + } + if print.Label.JobID == first { + t.Error("the reprint reuses the job id, and weighings.job_id is UNIQUE") + } + if print.Label.Barcode != "0493021012365" { + t.Errorf("the reprint carries barcode %s", print.Label.Barcode) + } + + r.at(11 * time.Second).send(PrintFinished{ + JobID: print.Label.JobID, Duration: 30 * time.Millisecond, + }) + + // One reprint per label. + effects = r.at(12 * time.Second).send(ReprintRequested{Key: "01J-THIRD"}) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a second reprint produced %d labels", n) + } +} + +// TestTransitionReprintIsJournalledAsAReprint checks the result column of §12.3 +// separately, because it is what a cashier's question resolves to. +func TestTransitionReprintIsJournalledAsAReprint(t *testing.T) { + r := nominalCycle(t) + effects := r.at(5 * time.Second).send(ReprintRequested{Key: "01J-AGAIN"}) + print, _ := findEffect[PrintEffect](effects) + effects = r.at(6 * time.Second).send(PrintFinished{ + JobID: print.Label.JobID, Duration: 25 * time.Millisecond, + }) + record, ok := findEffect[RecordEffect](effects) + if !ok { + t.Fatal("the reprint was not journalled") + } + if record.Weighing.Result != ResultReprint { + t.Errorf("journalled %q, want %q", record.Weighing.Result, ResultReprint) + } + if record.Weighing.JobID != print.Label.JobID { + t.Errorf("the row names job %q, the label %q", record.Weighing.JobID, print.Label.JobID) + } +} + +// TestTransitionRefusesAReprintOutsideItsWindow: the window is a real fraud +// window, and a zero window disables reprinting altogether. +func TestTransitionRefusesAReprintOutsideItsWindow(t *testing.T) { + r := nominalCycle(t) + effects := r.at(90 * time.Second).send(ReprintRequested{Key: "01J-LATE"}) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a reprint 90 s later produced %d labels", n) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Accepted { + t.Error("a late reprint was accepted") + } + + r = nominalCycle(t) + r.ctx.Cfg.UI.ReprintWindowSeconds = 0 + if n := countEffect[PrintEffect](r.at(2 * time.Second).send(ReprintRequested{Key: "01J-OFF"})); n != 0 { + t.Fatalf("a zero window still reprinted %d labels", n) + } + + // A reprint naming another job is a stale request, never a second label. + r = nominalCycle(t) + if n := countEffect[PrintEffect](r.at(2 * time.Second).send( + ReprintRequested{JobID: "01J-OTHER", Key: "01J-WRONG"})); n != 0 { + t.Fatalf("a reprint of another job produced %d labels", n) + } + + // And with nothing ever printed there is nothing to reprint. + r = newRun(t) + if n := countEffect[PrintEffect](r.send(ReprintRequested{Key: "01J-NOTHING"})); n != 0 { + t.Fatal("a station that never printed reprinted something") + } +} + +// TestTransitionInconsistentPriceGridFaultsRatherThanCrashes: configuration checks +// 10 to 16 exist to make this unreachable, and Price refuses a grid it cannot +// apply. Reaching it must therefore be a full-screen fault, never a dead process. +func TestTransitionInconsistentPriceGridFaultsRatherThanCrashes(t *testing.T) { + for name, rules := range map[string]PricingRules{ + "no tier at all": {PrimaryCode: "MEMBER", ReferenceCode: "MEMBER"}, + "a discount above a hundred percent": { + Tiers: []PriceTier{{Code: "M", Discount: FullDiscount + 1}}, PrimaryCode: "M", ReferenceCode: "M", + }, + "a primary code naming no tier": { + Tiers: []PriceTier{{Code: "M"}}, PrimaryCode: "GHOST", ReferenceCode: "M", + }, + } { + r := newRun(t) + r.ctx.Cfg.Pricing = rules + r.at(0).measure(1236, Stable) + effects := r.at(400*time.Millisecond).tap("894", "01J-BADGRID") + if r.m.State != Faulted { + t.Errorf("%s reached %s, want faulted", name, r.m.State) + } + if r.m.FaultCode != "ERR-CFG-01" { + t.Errorf("%s: fault code %q", name, r.m.FaultCode) + } + if n := countEffect[PrintEffect](effects); n != 0 { + t.Errorf("%s printed %d labels", name, n) + } + if ack, ok := findEffect[AckEffect](effects); !ok || ack.Ack.Accepted { + t.Errorf("%s: the command was not answered with a refusal", name) + } + } +} + +// TestTransitionRefusesAProductWhoseBarcodeCannotCarryTheWeight is the second half +// of §6.2's invariant: a reference whose reserved zone is not empty would print a +// label pointing at ANOTHER article at the till. One product is unusable; the +// station keeps serving the others. +func TestTransitionRefusesAProductWhoseBarcodeCannotCarryTheWeight(t *testing.T) { + // 0493 100 10000 -- the very shape §6.2 walks through: read as a three-digit + // reference it is PATATE DOUCE at 10,000 kg, not TOMME at 1,000 kg. + broken := machineGarlic(t) + broken.ID, broken.Name = "5115", "TOMME DE SAVOIE -MV" + broken.Reference = mustCompose(t, "049310010000") + + r := newRun(t) + r.ctx.Catalog = NewCatalog([]Product{broken}, nil) + r.at(0).measure(1236, Stable) + effects := r.at(400*time.Millisecond).tap("5115", "01J-BROKEN") + + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a reference with an occupied reserved zone printed %d labels", n) + } + if r.m.State != Rejected { + t.Fatalf("state %s, want rejected", r.m.State) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Code != CodeProductWithdrawn { + t.Errorf("refused on %q", ack.Ack.Code) + } + log, ok := findEffect[TechnicalLogEffect](effects) + if !ok { + t.Fatal("no technical trace names what has to be fixed in Odoo") + } + if log.Detail == "" { + t.Error("the technical trace carries no reason") + } + + // A product whose prefix is outside the plan has no encoding at all. + outside := machineGarlic(t) + outside.ID = "outside" + outside.Reference = mustCompose(t, "300000000000") + r = newRun(t) + r.ctx.Catalog = NewCatalog([]Product{outside}, nil) + r.at(0).measure(1236, Stable) + if n := countEffect[PrintEffect](r.at(400*time.Millisecond).tap("outside", "01J-OUT")); n != 0 { + t.Fatal("a prefix outside the plan produced a label") + } +} + +// TestTransitionRefusesAProductWhoseModeContradictsItsPrefix: the prefix is +// authoritative for the sale mode, never the `unite` column of the CSV (§10.2). +func TestTransitionRefusesAProductWhoseModeContradictsItsPrefix(t *testing.T) { + liar := machineGarlic(t) + liar.ID, liar.Mode = "liar", ByUnit // a 0493 reference sold "by unit" + r := newRun(t) + r.ctx.Catalog = NewCatalog([]Product{liar}, nil) + if n := countEffect[PrintEffect](r.at(0).tap("liar", "01J-LIAR")); n != 0 { + t.Fatal("a product contradicting its own prefix was priced") + } + if r.m.State != Rejected { + t.Fatalf("state %s, want rejected", r.m.State) + } +} + +// TestTransitionValidatingCompletesOnATick covers the transient state a replay can +// hand back: the model already holds everything the decision needs. +func TestTransitionValidatingCompletesOnATick(t *testing.T) { + product := machineGarlic(t) + m := Model{ + State: Validating, CurrentProduct: &product, Units: 1, JobID: "01J-REPLAY", + IdempotencyKey: "01J-REPLAY", Source: SourceScale, + LatchedWeight: Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 9}, + } + ctx := TransitionContext{ + Cfg: machineConfig(), Now: origin.Add(100 * time.Millisecond), + LastMeasurement: m.LatchedWeight, MeasurementAge: 100 * time.Millisecond, + Expiry: 1200 * time.Millisecond, Catalog: machineCatalog(t), + } + next, effects := Transition(m, Tick{}, ctx) + if next.State != Printing { + t.Fatalf("a pending validation reached %s, want printing", next.State) + } + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("%d labels", n) + } + + // Every other event is ignored rather than allowed to start a second cycle + // over the same frozen weight. + for _, ev := range []Event{MeasurementReceived{M: m.LatchedWeight}, TareTapped{}, Dismiss{}} { + got, effects := Transition(m, ev, ctx) + if got.State != Validating || len(effects) != 0 { + t.Errorf("%T moved a pending validation to %s with %d effects", + ev, got.State, len(effects)) + } + } + + // A validating model with nothing selected cannot validate anything. + orphan, effects := Transition(Model{State: Validating}, Tick{}, ctx) + if orphan.State != Idle || len(effects) != 0 { + t.Errorf("an orphaned validation reached %s with %d effects", orphan.State, len(effects)) + } +} + +// TestTransitionReprintWorksFromTheRestingState: the bottom bar is PERMANENT +// (§14.3), so a reprint has to survive the customer taking their bag off -- which is +// precisely the gesture that clears the cycle. +func TestTransitionReprintWorksFromTheRestingState(t *testing.T) { + r := nominalCycle(t) + r.at(2*time.Second).measure(0, Stable) + if r.m.State != Idle || r.m.CurrentProduct != nil { + t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) + } + + effects := r.at(20 * time.Second).send(ReprintRequested{Key: "01J-BAR"}) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the permanent bar reprinted nothing: %s", r.m.State) + } + if !print.Reprint || print.Label.Barcode != "0493021012365" { + t.Errorf("the reprint carries %+v", print.Label) + } + // The product comes back from the label, so the journal row can name it. + if r.m.CurrentProduct == nil || r.m.CurrentProduct.ID != "894" { + t.Fatalf("the reprint names no product: %v", r.m.CurrentProduct) + } + effects = r.at(21 * time.Second).send(PrintFinished{JobID: print.Label.JobID}) + record, ok := findEffect[RecordEffect](effects) + if !ok || record.Weighing.ProductID != "894" || record.Weighing.Result != ResultReprint { + t.Errorf("the reprint row is %+v", record.Weighing) + } +} diff --git a/internal/domain/ean13_modules_test.go b/internal/domain/ean13_modules_test.go new file mode 100644 index 0000000..16c016a --- /dev/null +++ b/internal/domain/ean13_modules_test.go @@ -0,0 +1,102 @@ +// This file holds the 95 modules the symbol is drawn from: the guards that are +// always there, the parity that carries the first digit, and the fact that the bars +// decode back to the digits they were built from. +// +// Decoding them back is the check that matters: a renderer that drew the right +// number of bars in the wrong order would pass every other test in this package. + +package domain + +import ( + "errors" + "testing" +) + +// --- The 95 modules of the symbol ------------------------------------------ + +// TestModulesOfTheReferenceBarcode is the frozen bit string of §7.4-1, obtained +// once and checked with an INDEPENDENT decoder: the 95 modules were re-read back +// to 0493021012365 by a decoder that shares no table with the encoder. +// +// Layout: 3 (left guard) + 6x7 + 5 (centre guard) + 6x7 + 3 (right guard) = 95. +func TestModulesOfTheReferenceBarcode(t *testing.T) { + const golden = "10101000110001011011110100011010010011001100101010111001011001101101100100001010100001001110101" + + modules, err := Modules("0493021012365") + if err != nil { + t.Fatalf("Modules: %v", err) + } + if len(golden) != 95 { + t.Fatalf("the golden string has %d characters, want 95", len(golden)) + } + for i, want := range golden { + if got := modules[i]; got != (want == '1') { + t.Fatalf("module %d = %v, want %v (golden %q)", i, got, want == '1', golden) + } + } +} + +// TestModulesGuardsAreAlwaysThere: the three guards do not depend on the digits, +// and a symbol missing one of them is unreadable at any magnification. +func TestModulesGuardsAreAlwaysThere(t *testing.T) { + for _, code := range []EAN13{"0493021012365", "0499000034014", "0493100012361", "0000000000000"} { + modules, err := Modules(code) + if err != nil { + t.Fatalf("Modules(%s): %v", code, err) + } + guards := []struct { + name string + at int + bits string + }{ + {"left", 0, "101"}, + {"centre", 45, "01010"}, + {"right", 92, "101"}, + } + for _, g := range guards { + for i, want := range g.bits { + if got := modules[g.at+i]; got != (want == '1') { + t.Errorf("%s: %s guard, module %d = %v, want %v", code, g.name, g.at+i, got, want == '1') + } + } + } + } +} + +// TestModulesAreDecodableBackToTheirDigits is the property the golden alone +// cannot give: whatever the 13 digits, the module pattern must read back to +// exactly those digits. It covers the parity pattern of the first digit, which a +// single golden exercises for one value only. +func TestModulesAreDecodableBackToTheirDigits(t *testing.T) { + codes := []string{"0493021012365", "0499000034014", "0493100012361", "0493021999994"} + // One code per leading digit, so every parity pattern is exercised. + for first := 0; first <= 9; first++ { + twelve := string(rune('0'+first)) + "12345678901" + full, err := Compose(twelve[:12]) + if err != nil { + t.Fatalf("Compose(%q): %v", twelve, err) + } + codes = append(codes, string(full)) + } + for _, code := range codes { + modules, err := Modules(EAN13(code)) + if err != nil { + t.Fatalf("Modules(%s): %v", code, err) + } + got, err := decodeModules(modules) + if err != nil { + t.Fatalf("decoding the modules of %s: %v", code, err) + } + if got != code { + t.Errorf("modules of %s decode back to %s", code, got) + } + } +} + +func TestModulesRejectsMalformedCode(t *testing.T) { + for _, code := range []EAN13{"", "04930210123", "049302101236A", "04930210123650"} { + if _, err := Modules(code); !errors.Is(err, ErrEAN13Format) { + t.Errorf("Modules(%q) error = %v, want ErrEAN13Format", code, err) + } + } +} diff --git a/internal/domain/ean13_plan_test.go b/internal/domain/ean13_plan_test.go new file mode 100644 index 0000000..33056fd --- /dev/null +++ b/internal/domain/ean13_plan_test.go @@ -0,0 +1,258 @@ +// This file holds vectors T24, T25 and T29 to T33: the numbering plan is a CONSTANT +// OF THE BINARY, indexed by prefix, and it self-checks at start-up (ADR-028). +// +// It also holds the sixteen real broken codes of flv.csv -- valid EAN-13, and +// unusable: their generator truncated past 999, so three of them collapse onto one +// label. That is the defect a station must refuse rather than encode. + +package domain + +import ( + "errors" + "strings" + "testing" +) + +// --- T24: the plan owns the sale mode -------------------------------------- + +// TestRequireModeFollowsThePlan freezes the rule of §10.2: the barcode prefix is +// authoritative for the sale mode, because it is the only one of the two pieces +// of information the till reads. A caller cannot contradict it. +func TestRequireModeFollowsThePlan(t *testing.T) { + if err := RequireMode(unitPattern, ByWeight); !errors.Is(err, ErrPrefixModeMismatch) { + t.Errorf("T24: RequireMode(%s, ByWeight) error = %v, want ErrPrefixModeMismatch", unitPattern, err) + } + if err := RequireMode(garlicPattern, ByUnit); !errors.Is(err, ErrPrefixModeMismatch) { + t.Errorf("RequireMode(%s, ByUnit) error = %v, want ErrPrefixModeMismatch", garlicPattern, err) + } + if err := RequireMode(garlicPattern, ByWeight); err != nil { + t.Errorf("RequireMode(%s, ByWeight) = %v, want no error", garlicPattern, err) + } + if err := RequireMode(unitPattern, ByUnit); err != nil { + t.Errorf("RequireMode(%s, ByUnit) = %v, want no error", unitPattern, err) + } +} + +// --- T25: a prefix outside the plan has no encoding at all ----------------- + +// TestPlanForRejectsPrefixOutsideThePlan: 0491 and 0492 are the "variable price" +// internal codes. They have no entry, so the prohibition comes from an ABSENCE +// and not from a configuration rule -- and the product leaves the import as +// INTERNAL_CODE_NOT_WEIGHABLE rather than as an error. +func TestPlanForRejectsPrefixOutsideThePlan(t *testing.T) { + for _, s := range []string{ + "0491021000009", // T25 + "0490000402001", // DEGRAISSANT SANS RINCAGE VRAC, real line of flv.csv + "3700147000000", // a prepackaged product: a supplier EAN-13 + } { + pattern := EAN13(s) + if _, err := PlanFor(pattern); !errors.Is(err, ErrPrefixNotInPlan) { + t.Errorf("PlanFor(%s) error = %v, want ErrPrefixNotInPlan", pattern, err) + } + if _, err := Generate(pattern, 1236, 5); !errors.Is(err, ErrPrefixNotInPlan) { + t.Errorf("Generate(%s, ...) error = %v, want ErrPrefixNotInPlan", pattern, err) + } + } +} + +// TestPlanCoversTheSixDeclaredWeightPrefixes: only 0493 is used by the two real +// catalogs; the five others are declared because the till already knows them +// (Module1.bas:4085 names the 0493-0498 range). +func TestPlanCoversTheSixDeclaredWeightPrefixes(t *testing.T) { + for _, prefix := range []string{"0493", "0494", "0495", "0496", "0497", "0498"} { + plan, err := PlanFor(EAN13(prefix + "021000003")) + if err != nil { + t.Fatalf("PlanFor(%s...): %v", prefix, err) + } + if plan.Mode != ByWeight || plan.RefWidth != 3 || plan.PayloadWidth != 5 || plan.Decimals != 3 { + t.Errorf("%s: plan = %+v, want by weight, ref 3, payload 5, 3 decimals", prefix, plan) + } + if plan.PriceLabel != " €/kg" { + t.Errorf("%s: PriceLabel = %q, want %q (leading space included)", prefix, plan.PriceLabel, " €/kg") + } + } + plan, err := PlanFor(unitPattern) + if err != nil { + t.Fatalf("PlanFor(0499...): %v", err) + } + if plan.Mode != ByUnit || plan.RefWidth != 6 || plan.PayloadWidth != 2 || plan.Decimals != 0 { + t.Errorf("0499: plan = %+v, want by unit, ref 6, payload 2, 0 decimals", plan) + } +} + +// --- T29 and T30: the plan self-checks at start-up ------------------------- + +// TestInconsistentPlanIsRefused: an inconsistent plan kills the process AT +// START-UP, never at print time. The check lives in a function so that a test +// can exercise it without restarting anything. +func TestInconsistentPlanIsRefused(t *testing.T) { + cases := []struct { + vector, name string + plan PrefixPlan + }{ + {"T29", "zero payload width", + // 4+8+0+1 = 13 passes the arithmetic, yet the variable field does not + // exist any more. That is why the check also demands both widths >= 1. + PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 8, PayloadWidth: 0, Decimals: 3}}, + {"T30", "widths that do not add up to 13", + PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 6, Decimals: 3}}, + {"", "zero reference width", + PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 0, PayloadWidth: 8, Decimals: 3}}, + {"", "prefix not four digits", + PrefixPlan{Prefix: "049", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}}, + {"", "prefix not numeric", + PrefixPlan{Prefix: "04x3", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}}, + {"", "negative decimals", + PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: -1}}, + {"", "more decimals than payload digits", + PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 6}}, + } + for _, c := range cases { + t.Run(c.vector+" "+c.name, func(t *testing.T) { + if err := validatePlan(map[string]PrefixPlan{c.plan.Prefix: c.plan}); err == nil { + t.Errorf("validatePlan accepted %+v", c.plan) + } + }) + } +} + +// TestPlanKeyMatchesItsPrefix catches the copy-paste that would let a map entry +// be reachable under a key its own Prefix field contradicts. +func TestPlanKeyMatchesItsPrefix(t *testing.T) { + mismatched := map[string]PrefixPlan{ + "0494": {Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}, + } + if err := validatePlan(mismatched); err == nil { + t.Error("validatePlan accepted an entry whose key and Prefix disagree") + } +} + +// TestShippedPlanIsConsistent is the same check the init function runs, so that a +// failure names the offending plan instead of panicking during another test. +func TestShippedPlanIsConsistent(t *testing.T) { + if err := validatePlan(internalPlan); err != nil { + t.Fatalf("the shipped numbering plan is inconsistent: %v", err) + } + if len(internalPlan) != 7 { + t.Errorf("%d entries in the plan, want 7 (0493-0498 by weight, 0499 by unit)", len(internalPlan)) + } +} + +// --- T31 to T33: the sixteen real broken codes of flv.csv ------------------ + +// brokenCodesOfFlvCsv are the SIXTEEN references of testdata/catalog/flv.csv +// whose reserved zone is occupied. Verified against the file: all sixteen have a +// VALID check digit -- that is exactly what makes them dangerous -- and an +// exhaustive scan of the 332 codes with prefix 0493 finds no others. +// +// They are already refused by the application in production, so these products +// are absent from the scales today (§10.3, L9). +var brokenCodesOfFlvCsv = []struct { + code, odooID, name string + csvLine int +}{ + {"0493100100006", "5115", "♥AA-TOMME DE SAVOIE -MV", 312}, + {"0493100200003", "5116", "♥SAUCISSE VOLAILLE CHIPOS AUX HERBES X6-MARAULI", 313}, + {"0493100300000", "5117", "♥POITRINE Marinée 4tr -PORC NOIR", 314}, + {"0493100600001", "5138", "MAGRET DE CANARD frais X1 -MR", 315}, + {"0493100700008", "5139", "TOURNEDOS DE CANARD X1 -MR", 316}, + {"0493100800005", "5140", "COEURS DE CANARD X6 -MR", 317}, + {"0493101100005", "5144", "SAUCISSE CANARD FACON CHIPO X4 -MR", 319}, + {"0493101200002", "5148", "MAGRET DE CANARD SECHE NATURE TR -MR", 320}, + {"0493101300009", "5149", "BROCHETTE DE POULET NATURE X2-MR", 321}, + {"0493101400006", "5150", "BROCHETTE DE POULET THYM CITRON X2-MR", 322}, + {"0493101600000", "5151", "BROCHETTE DE POULET PAPRIKA X2-MR", 323}, + {"0493101700007", "5152", "CORDON BLEU DE POULET X 1 -MR", 324}, + {"0493101800004", "5157", "CUISSE DE POULET désossée NATURE X2-MR", 325}, + {"0493101900001", "5158", "CUISSE DE POULET désossée THYM CITRON X 2 -MR", 326}, + {"0493102100004", "5200", "Concombre Local 100% Coopé", 356}, + {"0493102200001", "5209", "MYRTILLE BIO", 327}, +} + +// TestRealBrokenCodesAreValidEAN13ButUnusable is vector T31, one code at a time. +func TestRealBrokenCodesAreValidEAN13ButUnusable(t *testing.T) { + if len(brokenCodesOfFlvCsv) != 16 { + t.Fatalf("%d codes in the vector, want 16", len(brokenCodesOfFlvCsv)) + } + for _, c := range brokenCodesOfFlvCsv { + t.Run(c.code, func(t *testing.T) { + // First half of the vector: the check digit IS valid. These are not + // broken in the EAN-13 sense, and that is the whole danger. + pattern, err := ParseEAN13(c.code) + if err != nil { + t.Fatalf("id %s (%s, CSV line %d): the check digit must be valid, got %v", + c.odooID, c.name, c.csvLine, err) + } + // Second half: they still cannot produce a label. + if _, err := Generate(pattern, 1236, 5); !errors.Is(err, ErrPatternNotZeroed) { + t.Errorf("id %s: Generate error = %v, want ErrPatternNotZeroed", c.odooID, err) + } + // And the reference overflows onto the weight field, which is the + // sentence the import report has to say in French. + if reserved := c.code[7:12]; strings.Trim(reserved, "0") == "" { + t.Errorf("id %s: reserved zone %q is empty; this code does not belong in the vector", + c.odooID, reserved) + } + }) + } +} + +// TestWrongConventionWouldSubstituteAnotherProduct is vector T32, the numeric +// counter-example. Read as a 4-digit reference, TOMME DE SAVOIE at 1.236 kg +// would print 0493100112368 -- which the till, always reading 3 reference digits +// and 5 weight digits, decodes as reference 100 = PATATE DOUCE SAF (id 973, +// 4.67 EUR/kg) weighing 11.236 kg. A factor of ten on the mass AND a silent +// substitution of article. +func TestWrongConventionWouldSubstituteAnotherProduct(t *testing.T) { + tomme := mustPattern(t, "0493100100006") + + // What the test demands: NO label is produced, whichever way one asks. + if _, err := Generate(tomme, 1236, 4); !errors.Is(err, ErrWidthNotInPlan) { + t.Errorf("the 4-digit convention must be unreachable, got %v", err) + } + if _, err := Generate(tomme, 1236, 5); !errors.Is(err, ErrPatternNotZeroed) { + t.Errorf("the 5-digit plan must refuse this pattern, got %v", err) + } + + // And the arithmetic of the counter-example, so that the number in the + // document stays checkable: this is what the wrong convention WOULD yield. + wrong, err := Compose("04931001" + "1236") + if err != nil { + t.Fatalf("Compose: %v", err) + } + if wrong != "0493100112368" { + t.Errorf("the wrong convention yields %s, the document says 0493100112368", wrong) + } + // How the till reads those same 13 digits under the real plan. + if reference, payload := string(wrong)[4:7], string(wrong)[7:12]; reference != "100" || payload != "11236" { + t.Errorf("the till reads reference %q and payload %q, want 100 and 11236", reference, payload) + } +} + +// TestSixteenBrokenCodesCollapseIntoThreeLabels is vector T33: the defect is a +// COLLISION of articles, not a weight discrepancy. Printed at 1.236 kg under the +// real 3+5 plan, the sixteen codes yield only THREE distinct labels. +func TestSixteenBrokenCodesCollapseIntoThreeLabels(t *testing.T) { + distinct := map[EAN13]int{} + for _, c := range brokenCodesOfFlvCsv { + // Bypass the reserved-zone check on purpose: we are computing what WOULD + // come out if the invariant were not enforced. + label, err := Compose(c.code[:7] + "01236") + if err != nil { + t.Fatalf("Compose for %s: %v", c.code, err) + } + distinct[label]++ + } + if len(distinct) != 3 { + t.Fatalf("%d distinct labels, want 3: %v", len(distinct), distinct) + } + for _, want := range []EAN13{ + "0493100012361", // PATATE DOUCE SAF, id 973 + "0493101012360", // SAUCISSE CANARD FACON TOULOUSE X 2-MR, id 5143 + "0493102012369", // AIL BLANC SAF, id 894 + } { + if distinct[want] == 0 { + t.Errorf("label %s missing from the collision set", want) + } + } +} diff --git a/internal/domain/ean13_test.go b/internal/domain/ean13_test.go index e6518d0..56f64fc 100644 --- a/internal/domain/ean13_test.go +++ b/internal/domain/ean13_test.go @@ -1,8 +1,15 @@ +// This file holds vectors T1 to T23 and T26 to T28: the check digit, the encoding +// of a weight and of a unit count, the quantization of §6.2, and what parsing a +// thirteen-digit code gives back. +// +// The reference pattern is garlic, 0493021000003 -- and NOT the 0493021000009 the +// legacy help text published, whose check digit its own integrity check would have +// rejected. That wrong reference is kept as the rejection vector T23. + package domain import ( "errors" - "strings" "testing" ) @@ -357,336 +364,3 @@ func TestComposeAppendsTheCheckDigit(t *testing.T) { t.Errorf("Compose with 11 digits error = %v, want ErrEAN13Format", err) } } - -// --- T24: the plan owns the sale mode -------------------------------------- - -// TestRequireModeFollowsThePlan freezes the rule of §10.2: the barcode prefix is -// authoritative for the sale mode, because it is the only one of the two pieces -// of information the till reads. A caller cannot contradict it. -func TestRequireModeFollowsThePlan(t *testing.T) { - if err := RequireMode(unitPattern, ByWeight); !errors.Is(err, ErrPrefixModeMismatch) { - t.Errorf("T24: RequireMode(%s, ByWeight) error = %v, want ErrPrefixModeMismatch", unitPattern, err) - } - if err := RequireMode(garlicPattern, ByUnit); !errors.Is(err, ErrPrefixModeMismatch) { - t.Errorf("RequireMode(%s, ByUnit) error = %v, want ErrPrefixModeMismatch", garlicPattern, err) - } - if err := RequireMode(garlicPattern, ByWeight); err != nil { - t.Errorf("RequireMode(%s, ByWeight) = %v, want no error", garlicPattern, err) - } - if err := RequireMode(unitPattern, ByUnit); err != nil { - t.Errorf("RequireMode(%s, ByUnit) = %v, want no error", unitPattern, err) - } -} - -// --- T25: a prefix outside the plan has no encoding at all ----------------- - -// TestPlanForRejectsPrefixOutsideThePlan: 0491 and 0492 are the "variable price" -// internal codes. They have no entry, so the prohibition comes from an ABSENCE -// and not from a configuration rule -- and the product leaves the import as -// INTERNAL_CODE_NOT_WEIGHABLE rather than as an error. -func TestPlanForRejectsPrefixOutsideThePlan(t *testing.T) { - for _, s := range []string{ - "0491021000009", // T25 - "0490000402001", // DEGRAISSANT SANS RINCAGE VRAC, real line of flv.csv - "3700147000000", // a prepackaged product: a supplier EAN-13 - } { - pattern := EAN13(s) - if _, err := PlanFor(pattern); !errors.Is(err, ErrPrefixNotInPlan) { - t.Errorf("PlanFor(%s) error = %v, want ErrPrefixNotInPlan", pattern, err) - } - if _, err := Generate(pattern, 1236, 5); !errors.Is(err, ErrPrefixNotInPlan) { - t.Errorf("Generate(%s, ...) error = %v, want ErrPrefixNotInPlan", pattern, err) - } - } -} - -// TestPlanCoversTheSixDeclaredWeightPrefixes: only 0493 is used by the two real -// catalogs; the five others are declared because the till already knows them -// (Module1.bas:4085 names the 0493-0498 range). -func TestPlanCoversTheSixDeclaredWeightPrefixes(t *testing.T) { - for _, prefix := range []string{"0493", "0494", "0495", "0496", "0497", "0498"} { - plan, err := PlanFor(EAN13(prefix + "021000003")) - if err != nil { - t.Fatalf("PlanFor(%s...): %v", prefix, err) - } - if plan.Mode != ByWeight || plan.RefWidth != 3 || plan.PayloadWidth != 5 || plan.Decimals != 3 { - t.Errorf("%s: plan = %+v, want by weight, ref 3, payload 5, 3 decimals", prefix, plan) - } - if plan.PriceLabel != " €/kg" { - t.Errorf("%s: PriceLabel = %q, want %q (leading space included)", prefix, plan.PriceLabel, " €/kg") - } - } - plan, err := PlanFor(unitPattern) - if err != nil { - t.Fatalf("PlanFor(0499...): %v", err) - } - if plan.Mode != ByUnit || plan.RefWidth != 6 || plan.PayloadWidth != 2 || plan.Decimals != 0 { - t.Errorf("0499: plan = %+v, want by unit, ref 6, payload 2, 0 decimals", plan) - } -} - -// --- T29 and T30: the plan self-checks at start-up ------------------------- - -// TestInconsistentPlanIsRefused: an inconsistent plan kills the process AT -// START-UP, never at print time. The check lives in a function so that a test -// can exercise it without restarting anything. -func TestInconsistentPlanIsRefused(t *testing.T) { - cases := []struct { - vector, name string - plan PrefixPlan - }{ - {"T29", "zero payload width", - // 4+8+0+1 = 13 passes the arithmetic, yet the variable field does not - // exist any more. That is why the check also demands both widths >= 1. - PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 8, PayloadWidth: 0, Decimals: 3}}, - {"T30", "widths that do not add up to 13", - PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 6, Decimals: 3}}, - {"", "zero reference width", - PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 0, PayloadWidth: 8, Decimals: 3}}, - {"", "prefix not four digits", - PrefixPlan{Prefix: "049", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}}, - {"", "prefix not numeric", - PrefixPlan{Prefix: "04x3", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}}, - {"", "negative decimals", - PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: -1}}, - {"", "more decimals than payload digits", - PrefixPlan{Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 6}}, - } - for _, c := range cases { - t.Run(c.vector+" "+c.name, func(t *testing.T) { - if err := validatePlan(map[string]PrefixPlan{c.plan.Prefix: c.plan}); err == nil { - t.Errorf("validatePlan accepted %+v", c.plan) - } - }) - } -} - -// TestPlanKeyMatchesItsPrefix catches the copy-paste that would let a map entry -// be reachable under a key its own Prefix field contradicts. -func TestPlanKeyMatchesItsPrefix(t *testing.T) { - mismatched := map[string]PrefixPlan{ - "0494": {Prefix: "0493", Mode: ByWeight, RefWidth: 3, PayloadWidth: 5, Decimals: 3}, - } - if err := validatePlan(mismatched); err == nil { - t.Error("validatePlan accepted an entry whose key and Prefix disagree") - } -} - -// TestShippedPlanIsConsistent is the same check the init function runs, so that a -// failure names the offending plan instead of panicking during another test. -func TestShippedPlanIsConsistent(t *testing.T) { - if err := validatePlan(internalPlan); err != nil { - t.Fatalf("the shipped numbering plan is inconsistent: %v", err) - } - if len(internalPlan) != 7 { - t.Errorf("%d entries in the plan, want 7 (0493-0498 by weight, 0499 by unit)", len(internalPlan)) - } -} - -// --- T31 to T33: the sixteen real broken codes of flv.csv ------------------ - -// brokenCodesOfFlvCsv are the SIXTEEN references of testdata/catalog/flv.csv -// whose reserved zone is occupied. Verified against the file: all sixteen have a -// VALID check digit -- that is exactly what makes them dangerous -- and an -// exhaustive scan of the 332 codes with prefix 0493 finds no others. -// -// They are already refused by the application in production, so these products -// are absent from the scales today (§10.3, L9). -var brokenCodesOfFlvCsv = []struct { - code, odooID, name string - csvLine int -}{ - {"0493100100006", "5115", "♥AA-TOMME DE SAVOIE -MV", 312}, - {"0493100200003", "5116", "♥SAUCISSE VOLAILLE CHIPOS AUX HERBES X6-MARAULI", 313}, - {"0493100300000", "5117", "♥POITRINE Marinée 4tr -PORC NOIR", 314}, - {"0493100600001", "5138", "MAGRET DE CANARD frais X1 -MR", 315}, - {"0493100700008", "5139", "TOURNEDOS DE CANARD X1 -MR", 316}, - {"0493100800005", "5140", "COEURS DE CANARD X6 -MR", 317}, - {"0493101100005", "5144", "SAUCISSE CANARD FACON CHIPO X4 -MR", 319}, - {"0493101200002", "5148", "MAGRET DE CANARD SECHE NATURE TR -MR", 320}, - {"0493101300009", "5149", "BROCHETTE DE POULET NATURE X2-MR", 321}, - {"0493101400006", "5150", "BROCHETTE DE POULET THYM CITRON X2-MR", 322}, - {"0493101600000", "5151", "BROCHETTE DE POULET PAPRIKA X2-MR", 323}, - {"0493101700007", "5152", "CORDON BLEU DE POULET X 1 -MR", 324}, - {"0493101800004", "5157", "CUISSE DE POULET désossée NATURE X2-MR", 325}, - {"0493101900001", "5158", "CUISSE DE POULET désossée THYM CITRON X 2 -MR", 326}, - {"0493102100004", "5200", "Concombre Local 100% Coopé", 356}, - {"0493102200001", "5209", "MYRTILLE BIO", 327}, -} - -// TestRealBrokenCodesAreValidEAN13ButUnusable is vector T31, one code at a time. -func TestRealBrokenCodesAreValidEAN13ButUnusable(t *testing.T) { - if len(brokenCodesOfFlvCsv) != 16 { - t.Fatalf("%d codes in the vector, want 16", len(brokenCodesOfFlvCsv)) - } - for _, c := range brokenCodesOfFlvCsv { - t.Run(c.code, func(t *testing.T) { - // First half of the vector: the check digit IS valid. These are not - // broken in the EAN-13 sense, and that is the whole danger. - pattern, err := ParseEAN13(c.code) - if err != nil { - t.Fatalf("id %s (%s, CSV line %d): the check digit must be valid, got %v", - c.odooID, c.name, c.csvLine, err) - } - // Second half: they still cannot produce a label. - if _, err := Generate(pattern, 1236, 5); !errors.Is(err, ErrPatternNotZeroed) { - t.Errorf("id %s: Generate error = %v, want ErrPatternNotZeroed", c.odooID, err) - } - // And the reference overflows onto the weight field, which is the - // sentence the import report has to say in French. - if reserved := c.code[7:12]; strings.Trim(reserved, "0") == "" { - t.Errorf("id %s: reserved zone %q is empty; this code does not belong in the vector", - c.odooID, reserved) - } - }) - } -} - -// TestWrongConventionWouldSubstituteAnotherProduct is vector T32, the numeric -// counter-example. Read as a 4-digit reference, TOMME DE SAVOIE at 1.236 kg -// would print 0493100112368 -- which the till, always reading 3 reference digits -// and 5 weight digits, decodes as reference 100 = PATATE DOUCE SAF (id 973, -// 4.67 EUR/kg) weighing 11.236 kg. A factor of ten on the mass AND a silent -// substitution of article. -func TestWrongConventionWouldSubstituteAnotherProduct(t *testing.T) { - tomme := mustPattern(t, "0493100100006") - - // What the test demands: NO label is produced, whichever way one asks. - if _, err := Generate(tomme, 1236, 4); !errors.Is(err, ErrWidthNotInPlan) { - t.Errorf("the 4-digit convention must be unreachable, got %v", err) - } - if _, err := Generate(tomme, 1236, 5); !errors.Is(err, ErrPatternNotZeroed) { - t.Errorf("the 5-digit plan must refuse this pattern, got %v", err) - } - - // And the arithmetic of the counter-example, so that the number in the - // document stays checkable: this is what the wrong convention WOULD yield. - wrong, err := Compose("04931001" + "1236") - if err != nil { - t.Fatalf("Compose: %v", err) - } - if wrong != "0493100112368" { - t.Errorf("the wrong convention yields %s, the document says 0493100112368", wrong) - } - // How the till reads those same 13 digits under the real plan. - if reference, payload := string(wrong)[4:7], string(wrong)[7:12]; reference != "100" || payload != "11236" { - t.Errorf("the till reads reference %q and payload %q, want 100 and 11236", reference, payload) - } -} - -// TestSixteenBrokenCodesCollapseIntoThreeLabels is vector T33: the defect is a -// COLLISION of articles, not a weight discrepancy. Printed at 1.236 kg under the -// real 3+5 plan, the sixteen codes yield only THREE distinct labels. -func TestSixteenBrokenCodesCollapseIntoThreeLabels(t *testing.T) { - distinct := map[EAN13]int{} - for _, c := range brokenCodesOfFlvCsv { - // Bypass the reserved-zone check on purpose: we are computing what WOULD - // come out if the invariant were not enforced. - label, err := Compose(c.code[:7] + "01236") - if err != nil { - t.Fatalf("Compose for %s: %v", c.code, err) - } - distinct[label]++ - } - if len(distinct) != 3 { - t.Fatalf("%d distinct labels, want 3: %v", len(distinct), distinct) - } - for _, want := range []EAN13{ - "0493100012361", // PATATE DOUCE SAF, id 973 - "0493101012360", // SAUCISSE CANARD FACON TOULOUSE X 2-MR, id 5143 - "0493102012369", // AIL BLANC SAF, id 894 - } { - if distinct[want] == 0 { - t.Errorf("label %s missing from the collision set", want) - } - } -} - -// --- The 95 modules of the symbol ------------------------------------------ - -// TestModulesOfTheReferenceBarcode is the frozen bit string of §7.4-1, obtained -// once and checked with an INDEPENDENT decoder: the 95 modules were re-read back -// to 0493021012365 by a decoder that shares no table with the encoder. -// -// Layout: 3 (left guard) + 6x7 + 5 (centre guard) + 6x7 + 3 (right guard) = 95. -func TestModulesOfTheReferenceBarcode(t *testing.T) { - const golden = "10101000110001011011110100011010010011001100101010111001011001101101100100001010100001001110101" - - modules, err := Modules("0493021012365") - if err != nil { - t.Fatalf("Modules: %v", err) - } - if len(golden) != 95 { - t.Fatalf("the golden string has %d characters, want 95", len(golden)) - } - for i, want := range golden { - if got := modules[i]; got != (want == '1') { - t.Fatalf("module %d = %v, want %v (golden %q)", i, got, want == '1', golden) - } - } -} - -// TestModulesGuardsAreAlwaysThere: the three guards do not depend on the digits, -// and a symbol missing one of them is unreadable at any magnification. -func TestModulesGuardsAreAlwaysThere(t *testing.T) { - for _, code := range []EAN13{"0493021012365", "0499000034014", "0493100012361", "0000000000000"} { - modules, err := Modules(code) - if err != nil { - t.Fatalf("Modules(%s): %v", code, err) - } - guards := []struct { - name string - at int - bits string - }{ - {"left", 0, "101"}, - {"centre", 45, "01010"}, - {"right", 92, "101"}, - } - for _, g := range guards { - for i, want := range g.bits { - if got := modules[g.at+i]; got != (want == '1') { - t.Errorf("%s: %s guard, module %d = %v, want %v", code, g.name, g.at+i, got, want == '1') - } - } - } - } -} - -// TestModulesAreDecodableBackToTheirDigits is the property the golden alone -// cannot give: whatever the 13 digits, the module pattern must read back to -// exactly those digits. It covers the parity pattern of the first digit, which a -// single golden exercises for one value only. -func TestModulesAreDecodableBackToTheirDigits(t *testing.T) { - codes := []string{"0493021012365", "0499000034014", "0493100012361", "0493021999994"} - // One code per leading digit, so every parity pattern is exercised. - for first := 0; first <= 9; first++ { - twelve := string(rune('0'+first)) + "12345678901" - full, err := Compose(twelve[:12]) - if err != nil { - t.Fatalf("Compose(%q): %v", twelve, err) - } - codes = append(codes, string(full)) - } - for _, code := range codes { - modules, err := Modules(EAN13(code)) - if err != nil { - t.Fatalf("Modules(%s): %v", code, err) - } - got, err := decodeModules(modules) - if err != nil { - t.Fatalf("decoding the modules of %s: %v", code, err) - } - if got != code { - t.Errorf("modules of %s decode back to %s", code, got) - } - } -} - -func TestModulesRejectsMalformedCode(t *testing.T) { - for _, code := range []EAN13{"", "04930210123", "049302101236A", "04930210123650"} { - if _, err := Modules(code); !errors.Is(err, ErrEAN13Format) { - t.Errorf("Modules(%q) error = %v, want ErrEAN13Format", code, err) - } - } -} diff --git a/internal/domain/effects.go b/internal/domain/effects.go new file mode 100644 index 0000000..34356ba --- /dev/null +++ b/internal/domain/effects.go @@ -0,0 +1,115 @@ +package domain + +import "time" + +// This file holds the eight effects -- everything the outside world has to do once +// a transition has decided -- and the acknowledgement a command gets back. +// +// The machine DESCRIBES them and never performs them, which is what keeps +// Transition pure and h.execute trivial (§13.2). + +// Effect is something the outside world has to do. The machine DESCRIBES it and +// never performs it, which is what keeps Transition pure and h.execute trivial +// (§13.2). +type Effect interface { + effect() +} + +// PrintEffect hands one label to the print worker. +// +// It is emitted by the exit of Validating and by a reprint, and by nothing else +// (invariant 2 of §6.7). +type PrintEffect struct { + Label Label + // Reprint makes the renderer print the RÉIMPRESSION mention (§8.5), which is + // what neutralises the fraud vector: a cashier sees it. It is not a command + // flag but a property of the job -- the same label, printed a second time on + // purpose. + Reprint bool +} + +// RecordEffect hands one journal row to the journal worker. +// +// Two of the columns of §12.3 are deliberately left empty here: rate_ms and +// frame. The observed median cadence lives in the Hub's RateMeter and the raw +// serial frame in its capture ring; neither reaches a pure function, and inventing +// them would be worse than leaving them to the single component that owns them. +type RecordEffect struct{ Weighing Weighing } + +// MessageEffect is one banner message. Text is FRENCH and already interpolated: +// it is read by a customer at a screen. +type MessageEffect struct { + Level string + Code string + Text string + Duration time.Duration +} + +// SoundEffect names a sound the BROWSER plays. The backend does no audio I/O. +type SoundEffect struct{ Name string } + +// AckEffect is the answer rendered to a command, and the value stored under its +// idempotency key so a replayed command replays the answer instead of executing +// anything (§13.2, failure test 15). +type AckEffect struct { + Key string + Ack Ack +} + +// TechnicalLogEffect is one line of the technical journal -- what the station has +// to say about itself, never something a customer reads. +type TechnicalLogEffect struct { + Level string + Source string + Code string + Message string + Detail string +} + +// ArmTimerEffect declares how long the bounded wait the machine just entered may +// last, so that the screen can show it running out. +// +// The machine does not depend on it: expiry is decided by comparing Now with the +// instant the wait started, which is what makes it survive a lost tick. +type ArmTimerEffect struct{ Duration time.Duration } + +// ApplyCatalogEffect publishes a new catalog snapshot. +// +// It is emitted from Initializing and from Idle only. Emitting it from a weighing +// state would reorder the tiles under a customer's finger, which is exactly what +// the deferred swap of §10.8 exists to prevent. +// +// ImportedAt is carried through from the event, untouched: the effect publishes what +// an import produced, and it dates it from that import and not from its own moment. +type ApplyCatalogEffect struct { + Catalog *Catalog + ImportedAt time.Time +} + +func (PrintEffect) effect() {} +func (RecordEffect) effect() {} +func (MessageEffect) effect() {} +func (SoundEffect) effect() {} +func (AckEffect) effect() {} +func (TechnicalLogEffect) effect() {} +func (ArmTimerEffect) effect() {} +func (ApplyCatalogEffect) effect() {} + +// Ack is what a command gets back. +// +// A command cycle ALWAYS replies (§13.2, défaut 62): every terminal transition +// emits an AckEffect, and the Hub holds a safety net for the events a state +// ignores. +type Ack struct { + // Accepted says whether the command started a cycle. + Accepted bool + // State is the state reached, so the admin screen can render an ack the way + // the client screen renders it. + State State + // JobID is filled only when a label was handed to the printer. + JobID string + // Code is the safeguard code of a refusal, empty otherwise. + Code string + // Message is FRENCH: it is displayed as it is. + Message string +} diff --git a/internal/domain/events.go b/internal/domain/events.go new file mode 100644 index 0000000..2f69aa2 --- /dev/null +++ b/internal/domain/events.go @@ -0,0 +1,147 @@ +package domain + +import "time" + +// This file holds the thirteen events -- everything that can happen to the station +// -- and nothing else. What each state answers them with is transition_weighing.go +// and transition_outcome.go. + +// Event is one thing that happened to the station. +// +// The set is CLOSED, and the unexported method is what closes it: no package +// outside this one can add a fourteenth event, so the exhaustive test of §6.7 -- +// sixteen states times thirteen events -- stays exhaustive by construction. +// +// UnitsConfirmed is ABSENT for the same reason EnteringUnits is (ADR-023): it was +// emitted by a full-screen keypad that no longer exists. +type Event interface { + event() +} + +// MeasurementReceived carries one reading, whatever produced it. +type MeasurementReceived struct{ M Measurement } + +// ScaleDisconnected reports that the scale stopped answering. +// +// Err is a LOGGED REASON and conditions nothing (défaut 40): the trigger is the +// status alone. Making the loss of the scale depend on an optional field is what +// let the signal fall into a default branch and never reach the machine. +type ScaleDisconnected struct{ Err error } + +// ScaleReconnected reports that the scale answers again. +type ScaleReconnected struct{} + +// ProductTapped is one touch on one tile, and it carries the fields of +// POST /api/v1/weigh (§14.5). +type ProductTapped struct { + ProductID string + // Tare is AUTHORITATIVE for this weighing: the keypad lives in the front, and + // one field with one owner beats two that have to agree. + Tare Grams + // Units is the tile affordance of ADR-023. Zero means "the front sent none", + // which is one unit -- the answer in the overwhelming majority of cases. + Units int + // SeenWeight is the gross mass the customer was looking at when they touched. + // Zero means the front declared none. + SeenWeight Grams + // MeasurementSeq is the sequence number of the frame the front was showing. It + // is recorded rather than compared: a fresh frame arrives every 400 ms or so, + // so an equality test on the sequence would refuse every legitimate tap. The + // comparison that protects the customer is the one on the WEIGHT. + MeasurementSeq int64 + // Key is the ULID the front generated on pointerdown. It is the idempotency + // key of the cycle AND the identifier of the print job (§12.3). + Key string +} + +// ConfigurationRepaired reports that the configuration this station refused at start-up +// is now valid, and is the ONE way out of OutOfService. +// +// It is the mirror image of how that state is entered: §11.3 has the composition root +// put the station there, from OUTSIDE the machine, when the file it read carries faults. +// Leaving it the same way — on a signal the composition root raises once the faults are +// gone — is what makes the promise of §11.4 true for that station too: no configuration +// block requires a restart of the process. Without it, a station repaired from the +// administration screen kept showing « Poste hors service » until somebody restarted a +// service the screen deliberately has no button for. +// +// It carries NOTHING and it is INERT in the fifteen other states: a configuration saved +// while a customer is mid-cycle must not touch the weighing under their finger. +type ConfigurationRepaired struct{} + +// TareTapped opens the tare keypad. +type TareTapped struct{} + +// TareConfirmed carries the tare a volunteer or a customer typed, in grams. +// +// The value is NOT checked here: safeguard rule 7 is the single place that says +// whether a tare is usable, and it says it against the weight it will be applied +// to, which is not known yet. +type TareConfirmed struct { + Tare Grams + Key string +} + +// ManualWeightConfirmed carries a GROSS mass typed by hand, in grams. +// +// It is the degraded path: no model of the fleet supports Tare() over the serial +// line, and no scale at all on a station that declares scale.present false. +type ManualWeightConfirmed struct { + Weight Grams + Key string +} + +// PrintFinished reports the outcome of one print job. +type PrintFinished struct { + JobID string + Err error + Duration time.Duration +} + +// ReprintRequested asks for the last label again (§8.5). +// +// JobID names what is being reprinted, and it is checked against the label the +// model still holds: a reprint that names another job is a stale request, never a +// second label. +type ReprintRequested struct { + JobID string + Key string +} + +// CatalogReady carries the first usable catalog snapshot. +// +// ImportedAt is the instant of the import that produced it, which the client screen +// shows permanently (§14.3). It travels with the catalog rather than being read off +// the clock at the far end: the two moments are not the same one, and the screen +// answers « quand ce catalogue a-t-il été importé ? ». +type CatalogReady struct { + Catalog *Catalog + ImportedAt time.Time +} + +// Cancel clears the selection. It leads to a model with no product and no label +// from every state (invariant 1 of §6.7). +type Cancel struct{} + +// Dismiss acknowledges a full-screen fault. +type Dismiss struct{} + +// Tick wakes the loop up and carries NO temporal semantics (bloquant-1): every +// duration is computed from TransitionContext.Now, never accumulated tick by +// tick, so a lost tick can no longer under-count an age. +type Tick struct{} + +func (MeasurementReceived) event() {} +func (ScaleDisconnected) event() {} +func (ScaleReconnected) event() {} +func (ProductTapped) event() {} +func (TareTapped) event() {} +func (TareConfirmed) event() {} +func (ManualWeightConfirmed) event() {} +func (PrintFinished) event() {} +func (ReprintRequested) event() {} +func (CatalogReady) event() {} +func (Cancel) event() {} +func (Dismiss) event() {} +func (Tick) event() {} +func (ConfigurationRepaired) event() {} diff --git a/internal/domain/fault.go b/internal/domain/fault.go new file mode 100644 index 0000000..313d8e6 --- /dev/null +++ b/internal/domain/fault.go @@ -0,0 +1,35 @@ +package domain + +// This file holds what EVERY validation of this package returns, and the one +// predicate its lists of admissible values are read with. +// +// It is not the business of the template, of the configuration or of the numbering +// plan: the three of them answer in the same shape, so a screen renders one list of +// faults whatever produced it. + +// Fault is a single validation error, named by the field that carries it. +// +// Validation returns ALL the faults, not the first one: the admin screen is used +// by volunteers, it must report everything at once, in French, with the offending +// field named and, whenever possible, the list of acceptable values. +type Fault struct { + Field string `json:"field"` + Message string `json:"message"` + Values []string `json:"values,omitempty"` +} + +func (f Fault) String() string { return f.Field + " : " + f.Message } + +// known reports whether a value belongs to a closed list. +// +// It is the test behind every Fault.Values there is: what a fault offers as +// admissible and what the control accepts have to be the same list, read the same +// way, or a screen would propose a value its own station refuses. +func known(list []string, value string) bool { + for _, candidate := range list { + if candidate == value { + return true + } + } + return false +} diff --git a/internal/domain/fingerprint.go b/internal/domain/fingerprint.go new file mode 100644 index 0000000..0e2bf50 --- /dev/null +++ b/internal/domain/fingerprint.go @@ -0,0 +1,58 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" +) + +// This file holds the eight characters a volunteer compares by eye, and nothing +// else: the digest of one configuration block, and the digest of a whole station. + +// fingerprintLength is how many hexadecimal characters the dashboard shows. +// +// Eight is what makes "do the four stations display the same string?" a check +// anybody can do by eye -- which the 227 _Poste1..4 columns of the legacy +// application never allowed. +const fingerprintLength = 8 + +// BlockFingerprint reports the SHA-256 of the canonical JSON of one configuration +// block, as eight hexadecimal characters. +// +// It is what Station.Reload compares to decide whether a block REALLY changed +// (§11.4): a normalised comparison and not reflect.DeepEqual over raw JSON, so that +// a reformatted file does not close the serial port under a customer. +func BlockFingerprint(block any) string { + canonical, err := CanonicalJSON(block) + if err != nil { + return strings.Repeat("?", fingerprintLength) + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:])[:fingerprintLength] +} + +// Fingerprint reports the eight characters the dashboard shows, so that "do the +// four stations display the same string?" is a check anybody can do by eye (§11.5). +// +// IT IS COMPUTED ON THE HARDWARE-FREE VIEW, Export(false), with modified_at and +// _readme cleared -- and it has to be, otherwise it could never do the one job it +// exists for: four stations of one homogeneous fleet differ by their number, their +// name, their COM port and their print queue, and each file was written at a +// different instant. What the figure compares is what MUST be identical: the price +// grid, the safeguards, the template, the categories, the retention -- and, since the +// export stopped dropping the three option maps whole, the label offset, the +// darkness, the speed, the serial settings of the scale and the import guards of the +// catalog. +// +// That second half of the list was never decided HERE, and it must not be: the +// fingerprint FOLLOWS the export, because what two stations must have in common is +// exactly what a clone carries over, and one definition of that is worth more than +// two that drift apart. Widening what travels widens the digest with it -- a station +// whose darkness alone was raised now shows a different string, and it should: it +// does not print like the other three. +func (c *Config) Fingerprint() string { + subject := c.Export(false) + subject.ModifiedAt, subject.Readme = time.Time{}, "" + return BlockFingerprint(subject) +} diff --git a/internal/domain/fingerprint_test.go b/internal/domain/fingerprint_test.go new file mode 100644 index 0000000..de0b504 --- /dev/null +++ b/internal/domain/fingerprint_test.go @@ -0,0 +1,117 @@ +// This file holds the eight characters a volunteer compares by eye: what they +// ignore, what they must catch, and the fact that they FOLLOW the export rather +// than deciding for themselves what two stations share. + +package domain + +import ( + "encoding/json" + "testing" + "time" +) + +// TestFingerprintIsStableWhateverTheKeyOrder is the property §11.5 rests on: four +// stations compare eight characters, and a reformatted file must not change them. +func TestFingerprintIsStableWhateverTheKeyOrder(t *testing.T) { + first := loadDelivered(t) + + // Re-serialise and re-read: encoding/json emits the keys in the order of the Go + // fields, which is not the order of the delivered file. + reserialised, err := json.Marshal(first) + if err != nil { + t.Fatalf("encodage : %v", err) + } + var second Config + if err := json.Unmarshal(reserialised, &second); err != nil { + t.Fatalf("décodage : %v", err) + } + if first.Fingerprint() != second.Fingerprint() { + t.Fatalf("empreinte %q après réécriture, %q avant : l'ordre des clés ne doit rien changer", + second.Fingerprint(), first.Fingerprint()) + } + if got := len(first.Fingerprint()); got != fingerprintLength { + t.Fatalf("empreinte de %d caractères, attendu %d", got, fingerprintLength) + } +} + +// TestFingerprintIgnoresWhatDiffersFromStationToStation is what makes a homogeneous +// fleet show ONE string: four stations differ by their number, their name, their COM +// port and their print queue, and each file was written at a different instant. +func TestFingerprintIgnoresWhatDiffersFromStationToStation(t *testing.T) { + station2 := loadDelivered(t) + station3 := loadDelivered(t) + station3.Station.Number = 3 + station3.Station.Name = "Poste 3 — légumes" + station3.ModifiedAt = station2.ModifiedAt.Add(48 * time.Hour) + setOption(t, station3.Scale.Options, "port", "COM3") + setOption(t, station3.Printer.Options, "queue", "SATO WS408_3") + station3.Network.Listen = "127.0.0.1:8086" + station3.Admin.RecoveryCodeHash = "" + + if station2.Fingerprint() != station3.Fingerprint() { + t.Fatalf("empreintes %q et %q : deux postes du même parc doivent afficher la même chaîne", + station2.Fingerprint(), station3.Fingerprint()) + } +} + +// TestFingerprintChangesWhenASharedValueChanges is the other half: a station that +// diverges on something that MUST be identical has to show it. +func TestFingerprintChangesWhenASharedValueChanges(t *testing.T) { + reference := loadDelivered(t) + for name, mutate := range map[string]func(*Config){ + "une remise de tarif": func(c *Config) { c.Pricing.Tiers[0].Discount = 200 }, + "un seuil de panier": func(c *Config) { c.Limits.BasketMin = -300 }, + "le gabarit": func(c *Config) { c.Printer.Template = "weighing_neutral_single" }, + "une catégorie": func(c *Config) { c.Catalog.Categories[0].Visible = false }, + "la rétention du journal": func(c *Config) { c.Journal.MaxDays = 30 }, + // Two stations that disagree here do not show the same grid: one offers fifteen + // tiles the other does not have, and the eight characters have to say so. + "les produits à l'unité montrés": func(c *Config) { c.UI.ShowByUnitProducts = true }, + // Same reason, read from the other side: one station shows seven columns where + // its neighbour follows the screen. Neither is wrong, and a fleet that diverges + // by accident must be able to see it by eye. + "le nombre de colonnes de la grille": func(c *Config) { c.UI.GridColumns = 7 }, + } { + t.Run(name, func(t *testing.T) { + diverging := loadDelivered(t) + mutate(&diverging) + if diverging.Fingerprint() == reference.Fingerprint() { + t.Fatalf("empreinte inchangée (%q) alors que %s a changé", reference.Fingerprint(), name) + } + }) + } +} + +// TestBlockFingerprintIsWhatReloadCompares checks that a block reserialised with +// another key order does not cut the serial port in the middle of a service. +func TestBlockFingerprintIsWhatReloadCompares(t *testing.T) { + config := loadDelivered(t) + before := BlockFingerprint(config.Scale) + + reordered := config.Scale + reordered.Options = DriverOptions{} + for key, value := range config.Scale.Options { + reordered.Options[key] = json.RawMessage(" " + string(value) + " ") + } + if after := BlockFingerprint(reordered); after != before { + t.Fatalf("empreinte de bloc %q puis %q : une réécriture ne doit pas fermer le port série", before, after) + } + + setOption(t, reordered.Options, "port", "COM3") + if after := BlockFingerprint(reordered); after == before { + t.Fatal("changer le port doit changer l'empreinte du bloc balance") + } +} + +// TestTheFollowedRepositoryEntersTheFingerprint: the four stations of one +// cooperative must follow the same repository, and a divergence has to be visible +// on the eight characters the dashboard shows and a volunteer compares by eye. +func TestTheFollowedRepositoryEntersTheFingerprint(t *testing.T) { + reference := loadDelivered(t) + diverged := loadDelivered(t) + diverged.Update.Repository = "someone-else/OpenScale" + + if reference.Fingerprint() == diverged.Fingerprint() { + t.Fatal("deux postes suivant deux dépôts différents portent la même empreinte") + } +} diff --git a/internal/domain/fixture_test.go b/internal/domain/fixture_test.go new file mode 100644 index 0000000..c479a30 --- /dev/null +++ b/internal/domain/fixture_test.go @@ -0,0 +1,168 @@ +// This file holds what EVERY configuration test starts from: the delivered file +// itself, the driver registries a running binary would carry, and the four helpers +// that read a fault back. +// +// The delivered configuration is READ from testdata rather than reproduced in Go: +// reproducing it would reintroduce exactly the second source of truth ADR-026 +// removes. + +package domain + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +// deliveredConfigPath is the file lot L9 ships and the installer copies. It is read +// from the tests rather than reproduced in Go, because reproducing it would +// reintroduce exactly the second source of truth ADR-026 removes. +var deliveredConfigPath = filepath.Join("..", "..", "testdata", "config-lacagette.json") + +// loadDelivered returns a FRESH copy of the delivered configuration. +// +// Fresh for every case, and it matters: DriverOptions is a map, so a struct copy +// would let one broken case leak its mutation into the next one. +func loadDelivered(t *testing.T) Config { + t.Helper() + raw, err := os.ReadFile(deliveredConfigPath) + if err != nil { + t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) + } + var config Config + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("décodage de %s : %v", deliveredConfigPath, err) + } + return config +} + +// testRegistries declares the drivers the shipped configuration names, with the +// schema each of them would declare. +// +// The bounds of the options a NUMBERED CONTROL already owns -- roll_capacity, the two +// offsets, poll_interval_s, the two ratios, the two size ceilings -- are deliberately +// left open here: the control names the field and the reason, and declaring the same +// bound twice would report one mistake as two faults. +// +// `copies` is the exception, and it is the one option whose bound NO control owns any +// more: it belongs to the driver, raster.MaxConfiguredCopies, and the schema is the only +// place it is stated. What is declared here is that same [1, 10], as the real driver +// declares it -- a bound left open here would make control 7 look toothless on the one +// key it is now solely responsible for. +func testRegistries() Registries { + serial := []OptionSchema{ + {Key: "port", Kind: OptionText, Required: true}, + {Key: "baud", Kind: OptionInt}, + {Key: "bits", Kind: OptionInt}, + {Key: "parity", Kind: OptionEnum, Values: []string{"N", "E", "O"}}, + {Key: "stop", Kind: OptionInt}, + {Key: "backoff_min_ms", Kind: OptionInt}, + {Key: "backoff_max_ms", Kind: OptionInt}, + } + printerOptions := []OptionSchema{ + {Key: "transport", Kind: OptionEnum, Values: []string{ + TransportWinspool, TransportDevfile, TransportTCP, TransportFile}}, + {Key: "queue", Kind: OptionText}, + {Key: "path", Kind: OptionText}, + {Key: "address", Kind: OptionHostPort}, + {Key: "fallback", Kind: OptionGroup, Options: []OptionSchema{ + {Key: "enabled", Kind: OptionBool}, + {Key: "transport", Kind: OptionEnum, Values: []string{ + TransportWinspool, TransportDevfile, TransportTCP, TransportFile}}, + {Key: "queue", Kind: OptionText}, + }}, + {Key: "darkness", Kind: OptionInt}, + {Key: "speed", Kind: OptionInt}, + {Key: "offset_x", Kind: OptionInt}, + {Key: "offset_y", Kind: OptionInt}, + {Key: "invert_bits", Kind: OptionBool}, + {Key: "copies", Kind: OptionInt, Min: 1, Max: 10}, + {Key: "roll_capacity", Kind: OptionInt}, + } + commonCatalog := []OptionSchema{ + {Key: "separator", Kind: OptionText}, + {Key: "poll_interval_s", Kind: OptionInt}, + {Key: "stable_polls", Kind: OptionInt}, + {Key: "max_file_size_mb", Kind: OptionInt}, + {Key: "max_image_size_kb", Kind: OptionInt}, + {Key: "min_readable_ratio", Kind: OptionRatio}, + {Key: "max_weighable_drop", Kind: OptionRatio}, + {Key: "max_archives", Kind: OptionInt}, + {Key: "archive_days", Kind: OptionInt}, + {Key: "failures_before_reject", Kind: OptionInt}, + } + webdav := append([]OptionSchema{ + {Key: "url", Kind: OptionURL, Required: true}, + {Key: "username", Kind: OptionText}, + {Key: "password", Kind: OptionText}, + }, commonCatalog...) + // `directory` belongs to local_drop ALONE, exactly as the real descriptor declares + // it: it is the one source that watches a directory of this machine. Declaring it + // here is what makes control 46 the only voice on that field -- an undeclared key + // would already be refused by control 9, and the case would prove nothing. + localDrop := append([]OptionSchema{ + {Key: "directory", Kind: OptionText, Use: UseDropDirectory}, + }, commonCatalog...) + + return Registries{ + Scales: []DriverDescriptor{ + {ID: "gram-xfoc-rs", Label: "GRAM XFOC RS", Options: serial}, + {ID: "gram-xfoc-plus", Label: "GRAM XFOC +", Options: serial}, + }, + Printers: []DriverDescriptor{ + // The raster driver declares the head of the parc, exactly as + // cmd/openscale does: it is what rules 3 and 4 measure a template against. + // `preview` declares nothing, because it inks no paper. + {ID: PrinterRaster, Label: "Raster", Options: printerOptions, Capabilities: ReferenceHead()}, + {ID: PrinterSBPL, Label: "SBPL", Options: printerOptions, Capabilities: ReferenceHead()}, + {ID: PrinterPreview, Label: "Aperçu"}, + }, + Transports: []DriverDescriptor{ + {ID: TransportWinspool, Label: "file Windows"}, + {ID: TransportDevfile, Label: "nœud d'impression"}, + {ID: TransportTCP, Label: "imprimante réseau"}, + {ID: TransportFile, Label: "fichier"}, + }, + CatalogSources: []DriverDescriptor{ + {ID: CatalogSourceLocalDrop, Label: "répertoire de dépôt", Options: localDrop}, + {ID: CatalogSourceWebDAV, Label: "partage WebDAV", Options: webdav}, + }, + } +} + +// unreadablePaths is the PathChecker of a service that cannot see a path. +type unreadablePaths struct{} + +func (unreadablePaths) Readable(string) error { return fmt.Errorf("accès refusé") } +func (unreadablePaths) Droppable(string) error { return fmt.Errorf("accès refusé") } + +// setOption writes one driver option the way a file would carry it. +func setOption(t *testing.T, options DriverOptions, key string, value any) { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("encodage de l'option %s : %v", key, err) + } + options[key] = raw +} + +// fieldsOf reports the faulty fields, for a failure message that names them all. +func fieldsOf(faults []Fault) []string { + out := make([]string, 0, len(faults)) + for _, fault := range faults { + out = append(out, fault.String()) + } + return out +} + +// findFault returns the first fault on a field, or nil. +func findFault(faults []Fault, field string) *Fault { + for i := range faults { + if faults[i].Field == field { + return &faults[i] + } + } + return nil +} diff --git a/internal/domain/frame/accumulator.go b/internal/domain/frame/accumulator.go new file mode 100644 index 0000000..7d25234 --- /dev/null +++ b/internal/domain/frame/accumulator.go @@ -0,0 +1,306 @@ +package frame + +import ( + "time" + + "openscale/internal/domain" +) + +// This file holds the accumulator: how a BYTE STREAM becomes whole frames, which is +// a different question from how one frame is read (scanner.go). +// +// It exists because of a defect worth naming: the legacy application read EIGHTEEN +// FIXED BYTES per cycle for frames that are 18 bytes long including their +// terminator. One byte of drift and every subsequent frame was cut in half. Where a +// frame ENDS is a property of the grammar, and it is decided here, once, for the +// Hub, for `openscale capture` and for the « 20 dernières trames » viewer alike. + +// MaxBuffer is how many bytes the accumulator holds before resynchronising. +const MaxBuffer = 512 + +// resyncKeep is how many trailing bytes survive a resynchronisation: enough to +// hold the longest legal frame, so a valid frame straddling the cut is not lost. +const resyncKeep = 64 + +// Accumulator turns a byte stream into whole frames. +// +// It exists because of a defect worth naming: the legacy application read +// EIGHTEEN FIXED BYTES per cycle — CommRead(NumPort, strData, 18, …) — for frames +// that are 18 bytes long including their terminator. One byte of drift and every +// subsequent frame was cut in half. The "degraded" frames of the corpus +// (".996kg", " 0.996kg") are an ARTEFACT of that read, not a property of the +// scale. +type Accumulator struct { + pending []byte + // resyncs counts how many times the buffer was dropped. The diagnostic screen + // shows it: a line that resynchronises constantly is a cabling problem, not a + // parser problem. + resyncs int +} + +// Resyncs reports how many times the buffer was dropped. +// +// A method and not the exported field it used to be, because it is one of the four +// things domain.Decoder asks of every grammar: `openscale capture` and the living +// corpus print this figure, and reaching into the field of one implementation is what +// stopped them from printing it for any other. +func (a *Accumulator) Resyncs() int { return a.resyncs } + +// Feed appends p to the pending tail and returns every measurement the buffer now +// yields. +// +// It silently drops the noise that precedes a valid frame; past MaxBuffer without a +// valid frame it resynchronises by keeping only the last resyncKeep bytes — no +// memory leak, and no permanent lock-up on a noisy line. +func (a *Accumulator) Feed(p []byte, now time.Time) []domain.Measurement { + a.pending = append(a.pending, p...) + + var out []domain.Measurement + for { + measurement, consumed, ok := a.extract(now) + if !ok { + break + } + a.pending = a.pending[consumed:] + if measurement != nil { + out = append(out, *measurement) + } + } + + if len(a.pending) > MaxBuffer { + a.pending = append([]byte(nil), a.pending[len(a.pending)-resyncKeep:]...) + a.resyncs++ + } + return out +} + +// Pending reports how many bytes are waiting for the rest of their frame. The test +// of §9.2 asserts it never exceeds MaxBuffer. +func (a *Accumulator) Pending() int { return len(a.pending) } + +// Reset drops the buffer. Called when the port is reopened: half a frame from +// before a reconnection must not be completed by bytes from after it. +func (a *Accumulator) Reset() { a.pending, a.resyncs = nil, 0 } + +// extract pulls the next frame, or the next piece of noise, out of the buffer. +// +// It reports (measurement, bytes consumed, whether anything was consumed). A nil +// measurement with consumed > 0 means "that was noise, dropped". +func (a *Accumulator) extract(now time.Time) (*domain.Measurement, int, bool) { + // 0. The CONTROL FRAMING of the GRAM XFOC PLUS, and it comes first because it is + // what the parc really puts on the wire. + if measurement, consumed, ok := a.extractFramed(now); ok { + return measurement, consumed, true + } + + // 1. A terminator is the primary delimiter, because it is what the scales of + // this parc actually send. + if end := indexAny(a.pending, '\r', '\n'); end >= 0 { + consumed := end + 1 + // CRLF counts as one terminator. + if a.pending[end] == '\r' && consumed < len(a.pending) && a.pending[consumed] == '\n' { + consumed++ + } + // The LONGEST SUFFIX that parses, not just the whole candidate. Noise with + // no terminator of its own sits in front of the next real frame — that is + // exactly what a resynchronisation leaves behind — and dropping the whole + // line would cost a weighing for every burst of noise on the cable. + if measurement, ok := parseLongestSuffix(a.pending[:end], now); ok { + return measurement, consumed, true + } + return nil, consumed, true // nothing salvageable: dropped + } + + // 2. No terminator yet. The grammar allows a frame to end at its unit, so try + // every position just past a 'G' — the only byte a frame can end on. That + // keeps the scan proportional to the number of candidate ends rather than to + // the square of the buffer length, and it handles frames sent back to back + // with no terminator at all. + for i := 0; i < len(a.pending); i++ { + if upper(a.pending[i]) != 'G' { + continue + } + if measurement, err := Parse(a.pending[:i+1], now); err == nil { + return &measurement, i + 1, true + } + } + return nil, 0, false +} + +// The control codes that frame one transmission of a GRAM XFOC PLUS. +// +// The whole frame is sixteen bytes and was read off a real scale on the L0 bench: +// +// SOH STX S|U ' '|'-' ' 0,000' KG XOR ETX EOT flags +// 01 02 1 1 6 2 1 03 04 1 +// +// The XOR travels between the unit and ETX and covers everything from the status to +// the unit. The byte after EOT is a flag field — 0x80 whenever the mass is negative, +// 0x10 near zero — and NOTHING READS IT: the sign is already in the payload, and two +// sources for one fact are two things to keep in step. +const ( + startOfHeading = 0x01 + startOfText = 0x02 + endOfText = 0x03 + endOfTransmission = 0x04 +) + +// FrameEnd reports how many bytes at the head of p make up the first COMPLETE frame, +// or -1 when the frame is still arriving. +// +// It is a METHOD and not a function of this package, because it is the half of +// domain.Decoder that `openscale capture` needs: the command writes one frame per line +// and must cut the stream at exactly the same places the decoder does. Left as a +// package function it could only ever be called for THIS grammar, and a second protocol +// would be captured by whatever the command happened to search for — which is how the +// first bench capture came back with a summary of 194 decoded frames and a file +// containing none. +// +// It reads no state of the accumulator, and that is not an oversight: where a frame +// ends is a property of the GRAMMAR, not of what is currently buffered. The receiver is +// what carries the grammar's identity, nothing more. +// +// It handles the two DELIMITED forms — control framing and terminator — and not the +// back-to-back form the Accumulator also accepts, because a capture file with no +// delimiter at all could not be read back line by line anyway. +func (*Accumulator) FrameEnd(p []byte) int { + if start := indexByte(p, startOfText); start == 0 || (start > 0 && indexTerminatorByte(p[:start]) < 0) { + end := indexByte(p, endOfText) + if end < 0 { + return -1 + } + // The frame ends on ETX, then EOT, then one byte of flags. They are consumed + // when they have arrived so that a capture file holds what the scale sent — + // the flags are evidence, 0x80 on every negative mass — and never counted as + // mandatory, so a firmware that stops sending them does not hang this. + end++ + if end < len(p) && p[end] == endOfTransmission { + end++ + if end < len(p) && p[end] != startOfHeading && p[end] != startOfText { + end++ + } + } + return end + } + end := indexTerminatorByte(p) + if end < 0 { + return -1 + } + end++ + if p[end-1] == '\r' && end < len(p) && p[end] == '\n' { + end++ + } + return end +} + +func indexTerminatorByte(data []byte) int { return indexAny(data, '\r', '\n') } + +// extractFramed pulls one STX … ETX transmission out of the buffer. +// +// It answers the same triple as extract: the measurement, what to consume, and +// whether anything was consumed at all. A frame whose checksum does not agree is +// CONSUMED AND DROPPED — a corrupted mass is a wrong price on a label, and the one +// thing this package refuses to do is guess. +// +// It consumes up to and including ETX and no further. EOT and the flag byte become +// leading noise for the next call, which skips them looking for the next STX: two +// bytes of noise cost nothing, and a parser that counts trailing bytes it does not +// read is a parser that breaks the day a firmware adds one. +func (a *Accumulator) extractFramed(now time.Time) (*domain.Measurement, int, bool) { + start := indexByte(a.pending, startOfText) + if start < 0 { + return nil, 0, false + } + end := indexByte(a.pending[start:], endOfText) + if end < 0 { + return nil, 0, false // the rest of the frame has not arrived yet + } + end += start + + // Between STX and ETX: the payload, then the one byte of checksum. + body := a.pending[start+1 : end] + if len(body) < 2 { + return nil, end + 1, true // framing with nothing in it + } + payload, checksum := body[:len(body)-1], body[len(body)-1] + if xor(payload) != checksum { + return nil, end + 1, true + } + measurement, err := Parse(payload, now) + if err != nil { + return nil, end + 1, true + } + return &measurement, end + 1, true +} + +// xor is the checksum the GRAM computes over the payload of a frame. Verified on the +// 668 frames of the bench capture, and on the fourteen it took to find it. +func xor(payload []byte) byte { + var sum byte + for _, b := range payload { + sum ^= b + } + return sum +} + +func indexByte(data []byte, want byte) int { + for i, b := range data { + if b == want { + return i + } + } + return -1 +} + +// parseLongestSuffix finds the frame hidden at the end of a candidate line. +// +// It walks start positions from the left, so the FIRST success is the longest +// suffix that parses. Starting from the left rather than the right matters: on +// " 0.996kg" the longest suffix is the whole thing, 996 g, whereas the shortest +// would be "6kg" — 6000 g, a guess, and precisely the class of error this package +// exists to refuse. +// +// Only positions a frame could actually begin on are tried, which keeps a 512-byte +// buffer of noise from costing 512 parses. +func parseLongestSuffix(candidate []byte, now time.Time) (*domain.Measurement, bool) { + for start := 0; start < len(candidate); start++ { + if !canBeginAFrame(candidate[start]) { + continue + } + // NEVER start in the middle of a number. Without this guard the search + // re-introduces the very guess this package exists to refuse: on ".996kg" + // it would skip the dot, read "996kg", and report NINE HUNDRED AND + // NINETY-SIX KILOGRAMS for a frame whose leading digits were cut off. The + // living corpus caught exactly that. + if start > 0 && continuesANumber(candidate[start-1]) { + continue + } + if measurement, err := Parse(candidate[start:], now); err == nil { + return &measurement, true + } + } + return nil, false +} + +// continuesANumber reports whether a byte is part of a number, so that the byte +// after it cannot be treated as the start of a fresh frame. +func continuesANumber(b byte) bool { return isDigit(b) || b == '.' || b == ',' } + +// canBeginAFrame reports whether a byte can be the first byte of a frame: a status +// letter, a sign, a blank or a digit. +func canBeginAFrame(b byte) bool { + switch upper(b) { + case 'S', 'U', 'O', '+', '-', ' ', '\t': + return true + } + return isDigit(b) +} + +func indexAny(data []byte, a, b byte) int { + for i, c := range data { + if c == a || c == b { + return i + } + } + return -1 +} diff --git a/internal/domain/frame/frame.go b/internal/domain/frame/frame.go index 46d7c17..088e910 100644 --- a/internal/domain/frame/frame.go +++ b/internal/domain/frame/frame.go @@ -20,6 +20,10 @@ // characters, and their behaviour on a short frame. Those are not protocol // differences: they are two diverging copies of the same fixed-window code. One // case-insensitive grammar covers both models. +// +// The package is three files, one per question: this one decodes ONE frame, +// scanner.go walks the grammar token by token, accumulator.go turns a byte STREAM +// into whole frames. package frame import ( @@ -48,13 +52,6 @@ const ( UnitGram ) -// MaxBuffer is how many bytes the accumulator holds before resynchronising. -const MaxBuffer = 512 - -// resyncKeep is how many trailing bytes survive a resynchronisation: enough to -// hold the longest legal frame, so a valid frame straddling the cut is not lost. -const resyncKeep = 64 - // Parse decodes one complete frame into a measurement. It is pure and stateless. // // now is the instant the frame was decoded, and it is RECEIVED: the core reads no @@ -130,483 +127,3 @@ func digitsToInt(digits string) int64 { } return n } - -// --- the scanner ----------------------------------------------------------- - -// scanner walks a frame once, left to right. A hand-written scanner rather than a -// regular expression: it names which part of the grammar failed, which is what the -// living corpus of testdata/frames/ needs in order to be diagnosable. -type scanner struct { - data []byte - at int -} - -func (s *scanner) done() bool { return s.at >= len(s.data) } - -func (s *scanner) peek(offset int) byte { - if s.at+offset >= len(s.data) { - return 0 - } - return s.data[s.at+offset] -} - -// upper folds one byte to upper case, ASCII only: the grammar is case-insensitive -// and every token of it is ASCII. -func upper(b byte) byte { - if b >= 'a' && b <= 'z' { - return b - 'a' + 'A' - } - return b -} - -// hasWord reports whether the scanner sits on word, case-insensitively. -func (s *scanner) hasWord(word string) bool { - for i := 0; i < len(word); i++ { - if upper(s.peek(i)) != word[i] { - return false - } - } - return true -} - -// prefix consumes the optional status and mode fields and reports what the status -// says. Absent prefix means the model does not report stability, which the latch -// handles through its variation criterion instead of pretending to know. -func (s *scanner) prefix() (domain.Stability, bool) { - stability, overload := domain.StabilityUnknown, false - - // The two-letter forms first: "ST" must not be read as "S" followed by "T". - switch { - case s.hasWord("ST"): - stability, _ = domain.Stable, s.advance(2) - case s.hasWord("US"): - stability, _ = domain.Unstable, s.advance(2) - case s.hasWord("OL"): - // Over capacity. The mass that follows is meaningless, but the frame is - // still well-formed and the flag has to reach safeguard rule 1. - overload, _ = true, s.advance(2) - - // A LONE STATUS LETTER, followed by the value rather than by a comma. This is - // what a GRAM XFOC PLUS really sends — « S 0,002KG », « U- 0,432KG », measured - // on the L0 bench of 28/07/2026 over 668 frames. §9.2 made the comma mandatory, - // so the real frame was refused for « having no usable number »: the status - // letter was offered to the number parser, which is not a digit. - case upper(s.peek(0)) == 'S' && startsAValue(s.peek(1)): - stability, _ = domain.Stable, s.advance(1) - return stability, overload - case upper(s.peek(0)) == 'U' && startsAValue(s.peek(1)): - stability, _ = domain.Unstable, s.advance(1) - return stability, overload - - case upper(s.peek(0)) == 'S' && s.peek(1) == ',': - stability, _ = domain.Stable, s.advance(1) - case upper(s.peek(0)) == 'U' && s.peek(1) == ',': - stability, _ = domain.Unstable, s.advance(1) - default: - return stability, overload // no prefix at all - } - - if s.peek(0) != ',' { - // A status not followed by a comma is not a prefix; rewind so the bytes are - // offered to the number parser, which will refuse them properly. - s.at = 0 - return domain.StabilityUnknown, false - } - s.advance(1) - - // The optional mode field: gross or net. We read it and do not act on it — the - // tare is entered on screen, never announced by the scales of this parc. - for _, mode := range []string{"GS", "NT"} { - if s.hasWord(mode) && s.peek(2) == ',' { - s.advance(3) - return stability, overload - } - } - for _, mode := range []byte{'G', 'N'} { - if upper(s.peek(0)) == mode && s.peek(1) == ',' { - s.advance(2) - return stability, overload - } - } - return stability, overload -} - -func (s *scanner) advance(n int) bool { - s.at += n - return true -} - -// startsAValue reports whether a byte can open the value that follows a lone status -// letter: a sign, the blanks the GRAM right-aligns its number in, or a digit. -// -// It is what tells « S » the status from an « S » that would begin something else. -// Nothing else in this grammar starts with a letter, so the test is generous on -// purpose — a frame that is not one still fails on its number or on its unit, which -// are the two places this package refuses rather than guesses. -func startsAValue(b byte) bool { - switch b { - case ' ', '\t', '+', '-': - return true - } - return isDigit(b) -} - -// sign consumes an optional sign and reports whether the value is negative. -func (s *scanner) sign() bool { - switch s.peek(0) { - case '+': - s.advance(1) - case '-': - s.advance(1) - return true - } - return false -} - -// blanks consumes spaces and tabs. The GRAM right-aligns its number inside a fixed -// field, so the padding is part of the protocol rather than sloppiness. -func (s *scanner) blanks() { - for !s.done() && (s.peek(0) == ' ' || s.peek(0) == '\t') { - s.advance(1) - } -} - -// number consumes digit{1,6} [ ("." | ",") digit{1,4} ]. -// -// At least one digit BEFORE the separator, which is what makes ".996kg" an error -// rather than a guess. -func (s *scanner) number() (integerPart, fractionPart string, ok bool) { - start := s.at - for !s.done() && isDigit(s.peek(0)) && s.at-start < 6 { - s.advance(1) - } - if s.at == start { - return "", "", false - } - integerPart = string(s.data[start:s.at]) - - if s.peek(0) != '.' && s.peek(0) != ',' { - return integerPart, "", true - } - s.advance(1) - fractionStart := s.at - for !s.done() && isDigit(s.peek(0)) && s.at-fractionStart < 4 { - s.advance(1) - } - if s.at == fractionStart { - // A separator with no digit after it: "1.KG" is not a number. - return "", "", false - } - return integerPart, string(s.data[fractionStart:s.at]), true -} - -// unit consumes "KG" or "G". KG is tried first, so "KG" is never read as a stray -// "K" followed by the unit "G". -func (s *scanner) unit() (Unit, bool) { - if s.hasWord("KG") { - s.advance(2) - return UnitKg, true - } - if upper(s.peek(0)) == 'G' { - s.advance(1) - return UnitGram, true - } - return 0, false -} - -// terminator consumes an optional CR, LF or CRLF. -func (s *scanner) terminator() { - if s.peek(0) == '\r' { - s.advance(1) - } - if s.peek(0) == '\n' { - s.advance(1) - } -} - -func isDigit(b byte) bool { return b >= '0' && b <= '9' } - -// --- the accumulator ------------------------------------------------------- - -// Accumulator turns a byte stream into whole frames. -// -// It exists because of a defect worth naming: the legacy application read -// EIGHTEEN FIXED BYTES per cycle — CommRead(NumPort, strData, 18, …) — for frames -// that are 18 bytes long including their terminator. One byte of drift and every -// subsequent frame was cut in half. The "degraded" frames of the corpus -// (".996kg", " 0.996kg") are an ARTEFACT of that read, not a property of the -// scale. -type Accumulator struct { - pending []byte - // resyncs counts how many times the buffer was dropped. The diagnostic screen - // shows it: a line that resynchronises constantly is a cabling problem, not a - // parser problem. - resyncs int -} - -// Resyncs reports how many times the buffer was dropped. -// -// A method and not the exported field it used to be, because it is one of the four -// things domain.Decoder asks of every grammar: `openscale capture` and the living -// corpus print this figure, and reaching into the field of one implementation is what -// stopped them from printing it for any other. -func (a *Accumulator) Resyncs() int { return a.resyncs } - -// Feed appends p to the pending tail and returns every measurement the buffer now -// yields. -// -// It silently drops the noise that precedes a valid frame; past MaxBuffer without a -// valid frame it resynchronises by keeping only the last resyncKeep bytes — no -// memory leak, and no permanent lock-up on a noisy line. -func (a *Accumulator) Feed(p []byte, now time.Time) []domain.Measurement { - a.pending = append(a.pending, p...) - - var out []domain.Measurement - for { - measurement, consumed, ok := a.extract(now) - if !ok { - break - } - a.pending = a.pending[consumed:] - if measurement != nil { - out = append(out, *measurement) - } - } - - if len(a.pending) > MaxBuffer { - a.pending = append([]byte(nil), a.pending[len(a.pending)-resyncKeep:]...) - a.resyncs++ - } - return out -} - -// Pending reports how many bytes are waiting for the rest of their frame. The test -// of §9.2 asserts it never exceeds MaxBuffer. -func (a *Accumulator) Pending() int { return len(a.pending) } - -// Reset drops the buffer. Called when the port is reopened: half a frame from -// before a reconnection must not be completed by bytes from after it. -func (a *Accumulator) Reset() { a.pending, a.resyncs = nil, 0 } - -// extract pulls the next frame, or the next piece of noise, out of the buffer. -// -// It reports (measurement, bytes consumed, whether anything was consumed). A nil -// measurement with consumed > 0 means "that was noise, dropped". -func (a *Accumulator) extract(now time.Time) (*domain.Measurement, int, bool) { - // 0. The CONTROL FRAMING of the GRAM XFOC PLUS, and it comes first because it is - // what the parc really puts on the wire. - if measurement, consumed, ok := a.extractFramed(now); ok { - return measurement, consumed, true - } - - // 1. A terminator is the primary delimiter, because it is what the scales of - // this parc actually send. - if end := indexAny(a.pending, '\r', '\n'); end >= 0 { - consumed := end + 1 - // CRLF counts as one terminator. - if a.pending[end] == '\r' && consumed < len(a.pending) && a.pending[consumed] == '\n' { - consumed++ - } - // The LONGEST SUFFIX that parses, not just the whole candidate. Noise with - // no terminator of its own sits in front of the next real frame — that is - // exactly what a resynchronisation leaves behind — and dropping the whole - // line would cost a weighing for every burst of noise on the cable. - if measurement, ok := parseLongestSuffix(a.pending[:end], now); ok { - return measurement, consumed, true - } - return nil, consumed, true // nothing salvageable: dropped - } - - // 2. No terminator yet. The grammar allows a frame to end at its unit, so try - // every position just past a 'G' — the only byte a frame can end on. That - // keeps the scan proportional to the number of candidate ends rather than to - // the square of the buffer length, and it handles frames sent back to back - // with no terminator at all. - for i := 0; i < len(a.pending); i++ { - if upper(a.pending[i]) != 'G' { - continue - } - if measurement, err := Parse(a.pending[:i+1], now); err == nil { - return &measurement, i + 1, true - } - } - return nil, 0, false -} - -// The control codes that frame one transmission of a GRAM XFOC PLUS. -// -// The whole frame is sixteen bytes and was read off a real scale on the L0 bench: -// -// SOH STX S|U ' '|'-' ' 0,000' KG XOR ETX EOT flags -// 01 02 1 1 6 2 1 03 04 1 -// -// The XOR travels between the unit and ETX and covers everything from the status to -// the unit. The byte after EOT is a flag field — 0x80 whenever the mass is negative, -// 0x10 near zero — and NOTHING READS IT: the sign is already in the payload, and two -// sources for one fact are two things to keep in step. -const ( - startOfHeading = 0x01 - startOfText = 0x02 - endOfText = 0x03 - endOfTransmission = 0x04 -) - -// FrameEnd reports how many bytes at the head of p make up the first COMPLETE frame, -// or -1 when the frame is still arriving. -// -// It is a METHOD and not a function of this package, because it is the half of -// domain.Decoder that `openscale capture` needs: the command writes one frame per line -// and must cut the stream at exactly the same places the decoder does. Left as a -// package function it could only ever be called for THIS grammar, and a second protocol -// would be captured by whatever the command happened to search for — which is how the -// first bench capture came back with a summary of 194 decoded frames and a file -// containing none. -// -// It reads no state of the accumulator, and that is not an oversight: where a frame -// ends is a property of the GRAMMAR, not of what is currently buffered. The receiver is -// what carries the grammar's identity, nothing more. -// -// It handles the two DELIMITED forms — control framing and terminator — and not the -// back-to-back form the Accumulator also accepts, because a capture file with no -// delimiter at all could not be read back line by line anyway. -func (*Accumulator) FrameEnd(p []byte) int { - if start := indexByte(p, startOfText); start == 0 || (start > 0 && indexTerminatorByte(p[:start]) < 0) { - end := indexByte(p, endOfText) - if end < 0 { - return -1 - } - // The frame ends on ETX, then EOT, then one byte of flags. They are consumed - // when they have arrived so that a capture file holds what the scale sent — - // the flags are evidence, 0x80 on every negative mass — and never counted as - // mandatory, so a firmware that stops sending them does not hang this. - end++ - if end < len(p) && p[end] == endOfTransmission { - end++ - if end < len(p) && p[end] != startOfHeading && p[end] != startOfText { - end++ - } - } - return end - } - end := indexTerminatorByte(p) - if end < 0 { - return -1 - } - end++ - if p[end-1] == '\r' && end < len(p) && p[end] == '\n' { - end++ - } - return end -} - -func indexTerminatorByte(data []byte) int { return indexAny(data, '\r', '\n') } - -// extractFramed pulls one STX … ETX transmission out of the buffer. -// -// It answers the same triple as extract: the measurement, what to consume, and -// whether anything was consumed at all. A frame whose checksum does not agree is -// CONSUMED AND DROPPED — a corrupted mass is a wrong price on a label, and the one -// thing this package refuses to do is guess. -// -// It consumes up to and including ETX and no further. EOT and the flag byte become -// leading noise for the next call, which skips them looking for the next STX: two -// bytes of noise cost nothing, and a parser that counts trailing bytes it does not -// read is a parser that breaks the day a firmware adds one. -func (a *Accumulator) extractFramed(now time.Time) (*domain.Measurement, int, bool) { - start := indexByte(a.pending, startOfText) - if start < 0 { - return nil, 0, false - } - end := indexByte(a.pending[start:], endOfText) - if end < 0 { - return nil, 0, false // the rest of the frame has not arrived yet - } - end += start - - // Between STX and ETX: the payload, then the one byte of checksum. - body := a.pending[start+1 : end] - if len(body) < 2 { - return nil, end + 1, true // framing with nothing in it - } - payload, checksum := body[:len(body)-1], body[len(body)-1] - if xor(payload) != checksum { - return nil, end + 1, true - } - measurement, err := Parse(payload, now) - if err != nil { - return nil, end + 1, true - } - return &measurement, end + 1, true -} - -// xor is the checksum the GRAM computes over the payload of a frame. Verified on the -// 668 frames of the bench capture, and on the fourteen it took to find it. -func xor(payload []byte) byte { - var sum byte - for _, b := range payload { - sum ^= b - } - return sum -} - -func indexByte(data []byte, want byte) int { - for i, b := range data { - if b == want { - return i - } - } - return -1 -} - -// parseLongestSuffix finds the frame hidden at the end of a candidate line. -// -// It walks start positions from the left, so the FIRST success is the longest -// suffix that parses. Starting from the left rather than the right matters: on -// " 0.996kg" the longest suffix is the whole thing, 996 g, whereas the shortest -// would be "6kg" — 6000 g, a guess, and precisely the class of error this package -// exists to refuse. -// -// Only positions a frame could actually begin on are tried, which keeps a 512-byte -// buffer of noise from costing 512 parses. -func parseLongestSuffix(candidate []byte, now time.Time) (*domain.Measurement, bool) { - for start := 0; start < len(candidate); start++ { - if !canBeginAFrame(candidate[start]) { - continue - } - // NEVER start in the middle of a number. Without this guard the search - // re-introduces the very guess this package exists to refuse: on ".996kg" - // it would skip the dot, read "996kg", and report NINE HUNDRED AND - // NINETY-SIX KILOGRAMS for a frame whose leading digits were cut off. The - // living corpus caught exactly that. - if start > 0 && continuesANumber(candidate[start-1]) { - continue - } - if measurement, err := Parse(candidate[start:], now); err == nil { - return &measurement, true - } - } - return nil, false -} - -// continuesANumber reports whether a byte is part of a number, so that the byte -// after it cannot be treated as the start of a fresh frame. -func continuesANumber(b byte) bool { return isDigit(b) || b == '.' || b == ',' } - -// canBeginAFrame reports whether a byte can be the first byte of a frame: a status -// letter, a sign, a blank or a digit. -func canBeginAFrame(b byte) bool { - switch upper(b) { - case 'S', 'U', 'O', '+', '-', ' ', '\t': - return true - } - return isDigit(b) -} - -func indexAny(data []byte, a, b byte) int { - for i, c := range data { - if c == a || c == b { - return i - } - } - return -1 -} diff --git a/internal/domain/frame/scanner.go b/internal/domain/frame/scanner.go new file mode 100644 index 0000000..a970a4a --- /dev/null +++ b/internal/domain/frame/scanner.go @@ -0,0 +1,203 @@ +package frame + +import "openscale/internal/domain" + +// This file holds the scanner: one walk over one frame, left to right, one function +// per production of the grammar written at the head of frame.go. +// +// A hand-written scanner rather than a regular expression, and that is the reason it +// is worth its own file: it names WHICH PART of the grammar failed, which is what +// the living corpus of testdata/frames/ needs in order to be diagnosable. + +// scanner walks a frame once, left to right. A hand-written scanner rather than a +// regular expression: it names which part of the grammar failed, which is what the +// living corpus of testdata/frames/ needs in order to be diagnosable. +type scanner struct { + data []byte + at int +} + +func (s *scanner) done() bool { return s.at >= len(s.data) } + +func (s *scanner) peek(offset int) byte { + if s.at+offset >= len(s.data) { + return 0 + } + return s.data[s.at+offset] +} + +// upper folds one byte to upper case, ASCII only: the grammar is case-insensitive +// and every token of it is ASCII. +func upper(b byte) byte { + if b >= 'a' && b <= 'z' { + return b - 'a' + 'A' + } + return b +} + +// hasWord reports whether the scanner sits on word, case-insensitively. +func (s *scanner) hasWord(word string) bool { + for i := 0; i < len(word); i++ { + if upper(s.peek(i)) != word[i] { + return false + } + } + return true +} + +// prefix consumes the optional status and mode fields and reports what the status +// says. Absent prefix means the model does not report stability, which the latch +// handles through its variation criterion instead of pretending to know. +func (s *scanner) prefix() (domain.Stability, bool) { + stability, overload := domain.StabilityUnknown, false + + // The two-letter forms first: "ST" must not be read as "S" followed by "T". + switch { + case s.hasWord("ST"): + stability, _ = domain.Stable, s.advance(2) + case s.hasWord("US"): + stability, _ = domain.Unstable, s.advance(2) + case s.hasWord("OL"): + // Over capacity. The mass that follows is meaningless, but the frame is + // still well-formed and the flag has to reach safeguard rule 1. + overload, _ = true, s.advance(2) + + // A LONE STATUS LETTER, followed by the value rather than by a comma. This is + // what a GRAM XFOC PLUS really sends — « S 0,002KG », « U- 0,432KG », measured + // on the L0 bench of 28/07/2026 over 668 frames. §9.2 made the comma mandatory, + // so the real frame was refused for « having no usable number »: the status + // letter was offered to the number parser, which is not a digit. + case upper(s.peek(0)) == 'S' && startsAValue(s.peek(1)): + stability, _ = domain.Stable, s.advance(1) + return stability, overload + case upper(s.peek(0)) == 'U' && startsAValue(s.peek(1)): + stability, _ = domain.Unstable, s.advance(1) + return stability, overload + + case upper(s.peek(0)) == 'S' && s.peek(1) == ',': + stability, _ = domain.Stable, s.advance(1) + case upper(s.peek(0)) == 'U' && s.peek(1) == ',': + stability, _ = domain.Unstable, s.advance(1) + default: + return stability, overload // no prefix at all + } + + if s.peek(0) != ',' { + // A status not followed by a comma is not a prefix; rewind so the bytes are + // offered to the number parser, which will refuse them properly. + s.at = 0 + return domain.StabilityUnknown, false + } + s.advance(1) + + // The optional mode field: gross or net. We read it and do not act on it — the + // tare is entered on screen, never announced by the scales of this parc. + for _, mode := range []string{"GS", "NT"} { + if s.hasWord(mode) && s.peek(2) == ',' { + s.advance(3) + return stability, overload + } + } + for _, mode := range []byte{'G', 'N'} { + if upper(s.peek(0)) == mode && s.peek(1) == ',' { + s.advance(2) + return stability, overload + } + } + return stability, overload +} + +func (s *scanner) advance(n int) bool { + s.at += n + return true +} + +// startsAValue reports whether a byte can open the value that follows a lone status +// letter: a sign, the blanks the GRAM right-aligns its number in, or a digit. +// +// It is what tells « S » the status from an « S » that would begin something else. +// Nothing else in this grammar starts with a letter, so the test is generous on +// purpose — a frame that is not one still fails on its number or on its unit, which +// are the two places this package refuses rather than guesses. +func startsAValue(b byte) bool { + switch b { + case ' ', '\t', '+', '-': + return true + } + return isDigit(b) +} + +// sign consumes an optional sign and reports whether the value is negative. +func (s *scanner) sign() bool { + switch s.peek(0) { + case '+': + s.advance(1) + case '-': + s.advance(1) + return true + } + return false +} + +// blanks consumes spaces and tabs. The GRAM right-aligns its number inside a fixed +// field, so the padding is part of the protocol rather than sloppiness. +func (s *scanner) blanks() { + for !s.done() && (s.peek(0) == ' ' || s.peek(0) == '\t') { + s.advance(1) + } +} + +// number consumes digit{1,6} [ ("." | ",") digit{1,4} ]. +// +// At least one digit BEFORE the separator, which is what makes ".996kg" an error +// rather than a guess. +func (s *scanner) number() (integerPart, fractionPart string, ok bool) { + start := s.at + for !s.done() && isDigit(s.peek(0)) && s.at-start < 6 { + s.advance(1) + } + if s.at == start { + return "", "", false + } + integerPart = string(s.data[start:s.at]) + + if s.peek(0) != '.' && s.peek(0) != ',' { + return integerPart, "", true + } + s.advance(1) + fractionStart := s.at + for !s.done() && isDigit(s.peek(0)) && s.at-fractionStart < 4 { + s.advance(1) + } + if s.at == fractionStart { + // A separator with no digit after it: "1.KG" is not a number. + return "", "", false + } + return integerPart, string(s.data[fractionStart:s.at]), true +} + +// unit consumes "KG" or "G". KG is tried first, so "KG" is never read as a stray +// "K" followed by the unit "G". +func (s *scanner) unit() (Unit, bool) { + if s.hasWord("KG") { + s.advance(2) + return UnitKg, true + } + if upper(s.peek(0)) == 'G' { + s.advance(1) + return UnitGram, true + } + return 0, false +} + +// terminator consumes an optional CR, LF or CRLF. +func (s *scanner) terminator() { + if s.peek(0) == '\r' { + s.advance(1) + } + if s.peek(0) == '\n' { + s.advance(1) + } +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } diff --git a/internal/domain/machine.go b/internal/domain/machine.go index f7ee9c4..7aff2e8 100644 --- a/internal/domain/machine.go +++ b/internal/domain/machine.go @@ -1,19 +1,21 @@ package domain -import ( - "errors" - "fmt" - "time" -) +import "time" -// This file holds the state machine of the weighing station: its sixteen states, -// its thirteen events, its eight effects, and the ONE function that turns a model -// and an event into a new model and a list of effects. +// This file holds the ENTRY POINT of the state machine: the constants no operator +// has a legitimate choice about, the one function that turns a model and an event +// into a new model and a list of effects, and the three answers that are the same +// from every state. // // Transition is PURE, and that is the single most important design decision of // the domain (§6.6): the whole behaviour of the station can be replayed offline // from the journal, and every time-dependent rule is tested with literal instants // instead of a sleeping test. +// +// What it dispatches to is one file per half of a weighing: transition_weighing.go +// for the states a cycle is built up in, transition_outcome.go for the ones it ends +// in. The sixteen states are state.go, the thirteen events events.go, the eight +// effects effects.go. // MaxArmingTime bounds the ProductArmed state, and it is a CONSTANT OF THE CODE // rather than a configuration key (ADR-022, ADR-025). @@ -59,449 +61,6 @@ const ( LevelError = "error" ) -// State enumerates every state the weighing station can be in. -// -// EnteringUnits is ABSENT, and that is not an oversight: a product sold by unit -// prints at the FIRST TAP, for 1 unit, with the same gesture and the same -// immediacy as a product sold by weight. A multiple quantity is a local affordance -// of the tile, carried by the `units` field of the POST, so it never leaves the -// grid and is therefore not a state of this machine (§14.3, ADR-023). -type State uint8 - -const ( - // Initializing is before the first catalog: the station cannot serve yet. - Initializing State = iota - // Idle means scale empty, ready, nothing selected. - Idle - // ProductArmed means the product was chosen before the bag was put down -- - // BOUNDED arming, MaxArmingTime (ADR-022). - ProductArmed - // WeightPresent means a mass is detected. - WeightPresent - // WeightStable means the mass is latched. It is an INDICATOR, not a print - // condition in advisory mode (A3). - WeightStable - // AwaitingStability exists in blocking mode only. - AwaitingStability - // EnteringTare is the tare keypad, anchored under the banner (§14.3). - EnteringTare - // EnteringWeight is the manual weight keypad -- degraded paths only. - EnteringWeight - // ManualMode is a station that declares it has no scale (scale.present false) - // and allows manual entry. - ManualMode - // Validating is TRANSIENT: it is entered and left inside one call to - // Transition, because nothing outside the model decides its outcome. A model - // published with this state can only come from a hand-written value or a - // replay, and Transition still has to survive it. - Validating - // Printing means the label has been handed to the print worker. - Printing - // Succeeded means the label was sent. There is no 'ok': no transport can tell - // us a label physically came out (§12.3). - Succeeded - // Rejected means a blocking safeguard stopped the label. - Rejected - // Faulted is one of the three full-screen states, and it carries an ERR code - // for the telephone (§14.3). - Faulted - // ScaleLost is reached from every state but OutOfService, and getting there is - // idempotent. - ScaleLost - // OutOfService is terminal. Nothing in this machine enters it: it is the state - // the Hub STARTS in when the configuration is unusable (§11.3, ERR-CFG-01). - OutOfService -) - -// String reports the value published in the state snapshot, so that a log line, a -// test failure and the SSE payload spell a state the same way. -func (s State) String() string { - switch s { - case Initializing: - return "initializing" - case Idle: - return "idle" - case ProductArmed: - return "product_armed" - case WeightPresent: - return "weight_present" - case WeightStable: - return "weight_stable" - case AwaitingStability: - return "awaiting_stability" - case EnteringTare: - return "entering_tare" - case EnteringWeight: - return "entering_weight" - case ManualMode: - return "manual_mode" - case Validating: - return "validating" - case Printing: - return "printing" - case Succeeded: - return "succeeded" - case Rejected: - return "rejected" - case Faulted: - return "faulted" - case ScaleLost: - return "scale_lost" - case OutOfService: - return "out_of_service" - } - return "unknown" -} - -// --- The thirteen events --------------------------------------------------- - -// Event is one thing that happened to the station. -// -// The set is CLOSED, and the unexported method is what closes it: no package -// outside this one can add a fourteenth event, so the exhaustive test of §6.7 -- -// sixteen states times thirteen events -- stays exhaustive by construction. -// -// UnitsConfirmed is ABSENT for the same reason EnteringUnits is (ADR-023): it was -// emitted by a full-screen keypad that no longer exists. -type Event interface { - event() -} - -// MeasurementReceived carries one reading, whatever produced it. -type MeasurementReceived struct{ M Measurement } - -// ScaleDisconnected reports that the scale stopped answering. -// -// Err is a LOGGED REASON and conditions nothing (défaut 40): the trigger is the -// status alone. Making the loss of the scale depend on an optional field is what -// let the signal fall into a default branch and never reach the machine. -type ScaleDisconnected struct{ Err error } - -// ScaleReconnected reports that the scale answers again. -type ScaleReconnected struct{} - -// ProductTapped is one touch on one tile, and it carries the fields of -// POST /api/v1/weigh (§14.5). -type ProductTapped struct { - ProductID string - // Tare is AUTHORITATIVE for this weighing: the keypad lives in the front, and - // one field with one owner beats two that have to agree. - Tare Grams - // Units is the tile affordance of ADR-023. Zero means "the front sent none", - // which is one unit -- the answer in the overwhelming majority of cases. - Units int - // SeenWeight is the gross mass the customer was looking at when they touched. - // Zero means the front declared none. - SeenWeight Grams - // MeasurementSeq is the sequence number of the frame the front was showing. It - // is recorded rather than compared: a fresh frame arrives every 400 ms or so, - // so an equality test on the sequence would refuse every legitimate tap. The - // comparison that protects the customer is the one on the WEIGHT. - MeasurementSeq int64 - // Key is the ULID the front generated on pointerdown. It is the idempotency - // key of the cycle AND the identifier of the print job (§12.3). - Key string -} - -// ConfigurationRepaired reports that the configuration this station refused at start-up -// is now valid, and is the ONE way out of OutOfService. -// -// It is the mirror image of how that state is entered: §11.3 has the composition root -// put the station there, from OUTSIDE the machine, when the file it read carries faults. -// Leaving it the same way — on a signal the composition root raises once the faults are -// gone — is what makes the promise of §11.4 true for that station too: no configuration -// block requires a restart of the process. Without it, a station repaired from the -// administration screen kept showing « Poste hors service » until somebody restarted a -// service the screen deliberately has no button for. -// -// It carries NOTHING and it is INERT in the fifteen other states: a configuration saved -// while a customer is mid-cycle must not touch the weighing under their finger. -type ConfigurationRepaired struct{} - -// TareTapped opens the tare keypad. -type TareTapped struct{} - -// TareConfirmed carries the tare a volunteer or a customer typed, in grams. -// -// The value is NOT checked here: safeguard rule 7 is the single place that says -// whether a tare is usable, and it says it against the weight it will be applied -// to, which is not known yet. -type TareConfirmed struct { - Tare Grams - Key string -} - -// ManualWeightConfirmed carries a GROSS mass typed by hand, in grams. -// -// It is the degraded path: no model of the fleet supports Tare() over the serial -// line, and no scale at all on a station that declares scale.present false. -type ManualWeightConfirmed struct { - Weight Grams - Key string -} - -// PrintFinished reports the outcome of one print job. -type PrintFinished struct { - JobID string - Err error - Duration time.Duration -} - -// ReprintRequested asks for the last label again (§8.5). -// -// JobID names what is being reprinted, and it is checked against the label the -// model still holds: a reprint that names another job is a stale request, never a -// second label. -type ReprintRequested struct { - JobID string - Key string -} - -// CatalogReady carries the first usable catalog snapshot. -// -// ImportedAt is the instant of the import that produced it, which the client screen -// shows permanently (§14.3). It travels with the catalog rather than being read off -// the clock at the far end: the two moments are not the same one, and the screen -// answers « quand ce catalogue a-t-il été importé ? ». -type CatalogReady struct { - Catalog *Catalog - ImportedAt time.Time -} - -// Cancel clears the selection. It leads to a model with no product and no label -// from every state (invariant 1 of §6.7). -type Cancel struct{} - -// Dismiss acknowledges a full-screen fault. -type Dismiss struct{} - -// Tick wakes the loop up and carries NO temporal semantics (bloquant-1): every -// duration is computed from TransitionContext.Now, never accumulated tick by -// tick, so a lost tick can no longer under-count an age. -type Tick struct{} - -func (MeasurementReceived) event() {} -func (ScaleDisconnected) event() {} -func (ScaleReconnected) event() {} -func (ProductTapped) event() {} -func (TareTapped) event() {} -func (TareConfirmed) event() {} -func (ManualWeightConfirmed) event() {} -func (PrintFinished) event() {} -func (ReprintRequested) event() {} -func (CatalogReady) event() {} -func (Cancel) event() {} -func (Dismiss) event() {} -func (Tick) event() {} -func (ConfigurationRepaired) event() {} - -// --- The eight effects ----------------------------------------------------- - -// Effect is something the outside world has to do. The machine DESCRIBES it and -// never performs it, which is what keeps Transition pure and h.execute trivial -// (§13.2). -type Effect interface { - effect() -} - -// PrintEffect hands one label to the print worker. -// -// It is emitted by the exit of Validating and by a reprint, and by nothing else -// (invariant 2 of §6.7). -type PrintEffect struct { - Label Label - // Reprint makes the renderer print the RÉIMPRESSION mention (§8.5), which is - // what neutralises the fraud vector: a cashier sees it. It is not a command - // flag but a property of the job -- the same label, printed a second time on - // purpose. - Reprint bool -} - -// RecordEffect hands one journal row to the journal worker. -// -// Two of the columns of §12.3 are deliberately left empty here: rate_ms and -// frame. The observed median cadence lives in the Hub's RateMeter and the raw -// serial frame in its capture ring; neither reaches a pure function, and inventing -// them would be worse than leaving them to the single component that owns them. -type RecordEffect struct{ Weighing Weighing } - -// MessageEffect is one banner message. Text is FRENCH and already interpolated: -// it is read by a customer at a screen. -type MessageEffect struct { - Level string - Code string - Text string - Duration time.Duration -} - -// SoundEffect names a sound the BROWSER plays. The backend does no audio I/O. -type SoundEffect struct{ Name string } - -// AckEffect is the answer rendered to a command, and the value stored under its -// idempotency key so a replayed command replays the answer instead of executing -// anything (§13.2, failure test 15). -type AckEffect struct { - Key string - Ack Ack -} - -// TechnicalLogEffect is one line of the technical journal -- what the station has -// to say about itself, never something a customer reads. -type TechnicalLogEffect struct { - Level string - Source string - Code string - Message string - Detail string -} - -// ArmTimerEffect declares how long the bounded wait the machine just entered may -// last, so that the screen can show it running out. -// -// The machine does not depend on it: expiry is decided by comparing Now with the -// instant the wait started, which is what makes it survive a lost tick. -type ArmTimerEffect struct{ Duration time.Duration } - -// ApplyCatalogEffect publishes a new catalog snapshot. -// -// It is emitted from Initializing and from Idle only. Emitting it from a weighing -// state would reorder the tiles under a customer's finger, which is exactly what -// the deferred swap of §10.8 exists to prevent. -// -// ImportedAt is carried through from the event, untouched: the effect publishes what -// an import produced, and it dates it from that import and not from its own moment. -type ApplyCatalogEffect struct { - Catalog *Catalog - ImportedAt time.Time -} - -func (PrintEffect) effect() {} -func (RecordEffect) effect() {} -func (MessageEffect) effect() {} -func (SoundEffect) effect() {} -func (AckEffect) effect() {} -func (TechnicalLogEffect) effect() {} -func (ArmTimerEffect) effect() {} -func (ApplyCatalogEffect) effect() {} - -// --- Ack ------------------------------------------------------------------- - -// Ack is what a command gets back. -// -// A command cycle ALWAYS replies (§13.2, défaut 62): every terminal transition -// emits an AckEffect, and the Hub holds a safety net for the events a state -// ignores. -type Ack struct { - // Accepted says whether the command started a cycle. - Accepted bool - // State is the state reached, so the admin screen can render an ack the way - // the client screen renders it. - State State - // JobID is filled only when a label was handed to the printer. - JobID string - // Code is the safeguard code of a refusal, empty otherwise. - Code string - // Message is FRENCH: it is displayed as it is. - Message string -} - -// --- Model and context ----------------------------------------------------- - -// Model is everything the machine remembers between two events. -// -// It is a VALUE: Transition receives a copy, returns a copy, and mutates nothing -// it was given. That is what makes the function replayable and what makes the -// "single writer" inventory of §13.2 true without a mutex. -type Model struct { - State State - - // CurrentProduct is the selection of the cycle in flight. Nil is "nothing - // selected", and invariant 1 makes Cancel put it back to nil from every state. - CurrentProduct *Product - - // LatchedWeight is the reading the label is built from, FROZEN at the entry of - // Validating and never touched again for that cycle (invariant 3). - // - // The whole reading is frozen and not just a number: the label needs the gross - // and the tare, the journal needs the stability and the sequence, and freezing - // only the mass would let the stability recorded in the journal drift from the - // weight printed on the label. - LatchedWeight Measurement - - // Label is the printable label. Nil until Validating succeeds, and nil again - // after Cancel. - Label *Label - - // Tare is the tare in force, in grams. - Tare Grams - // Units is the count a by-unit sale carries. One by default (ADR-023). - Units int - - // Latch turns the stream of measurements into latched / not latched. It is - // held BY VALUE: Transition folds a measurement into a copy and returns the - // copy, so nothing is shared and nothing is mutated behind the caller's back. - Latch WeightLatch - // LatchState is what the latch said about the last measurement folded in. - LatchState LatchState - - // ArmedAt is the instant the current BOUNDED WAIT started -- arming, waiting - // for stability, or a keypad entry. One field, because there is never two at - // once, and one meaning: "since when are we waiting". - ArmedAt time.Time - // StartedAt is the instant the cycle started, which is what weighings.duration_ms - // measures -- the figure §14.3 uses to decide whether the grid is fast enough. - StartedAt time.Time - - // IdempotencyKey is the ULID the front generated on pointerdown, kept for the - // journal row that will be written when printing ends. - IdempotencyKey string - // JobID is the identifier of the print job of this cycle. It is minted when - // the cycle starts and not when printing starts, because a REJECTED weighing - // is a journal row too, and weighings.job_id is UNIQUE (§12.3). - JobID string - // Source is where the weight came from: scale, manual or replay. A replay run - // is invisible from here -- Measurement carries no provenance -- so the Hub, - // which knows which driver is open, substitutes it. - Source string - - // Diagnostics is what the safeguards said about the cycle in flight, all of - // them: the admin screen displays every one, the machine acts on the first - // blocking one (§6.4). - Diagnostics []Diagnostic - - // FaultCode is the ERR-xxx-nn shown in 18 px on the full-screen fault, for the - // volunteer who is going to read it out over the telephone (§14.3). - FaultCode string - - // LastLabel and LastPrintedAt outlive the cycle: the reprint bar of the client - // screen is PERMANENT and stays active for reprint_window_s (§8.5, §14.3). - LastLabel *Label - LastPrintedAt time.Time - // Reprinted enforces "one reprint only" (§8.5). It travels with LastLabel. - Reprinted bool -} - -// TransitionContext carries everything a transition is allowed to read. It -// depends on no database, no port, no network and no global clock. -type TransitionContext struct { - Cfg Config - // Now comes from ports.Clock, NEVER from time.Now(). - Now time.Time - // LastMeasurement is the most recent reading the Hub received. - LastMeasurement Measurement - // MeasurementAge is COMPUTED by the Hub as Now - Measurement.Timestamp, never - // accumulated (bloquant-1). A lost tick can therefore no longer under-count it - // and let an expired weight through. - MeasurementAge time.Duration - // Expiry is DERIVED from the observed cadence, never a constant (A3). - Expiry time.Duration - // Catalog is an immutable snapshot. Nil is tolerated everywhere: a station - // still initializing has none. - Catalog *Catalog -} - -// --- Transition ------------------------------------------------------------ - // Transition is PURE: same inputs -> same outputs, no side effect, no clock // access, no I/O. It is the single most important design decision of the domain, // because it makes the machine replayable offline from the journal. @@ -621,1059 +180,3 @@ func cancel(m Model) (Model, []Effect) { } return next, []Effect{AckEffect{Ack: Ack{Accepted: true, State: next.State}}} } - -// initializing serves the state before the first catalog. The station cannot -// weigh, so it answers one event and ignores the rest. -func initializing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - e, ok := ev.(CatalogReady) - if !ok || e.Catalog == nil || e.Catalog.Len() == 0 { - return m, nil - } - next := m.clear(Idle) - return next, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} -} - -// idle serves the resting state: scale empty, nothing selected. -func idle(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case ProductTapped: - return tapFromIdle(m, e, ctx) - - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - return next, nil - } - next.State = presentOrStable(next) - return next, nil - - case TareTapped: - next := m - next.State, next.ArmedAt = EnteringTare, ctx.Now - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Ack: Ack{Accepted: true, State: EnteringTare}}, - } - - case ReprintRequested: - return reprint(m, e, ctx) - - case CatalogReady: - if e.Catalog == nil || e.Catalog.Len() == 0 { - return m, nil - } - return m, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} - - case Tick: - // A station that declares it has no scale and allows manual entry has no - // resting state of its own: ManualMode IS its resting state. - if manualOnly(ctx.Cfg) { - next := m - next.State = ManualMode - return next, nil - } - return m, nil - } - return m, nil -} - -// tapFromIdle is the transition that ADR-022 is about. -// -// Touching a by-weight product on an empty scale ARMS the selection instead of -// refusing it. The legacy application imposed the opposite order, and not for -// ergonomic reasons: printing was triggered synchronously by the click, which -// re-read the caption of the banner at that very instant, so there was NOWHERE to -// remember a pending selection. This architecture has somewhere. -func tapFromIdle(m Model, ev ProductTapped, ctx TransitionContext) (Model, []Effect) { - product, ok := offered(ctx.Catalog, ev.ProductID) - if !ok { - return m, refuseProduct(m.State) - } - next := m.startCycle(product, ev, ctx) - if product.Mode == ByUnit { - // One tap, one label, for one unit -- no weight is read at all (ADR-023). - return validate(next, byUnit(next.Units, SourceScale, ctx), ctx) - } - if manualOnly(ctx.Cfg) { - next.State = EnteringWeight - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Key: ev.Key, Ack: Ack{Accepted: true, State: EnteringWeight}}, - } - } - next.State = ProductArmed - return next, armEffects(ev.Key) -} - -// armed serves ProductArmed: a product is chosen, the bag is not there yet. -func armed(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - return next, nil - } - // The first valid measurement is what triggers the print. The stability - // rule is the SAME as the one that applies when the two gestures happen in - // the other order (see weighing): the order of the gestures must not change - // the outcome, which is the whole point of ADR-022. - if blockingStability(ctx.Cfg) && !next.LatchState.Latched { - return awaitStability(next, ctx) - } - return validate(next, fromScale(next, ctx), ctx) - - case ProductTapped: - // The last intention expressed wins, always: another tile re-arms on the - // new product and restarts the timer. - return tapFromIdle(m.clear(Idle), e, ctx) - - case TareTapped: - next := m - next.State, next.ArmedAt = EnteringTare, ctx.Now - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Ack: Ack{Accepted: true, State: EnteringTare}}, - } - - case Tick: - if ctx.Now.Sub(m.ArmedAt) < MaxArmingTime { - return m, nil - } - // SILENT disarming: there is nobody in front of the screen to read a - // message, and a screen that talks to itself in an empty shop is noise. - return m.clear(Idle), nil - } - return m, nil -} - -// weighing serves WeightPresent and WeightStable, which differ only by what the -// latch says. They answer the same events, so they share one function. -func weighing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - return next.clear(Idle), nil - } - next.State = presentOrStable(next) - return next, nil - - case ProductTapped: - return tapOnWeight(m, e, ctx) - - case TareTapped: - next := m - next.State, next.ArmedAt = EnteringTare, ctx.Now - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Ack: Ack{Accepted: true, State: EnteringTare}}, - } - - case ReprintRequested: - return reprint(m, e, ctx) - } - return m, nil -} - -// tapOnWeight serves a tap while a mass is on the plate -- the nominal order of -// the gestures. -func tapOnWeight(m Model, ev ProductTapped, ctx TransitionContext) (Model, []Effect) { - product, ok := offered(ctx.Catalog, ev.ProductID) - if !ok { - return m, refuseProduct(m.State) - } - next := m.startCycle(product, ev, ctx) - if product.Mode == ByUnit { - return validate(next, byUnit(next.Units, SourceScale, ctx), ctx) - } - if changed, seen, now := weightMoved(next, ev, ctx); changed { - // The customer touched a weight that is no longer there. Printing the - // current mass would hand them a price they never saw, and printing the - // one they saw would hand them a mass that is not on the plate. So we - // print neither: the tile comes back and the next tap lands on a fresh - // weight. - return m, []Effect{ - MessageEffect{ - Level: LevelInfo, Code: CodeWeightUnstable, - Text: DefaultMessage(CodeWeightUnstable), Duration: SuccessMessageDuration, - }, - TechnicalLogEffect{ - Level: LevelWarn, Source: "ui", Code: "", - Message: "Toucher sur un poids qui avait déjà changé.", - Detail: fmt.Sprintf("vu %d g, mesuré %d g", seen, now), - }, - AckEffect{Key: ev.Key, Ack: Ack{ - Accepted: false, State: m.State, Code: CodeWeightUnstable, - Message: DefaultMessage(CodeWeightUnstable), - }}, - } - } - if blockingStability(ctx.Cfg) && !next.LatchState.Latched { - return awaitStability(next, ctx) - } - return validate(next, fromScale(next, ctx), ctx) -} - -// awaitingStability serves the blocking mode only (A3). The shipped default never -// reaches it. -func awaitingStability(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - return next.clear(Idle), nil - } - if next.LatchState.Latched { - return validate(next, fromScale(next, ctx), ctx) - } - return next, nil - - case Tick: - if ctx.Now.Sub(m.ArmedAt) < time.Duration(ctx.Cfg.Stability.Timeout) { - return m, nil - } - switch ctx.Cfg.Stability.OnTimeout { - case OnTimeoutReject: - return rejectUnstable(m, ctx) - case OnTimeoutManualEntry: - next := m - next.State, next.ArmedAt = EnteringWeight, ctx.Now - return next, []Effect{ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}} - default: - // warn_and_print, the shipped answer: the label comes out and the - // journal records stability='unstable'. - // - // The effective severity of rule 6 is lowered HERE, and that is the - // whole content of the answer: were it still blocking at this instant, - // warn_and_print would warn and print NOTHING -- the timeout would walk - // into the validation and be refused there for the very reason the - // operator chose to forgive. The other thirteen safeguards are - // untouched, an expired weight included. - frozen := fromScale(m, ctx) - frozen.StabilityBlocks = false - return validate(m, frozen, ctx) - } - } - return m, nil -} - -// enteringTare serves the tare keypad. The scale stays visible during the whole -// entry, so measurements keep being folded in (§14.3). -func enteringTare(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case TareConfirmed: - next := m - next.Tare, next.State = e.Tare, Idle - return next, []Effect{AckEffect{Key: e.Key, Ack: Ack{Accepted: true, State: Idle}}} - - case MeasurementReceived: - return m.fold(e.M, ctx.Cfg.Stability), nil - - case Tick: - return abandonEntry(m, ctx) - } - return m, nil -} - -// enteringWeight serves the manual weight keypad -- degraded paths only. -func enteringWeight(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case ManualWeightConfirmed: - if m.CurrentProduct == nil { - return m, nil - } - next := m - next.IdempotencyKey, next.JobID = e.Key, deriveJobID(e.Key, ctx) - msr := Measurement{ - Gross: e.Weight, Tare: m.Tare, Quantity: m.Units, - // The manual weight source DOES NOT LIE about stability: an entry is - // latched by construction, and the engine needs no special case (§6.5). - Stability: StabilityNotApplicable, - Timestamp: ctx.Now, - } - // A typed weight has an age of ZERO whatever the scale is doing. Passing - // the age of the last frame instead would make safeguard rule 2 refuse - // every manual entry on a station whose scale is silent -- that is, in the - // only situation manual entry exists for. - return validate(next, frozenWeight{ - Measurement: msr, Source: SourceManual, - StabilityBlocks: blockingStability(ctx.Cfg), - }, ctx) - - case MeasurementReceived: - return m.fold(e.M, ctx.Cfg.Stability), nil - - case Tick: - return abandonEntry(m, ctx) - } - return m, nil -} - -// manualMode serves a station that declares it has no scale. -func manualMode(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case ProductTapped: - product, ok := offered(ctx.Catalog, e.ProductID) - if !ok { - return m, refuseProduct(m.State) - } - next := m.startCycle(product, e, ctx) - if product.Mode == ByUnit { - return validate(next, byUnit(next.Units, SourceManual, ctx), ctx) - } - next.State = EnteringWeight - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Key: e.Key, Ack: Ack{Accepted: true, State: EnteringWeight}}, - } - - case ScaleReconnected: - return m.clear(Idle), nil - - case ReprintRequested: - return reprint(m, e, ctx) - - case Tick: - if !manualOnly(ctx.Cfg) { - next := m - next.State = Idle - return next, nil - } - return m, nil - } - return m, nil -} - -// validating exists for a model that CLAIMS to be validating. -// -// Validating is transient: it is entered and left inside one call, so no model the -// Hub publishes ever carries it. A hand-written value or a truncated replay can, -// and Transition still has to answer. A Tick finishes the pending validation -- -// the model already holds everything it needs -- and every other event is ignored -// rather than allowed to start a second cycle over the same frozen weight. -func validating(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - if _, ok := ev.(Tick); !ok { - return m, nil - } - if m.CurrentProduct == nil { - return m.clear(Idle), nil - } - return validate(m, frozenWeight{ - Measurement: m.LatchedWeight, Source: m.Source, - Age: ctx.MeasurementAge, StabilityBlocks: blockingStability(ctx.Cfg), - }, ctx) -} - -// printing serves the wait for the print worker. -func printing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case PrintFinished: - return printFinished(m, e, ctx) - - case MeasurementReceived: - // The weight keeps being displayed, but the frozen one is untouchable: - // invariant 3 of §6.7 lives in fold, which never writes LatchedWeight. - return m.fold(e.M, ctx.Cfg.Stability), nil - } - return m, nil -} - -// printFinished turns the outcome of a print job into a journal row. -func printFinished(m Model, ev PrintFinished, ctx TransitionContext) (Model, []Effect) { - if m.Label == nil { - return m, nil - } - if ev.JobID != "" && ev.JobID != m.Label.JobID { - // A result belonging to another job: a late answer from a job the customer - // has already forgotten. Acting on it would move a cycle that is not the - // one it names. - return m, []Effect{TechnicalLogEffect{ - Level: LevelWarn, Source: "printer", Code: "", - Message: "Résultat d'impression arrivé hors de son cycle.", - Detail: "reçu " + ev.JobID + ", attendu " + m.Label.JobID, - }} - } - - duration := int(ev.Duration.Milliseconds()) - if ev.Err != nil { - next := m - next.State, next.FaultCode = Faulted, "ERR-PRN-01" - record := m.record(*m.Label, ResultFailed, ev.Err.Error(), duration, ctx) - return next, []Effect{ - RecordEffect{Weighing: record}, - MessageEffect{ - Level: LevelError, Code: "ERR-PRN-01", - Text: "L'imprimante ne répond pas. Prévenez un responsable.", - }, - TechnicalLogEffect{ - Level: LevelError, Source: "printer", Code: "ERR-PRN-01", - Message: "Impression échouée.", Detail: ev.Err.Error(), - }, - } - } - - next := m - next.State = Succeeded - next.LastLabel, next.LastPrintedAt = m.Label, ctx.Now - if m.Reprinted { - // A reprint does not reopen the right to reprint. - next.LastLabel, next.LastPrintedAt = m.LastLabel, m.LastPrintedAt - } - result := ResultSent - if m.Reprinted { - result = ResultReprint - } - effects := []Effect{ - RecordEffect{Weighing: m.record(*m.Label, result, "", duration, ctx)}, - MessageEffect{ - Level: LevelInfo, Code: "", Text: "Étiquette envoyée.", - Duration: SuccessMessageDuration, - }, - } - if ctx.Cfg.UI.Sound { - effects = append(effects, SoundEffect{Name: "ok"}) - } - return next, effects -} - -// succeeded serves the discreet acknowledgement in the banner. The grid stays -// visible and nothing has to be closed (§14.3, ADR-023). -func succeeded(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - // The customer takes the bag off: that is the signal the machine - // already owns, and it is more accurate than a guessed delay. - return next.clear(Idle), nil - } - return next, nil - - case ReprintRequested: - return reprint(m, e, ctx) - - // ProductTapped is deliberately absent. A second label on the same bag is - // exactly the burst invariant 4 of §6.7 forbids: the mass has to leave the - // plate, which brings the station back to Idle, before anything can be - // weighed again. - } - return m, nil -} - -// rejected serves a refusal. It falls back on the same physical signal as a -// success, and it lets the customer CORRECT without having anything to close. -func rejected(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case MeasurementReceived: - next := m.fold(e.M, ctx.Cfg.Stability) - if emptyZone(e.M.Gross, ctx.Cfg.Limits) { - return next.clear(Idle), nil - } - return next, nil - - case ProductTapped: - // Nothing was printed, so nothing forbids another attempt. The cycle is - // cleared first, which is what keeps invariant 3 unambiguous: a frozen - // weight belongs to ONE cycle and a new cycle freezes its own. - return tapOnWeight(m.clear(m.State), e, ctx) - - case ReprintRequested: - return reprint(m, e, ctx) - } - return m, nil -} - -// faulted serves the full-screen fault. Only an acknowledgement leaves it. -func faulted(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - if _, ok := ev.(Dismiss); !ok { - return m, nil - } - next := m.clear(Idle) - return next, []Effect{AckEffect{Ack: Ack{Accepted: true, State: Idle}}} -} - -// scaleLost serves a station whose scale stopped answering. -func scaleLost(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { - switch e := ev.(type) { - case ScaleReconnected: - next := m.clear(Idle) - // A weight measured before the outage must not be able to latch after it, - // and intervals measured across it describe the outage, not the cadence. - next.Latch, next.LatchState = WeightLatch{}, LatchState{} - return next, []Effect{TechnicalLogEffect{ - Level: LevelInfo, Source: "scale", Code: "", - Message: "La balance répond de nouveau.", - }} - - case MeasurementReceived: - // A driver that resumes emitting without announcing itself. Refusing the - // measurement would leave the station dead for a reason nobody can name. - next := m.clear(Idle) - next.Latch, next.LatchState = WeightLatch{}, LatchState{} - next = next.fold(e.M, ctx.Cfg.Stability) - if !emptyZone(e.M.Gross, ctx.Cfg.Limits) { - next.State = presentOrStable(next) - } - return next, nil - - case CatalogReady: - // A catalog does NOT need a scale to take service, and refusing it here loses - // it for good: the source deletes the file once the batch is acknowledged — - // the deletion IS the acknowledgement (§10.1) — so a batch this machine - // ignores is a catalog nobody will offer again until somebody drops another - // file. A station whose scale did not answer at start-up sat in this state - // showing « Catalogue vide » while its 331 tiles were already in the base. - // - // The state does NOT change: the scale is still missing, and that is what the - // screen must keep saying. Only the grid behind the message is filled. - if e.Catalog == nil || e.Catalog.Len() == 0 { - return m, nil - } - return m, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} - - case ProductTapped: - // The manual entry a volunteer reaches through the troubleshooting button - // of §15.4: "you can type the weight in". - if !ctx.Cfg.Scale.ManualEntryAllowed { - return m, nil - } - product, ok := offered(ctx.Catalog, e.ProductID) - if !ok { - return m, refuseProduct(m.State) - } - next := m.startCycle(product, e, ctx) - if product.Mode == ByUnit { - return validate(next, byUnit(next.Units, SourceManual, ctx), ctx) - } - next.State = EnteringWeight - return next, []Effect{ - ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, - AckEffect{Key: e.Key, Ack: Ack{Accepted: true, State: EnteringWeight}}, - } - } - return m, nil -} - -// --- The validating step --------------------------------------------------- - -// validate is both the entry and the exit of Validating, in one step. -// -// It is written as one function because nothing OUTSIDE the model decides its -// outcome: the safeguards and the price are pure functions of what is already -// frozen. A state the machine would rest in would be a state where a second -// measurement could change the weight under a label about to be printed. -// -// THE WEIGHT IS FROZEN HERE, and never read again for this cycle (invariant 3). -func validate(m Model, w frozenWeight, ctx TransitionContext) (Model, []Effect) { - if m.CurrentProduct == nil { - return m.clear(Idle), nil - } - product := *m.CurrentProduct - - next := m - next.LatchedWeight, next.Source = w.Measurement, w.Source - - prep, err := Prepare(m.prepareInput(product, w, ctx)) - next.Diagnostics = prep.Diagnostics - - switch { - case errors.Is(err, ErrInconsistentTiers): - // Configuration checks 10 to 16 exist to make this unreachable (§11.3). - // Reaching it means the station cannot price ANY product, which is a - // full-screen fault and not one refused weighing. - next.State, next.FaultCode = Faulted, "ERR-CFG-01" - next.Label = nil - return next, []Effect{ - MessageEffect{ - Level: LevelError, Code: "ERR-CFG-01", - Text: "Le poste ne peut pas calculer les prix (ERR-CFG-01). Prévenez un responsable.", - }, - TechnicalLogEffect{ - Level: LevelError, Source: "config", Code: "ERR-CFG-01", - Message: "Grille de tarifs inutilisable.", Detail: err.Error(), - }, - AckEffect{Key: m.IdempotencyKey, Ack: Ack{ - Accepted: false, State: Faulted, Code: "ERR-CFG-01", - Message: "Le poste ne peut pas calculer les prix (ERR-CFG-01). Prévenez un responsable.", - }}, - } - - case err != nil: - // A barcode this product cannot carry: a prefix outside the plan, a - // reserved zone that is not empty, a payload that does not fit, a mode that - // contradicts its own prefix, or an article that has no tile at all. It is - // one PRODUCT that is unusable, so the station keeps serving the others. - return next.reject(prep.Priced, Diagnostic{ - Code: CodeProductWithdrawn, Severity: Blocking, - Message: DefaultMessage(CodeProductWithdrawn), ProductID: product.ID, - }, err.Error(), ctx) - } - - // Prepare's invariant: Label is non-nil exactly when Refusal is nil. - if prep.Refusal != nil { - return next.reject(prep.Priced, *prep.Refusal, "", ctx) - } - - next.State, next.Label = Printing, prep.Label - next.Reprinted = false - return next, []Effect{ - PrintEffect{Label: *prep.Label}, - AckEffect{Key: next.IdempotencyKey, Ack: Ack{ - Accepted: true, State: Printing, JobID: prep.Label.JobID, - }}, - } -} - -// prepareInput names what the machine hands to the single calculation path. -// -// TWO of Prepare's inputs cannot be filled from a TransitionContext, and both are -// worth naming rather than hiding behind a zero value: -// -// Decision stays nil. The human judgement of §10.6 lives in local_decisions, and -// neither TransitionContext nor Catalog carries it -- Product has no Offered field -// and NewCatalog takes no decision table. Nil is the right default (the absence of -// a row is not a refusal), but it means safeguard rule 14 and the light-product -// waiver are UNREACHABLE from the machine today. Closing that needs a field on -// Product or a table on Catalog, neither of which is this file's to add. -// -// StabilityBlocking is stability.mode, which is not quite what Prepare asks for: -// it wants the EFFECTIVE severity, and blocking mode auto-disables itself when -// fewer than min_latch_rate of the weighings settle over five minutes (§6.5). That -// sliding window is held by the Hub -- a pure function has no business remembering -// five minutes of history -- so the fallback cannot be seen from here. -func (m Model) prepareInput(p Product, w frozenWeight, ctx TransitionContext) PrepareInput { - return PrepareInput{ - Product: p, - Measurement: w.Measurement, - Rules: ctx.Cfg.Pricing, - Limits: ctx.Cfg.Limits, - Decision: nil, - MeasurementAge: w.Age, - Expiry: ctx.Expiry, - StabilityBlocking: w.StabilityBlocks, - JobID: m.JobID, - } -} - -// reject records a refused weighing and shows its message. -func (m Model) reject(label Label, blocking Diagnostic, detail string, - ctx TransitionContext) (Model, []Effect) { - - next := m - next.State, next.Label = Rejected, nil - record := m.record(label, ResultRejected, rejectDetail(blocking, detail), 0, ctx) - effects := []Effect{ - MessageEffect{ - Level: LevelWarn, Code: blocking.Code, Text: blocking.Message, - Duration: RejectMessageDuration, - }, - RecordEffect{Weighing: record}, - AckEffect{Key: m.IdempotencyKey, Ack: Ack{ - Accepted: false, State: Rejected, - Code: blocking.Code, Message: blocking.Message, - }}, - } - if detail != "" { - effects = append(effects, TechnicalLogEffect{ - Level: LevelWarn, Source: "catalog", Code: "", - Message: "Étiquette impossible pour ce produit.", Detail: detail, - }) - } - return next, effects -} - -// rejectDetail is what the journal keeps about a refusal: the code always, and -// the technical reason when there is one. -func rejectDetail(blocking Diagnostic, detail string) string { - if detail == "" { - return blocking.Code - } - return blocking.Code + ": " + detail -} - -// rejectUnstable is the blocking-mode timeout with on_timeout = reject. -// -// The refusal is stated explicitly rather than left to safeguard rule 6, and the -// difference is real: the latch also fails to hold when every individual frame -// says ST while the mass keeps walking beyond the tolerance. Rule 6 reads the FLAG -// and would let that weighing through, so on_timeout = reject would print exactly -// what the operator asked it not to. -func rejectUnstable(m Model, ctx TransitionContext) (Model, []Effect) { - msr := m.frozen(ctx) - next := m - next.LatchedWeight = msr - return next.reject(m.priced(fromScale(m, ctx), ctx), Diagnostic{ - Code: CodeWeightUnstable, Severity: Blocking, - Message: DefaultMessage(CodeWeightUnstable), - }, "", ctx) -} - -// priced is what the weighing WOULD have cost, for a refusal the safeguards did -// not raise themselves. -// -// "At 8 g this product was refused, and here is what it would have cost" is the -// line an operator reads afterwards, and weighing_lines is mandatory (§12.3). It -// goes through the same single calculation path as everything else, and a label -// that cannot even be priced simply carries no lines. -func (m Model) priced(w frozenWeight, ctx TransitionContext) Label { - if m.CurrentProduct == nil { - return Label{} - } - prep, _ := Prepare(m.prepareInput(*m.CurrentProduct, w, ctx)) - return prep.Priced -} - -// --- Reprint --------------------------------------------------------------- - -// reprint prints the LAST label a second time (§8.5). -// -// It is the one PrintEffect that does not come out of Validating, and that is -// deliberate: the label was validated once, against a weight that was on the -// plate at that moment, and re-validating it would refuse it for -// MEASUREMENT_EXPIRED -- the very code that protects the FIRST print. A reprint is -// an explicitly wanted duplicate of an already validated label, it carries the -// RÉIMPRESSION mention so a cashier sees it, and it is journalled result='reprint'. -// -// One reprint per label, inside reprint_window_s. A window of zero disables -// reprinting, which is the only sensible reading of "how long the bar stays -// active" = 0. -func reprint(m Model, ev ReprintRequested, ctx TransitionContext) (Model, []Effect) { - if m.LastLabel == nil || m.Reprinted { - return m, refuseReprint(m.State) - } - if ev.JobID != "" && ev.JobID != m.LastLabel.JobID { - return m, refuseReprint(m.State) - } - if ctx.Now.Sub(m.LastPrintedAt) > reprintWindow(ctx.Cfg) { - return m, refuseReprint(m.State) - } - - label := *m.LastLabel - label.JobID = deriveJobID(ev.Key, ctx) - next := m - next.State = Printing - next.Label = &label - next.Reprinted = true - next.IdempotencyKey = ev.Key - next.JobID = label.JobID - next.StartedAt = ctx.Now - if next.CurrentProduct == nil { - product := label.Product - next.CurrentProduct = &product - } - return next, []Effect{ - PrintEffect{Label: label, Reprint: true}, - AckEffect{Key: ev.Key, Ack: Ack{ - Accepted: true, State: Printing, JobID: label.JobID, - }}, - } -} - -// refuseReprint answers a reprint that cannot be served, in French. -func refuseReprint(state State) []Effect { - const text = "Cette étiquette ne peut plus être réimprimée." - return []Effect{ - MessageEffect{Level: LevelWarn, Text: text, Duration: RejectMessageDuration}, - AckEffect{Ack: Ack{Accepted: false, State: state, Message: text}}, - } -} - -// refuseProduct answers a tap on a product the published catalog does not offer. -// -// It reuses the wording of safeguard 14: from the customer's side, a product -// absent from the snapshot and a product withdrawn by a volunteer are the same -// sentence, and inventing a fifteenth code would only add a string to translate. -func refuseProduct(state State) []Effect { - return []Effect{ - MessageEffect{ - Level: LevelWarn, Code: CodeProductWithdrawn, - Text: DefaultMessage(CodeProductWithdrawn), Duration: RejectMessageDuration, - }, - AckEffect{Ack: Ack{ - Accepted: false, State: state, Code: CodeProductWithdrawn, - Message: DefaultMessage(CodeProductWithdrawn), - }}, - } -} - -// --- Model helpers --------------------------------------------------------- - -// clear ends the cycle in flight and keeps what outlives it. -// -// It is what makes invariant 1 of §6.7 true in one place instead of sixteen: the -// selection, the frozen weight, the label, the tare and the diagnostics go; the -// latch, the last printed label and its instant stay, because they describe the -// plate and the reprint bar, not the cycle. -func (m Model) clear(state State) Model { - return Model{ - State: state, - Latch: m.Latch, - LatchState: m.LatchState, - LastLabel: m.LastLabel, - LastPrintedAt: m.LastPrintedAt, - Reprinted: m.Reprinted, - } -} - -// startCycle opens a weighing cycle on a product. -func (m Model) startCycle(p Product, ev ProductTapped, ctx TransitionContext) Model { - next := m.clear(m.State) - product := p - next.CurrentProduct = &product - next.Tare = ev.Tare - next.Units = ev.Units - if next.Units <= 0 { - next.Units = 1 - } - next.ArmedAt, next.StartedAt = ctx.Now, ctx.Now - next.IdempotencyKey = ev.Key - next.JobID = deriveJobID(ev.Key, ctx) - return next -} - -// fold folds one measurement into a COPY of the latch. -// -// A copy, because Transition mutates nothing it was given. The policy is -// re-applied on every call: a hot reload may change the tolerance or the minimum -// duration, and the anchor has to survive that without the latch being rebuilt -// (§11.4, ADR-027). -// -// It never writes LatchedWeight, which is what makes invariant 3 of §6.7 a -// property of the code rather than of a reviewer's attention. -func (m Model) fold(msr Measurement, policy StabilityPolicy) Model { - latch := m.Latch - latch.policy = policy - next := m - next.LatchState = latch.Feed(msr) - next.Latch = latch - return next -} - -// frozen is the reading a label is built from. -// -// When the latch holds, it is the ANCHOR and not the last frame: inside a window -// that holds to within the tolerance we want a reproducible value, not the latest -// fluctuation (§6.5). The tare is the one the model holds, because no model of the -// fleet supports Tare() over the serial line (§19), so the frame never carries one. -func (m Model) frozen(ctx TransitionContext) Measurement { - msr := ctx.LastMeasurement - msr.Tare = m.Tare - msr.Quantity = m.Units - if m.LatchState.Latched { - msr.Gross = m.LatchState.Gross - } - return msr -} - -// record builds the journal row of one weighing. -// -// It records what the LABEL carried and not what the plate read: a row whose net -// weight differs from the printed one is unusable at the till, and the till is the -// only reason the row exists. -func (m Model) record(label Label, result, detail string, duration int, - ctx TransitionContext) Weighing { - - w := Weighing{ - OccurredAt: ctx.Now, - Station: ctx.Cfg.Station.Number, - JobID: m.JobID, - IdempotencyKey: m.IdempotencyKey, - GrossWeight: label.GrossWeight, - Tare: label.Tare, - NetWeight: label.NetWeight, - Quantity: label.Quantity, - Barcode: label.Barcode, - Source: m.Source, - Stability: m.LatchedWeight.Stability, - Result: result, - Detail: detail, - DurationMS: duration, - } - if m.CurrentProduct != nil { - w.ProductID = m.CurrentProduct.ID - w.ProductName = m.CurrentProduct.Name - w.Reference = m.CurrentProduct.Reference - w.Mode = m.CurrentProduct.Mode - w.BaseUnitPrice = m.CurrentProduct.UnitPrice - } - if w.DurationMS == 0 && !m.StartedAt.IsZero() { - w.DurationMS = int(ctx.Now.Sub(m.StartedAt).Milliseconds()) - } - for _, line := range label.Lines { - w.Lines = append(w.Lines, WeighingLine{ - TierCode: line.Tier.Code, UnitPrice: line.UnitPrice, Amount: line.Amount, - }) - } - return w -} - -// --- Small pure helpers ---------------------------------------------------- - -// armEffects is what entering ProductArmed tells the outside world. -// -// The wording is safeguard 4's, taken from the table of §6.4 rather than written -// again here: "the scale is empty" and "put your product down" are the same -// sentence said to a customer, and one French string with one owner cannot drift -// from the other. -func armEffects(key string) []Effect { - return []Effect{ - MessageEffect{ - Level: LevelInfo, Code: CodeScaleEmpty, - Text: DefaultMessage(CodeScaleEmpty), Duration: MaxArmingTime, - }, - ArmTimerEffect{Duration: MaxArmingTime}, - AckEffect{Key: key, Ack: Ack{Accepted: true, State: ProductArmed}}, - } -} - -// awaitStability enters the blocking-mode wait. -func awaitStability(m Model, ctx TransitionContext) (Model, []Effect) { - next := m - next.State, next.ArmedAt = AwaitingStability, ctx.Now - timeout := time.Duration(ctx.Cfg.Stability.Timeout) - return next, []Effect{ - MessageEffect{ - Level: LevelInfo, Code: CodeWeightUnstable, - Text: DefaultMessage(CodeWeightUnstable), Duration: timeout, - }, - ArmTimerEffect{Duration: timeout}, - AckEffect{Key: m.IdempotencyKey, Ack: Ack{ - Accepted: true, State: AwaitingStability, Code: CodeWeightUnstable, - Message: DefaultMessage(CodeWeightUnstable), - }}, - } -} - -// abandonEntry clears a keypad entry nobody came back to. -// -// This is all that is left of idle_timeout_s (§14.3): no report is ever chased off -// the screen by a stopwatch, but a customer who walks away never leaves a -// half-typed figure for the next one. It is silent, for the same reason the -// disarming is. -func abandonEntry(m Model, ctx TransitionContext) (Model, []Effect) { - if ctx.Now.Sub(m.ArmedAt) < idleTimeout(ctx.Cfg) { - return m, nil - } - next := m.clear(Idle) - if manualOnly(ctx.Cfg) { - next.State = ManualMode - } - return next, nil -} - -// offered reports the product a tap names, and whether the station offers it. -// -// A product absent from the snapshot and a product the qualification kept out of -// the grid are one answer: no tile, therefore no label. The catalog may be nil -- -// a station still starting up has none -- and ByID already tolerates that. -func offered(catalog *Catalog, id string) (Product, bool) { - p, ok := catalog.ByID(id) - if !ok || p.Qualification != Weighable { - return Product{}, false - } - return p, true -} - -// frozenWeight is what the machine knows about the reading it just froze and that -// the calculation cannot infer on its own. -// -// It is a value rather than three more parameters because the three travel -// together and are decided together, and because the third one has to be READ to -// be understood: StabilityBlocks is the EFFECTIVE severity of safeguard rule 6 at -// this instant, and not a copy of stability.mode. Exactly one place lowers it, and -// it says why. -type frozenWeight struct { - Measurement Measurement - Source string - Age time.Duration - StabilityBlocks bool -} - -// fromScale is the reading the plate is holding, with the age the Hub computed. -func fromScale(m Model, ctx TransitionContext) frozenWeight { - return frozenWeight{ - Measurement: m.frozen(ctx), - Source: SourceScale, - Age: ctx.MeasurementAge, - StabilityBlocks: blockingStability(ctx.Cfg), - } -} - -// byUnit is the reading of a by-unit sale: no mass at all, and no age. -// -// Not a zero weight some rule could interpret, but an explicit absence -- the -// stability says not_applicable, and Prepare drops the one rule that would still -// talk about a plate. The age is zero because there is no measurement to grow old: -// passing the age of whatever the scale last said would refuse every item sold by -// the piece after a quiet spell at the station. -func byUnit(units int, source string, ctx TransitionContext) frozenWeight { - return frozenWeight{ - Measurement: Measurement{ - Quantity: units, Stability: StabilityNotApplicable, Timestamp: ctx.Now, - }, - Source: source, - StabilityBlocks: blockingStability(ctx.Cfg), - } -} - -// weightMoved reports whether the mass changed between the frame the customer was -// looking at and the one about to be frozen. -// -// The tolerance is the latch's: below it the two frames describe the same bag, and -// refusing them would refuse every legitimate tap. Zero SeenWeight means the front -// declared none, and there is nothing to compare. -func weightMoved(m Model, ev ProductTapped, ctx TransitionContext) (bool, Grams, Grams) { - if ev.SeenWeight == 0 { - return false, 0, 0 - } - now := m.frozen(ctx).Gross - return abs(now-ev.SeenWeight) > ctx.Cfg.Stability.ToleranceGrams, ev.SeenWeight, now -} - -// presentOrStable reports which of the two weight states a folded model is in. -func presentOrStable(m Model) State { - if m.LatchState.Latched { - return WeightStable - } - return WeightPresent -} - -// emptyZone reports whether a mass is inside the "the scale is empty" band. -func emptyZone(g Grams, limits WeighingLimits) bool { return abs(g) <= limits.EmptyMax } - -// blockingStability reports whether stability BLOCKS a print. The shipped default -// is advisory (A3, ADR-005). -func blockingStability(cfg Config) bool { return cfg.Stability.Mode == ModeBlocking } - -// manualOnly reports a station that declares it has no scale and allows manual -// entry -- the EXPLICIT and unique declaration of §11.2, which turns the light off -// instead of leaving it red. -func manualOnly(cfg Config) bool { - return !cfg.Scale.Present && cfg.Scale.ManualEntryAllowed -} - -// idleTimeout is how long a keypad entry survives without a touch. -func idleTimeout(cfg Config) time.Duration { - return time.Duration(cfg.UI.IdleTimeoutSeconds) * time.Second -} - -// reprintWindow is how long the permanent bottom bar stays active. -func reprintWindow(cfg Config) time.Duration { - return time.Duration(cfg.UI.ReprintWindowSeconds) * time.Second -} - -// deriveJobID mints the identifier of one print job from what the cycle carries. -// -// A pure function has neither entropy nor a clock of its own, so it cannot mint a -// ULID -- and it does not have to: the front generates one at pointerdown and -// sends it as the idempotency key (§4), which is unique per touch, exactly what -// weighings.job_id needs. When no key travels -- a command injected without a -// caller, a troubleshooting reprint -- the identifier is DERIVED from the instant -// and the measurement sequence: unique on one station, and reproduced identically -// when the journal is replayed, which a random identifier would not be. -func deriveJobID(key string, ctx TransitionContext) string { - if key != "" { - return key - } - return fmt.Sprintf("j%013d-%06d", ctx.Now.UnixMilli(), ctx.LastMeasurement.Seq) -} diff --git a/internal/domain/machine_test.go b/internal/domain/machine_test.go index 71a58a0..7e018c1 100644 --- a/internal/domain/machine_test.go +++ b/internal/domain/machine_test.go @@ -1,204 +1,19 @@ +// This file holds what is true of the machine AS A WHOLE: that the two +// enumerations are closed, that the sixteen states times the fourteen events never +// panic, and that a state spells itself the one way the snapshot publishes it. +// +// The exhaustive product is the point (§6.7). It is what keeps « an event a state +// has nothing to say about » a decision rather than an oversight, and it stays +// exhaustive by construction because no package outside this one can add an event. + package domain import ( "errors" - "reflect" "testing" "time" ) -// This file holds the eight invariants of §6.7 and the scenarios that give them -// teeth. Not one of them sleeps: every instant is a literal, because the clock is -// injected. The whole file runs in milliseconds, which is the property that makes -// the time-dependent rules testable at all. - -// --- Fixtures --------------------------------------------------------------- - -// origin is the instant every scenario starts from. A literal, never time.Now(). -var origin = time.Date(2026, 7, 25, 9, 30, 0, 0, time.UTC) - -// machineConfig is the neutral profile with a scale and the price grid of A7. -// -// It is built from NeutralProfile so that a value added to the schema cannot leave -// this file silently behind, and it changes exactly what the neutral profile is -// wrong about for a weighing test: it has a scale, and two price tiers. -func machineConfig() Config { - cfg := NeutralProfile() - cfg.Scale.Present = true - cfg.Scale.Type = "gram-xfoc-rs" - cfg.Scale.ManualEntryAllowed = true - cfg.Pricing = LaCagetteRules() - return cfg -} - -// mustCompose builds a pattern from twelve digits and its computed check digit, -// so no test carries a hand-computed one. -func mustCompose(t *testing.T, twelve string) EAN13 { - t.Helper() - code, err := Compose(twelve) - if err != nil { - t.Fatalf("Compose(%q): %v", twelve, err) - } - return code -} - -// machineGarlic is the reference vector of §16.1: solidarity unit price 5,32 €/kg, -// member discount of 10 %, weighed at 1,236 kg. -func machineGarlic(t *testing.T) Product { - t.Helper() - return Product{ - ID: "894", Name: "AIL BLANC SAF", - Reference: mustCompose(t, "049302100000"), - Mode: ByWeight, - PriceSuffix: " €/kg", - UnitPrice: 532, - CategoryCode: "vegetables", - Qualification: Weighable, - } -} - -// machineEggs is a by-unit product: prefix 0499, six reference digits, two payload ones. -func machineEggs(t *testing.T) Product { - t.Helper() - return Product{ - ID: "5209", Name: "OEUFS PLEIN AIR X6", - Reference: mustCompose(t, "049912345600"), - Mode: ByUnit, - PriceSuffix: " € l'unité", - UnitPrice: 315, - CategoryCode: "other", - Qualification: Weighable, - } -} - -// machineHidden is a product the qualification kept out of the grid. -func machineHidden(t *testing.T) Product { - t.Helper() - p := machineGarlic(t) - p.ID, p.Name = "5115", "TOMME DE SAVOIE -MV" - p.Qualification, p.Reason = Anomaly, FindingReservedZoneNotEmpty - return p -} - -func machineCatalog(t *testing.T) *Catalog { - t.Helper() - return NewCatalog( - []Product{machineGarlic(t), machineEggs(t), machineHidden(t)}, - []Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, - ) -} - -// --- A driver that keeps the model, the context and purity together ---------- - -// run drives one scenario. It advances the injected clock explicitly and checks, -// on every single event, that Transition did not touch what it was given. -type run struct { - t *testing.T - m Model - ctx TransitionContext - seq int64 -} - -func newRun(t *testing.T) *run { - t.Helper() - return &run{ - t: t, - m: Model{State: Idle}, - ctx: TransitionContext{ - Cfg: machineConfig(), Now: origin, - Expiry: 1200 * time.Millisecond, Catalog: machineCatalog(t), - }, - } -} - -// send applies one event and proves Transition is pure while doing it. -func (r *run) send(ev Event) []Effect { - r.t.Helper() - before := deepCopy(r.m) - next, effects := Transition(r.m, ev, r.ctx) - if !reflect.DeepEqual(before, r.m) { - r.t.Fatalf("Transition mutated the model it was given, on %T", ev) - } - r.m = next - return effects -} - -// at moves the injected clock and recomputes the age of the last measurement the -// way the Hub does: Now - Timestamp, never accumulated (bloquant-1). -func (r *run) at(d time.Duration) *run { - r.ctx.Now = origin.Add(d) - if !r.ctx.LastMeasurement.Timestamp.IsZero() { - r.ctx.MeasurementAge = r.ctx.Now.Sub(r.ctx.LastMeasurement.Timestamp) - } - return r -} - -// measure pushes one reading at the current instant. -func (r *run) measure(g Grams, stability Stability) []Effect { - r.t.Helper() - r.seq++ - msr := Measurement{Gross: g, Stability: stability, Timestamp: r.ctx.Now, Seq: r.seq} - r.ctx.LastMeasurement = msr - r.ctx.MeasurementAge = 0 - return r.send(MeasurementReceived{M: msr}) -} - -func (r *run) tap(id, key string) []Effect { - r.t.Helper() - return r.send(ProductTapped{ProductID: id, Key: key}) -} - -// deepCopy duplicates everything the model reaches through a pointer, so that a -// mutation of a pointee is caught and not merely the reassignment of a field. -func deepCopy(m Model) Model { - out := m - if m.CurrentProduct != nil { - p := *m.CurrentProduct - out.CurrentProduct = &p - } - if m.Label != nil { - l := *m.Label - l.Lines = append([]PriceLine(nil), m.Label.Lines...) - out.Label = &l - } - if m.LastLabel != nil { - l := *m.LastLabel - l.Lines = append([]PriceLine(nil), m.LastLabel.Lines...) - out.LastLabel = &l - } - out.Diagnostics = append([]Diagnostic(nil), m.Diagnostics...) - return out -} - -func findEffect[T Effect](effects []Effect) (T, bool) { - for _, ef := range effects { - if got, ok := ef.(T); ok { - return got, true - } - } - var zero T - return zero, false -} - -func countEffect[T Effect](effects []Effect) int { - n := 0 - for _, ef := range effects { - if _, ok := ef.(T); ok { - n++ - } - } - return n -} - -// --- The exhaustive product ------------------------------------------------- - -// allStates is the sixteen states of §6.6, in declaration order. -var allStates = []State{ - Initializing, Idle, ProductArmed, WeightPresent, WeightStable, AwaitingStability, - EnteringTare, EnteringWeight, ManualMode, Validating, Printing, Succeeded, - Rejected, Faulted, ScaleLost, OutOfService, -} - // allEvents is the fourteen events of §6.6. func allEvents(t *testing.T) []Event { t.Helper() @@ -391,1794 +206,34 @@ func TestTransitionSurvivesANilEvent(t *testing.T) { } } -// --- Invariant 1 ------------------------------------------------------------ - -// TestTransitionCancelAlwaysClearsTheSelection is invariant 1 of §6.7: from any -// state, Cancel leads to a model where CurrentProduct is nil and Label is nil. -// -// It is checked on the FULL seeds -- the ones that actually carry a product and a -// label -- because on an empty model the invariant is true of nothing. -func TestTransitionCancelAlwaysClearsTheSelection(t *testing.T) { - ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} - states := map[State]bool{} - for _, seed := range modelSeeds(t) { - if seed.CurrentProduct == nil { - continue // the bare seeds prove nothing here - } - states[seed.State] = true - next, _ := Transition(seed, Cancel{}, ctx) - if next.CurrentProduct != nil { - t.Errorf("Cancel from %s left a product selected", seed.State) - } - if next.Label != nil { - t.Errorf("Cancel from %s left a label", seed.State) - } - if next.Tare != 0 || next.Diagnostics != nil { - t.Errorf("Cancel from %s left a tare or diagnostics behind", seed.State) - } - switch seed.State { - case OutOfService, ScaleLost: - // Publishing Idle would say "ready to weigh" about a station that is - // not: OutOfService is terminal and ScaleLost still has no scale. - if next.State != seed.State { - t.Errorf("Cancel moved %s to %s", seed.State, next.State) - } - default: - if next.State != Idle { - t.Errorf("Cancel from %s reached %s, want idle", seed.State, next.State) - } - } - } - if len(states) != 16 { - t.Fatalf("Cancel was exercised from %d states, want 16", len(states)) - } -} - -// TestTransitionCancelKeepsTheReprintBarAlive: a cancelled selection is not a -// cancelled label. The bottom bar is PERMANENT (§14.3), so what outlives the cycle -// has to outlive Cancel too. -func TestTransitionCancelKeepsTheReprintBarAlive(t *testing.T) { - r := nominalCycle(t) - before := *r.m.LastLabel - r.send(Cancel{}) - if r.m.LastLabel == nil || r.m.LastLabel.Barcode != before.Barcode { - t.Fatalf("Cancel forgot the last label") - } - if !r.m.LastPrintedAt.Equal(r.ctx.Now) && r.m.LastPrintedAt.IsZero() { - t.Fatal("Cancel forgot when the last label was printed") - } -} - -// --- Invariant 2 ------------------------------------------------------------ - -// nominalCycle runs the reference weighing to Succeeded and returns the run. -func nominalCycle(t *testing.T) *run { - t.Helper() - r := newRun(t) - r.at(0).measure(1236, Stable) - if r.m.State != WeightPresent { - t.Fatalf("a 1 236 g reading on an empty station reached %s", r.m.State) - } - effects := r.at(400*time.Millisecond).tap("894", "01J-TAP") - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the reference vector produced no label: %s, %#v", r.m.State, effects) - } - if r.m.State != Printing { - t.Fatalf("after the tap the state is %s, want printing", r.m.State) - } - r.at(430 * time.Millisecond).send(PrintFinished{ - JobID: print.Label.JobID, Duration: 30 * time.Millisecond, - }) - if r.m.State != Succeeded { - t.Fatalf("a successful print reached %s, want succeeded", r.m.State) - } - return r -} - -// TestTransitionNominalCycleReproducesTheReferenceVector is the numeric contract of -// §16.1 walked through the machine rather than through Price alone: 1,236 kg of -// garlic at 5,32 €/kg solidarity gives 6,58 / 5,92 / 4,79, and the barcode is -// 0493021012365. -func TestTransitionNominalCycleReproducesTheReferenceVector(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - effects := r.at(400*time.Millisecond).tap("894", "01J-TAP") - - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("no PrintEffect: %s, %#v", r.m.State, effects) - } - if got := print.Label.Barcode; got != "0493021012365" { - t.Errorf("barcode %s, want 0493021012365", got) - } - if print.Reprint { - t.Error("a first print is not a reprint") - } - for _, want := range []struct { - code string - unitPrice Cents - amount Cents - }{ - {"MEMBER", 479, 592}, - {"SOLIDARITY", 532, 658}, +// TestModelStateStringsSpellTheSnapshotValues pins the wording the SSE payload and +// the log lines share. +func TestModelStateStringsSpellTheSnapshotValues(t *testing.T) { + for state, want := range map[State]string{ + Initializing: "initializing", Idle: "idle", ProductArmed: "product_armed", + WeightPresent: "weight_present", WeightStable: "weight_stable", + AwaitingStability: "awaiting_stability", EnteringTare: "entering_tare", + EnteringWeight: "entering_weight", ManualMode: "manual_mode", + Validating: "validating", Printing: "printing", Succeeded: "succeeded", + Rejected: "rejected", Faulted: "faulted", ScaleLost: "scale_lost", + OutOfService: "out_of_service", } { - line := print.Label.Find(want.code) - if line == nil { - t.Fatalf("tier %s missing from the label", want.code) - } - if line.UnitPrice != want.unitPrice || line.Amount != want.amount { - t.Errorf("%s: %d cents/kg and %d cents, want %d and %d", - want.code, line.UnitPrice, line.Amount, want.unitPrice, want.amount) + if got := state.String(); got != want { + t.Errorf("State(%d) spells %q, want %q", state, got, want) } } - if print.Label.NetWeight != 1236 { - t.Errorf("net weight %d g, want 1236", print.Label.NetWeight) - } - // The frozen weight is the one that was printed, and the job id is the key the - // front generated on pointerdown. - if r.m.LatchedWeight.Gross != 1236 { - t.Errorf("frozen gross %d g, want 1236", r.m.LatchedWeight.Gross) - } - if print.Label.JobID != "01J-TAP" { - t.Errorf("job id %q, want the idempotency key", print.Label.JobID) - } - ack, ok := findEffect[AckEffect](effects) - if !ok || !ack.Ack.Accepted || ack.Ack.JobID != "01J-TAP" { - t.Errorf("the accepted ack does not carry the job id: %#v", ack) - } } -// TestTransitionPrintsExactlyOneLabelPerCycle is invariant 2 of §6.7: one -// PrintEffect per cycle, and it comes out of Validating. -// -// The repeats matter more than the single print does: a measurement that keeps -// arriving while the label is being printed, and a second tap on the same bag, are -// the two ways a station hands a customer two labels for one weighing. -func TestTransitionPrintsExactlyOneLabelPerCycle(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - prints := countEffect[PrintEffect](r.at(400*time.Millisecond).tap("894", "01J-TAP")) - if prints != 1 { - t.Fatalf("the tap emitted %d PrintEffect, want 1", prints) - } - - extra := 0 - for i := 1; i <= 5; i++ { - at := time.Duration(400+i*100) * time.Millisecond - extra += countEffect[PrintEffect](r.at(at).measure(1236, Stable)) - extra += countEffect[PrintEffect](r.at(at).tap("894", "01J-AGAIN")) - extra += countEffect[PrintEffect](r.at(at).send(Tick{})) - } - if extra != 0 { - t.Fatalf("%d extra labels came out while printing", extra) - } - - r.at(time.Second).send(PrintFinished{JobID: "01J-TAP", Duration: 30 * time.Millisecond}) - for i := 1; i <= 5; i++ { - at := time.Second + time.Duration(i*100)*time.Millisecond - extra += countEffect[PrintEffect](r.at(at).measure(1236, Stable)) - extra += countEffect[PrintEffect](r.at(at).tap("894", "01J-AGAIN")) - } - if extra != 0 { - t.Fatalf("%d extra labels came out on the same bag after success", extra) - } -} - -// TestTransitionEmitsPrintEffectOnlyFromValidatingOrAReprint walks the whole -// cartesian product and checks WHERE a PrintEffect can come from. -// -// The reprint is the one exception, and it is written down rather than tolerated: a -// reprint is a deliberate duplicate of an ALREADY VALIDATED label, it carries the -// RÉIMPRESSION mention, and re-validating it would refuse it for -// MEASUREMENT_EXPIRED -- the very code that protects the first print. -func TestTransitionEmitsPrintEffectOnlyFromValidatingOrAReprint(t *testing.T) { - ctx := TransitionContext{ - Cfg: machineConfig(), Now: origin.Add(200 * time.Millisecond), - LastMeasurement: Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 4}, - MeasurementAge: 200 * time.Millisecond, Expiry: 1200 * time.Millisecond, - Catalog: machineCatalog(t), - } - reprints, validations := 0, 0 +// TestTransitionSurvivesAStateNoEnumerationDeclares: a model rebuilt from a journal +// written by a newer binary carries a state this one has never heard of. It must be +// inert, never a panic in the Hub goroutine. +func TestTransitionSurvivesAStateNoEnumerationDeclares(t *testing.T) { + ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} + unknown := Model{State: State(99)} for _, ev := range allEvents(t) { - for _, seed := range modelSeeds(t) { - next, effects := Transition(seed, ev, ctx) - print, ok := findEffect[PrintEffect](effects) - if !ok { - continue - } - if next.State != Printing { - t.Errorf("(%s, %T) emitted a label while reaching %s", seed.State, ev, next.State) - } - if print.Reprint { - reprints++ - if _, isReprint := ev.(ReprintRequested); !isReprint { - t.Errorf("(%s, %T) emitted a reprint", seed.State, ev) - } - continue - } - validations++ - switch ev.(type) { - case ProductTapped, MeasurementReceived, ManualWeightConfirmed, Tick: - default: - t.Errorf("(%s, %T) emitted a first label outside a validating trigger", - seed.State, ev) - } - } - } - if validations == 0 || reprints == 0 { - t.Fatalf("the walk found %d validations and %d reprints: it proves nothing", - validations, reprints) - } -} - -// --- Invariant 3 ------------------------------------------------------------ - -// TestTransitionNeverChangesTheFrozenWeightAfterValidating is invariant 3 of §6.7. -// -// Twenty different readings arrive after the label was built -- the customer leans -// on the counter, the bag settles, the plate drifts -- and not one of them may -// reach the weight the label carries. -func TestTransitionNeverChangesTheFrozenWeightAfterValidating(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-TAP") - frozen := r.m.LatchedWeight - if frozen.Gross != 1236 { - t.Fatalf("the frozen gross is %d g, want 1236", frozen.Gross) - } - - for i := 1; i <= 20; i++ { - r.at(time.Duration(400+i*50)*time.Millisecond).measure(Grams(1200+i*7), Unstable) - if r.m.LatchedWeight != frozen { - t.Fatalf("reading %d changed the frozen weight: %+v", i, r.m.LatchedWeight) - } - if r.m.Label == nil || r.m.Label.NetWeight != 1236 { - t.Fatalf("reading %d changed the label", i) - } - } - r.send(PrintFinished{JobID: r.m.Label.JobID, Duration: 30 * time.Millisecond}) - if r.m.LatchedWeight != frozen { - t.Fatalf("the print result changed the frozen weight: %+v", r.m.LatchedWeight) - } - for i := 1; i <= 5; i++ { - r.at(time.Duration(2000+i*50)*time.Millisecond).measure(Grams(1300+i), Stable) - if r.m.LatchedWeight != frozen { - t.Fatalf("a reading after success changed the frozen weight: %+v", r.m.LatchedWeight) - } - } -} - -// --- Invariant 4 ------------------------------------------------------------ - -// TestTransitionHasNoCycleWithoutIdle is invariant 4 of §6.7: no burst of labels on -// one bag. The plate has to come back to the empty band, which is the signal the -// machine already owns. -func TestTransitionHasNoCycleWithoutIdle(t *testing.T) { - r := nominalCycle(t) - - for i := 1; i <= 4; i++ { - effects := r.at(time.Duration(1000+i*100)*time.Millisecond).tap("894", "01J-BURST") - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("tap %d printed %d labels without the bag ever leaving the plate", i, n) - } - if r.m.State != Succeeded { - t.Fatalf("tap %d moved the station to %s", i, r.m.State) - } - } - - // The bag leaves: THAT is what ends the cycle. - r.at(2*time.Second).measure(0, Stable) - if r.m.State != Idle { - t.Fatalf("an empty plate reached %s, want idle", r.m.State) - } - if r.m.CurrentProduct != nil || r.m.Label != nil || r.m.LatchedWeight.Gross != 0 { - t.Fatalf("the model was not reset on the way back to idle: %+v", r.m) - } - - r.at(3*time.Second).measure(2400, Stable) - if n := countEffect[PrintEffect](r.at(3500*time.Millisecond).tap("894", "01J-NEXT")); n != 1 { - t.Fatalf("the next customer got %d labels, want 1", n) - } -} - -// --- Invariant 8 ------------------------------------------------------------ - -// TestArmingExpiresBeforeNextCustomerBag is invariant 8 of §6.7 and failure test -// 17: no selection survives the departure of a customer. -// -// Wall-clock duration well under 5 ms, because every instant below is a literal. -func TestArmingExpiresBeforeNextCustomerBag(t *testing.T) { - t.Run("expired arming prints nothing for the next bag", func(t *testing.T) { - r := newRun(t) - effects := r.at(0).tap("894", "01J-ARM") - if r.m.State != ProductArmed { - t.Fatalf("a tap on an empty scale reached %s, want product_armed", r.m.State) - } - message, ok := findEffect[MessageEffect](effects) - if !ok || message.Text != "Posez votre produit." { - t.Errorf("arming said %q", message.Text) - } - timer, ok := findEffect[ArmTimerEffect](effects) - if !ok || timer.Duration != MaxArmingTime { - t.Errorf("the arming timer is %v, want %v", timer.Duration, MaxArmingTime) - } - - // The customer walks away. Ten seconds and one tick later, in silence. - if n := len(r.at(9900 * time.Millisecond).send(Tick{})); n != 0 { - t.Errorf("a tick before the deadline produced %d effects", n) - } - if r.m.State != ProductArmed { - t.Fatalf("the arming died at 9,9 s") - } - effects = r.at(10100 * time.Millisecond).send(Tick{}) - if len(effects) != 0 { - t.Errorf("the disarming is not silent: %#v", effects) - } - if r.m.State != Idle || r.m.CurrentProduct != nil { - t.Fatalf("after expiry: state %s, product %v", r.m.State, r.m.CurrentProduct) - } - - // The next customer puts an 800 g bag down: NOTHING is printed. - effects = r.at(12*time.Second).measure(800, Stable) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("the next customer's bag produced %d labels", n) - } - if r.m.State != WeightPresent { - t.Fatalf("the bag put the station in %s, want weight_present", r.m.State) - } - }) - - t.Run("a: bag at 9,9 s prints one label of the right product", func(t *testing.T) { - r := newRun(t) - r.at(0).tap("894", "01J-ARM") - effects := r.at(9900*time.Millisecond).measure(1236, Stable) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the bag at 9,9 s printed nothing: %s", r.m.State) - } - if countEffect[PrintEffect](effects) != 1 { - t.Fatal("more than one label") - } - if print.Label.Product.ID != "894" { - t.Errorf("the label carries product %s, want 894", print.Label.Product.ID) - } - if print.Label.Barcode != "0493021012365" { - t.Errorf("barcode %s", print.Label.Barcode) - } - }) - - t.Run("b: a second product at 5 s wins and re-arms the timer", func(t *testing.T) { - r := newRun(t) - r.at(0).tap("894", "01J-FIRST") - effects := r.at(5*time.Second).tap("5209", "01J-SECOND") - if r.m.State != Printing { - // eggs are by unit: they print at once. Re-arming is proven by the - // by-weight case below. - t.Fatalf("tapping the by-unit product reached %s", r.m.State) - } - if print, _ := findEffect[PrintEffect](effects); print.Label.Product.ID != "5209" { - t.Errorf("the label carries %s, want the second product", print.Label.Product.ID) - } - - // Same scenario with two by-weight products, which is what re-arming is for. - second := machineGarlic(t) - second.ID, second.Name = "973", "PATATE DOUCE SAF" - second.Reference = mustCompose(t, "049310000000") - second.UnitPrice = 467 - r = newRun(t) - r.ctx.Catalog = NewCatalog([]Product{machineGarlic(t), second}, nil) - - r.at(0).tap("894", "01J-FIRST") - effects = r.at(5*time.Second).tap("973", "01J-SECOND") - if r.m.State != ProductArmed || r.m.CurrentProduct.ID != "973" { - t.Fatalf("re-arming left state %s on product %v", r.m.State, r.m.CurrentProduct) - } - if timer, ok := findEffect[ArmTimerEffect](effects); !ok || timer.Duration != MaxArmingTime { - t.Error("the timer was not re-armed") - } - // The first product's deadline (10 s) passes and the arming SURVIVES, - // because the deadline that counts is the second product's (15 s). - if r.at(10500 * time.Millisecond).send(Tick{}); r.m.State != ProductArmed { - t.Fatal("the re-armed selection died on the first product's deadline") - } - print, ok := findEffect[PrintEffect](r.at(14*time.Second).measure(1236, Stable)) - if !ok { - t.Fatalf("the bag at 14 s printed nothing: %s", r.m.State) - } - if print.Label.Product.ID != "973" { - t.Errorf("the label carries %s, want the second product", print.Label.Product.ID) - } - }) - - t.Run("c: Cancel during arming returns to idle at once", func(t *testing.T) { - r := newRun(t) - r.at(0).tap("894", "01J-ARM") - r.at(3 * time.Second).send(Cancel{}) - if r.m.State != Idle || r.m.CurrentProduct != nil || r.m.Label != nil { - t.Fatalf("Cancel left state %s, product %v", r.m.State, r.m.CurrentProduct) - } - if n := countEffect[PrintEffect](r.at(4*time.Second).measure(1236, Stable)); n != 0 { - t.Fatalf("a cancelled arming still printed %d labels", n) - } - }) - - t.Run("d: after expiry the bag prints nothing at all", func(t *testing.T) { - r := newRun(t) - r.at(0).tap("894", "01J-ARM") - r.at(10100 * time.Millisecond).send(Tick{}) - total := 0 - for i := 0; i < 6; i++ { - at := 11*time.Second + time.Duration(i*400)*time.Millisecond - total += countEffect[PrintEffect](r.at(at).measure(800, Stable)) - } - if total != 0 { - t.Fatalf("%d labels were printed after the arming expired", total) - } - }) -} - -// TestArmingIsBoundedByACodeConstant pins the number itself. Ten seconds is more -// than the time it takes to open a bag and less than the time it takes to change -// customer; it is a code constant and not a setting (ADR-022, ADR-025). -func TestArmingIsBoundedByACodeConstant(t *testing.T) { - if MaxArmingTime != 10*time.Second { - t.Errorf("MaxArmingTime is %v, §6.6 says 10 s", MaxArmingTime) - } - if MaxSwitchIdle != 10*time.Second { - t.Errorf("MaxSwitchIdle is %v, §10.8 says 10 s", MaxSwitchIdle) - } - // The deadline is inclusive: at exactly MaxArmingTime the arming is over. - r := newRun(t) - r.at(0).tap("894", "01J-ARM") - r.at(MaxArmingTime).send(Tick{}) - if r.m.State != Idle { - t.Errorf("at exactly %v the state is %s, want idle", MaxArmingTime, r.m.State) - } -} - -// --- Scenarios -------------------------------------------------------------- - -// TestTransitionByUnitProductPrintsAtFirstTapForOneUnit is ADR-023: the same -// gesture and the same immediacy as a product sold by weight, on an EMPTY plate. -// -// This is the scenario safeguard rule 4 would refuse if the by-unit path were fed -// the state of the scale: SCALE_EMPTY is blocking, and the plate is empty by -// design here. -func TestTransitionByUnitProductPrintsAtFirstTapForOneUnit(t *testing.T) { - r := newRun(t) - effects := r.at(0).tap("5209", "01J-EGGS") - - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("a by-unit tap printed nothing: %s, %#v", r.m.State, effects) - } - if print.Label.Quantity != 1 { - t.Errorf("quantity %d, want 1", print.Label.Quantity) - } - if got := print.Label.Barcode; got != mustCompose(t, "049912345601") { - t.Errorf("barcode %s, want the pattern with a payload of 01", got) - } - if line := print.Label.Find("MEMBER"); line == nil || line.Amount != 284 { - t.Errorf("member amount %v, want 284 cents (315 x 9/10 = 283,5 -> 284)", line) - } - - // A multiple quantity is a field of the POST, not a state of the machine. - r = newRun(t) - effects = r.send(ProductTapped{ProductID: "5209", Units: 3, Key: "01J-THREE"}) - print, ok = findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("three units printed nothing: %s", r.m.State) - } - if print.Label.Quantity != 3 { - t.Errorf("quantity %d, want 3", print.Label.Quantity) - } - if got := print.Label.Barcode; got != mustCompose(t, "049912345603") { - t.Errorf("barcode %s, want a payload of 03", got) - } - if line := print.Label.Find("SOLIDARITY"); line == nil || line.Amount != 945 { - t.Errorf("solidarity amount %v, want 945 cents (315 x 3)", line) - } -} - -// TestTransitionRefusesAQuantityOutsideItsBounds keeps safeguard 10 reachable from -// the machine even though the quantity stopped being a state (§6.6). -func TestTransitionRefusesAQuantityOutsideItsBounds(t *testing.T) { - r := newRun(t) - effects := r.send(ProductTapped{ProductID: "5209", Units: 120, Key: "01J-MANY"}) - if r.m.State != Rejected { - t.Fatalf("120 units reached %s, want rejected", r.m.State) - } - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("120 units printed %d labels", n) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Accepted || ack.Ack.Code != CodeUnitsOutOfRange { - t.Errorf("the ack says %+v, want a refusal on %s", ack.Ack, CodeUnitsOutOfRange) - } - record, ok := findEffect[RecordEffect](effects) - if !ok || record.Weighing.Result != ResultRejected { - t.Error("a refused weighing is a journal row too") - } - if len(record.Weighing.Lines) == 0 { - t.Error("weighing_lines is mandatory, even on a refusal (§12.3)") - } - if record.Weighing.Barcode != "" { - t.Error("a refused weighing carries no barcode: nothing was printed") - } -} - -// TestTransitionRefusesAProductTheCatalogDoesNotOffer covers both a product absent -// from the snapshot and one the qualification kept out of the grid. From the -// customer's side they are the same sentence. -func TestTransitionRefusesAProductTheCatalogDoesNotOffer(t *testing.T) { - for _, id := range []string{"5115", "does-not-exist"} { - r := newRun(t) - r.at(0).measure(1236, Stable) - effects := r.at(400*time.Millisecond).tap(id, "01J-NOPE") - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("product %q printed %d labels", id, n) - } - if r.m.State != WeightPresent { - t.Errorf("product %q moved the station to %s", id, r.m.State) - } - ack, ok := findEffect[AckEffect](effects) - if !ok || ack.Ack.Accepted || ack.Ack.Code != CodeProductWithdrawn { - t.Errorf("product %q: ack %+v", id, ack.Ack) - } - message, _ := findEffect[MessageEffect](effects) - if message.Text != "Ce produit n'est pas disponible." { - t.Errorf("product %q says %q", id, message.Text) - } - } -} - -// TestTransitionRefusesAnExpiredMeasurement is the domain half of failure test -// 3 ter: the scale goes quiet after a valid reading and the weight must not be -// printed. The boundary is `age > Expiry`, not `>=`. -func TestTransitionRefusesAnExpiredMeasurement(t *testing.T) { - for _, tc := range []struct { - name string - age time.Duration - print bool - }{ - {"one millisecond before the expiry", 1199 * time.Millisecond, true}, - {"at exactly the expiry", 1200 * time.Millisecond, true}, - {"one millisecond after the expiry", 1201 * time.Millisecond, false}, - } { - for _, mode := range []string{ModeAdvisory, ModeBlocking} { - r := newRun(t) - r.ctx.Cfg.Stability.Mode = mode - r.at(0).measure(1236, Stable) - // The latch holds, so blocking mode does not divert to - // AwaitingStability and the two modes compare like for like. The - // scale then goes quiet, and the age is counted from THAT frame. - r.at(400*time.Millisecond).measure(1236, Stable) - effects := r.at(400*time.Millisecond+tc.age).tap("894", "01J-OLD") - - printed := countEffect[PrintEffect](effects) == 1 - if printed != tc.print { - t.Errorf("%s in %s mode: printed=%v, want %v", tc.name, mode, printed, tc.print) - } - if tc.print { - continue - } - if r.m.State != Rejected { - t.Errorf("%s in %s mode: state %s, want rejected", tc.name, mode, r.m.State) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Code != CodeMeasurementExpired { - t.Errorf("%s in %s mode: refused on %q, want %s", - tc.name, mode, ack.Ack.Code, CodeMeasurementExpired) - } - if r.m.Label != nil { - t.Errorf("%s in %s mode: a label was built for an expired weight", tc.name, mode) - } - } - } -} - -// TestTransitionAdvisoryStabilityPrintsAnUnstableWeight is failure test 3: a scale -// that never says ST still serves customers, and the journal says so (A3). -func TestTransitionAdvisoryStabilityPrintsAnUnstableWeight(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Unstable) - effects := r.at(200*time.Millisecond).tap("894", "01J-US") - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("advisory mode printed %d labels on an unstable weight", n) - } - found := false - for _, d := range r.m.Diagnostics { - if d.Code == CodeWeightUnstable { - found = true - if d.Blocks() { - t.Error("rule 6 blocked in advisory mode") - } - } - } - if !found { - t.Error("the instability was not recorded") - } - // The journal keeps the stability of the FROZEN reading and not of the last - // frame, which is what makes "enable blocking mode?" answerable on evidence - // later on (A3). - effects = r.at(300 * time.Millisecond).send(PrintFinished{JobID: r.m.Label.JobID}) - record, ok := findEffect[RecordEffect](effects) - if !ok { - t.Fatal("the weighing was not journalled") - } - if record.Weighing.Stability != Unstable { - t.Errorf("journalled stability %s, want unstable", record.Weighing.Stability) - } - if r.m.LatchedWeight.Stability != Unstable { - t.Errorf("frozen stability %s, want unstable", r.m.LatchedWeight.Stability) - } -} - -// TestTransitionBlockingStabilityWaitsThenActsOnItsTimeout covers the three -// on_timeout answers of §6.5, and the nominal case where the weight settles. -func TestTransitionBlockingStabilityWaitsThenActsOnItsTimeout(t *testing.T) { - blocking := func(t *testing.T, onTimeout string) *run { - t.Helper() - r := newRun(t) - r.ctx.Cfg.Stability.Mode = ModeBlocking - r.ctx.Cfg.Stability.OnTimeout = onTimeout - r.at(0).measure(1236, Unstable) - effects := r.at(100*time.Millisecond).tap("894", "01J-WAIT") - if r.m.State != AwaitingStability { - t.Fatalf("blocking mode on an unlatched weight reached %s", r.m.State) - } - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("blocking mode printed %d labels before stability", n) - } - if timer, ok := findEffect[ArmTimerEffect](effects); !ok || - timer.Duration != time.Duration(r.ctx.Cfg.Stability.Timeout) { - t.Error("the wait declares no timer") - } - return r - } - - // wobble keeps the scale TALKING while the mass refuses to settle, which is - // what a real wait looks like. A scale that goes silent instead is a different - // failure, and the machine answers it differently -- MEASUREMENT_EXPIRED -- - // which is why the wait has to be fed to test the timeout at all. - wobble := func(r *run, until time.Duration) { - for d := 500 * time.Millisecond; d <= until; d += 400 * time.Millisecond { - r.at(d).measure(1236, Unstable) - } - } - - t.Run("the weight settles", func(t *testing.T) { - r := blocking(t, OnTimeoutWarnAndPrint) - r.at(200*time.Millisecond).measure(1236, Stable) - effects := r.at(600*time.Millisecond).measure(1237, Stable) - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("a latched weight printed %d labels: %s", n, r.m.State) - } - // The ANCHOR is printed, not the last frame (§6.5). - print, _ := findEffect[PrintEffect](effects) - if print.Label.NetWeight != 1236 { - t.Errorf("the label carries %d g, want the anchor 1236", print.Label.NetWeight) - } - }) - - t.Run("warn_and_print", func(t *testing.T) { - r := blocking(t, OnTimeoutWarnAndPrint) - wobble(r, 2*time.Second) - if n := len(r.at(2 * time.Second).send(Tick{})); n != 0 { - t.Error("the timeout fired early") - } - wobble(r, 3100*time.Millisecond) - effects := r.at(3200 * time.Millisecond).send(Tick{}) - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("warn_and_print produced %d labels: %s", n, r.m.State) - } - }) - - t.Run("reject", func(t *testing.T) { - r := blocking(t, OnTimeoutReject) - wobble(r, 3100*time.Millisecond) - effects := r.at(3200 * time.Millisecond).send(Tick{}) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("reject printed %d labels", n) - } - if r.m.State != Rejected { - t.Fatalf("reject reached %s", r.m.State) - } - record, ok := findEffect[RecordEffect](effects) - if !ok || record.Weighing.Result != ResultRejected { - t.Error("the refusal was not journalled") - } - }) - - t.Run("manual_entry", func(t *testing.T) { - r := blocking(t, OnTimeoutManualEntry) - wobble(r, 3100*time.Millisecond) - r.at(3200 * time.Millisecond).send(Tick{}) - if r.m.State != EnteringWeight { - t.Fatalf("manual_entry reached %s", r.m.State) - } - effects := r.at(4 * time.Second).send(ManualWeightConfirmed{Weight: 1236, Key: "01J-HAND"}) - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("the typed weight produced %d labels: %s", n, r.m.State) - } - }) -} - -// TestTransitionManualEntryIsNeverRefusedForAnAgedFrame is the reason a typed -// weight carries an age of zero. -// -// The scale has been quiet for an hour -- which is the only situation manual entry -// exists for. Passing the age of the last frame would make safeguard rule 2 refuse -// every single manual weighing, in exactly the case the feature was written for. -func TestTransitionManualEntryIsNeverRefusedForAnAgedFrame(t *testing.T) { - r := newRun(t) - r.ctx.Cfg.Scale.Present = false - r.at(0).measure(0, Stable) - r.at(time.Hour).send(Tick{}) - if r.m.State != ManualMode { - t.Fatalf("a station without a scale rests in %s, want manual_mode", r.m.State) - } - - effects := r.tap("894", "01J-HANDTAP") - if r.m.State != EnteringWeight { - t.Fatalf("a tap in manual mode reached %s", r.m.State) - } - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatal("the tap printed before a weight was typed") - } - - effects = r.at(time.Hour + time.Second).send( - ManualWeightConfirmed{Weight: 1236, Key: "01J-HAND"}) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the typed weight printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) - } - if print.Label.Barcode != "0493021012365" { - t.Errorf("barcode %s", print.Label.Barcode) - } - if r.m.Source != SourceManual { - t.Errorf("source %q, want %q", r.m.Source, SourceManual) - } - if r.m.LatchedWeight.Stability != StabilityNotApplicable { - t.Errorf("a typed weight reports %s, want not_applicable", r.m.LatchedWeight.Stability) - } - effects = r.send(PrintFinished{JobID: print.Label.JobID, Duration: 20 * time.Millisecond}) - record, ok := findEffect[RecordEffect](effects) - if !ok || record.Weighing.Source != SourceManual { - t.Errorf("the journal row says the weight came from %q", record.Weighing.Source) - } -} - -// TestTransitionManualEntryIsReachableFromALostScale is the "you can type the -// weight in" button of §15.4, on a station whose scale died mid-service. -func TestTransitionManualEntryIsReachableFromALostScale(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(time.Second).send(ScaleDisconnected{Err: errors.New("COM8: i/o timeout")}) - if r.m.State != ScaleLost { - t.Fatalf("state %s, want scale_lost", r.m.State) - } - - r.at(2*time.Second).tap("894", "01J-DEGRADED") - if r.m.State != EnteringWeight { - t.Fatalf("a tap on a lost scale reached %s", r.m.State) - } - effects := r.at(3 * time.Second).send(ManualWeightConfirmed{Weight: 900, Key: "01J-HAND"}) - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("the typed weight produced %d labels: %+v", n, r.m.Diagnostics) - } - - // Without the operator switch, the same tap does nothing at all. - r = newRun(t) - r.ctx.Cfg.Scale.ManualEntryAllowed = false - r.at(0).send(ScaleDisconnected{}) - if n := len(r.at(time.Second).tap("894", "01J-NO")); n != 0 { - t.Errorf("manual entry is forbidden and the tap produced %d effects", n) - } -} - -// TestTransitionScaleLossIsIdempotent is failure test 1: twenty consecutive -// StatusDisconnected from the reconnection backoff cost ONE transition. -func TestTransitionScaleLossIsIdempotent(t *testing.T) { - for _, name := range []string{"with an error", "with a nil error"} { - r := newRun(t) - r.at(0).measure(1236, Stable) - ev := ScaleDisconnected{Err: errors.New("COM8: i/o timeout")} - if name == "with a nil error" { - ev = ScaleDisconnected{} - } - effects := r.at(time.Second).send(ev) - if r.m.State != ScaleLost { - t.Fatalf("%s: state %s, want scale_lost", name, r.m.State) - } - if _, ok := findEffect[MessageEffect](effects); !ok { - t.Errorf("%s: the loss said nothing to the customer", name) - } - if r.m.CurrentProduct != nil || r.m.Label != nil { - t.Errorf("%s: the cycle survived the loss of the scale", name) - } - - for i := 0; i < 20; i++ { - at := time.Second + time.Duration(i+1)*time.Second - if n := len(r.at(at).send(ev)); n != 0 { - t.Fatalf("%s: repetition %d produced %d effects", name, i+1, n) - } - } - - effects = r.at(30 * time.Second).send(ScaleReconnected{}) - if r.m.State != Idle { - t.Errorf("%s: reconnection reached %s", name, r.m.State) - } - if _, ok := findEffect[TechnicalLogEffect](effects); !ok { - t.Errorf("%s: the reconnection was not logged", name) - } - // A weight measured before the outage must not latch after it. - if r.m.LatchState.Latched { - t.Errorf("%s: the latch survived the outage", name) - } - } -} - -// TestTransitionScaleLossIsIgnoredOutOfService keeps the note of §6.6 honest: the -// only state the loss of the scale does not reach is the terminal one. -func TestTransitionScaleLossIsIgnoredOutOfService(t *testing.T) { - ctx := TransitionContext{Cfg: machineConfig(), Now: origin} - next, effects := Transition(Model{State: OutOfService}, ScaleDisconnected{}, ctx) - if next.State != OutOfService || len(effects) != 0 { - t.Fatalf("out of service reacted to the loss of the scale: %s, %#v", next.State, effects) - } - reached := 0 - for _, s := range allStates { - if s == OutOfService || s == ScaleLost { - continue - } - next, _ := Transition(Model{State: s}, ScaleDisconnected{}, ctx) - if next.State != ScaleLost { - t.Errorf("%s did not reach scale_lost", s) - continue - } - reached++ - } - if reached != 14 { - t.Fatalf("%d states reached scale_lost, want 14", reached) - } -} - -// TestTransitionPrintFailureFaultsAndKeepsTheCode is failure test 4 seen from the -// machine: the full screen carries the ERR code a volunteer reads over the phone. -func TestTransitionPrintFailureFaultsAndKeepsTheCode(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-TAP") - effects := r.at(time.Second).send(PrintFinished{ - JobID: "01J-TAP", Err: errors.New("winspool: StartDocPrinter: file not found"), - }) - if r.m.State != Faulted { - t.Fatalf("a failed print reached %s, want faulted", r.m.State) - } - if r.m.FaultCode != "ERR-PRN-01" { - t.Errorf("fault code %q, want ERR-PRN-01", r.m.FaultCode) - } - record, ok := findEffect[RecordEffect](effects) - if !ok || record.Weighing.Result != ResultFailed { - t.Errorf("a failed print was journalled %q, want %q", record.Weighing.Result, ResultFailed) - } - if _, ok := findEffect[TechnicalLogEffect](effects); !ok { - t.Error("a failed print left no technical trace") - } - - // Only an acknowledgement leaves the full screen. - if n := len(r.at(2*time.Second).measure(0, Stable)); n != 0 { - t.Error("an empty plate cleared a fault screen") - } - if r.m.State != Faulted { - t.Fatalf("state %s after a measurement, want faulted", r.m.State) - } - r.at(3 * time.Second).send(Dismiss{}) - if r.m.State != Idle || r.m.FaultCode != "" { - t.Fatalf("Dismiss left state %s and code %q", r.m.State, r.m.FaultCode) - } -} - -// TestTransitionIgnoresAPrintResultFromAnotherJob: a late answer names a job the -// customer has already forgotten, and acting on it would move a cycle it is not -// about. -func TestTransitionIgnoresAPrintResultFromAnotherJob(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-TAP") - effects := r.at(time.Second).send(PrintFinished{JobID: "01J-SOMETHING-ELSE"}) - if r.m.State != Printing { - t.Fatalf("a foreign result moved the station to %s", r.m.State) - } - if _, ok := findEffect[RecordEffect](effects); ok { - t.Error("a foreign result was journalled") - } - if _, ok := findEffect[TechnicalLogEffect](effects); !ok { - t.Error("a foreign result left no technical trace") - } -} - -// TestTransitionRejectedLetsTheCustomerCorrect is §14.3: the message lives in the -// banner, the grid stays visible, and the customer corrects without closing -// anything. Nothing was printed, so nothing forbids a second attempt. -func TestTransitionRejectedLetsTheCustomerCorrect(t *testing.T) { - r := newRun(t) - r.ctx.Cfg.Limits.MinWeight = 2000 // the garlic at 1 236 g is too light - r.at(0).measure(1236, Stable) - effects := r.at(400*time.Millisecond).tap("894", "01J-LIGHT") - if r.m.State != Rejected { - t.Fatalf("a too-light weighing reached %s", r.m.State) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Code != CodeWeightTooLow { - t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeWeightTooLow) - } - - // The customer adds to the bag and taps again: the second attempt goes through - // and it freezes ITS OWN weight. - r.at(2*time.Second).measure(2400, Stable) - effects = r.at(2500*time.Millisecond).tap("894", "01J-HEAVIER") - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the corrected weighing printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) - } - if print.Label.NetWeight != 2400 { - t.Errorf("the label carries %d g, want 2400", print.Label.NetWeight) - } - if print.Label.JobID != "01J-HEAVIER" { - t.Errorf("job id %q, want the key of the second tap", print.Label.JobID) - } -} - -// TestTransitionIgnoresATapOnAWeightThatMoved: printing the current mass would -// hand the customer a price they never saw, and printing the one they saw would -// hand them a mass that is not on the plate. So neither is printed. -func TestTransitionIgnoresATapOnAWeightThatMoved(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - effects := r.at(400 * time.Millisecond).send(ProductTapped{ - ProductID: "894", SeenWeight: 800, MeasurementSeq: 1, Key: "01J-STALE", - }) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a stale tap printed %d labels", n) - } - if r.m.State != WeightPresent { - t.Errorf("a stale tap moved the station to %s", r.m.State) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Accepted { - t.Error("a stale tap was accepted") - } - if _, ok := findEffect[TechnicalLogEffect](effects); !ok { - t.Error("a stale tap left no technical trace") - } - - // Inside the latch tolerance the two frames describe the same bag, and - // refusing them would refuse every legitimate tap. - r = newRun(t) - r.at(0).measure(1236, Stable) - effects = r.at(400 * time.Millisecond).send(ProductTapped{ - ProductID: "894", SeenWeight: 1235, MeasurementSeq: 1, Key: "01J-FRESH", - }) - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("a one-gram drift refused the tap: %s", r.m.State) - } -} - -// TestTransitionReprintPrintsOnceInsideItsWindow is §8.5: one reprint, marked -// RÉIMPRESSION, journalled result='reprint'. -func TestTransitionReprintPrintsOnceInsideItsWindow(t *testing.T) { - r := nominalCycle(t) - first := r.m.LastLabel.JobID - - effects := r.at(10 * time.Second).send(ReprintRequested{JobID: first, Key: "01J-AGAIN"}) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the reprint printed nothing: %s", r.m.State) - } - if !print.Reprint { - t.Error("the reprint is not marked as one: no RÉIMPRESSION would be printed") - } - if print.Label.JobID == first { - t.Error("the reprint reuses the job id, and weighings.job_id is UNIQUE") - } - if print.Label.Barcode != "0493021012365" { - t.Errorf("the reprint carries barcode %s", print.Label.Barcode) - } - - r.at(11 * time.Second).send(PrintFinished{ - JobID: print.Label.JobID, Duration: 30 * time.Millisecond, - }) - - // One reprint per label. - effects = r.at(12 * time.Second).send(ReprintRequested{Key: "01J-THIRD"}) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a second reprint produced %d labels", n) - } -} - -// TestTransitionReprintIsJournalledAsAReprint checks the result column of §12.3 -// separately, because it is what a cashier's question resolves to. -func TestTransitionReprintIsJournalledAsAReprint(t *testing.T) { - r := nominalCycle(t) - effects := r.at(5 * time.Second).send(ReprintRequested{Key: "01J-AGAIN"}) - print, _ := findEffect[PrintEffect](effects) - effects = r.at(6 * time.Second).send(PrintFinished{ - JobID: print.Label.JobID, Duration: 25 * time.Millisecond, - }) - record, ok := findEffect[RecordEffect](effects) - if !ok { - t.Fatal("the reprint was not journalled") - } - if record.Weighing.Result != ResultReprint { - t.Errorf("journalled %q, want %q", record.Weighing.Result, ResultReprint) - } - if record.Weighing.JobID != print.Label.JobID { - t.Errorf("the row names job %q, the label %q", record.Weighing.JobID, print.Label.JobID) - } -} - -// TestTransitionRefusesAReprintOutsideItsWindow: the window is a real fraud -// window, and a zero window disables reprinting altogether. -func TestTransitionRefusesAReprintOutsideItsWindow(t *testing.T) { - r := nominalCycle(t) - effects := r.at(90 * time.Second).send(ReprintRequested{Key: "01J-LATE"}) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a reprint 90 s later produced %d labels", n) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Accepted { - t.Error("a late reprint was accepted") - } - - r = nominalCycle(t) - r.ctx.Cfg.UI.ReprintWindowSeconds = 0 - if n := countEffect[PrintEffect](r.at(2 * time.Second).send(ReprintRequested{Key: "01J-OFF"})); n != 0 { - t.Fatalf("a zero window still reprinted %d labels", n) - } - - // A reprint naming another job is a stale request, never a second label. - r = nominalCycle(t) - if n := countEffect[PrintEffect](r.at(2 * time.Second).send( - ReprintRequested{JobID: "01J-OTHER", Key: "01J-WRONG"})); n != 0 { - t.Fatalf("a reprint of another job produced %d labels", n) - } - - // And with nothing ever printed there is nothing to reprint. - r = newRun(t) - if n := countEffect[PrintEffect](r.send(ReprintRequested{Key: "01J-NOTHING"})); n != 0 { - t.Fatal("a station that never printed reprinted something") - } -} - -// TestTransitionAbandonedEntryIsClearedSilently is all that is left of -// idle_timeout_s (§14.3): a customer who walks away never leaves a half-typed -// figure for the next one, and no report is ever chased off the screen. -func TestTransitionAbandonedEntryIsClearedSilently(t *testing.T) { - r := newRun(t) - effects := r.at(0).send(TareTapped{}) - if r.m.State != EnteringTare { - t.Fatalf("TareTapped reached %s", r.m.State) - } - if timer, ok := findEffect[ArmTimerEffect](effects); !ok || timer.Duration != 45*time.Second { - t.Errorf("the entry declares a timer of %v, want 45 s", timer.Duration) - } - - // The scale stays visible during the whole entry (§14.3). - if n := len(r.at(2*time.Second).measure(1236, Stable)); n != 0 { - t.Error("a measurement during a tare entry produced an effect") - } - if r.m.State != EnteringTare { - t.Fatalf("a measurement left the tare entry: %s", r.m.State) - } - - if n := len(r.at(44 * time.Second).send(Tick{})); n != 0 || r.m.State != EnteringTare { - t.Errorf("the entry died at 44 s: %s", r.m.State) - } - if n := len(r.at(46 * time.Second).send(Tick{})); n != 0 { - t.Errorf("the abandoned entry is not silent: %d effects", n) - } - if r.m.State != Idle || r.m.Tare != 0 { - t.Fatalf("after the timeout: state %s, tare %d", r.m.State, r.m.Tare) - } -} - -// TestTransitionTareTravelsWithTheTapAndReachesTheLabel: rule 7 is the single -// place that says whether a tare is usable, and it says it against the weight it -// will be applied to. -func TestTransitionTareTravelsWithTheTapAndReachesTheLabel(t *testing.T) { - r := newRun(t) - r.at(0).send(TareTapped{}) - effects := r.at(3 * time.Second).send(TareConfirmed{Tare: 236, Key: "01J-TARE"}) - if r.m.State != Idle || r.m.Tare != 236 { - t.Fatalf("after the tare: state %s, tare %d", r.m.State, r.m.Tare) - } - if ack, ok := findEffect[AckEffect](effects); !ok || !ack.Ack.Accepted { - t.Error("the confirmed tare was not acknowledged") - } - - r.at(4*time.Second).measure(1472, Stable) - effects = r.at(4500 * time.Millisecond).send(ProductTapped{ - ProductID: "894", Tare: 236, Key: "01J-TARED", - }) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the tared weighing printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) - } - if print.Label.Tare != 236 || print.Label.NetWeight != 1236 { - t.Errorf("tare %d and net %d, want 236 and 1236", print.Label.Tare, print.Label.NetWeight) - } - if print.Label.Barcode != "0493021012365" { - t.Errorf("barcode %s: the payload carries the NET weight", print.Label.Barcode) - } - - // A tare heavier than the weighing is refused by rule 7, not by the machine. - r = newRun(t) - r.at(0).measure(200, Stable) - effects = r.at(400 * time.Millisecond).send(ProductTapped{ - ProductID: "894", Tare: 300, Key: "01J-BADTARE", - }) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a tare heavier than the weighing printed %d labels", n) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Code != CodeTareInvalid { - t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeTareInvalid) - } -} - -// TestTransitionCatalogArrivesOnlyWhereItIsSafe: a swap from a weighing state would -// reorder the tiles under a customer's finger, which is what the deferred swap of -// §10.8 exists to prevent. -func TestTransitionCatalogArrivesOnlyWhereItIsSafe(t *testing.T) { - catalog := machineCatalog(t) - r := &run{t: t, m: Model{State: Initializing}, - ctx: TransitionContext{Cfg: machineConfig(), Now: origin}} - - if n := len(r.send(CatalogReady{})); n != 0 || r.m.State != Initializing { - t.Fatalf("an empty catalog started the station: %s", r.m.State) - } - effects := r.send(CatalogReady{Catalog: catalog}) - if r.m.State != Idle { - t.Fatalf("the first catalog reached %s, want idle", r.m.State) - } - apply, ok := findEffect[ApplyCatalogEffect](effects) - if !ok || apply.Catalog != catalog { - t.Error("the catalog was not applied") - } - r.ctx.Catalog = catalog - - if _, ok := findEffect[ApplyCatalogEffect](r.send(CatalogReady{Catalog: catalog})); !ok { - t.Error("a catalog arriving at rest was not applied") - } - - r.at(0).measure(1236, Stable) - if _, ok := findEffect[ApplyCatalogEffect](r.send(CatalogReady{Catalog: catalog})); ok { - t.Error("a catalog was applied while a bag was on the plate") - } -} - -// TestTransitionInconsistentPriceGridFaultsRatherThanCrashes: configuration checks -// 10 to 16 exist to make this unreachable, and Price refuses a grid it cannot -// apply. Reaching it must therefore be a full-screen fault, never a dead process. -func TestTransitionInconsistentPriceGridFaultsRatherThanCrashes(t *testing.T) { - for name, rules := range map[string]PricingRules{ - "no tier at all": {PrimaryCode: "MEMBER", ReferenceCode: "MEMBER"}, - "a discount above a hundred percent": { - Tiers: []PriceTier{{Code: "M", Discount: FullDiscount + 1}}, PrimaryCode: "M", ReferenceCode: "M", - }, - "a primary code naming no tier": { - Tiers: []PriceTier{{Code: "M"}}, PrimaryCode: "GHOST", ReferenceCode: "M", - }, - } { - r := newRun(t) - r.ctx.Cfg.Pricing = rules - r.at(0).measure(1236, Stable) - effects := r.at(400*time.Millisecond).tap("894", "01J-BADGRID") - if r.m.State != Faulted { - t.Errorf("%s reached %s, want faulted", name, r.m.State) - } - if r.m.FaultCode != "ERR-CFG-01" { - t.Errorf("%s: fault code %q", name, r.m.FaultCode) - } - if n := countEffect[PrintEffect](effects); n != 0 { - t.Errorf("%s printed %d labels", name, n) - } - if ack, ok := findEffect[AckEffect](effects); !ok || ack.Ack.Accepted { - t.Errorf("%s: the command was not answered with a refusal", name) - } - } -} - -// TestTransitionRefusesAProductWhoseBarcodeCannotCarryTheWeight is the second half -// of §6.2's invariant: a reference whose reserved zone is not empty would print a -// label pointing at ANOTHER article at the till. One product is unusable; the -// station keeps serving the others. -func TestTransitionRefusesAProductWhoseBarcodeCannotCarryTheWeight(t *testing.T) { - // 0493 100 10000 -- the very shape §6.2 walks through: read as a three-digit - // reference it is PATATE DOUCE at 10,000 kg, not TOMME at 1,000 kg. - broken := machineGarlic(t) - broken.ID, broken.Name = "5115", "TOMME DE SAVOIE -MV" - broken.Reference = mustCompose(t, "049310010000") - - r := newRun(t) - r.ctx.Catalog = NewCatalog([]Product{broken}, nil) - r.at(0).measure(1236, Stable) - effects := r.at(400*time.Millisecond).tap("5115", "01J-BROKEN") - - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a reference with an occupied reserved zone printed %d labels", n) - } - if r.m.State != Rejected { - t.Fatalf("state %s, want rejected", r.m.State) - } - ack, _ := findEffect[AckEffect](effects) - if ack.Ack.Code != CodeProductWithdrawn { - t.Errorf("refused on %q", ack.Ack.Code) - } - log, ok := findEffect[TechnicalLogEffect](effects) - if !ok { - t.Fatal("no technical trace names what has to be fixed in Odoo") - } - if log.Detail == "" { - t.Error("the technical trace carries no reason") - } - - // A product whose prefix is outside the plan has no encoding at all. - outside := machineGarlic(t) - outside.ID = "outside" - outside.Reference = mustCompose(t, "300000000000") - r = newRun(t) - r.ctx.Catalog = NewCatalog([]Product{outside}, nil) - r.at(0).measure(1236, Stable) - if n := countEffect[PrintEffect](r.at(400*time.Millisecond).tap("outside", "01J-OUT")); n != 0 { - t.Fatal("a prefix outside the plan produced a label") - } -} - -// TestTransitionRefusesAProductWhoseModeContradictsItsPrefix: the prefix is -// authoritative for the sale mode, never the `unite` column of the CSV (§10.2). -func TestTransitionRefusesAProductWhoseModeContradictsItsPrefix(t *testing.T) { - liar := machineGarlic(t) - liar.ID, liar.Mode = "liar", ByUnit // a 0493 reference sold "by unit" - r := newRun(t) - r.ctx.Catalog = NewCatalog([]Product{liar}, nil) - if n := countEffect[PrintEffect](r.at(0).tap("liar", "01J-LIAR")); n != 0 { - t.Fatal("a product contradicting its own prefix was priced") - } - if r.m.State != Rejected { - t.Fatalf("state %s, want rejected", r.m.State) - } -} - -// TestTransitionOverloadAndAnEmptyPlateAreRefused walks the two safeguards a -// customer meets most often, through the machine rather than through Evaluate. -func TestTransitionOverloadAndAnEmptyPlateAreRefused(t *testing.T) { - // The scale itself declares it is over capacity: no arithmetic on the mass can - // replace the flag. - r := newRun(t) - r.seq++ - msr := Measurement{Gross: 4000, Overload: true, Timestamp: origin, Seq: 1} - r.ctx.LastMeasurement = msr - r.send(MeasurementReceived{M: msr}) - effects := r.at(400*time.Millisecond).tap("894", "01J-OL") - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("an overloaded scale printed %d labels", n) - } - if ack, _ := findEffect[AckEffect](effects); ack.Ack.Code != CodeOverload { - t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeOverload) - } - - // A by-weight product typed at 0 g by hand: rule 4 is still evaluated for the - // derived paths, which is exactly what §6.4 keeps it for. - r = newRun(t) - r.ctx.Cfg.Scale.Present = false - r.at(0).send(Tick{}) - r.tap("894", "01J-ZERO") - effects = r.send(ManualWeightConfirmed{Weight: 0, Key: "01J-ZEROW"}) - if n := countEffect[PrintEffect](effects); n != 0 { - t.Fatalf("a manual weight of 0 g printed %d labels", n) - } - if ack, _ := findEffect[AckEffect](effects); ack.Ack.Code != CodeScaleEmpty { - t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeScaleEmpty) - } -} - -// TestTransitionLatchesTheAnchorAndNotTheLastFrame is §6.5 seen from the machine: -// inside a window that holds to within the tolerance we want a reproducible value. -func TestTransitionLatchesTheAnchorAndNotTheLastFrame(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - if r.m.State != WeightPresent { - t.Fatalf("the first frame reached %s", r.m.State) - } - r.at(200*time.Millisecond).measure(1237, Stable) - if r.m.State != WeightPresent { - t.Fatalf("200 ms is below min_duration and the state is %s", r.m.State) - } - r.at(400*time.Millisecond).measure(1235, Stable) - if r.m.State != WeightStable { - t.Fatalf("after 400 ms the state is %s, want weight_stable", r.m.State) - } - if r.m.LatchState.Gross != 1236 { - t.Errorf("the anchor is %d g, want the first frame 1236", r.m.LatchState.Gross) - } - print, ok := findEffect[PrintEffect](r.at(500*time.Millisecond).tap("894", "01J-ANCHOR")) - if !ok { - t.Fatalf("no label: %s", r.m.State) - } - if print.Label.NetWeight != 1236 { - t.Errorf("the label carries %d g, want the anchor", print.Label.NetWeight) - } - - // A mass that walks away breaks the window: the state falls back -- once the - // print job has answered, because Printing waits for its result and for - // nothing else. - r.at(550 * time.Millisecond).send(PrintFinished{JobID: print.Label.JobID}) - r.at(600*time.Millisecond).measure(0, Stable) - if r.m.State != Idle { - t.Fatalf("an empty plate reached %s", r.m.State) - } - r.at(700*time.Millisecond).measure(3000, Stable) - if r.m.State != WeightPresent { - t.Fatalf("a new mass reached %s, want weight_present", r.m.State) - } -} - -// TestTransitionJournalRowCarriesWhatTheLabelCarried: a row whose net weight -// differs from the printed one is unusable at the till, and the till is the only -// reason the row exists. -func TestTransitionJournalRowCarriesWhatTheLabelCarried(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-TAP") - label := *r.m.Label - - effects := r.at(1400 * time.Millisecond).send(PrintFinished{ - JobID: label.JobID, Duration: 40 * time.Millisecond, - }) - record, ok := findEffect[RecordEffect](effects) - if !ok { - t.Fatal("a successful print was not journalled") - } - w := record.Weighing - if w.Result != ResultSent { - t.Errorf("result %q, want %q -- there is no 'ok'", w.Result, ResultSent) - } - if w.NetWeight != label.NetWeight || w.GrossWeight != label.GrossWeight || - w.Barcode != label.Barcode { - t.Errorf("the row and the label disagree: %+v vs %+v", w, label) - } - if w.JobID != "01J-TAP" || w.IdempotencyKey != "01J-TAP" { - t.Errorf("job id %q, key %q", w.JobID, w.IdempotencyKey) - } - if w.ProductID != "894" || w.ProductName != "AIL BLANC SAF" || w.Mode != ByWeight { - t.Errorf("the row does not name the product: %+v", w) - } - if w.BaseUnitPrice != 532 { - t.Errorf("base unit price %d, want the catalog price 532", w.BaseUnitPrice) - } - if w.Station != 1 { - t.Errorf("station %d, want 1", w.Station) - } - if w.Source != SourceScale || w.Stability != Stable { - t.Errorf("source %q, stability %s", w.Source, w.Stability) - } - if w.DurationMS != 40 { - t.Errorf("duration %d ms, want the 40 the printer reported", w.DurationMS) - } - if len(w.Lines) != 2 { - t.Fatalf("%d journal lines, want one per tier", len(w.Lines)) - } - if line := w.Line("MEMBER"); line == nil || line.Amount != 592 || line.UnitPrice != 479 { - t.Errorf("the member line is %+v", line) - } - // rate_ms and frame belong to the Hub: a pure function reaches neither. - if w.RateMS != 0 || w.Frame != "" { - t.Errorf("the domain filled rate_ms or frame: %d, %q", w.RateMS, w.Frame) - } - if !w.OccurredAt.Equal(r.ctx.Now) { - t.Errorf("occurred at %v, want the injected instant %v", w.OccurredAt, r.ctx.Now) - } -} - -// TestTransitionSoundFollowsTheConfiguration: the browser plays the sound and the -// backend does no audio I/O, so the only question here is whether it is asked for. -func TestTransitionSoundFollowsTheConfiguration(t *testing.T) { - for _, on := range []bool{true, false} { - r := newRun(t) - r.ctx.Cfg.UI.Sound = on - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-TAP") - effects := r.at(time.Second).send(PrintFinished{JobID: "01J-TAP"}) - sound, played := findEffect[SoundEffect](effects) - if played != on { - t.Errorf("ui.sound=%v produced a sound: %v", on, played) - } - if on && sound.Name != "ok" { - t.Errorf("the sound is %q, want ok", sound.Name) - } - } -} - -// TestTransitionOutOfServiceIsTerminal: nothing in the machine enters it, and -// nothing but Cancel and ConfigurationRepaired is answered from it. -func TestTransitionOutOfServiceIsTerminal(t *testing.T) { - ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} - for _, ev := range allEvents(t) { - next, effects := Transition(Model{State: OutOfService}, ev, ctx) - if _, isCancel := ev.(Cancel); isCancel { - continue - } - if _, repaired := ev.(ConfigurationRepaired); repaired { - continue - } - if next.State != OutOfService { - t.Errorf("%T left out_of_service for %s", ev, next.State) - } - if len(effects) != 0 { - t.Errorf("%T produced %d effects out of service", ev, len(effects)) - } - } - // And no event reaches it either. - for _, s := range allStates { - for _, ev := range allEvents(t) { - next, _ := Transition(Model{State: s, ArmedAt: origin}, ev, ctx) - if next.State == OutOfService && s != OutOfService { - t.Errorf("(%s, %T) entered out_of_service", s, ev) - } - } - } -} - -// TestTransitionRepairedIsTheONEWayOutOfOutOfService. -// -// §11.3 puts a station in the terminal state from OUTSIDE the machine, when the file it -// read is unusable. §11.4 promises that no configuration block requires a restart of the -// process — and that promise was false for exactly this station: it could be repaired -// from the administration screen and would keep showing « Poste hors service » until -// somebody restarted a service the screen has no button for. -func TestTransitionRepairedIsTheONEWayOutOfOutOfService(t *testing.T) { - ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} - - // With a catalog in memory the station is ready to serve, and saying « Catalogue vide » - // about a grid that holds 331 tiles would be the second wrong screen in a row. - served, effects := Transition(Model{State: OutOfService}, ConfigurationRepaired{}, ctx) - if served.State != Idle { - t.Errorf("poste réparé avec catalogue = %s, attendu idle", served.State) - } - if len(effects) != 0 { - t.Errorf("la réparation produit %d effets, elle n'en produit aucun", len(effects)) - } - - // Without one, it goes back to waiting for its first flv_.csv (§15.4). - empty := TransitionContext{Cfg: machineConfig(), Now: origin} - waiting, _ := Transition(Model{State: OutOfService}, ConfigurationRepaired{}, empty) - if waiting.State != Initializing { - t.Errorf("poste réparé sans catalogue = %s, attendu initializing", waiting.State) - } - - // And it is INERT everywhere else: a configuration saved while a customer is mid-cycle - // must not cancel the weighing under their finger. - for _, state := range allStates { - if state == OutOfService { - continue - } - before := Model{State: state, ArmedAt: origin} - after, produced := Transition(before, ConfigurationRepaired{}, ctx) - if after.State != state || len(produced) != 0 { - t.Errorf("(%s, ConfigurationRepaired) = %s avec %d effets : la réparation "+ - "doit être sans effet hors de out_of_service", state, after.State, len(produced)) - } - } -} - -// TestTransitionValidatingCompletesOnATick covers the transient state a replay can -// hand back: the model already holds everything the decision needs. -func TestTransitionValidatingCompletesOnATick(t *testing.T) { - product := machineGarlic(t) - m := Model{ - State: Validating, CurrentProduct: &product, Units: 1, JobID: "01J-REPLAY", - IdempotencyKey: "01J-REPLAY", Source: SourceScale, - LatchedWeight: Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 9}, - } - ctx := TransitionContext{ - Cfg: machineConfig(), Now: origin.Add(100 * time.Millisecond), - LastMeasurement: m.LatchedWeight, MeasurementAge: 100 * time.Millisecond, - Expiry: 1200 * time.Millisecond, Catalog: machineCatalog(t), - } - next, effects := Transition(m, Tick{}, ctx) - if next.State != Printing { - t.Fatalf("a pending validation reached %s, want printing", next.State) - } - if n := countEffect[PrintEffect](effects); n != 1 { - t.Fatalf("%d labels", n) - } - - // Every other event is ignored rather than allowed to start a second cycle - // over the same frozen weight. - for _, ev := range []Event{MeasurementReceived{M: m.LatchedWeight}, TareTapped{}, Dismiss{}} { - got, effects := Transition(m, ev, ctx) - if got.State != Validating || len(effects) != 0 { - t.Errorf("%T moved a pending validation to %s with %d effects", - ev, got.State, len(effects)) - } - } - - // A validating model with nothing selected cannot validate anything. - orphan, effects := Transition(Model{State: Validating}, Tick{}, ctx) - if orphan.State != Idle || len(effects) != 0 { - t.Errorf("an orphaned validation reached %s with %d effects", orphan.State, len(effects)) - } -} - -// TestModelDeriveJobIDIsDeterministic: a pure function cannot mint a ULID, and it -// does not have to. What it must do is reproduce the same identifier when the same -// journal is replayed. -func TestModelDeriveJobIDIsDeterministic(t *testing.T) { - ctx := TransitionContext{ - Now: origin, LastMeasurement: Measurement{Seq: 42, Timestamp: origin}, - } - if got := deriveJobID("01J-KEY", ctx); got != "01J-KEY" { - t.Errorf("with a key the job id is %q, want the key itself", got) - } - first := deriveJobID("", ctx) - if first != deriveJobID("", ctx) { - t.Error("the derived job id is not reproducible") - } - if first == "" { - t.Error("the derived job id is empty") - } - later := ctx - later.Now = origin.Add(time.Millisecond) - if deriveJobID("", later) == first { - t.Error("two instants derive the same job id") - } - other := ctx - other.LastMeasurement.Seq = 43 - if deriveJobID("", other) == first { - t.Error("two measurements derive the same job id") - } -} - -// TestModelStateStringsSpellTheSnapshotValues pins the wording the SSE payload and -// the log lines share. -func TestModelStateStringsSpellTheSnapshotValues(t *testing.T) { - for state, want := range map[State]string{ - Initializing: "initializing", Idle: "idle", ProductArmed: "product_armed", - WeightPresent: "weight_present", WeightStable: "weight_stable", - AwaitingStability: "awaiting_stability", EnteringTare: "entering_tare", - EnteringWeight: "entering_weight", ManualMode: "manual_mode", - Validating: "validating", Printing: "printing", Succeeded: "succeeded", - Rejected: "rejected", Faulted: "faulted", ScaleLost: "scale_lost", - OutOfService: "out_of_service", - } { - if got := state.String(); got != want { - t.Errorf("State(%d) spells %q, want %q", state, got, want) - } - } -} - -// TestModelClearKeepsWhatOutlivesACycle pins the split the reprint bar depends on. -func TestModelClearKeepsWhatOutlivesACycle(t *testing.T) { - product := machineGarlic(t) - label := Label{Product: product, JobID: "01J-OLD"} - m := Model{ - State: Printing, CurrentProduct: &product, Label: &label, Tare: 236, Units: 4, - LatchedWeight: Measurement{Gross: 1236}, IdempotencyKey: "01J-OLD", JobID: "01J-OLD", - Source: SourceScale, FaultCode: "ERR-PRN-01", - Diagnostics: []Diagnostic{{Code: CodeZeroPrice}}, - LatchState: LatchState{Latched: true, Gross: 1236}, - LastLabel: &label, LastPrintedAt: origin, Reprinted: true, - } - got := m.clear(Idle) - - if got.CurrentProduct != nil || got.Label != nil || got.Diagnostics != nil || - got.Tare != 0 || got.Units != 0 || got.LatchedWeight != (Measurement{}) || - got.IdempotencyKey != "" || got.JobID != "" || got.Source != "" || - got.FaultCode != "" { - t.Errorf("clear kept something belonging to the cycle: %+v", got) - } - if got.LastLabel != &label || !got.LastPrintedAt.Equal(origin) || !got.Reprinted { - t.Error("clear forgot the reprint bar") - } - if !got.LatchState.Latched || got.LatchState.Gross != 1236 { - t.Error("clear forgot the latch, which describes the plate and not the cycle") - } -} - -// TestTransitionEmptyPlateAlwaysBringsTheStationHome walks the states a mass can be -// present in and checks the ONE signal that ends them all: the plate coming back to -// the empty band. It is the signal the machine already owns, it is exact, and it -// waits for nothing (§14.3). -func TestTransitionEmptyPlateAlwaysBringsTheStationHome(t *testing.T) { - t.Run("weight_present", func(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - r.at(200*time.Millisecond).measure(0, Stable) - if r.m.State != Idle { - t.Fatalf("state %s", r.m.State) - } - }) - - t.Run("awaiting_stability", func(t *testing.T) { - r := newRun(t) - r.ctx.Cfg.Stability.Mode = ModeBlocking - r.at(0).measure(1236, Unstable) - r.at(100*time.Millisecond).tap("894", "01J-WAIT") - if r.m.State != AwaitingStability { - t.Fatalf("state %s", r.m.State) - } - // The customer gives up and takes the bag back. - r.at(500*time.Millisecond).measure(0, Stable) - if r.m.State != Idle || r.m.CurrentProduct != nil { - t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) - } - }) - - t.Run("rejected", func(t *testing.T) { - r := newRun(t) - r.ctx.Cfg.Limits.MinWeight = 2000 - r.at(0).measure(1236, Stable) - r.at(400*time.Millisecond).tap("894", "01J-LIGHT") - if r.m.State != Rejected { - t.Fatalf("state %s", r.m.State) - } - if n := len(r.at(600*time.Millisecond).measure(1240, Stable)); n != 0 { - t.Error("a reading that keeps the bag on the plate produced an effect") - } - if r.m.State != Rejected { - t.Fatalf("the refusal was cleared by a reading: %s", r.m.State) - } - r.at(time.Second).measure(0, Stable) - if r.m.State != Idle || r.m.Diagnostics != nil { - t.Fatalf("state %s, diagnostics %v", r.m.State, r.m.Diagnostics) - } - }) -} - -// TestTransitionArmingSurvivesAHandBrushingThePlate: the arming ends on a MASS, not -// on any reading at all. A hand steadying the plate, a draught, a zero that drifts -// by a gram must not consume the ten seconds a customer has to open their bag. -func TestTransitionArmingSurvivesAHandBrushingThePlate(t *testing.T) { - r := newRun(t) - r.at(0).tap("894", "01J-ARM") - for i := 1; i <= 5; i++ { - effects := r.at(time.Duration(i)*time.Second).measure(Grams(i-3), Stable) - if len(effects) != 0 { - t.Fatalf("a reading inside the empty band at %d s produced %d effects", i, len(effects)) - } - if r.m.State != ProductArmed { - t.Fatalf("a reading inside the empty band at %d s left %s", i, r.m.State) - } - } - if n := countEffect[PrintEffect](r.at(6*time.Second).measure(1236, Stable)); n != 1 { - t.Fatalf("the bag produced %d labels", n) - } -} - -// TestTransitionByUnitSaleIgnoresWhatIsOnThePlate: a customer weighing vegetables -// who then taps a by-unit tile gets a label for the items and nothing about the -// mass -- the sale does not use the plate (ADR-023). -func TestTransitionByUnitSaleIgnoresWhatIsOnThePlate(t *testing.T) { - r := newRun(t) - r.at(0).measure(1236, Stable) - effects := r.at(400 * time.Millisecond).send(ProductTapped{ - ProductID: "5209", Units: 2, Key: "01J-EGGS", - }) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("no label: %s, %+v", r.m.State, r.m.Diagnostics) - } - if print.Label.GrossWeight != 0 || print.Label.NetWeight != 0 { - t.Errorf("a by-unit label carries a mass: %+v", print.Label) - } - if print.Label.Quantity != 2 { - t.Errorf("quantity %d, want 2", print.Label.Quantity) - } - - // Same from a station with no scale at all, and from one that lost it. - for _, arrange := range []func(*run){ - func(r *run) { r.ctx.Cfg.Scale.Present = false; r.at(0).send(Tick{}) }, - func(r *run) { r.at(0).send(ScaleDisconnected{}) }, - } { - r := newRun(t) - arrange(r) - if n := countEffect[PrintEffect](r.send(ProductTapped{ProductID: "5209", Key: "01J-E"})); n != 1 { - t.Fatalf("a by-unit sale printed %d labels from %s", n, r.m.State) - } - if r.m.Source != SourceManual { - t.Errorf("source %q, want %q on a station with no weight", r.m.Source, SourceManual) - } - } -} - -// TestTransitionLostScaleRefusesAProductItDoesNotOffer keeps the degraded path as -// strict as the nominal one: losing the scale does not open the grid. -func TestTransitionLostScaleRefusesAProductItDoesNotOffer(t *testing.T) { - r := newRun(t) - r.at(0).send(ScaleDisconnected{}) - effects := r.at(time.Second).tap("5115", "01J-HIDDEN") - if r.m.State != ScaleLost { - t.Fatalf("state %s", r.m.State) - } - if ack, ok := findEffect[AckEffect](effects); !ok || ack.Ack.Code != CodeProductWithdrawn { - t.Errorf("ack %+v", ack.Ack) - } -} - -// TestTransitionReprintWorksFromTheRestingState: the bottom bar is PERMANENT -// (§14.3), so a reprint has to survive the customer taking their bag off -- which is -// precisely the gesture that clears the cycle. -func TestTransitionReprintWorksFromTheRestingState(t *testing.T) { - r := nominalCycle(t) - r.at(2*time.Second).measure(0, Stable) - if r.m.State != Idle || r.m.CurrentProduct != nil { - t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) - } - - effects := r.at(20 * time.Second).send(ReprintRequested{Key: "01J-BAR"}) - print, ok := findEffect[PrintEffect](effects) - if !ok { - t.Fatalf("the permanent bar reprinted nothing: %s", r.m.State) - } - if !print.Reprint || print.Label.Barcode != "0493021012365" { - t.Errorf("the reprint carries %+v", print.Label) - } - // The product comes back from the label, so the journal row can name it. - if r.m.CurrentProduct == nil || r.m.CurrentProduct.ID != "894" { - t.Fatalf("the reprint names no product: %v", r.m.CurrentProduct) - } - effects = r.at(21 * time.Second).send(PrintFinished{JobID: print.Label.JobID}) - record, ok := findEffect[RecordEffect](effects) - if !ok || record.Weighing.ProductID != "894" || record.Weighing.Result != ResultReprint { - t.Errorf("the reprint row is %+v", record.Weighing) - } -} - -// TestTransitionAbandonedEntryReturnsToManualModeWhereThatIsHome: a station with no -// scale has no resting state other than manual entry, and an abandoned keypad must -// not leave it somewhere nothing can be tapped. -func TestTransitionAbandonedEntryReturnsToManualModeWhereThatIsHome(t *testing.T) { - r := newRun(t) - r.ctx.Cfg.Scale.Present = false - r.at(0).send(Tick{}) - r.tap("894", "01J-HANDTAP") - if r.m.State != EnteringWeight { - t.Fatalf("state %s", r.m.State) - } - r.at(50 * time.Second).send(Tick{}) - if r.m.State != ManualMode || r.m.CurrentProduct != nil { - t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) - } -} - -// TestTransitionIgnoresACatalogItCannotUse: an empty snapshot is not a catalog, and -// applying it would empty the grid of a station that was serving customers. -func TestTransitionIgnoresACatalogItCannotUse(t *testing.T) { - r := newRun(t) - for _, ev := range []Event{CatalogReady{}, CatalogReady{Catalog: NewCatalog(nil, nil)}} { - if n := len(r.send(ev)); n != 0 { - t.Errorf("%#v was applied", ev) - } - if r.m.State != Idle { - t.Fatalf("state %s", r.m.State) - } - } -} - -// TestTransitionSurvivesAStateNoEnumerationDeclares: a model rebuilt from a journal -// written by a newer binary carries a state this one has never heard of. It must be -// inert, never a panic in the Hub goroutine. -func TestTransitionSurvivesAStateNoEnumerationDeclares(t *testing.T) { - ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} - unknown := Model{State: State(99)} - for _, ev := range allEvents(t) { - switch ev.(type) { - case ScaleDisconnected, Cancel: - continue // both are answered before the state is looked at + switch ev.(type) { + case ScaleDisconnected, Cancel: + continue // both are answered before the state is looked at } next, effects := Transition(unknown, ev, ctx) if next.State != State(99) || len(effects) != 0 { @@ -2187,20 +242,3 @@ func TestTransitionSurvivesAStateNoEnumerationDeclares(t *testing.T) { } } } - -// TestModelFoldNeverWritesTheFrozenWeight is invariant 3 proven at the level it -// lives at: one function folds measurements, and it cannot reach LatchedWeight. -func TestModelFoldNeverWritesTheFrozenWeight(t *testing.T) { - frozen := Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 3} - m := Model{State: Printing, LatchedWeight: frozen} - policy := DefaultStabilityPolicy() - for i := 1; i <= 50; i++ { - m = m.fold(Measurement{ - Gross: Grams(1000 + i*13), Stability: Unstable, - Timestamp: origin.Add(time.Duration(i) * 100 * time.Millisecond), - }, policy) - if m.LatchedWeight != frozen { - t.Fatalf("fold %d wrote the frozen weight: %+v", i, m.LatchedWeight) - } - } -} diff --git a/internal/domain/machinefixture_test.go b/internal/domain/machinefixture_test.go new file mode 100644 index 0000000..84f945f --- /dev/null +++ b/internal/domain/machinefixture_test.go @@ -0,0 +1,221 @@ +// This file holds what EVERY machine scenario is built from: the neutral station +// with a scale, the four products, and the small driver that keeps a model, a +// context and an instant together. +// +// Not one of them sleeps. Every instant is a literal offset from `origin`, because +// the clock is injected -- which is the property that makes the time-dependent +// rules testable at all, and the whole file runs in milliseconds. + +package domain + +import ( + "reflect" + "testing" + "time" +) + +// origin is the instant every scenario starts from. A literal, never time.Now(). +var origin = time.Date(2026, 7, 25, 9, 30, 0, 0, time.UTC) + +// machineConfig is the neutral profile with a scale and the price grid of A7. +// +// It is built from NeutralProfile so that a value added to the schema cannot leave +// this file silently behind, and it changes exactly what the neutral profile is +// wrong about for a weighing test: it has a scale, and two price tiers. +func machineConfig() Config { + cfg := NeutralProfile() + cfg.Scale.Present = true + cfg.Scale.Type = "gram-xfoc-rs" + cfg.Scale.ManualEntryAllowed = true + cfg.Pricing = LaCagetteRules() + return cfg +} + +// mustCompose builds a pattern from twelve digits and its computed check digit, +// so no test carries a hand-computed one. +func mustCompose(t *testing.T, twelve string) EAN13 { + t.Helper() + code, err := Compose(twelve) + if err != nil { + t.Fatalf("Compose(%q): %v", twelve, err) + } + return code +} + +// machineGarlic is the reference vector of §16.1: solidarity unit price 5,32 €/kg, +// member discount of 10 %, weighed at 1,236 kg. +func machineGarlic(t *testing.T) Product { + t.Helper() + return Product{ + ID: "894", Name: "AIL BLANC SAF", + Reference: mustCompose(t, "049302100000"), + Mode: ByWeight, + PriceSuffix: " €/kg", + UnitPrice: 532, + CategoryCode: "vegetables", + Qualification: Weighable, + } +} + +// machineEggs is a by-unit product: prefix 0499, six reference digits, two payload ones. +func machineEggs(t *testing.T) Product { + t.Helper() + return Product{ + ID: "5209", Name: "OEUFS PLEIN AIR X6", + Reference: mustCompose(t, "049912345600"), + Mode: ByUnit, + PriceSuffix: " € l'unité", + UnitPrice: 315, + CategoryCode: "other", + Qualification: Weighable, + } +} + +// machineHidden is a product the qualification kept out of the grid. +func machineHidden(t *testing.T) Product { + t.Helper() + p := machineGarlic(t) + p.ID, p.Name = "5115", "TOMME DE SAVOIE -MV" + p.Qualification, p.Reason = Anomaly, FindingReservedZoneNotEmpty + return p +} + +func machineCatalog(t *testing.T) *Catalog { + t.Helper() + return NewCatalog( + []Product{machineGarlic(t), machineEggs(t), machineHidden(t)}, + []Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, + ) +} + +// run drives one scenario. It advances the injected clock explicitly and checks, +// on every single event, that Transition did not touch what it was given. +type run struct { + t *testing.T + m Model + ctx TransitionContext + seq int64 +} + +func newRun(t *testing.T) *run { + t.Helper() + return &run{ + t: t, + m: Model{State: Idle}, + ctx: TransitionContext{ + Cfg: machineConfig(), Now: origin, + Expiry: 1200 * time.Millisecond, Catalog: machineCatalog(t), + }, + } +} + +// send applies one event and proves Transition is pure while doing it. +func (r *run) send(ev Event) []Effect { + r.t.Helper() + before := deepCopy(r.m) + next, effects := Transition(r.m, ev, r.ctx) + if !reflect.DeepEqual(before, r.m) { + r.t.Fatalf("Transition mutated the model it was given, on %T", ev) + } + r.m = next + return effects +} + +// at moves the injected clock and recomputes the age of the last measurement the +// way the Hub does: Now - Timestamp, never accumulated (bloquant-1). +func (r *run) at(d time.Duration) *run { + r.ctx.Now = origin.Add(d) + if !r.ctx.LastMeasurement.Timestamp.IsZero() { + r.ctx.MeasurementAge = r.ctx.Now.Sub(r.ctx.LastMeasurement.Timestamp) + } + return r +} + +// measure pushes one reading at the current instant. +func (r *run) measure(g Grams, stability Stability) []Effect { + r.t.Helper() + r.seq++ + msr := Measurement{Gross: g, Stability: stability, Timestamp: r.ctx.Now, Seq: r.seq} + r.ctx.LastMeasurement = msr + r.ctx.MeasurementAge = 0 + return r.send(MeasurementReceived{M: msr}) +} + +func (r *run) tap(id, key string) []Effect { + r.t.Helper() + return r.send(ProductTapped{ProductID: id, Key: key}) +} + +// deepCopy duplicates everything the model reaches through a pointer, so that a +// mutation of a pointee is caught and not merely the reassignment of a field. +func deepCopy(m Model) Model { + out := m + if m.CurrentProduct != nil { + p := *m.CurrentProduct + out.CurrentProduct = &p + } + if m.Label != nil { + l := *m.Label + l.Lines = append([]PriceLine(nil), m.Label.Lines...) + out.Label = &l + } + if m.LastLabel != nil { + l := *m.LastLabel + l.Lines = append([]PriceLine(nil), m.LastLabel.Lines...) + out.LastLabel = &l + } + out.Diagnostics = append([]Diagnostic(nil), m.Diagnostics...) + return out +} + +func findEffect[T Effect](effects []Effect) (T, bool) { + for _, ef := range effects { + if got, ok := ef.(T); ok { + return got, true + } + } + var zero T + return zero, false +} + +func countEffect[T Effect](effects []Effect) int { + n := 0 + for _, ef := range effects { + if _, ok := ef.(T); ok { + n++ + } + } + return n +} + +// allStates is the sixteen states of §6.6, in declaration order. +var allStates = []State{ + Initializing, Idle, ProductArmed, WeightPresent, WeightStable, AwaitingStability, + EnteringTare, EnteringWeight, ManualMode, Validating, Printing, Succeeded, + Rejected, Faulted, ScaleLost, OutOfService, +} + +// nominalCycle runs the reference weighing to Succeeded and returns the run. +func nominalCycle(t *testing.T) *run { + t.Helper() + r := newRun(t) + r.at(0).measure(1236, Stable) + if r.m.State != WeightPresent { + t.Fatalf("a 1 236 g reading on an empty station reached %s", r.m.State) + } + effects := r.at(400*time.Millisecond).tap("894", "01J-TAP") + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the reference vector produced no label: %s, %#v", r.m.State, effects) + } + if r.m.State != Printing { + t.Fatalf("after the tap the state is %s, want printing", r.m.State) + } + r.at(430 * time.Millisecond).send(PrintFinished{ + JobID: print.Label.JobID, Duration: 30 * time.Millisecond, + }) + if r.m.State != Succeeded { + t.Fatalf("a successful print reached %s, want succeeded", r.m.State) + } + return r +} diff --git a/internal/domain/model.go b/internal/domain/model.go new file mode 100644 index 0000000..bcb9c58 --- /dev/null +++ b/internal/domain/model.go @@ -0,0 +1,212 @@ +package domain + +import "time" + +// This file holds what the machine REMEMBERS between two events, what it is allowed +// to READ while deciding, and the five helpers that move a model from one cycle to +// the next. +// +// A Model is a VALUE, and that is what makes the "single writer" inventory of §13.2 +// true without a mutex: Transition receives a copy, returns a copy, and mutates +// nothing it was given. + +// Model is everything the machine remembers between two events. +// +// It is a VALUE: Transition receives a copy, returns a copy, and mutates nothing +// it was given. That is what makes the function replayable and what makes the +// "single writer" inventory of §13.2 true without a mutex. +type Model struct { + State State + + // CurrentProduct is the selection of the cycle in flight. Nil is "nothing + // selected", and invariant 1 makes Cancel put it back to nil from every state. + CurrentProduct *Product + + // LatchedWeight is the reading the label is built from, FROZEN at the entry of + // Validating and never touched again for that cycle (invariant 3). + // + // The whole reading is frozen and not just a number: the label needs the gross + // and the tare, the journal needs the stability and the sequence, and freezing + // only the mass would let the stability recorded in the journal drift from the + // weight printed on the label. + LatchedWeight Measurement + + // Label is the printable label. Nil until Validating succeeds, and nil again + // after Cancel. + Label *Label + + // Tare is the tare in force, in grams. + Tare Grams + // Units is the count a by-unit sale carries. One by default (ADR-023). + Units int + + // Latch turns the stream of measurements into latched / not latched. It is + // held BY VALUE: Transition folds a measurement into a copy and returns the + // copy, so nothing is shared and nothing is mutated behind the caller's back. + Latch WeightLatch + // LatchState is what the latch said about the last measurement folded in. + LatchState LatchState + + // ArmedAt is the instant the current BOUNDED WAIT started -- arming, waiting + // for stability, or a keypad entry. One field, because there is never two at + // once, and one meaning: "since when are we waiting". + ArmedAt time.Time + // StartedAt is the instant the cycle started, which is what weighings.duration_ms + // measures -- the figure §14.3 uses to decide whether the grid is fast enough. + StartedAt time.Time + + // IdempotencyKey is the ULID the front generated on pointerdown, kept for the + // journal row that will be written when printing ends. + IdempotencyKey string + // JobID is the identifier of the print job of this cycle. It is minted when + // the cycle starts and not when printing starts, because a REJECTED weighing + // is a journal row too, and weighings.job_id is UNIQUE (§12.3). + JobID string + // Source is where the weight came from: scale, manual or replay. A replay run + // is invisible from here -- Measurement carries no provenance -- so the Hub, + // which knows which driver is open, substitutes it. + Source string + + // Diagnostics is what the safeguards said about the cycle in flight, all of + // them: the admin screen displays every one, the machine acts on the first + // blocking one (§6.4). + Diagnostics []Diagnostic + + // FaultCode is the ERR-xxx-nn shown in 18 px on the full-screen fault, for the + // volunteer who is going to read it out over the telephone (§14.3). + FaultCode string + + // LastLabel and LastPrintedAt outlive the cycle: the reprint bar of the client + // screen is PERMANENT and stays active for reprint_window_s (§8.5, §14.3). + LastLabel *Label + LastPrintedAt time.Time + // Reprinted enforces "one reprint only" (§8.5). It travels with LastLabel. + Reprinted bool +} + +// TransitionContext carries everything a transition is allowed to read. It +// depends on no database, no port, no network and no global clock. +type TransitionContext struct { + Cfg Config + // Now comes from ports.Clock, NEVER from time.Now(). + Now time.Time + // LastMeasurement is the most recent reading the Hub received. + LastMeasurement Measurement + // MeasurementAge is COMPUTED by the Hub as Now - Measurement.Timestamp, never + // accumulated (bloquant-1). A lost tick can therefore no longer under-count it + // and let an expired weight through. + MeasurementAge time.Duration + // Expiry is DERIVED from the observed cadence, never a constant (A3). + Expiry time.Duration + // Catalog is an immutable snapshot. Nil is tolerated everywhere: a station + // still initializing has none. + Catalog *Catalog +} + +// clear ends the cycle in flight and keeps what outlives it. +// +// It is what makes invariant 1 of §6.7 true in one place instead of sixteen: the +// selection, the frozen weight, the label, the tare and the diagnostics go; the +// latch, the last printed label and its instant stay, because they describe the +// plate and the reprint bar, not the cycle. +func (m Model) clear(state State) Model { + return Model{ + State: state, + Latch: m.Latch, + LatchState: m.LatchState, + LastLabel: m.LastLabel, + LastPrintedAt: m.LastPrintedAt, + Reprinted: m.Reprinted, + } +} + +// startCycle opens a weighing cycle on a product. +func (m Model) startCycle(p Product, ev ProductTapped, ctx TransitionContext) Model { + next := m.clear(m.State) + product := p + next.CurrentProduct = &product + next.Tare = ev.Tare + next.Units = ev.Units + if next.Units <= 0 { + next.Units = 1 + } + next.ArmedAt, next.StartedAt = ctx.Now, ctx.Now + next.IdempotencyKey = ev.Key + next.JobID = deriveJobID(ev.Key, ctx) + return next +} + +// fold folds one measurement into a COPY of the latch. +// +// A copy, because Transition mutates nothing it was given. The policy is +// re-applied on every call: a hot reload may change the tolerance or the minimum +// duration, and the anchor has to survive that without the latch being rebuilt +// (§11.4, ADR-027). +// +// It never writes LatchedWeight, which is what makes invariant 3 of §6.7 a +// property of the code rather than of a reviewer's attention. +func (m Model) fold(msr Measurement, policy StabilityPolicy) Model { + latch := m.Latch + latch.policy = policy + next := m + next.LatchState = latch.Feed(msr) + next.Latch = latch + return next +} + +// frozen is the reading a label is built from. +// +// When the latch holds, it is the ANCHOR and not the last frame: inside a window +// that holds to within the tolerance we want a reproducible value, not the latest +// fluctuation (§6.5). The tare is the one the model holds, because no model of the +// fleet supports Tare() over the serial line (§19), so the frame never carries one. +func (m Model) frozen(ctx TransitionContext) Measurement { + msr := ctx.LastMeasurement + msr.Tare = m.Tare + msr.Quantity = m.Units + if m.LatchState.Latched { + msr.Gross = m.LatchState.Gross + } + return msr +} + +// record builds the journal row of one weighing. +// +// It records what the LABEL carried and not what the plate read: a row whose net +// weight differs from the printed one is unusable at the till, and the till is the +// only reason the row exists. +func (m Model) record(label Label, result, detail string, duration int, + ctx TransitionContext) Weighing { + w := Weighing{ + OccurredAt: ctx.Now, + Station: ctx.Cfg.Station.Number, + JobID: m.JobID, + IdempotencyKey: m.IdempotencyKey, + GrossWeight: label.GrossWeight, + Tare: label.Tare, + NetWeight: label.NetWeight, + Quantity: label.Quantity, + Barcode: label.Barcode, + Source: m.Source, + Stability: m.LatchedWeight.Stability, + Result: result, + Detail: detail, + DurationMS: duration, + } + if m.CurrentProduct != nil { + w.ProductID = m.CurrentProduct.ID + w.ProductName = m.CurrentProduct.Name + w.Reference = m.CurrentProduct.Reference + w.Mode = m.CurrentProduct.Mode + w.BaseUnitPrice = m.CurrentProduct.UnitPrice + } + if w.DurationMS == 0 && !m.StartedAt.IsZero() { + w.DurationMS = int(ctx.Now.Sub(m.StartedAt).Milliseconds()) + } + for _, line := range label.Lines { + w.Lines = append(w.Lines, WeighingLine{ + TierCode: line.Tier.Code, UnitPrice: line.UnitPrice, Amount: line.Amount, + }) + } + return w +} diff --git a/internal/domain/model_test.go b/internal/domain/model_test.go new file mode 100644 index 0000000..8a09bdb --- /dev/null +++ b/internal/domain/model_test.go @@ -0,0 +1,84 @@ +// This file holds what the model REMEMBERS between two events: what a cleared +// cycle keeps, what folding a measurement may never touch, and where the +// identifier of a print job comes from when no key travelled. + +package domain + +import ( + "testing" + "time" +) + +// TestModelDeriveJobIDIsDeterministic: a pure function cannot mint a ULID, and it +// does not have to. What it must do is reproduce the same identifier when the same +// journal is replayed. +func TestModelDeriveJobIDIsDeterministic(t *testing.T) { + ctx := TransitionContext{ + Now: origin, LastMeasurement: Measurement{Seq: 42, Timestamp: origin}, + } + if got := deriveJobID("01J-KEY", ctx); got != "01J-KEY" { + t.Errorf("with a key the job id is %q, want the key itself", got) + } + first := deriveJobID("", ctx) + if first != deriveJobID("", ctx) { + t.Error("the derived job id is not reproducible") + } + if first == "" { + t.Error("the derived job id is empty") + } + later := ctx + later.Now = origin.Add(time.Millisecond) + if deriveJobID("", later) == first { + t.Error("two instants derive the same job id") + } + other := ctx + other.LastMeasurement.Seq = 43 + if deriveJobID("", other) == first { + t.Error("two measurements derive the same job id") + } +} + +// TestModelClearKeepsWhatOutlivesACycle pins the split the reprint bar depends on. +func TestModelClearKeepsWhatOutlivesACycle(t *testing.T) { + product := machineGarlic(t) + label := Label{Product: product, JobID: "01J-OLD"} + m := Model{ + State: Printing, CurrentProduct: &product, Label: &label, Tare: 236, Units: 4, + LatchedWeight: Measurement{Gross: 1236}, IdempotencyKey: "01J-OLD", JobID: "01J-OLD", + Source: SourceScale, FaultCode: "ERR-PRN-01", + Diagnostics: []Diagnostic{{Code: CodeZeroPrice}}, + LatchState: LatchState{Latched: true, Gross: 1236}, + LastLabel: &label, LastPrintedAt: origin, Reprinted: true, + } + got := m.clear(Idle) + + if got.CurrentProduct != nil || got.Label != nil || got.Diagnostics != nil || + got.Tare != 0 || got.Units != 0 || got.LatchedWeight != (Measurement{}) || + got.IdempotencyKey != "" || got.JobID != "" || got.Source != "" || + got.FaultCode != "" { + t.Errorf("clear kept something belonging to the cycle: %+v", got) + } + if got.LastLabel != &label || !got.LastPrintedAt.Equal(origin) || !got.Reprinted { + t.Error("clear forgot the reprint bar") + } + if !got.LatchState.Latched || got.LatchState.Gross != 1236 { + t.Error("clear forgot the latch, which describes the plate and not the cycle") + } +} + +// TestModelFoldNeverWritesTheFrozenWeight is invariant 3 proven at the level it +// lives at: one function folds measurements, and it cannot reach LatchedWeight. +func TestModelFoldNeverWritesTheFrozenWeight(t *testing.T) { + frozen := Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 3} + m := Model{State: Printing, LatchedWeight: frozen} + policy := DefaultStabilityPolicy() + for i := 1; i <= 50; i++ { + m = m.fold(Measurement{ + Gross: Grams(1000 + i*13), Stability: Unstable, + Timestamp: origin.Add(time.Duration(i) * 100 * time.Millisecond), + }, policy) + if m.LatchedWeight != frozen { + t.Fatalf("fold %d wrote the frozen weight: %+v", i, m.LatchedWeight) + } + } +} diff --git a/internal/domain/options.go b/internal/domain/options.go new file mode 100644 index 0000000..f6b50df --- /dev/null +++ b/internal/domain/options.go @@ -0,0 +1,491 @@ +package domain + +import ( + "bytes" + "encoding/json" + "sort" + "strconv" +) + +// This file holds the DRIVER-SPECIFIC half of a configuration: the untyped option +// map a file carries, the schema a driver declares to describe it, and the +// registries a running binary answers "which drivers exist?" from. +// +// It is one subject and not two: an option map means nothing without the schema +// that declares its keys, and a schema means nothing without the descriptor that +// owns it. What JUDGES them lives in validate.go and validate_options.go. + +// --- Driver options ------------------------------------------------------------ + +// DriverOptions is the driver-specific half of a hardware or catalog block. +// +// It stays UNTYPED on purpose: the administration screen generates its form from +// the schema the driver DECLARES (§9.3), so adding a scale model must not mean +// adding a Go field here. The values are kept as raw JSON rather than as `any` +// because decoding into `any` turns every number into a float64, and no float +// carries a quantity in this application. +type DriverOptions map[string]json.RawMessage + +// Text reports a string option, and whether it is present and really a string. +func (o DriverOptions) Text(key string) (string, bool) { + raw, ok := o[key] + if !ok { + return "", false + } + var value string + if json.Unmarshal(raw, &value) != nil { + return "", false + } + return value, true +} + +// Int reports a whole-number option, and whether it is present and really whole. +func (o DriverOptions) Int(key string) (int64, bool) { + number, ok := jsonNumber(o[key]) + if !ok { + return 0, false + } + value, err := strconv.ParseInt(number.String(), 10, 64) + if err != nil { + return 0, false + } + return value, true +} + +// jsonNumber decodes a raw value as a JSON number, refusing a QUOTED one. +// +// The refusal is deliberate: encoding/json happily reads a quoted numeric literal +// into a json.Number, so `"baud": "9600"` would pass silently. A configuration that +// spells a baud rate as text has a type error, and the driver form is what must say +// so -- the admin screen offers a numeric field, and a file that came from somewhere +// else has to be told. +func jsonNumber(raw json.RawMessage) (json.Number, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] == '"' { + return "", false + } + var number json.Number + if json.Unmarshal(trimmed, &number) != nil { + return "", false + } + return number, true +} + +// Ratio reports a fractional option, and whether it is present and numeric. +// +// The only floats a configuration carries are RATIOS -- min_readable_ratio, +// max_weighable_drop -- and never a mass, a price or a length. +func (o DriverOptions) Ratio(key string) (float64, bool) { + number, ok := jsonNumber(o[key]) + if !ok { + return 0, false + } + value, err := number.Float64() + if err != nil { + return 0, false + } + return value, true +} + +// Bool reports a boolean option, and whether it is present and really a boolean. +func (o DriverOptions) Bool(key string) (bool, bool) { + raw, ok := o[key] + if !ok { + return false, false + } + var value bool + if json.Unmarshal(raw, &value) != nil { + return false, false + } + return value, true +} + +// Group reports a nested option object, such as printer.options.fallback. +func (o DriverOptions) Group(key string) (DriverOptions, bool) { + raw, ok := o[key] + if !ok { + return nil, false + } + var value DriverOptions + if json.Unmarshal(raw, &value) != nil { + return nil, false + } + return value, true +} + +// Has reports whether the option is present, whatever its value. +func (o DriverOptions) Has(key string) bool { + _, ok := o[key] + return ok +} + +// Keys reports the option names in a stable order, so that two runs of the +// validation produce the faults in the same sequence. +func (o DriverOptions) Keys() []string { return sortedKeys(o) } + +// WithText returns the same options with one key set to a string value. +// +// It never touches the receiver, for the reason clone exists: a DriverOptions is a MAP, +// so a copy of a Config shares it with the configuration the station is running on, and +// writing through one of them would change the other. +func (o DriverOptions) WithText(key, value string) DriverOptions { + next := o.clone() + if next == nil { + next = make(DriverOptions, 1) + } + // json.Marshal of a string cannot fail: it escapes what it must, and replaces what is + // not valid UTF-8 rather than refusing it. + raw, _ := json.Marshal(value) + next[key] = raw + return next +} + +// clone returns a shallow copy, so that Export can strip a secret without reaching +// into the configuration the station is running on. +func (o DriverOptions) clone() DriverOptions { + if o == nil { + return nil + } + out := make(DriverOptions, len(o)) + for key, value := range o { + out[key] = value + } + return out +} + +// --- What a driver declares ------------------------------------------------------ + +// OptionKind names the shape one driver option accepts. +type OptionKind uint8 + +const ( + // OptionText is any string. + OptionText OptionKind = iota + // OptionInt is a whole number, bounded by Min and Max when Max is non-zero. + OptionInt + // OptionBool is a boolean. + OptionBool + // OptionRatio is a fraction between Min and Max expressed in per mille, which is + // how a ratio gets bounded without a float ever entering a declaration. + OptionRatio + // OptionEnum is one of Values. + OptionEnum + // OptionHostPort is a host:port pair. + OptionHostPort + // OptionURL is an absolute http or https URL. + OptionURL + // OptionGroup is a nested object whose own schema is Options. + OptionGroup +) + +// String reports the kind the way a fault names it, in French. +func (k OptionKind) String() string { + switch k { + case OptionText: + return "texte" + case OptionInt: + return "nombre entier" + case OptionBool: + return "vrai ou faux" + case OptionRatio: + return "nombre" + case OptionEnum: + return "valeur d'une liste" + case OptionHostPort: + return "hôte:port" + case OptionURL: + return "URL http ou https" + case OptionGroup: + return "objet" + } + return "inconnu" +} + +// OptionUse names what a value DESIGNATES, when knowing that lets a control judge it +// without knowing which driver declared the key. +// +// Kind says what SHAPE a value has — text, a whole number, a web address. Use says what +// it POINTS AT, and only the second lets Config.Validate probe a directory or refuse an +// HTTP host on a key it has never heard of. +// +// It exists because the three controls that did this work were three `if` statements +// naming `local_drop` and `webdav` INSIDE THE DOMAIN: a third catalog source could not +// be added without editing this file, which is the exact opposite of a plug-in point. +// The guards themselves have not moved an inch — what moved is who declares them +// (ADR-052). +type OptionUse uint8 + +const ( + // UseNone is the zero value, and what almost every option declares: the schema says + // nothing beyond the shape of the value. + UseNone OptionUse = iota + // UseDropDirectory is a directory ON THIS MACHINE the service must be able to list, + // write into and delete from — the acknowledgement of §10.1 IS a deletion. + // + // It carries the guard of important-11: a value that names an HTTP(S) host is refused + // outright. A "local" directory reached through an account and a password is the Z: + // drive of the legacy application under another name, and a source that fetches from + // a share is a different source, with a different acknowledgement. + UseDropDirectory +) + +// OptionSchema declares one option of a driver. +// +// It is what lets the administration screen GENERATE its form and the validation +// check the OPTIONS of a driver instead of only its type name: `port` among the +// enumerated ports, `queue` among the queues REALLY visible, `address` as +// host:port (§11.3). +type OptionSchema struct { + Key string + Kind OptionKind + Required bool + // Use is what the value points at, when a control can act on knowing it. Almost every + // option leaves it at UseNone. + Use OptionUse + // Values is the closed list of an enum, and for an option the platform can + // enumerate -- a serial port, a print queue -- the values it REALLY found. An + // empty list means "we could not enumerate": the form is checked, membership is + // not. + Values []string + // Min and Max bound an OptionInt, and an OptionRatio IN PER MILLE. Both zero + // means unbounded. + Min, Max int64 + // Options is the schema of a nested OptionGroup. + Options []OptionSchema +} + +// DriverDescriptor is what validating a configuration needs to know about a +// driver: its registry key, the wording a volunteer reads, and the schema of its +// options. +type DriverDescriptor struct { + // ID is the registry key, the value that goes into the file: "gram-xfoc-plus". + ID string + // Label is what the drop-down list shows, in French: "GRAM XFOC +". + Label string + Options []OptionSchema + // Capabilities is what a PRINTER driver declares about the head it drives, and it + // is what controls 29 and 38 measure a template against. + // + // The zero value is what every other kind of driver leaves here, and also what a + // printer that inks no paper declares: the rules then bear on ReferenceHead, the + // WS408 of the parc. + Capabilities PrinterCapabilities + // SelfTests are the built-in patterns of §8.6 a PRINTER driver honours, by the name + // the troubleshooting route sends: "label", "alignment", "ruler". + // + // Plain strings, and not a type of their own: the catalogue of the three lives in + // internal/printing, which is where their wording, their access level and what each + // print settles are written. What crosses into the domain is WHICH ONES a driver + // honours, so that the administration screen offers no button whose only possible + // answer is a refusal (ADR-025). + // + // Nil means « this binary cannot say », which is the honest answer of a validation + // run with no driver registry at all; an EMPTY slice is the assertion « none ». + SelfTests []string + // DeviceKey is the printer.options key a TRANSPORT descriptor reads to DESIGNATE ITS + // DEVICE: DeviceKeyQueue for winspool, DeviceKeyPath for devfile and file, + // DeviceKeyAddress for tcp. Empty on every other kind of descriptor. + // + // It travels for the reason Endpoint does just below, and it was learnt the same way. + // The Matériel screen carried ONE device field, wired to `queue` whatever the transport + // was; « Rechercher l'imprimante » proposes hosts answering on port 9100, and clicking + // one wrote 192.168.0.43:9100 into printer.options.queue. Nothing refused it — `queue` + // is a key of the driver, and no control ties a key to a transport — so the station + // saved a configuration that could not print, and said so only when the socket was + // opened. + // + // Declaring it here is what lets the screen ask the STATION where to write instead of + // carrying a table of its own: a fifth transport is then one line in a registry, and the + // form follows. + DeviceKey string + // Endpoint is the kind of access point a SCALE driver is reached and recognised on: + // EndpointSerialPort, or empty for a protocol that names none. + // + // It travels with the descriptor for the same reason SelfTests does — a screen and a + // diagnosis must read what the DRIVER declares instead of assuming. `openscale + // doctor` checked « le port série est présent et ouvrable » on every station that + // declares a scale, reading scale.options.port whatever the protocol was: on a scale + // reached any other way that control was a red light on a key that does not exist. + Endpoint string +} + +// The kinds of access point a scale protocol can be reached on, spelled once. +// +// They live in the domain because a descriptor carries them across to `openscale doctor` +// and to the administration screen, and a second spelling on the far side is how a +// declaration and its reader stop meaning the same thing. +const ( + // EndpointSerialPort is one serial port of the machine, as the platform enumerates + // them. + EndpointSerialPort = "serial-port" + // EndpointNone is a protocol that declares no access point of a kind this + // application enumerates: it is chosen by hand and never detected. + EndpointNone = "none" +) + +// --- Registries ------------------------------------------------------------------ + +// PathChecker answers the questions a pure validation cannot: what can this path do +// FROM THE CONTEXT OF THE SERVICE? +// +// It is an interface declared on the consumer side, and a nil one is a legitimate +// state: `openscale config validate` on a laptop cannot know what the service +// account sees. The form is then validated and existence is not. +type PathChecker interface { + // Readable reports nil when the service could read that path. + Readable(path string) error + // Droppable reports nil when the service could create AND DELETE a file there. + // + // Two questions and not one: a catalog is acknowledged by DELETING it (ADR-004), so a + // directory the service may only read would make the same import loop for ever -- + // applied, archived, and still there at the next poll. + Droppable(path string) error +} + +// Registries carries the driver descriptors and the templates a running binary +// knows. +// +// It exists so that the validation can check the OPTIONS of each driver and not +// merely say "unknown type". An EMPTY registry is a legitimate state -- the drivers +// are delivered by later lots, and `openscale config validate` may run outside the +// service: membership is then not checked and the message says so. What is always +// checked is the FORM, and the values that were RETIRED with a written reason. +type Registries struct { + Scales []DriverDescriptor + Printers []DriverDescriptor + Transports []DriverDescriptor + CatalogSources []DriverDescriptor + // Templates is the label layouts this binary can load. Nil means "the templates + // compiled into the binary", which is where they live until L4. + Templates map[string]Template + // Paths probes the filesystem for controls 44 and 46. Nil means "we cannot know". + Paths PathChecker +} + +// ScaleTypes reports the scale protocols a volunteer may choose from. +func (r Registries) ScaleTypes() []string { return descriptorIDs(r.Scales) } + +// PrinterTypes reports the printer drivers a volunteer may choose from. +func (r Registries) PrinterTypes() []string { return descriptorIDs(r.Printers) } + +// TransportNames reports the byte transports a volunteer may choose from. +func (r Registries) TransportNames() []string { return descriptorIDs(r.Transports) } + +// CatalogSourceNames reports the catalog sources a volunteer may choose from. +func (r Registries) CatalogSourceNames() []string { return descriptorIDs(r.CatalogSources) } + +// PrinterHead reports the geometry the driver printer.type names declares about its +// head. +// +// An unknown driver — and an EMPTY registry, which `openscale config validate` on a +// laptop legitimately is — answers a head that declares nothing, and the rules then +// fall back on the label of the parc rather than on nothing at all. +func (r Registries) PrinterHead(id string) PrinterCapabilities { + if descriptor := descriptorByID(r.Printers, id); descriptor != nil { + return descriptor.Capabilities + } + return PrinterCapabilities{} +} + +// TemplateNames reports the label layouts this binary can load, in a stable order. +func (r Registries) TemplateNames() []string { return sortedKeys(r.templates()) } + +// Template returns a layout by name, and whether it exists. +func (r Registries) Template(name string) (Template, bool) { + template, ok := r.templates()[name] + return template, ok +} + +// templates falls back on the layouts compiled into the binary, which is where +// they live until the rendering engine turns them into files (templates.go). +func (r Registries) templates() map[string]Template { + if r.Templates != nil { + return r.Templates + } + return ShippedTemplates() +} + +func descriptorIDs(list []DriverDescriptor) []string { + if len(list) == 0 { + return nil + } + out := make([]string, 0, len(list)) + for _, descriptor := range list { + out = append(out, descriptor.ID) + } + sort.Strings(out) + return out +} + +func descriptorByID(list []DriverDescriptor, id string) *DriverDescriptor { + for i := range list { + if list[i].ID == id { + return &list[i] + } + } + return nil +} + +// optionsUsedAs reports the options a named driver declares for a given use. +// +// It is what lets a control act on WHAT A VALUE POINTS AT without naming a driver: the +// key that carries a drop directory is `directory` in the source shipped today and may be +// anything in the next one, and the validation is not entitled to a second copy of that +// decision. An unknown driver yields nothing, which is the honest behaviour of a +// validation run against a registry that does not carry it. +func optionsUsedAs(list []DriverDescriptor, id string, use OptionUse) []OptionSchema { + descriptor := descriptorByID(list, id) + if descriptor == nil { + return nil + } + var out []OptionSchema + for _, schema := range descriptor.Options { + if schema.Use == use { + out = append(out, schema) + } + } + return out +} + +// sourcesFetchingByURL reports the sources that go and GET the catalog from an address. +// +// It is the suggestion control 39 offers when somebody types a web address into a drop +// path: « choose the source that fetches from a share » is only useful if it can say +// which one that is, and reading the schemas answers it for a source that did not exist +// when the control was written. +func sourcesFetchingByURL(list []DriverDescriptor) []string { + var out []string + for _, descriptor := range list { + for _, schema := range descriptor.Options { + if schema.Kind == OptionURL { + out = append(out, descriptor.ID) + break + } + } + } + sort.Strings(out) + return out +} + +// driversDeclaring reports which OTHER drivers of a list declare a given option key. +// +// It turns « option inconnue du driver "webdav" » into « … c'est "local_drop" qui la +// déclare », which is the difference between a refusal and a piece of advice — and it +// does it for every driver family and every key, where the control it replaces knew one +// key and two sources by name. +func driversDeclaring(list []DriverDescriptor, key, except string) []string { + var out []string + for _, descriptor := range list { + if descriptor.ID == except { + continue + } + for _, schema := range descriptor.Options { + if schema.Key == key { + out = append(out, descriptor.ID) + break + } + } + } + sort.Strings(out) + return out +} diff --git a/internal/domain/options_test.go b/internal/domain/options_test.go new file mode 100644 index 0000000..48d2a72 --- /dev/null +++ b/internal/domain/options_test.go @@ -0,0 +1,58 @@ +// This file holds what a DRIVER OPTION reads back as -- without a float ever +// carrying a quantity -- and what a registry answers when it carries no template of +// its own. + +package domain + +import "testing" + +func TestDriverOptionsReadTheirValuesWithoutAFloat(t *testing.T) { + options := DriverOptions{} + setOption(t, options, "port", "COM8") + setOption(t, options, "baud", 9600) + setOption(t, options, "invert_bits", false) + setOption(t, options, "min_readable_ratio", 0.9) + setOption(t, options, "fallback", map[string]any{"enabled": true}) + + if value, ok := options.Text("port"); !ok || value != "COM8" { + t.Errorf("port = %q, %v", value, ok) + } + if value, ok := options.Int("baud"); !ok || value != 9600 { + t.Errorf("baud = %d, %v", value, ok) + } + // A baud rate is not a ratio: reading a whole number as one must not silently + // succeed through a float. + if _, ok := options.Int("min_readable_ratio"); ok { + t.Error("0,9 n'est pas un entier") + } + if value, ok := options.Ratio("min_readable_ratio"); !ok || value != 0.9 { + t.Errorf("min_readable_ratio = %v, %v", value, ok) + } + if value, ok := options.Bool("invert_bits"); !ok || value { + t.Errorf("invert_bits = %v, %v", value, ok) + } + if group, ok := options.Group("fallback"); !ok { + t.Error("fallback doit se lire comme un objet") + } else if enabled, ok := group.Bool("enabled"); !ok || !enabled { + t.Error("fallback.enabled doit se lire depuis le groupe") + } + if _, ok := options.Text("absent"); ok { + t.Error("une option absente ne doit pas se lire") + } + if got := options.Keys(); len(got) != 5 || got[0] != "baud" { + t.Errorf("Keys() = %v, il doit être trié", got) + } +} + +func TestRegistriesFallBackOnTheCompiledTemplates(t *testing.T) { + var empty Registries + if _, ok := empty.Template(DefaultTemplateName); !ok { + t.Fatalf("un registre vide doit servir les gabarits compilés, %q absent", DefaultTemplateName) + } + if got := empty.TemplateNames(); len(got) != len(ShippedTemplates()) { + t.Fatalf("gabarits = %v, attendu les %d gabarits livrés", got, len(ShippedTemplates())) + } + if _, ok := empty.Template("weighing_imaginaire"); ok { + t.Error("un gabarit inexistant ne doit pas se résoudre") + } +} diff --git a/internal/domain/prepare_barcode_test.go b/internal/domain/prepare_barcode_test.go new file mode 100644 index 0000000..59d22cd --- /dev/null +++ b/internal/domain/prepare_barcode_test.go @@ -0,0 +1,413 @@ +// This file holds scenarios 10 to 18 of §16.1 -- the ones where the BARCODE or the +// PRODUCT is what refuses: a frozen weight, an overload, a null price, an occupied +// reserved zone, a code the plan cannot encode, a product no volunteer offers. +// +// They are the refusals a station keeps serving through: one product is unusable, +// the others still print. + +package domain + +import ( + "errors" + "reflect" + "testing" +) + +// --- 10. Manual entry ------------------------------------------------------ + +// TestPrepareManualEntryYieldsTheIdenticalLabel is guiding principle 1, stated as +// an equality. A connected scale, an absent scale and a keypad change the SOURCE OF +// THE WEIGHT and nothing else: at 1236 g the label is the SAME label, to the last +// field. +// +// This is the test that would have caught the first functional risk of the legacy +// application, where the member discount existed in the automatic path and in none +// of the three keypads. +func TestPrepareManualEntryYieldsTheIdenticalLabel(t *testing.T) { + fromScale := mustPrepare(t, nominalWeighing()) + + manual := nominalWeighing() + // A manual entry carries no stability, no sequence and no age: it is latched by + // construction and there is no frame to grow stale. + manual.Measurement = Measurement{Gross: 1236, Stability: StabilityNotApplicable} + manual.MeasurementAge = 0 + fromKeypad := mustPrepare(t, manual) + + if !reflect.DeepEqual(*fromScale.Label, *fromKeypad.Label) { + t.Fatalf("the keypad label differs from the scale label:\n scale %+v\n keypad %+v", + *fromScale.Label, *fromKeypad.Label) + } + if len(fromKeypad.Diagnostics) != 0 { + t.Errorf("diagnostics = %v, want none", codesOf(fromKeypad.Diagnostics)) + } +} + +// --- 11. The weight moved between the display and the tap ------------------ + +// TestPrepareFreezesTheWeightItWasGiven is the "poids changé" scenario. +// +// Prepare is a pure function of the measurement it is handed, so a frame arriving +// after the tap CANNOT change a label already computed: that is what makes +// principle 4 ("the weight is frozen at validation, never read again") a property +// of the code instead of a promise. +// +// And the second half, which is the one the legacy application failed: the mass +// DISPLAYED, the mass PRICED and the mass ENCODED all come from a single +// quantization, so they move together or not at all. The old code applied its +// Decimales_Poids setting to the banner and not to the encoding, and could show +// 1,23 kg while encoding 1,236 kg. +func TestPrepareFreezesTheWeightItWasGiven(t *testing.T) { + seen := mustPrepare(t, nominalWeighing()) + + moved := nominalWeighing() + moved.Measurement.Gross = 1240 + moved.Measurement.Seq = 5 + after := mustPrepare(t, moved) + + // The first label did not follow the scale. + if seen.Label.NetWeight != 1236 || seen.Label.Barcode != "0493021012365" { + t.Fatalf("the frozen label moved: net = %d, barcode = %q", + seen.Label.NetWeight, seen.Label.Barcode) + } + // The second one moved EVERYWHERE, consistently. + if after.Label.NetWeight != 1240 { + t.Errorf("net = %d g, want 1240", after.Label.NetWeight) + } + if after.Label.Barcode != "0493021012402" { + t.Errorf("barcode = %q, want 0493021012402", after.Label.Barcode) + } + if after.Label.PrimaryLine.Amount != 594 { // 479 x 1240 / 1000 = 593,96 + t.Errorf("member amount = %d c, want 594", after.Label.PrimaryLine.Amount) + } + // The printed mass and the encoded payload are the same number, on both labels. + for _, prep := range []Preparation{seen, after} { + encoded := string(prep.Label.Barcode)[7:12] + if want := pad(int(prep.Label.NetWeight), 5); encoded != want { + t.Errorf("label shows %d g and encodes %q, want %q", + prep.Label.NetWeight, encoded, want) + } + } +} + +// --- 12. Overload ---------------------------------------------------------- + +// TestPrepareOverload checks both halves of rule 1 -- the OL flag of the frame and +// the arithmetic bound -- and the NORMATIVE ORDER: OVERLOAD is the message shown, +// even though the net weight also exceeds the capacity of the barcode field. +func TestPrepareOverload(t *testing.T) { + t.Run("the frame says OL", func(t *testing.T) { + in := nominalWeighing() + in.Measurement.Overload = true // a saturated scale may report ANY plausible mass + prep := mustPrepare(t, in) + checkExclusive(t, prep) + if prep.Label != nil { + t.Errorf("a label was produced on an overload: %+v", prep.Label) + } + if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeOverload}) { + t.Fatalf("diagnostics = %v, want exactly [%s]", got, CodeOverload) + } + if prep.Refusal.Message != "La balance est en surcharge. Retirez votre article." { + t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) + } + }) + t.Run("beyond the capacity of the field", func(t *testing.T) { + in := nominalWeighing() + in.Measurement.Gross = 150_000 + prep := mustPrepare(t, in) + checkExclusive(t, prep) + if prep.Label != nil { + t.Errorf("a label was produced above max_weight_g: %+v", prep.Label) + } + if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeOverload, CodeWeightTooHigh}) { + t.Fatalf("diagnostics = %v, want [%s %s] in that order", + got, CodeOverload, CodeWeightTooHigh) + } + }) +} + +// TestPrepareEncodesTheHeaviestAdmissibleWeight is the other side of rule 9's `>`: +// max_weight_g is the CAPACITY of the NNDDD field, so it must stay reachable +// through the whole chain and not only through Generate (vector T4). +func TestPrepareEncodesTheHeaviestAdmissibleWeight(t *testing.T) { + in := nominalWeighing() + in.Measurement.Gross = 99_999 + + prep := mustPrepare(t, in) + checkExclusive(t, prep) + if prep.Label == nil { + t.Fatalf("99,999 kg refused: %v", codesOf(prep.Diagnostics)) + } + if prep.Label.Barcode != "0493021999994" { + t.Errorf("barcode = %q, want 0493021999994", prep.Label.Barcode) + } +} + +// --- 13. A null price ------------------------------------------------------ + +// TestPrepareZeroPrice is rule 12, and it is a backstop rather than a filter: the +// import already refuses a product priced at zero. It stays evaluated because the +// price can also become zero AFTER the catalog -- a tier configured at 100 % is +// accepted by the configuration checks and would turn every article free. +func TestPrepareZeroPrice(t *testing.T) { + cases := []struct { + name string + mutil func(*PrepareInput) + }{ + {"the catalog price is zero", func(in *PrepareInput) { in.Product.UnitPrice = 0 }}, + {"the primary tier is free", func(in *PrepareInput) { + rules := LaCagetteRules() + rules.Tiers[0].Discount = FullDiscount + in.Rules = rules + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + in := nominalWeighing() + c.mutil(&in) + prep := mustPrepare(t, in) + checkExclusive(t, prep) + if prep.Label != nil { + t.Errorf("a label was produced at 0 €: %+v", prep.Label) + } + if prep.Refusal == nil || prep.Refusal.Code != CodeZeroPrice { + t.Fatalf("refusal = %v, want %s", prep.Refusal, CodeZeroPrice) + } + if prep.Refusal.Message != "Prix nul. Appelez un bénévole." { + t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) + } + }) + } +} + +// --- 14. The reserved zone is occupied ------------------------------------ + +// TestPrepareRefusesAnOccupiedReservedZone is the second line of defence behind the +// import. 0493100100006 is a REAL code of flv.csv -- id 5115, ♥AA-TOMME DE SAVOIE +// -MV -- whose digits 8 to 12 are not zero: the reference would overflow into the +// weight field. Read by the till as 3 reference digits plus 5 weight digits, the +// label printed at 1,236 kg would announce PATATE DOUCE SAF at 11,236 kg. A factor +// ten on the mass AND a silent substitution of article (§6.2, T32). +// +// The import marks such a product an anomaly, so it has no tile. This test forces +// the mislabelled case -- a product declared Weighable with that reference -- and +// requires an error rather than a label. +func TestPrepareRefusesAnOccupiedReservedZone(t *testing.T) { + in := nominalWeighing() + in.Product.ID = "5115" + in.Product.Name = "♥AA-TOMME DE SAVOIE -MV" + in.Product.Reference = mustPattern(t, "0493100100006") + in.Product.UnitPrice = 2575 + + prep, err := Prepare(in) + if !errors.Is(err, ErrPatternNotZeroed) { + t.Fatalf("error = %v, want ErrPatternNotZeroed", err) + } + if prep.Label != nil { + t.Errorf("a label was produced: %+v", prep.Label) + } + // The diagnostics that were evaluated are still handed back: an operator reading + // a refusal needs to see what was checked, not only what failed. + if prep.Diagnostics == nil { + t.Log("no diagnostic on this weighing, which is expected: nothing else was wrong") + } +} + +// --- 15. A code the scale cannot encode ----------------------------------- + +// TestPrepareRefusesACodeThatIsNotWeighable covers the two doors of step 1: the +// qualification the import computed, and the numbering plan itself. +// +// A supplier EAN-13 is not an error -- it is a prepackaged product, and calling it +// invalid is what mislabelled 30 % of a real catalog (ADR-021). It simply has no +// tile, so Prepare must never be asked about it, and says so. +func TestPrepareRefusesACodeThatIsNotWeighable(t *testing.T) { + cases := []struct { + name string + product Product + wantError error + }{ + { + name: "an internal code 0491, not weighable", + product: Product{ + ID: "77", Name: "CODE INTERNE", Reference: mustPattern(t, "0491000000006"), + Mode: ByWeight, UnitPrice: 500, + Qualification: NotWeighable, Reason: FindingInternalCodeNotWeighable, + }, + wantError: ErrProductNotWeighable, + }, + { + name: "a supplier EAN, prepackaged", + product: Product{ + ID: "78", Name: "PREEMBALLE", Reference: mustPattern(t, "3760091721938"), + Mode: ByWeight, UnitPrice: 500, + Qualification: NotWeighable, Reason: FindingPrepackagedProduct, + }, + wantError: ErrProductNotWeighable, + }, + { + name: "an anomaly the import flagged", + product: Product{ + ID: "79", Name: "ANOMALIE", Reference: mustPattern(t, "0493100100006"), + Mode: ByWeight, UnitPrice: 500, + Qualification: Anomaly, Reason: FindingReservedZoneNotEmpty, + }, + wantError: ErrProductNotWeighable, + }, + { + // The qualification lies: the prefix has no entry in the plan, so there is + // no field layout to write into. + name: "a prefix outside the plan, wrongly qualified", + product: Product{ + ID: "80", Name: "HORS PLAN", Reference: mustPattern(t, "0491000000006"), + Mode: ByWeight, UnitPrice: 500, Qualification: Weighable, + }, + wantError: ErrPrefixNotInPlan, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + in := nominalWeighing() + in.Product = c.product + prep, err := Prepare(in) + if !errors.Is(err, c.wantError) { + t.Fatalf("error = %v, want %v", err, c.wantError) + } + if prep.Label != nil || prep.Refusal != nil { + t.Errorf("preparation = %+v, want nothing at all", prep) + } + }) + } +} + +// TestPrepareRefusesAModeContradictingThePrefix is the one place the two sources of +// the sale mode can disagree. The prefix wins everywhere else, so here the +// contradiction is refused rather than arbitrated: Price switches on Product.Mode +// while the payload is laid out by the plan, and a product priced by the unit while +// encoded by the gram would print a coherent label announcing the wrong thing. +func TestPrepareRefusesAModeContradictingThePrefix(t *testing.T) { + in := nominalWeighing() + in.Product.Reference = mustPattern(t, "0499000046000") + in.Product.Mode = ByWeight // the plan of 0499 sells by unit + + _, err := Prepare(in) + if !errors.Is(err, ErrPrefixModeMismatch) { + t.Fatalf("error = %v, want ErrPrefixModeMismatch", err) + } +} + +// TestPrepareRefusesAnInconsistentGrid checks that a price grid that cannot be +// applied comes back as an error and never as a panic in the Hub goroutine. +func TestPrepareRefusesAnInconsistentGrid(t *testing.T) { + in := nominalWeighing() + rules := LaCagetteRules() + rules.PrimaryCode = "ABSENT" + in.Rules = rules + + prep, err := Prepare(in) + if !errors.Is(err, ErrInconsistentTiers) { + t.Fatalf("error = %v, want ErrInconsistentTiers", err) + } + if prep.Label != nil { + t.Errorf("a label was produced on an inconsistent grid: %+v", prep.Label) + } +} + +// --- 16. The product is not offered locally ------------------------------- + +// TestPrepareProductNotOfferedLocally is rule 14 and ADR-017. The case it exists +// for is the one no import rule can detect: a reference that is irreproachable -- +// 13 digits, right check digit, reserved zone empty, coherent prefix -- and wrong at +// heart. It is a JUDGEMENT, so it produces a French message rather than an error. +func TestPrepareProductNotOfferedLocally(t *testing.T) { + in := nominalWeighing() + in.Decision = &LocalDecision{ + ProductID: "4412", Offered: false, + Reason: "le code appartient à un autre article", + } + + prep := mustPrepare(t, in) + checkExclusive(t, prep) + if prep.Label != nil { + t.Errorf("a label was produced for a withdrawn product: %+v", prep.Label) + } + if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeProductWithdrawn}) { + t.Fatalf("diagnostics = %v, want exactly [%s]", got, CodeProductWithdrawn) + } + if prep.Refusal.Message != "Ce produit n'est pas disponible." { + t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) + } +} + +// TestPrepareOffersWhatNoHumanDecidedAbout is the other half of the same rule: an +// absent row of local_decisions is not a refusal. Almost no product has one. +func TestPrepareOffersWhatNoHumanDecidedAbout(t *testing.T) { + for _, decision := range []*LocalDecision{ + nil, + {ProductID: "4412", Offered: true}, + {ProductID: "4412", Offered: true, MinWeightG: waiver(5)}, + } { + in := nominalWeighing() + in.Decision = decision + prep := mustPrepare(t, in) + if prep.Label == nil { + t.Fatalf("decision %+v: no label, diagnostics %v", decision, codesOf(prep.Diagnostics)) + } + if prep.Label.Barcode != "0493021012365" { + t.Errorf("decision %+v: barcode = %q", decision, prep.Label.Barcode) + } + } +} + +// --- 17. A reprint --------------------------------------------------------- + +// TestPrepareIsDeterministicSoAReprintCannotDiffer is the reprint scenario. +// +// A reprint sends the SAME label again, with the RÉIMPRESSION wording added by the +// template (§8.5) -- and the domain guarantee underneath it is that preparing the +// same weighing twice yields the same label, to the cent and to the digit. Nothing +// here reads a clock, a counter or a random source, so there is no way for a second +// label to disagree with the first about what the customer owes. +// +// The second half is not decoration: Label carries a slice and two pointers INTO +// it, and it is handed to the print worker and to the journal worker. Two +// preparations must share nothing. +func TestPrepareIsDeterministicSoAReprintCannotDiffer(t *testing.T) { + first := mustPrepare(t, nominalWeighing()) + second := mustPrepare(t, nominalWeighing()) + + if !reflect.DeepEqual(*first.Label, *second.Label) { + t.Fatalf("two preparations of one weighing differ:\n %+v\n %+v", + *first.Label, *second.Label) + } + first.Label.Lines[0].Amount = 1 + if second.Label.Lines[0].Amount != 592 { + t.Error("the two labels share their price lines: a reprint could rewrite the original") + } + if second.Label.PrimaryLine.Amount != 592 { + t.Error("the primary line of one label points into the other") + } +} + +// --- 18. A single tier ---------------------------------------------------- + +// TestPrepareSingleTier is contraint 8 made structural: dual pricing is the +// CARDINALITY of Tiers, not a boolean. One tier means one price line, and the +// barcode does not move -- the payload carries a mass, never a price. +func TestPrepareSingleTier(t *testing.T) { + in := nominalWeighing() + in.Rules = SingleTierRules() + + prep := mustPrepare(t, in) + checkExclusive(t, prep) + checkLabel(t, prep.Label, wantLabel{ + productID: "4412", + mode: ByWeight, + gross: 1236, + net: 1236, + lines: []wantLine{{"STANDARD", 532, 658}}, + primary: "STANDARD", + reference: "STANDARD", + barcode: "0493021012365", + jobID: "01J9F2ABCDEFGHJKMNPQRSTV", + }) +} diff --git a/internal/domain/prepare_properties_test.go b/internal/domain/prepare_properties_test.go new file mode 100644 index 0000000..2e79e01 --- /dev/null +++ b/internal/domain/prepare_properties_test.go @@ -0,0 +1,183 @@ +// This file holds what the seventeen scenarios SHARE, and that no single one of +// them can establish: a label and a refusal are exclusive, a refused weighing is +// priced all the same, Prepare touches nothing it was given, and one quantization +// serves the display, the price and the barcode alike. + +package domain + +import ( + "reflect" + "testing" + "time" +) + +// --- The properties the seventeen scenarios share -------------------------- + +// TestPrepareNeverProducesALabelAndARefusal walks every scenario shape at once. It +// is the invariant that makes "a blocking diagnostic means no label" checkable +// rather than asserted: not a label flagged as refused, not a label with an empty +// barcode -- no label, because a Label that exists is a Label something downstream +// may print. +func TestPrepareNeverProducesALabelAndARefusal(t *testing.T) { + inputs := map[string]func(*PrepareInput){ + "nominal": func(*PrepareInput) {}, + "tare": func(in *PrepareInput) { in.Measurement.Tare = 50 }, + "invalid tare": func(in *PrepareInput) { in.Measurement.Tare = 1300 }, + "empty scale": func(in *PrepareInput) { in.Measurement.Gross = 0 }, + "basket missing": func(in *PrepareInput) { in.Measurement.Gross = -275 }, + "below the empty band": func(in *PrepareInput) { in.Measurement.Gross = -1000 }, + "light product": func(in *PrepareInput) { in.Measurement.Gross = 8 }, + "overload": func(in *PrepareInput) { in.Measurement.Overload = true }, + "too heavy": func(in *PrepareInput) { in.Measurement.Gross = 100_000 }, + "expired": func(in *PrepareInput) { in.MeasurementAge = time.Minute }, + "unstable advisory": func(in *PrepareInput) { in.Measurement.Stability = Unstable }, + "unstable blocking": func(in *PrepareInput) { + in.Measurement.Stability, in.StabilityBlocking = Unstable, true + }, + "withdrawn": func(in *PrepareInput) { + in.Decision = &LocalDecision{ProductID: "4412"} + }, + "zero price": func(in *PrepareInput) { in.Product.UnitPrice = 0 }, + "single tier": func(in *PrepareInput) { in.Rules = SingleTierRules() }, + "by unit": func(in *PrepareInput) { in.Product, in.Measurement = garlicShoot(), Measurement{Quantity: 2} }, + "zero units": func(in *PrepareInput) { in.Product, in.Measurement = garlicShoot(), Measurement{} }, + "too many units": func(in *PrepareInput) { + in.Product, in.Measurement = garlicShoot(), Measurement{Quantity: 100} + }, + } + for name, mutate := range inputs { + t.Run(name, func(t *testing.T) { + in := nominalWeighing() + mutate(&in) + prep, err := Prepare(in) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + checkExclusive(t, prep) + // Whatever happens, a produced label carries a barcode: there is no such + // thing as a half-built label. + if prep.Label != nil && prep.Label.Barcode == "" { + t.Error("a label was produced without a barcode") + } + }) + } +} + +// TestPrepareStillPricesARefusedWeighing is §12.3 seen from the domain. A refusal +// is a journal row like any other and weighing_lines is mandatory: "at 8 g this +// product was refused, and here is what it would have cost" is what an operator +// reads afterwards. The one thing it must not carry is a barcode. +func TestPrepareStillPricesARefusedWeighing(t *testing.T) { + in := nominalWeighing() + in.Measurement.Gross = 8 // refused by rule 8, no derogation + + prep := mustPrepare(t, in) + if prep.Label != nil { + t.Fatalf("a printable label was produced: %+v", prep.Label) + } + if prep.Priced.Barcode != "" { + t.Errorf("barcode = %q, want none: nothing may be printed", prep.Priced.Barcode) + } + if prep.Priced.NetWeight != 8 || prep.Priced.Product.ID != "4412" { + t.Errorf("priced weighing = %d g of product %q, want 8 g of 4412", + prep.Priced.NetWeight, prep.Priced.Product.ID) + } + if len(prep.Priced.Lines) != 2 { + t.Fatalf("%d price lines, want 2: weighing_lines is mandatory", len(prep.Priced.Lines)) + } + if prep.Priced.Lines[0].Amount != 4 || prep.Priced.Lines[1].Amount != 4 { + t.Errorf("amounts = %d / %d c, want 4 / 4", + prep.Priced.Lines[0].Amount, prep.Priced.Lines[1].Amount) + } +} + +// TestPrepareTouchesNothingItWasGiven is what "pure" means for a struct argument: +// the caller's product, measurement, grid and decision come back untouched, so the +// immutable catalog snapshot cannot be modified through a weighing. +func TestPrepareTouchesNothingItWasGiven(t *testing.T) { + in := nominalWeighing() + in.Measurement.Tare = 50 + in.Decision = &LocalDecision{ProductID: "4412", Offered: true, MinWeightG: waiver(5)} + before := in + + if _, err := Prepare(in); err != nil { + t.Fatalf("Prepare: %v", err) + } + if !reflect.DeepEqual(in, before) { + t.Errorf("Prepare modified its input:\n before %+v\n after %+v", before, in) + } +} + +// TestPrepareQuantizesOnceForDisplayPriceAndBarcode is the coherence §6.2 demands, +// checked on the only quantity that can betray it. +// +// The shipped plan declares 3 kilogram decimals, so the quantization is the +// identity on every real product: the assertion below is therefore about the +// POLICY, exercised directly. Rounding a mass half-up would encode matter that was +// never on the plate -- 1,236 kg becoming 1,24 kg -- and the till would charge for +// it. A6 arbitrates the rounding of money; a mass follows the customer. +func TestPrepareQuantizesOnceForDisplayPriceAndBarcode(t *testing.T) { + for _, plan := range internalPlan { + if plan.Mode == ByWeight && plan.Decimals != 3 { + t.Errorf("prefix %s declares %d decimals: the vectors below assume 3", + plan.Prefix, plan.Decimals) + } + } + cases := []struct { + decimals int + want Grams + }{ + {3, 1236}, // the shipped plan: the identity + {2, 1230}, // never 1240 + {1, 1200}, + {0, 1000}, + } + for _, c := range cases { + if got := Quantize(1236, c.decimals, weightQuantization); got != c.want { + t.Errorf("Quantize(1236, %d) = %d g, want %d -- a quantized mass is never "+ + "heavier than the mass measured", c.decimals, got, c.want) + } + } +} + +// TestPayloadStepsCoverTheShippedPlan is the start-up self-check, exercised on a +// deliberately broken plan rather than by restarting a process -- the same shape as +// the plan check of §6.2 (T29, T30). +func TestPayloadStepsCoverTheShippedPlan(t *testing.T) { + if err := validatePayloadSteps(internalPlan); err != nil { + t.Fatalf("the shipped plan is out of reach of the encoder: %v", err) + } + broken := map[string]PrefixPlan{ + // Four kilogram decimals means a payload counting tenths of a gram, which a + // whole-gram mass cannot express. + "0493": {"0493", ByWeight, 3, 5, 4, " €/kg"}, + } + if err := validatePayloadSteps(broken); err == nil { + t.Error("a plan asking for a tenth of a gram was accepted") + } + // A by-unit plan counts items, not mass: its decimals are not consulted. + unit := map[string]PrefixPlan{"0499": {"0499", ByUnit, 6, 2, 0, " € l'unité"}} + if err := validatePayloadSteps(unit); err != nil { + t.Errorf("the by-unit plan was refused: %v", err) + } +} + +// TestWithoutCodeLeavesItsInputAlone guards the one filtered diagnostic: the caller +// of a filter must not have its slice rewritten underneath, because FirstBlocking +// hands out pointers into it. +func TestWithoutCodeLeavesItsInputAlone(t *testing.T) { + original := []Diagnostic{ + {Code: CodeScaleEmpty, Severity: Blocking}, + {Code: CodeZeroPrice, Severity: Blocking}, + } + filtered := withoutCode(original, CodeScaleEmpty) + if len(filtered) != 1 || filtered[0].Code != CodeZeroPrice { + t.Fatalf("filtered = %v, want [%s]", codesOf(filtered), CodeZeroPrice) + } + if len(original) != 2 || original[0].Code != CodeScaleEmpty { + t.Errorf("the input was rewritten: %v", codesOf(original)) + } + if got := withoutCode(original, CodeOverload); len(got) != 2 { + t.Errorf("filtering an absent code dropped something: %v", codesOf(got)) + } +} diff --git a/internal/domain/prepare_test.go b/internal/domain/prepare_test.go index f1531b0..612eb3f 100644 --- a/internal/domain/prepare_test.go +++ b/internal/domain/prepare_test.go @@ -1,176 +1,17 @@ +// This file holds scenarios 1 to 9 of §16.1 -- the ones where a WEIGHING is judged: +// the nominal sale by weight and by unit, the tare, the light product and its +// waiver, the missing basket, the two stability modes and the expired measurement. +// +// Every one of them checks the Label FIELD BY FIELD, and the blocking ones check +// that no Label exists at all. + package domain import ( - "errors" - "reflect" "testing" "time" ) -// The seventeen scenarios of §16.1. Every one of them checks the Label FIELD BY -// FIELD, and the blocking ones check that no Label exists at all. -// -// The reference vector is the one of §6.3: garlic() -- id 4412, AIL VIOLET BIO, -// 532 c/kg, pattern 0493021000003 -- weighed at 1236 g with no tare, on the La -// Cagette grid with commercial rounding. -// -// ONE HONEST WARNING ABOUT THAT VECTOR, verified against the fixtures: its -// ARITHMETIC is exact and reproduced below to the cent and to the digit, but its -// IDENTITY is synthetic. In testdata/catalog/flv.csv the code 0493021000003 belongs -// to id 1153, CELERI BRANCHE SAF, at 3,35 €/kg; no row of either fixture is priced -// 5,32 €/kg, and no row carries id 4412. Nobody should ever "correct" the price to -// 3,35: the vector is a CALCULATION vector, and the whole document depends on the -// numbers 658 / 592 / 479 and on the barcode 0493021012365. - -// garlicShoot is the by-unit product, and it is a REAL row of flv.csv: id 1620, -// AILLET (NON BIO), 1,56 € the unit, prefix 0499 and the `unite` column agreeing -// with it for once. -func garlicShoot() Product { - return Product{ - ID: "1620", Name: "AILLET (NON BIO)", Reference: EAN13("0499000046000"), - Mode: ByUnit, PriceSuffix: " € l'unité", UnitPrice: 156, - CategoryCode: "L", Qualification: Weighable, - } -} - -// nominalWeighing is the reference vector as Prepare receives it: a stable frame, -// an age well inside the derived expiry, advisory stability. -func nominalWeighing() PrepareInput { - return PrepareInput{ - Product: garlic(), - Measurement: Measurement{Gross: 1236, Stability: Stable, Timestamp: at(0), Seq: 4}, - Rules: LaCagetteRules(), - Limits: laCagetteLimits(), - MeasurementAge: 400 * time.Millisecond, - Expiry: 1200 * time.Millisecond, - StabilityBlocking: false, - JobID: "01J9F2ABCDEFGHJKMNPQRSTV", - } -} - -// wantLine is one expected price line: the tier, the DERIVED unit price and the -// amount. -type wantLine struct { - tier string - unitPrice Cents - amount Cents -} - -// wantLabel is a label expected field by field. Nothing is left implicit -- a field -// absent from this struct is a field the test would not have checked. -type wantLabel struct { - productID string - mode SaleMode - gross Grams - tare Grams - net Grams - quantity int - lines []wantLine - primary string - reference string - barcode EAN13 - jobID string -} - -// checkLabel compares a produced label with an expected one, field by field, and -// checks the two pointers really point INTO Lines rather than at copies. -func checkLabel(t *testing.T, got *Label, want wantLabel) { - t.Helper() - if got == nil { - t.Fatal("no label produced, want one") - } - if got.Product.ID != want.productID { - t.Errorf("product id = %q, want %q", got.Product.ID, want.productID) - } - if got.Mode != want.mode { - t.Errorf("mode = %s, want %s", got.Mode, want.mode) - } - if got.GrossWeight != want.gross { - t.Errorf("gross = %d g, want %d", got.GrossWeight, want.gross) - } - if got.Tare != want.tare { - t.Errorf("tare = %d g, want %d", got.Tare, want.tare) - } - if got.NetWeight != want.net { - t.Errorf("net = %d g, want %d", got.NetWeight, want.net) - } - if got.Quantity != want.quantity { - t.Errorf("quantity = %d, want %d", got.Quantity, want.quantity) - } - if got.Barcode != want.barcode { - t.Errorf("barcode = %q, want %q", got.Barcode, want.barcode) - } - if got.JobID != want.jobID { - t.Errorf("job id = %q, want %q", got.JobID, want.jobID) - } - if len(got.Lines) != len(want.lines) { - t.Fatalf("%d price lines, want %d", len(got.Lines), len(want.lines)) - } - for i, line := range want.lines { - if got.Lines[i].Tier.Code != line.tier { - t.Errorf("line %d: tier = %q, want %q", i, got.Lines[i].Tier.Code, line.tier) - } - if got.Lines[i].UnitPrice != line.unitPrice { - t.Errorf("line %d (%s): unit price = %d c, want %d", - i, line.tier, got.Lines[i].UnitPrice, line.unitPrice) - } - if got.Lines[i].Amount != line.amount { - t.Errorf("line %d (%s): amount = %d c, want %d", - i, line.tier, got.Lines[i].Amount, line.amount) - } - } - if got.PrimaryLine == nil || got.PrimaryLine.Tier.Code != want.primary { - t.Fatalf("primary line = %+v, want tier %q", got.PrimaryLine, want.primary) - } - if got.ReferenceLine == nil || got.ReferenceLine.Tier.Code != want.reference { - t.Fatalf("reference line = %+v, want tier %q", got.ReferenceLine, want.reference) - } - // The two pointers must address the slice itself: a copy could drift from it. - if got.PrimaryLine != got.Find(want.primary) { - t.Error("the primary line is a copy and not a pointer into Lines") - } -} - -// mustPrepare fails the test on an error and returns the preparation. -func mustPrepare(t *testing.T, in PrepareInput) Preparation { - t.Helper() - prep, err := Prepare(in) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - return prep -} - -// waiver is the per-product light-product derogation of §10.6. -func waiver(g Grams) *Grams { return &g } - -// checkExclusive is the invariant every scenario shares: a label exists exactly -// when nothing blocked it. -func checkExclusive(t *testing.T, prep Preparation) { - t.Helper() - if prep.Refusal != nil && prep.Label != nil { - t.Errorf("a label was produced while %s blocks it", prep.Refusal.Code) - } - if prep.Refusal == nil && prep.Label == nil { - t.Error("no label and nothing blocking it: one of the two is wrong") - } - if prep.Refusal != nil && prep.Refusal != FirstBlocking(prep.Diagnostics) { - t.Error("Refusal is not the first blocking diagnostic of Diagnostics") - } - // The journal row exists whatever happened; the barcode only when something may - // be printed. - if prep.Priced.Product.ID == "" { - t.Error("nothing was priced: a refused weighing is still a journal row (§12.3)") - } - if prep.Refusal != nil && prep.Priced.Barcode != "" { - t.Errorf("a refused weighing carries the barcode %q", prep.Priced.Barcode) - } - if prep.Label != nil && prep.Priced.Barcode != prep.Label.Barcode { - t.Errorf("priced barcode %q, printable barcode %q: they must be the same weighing", - prep.Priced.Barcode, prep.Label.Barcode) - } -} - // --- 1. Nominal, by weight: the reference vector of §6.3 -------------------- // TestPrepareNominalByWeight is the vector the whole document is built on. Every @@ -543,573 +384,3 @@ func TestPrepareExpiryBoundaryAndBothStabilityModes(t *testing.T) { } } } - -// --- 10. Manual entry ------------------------------------------------------ - -// TestPrepareManualEntryYieldsTheIdenticalLabel is guiding principle 1, stated as -// an equality. A connected scale, an absent scale and a keypad change the SOURCE OF -// THE WEIGHT and nothing else: at 1236 g the label is the SAME label, to the last -// field. -// -// This is the test that would have caught the first functional risk of the legacy -// application, where the member discount existed in the automatic path and in none -// of the three keypads. -func TestPrepareManualEntryYieldsTheIdenticalLabel(t *testing.T) { - fromScale := mustPrepare(t, nominalWeighing()) - - manual := nominalWeighing() - // A manual entry carries no stability, no sequence and no age: it is latched by - // construction and there is no frame to grow stale. - manual.Measurement = Measurement{Gross: 1236, Stability: StabilityNotApplicable} - manual.MeasurementAge = 0 - fromKeypad := mustPrepare(t, manual) - - if !reflect.DeepEqual(*fromScale.Label, *fromKeypad.Label) { - t.Fatalf("the keypad label differs from the scale label:\n scale %+v\n keypad %+v", - *fromScale.Label, *fromKeypad.Label) - } - if len(fromKeypad.Diagnostics) != 0 { - t.Errorf("diagnostics = %v, want none", codesOf(fromKeypad.Diagnostics)) - } -} - -// --- 11. The weight moved between the display and the tap ------------------ - -// TestPrepareFreezesTheWeightItWasGiven is the "poids changé" scenario. -// -// Prepare is a pure function of the measurement it is handed, so a frame arriving -// after the tap CANNOT change a label already computed: that is what makes -// principle 4 ("the weight is frozen at validation, never read again") a property -// of the code instead of a promise. -// -// And the second half, which is the one the legacy application failed: the mass -// DISPLAYED, the mass PRICED and the mass ENCODED all come from a single -// quantization, so they move together or not at all. The old code applied its -// Decimales_Poids setting to the banner and not to the encoding, and could show -// 1,23 kg while encoding 1,236 kg. -func TestPrepareFreezesTheWeightItWasGiven(t *testing.T) { - seen := mustPrepare(t, nominalWeighing()) - - moved := nominalWeighing() - moved.Measurement.Gross = 1240 - moved.Measurement.Seq = 5 - after := mustPrepare(t, moved) - - // The first label did not follow the scale. - if seen.Label.NetWeight != 1236 || seen.Label.Barcode != "0493021012365" { - t.Fatalf("the frozen label moved: net = %d, barcode = %q", - seen.Label.NetWeight, seen.Label.Barcode) - } - // The second one moved EVERYWHERE, consistently. - if after.Label.NetWeight != 1240 { - t.Errorf("net = %d g, want 1240", after.Label.NetWeight) - } - if after.Label.Barcode != "0493021012402" { - t.Errorf("barcode = %q, want 0493021012402", after.Label.Barcode) - } - if after.Label.PrimaryLine.Amount != 594 { // 479 x 1240 / 1000 = 593,96 - t.Errorf("member amount = %d c, want 594", after.Label.PrimaryLine.Amount) - } - // The printed mass and the encoded payload are the same number, on both labels. - for _, prep := range []Preparation{seen, after} { - encoded := string(prep.Label.Barcode)[7:12] - if want := pad(int(prep.Label.NetWeight), 5); encoded != want { - t.Errorf("label shows %d g and encodes %q, want %q", - prep.Label.NetWeight, encoded, want) - } - } -} - -// --- 12. Overload ---------------------------------------------------------- - -// TestPrepareOverload checks both halves of rule 1 -- the OL flag of the frame and -// the arithmetic bound -- and the NORMATIVE ORDER: OVERLOAD is the message shown, -// even though the net weight also exceeds the capacity of the barcode field. -func TestPrepareOverload(t *testing.T) { - t.Run("the frame says OL", func(t *testing.T) { - in := nominalWeighing() - in.Measurement.Overload = true // a saturated scale may report ANY plausible mass - prep := mustPrepare(t, in) - checkExclusive(t, prep) - if prep.Label != nil { - t.Errorf("a label was produced on an overload: %+v", prep.Label) - } - if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeOverload}) { - t.Fatalf("diagnostics = %v, want exactly [%s]", got, CodeOverload) - } - if prep.Refusal.Message != "La balance est en surcharge. Retirez votre article." { - t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) - } - }) - t.Run("beyond the capacity of the field", func(t *testing.T) { - in := nominalWeighing() - in.Measurement.Gross = 150_000 - prep := mustPrepare(t, in) - checkExclusive(t, prep) - if prep.Label != nil { - t.Errorf("a label was produced above max_weight_g: %+v", prep.Label) - } - if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeOverload, CodeWeightTooHigh}) { - t.Fatalf("diagnostics = %v, want [%s %s] in that order", - got, CodeOverload, CodeWeightTooHigh) - } - }) -} - -// TestPrepareEncodesTheHeaviestAdmissibleWeight is the other side of rule 9's `>`: -// max_weight_g is the CAPACITY of the NNDDD field, so it must stay reachable -// through the whole chain and not only through Generate (vector T4). -func TestPrepareEncodesTheHeaviestAdmissibleWeight(t *testing.T) { - in := nominalWeighing() - in.Measurement.Gross = 99_999 - - prep := mustPrepare(t, in) - checkExclusive(t, prep) - if prep.Label == nil { - t.Fatalf("99,999 kg refused: %v", codesOf(prep.Diagnostics)) - } - if prep.Label.Barcode != "0493021999994" { - t.Errorf("barcode = %q, want 0493021999994", prep.Label.Barcode) - } -} - -// --- 13. A null price ------------------------------------------------------ - -// TestPrepareZeroPrice is rule 12, and it is a backstop rather than a filter: the -// import already refuses a product priced at zero. It stays evaluated because the -// price can also become zero AFTER the catalog -- a tier configured at 100 % is -// accepted by the configuration checks and would turn every article free. -func TestPrepareZeroPrice(t *testing.T) { - cases := []struct { - name string - mutil func(*PrepareInput) - }{ - {"the catalog price is zero", func(in *PrepareInput) { in.Product.UnitPrice = 0 }}, - {"the primary tier is free", func(in *PrepareInput) { - rules := LaCagetteRules() - rules.Tiers[0].Discount = FullDiscount - in.Rules = rules - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - in := nominalWeighing() - c.mutil(&in) - prep := mustPrepare(t, in) - checkExclusive(t, prep) - if prep.Label != nil { - t.Errorf("a label was produced at 0 €: %+v", prep.Label) - } - if prep.Refusal == nil || prep.Refusal.Code != CodeZeroPrice { - t.Fatalf("refusal = %v, want %s", prep.Refusal, CodeZeroPrice) - } - if prep.Refusal.Message != "Prix nul. Appelez un bénévole." { - t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) - } - }) - } -} - -// --- 14. The reserved zone is occupied ------------------------------------ - -// TestPrepareRefusesAnOccupiedReservedZone is the second line of defence behind the -// import. 0493100100006 is a REAL code of flv.csv -- id 5115, ♥AA-TOMME DE SAVOIE -// -MV -- whose digits 8 to 12 are not zero: the reference would overflow into the -// weight field. Read by the till as 3 reference digits plus 5 weight digits, the -// label printed at 1,236 kg would announce PATATE DOUCE SAF at 11,236 kg. A factor -// ten on the mass AND a silent substitution of article (§6.2, T32). -// -// The import marks such a product an anomaly, so it has no tile. This test forces -// the mislabelled case -- a product declared Weighable with that reference -- and -// requires an error rather than a label. -func TestPrepareRefusesAnOccupiedReservedZone(t *testing.T) { - in := nominalWeighing() - in.Product.ID = "5115" - in.Product.Name = "♥AA-TOMME DE SAVOIE -MV" - in.Product.Reference = mustPattern(t, "0493100100006") - in.Product.UnitPrice = 2575 - - prep, err := Prepare(in) - if !errors.Is(err, ErrPatternNotZeroed) { - t.Fatalf("error = %v, want ErrPatternNotZeroed", err) - } - if prep.Label != nil { - t.Errorf("a label was produced: %+v", prep.Label) - } - // The diagnostics that were evaluated are still handed back: an operator reading - // a refusal needs to see what was checked, not only what failed. - if prep.Diagnostics == nil { - t.Log("no diagnostic on this weighing, which is expected: nothing else was wrong") - } -} - -// --- 15. A code the scale cannot encode ----------------------------------- - -// TestPrepareRefusesACodeThatIsNotWeighable covers the two doors of step 1: the -// qualification the import computed, and the numbering plan itself. -// -// A supplier EAN-13 is not an error -- it is a prepackaged product, and calling it -// invalid is what mislabelled 30 % of a real catalog (ADR-021). It simply has no -// tile, so Prepare must never be asked about it, and says so. -func TestPrepareRefusesACodeThatIsNotWeighable(t *testing.T) { - cases := []struct { - name string - product Product - wantError error - }{ - { - name: "an internal code 0491, not weighable", - product: Product{ - ID: "77", Name: "CODE INTERNE", Reference: mustPattern(t, "0491000000006"), - Mode: ByWeight, UnitPrice: 500, - Qualification: NotWeighable, Reason: FindingInternalCodeNotWeighable, - }, - wantError: ErrProductNotWeighable, - }, - { - name: "a supplier EAN, prepackaged", - product: Product{ - ID: "78", Name: "PREEMBALLE", Reference: mustPattern(t, "3760091721938"), - Mode: ByWeight, UnitPrice: 500, - Qualification: NotWeighable, Reason: FindingPrepackagedProduct, - }, - wantError: ErrProductNotWeighable, - }, - { - name: "an anomaly the import flagged", - product: Product{ - ID: "79", Name: "ANOMALIE", Reference: mustPattern(t, "0493100100006"), - Mode: ByWeight, UnitPrice: 500, - Qualification: Anomaly, Reason: FindingReservedZoneNotEmpty, - }, - wantError: ErrProductNotWeighable, - }, - { - // The qualification lies: the prefix has no entry in the plan, so there is - // no field layout to write into. - name: "a prefix outside the plan, wrongly qualified", - product: Product{ - ID: "80", Name: "HORS PLAN", Reference: mustPattern(t, "0491000000006"), - Mode: ByWeight, UnitPrice: 500, Qualification: Weighable, - }, - wantError: ErrPrefixNotInPlan, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - in := nominalWeighing() - in.Product = c.product - prep, err := Prepare(in) - if !errors.Is(err, c.wantError) { - t.Fatalf("error = %v, want %v", err, c.wantError) - } - if prep.Label != nil || prep.Refusal != nil { - t.Errorf("preparation = %+v, want nothing at all", prep) - } - }) - } -} - -// TestPrepareRefusesAModeContradictingThePrefix is the one place the two sources of -// the sale mode can disagree. The prefix wins everywhere else, so here the -// contradiction is refused rather than arbitrated: Price switches on Product.Mode -// while the payload is laid out by the plan, and a product priced by the unit while -// encoded by the gram would print a coherent label announcing the wrong thing. -func TestPrepareRefusesAModeContradictingThePrefix(t *testing.T) { - in := nominalWeighing() - in.Product.Reference = mustPattern(t, "0499000046000") - in.Product.Mode = ByWeight // the plan of 0499 sells by unit - - _, err := Prepare(in) - if !errors.Is(err, ErrPrefixModeMismatch) { - t.Fatalf("error = %v, want ErrPrefixModeMismatch", err) - } -} - -// TestPrepareRefusesAnInconsistentGrid checks that a price grid that cannot be -// applied comes back as an error and never as a panic in the Hub goroutine. -func TestPrepareRefusesAnInconsistentGrid(t *testing.T) { - in := nominalWeighing() - rules := LaCagetteRules() - rules.PrimaryCode = "ABSENT" - in.Rules = rules - - prep, err := Prepare(in) - if !errors.Is(err, ErrInconsistentTiers) { - t.Fatalf("error = %v, want ErrInconsistentTiers", err) - } - if prep.Label != nil { - t.Errorf("a label was produced on an inconsistent grid: %+v", prep.Label) - } -} - -// --- 16. The product is not offered locally ------------------------------- - -// TestPrepareProductNotOfferedLocally is rule 14 and ADR-017. The case it exists -// for is the one no import rule can detect: a reference that is irreproachable -- -// 13 digits, right check digit, reserved zone empty, coherent prefix -- and wrong at -// heart. It is a JUDGEMENT, so it produces a French message rather than an error. -func TestPrepareProductNotOfferedLocally(t *testing.T) { - in := nominalWeighing() - in.Decision = &LocalDecision{ - ProductID: "4412", Offered: false, - Reason: "le code appartient à un autre article", - } - - prep := mustPrepare(t, in) - checkExclusive(t, prep) - if prep.Label != nil { - t.Errorf("a label was produced for a withdrawn product: %+v", prep.Label) - } - if got := codesOf(prep.Diagnostics); !sameStrings(got, []string{CodeProductWithdrawn}) { - t.Fatalf("diagnostics = %v, want exactly [%s]", got, CodeProductWithdrawn) - } - if prep.Refusal.Message != "Ce produit n'est pas disponible." { - t.Errorf("message = %q, want the French wording of §6.4", prep.Refusal.Message) - } -} - -// TestPrepareOffersWhatNoHumanDecidedAbout is the other half of the same rule: an -// absent row of local_decisions is not a refusal. Almost no product has one. -func TestPrepareOffersWhatNoHumanDecidedAbout(t *testing.T) { - for _, decision := range []*LocalDecision{ - nil, - {ProductID: "4412", Offered: true}, - {ProductID: "4412", Offered: true, MinWeightG: waiver(5)}, - } { - in := nominalWeighing() - in.Decision = decision - prep := mustPrepare(t, in) - if prep.Label == nil { - t.Fatalf("decision %+v: no label, diagnostics %v", decision, codesOf(prep.Diagnostics)) - } - if prep.Label.Barcode != "0493021012365" { - t.Errorf("decision %+v: barcode = %q", decision, prep.Label.Barcode) - } - } -} - -// --- 17. A reprint --------------------------------------------------------- - -// TestPrepareIsDeterministicSoAReprintCannotDiffer is the reprint scenario. -// -// A reprint sends the SAME label again, with the RÉIMPRESSION wording added by the -// template (§8.5) -- and the domain guarantee underneath it is that preparing the -// same weighing twice yields the same label, to the cent and to the digit. Nothing -// here reads a clock, a counter or a random source, so there is no way for a second -// label to disagree with the first about what the customer owes. -// -// The second half is not decoration: Label carries a slice and two pointers INTO -// it, and it is handed to the print worker and to the journal worker. Two -// preparations must share nothing. -func TestPrepareIsDeterministicSoAReprintCannotDiffer(t *testing.T) { - first := mustPrepare(t, nominalWeighing()) - second := mustPrepare(t, nominalWeighing()) - - if !reflect.DeepEqual(*first.Label, *second.Label) { - t.Fatalf("two preparations of one weighing differ:\n %+v\n %+v", - *first.Label, *second.Label) - } - first.Label.Lines[0].Amount = 1 - if second.Label.Lines[0].Amount != 592 { - t.Error("the two labels share their price lines: a reprint could rewrite the original") - } - if second.Label.PrimaryLine.Amount != 592 { - t.Error("the primary line of one label points into the other") - } -} - -// --- 18. A single tier ---------------------------------------------------- - -// TestPrepareSingleTier is contraint 8 made structural: dual pricing is the -// CARDINALITY of Tiers, not a boolean. One tier means one price line, and the -// barcode does not move -- the payload carries a mass, never a price. -func TestPrepareSingleTier(t *testing.T) { - in := nominalWeighing() - in.Rules = SingleTierRules() - - prep := mustPrepare(t, in) - checkExclusive(t, prep) - checkLabel(t, prep.Label, wantLabel{ - productID: "4412", - mode: ByWeight, - gross: 1236, - net: 1236, - lines: []wantLine{{"STANDARD", 532, 658}}, - primary: "STANDARD", - reference: "STANDARD", - barcode: "0493021012365", - jobID: "01J9F2ABCDEFGHJKMNPQRSTV", - }) -} - -// --- The properties the seventeen scenarios share -------------------------- - -// TestPrepareNeverProducesALabelAndARefusal walks every scenario shape at once. It -// is the invariant that makes "a blocking diagnostic means no label" checkable -// rather than asserted: not a label flagged as refused, not a label with an empty -// barcode -- no label, because a Label that exists is a Label something downstream -// may print. -func TestPrepareNeverProducesALabelAndARefusal(t *testing.T) { - inputs := map[string]func(*PrepareInput){ - "nominal": func(*PrepareInput) {}, - "tare": func(in *PrepareInput) { in.Measurement.Tare = 50 }, - "invalid tare": func(in *PrepareInput) { in.Measurement.Tare = 1300 }, - "empty scale": func(in *PrepareInput) { in.Measurement.Gross = 0 }, - "basket missing": func(in *PrepareInput) { in.Measurement.Gross = -275 }, - "below the empty band": func(in *PrepareInput) { in.Measurement.Gross = -1000 }, - "light product": func(in *PrepareInput) { in.Measurement.Gross = 8 }, - "overload": func(in *PrepareInput) { in.Measurement.Overload = true }, - "too heavy": func(in *PrepareInput) { in.Measurement.Gross = 100_000 }, - "expired": func(in *PrepareInput) { in.MeasurementAge = time.Minute }, - "unstable advisory": func(in *PrepareInput) { in.Measurement.Stability = Unstable }, - "unstable blocking": func(in *PrepareInput) { - in.Measurement.Stability, in.StabilityBlocking = Unstable, true - }, - "withdrawn": func(in *PrepareInput) { - in.Decision = &LocalDecision{ProductID: "4412"} - }, - "zero price": func(in *PrepareInput) { in.Product.UnitPrice = 0 }, - "single tier": func(in *PrepareInput) { in.Rules = SingleTierRules() }, - "by unit": func(in *PrepareInput) { in.Product, in.Measurement = garlicShoot(), Measurement{Quantity: 2} }, - "zero units": func(in *PrepareInput) { in.Product, in.Measurement = garlicShoot(), Measurement{} }, - "too many units": func(in *PrepareInput) { - in.Product, in.Measurement = garlicShoot(), Measurement{Quantity: 100} - }, - } - for name, mutate := range inputs { - t.Run(name, func(t *testing.T) { - in := nominalWeighing() - mutate(&in) - prep, err := Prepare(in) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - checkExclusive(t, prep) - // Whatever happens, a produced label carries a barcode: there is no such - // thing as a half-built label. - if prep.Label != nil && prep.Label.Barcode == "" { - t.Error("a label was produced without a barcode") - } - }) - } -} - -// TestPrepareStillPricesARefusedWeighing is §12.3 seen from the domain. A refusal -// is a journal row like any other and weighing_lines is mandatory: "at 8 g this -// product was refused, and here is what it would have cost" is what an operator -// reads afterwards. The one thing it must not carry is a barcode. -func TestPrepareStillPricesARefusedWeighing(t *testing.T) { - in := nominalWeighing() - in.Measurement.Gross = 8 // refused by rule 8, no derogation - - prep := mustPrepare(t, in) - if prep.Label != nil { - t.Fatalf("a printable label was produced: %+v", prep.Label) - } - if prep.Priced.Barcode != "" { - t.Errorf("barcode = %q, want none: nothing may be printed", prep.Priced.Barcode) - } - if prep.Priced.NetWeight != 8 || prep.Priced.Product.ID != "4412" { - t.Errorf("priced weighing = %d g of product %q, want 8 g of 4412", - prep.Priced.NetWeight, prep.Priced.Product.ID) - } - if len(prep.Priced.Lines) != 2 { - t.Fatalf("%d price lines, want 2: weighing_lines is mandatory", len(prep.Priced.Lines)) - } - if prep.Priced.Lines[0].Amount != 4 || prep.Priced.Lines[1].Amount != 4 { - t.Errorf("amounts = %d / %d c, want 4 / 4", - prep.Priced.Lines[0].Amount, prep.Priced.Lines[1].Amount) - } -} - -// TestPrepareTouchesNothingItWasGiven is what "pure" means for a struct argument: -// the caller's product, measurement, grid and decision come back untouched, so the -// immutable catalog snapshot cannot be modified through a weighing. -func TestPrepareTouchesNothingItWasGiven(t *testing.T) { - in := nominalWeighing() - in.Measurement.Tare = 50 - in.Decision = &LocalDecision{ProductID: "4412", Offered: true, MinWeightG: waiver(5)} - before := in - - if _, err := Prepare(in); err != nil { - t.Fatalf("Prepare: %v", err) - } - if !reflect.DeepEqual(in, before) { - t.Errorf("Prepare modified its input:\n before %+v\n after %+v", before, in) - } -} - -// TestPrepareQuantizesOnceForDisplayPriceAndBarcode is the coherence §6.2 demands, -// checked on the only quantity that can betray it. -// -// The shipped plan declares 3 kilogram decimals, so the quantization is the -// identity on every real product: the assertion below is therefore about the -// POLICY, exercised directly. Rounding a mass half-up would encode matter that was -// never on the plate -- 1,236 kg becoming 1,24 kg -- and the till would charge for -// it. A6 arbitrates the rounding of money; a mass follows the customer. -func TestPrepareQuantizesOnceForDisplayPriceAndBarcode(t *testing.T) { - for _, plan := range internalPlan { - if plan.Mode == ByWeight && plan.Decimals != 3 { - t.Errorf("prefix %s declares %d decimals: the vectors below assume 3", - plan.Prefix, plan.Decimals) - } - } - cases := []struct { - decimals int - want Grams - }{ - {3, 1236}, // the shipped plan: the identity - {2, 1230}, // never 1240 - {1, 1200}, - {0, 1000}, - } - for _, c := range cases { - if got := Quantize(1236, c.decimals, weightQuantization); got != c.want { - t.Errorf("Quantize(1236, %d) = %d g, want %d -- a quantized mass is never "+ - "heavier than the mass measured", c.decimals, got, c.want) - } - } -} - -// TestPayloadStepsCoverTheShippedPlan is the start-up self-check, exercised on a -// deliberately broken plan rather than by restarting a process -- the same shape as -// the plan check of §6.2 (T29, T30). -func TestPayloadStepsCoverTheShippedPlan(t *testing.T) { - if err := validatePayloadSteps(internalPlan); err != nil { - t.Fatalf("the shipped plan is out of reach of the encoder: %v", err) - } - broken := map[string]PrefixPlan{ - // Four kilogram decimals means a payload counting tenths of a gram, which a - // whole-gram mass cannot express. - "0493": {"0493", ByWeight, 3, 5, 4, " €/kg"}, - } - if err := validatePayloadSteps(broken); err == nil { - t.Error("a plan asking for a tenth of a gram was accepted") - } - // A by-unit plan counts items, not mass: its decimals are not consulted. - unit := map[string]PrefixPlan{"0499": {"0499", ByUnit, 6, 2, 0, " € l'unité"}} - if err := validatePayloadSteps(unit); err != nil { - t.Errorf("the by-unit plan was refused: %v", err) - } -} - -// TestWithoutCodeLeavesItsInputAlone guards the one filtered diagnostic: the caller -// of a filter must not have its slice rewritten underneath, because FirstBlocking -// hands out pointers into it. -func TestWithoutCodeLeavesItsInputAlone(t *testing.T) { - original := []Diagnostic{ - {Code: CodeScaleEmpty, Severity: Blocking}, - {Code: CodeZeroPrice, Severity: Blocking}, - } - filtered := withoutCode(original, CodeScaleEmpty) - if len(filtered) != 1 || filtered[0].Code != CodeZeroPrice { - t.Fatalf("filtered = %v, want [%s]", codesOf(filtered), CodeZeroPrice) - } - if len(original) != 2 || original[0].Code != CodeScaleEmpty { - t.Errorf("the input was rewritten: %v", codesOf(original)) - } - if got := withoutCode(original, CodeOverload); len(got) != 2 { - t.Errorf("filtering an absent code dropped something: %v", codesOf(got)) - } -} diff --git a/internal/domain/preparefixture_test.go b/internal/domain/preparefixture_test.go new file mode 100644 index 0000000..8555d11 --- /dev/null +++ b/internal/domain/preparefixture_test.go @@ -0,0 +1,174 @@ +// This file holds the REFERENCE VECTOR the seventeen scenarios start from, and the +// field-by-field readers they check a label with. The paragraph below is the one +// that used to open the whole file, and it is what to read first. + +package domain + +import ( + "testing" + "time" +) + +// The seventeen scenarios of §16.1. Every one of them checks the Label FIELD BY +// FIELD, and the blocking ones check that no Label exists at all. +// +// The reference vector is the one of §6.3: garlic() -- id 4412, AIL VIOLET BIO, +// 532 c/kg, pattern 0493021000003 -- weighed at 1236 g with no tare, on the La +// Cagette grid with commercial rounding. +// +// ONE HONEST WARNING ABOUT THAT VECTOR, verified against the fixtures: its +// ARITHMETIC is exact and reproduced below to the cent and to the digit, but its +// IDENTITY is synthetic. In testdata/catalog/flv.csv the code 0493021000003 belongs +// to id 1153, CELERI BRANCHE SAF, at 3,35 €/kg; no row of either fixture is priced +// 5,32 €/kg, and no row carries id 4412. Nobody should ever "correct" the price to +// 3,35: the vector is a CALCULATION vector, and the whole document depends on the +// numbers 658 / 592 / 479 and on the barcode 0493021012365. + +// garlicShoot is the by-unit product, and it is a REAL row of flv.csv: id 1620, +// AILLET (NON BIO), 1,56 € the unit, prefix 0499 and the `unite` column agreeing +// with it for once. +func garlicShoot() Product { + return Product{ + ID: "1620", Name: "AILLET (NON BIO)", Reference: EAN13("0499000046000"), + Mode: ByUnit, PriceSuffix: " € l'unité", UnitPrice: 156, + CategoryCode: "L", Qualification: Weighable, + } +} + +// nominalWeighing is the reference vector as Prepare receives it: a stable frame, +// an age well inside the derived expiry, advisory stability. +func nominalWeighing() PrepareInput { + return PrepareInput{ + Product: garlic(), + Measurement: Measurement{Gross: 1236, Stability: Stable, Timestamp: at(0), Seq: 4}, + Rules: LaCagetteRules(), + Limits: laCagetteLimits(), + MeasurementAge: 400 * time.Millisecond, + Expiry: 1200 * time.Millisecond, + StabilityBlocking: false, + JobID: "01J9F2ABCDEFGHJKMNPQRSTV", + } +} + +// wantLine is one expected price line: the tier, the DERIVED unit price and the +// amount. +type wantLine struct { + tier string + unitPrice Cents + amount Cents +} + +// wantLabel is a label expected field by field. Nothing is left implicit -- a field +// absent from this struct is a field the test would not have checked. +type wantLabel struct { + productID string + mode SaleMode + gross Grams + tare Grams + net Grams + quantity int + lines []wantLine + primary string + reference string + barcode EAN13 + jobID string +} + +// checkLabel compares a produced label with an expected one, field by field, and +// checks the two pointers really point INTO Lines rather than at copies. +func checkLabel(t *testing.T, got *Label, want wantLabel) { + t.Helper() + if got == nil { + t.Fatal("no label produced, want one") + } + if got.Product.ID != want.productID { + t.Errorf("product id = %q, want %q", got.Product.ID, want.productID) + } + if got.Mode != want.mode { + t.Errorf("mode = %s, want %s", got.Mode, want.mode) + } + if got.GrossWeight != want.gross { + t.Errorf("gross = %d g, want %d", got.GrossWeight, want.gross) + } + if got.Tare != want.tare { + t.Errorf("tare = %d g, want %d", got.Tare, want.tare) + } + if got.NetWeight != want.net { + t.Errorf("net = %d g, want %d", got.NetWeight, want.net) + } + if got.Quantity != want.quantity { + t.Errorf("quantity = %d, want %d", got.Quantity, want.quantity) + } + if got.Barcode != want.barcode { + t.Errorf("barcode = %q, want %q", got.Barcode, want.barcode) + } + if got.JobID != want.jobID { + t.Errorf("job id = %q, want %q", got.JobID, want.jobID) + } + if len(got.Lines) != len(want.lines) { + t.Fatalf("%d price lines, want %d", len(got.Lines), len(want.lines)) + } + for i, line := range want.lines { + if got.Lines[i].Tier.Code != line.tier { + t.Errorf("line %d: tier = %q, want %q", i, got.Lines[i].Tier.Code, line.tier) + } + if got.Lines[i].UnitPrice != line.unitPrice { + t.Errorf("line %d (%s): unit price = %d c, want %d", + i, line.tier, got.Lines[i].UnitPrice, line.unitPrice) + } + if got.Lines[i].Amount != line.amount { + t.Errorf("line %d (%s): amount = %d c, want %d", + i, line.tier, got.Lines[i].Amount, line.amount) + } + } + if got.PrimaryLine == nil || got.PrimaryLine.Tier.Code != want.primary { + t.Fatalf("primary line = %+v, want tier %q", got.PrimaryLine, want.primary) + } + if got.ReferenceLine == nil || got.ReferenceLine.Tier.Code != want.reference { + t.Fatalf("reference line = %+v, want tier %q", got.ReferenceLine, want.reference) + } + // The two pointers must address the slice itself: a copy could drift from it. + if got.PrimaryLine != got.Find(want.primary) { + t.Error("the primary line is a copy and not a pointer into Lines") + } +} + +// mustPrepare fails the test on an error and returns the preparation. +func mustPrepare(t *testing.T, in PrepareInput) Preparation { + t.Helper() + prep, err := Prepare(in) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + return prep +} + +// waiver is the per-product light-product derogation of §10.6. +func waiver(g Grams) *Grams { return &g } + +// checkExclusive is the invariant every scenario shares: a label exists exactly +// when nothing blocked it. +func checkExclusive(t *testing.T, prep Preparation) { + t.Helper() + if prep.Refusal != nil && prep.Label != nil { + t.Errorf("a label was produced while %s blocks it", prep.Refusal.Code) + } + if prep.Refusal == nil && prep.Label == nil { + t.Error("no label and nothing blocking it: one of the two is wrong") + } + if prep.Refusal != nil && prep.Refusal != FirstBlocking(prep.Diagnostics) { + t.Error("Refusal is not the first blocking diagnostic of Diagnostics") + } + // The journal row exists whatever happened; the barcode only when something may + // be printed. + if prep.Priced.Product.ID == "" { + t.Error("nothing was priced: a refused weighing is still a journal row (§12.3)") + } + if prep.Refusal != nil && prep.Priced.Barcode != "" { + t.Errorf("a refused weighing carries the barcode %q", prep.Priced.Barcode) + } + if prep.Label != nil && prep.Priced.Barcode != prep.Label.Barcode { + t.Errorf("priced barcode %q, printable barcode %q: they must be the same weighing", + prep.Priced.Barcode, prep.Label.Barcode) + } +} diff --git a/internal/domain/reading.go b/internal/domain/reading.go new file mode 100644 index 0000000..637456f --- /dev/null +++ b/internal/domain/reading.go @@ -0,0 +1,130 @@ +package domain + +import ( + "fmt" + "time" +) + +// This file holds what a transition READS before it decides: the reading it is about +// to freeze, the product a tap names, and the four settings the machine consults by +// question rather than by field. +// +// Every function here is pure and answers ONE question, which is what lets the state +// handlers read like the table of §6.6 instead of like a configuration parser. + +// frozenWeight is what the machine knows about the reading it just froze and that +// the calculation cannot infer on its own. +// +// It is a value rather than three more parameters because the three travel +// together and are decided together, and because the third one has to be READ to +// be understood: StabilityBlocks is the EFFECTIVE severity of safeguard rule 6 at +// this instant, and not a copy of stability.mode. Exactly one place lowers it, and +// it says why. +type frozenWeight struct { + Measurement Measurement + Source string + Age time.Duration + StabilityBlocks bool +} + +// fromScale is the reading the plate is holding, with the age the Hub computed. +func fromScale(m Model, ctx TransitionContext) frozenWeight { + return frozenWeight{ + Measurement: m.frozen(ctx), + Source: SourceScale, + Age: ctx.MeasurementAge, + StabilityBlocks: blockingStability(ctx.Cfg), + } +} + +// byUnit is the reading of a by-unit sale: no mass at all, and no age. +// +// Not a zero weight some rule could interpret, but an explicit absence -- the +// stability says not_applicable, and Prepare drops the one rule that would still +// talk about a plate. The age is zero because there is no measurement to grow old: +// passing the age of whatever the scale last said would refuse every item sold by +// the piece after a quiet spell at the station. +func byUnit(units int, source string, ctx TransitionContext) frozenWeight { + return frozenWeight{ + Measurement: Measurement{ + Quantity: units, Stability: StabilityNotApplicable, Timestamp: ctx.Now, + }, + Source: source, + StabilityBlocks: blockingStability(ctx.Cfg), + } +} + +// weightMoved reports whether the mass changed between the frame the customer was +// looking at and the one about to be frozen. +// +// The tolerance is the latch's: below it the two frames describe the same bag, and +// refusing them would refuse every legitimate tap. Zero SeenWeight means the front +// declared none, and there is nothing to compare. +func weightMoved(m Model, ev ProductTapped, ctx TransitionContext) (bool, Grams, Grams) { + if ev.SeenWeight == 0 { + return false, 0, 0 + } + now := m.frozen(ctx).Gross + return abs(now-ev.SeenWeight) > ctx.Cfg.Stability.ToleranceGrams, ev.SeenWeight, now +} + +// presentOrStable reports which of the two weight states a folded model is in. +func presentOrStable(m Model) State { + if m.LatchState.Latched { + return WeightStable + } + return WeightPresent +} + +// emptyZone reports whether a mass is inside the "the scale is empty" band. +func emptyZone(g Grams, limits WeighingLimits) bool { return abs(g) <= limits.EmptyMax } + +// offered reports the product a tap names, and whether the station offers it. +// +// A product absent from the snapshot and a product the qualification kept out of +// the grid are one answer: no tile, therefore no label. The catalog may be nil -- +// a station still starting up has none -- and ByID already tolerates that. +func offered(catalog *Catalog, id string) (Product, bool) { + p, ok := catalog.ByID(id) + if !ok || p.Qualification != Weighable { + return Product{}, false + } + return p, true +} + +// blockingStability reports whether stability BLOCKS a print. The shipped default +// is advisory (A3, ADR-005). +func blockingStability(cfg Config) bool { return cfg.Stability.Mode == ModeBlocking } + +// manualOnly reports a station that declares it has no scale and allows manual +// entry -- the EXPLICIT and unique declaration of §11.2, which turns the light off +// instead of leaving it red. +func manualOnly(cfg Config) bool { + return !cfg.Scale.Present && cfg.Scale.ManualEntryAllowed +} + +// idleTimeout is how long a keypad entry survives without a touch. +func idleTimeout(cfg Config) time.Duration { + return time.Duration(cfg.UI.IdleTimeoutSeconds) * time.Second +} + +// reprintWindow is how long the permanent bottom bar stays active. +func reprintWindow(cfg Config) time.Duration { + return time.Duration(cfg.UI.ReprintWindowSeconds) * time.Second +} + +// deriveJobID mints the identifier of one print job from what the cycle carries. +// +// A pure function has neither entropy nor a clock of its own, so it cannot mint a +// ULID -- and it does not have to: the front generates one at pointerdown and +// sends it as the idempotency key (§4), which is unique per touch, exactly what +// weighings.job_id needs. When no key travels -- a command injected without a +// caller, a troubleshooting reprint -- the identifier is DERIVED from the instant +// and the measurement sequence: unique on one station, and reproduced identically +// when the journal is replayed, which a random identifier would not be. +func deriveJobID(key string, ctx TransitionContext) string { + if key != "" { + return key + } + return fmt.Sprintf("j%013d-%06d", ctx.Now.UnixMilli(), ctx.LastMeasurement.Seq) +} diff --git a/internal/domain/redact.go b/internal/domain/redact.go new file mode 100644 index 0000000..88b500b --- /dev/null +++ b/internal/domain/redact.go @@ -0,0 +1,244 @@ +package domain + +import ( + "bytes" + "encoding/json" + "strings" +) + +// This file holds WHAT NEVER LEAVES THE STATION, and the walk that takes it out: +// the option names that carry a secret, the ones that designate one station or one +// site, and Export, which is the only door they are checked at. + +// secretOptionKeys names the option keys whose VALUE never leaves the station, in ANY +// of the three option maps, at ANY depth, in BOTH modes of includeHardware. +// +// It is the list internal/diag/redact.go redacts by, and it is deliberately the SAME +// list: an export and a diagnostic archive are two doors to the outside, and two doors +// must not have two levels of rigour. redact.go owns the reason the match is on the NAME +// and not on a path -- « a driver option added in two years and called `token` is caught +// without anybody remembering to come back here » -- and it now reads this list instead +// of keeping its own copy. The list lives HERE, in the package that depends on nothing, +// because that is the only direction the dependency can go: internal/diag imports +// internal/domain, never the reverse (§5.2). +// +// What is NOT in it, and must not be: `url`. redact.go removes an address because an +// archive is handed to whoever offers to help, and the private host of a cooperative is +// not ours to publish. A catalog URL is not a secret, it designates a SITE -- so it is +// stationSpecificOptions that names it, and a HARDWARE export, which is the backup of one +// station, legitimately keeps it. +var secretOptionKeys = map[string]bool{ + "password": true, + "password_hash": true, + "recovery_code_hash": true, + "passphrase": true, + "secret": true, + "token": true, + "api_key": true, + "apikey": true, + "credential": true, + "credentials": true, + "private_key": true, +} + +// IsSecretOptionKey reports whether a driver option under this name carries a secret. +// +// Exported so that the archive redacts exactly what the export refuses to carry. The +// match is case-insensitive: a file written by hand may well spell `Password`, and a +// secret that leaves because of a capital letter is still a secret that left. +func IsSecretOptionKey(key string) bool { + return secretOptionKeys[strings.ToLower(key)] +} + +// stationSpecificOptions names the driver option keys an export must not carry when +// it is meant to seed ANOTHER station. +// +// Everything else in the three option maps travels, and that default is deliberate: a +// driver option is a setting the parc SHARES until somebody proves otherwise, and the +// proof is written here. Dropping the maps whole was the opposite default, and it made +// INSTALLATION.md lie -- it promises the label offset travels with the cloned +// configuration, and printer.options went out with it. +// +// Two kinds of key are named, and only those two: what designates ONE station (a +// serial port, a Windows queue), and what designates ONE SITE's infrastructure (a +// host, an account, a path). A value that is neither belongs to the parc. +// +// It names KEYS OF A MAP, and it can name nothing else. A site value that lives in a +// TYPED field -- catalog.images.path -- is out of reach of withoutKeys and is dropped +// by Export itself; that is where to look before adding a name here. +// +// It names a key and never a PATH: each list applies to its whole option tree, so a +// serial port under `gateway` and a print queue under `fallback.deeper` go the same way +// as the ones at the first level. The previous version named the group « fallback » in +// the code, which meant one nested object out of all the ones a driver may declare. +var stationSpecificOptions = struct { + scale []string + printer []string + catalog []string +}{ + // COM8 on this station, something else on the next one. + scale: []string{"port"}, + // A Windows queue name differs per machine: the « _2 » of « SATO WS408_2 » is a + // duplicate suffix Windows added, measured on PC-RECEPTION. And `address` is a + // HOST -- 192.168.0.43:9100 on the bench -- which this repository never ships + // (docs/00-donnees-retirees.md). + printer: []string{"queue", "address", "path"}, + // The share and the account belong to one site. The password leaves in NO mode, + // and that is handled before this list, unconditionally. + catalog: []string{"url", "username", "directory"}, +} + +// oneOf reports the membership test of a strip list, in the shape withoutKeys takes. +func oneOf(keys []string) func(string) bool { + return func(key string) bool { return known(keys, key) } +} + +// withoutKeys returns the options minus every key drop names, AT ANY DEPTH. +// +// Depth is the whole point. An option map is free-form -- the administration screen +// builds its form from the schema the DRIVER declares (§9.3) -- so a driver is free to +// nest a gateway, a proxy or a second fallback under any name it invents, and a strip +// that only visited the ground floor let a password walk out from the first. Nothing +// here names a group: only leaf keys are named, and every object is visited. +// +// An absent block stays absent: returning an empty map where there was none would +// turn « ce poste ne déclare pas d'imprimante » into « ce poste déclare une +// imprimante sans rien dedans », which validates differently. +func withoutKeys(options DriverOptions, drop func(key string) bool) DriverOptions { + if options == nil { + return nil + } + out := options.clone() + for key, raw := range options { + if drop(key) { + delete(out, key) + continue + } + if stripped, changed := strippedValue(raw, drop); changed { + out[key] = stripped + } + } + return out +} + +// strippedValue returns one raw option value minus what drop names anywhere inside it, +// and whether anything moved. +// +// The « whether » is not a convenience: re-encoding a value reorders its keys and drops +// the whitespace the file spelled it with, so an untouched value is handed back BYTE FOR +// BYTE instead of being rewritten. A value that does not decode is left alone rather than +// dropped, for the same reason a malformed group used to be: hiding it would send the +// operator looking for a key the file still carries. +// +// It walks a generic tree, and it duplicates fifteen lines of internal/diag's redactTree +// on purpose, because the two cannot be one function. That one REPLACES a value with a +// visible marker and keeps the key, so a reader can tell « ce poste n'a pas de mot de +// passe » from « le mot de passe a été retiré » ; this one DELETES the key, because an +// export is merged field by field into a target (§11.5) and a marker would overwrite the +// target's own secret with the word « [caviardé] ». What the two do share is the thing +// that rots -- the list of names -- and secretOptionKeys is where they share it. +func strippedValue(raw json.RawMessage, drop func(key string) bool) (json.RawMessage, bool) { + // UseNumber, so that a baud rate re-encodes as 9600 and never as 9.6e+03: decoding + // a number into `any` yields a float64, and no float carries a quantity here. + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var node any + if err := decoder.Decode(&node); err != nil { + return raw, false + } + stripped, changed := strippedTree(node, drop) + if !changed { + return raw, false + } + encoded, err := json.Marshal(stripped) + if err != nil { + return raw, false + } + return encoded, true +} + +// strippedTree walks a decoded JSON value and removes every member drop names. +// +// Lists are walked too, and that is not zeal: a driver that declares its mirrors as a +// list of objects puts one set of credentials per entry, and a walk that only knew about +// objects would ship all of them. +func strippedTree(node any, drop func(key string) bool) (any, bool) { + switch value := node.(type) { + case map[string]any: + out := make(map[string]any, len(value)) + changed := false + for key, child := range value { + if drop(key) { + changed = true + continue + } + stripped, touched := strippedTree(child, drop) + out[key] = stripped + changed = changed || touched + } + return out, changed + case []any: + out := make([]any, len(value)) + changed := false + for i, child := range value { + stripped, touched := strippedTree(child, drop) + out[i] = stripped + changed = changed || touched + } + return out, changed + } + return node, false +} + +// Export returns a copy of the configuration fit to leave the station. +// +// With includeHardware false it drops station.number, station.name, network, the +// admin fingerprints, catalog.images.path and the option keys of +// stationSpecificOptions -- a serial port, a print queue, a host, an account, a +// path. What is left is what four stations of one fleet share, and it is what "clone +// a station" copies (§11.5). +// +// NO SECRET EVER LEAVES, whatever includeHardware says: the admin password, and every +// option secretOptionKeys names, in the three option maps and at any depth. On import a +// station without a password runs the "first access" journey, which IMPOSES setting one +// -- an exported password would turn a fleet into four stations sharing one secret +// nobody chose. The promise used to be written as "two secrets" and enforced by one +// delete on one key of one map: a password under scale.options.gateway went out in clear +// text, and so did anything a driver called `token`. +// +// The result is NOT a loadable configuration: without a station number it fails +// control 1. It is meant to be MERGED into a target, field by field, with the diff +// preview of §11.5. +func (c *Config) Export(includeHardware bool) Config { + out := *c + out.retired = nil + out.Admin.PasswordHash = "" + + out.Scale.Options = withoutKeys(c.Scale.Options, IsSecretOptionKey) + out.Printer.Options = withoutKeys(c.Printer.Options, IsSecretOptionKey) + out.Catalog.Options = withoutKeys(c.Catalog.Options, IsSecretOptionKey) + + // Copies, so that a caller editing the export cannot reach into the + // configuration the station is running on. + out.Pricing.Tiers = append([]PriceTier(nil), c.Pricing.Tiers...) + out.Pricing.SecondaryCodes = append([]string(nil), c.Pricing.SecondaryCodes...) + out.Catalog.Categories = append([]Category(nil), c.Catalog.Categories...) + + if includeHardware { + return out + } + out.Station.Number, out.Station.Name = 0, "" + out.Network = NetworkConfig{} + out.Admin.RecoveryCodeHash = "" + out.Scale.Options = withoutKeys(out.Scale.Options, oneOf(stationSpecificOptions.scale)) + out.Printer.Options = withoutKeys(out.Printer.Options, oneOf(stationSpecificOptions.printer)) + out.Catalog.Options = withoutKeys(out.Catalog.Options, oneOf(stationSpecificOptions.catalog)) + // catalog.images.path designates ONE SITE just as catalog.options.url does -- a + // share on the NAS, a letter mapped on this machine -- and it left with the export + // for as long as it existed, because the strip list only knows how to delete a KEY + // and this is a FIELD. images.source stays: "the pictures come with the CSV" is an + // answer the whole fleet shares, and a clone that lost it would fall back on the + // names of the products. + out.Catalog.Images.Path = "" + return out +} diff --git a/internal/domain/redact_test.go b/internal/domain/redact_test.go new file mode 100644 index 0000000..2b44973 --- /dev/null +++ b/internal/domain/redact_test.go @@ -0,0 +1,291 @@ +// This file holds WHAT NEVER LEAVES THE STATION. +// +// The hostile configuration below is the whole point: a password under a group a +// driver invented, a serial port two levels down, a print queue inside a list. An +// export that only visited the ground floor let all three walk out. + +package domain + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestExportWithoutHardwareDropsWhatBelongsToOneStation(t *testing.T) { + config := loadDelivered(t) + // A local drop names a directory; the delivered file is on webdav, so the key + // has to be put there for the test to have anything to assert on. + setOption(t, config.Catalog.Options, "directory", `C:\ProgramData\OpenScale\data\catalog\incoming`) + setOption(t, config.Printer.Options, "address", "192.168.0.43:9100") + exported := config.Export(false) + + if exported.Station.Number != 0 || exported.Station.Name != "" { + t.Errorf("station = %+v, le numéro et le nom ne s'exportent pas", exported.Station) + } + if exported.Network != (NetworkConfig{}) { + t.Errorf("network = %+v, il ne s'exporte pas", exported.Network) + } + if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" { + t.Error("les empreintes admin ne s'exportent pas") + } + + gone := []struct { + path string + key string + options DriverOptions + }{ + {"scale.options.port", "port", exported.Scale.Options}, + {"printer.options.queue", "queue", exported.Printer.Options}, + {"printer.options.address", "address", exported.Printer.Options}, + {"printer.options.path", "path", exported.Printer.Options}, + {"catalog.options.url", "url", exported.Catalog.Options}, + {"catalog.options.username", "username", exported.Catalog.Options}, + {"catalog.options.password", "password", exported.Catalog.Options}, + {"catalog.options.directory", "directory", exported.Catalog.Options}, + } + for _, option := range gone { + if _, present := option.options[option.key]; present { + t.Errorf("%s s'exporte, alors qu'il désigne un poste ou un site", option.path) + } + } + fallback, ok := exported.Printer.Options.Group("fallback") + if !ok { + t.Fatal("printer.options.fallback a disparu de l'export : seules ses clés de repli partent") + } + for _, key := range []string{"queue", "address", "path"} { + if _, present := fallback[key]; present { + t.Errorf("printer.options.fallback.%s s'exporte", key) + } + } + + // The original is untouched: an export is a copy, not a stripping. + if config.Station.Number != 2 { + t.Error("l'export ne doit rien retirer à la configuration en service") + } + if port, _ := config.Scale.Options.Text("port"); port != "COM8" { + t.Error("l'export a retiré le port de la configuration en service") + } + if fallback, ok := config.Printer.Options.Group("fallback"); !ok { + t.Error("l'export a retiré le repli de la configuration en service") + } else if queue, _ := fallback.Text("queue"); queue != "SATO WS408_3" { + t.Error("l'export a retiré la file de repli de la configuration en service") + } +} + +// TestExportWithoutHardwareKeepsWhatTheFleetShares is the reason this lot exists. +// +// INSTALLATION.md promises the next stations that the label offset « voyage avec la +// configuration clonée ». It lives in printer.options, which the export used to drop +// whole, so the promise was false. +func TestExportWithoutHardwareKeepsWhatTheFleetShares(t *testing.T) { + config := loadDelivered(t) + exported := config.Export(false) + + kept := []struct { + path string + key string + options DriverOptions + }{ + {"printer.options.offset_x", "offset_x", exported.Printer.Options}, + {"printer.options.offset_y", "offset_y", exported.Printer.Options}, + {"printer.options.darkness", "darkness", exported.Printer.Options}, + {"printer.options.speed", "speed", exported.Printer.Options}, + {"printer.options.transport", "transport", exported.Printer.Options}, + {"scale.options.baud", "baud", exported.Scale.Options}, + {"scale.options.parity", "parity", exported.Scale.Options}, + {"catalog.options.separator", "separator", exported.Catalog.Options}, + {"catalog.options.poll_interval_s", "poll_interval_s", exported.Catalog.Options}, + {"catalog.options.max_weighable_drop", "max_weighable_drop", exported.Catalog.Options}, + } + for _, option := range kept { + if _, present := option.options[option.key]; !present { + t.Errorf("%s ne voyage pas, alors que les quatre postes le partagent", option.path) + } + } + // The grid, the template and the coop name were already travelling: they must + // keep doing so. + if len(exported.Pricing.Tiers) != 2 || exported.Printer.Template != DefaultTemplateName { + t.Error("la grille de tarifs et le gabarit doivent voyager") + } + if exported.Station.Coop != config.Station.Coop { + t.Error("le nom de la coopérative doit voyager : il est partagé par les quatre postes") + } +} + +func TestExportNeverCarriesAPassword(t *testing.T) { + config := loadDelivered(t) + setOption(t, config.Catalog.Options, "password", "un secret") + + for _, includeHardware := range []bool{false, true} { + exported := config.Export(includeHardware) + if exported.Admin.PasswordHash != "" { + t.Errorf("hardware=%v : le mot de passe admin ne s'exporte jamais, ni haché ni en clair", includeHardware) + } + if secret, ok := exported.Catalog.Options.Text("password"); ok && secret != "" { + t.Errorf("hardware=%v : le mot de passe webdav ne s'exporte jamais", includeHardware) + } + } + // And the station keeps its own. + if secret, _ := config.Catalog.Options.Text("password"); secret != "un secret" { + t.Error("l'export ne doit pas effacer le secret de la configuration en service") + } +} + +// hostileConfig buries a secret and a site value under every shape a driver author +// may legitimately invent, and that Export knows no name for. +// +// Nothing here is exotic: a serial gateway with its own credentials, an HTTP proxy in +// front of the share, a second fallback under the first. The point is that the export +// has never heard of « gateway », « proxy » or « deeper », and must strip them anyway -- +// the same reason internal/diag/redact.go redacts by key name over the whole tree. +func hostileConfig(t *testing.T) Config { + t.Helper() + config := loadDelivered(t) + setOption(t, config.Scale.Options, "gateway", map[string]any{ + "password": "secret-passerelle-balance", + "port": "COM12", + "retries": 3, + }) + setOption(t, config.Catalog.Options, "proxy", map[string]any{ + "token": "secret-jeton-proxy", + "url": "https://proxy.exemple.lan:3128/", + "username": "compte-proxy", + "timeout_s": 5, + }) + setOption(t, config.Printer.Options, "password", "secret-mot-de-passe-imprimante") + + // Two levels down, under the one group name the export used to hard-code: the + // depth a single hard-coded name can never reach. + fallback, ok := config.Printer.Options.Group("fallback") + if !ok { + t.Fatal("la configuration livrée ne porte plus printer.options.fallback") + } + setOption(t, fallback, "deeper", map[string]any{ + "password": "secret-mot-de-passe-repli", + "queue": "SATO WS408_9", + "darkness": 4, + }) + setOption(t, config.Printer.Options, "fallback", fallback) + return config +} + +// TestExportStripsSecretsAtAnyDepth holds the promise the godoc of Export makes. +// +// « TWO SECRETS NEVER LEAVE, whatever includeHardware says » was enforced by a single +// delete on a single key of a single map, so a password one level down walked out in +// clear text. The assertion is on the SERIALISED export and not on a key lookup: what +// leaves the station is bytes, and a test that reads the structure would miss a secret +// hidden under a name it did not think to look up. +func TestExportStripsSecretsAtAnyDepth(t *testing.T) { + config := hostileConfig(t) + setOption(t, config.Catalog.Options, "password", "secret-mot-de-passe-webdav") + + secrets := map[string]string{ + "secret-passerelle-balance": "scale.options.gateway.password", + "secret-jeton-proxy": "catalog.options.proxy.token", + "secret-mot-de-passe-imprimante": "printer.options.password", + "secret-mot-de-passe-repli": "printer.options.fallback.deeper.password", + "secret-mot-de-passe-webdav": "catalog.options.password", + } + for _, includeHardware := range []bool{false, true} { + shipped, err := json.Marshal(config.Export(includeHardware)) + if err != nil { + t.Fatalf("matériel=%v : encodage de l'export : %v", includeHardware, err) + } + for secret, path := range secrets { + if bytes.Contains(shipped, []byte(secret)) { + t.Errorf("matériel=%v : l'export porte le secret de %s (%q), à quelque profondeur qu'on le range", + includeHardware, path, secret) + } + } + } + + // The station keeps its own: an export is a copy, never a stripping. + gateway, ok := config.Scale.Options.Group("gateway") + if !ok { + t.Fatal("l'export a retiré scale.options.gateway de la configuration en service") + } + if secret, _ := gateway.Text("password"); secret != "secret-passerelle-balance" { + t.Error("l'export a retiré un secret imbriqué de la configuration en service") + } +} + +// TestExportStripsStationKeysAtAnyDepth applies the strip list to the whole option +// tree, not to its first floor and to one group called « fallback ». +// +// The default of the lot does not move: a driver option is a setting the parc SHARES +// until stationSpecificOptions proves otherwise. What moves is the REACH of that proof. +func TestExportStripsStationKeysAtAnyDepth(t *testing.T) { + config := hostileConfig(t) + shipped, err := json.Marshal(config.Export(false)) + if err != nil { + t.Fatalf("encodage de l'export : %v", err) + } + stationValues := map[string]string{ + "COM12": "un port série sous scale.options.gateway", + "https://proxy.exemple.lan:3128/": "un hôte sous catalog.options.proxy", + "compte-proxy": "un compte sous catalog.options.proxy", + "SATO WS408_9": "une file d'impression sous printer.options.fallback.deeper", + } + for value, what := range stationValues { + if bytes.Contains(shipped, []byte(value)) { + t.Errorf("l'export porte %s (%q) : il désigne un poste ou un site", what, value) + } + } + + // Only the NAMED keys leave. A group emptied whole would drop what the parc + // shares, which is the defect this lot was opened to repair. + exported := config.Export(false) + gateway, ok := exported.Scale.Options.Group("gateway") + if !ok { + t.Fatal("scale.options.gateway a disparu de l'export : seules ses clés de poste partent") + } + if retries, ok := gateway.Int("retries"); !ok || retries != 3 { + t.Error("scale.options.gateway.retries ne voyage pas, alors que les quatre postes le partagent") + } + proxy, ok := exported.Catalog.Options.Group("proxy") + if !ok { + t.Fatal("catalog.options.proxy a disparu de l'export : seules ses clés de site partent") + } + if timeout, ok := proxy.Int("timeout_s"); !ok || timeout != 5 { + t.Error("catalog.options.proxy.timeout_s ne voyage pas, alors que les quatre postes le partagent") + } + + // An export WITH hardware is the backup of ONE station: its port, its queue and + // its share belong to it, at every depth. + backup, err := json.Marshal(config.Export(true)) + if err != nil { + t.Fatalf("encodage de l'export matériel : %v", err) + } + for value, what := range stationValues { + if !bytes.Contains(backup, []byte(value)) { + t.Errorf("un export matériel est la sauvegarde d'un poste : %s (%q) doit y rester", what, value) + } + } +} + +func TestExportWithHardwareKeepsTheRecoveryCode(t *testing.T) { + // An export WITH hardware is the backup of one station, not the clone template: + // the recovery code of the installation sheet belongs to that backup. + config := loadDelivered(t) + exported := config.Export(true) + if exported.Admin.RecoveryCodeHash != config.Admin.RecoveryCodeHash { + t.Error("un export matériel conserve l'empreinte du code de secours") + } + if port, _ := exported.Scale.Options.Text("port"); port != "COM8" { + t.Error("un export matériel conserve le port de la balance") + } +} + +// TestTheFollowedRepositorySurvivesAnExportWithoutHardware: it is a decision of +// the cooperative and not a property of one machine, so cloning a station must +// carry it. +func TestTheFollowedRepositorySurvivesAnExportWithoutHardware(t *testing.T) { + config := loadDelivered(t) + config.Update.Repository = "la-cagette/openscale" + + if got := config.Export(false).Update.Repository; got != "la-cagette/openscale" { + t.Fatalf("dépôt après export sans matériel = %q", got) + } +} diff --git a/internal/domain/retired.go b/internal/domain/retired.go new file mode 100644 index 0000000..56b7a3a --- /dev/null +++ b/internal/domain/retired.go @@ -0,0 +1,127 @@ +package domain + +import ( + "fmt" + "strings" +) + +// This file holds the keys this binary REFUSES to read, and everything that names +// them: the reason each one left (§11.2), the scan that finds them in a file, and +// the two ways a caller is told about them. +// +// Refusing rather than ignoring is the whole point, and it is written key by key +// below: encoding/json drops what no field claims, so a configuration carrying an +// obsolete key would decode in SILENCE, with the fact it declared simply gone. + +// retiredKeys are the keys control 20 REFUSES outright, each with the reason §11.2 +// gives for its removal. +// +// Two families, and refusing rather than ignoring is the whole point of both. +// The first six used to declare a piece of the numbering plan from a file; the +// plan is now a CONSTANT OF THE BINARY indexed by prefix and self-checked at +// start-up (ADR-028), because a field that changes the MEANING of the code the +// till reads is not a setting, it is an external contract. The last two are the +// rational coefficient ADR-034 replaced by a percentage: encoding/json drops +// what no field claims, so an old file would decode in silence with every +// discount at zero -- and every member would pay the full price with nothing to +// say why. +var retiredKeys = map[string]string{ + "weight_decimals": "les décimales du poids sont déclarées par le plan compilé, indexé par préfixe (ADR-028)", + "units_field_width": "la largeur du champ des unités est déclarée par le plan compilé, indexé par préfixe (ADR-028)", + "weight_prefix": "les préfixes au poids sont déclarés par le plan compilé (0493 à 0498), jamais par un fichier", + "unit_prefix": "le préfixe à l'unité est déclaré par le plan compilé (0499), jamais par un fichier", + "content": "ce que transporte la charge utile est déclaré par le plan compilé, jamais par un fichier", + "rules_by_prefix": "la table de règles par préfixe est remplacée par le plan compilé, auto-contrôlé au démarrage", + "coef_num": "la remise d'un tarif se déclare en pourcentage : discount_percent, au dixième de point (ADR-034)", + "coef_den": "la remise d'un tarif se déclare en pourcentage : discount_percent, il n'y a plus de dénominateur (ADR-034)", + "tile_size": "la densité de la grille s'adapte en continu à l'écran (clamp CSS), il n'y a plus de palier à choisir (ADR-035, remplace ADR-031) ; ce qui se règle désormais est le nombre de colonnes, ui.grid_columns, un entier (ADR-057)", +} + +// RetiredKeyReason reports why the key at the end of a dotted path -- "barcode.weight_decimals", +// exactly as scanRetired and Config.Retired name it -- was retired, in the French an +// operator reads. +// +// It exists so that a reason written ONCE in retiredKeys is read everywhere a refusal is +// shown, instead of being copied a second time by whoever writes the next one: control 20 +// and `openscale config migrate` (cmd/openscale/config.go) both name a key this binary +// will not convert, and they have to say the SAME thing, word for word, or a volunteer +// comparing the two would read them as two different problems. +// +// The extraction is the one control 20 already did before this function existed: the last +// segment of the path, because retiredKeys is indexed by the bare key and not by where it +// was found. A path this binary never retired -- unreachable through Config.Retired, which +// only ever names a key of retiredKeys -- reports that plainly rather than an empty string, +// which would truncate whatever sentence names it. +func RetiredKeyReason(path string) string { + key := path[strings.LastIndexByte(path, '.')+1:] + if reason, known := retiredKeys[key]; known { + return reason + } + return "clé retirée dont la raison n'est plus documentée" +} + +// scanRetired appends the dotted path of every retired key of a decoded document. +func scanRetired(prefix string, value any, out *[]string) { + switch typed := value.(type) { + case map[string]any: + for _, key := range sortedKeys(typed) { + path := key + if prefix != "" { + path = prefix + "." + key + } + if _, retired := retiredKeys[key]; retired { + *out = append(*out, path) + } + scanRetired(path, typed[key], out) + } + case []any: + for i, item := range typed { + scanRetired(fmt.Sprintf("%s[%d]", prefix, i), item, out) + } + } +} + +// Retired reports the dotted paths of the retired keys the file carried, in a +// stable order. +// +// It exists so that the administration screen can say « supprimez ces lignes » +// while pointing at the file, and so that a test can assert on the FILE rather than +// on a structure in which a retired key cannot exist. +func (c *Config) Retired() []string { + return append([]string(nil), c.retired...) +} + +// RetiredKeysError reports that a Config still carries a key control 20 refuses. +// +// It is what ConfigStore.Save returns instead of writing: the struct is about to be +// marshalled, and marshalling is what LAUNDERS the key -- encoding/json already +// dropped it once, at decode, and the field it stood for (a member's discount, for +// coef_num) goes with it. A caller that reaches Save without having checked first +// gets this instead of a file that decodes clean on the very next read. +type RetiredKeysError struct { + // Keys are the dotted paths Config.Retired returned. + Keys []string +} + +// Error names the retired keys. +func (e *RetiredKeysError) Error() string { + return fmt.Sprintf("domain: config still carries retired key(s): %s", strings.Join(e.Keys, ", ")) +} + +// RefuseIfRetired reports a *RetiredKeysError when the configuration still carries a +// key control 20 refuses, and nil otherwise. +// +// It is deliberately narrower than Validate: Validate needs Registries and can fail +// on a print queue this station does not have, which is not a reason to refuse +// WRITING a configuration that was already sitting on disk. This checks the one +// thing that must never reach a file regardless of everything else about it -- and +// it is cheap enough to run on every save, by every caller, including the ones that +// will never think to call Validate first (the recovery route does not: a rescue +// cannot be made to depend on the very validation that put the station out of +// service to begin with). +func (c *Config) RefuseIfRetired() error { + if keys := c.Retired(); len(keys) > 0 { + return &RetiredKeysError{Keys: keys} + } + return nil +} diff --git a/internal/domain/retired_test.go b/internal/domain/retired_test.go new file mode 100644 index 0000000..f7cb227 --- /dev/null +++ b/internal/domain/retired_test.go @@ -0,0 +1,190 @@ +// This file holds control 20: the keys this binary REFUSES to read. +// +// Refusing rather than ignoring is what every test here is about -- encoding/json +// drops what no field claims, so an ignored key would take the fact it declared +// with it, in silence, and every member would pay the full price with nothing to +// say why. + +package domain + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" +) + +func TestControl20RefusesARetiredPlanKey(t *testing.T) { + raw, err := os.ReadFile(deliveredConfigPath) + if err != nil { + t.Fatalf("lecture de %s : %v", deliveredConfigPath, err) + } + + for _, key := range []string{ + "weight_decimals", "units_field_width", "weight_prefix", + "unit_prefix", "content", "rules_by_prefix", + } { + t.Run(key, func(t *testing.T) { + // The key is injected into the barcode block, which is where every one of + // them used to live. + injected := strings.Replace(string(raw), + `"barcode": { "verify_reference_check_digit": true }`, + `"barcode": { "verify_reference_check_digit": true, "`+key+`": 3 }`, 1) + if injected == string(raw) { + t.Fatal("l'injection n'a rien remplacé : le bloc barcode du fichier livré a changé de forme") + } + var config Config + if err := json.Unmarshal([]byte(injected), &config); err != nil { + t.Fatalf("décodage : %v", err) + } + if got := config.Retired(); len(got) != 1 || got[0] != "barcode."+key { + t.Fatalf("clés supprimées relevées = %v, attendu [barcode.%s]", got, key) + } + faults := config.Validate(testRegistries()) + fault := findFault(faults, "barcode."+key) + if fault == nil { + t.Fatalf("aucune faute sur barcode.%s ; obtenu :\n%s", key, strings.Join(fieldsOf(faults), "\n")) + } + // The message must send the reader back to the compiled plan, otherwise a + // station would keep believing its old width setting applies. + if !strings.Contains(fault.Message, "supprimée") { + t.Errorf("message = %q, il doit dire que la clé est supprimée", fault.Message) + } + }) + } +} + +func TestControl20IgnoresARetiredKeyOutsideTheFile(t *testing.T) { + // A Config built in Go carries none by construction: only a FILE can hold a key + // no field claims. + config := NeutralProfile() + if got := config.Retired(); len(got) != 0 { + t.Fatalf("un profil compilé ne peut porter aucune clé supprimée, obtenu %v", got) + } +} + +// TestOldCoefficientKeysAreRefused is the safety net of ADR-034. encoding/json +// drops what no field claims, so a file of the old format would decode WITHOUT A +// WORD, with every discount at zero: every member would pay the full price, and +// nothing on any screen would say why. Check 20 refuses the file instead. +func TestOldCoefficientKeysAreRefused(t *testing.T) { + for _, key := range []string{"coef_num", "coef_den"} { + raw := []byte(`{"pricing":{"tiers":[{"code":"MEMBER","` + key + `":9}]}}`) + var config Config + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("%s : %v", key, err) + } + retired := config.Retired() + if len(retired) == 0 { + t.Errorf("%s : aucune clé retirée signalée", key) + continue + } + if !strings.Contains(retired[0], key) { + t.Errorf("%s : clé retirée %q, elle doit nommer la clé", key, retired[0]) + } + } +} + +// TestRetiredTileSizeIsRefused covers ADR-035: grid density becomes continuous +// again (clamp() on the front end) and ui.tile_size no longer has any field to +// carry it. A file that still carries it must be refused the way ADR-034 +// refused coef_num, not silently ignored. +func TestRetiredTileSizeIsRefused(t *testing.T) { + raw := []byte(`{"ui":{"tile_size":"medium"}}`) + var config Config + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("décodage : %v", err) + } + retired := config.Retired() + if len(retired) != 1 || retired[0] != "ui.tile_size" { + t.Fatalf("clés retirées = %v, attendu [ui.tile_size]", retired) + } + reason, known := retiredKeys["tile_size"] + if !known || reason == "" { + t.Fatal("tile_size absente de la table des clés retirées, ou sans raison") + } +} + +// TestRetiredCoefficientMessagesPointAtTheNewKey: refusing is only half of it -- +// the message has to say what to write instead, or a volunteer is stuck. +func TestRetiredCoefficientMessagesPointAtTheNewKey(t *testing.T) { + for _, key := range []string{"coef_num", "coef_den"} { + reason, known := retiredKeys[key] + if !known { + t.Errorf("%s absente de la table des clés retirées", key) + continue + } + if !strings.Contains(reason, "discount_percent") { + t.Errorf("%s : message %q, il doit nommer discount_percent", key, reason) + } + } +} + +// TestRefuseIfRetiredNamesTheKeys is the guard ConfigStore.Save calls before writing a +// single byte (ADR-034). It exists because control 20 alone is not enough: Validate +// only runs where a caller remembers to call it, and the recovery route -- the one +// that matters most, because it is a station's only way back in -- never did. +func TestRefuseIfRetiredNamesTheKeys(t *testing.T) { + raw := []byte(`{"pricing":{"tiers":[{"code":"MEMBER","coef_num":9}]}}`) + var config Config + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("décodage : %v", err) + } + + err := config.RefuseIfRetired() + if err == nil { + t.Fatal("une configuration carrying coef_num n'a pas été refusée") + } + var retired *RetiredKeysError + if !errors.As(err, &retired) { + t.Fatalf("l'erreur n'est pas un *RetiredKeysError : %v", err) + } + if len(retired.Keys) != 1 || !strings.Contains(retired.Keys[0], "coef_num") { + t.Fatalf("clés = %v, coef_num attendu", retired.Keys) + } +} + +// TestRefuseIfRetiredAcceptsAConfigBuiltInGo: Retired is filled by UnmarshalJSON +// alone, so a configuration assembled in code -- the neutral profile, or one a test +// builds by hand -- carries none, and nothing legitimate is blocked. +func TestRefuseIfRetiredAcceptsAConfigBuiltInGo(t *testing.T) { + profile := NeutralProfile() + if err := profile.RefuseIfRetired(); err != nil { + t.Fatalf("un profil compilé est refusé : %v", err) + } +} + +// TestTileSizeStaysRetiredBesideTheColumnSetting is the non-regression of the +// ADR-031 → ADR-035 → ADR-057 round trip. +// +// The reopened question is « combien de produits voir d'un coup », which no screen +// measurement answers; the one ADR-035 closed -- the heterogeneity of the fleet -- +// stays closed, and `clamp()` still answers it, since it remains the default. So the +// new key must be read and the old one must still be REFUSED, in the very same file: +// ui.tile_size never comes back through the side door. +func TestTileSizeStaysRetiredBesideTheColumnSetting(t *testing.T) { + raw := []byte(`{"version":1,"ui":{"tile_size":"medium","grid_columns":7}}`) + var config Config + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("décodage : %v", err) + } + if config.UI.GridColumns != 7 { + t.Fatalf("grid_columns relu à %d, attendu 7", config.UI.GridColumns) + } + if got := config.Retired(); len(got) != 1 || got[0] != "ui.tile_size" { + t.Fatalf("clés retirées = %v, attendu [ui.tile_size]", got) + } + fault := findFault(config.Validate(testRegistries()), "ui.tile_size") + if fault == nil { + t.Fatal("ui.tile_size passe le contrôle 20 dès qu'un réglage de grille est écrit à côté") + } + if !strings.Contains(fault.Message, "supprimée") { + t.Errorf("message = %q, il doit dire que la clé est supprimée", fault.Message) + } + // Refusing is only half of it: a volunteer who wrote tile_size wanted a denser + // grid, and the refusal has to name the key that gives them one. + if !strings.Contains(fault.Message, "grid_columns") { + t.Errorf("message = %q, il doit nommer la clé qui règle désormais la grille", fault.Message) + } +} diff --git a/internal/domain/state.go b/internal/domain/state.go new file mode 100644 index 0000000..4b4843a --- /dev/null +++ b/internal/domain/state.go @@ -0,0 +1,98 @@ +package domain + +// This file holds the sixteen states the weighing station can be in, and the one +// spelling each of them has. + +// State enumerates every state the weighing station can be in. +// +// EnteringUnits is ABSENT, and that is not an oversight: a product sold by unit +// prints at the FIRST TAP, for 1 unit, with the same gesture and the same +// immediacy as a product sold by weight. A multiple quantity is a local affordance +// of the tile, carried by the `units` field of the POST, so it never leaves the +// grid and is therefore not a state of this machine (§14.3, ADR-023). +type State uint8 + +const ( + // Initializing is before the first catalog: the station cannot serve yet. + Initializing State = iota + // Idle means scale empty, ready, nothing selected. + Idle + // ProductArmed means the product was chosen before the bag was put down -- + // BOUNDED arming, MaxArmingTime (ADR-022). + ProductArmed + // WeightPresent means a mass is detected. + WeightPresent + // WeightStable means the mass is latched. It is an INDICATOR, not a print + // condition in advisory mode (A3). + WeightStable + // AwaitingStability exists in blocking mode only. + AwaitingStability + // EnteringTare is the tare keypad, anchored under the banner (§14.3). + EnteringTare + // EnteringWeight is the manual weight keypad -- degraded paths only. + EnteringWeight + // ManualMode is a station that declares it has no scale (scale.present false) + // and allows manual entry. + ManualMode + // Validating is TRANSIENT: it is entered and left inside one call to + // Transition, because nothing outside the model decides its outcome. A model + // published with this state can only come from a hand-written value or a + // replay, and Transition still has to survive it. + Validating + // Printing means the label has been handed to the print worker. + Printing + // Succeeded means the label was sent. There is no 'ok': no transport can tell + // us a label physically came out (§12.3). + Succeeded + // Rejected means a blocking safeguard stopped the label. + Rejected + // Faulted is one of the three full-screen states, and it carries an ERR code + // for the telephone (§14.3). + Faulted + // ScaleLost is reached from every state but OutOfService, and getting there is + // idempotent. + ScaleLost + // OutOfService is terminal. Nothing in this machine enters it: it is the state + // the Hub STARTS in when the configuration is unusable (§11.3, ERR-CFG-01). + OutOfService +) + +// String reports the value published in the state snapshot, so that a log line, a +// test failure and the SSE payload spell a state the same way. +func (s State) String() string { + switch s { + case Initializing: + return "initializing" + case Idle: + return "idle" + case ProductArmed: + return "product_armed" + case WeightPresent: + return "weight_present" + case WeightStable: + return "weight_stable" + case AwaitingStability: + return "awaiting_stability" + case EnteringTare: + return "entering_tare" + case EnteringWeight: + return "entering_weight" + case ManualMode: + return "manual_mode" + case Validating: + return "validating" + case Printing: + return "printing" + case Succeeded: + return "succeeded" + case Rejected: + return "rejected" + case Faulted: + return "faulted" + case ScaleLost: + return "scale_lost" + case OutOfService: + return "out_of_service" + } + return "unknown" +} diff --git a/internal/domain/template.go b/internal/domain/template.go index 1684bc4..b40173b 100644 --- a/internal/domain/template.go +++ b/internal/domain/template.go @@ -2,18 +2,12 @@ package domain import "fmt" -// Fault is a single validation error, named by the field that carries it. +// This file holds the GEOMETRY of a label layout: the closed lists a template may +// name, the head its lengths are counted against, the media, the symbol, the +// elements -- and the arithmetic that says how far the ink reaches. // -// Validation returns ALL the faults, not the first one: the admin screen is used -// by volunteers, it must report everything at once, in French, with the offending -// field named and, whenever possible, the list of acceptable values. -type Fault struct { - Field string `json:"field"` - Message string `json:"message"` - Values []string `json:"values,omitempty"` -} - -func (f Fault) String() string { return f.Field + " : " + f.Message } +// Every length is in micrometres, except the barcode module, which is in milli-dots +// and says why at SymbolGeometry. What REFUSES a layout is template_validate.go. // The CLOSED list of field identifiers a template may place. // @@ -303,216 +297,6 @@ type Template struct { TruncationAccepted bool `json:"truncation_accepted"` } -// Validate reports every hard rule the template breaks ON THE HEAD OF THE FLEET; an -// empty slice means the template may be loaded. -// -// tierCount decides which conditional elements are active, because rules 3, 5 and 8 -// are about what is actually INKED: a mono-tarif station must not be refused a -// template because of a field that will never be drawn on it. -// -// A station validates against the head ITS OWN DRIVER declares (ValidateOn); this form -// is for every caller that has no descriptor in hand, and it answers for the WS408. -func (t *Template) Validate(tierCount int) []Fault { - return t.ValidateOn(ReferenceHead(), tierCount) -} - -// ValidateOn reports every hard rule the template breaks ON THIS HEAD. -// -// Rules 3 and 4 are the two that need one: they bound the ink by what the head can -// print, and holding that bound as a constant of the core made every station whose -// printer is not the WS408 of the parc fail its own validation at start-up. -// -// A head that declares nothing is measured against ReferenceHead, so a caller with no -// descriptor in hand — a test, a preview — validates exactly what it validated before. -func (t *Template) ValidateOn(head PrinterCapabilities, tierCount int) []Fault { - head = head.orReference() - var faults []Fault - fail := func(field, format string, args ...any) { - faults = append(faults, Fault{Field: field, Message: fmt.Sprintf(format, args...)}) - } - - if t.Media.DotsPerMM <= 0 { - fail("media.dots_per_mm", "doit être strictement positif (8 sur une WS408, 12 sur une WS412)") - // Every geometric rule below divides the world by this number. Carrying on - // would produce a page of faults that all say the same thing. - return faults - } - - // Rule 6: the offset is applied BEFORE validation, so a shift that would push a - // quiet zone off the label is refused rather than silently cropped. The ±1 dot - // arrows of the admin screen invite that adjustment; it must be bounded by the - // geometry and not merely by ±99. - shifted := t.withOffset() - - // The template and the head must count dots at the SAME pitch, and this is where a - // template measured for another one is caught. - // - // Symbol.ModuleMilliDots is the ONE length of a template expressed in units of - // resolution, deliberately: 0.293 mm is 2.344 dots, and that fractional module is - // the whole technical point of arbitration A2. The unit stays, so the AMBIGUITY has - // to go — the same 2 344 milli-dots print 0.293 mm on a WS408 and 0.195 mm on a - // WS412, under every GS1 floor, and no byte of the frame says so. The label simply - // comes out wrong. - pitchAgrees := head.DotsPerMM == t.Media.DotsPerMM - if !pitchAgrees { - fail("media.dots_per_mm", - "le gabarit est mesuré pour une tête de %g dots/mm et la tête d'impression en fait "+ - "%g dots/mm : à ce module le symbole sortirait à un autre grandissement", - t.Media.DotsPerMM, head.DotsPerMM) - } - - inkedWidth := int64(head.InkedWidthDots) * 1000 - inkedHeight := int64(head.InkedHeightDots) * 1000 - - // Rule 9, checked first: a template with an absurd module or an unreadable type - // size produces cascades of geometric faults, and naming the cause is kinder - // than naming ten consequences. - // - // The module is measured against the pitch the HEAD declares, so a template whose - // module is legal here is legal in millimetres, not merely in dots. Skipped when - // the two pitches disagree: the module would then be read at a resolution this - // template was never measured for, and naming ten consequences of a cause already - // named helps nobody. - if pitchAgrees { - if um := shifted.Symbol.ModuleUM(head.DotsPerMM); um < GS1MinModuleUM || um > GS1MaxModuleUM { - fail("symbol.module_milli_dots", - "%d milli-dots valent %d µm à %g dots/mm, soit un grandissement de %.1f %% : "+ - "hors de la plage GS1 [%d ; %d] µm (80 %% à 200 %% de la nominale de 330 µm)", - shifted.Symbol.ModuleMilliDots, um, head.DotsPerMM, - shifted.Symbol.Magnification(head.DotsPerMM)*100, GS1MinModuleUM, GS1MaxModuleUM) - } - } - for i, e := range shifted.Elements { - if e.Field == FieldBarcode { - continue // the symbol carries no type size of its own - } - if e.FontSizeUM < MinFontSizeUM { - fail(fmt.Sprintf("elements[%d].font_size_um", i), - "%d µm est sous le plancher de lisibilité de %d µm (%s)", - e.FontSizeUM, MinFontSizeUM, e.Field) - } - if e.MinFontSizeUM != 0 && e.MinFontSizeUM < MinFontSizeUM { - fail(fmt.Sprintf("elements[%d].min_font_size_um", i), - "%d µm est sous le plancher de %d µm", e.MinFontSizeUM, MinFontSizeUM) - } - if e.MinFontSizeUM > e.FontSizeUM { - fail(fmt.Sprintf("elements[%d].min_font_size_um", i), - "%d µm dépasse le corps nominal de %d µm", e.MinFontSizeUM, e.FontSizeUM) - } - } - - // Rule 7: closed lists. No template engine, so no injection and no rendering - // error at print time. - seen := make(map[string]bool, len(shifted.Elements)) - for i, e := range shifted.Elements { - if !known(knownFields, e.Field) { - fail(fmt.Sprintf("elements[%d].field", i), "champ inconnu %q", e.Field) - faults[len(faults)-1].Values = knownFields - } - if !known(knownConditions, e.When) { - fail(fmt.Sprintf("elements[%d].when", i), "condition inconnue %q", e.When) - faults[len(faults)-1].Values = knownConditions - } - if e.Field != FieldBarcode && !known(knownAlignments, e.Align) { - fail(fmt.Sprintf("elements[%d].align", i), "alignement inconnu %q", e.Align) - faults[len(faults)-1].Values = knownAlignments - } - if seen[e.Field] { - fail(fmt.Sprintf("elements[%d].field", i), "le champ %q est placé deux fois", e.Field) - } - seen[e.Field] = true - if e.WidthUM <= 0 || e.HeightUM <= 0 { - fail(fmt.Sprintf("elements[%d]", i), "le champ %q a une boîte de %d × %d µm", - e.Field, e.WidthUM, e.HeightUM) - } - } - - // Rules 1 and 2: boxes inside the printable area, printable area inside the - // media. Both bear on the DECLARED geometry, which is the operator's own - // statement about their printer -- unlike rules 3 and 4, which bear on measured - // ink. - if t.PrintableWidthUM > t.Media.WidthUM || t.PrintableHeightUM > t.Media.HeightUM { - fail("printable_area", "la zone imprimable (%d × %d µm) sort du média (%d × %d µm)", - t.PrintableWidthUM, t.PrintableHeightUM, t.Media.WidthUM, t.Media.HeightUM) - } - for i, e := range shifted.Elements { - if !e.Active(tierCount) { - continue - } - if e.XUM < 0 || e.YUM < 0 { - fail(fmt.Sprintf("elements[%d]", i), "le champ %q est placé en (%d ; %d) µm, hors de l'étiquette", - e.Field, e.XUM, e.YUM) - continue - } - if t.PrintableWidthUM > 0 && e.Right() > t.PrintableWidthUM { - fail(fmt.Sprintf("elements[%d]", i), "le champ %q dépasse la zone imprimable en largeur (%d > %d µm)", - e.Field, e.Right(), t.PrintableWidthUM) - } - if t.PrintableHeightUM > 0 && e.Bottom() > t.PrintableHeightUM { - fail(fmt.Sprintf("elements[%d]", i), "le champ %q dépasse la zone imprimable en hauteur (%d > %d µm)", - e.Field, e.Bottom(), t.PrintableHeightUM) - } - } - - // Rules 3 and 4 compare dots to dots, so they only mean anything once the two - // pitches agree. Enumerating them on top of a mismatch would name ten consequences - // of a cause already named, which is the reasoning the zero resolution above - // follows. - if pitchAgrees { - // Rule 3: THE INKED CONTENT FITS THE GEOMETRY OF THE EXISTING LABEL. - bottom, right := shifted.inkedExtent(tierCount) - if bottom > inkedHeight { - fail("inked_content", "le contenu encré descend à %s dots, au-delà des %d dots de hauteur encrée de l'étiquette", - formatMilliDots(bottom), head.InkedHeightDots) - } - if right > inkedWidth { - fail("inked_content", "le contenu encré s'étend à %s dots, au-delà des %d dots de largeur encrée de l'étiquette", - formatMilliDots(right), head.InkedWidthDots) - } - - // Rule 4: the over-all width of the symbol, quiet zones included. - if width := shifted.Symbol.TotalWidthMilliDots(); width > inkedWidth { - fail("symbol.module_milli_dots", - "le hors-tout du symbole (113 modules = %s dots) dépasse les %d dots de largeur encrée", - formatMilliDots(width), head.InkedWidthDots) - } - } - - // Rule 5: nothing may intersect the symbol, QUIET ZONES INCLUDED. A field - // overlapping a quiet zone does not look wrong on a preview; it makes the - // symbol unreadable at the till, which is far worse. - symbolBox := shifted.symbolBox() - for i, e := range shifted.Elements { - if e.Field == FieldBarcode || !e.Active(tierCount) { - continue - } - if overlaps(shifted.box(e), symbolBox) { - fail(fmt.Sprintf("elements[%d]", i), - "le champ %q recouvre le symbole ou l'une de ses zones de silence", e.Field) - } - } - - // Rule 8: no overlap between two active elements. Two fields on top of each - // other print as an unreadable smudge, and the preview is the only place anyone - // would notice. - for i := range shifted.Elements { - for j := i + 1; j < len(shifted.Elements); j++ { - a, b := shifted.Elements[i], shifted.Elements[j] - if a.Field == FieldBarcode || b.Field == FieldBarcode { - continue // rule 5 owns the symbol - } - if !a.Active(tierCount) || !b.Active(tierCount) { - continue - } - if overlaps(shifted.box(a), shifted.box(b)) { - fail(fmt.Sprintf("elements[%d]", j), "le champ %q recouvre le champ %q", b.Field, a.Field) - } - } - } - - return faults -} - // MaxOffsetDots reports how far the volunteer's adjustment may go in each // direction before rule 3 or rule 5 would refuse it. // @@ -620,15 +404,6 @@ func overlaps(a, b rectangle) bool { return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom } -func known(list []string, value string) bool { - for _, candidate := range list { - if candidate == value { - return true - } - } - return false -} - // formatMilliDots renders a milli-dot count as dots with three decimals, so a // message says "279,824 dots" rather than "279824". func formatMilliDots(milliDots int64) string { diff --git a/internal/domain/template_shipped_test.go b/internal/domain/template_shipped_test.go new file mode 100644 index 0000000..73bf86e --- /dev/null +++ b/internal/domain/template_shipped_test.go @@ -0,0 +1,263 @@ +// This file holds what the SHIPPED layouts are worth: that both pass every rule, +// that the production one reproduces the geometry measured on the label, and that +// the one this repository retired cannot come back. +// +// weighing_identical is the label a till has been reading for years, so what is +// checked here is the label itself and not an idea of it. + +package domain + +import "testing" + +// TestShippedTemplatesPassEveryRule: whatever else changes, a template we ship must +// load. The CI fails otherwise (§7.5). +func TestShippedTemplatesPassEveryRule(t *testing.T) { + for name, template := range ShippedTemplates() { + for _, tierCount := range []int{1, 2, 3} { + if faults := template.Validate(tierCount); len(faults) != 0 { + t.Errorf("%s with %d tier(s): %d faults", name, tierCount, len(faults)) + for _, f := range faults { + t.Errorf(" %s", f) + } + } + } + } +} + +// TestNeutralTemplateInkedExtentHasMargin: rule 3 is the one that bit before, when +// the document compared an Access box to an assumed media and failed the CI on the +// very template A1 requires. Here we assert the margin, so a future edit that eats +// it is visible. +func TestNeutralTemplateInkedExtentHasMargin(t *testing.T) { + template := NeutralSingleTemplate() + bottom, right := template.inkedExtent(2) + + bottomDots, rightDots := bottom/1000, right/1000 + t.Logf("contenu encré : %d dots de haut, %d dots de large (limites %d et %d)", + bottomDots, rightDots, ReferenceInkedHeightDots, ReferenceInkedWidthDots) + + if bottomDots > ReferenceInkedHeightDots { + t.Errorf("hauteur encrée %d > %d", bottomDots, ReferenceInkedHeightDots) + } + if rightDots > ReferenceInkedWidthDots { + t.Errorf("largeur encrée %d > %d", rightDots, ReferenceInkedWidthDots) + } + // The bottom is dominated by the symbol block, as on the production label. + symbolBottom := template.Media.MilliDots(template.Symbol.YUM + template.Symbol.HeightUM()) + if bottom != symbolBottom { + t.Errorf("le bas encré (%d) devrait être celui du symbole (%d)", bottom, symbolBottom) + } +} + +// TestGabaritBIsRetiredAndCannotComeBack is the other half of removing it: a template +// that no rule refuses comes back the day someone needs a quick experiment, and gabarit +// B's whole problem was that its winning arm could never be adopted (75.8 %, below the +// GS1 floor). Rule 9 now says so, so the removal is enforced rather than merely done. +func TestGabaritBIsRetiredAndCannotComeBack(t *testing.T) { + shipped := ShippedTemplates() + if _, found := shipped["weighing_integer_module"]; found { + t.Error("weighing_integer_module est encore livré") + } + if len(shipped) != 2 { + t.Errorf("%d gabarits livrés, attendu 2 : %v", len(shipped), shipped) + } + + b := NeutralSingleTemplate() + b.Symbol.ModuleMilliDots = 2_000 // 2 dots exactly = 250 um at 8 dots/mm + faults := b.Validate(1) + if len(faults) == 0 { + t.Fatal("un module de 2 dots vaut 250 µm, soit 75,8 % : la règle 9 doit le refuser") + } + t.Logf("refusé, et la raison est lisible : %v", faults[0]) +} + +// TestMeasuredProductionGeometry freezes what was read out of the test PDF. +// +// reference/test_etiquette_EtataImprimer.pdf was decompressed and its content stream +// parsed. The six control boxes of §7.2 are confirmed to within 40 um — under a third +// of a dot. Two things the document does not say came out of it, and both belong to +// L4: +// +// - the symbol does NOT start at the top of its control box. §7.2 gives 8 996 um, +// which is the box; the glyph's baseline is at 21 326 um and rises 0.977 em, so +// the bars begin at 9 604 um. The block height is exactly the stated 14 650 um — +// it is the origin that is off by 608 um, and the inked content therefore reaches +// 194 dots rather than 189; +// - the production boxes overlap each other and the symbol box, because an Access +// control carries its leading inside its height. +// +// This test does not exercise production code. It exists so the figures survive in +// the repository rather than in a terminal, and so that L4 starts from a measurement +// instead of from a transcription. +func TestMeasuredProductionGeometry(t *testing.T) { + const ( + measuredBaselineUM = 21_326 // baseline of the 34 pt barcode glyph + measuredBarTopUM = 9_604 // baseline - 0.977 em + measuredHRIBottom = 24_254 // baseline + 0.244 em + documentedOriginUM = 8_996 // §7.2: the top of the CodeBarre control box + ) + + if got := measuredHRIBottom - measuredBarTopUM; got != 14_650 { + t.Errorf("hauteur mesurée du bloc = %d µm, la documentation dit 14650", got) + } + if drift := measuredBarTopUM - documentedOriginUM; drift != 608 { + t.Errorf("écart origine documentée / origine mesurée = %d µm, want 608", drift) + } + + // What that means for rule 3, on the production geometry: 194 dots, not 189. + inked := Media{DotsPerMM: 8}.MilliDots(measuredHRIBottom) / 1000 + if inked != 194 { + t.Errorf("contenu encré mesuré = %d dots, want 194", inked) + } + if inked > ReferenceInkedHeightDots { + t.Errorf("le contenu encré mesuré (%d dots) dépasse la hauteur encrée de l'étiquette (%d)", + inked, ReferenceInkedHeightDots) + } + t.Logf("géométrie de production mesurée : symbole de %d à %d µm, contenu encré %d dots, "+ + "marge %d dots sous les %d dots de l'étiquette", + measuredBarTopUM, measuredHRIBottom, inked, ReferenceInkedHeightDots-int(inked), ReferenceInkedHeightDots) +} + +// TestIdenticalTemplateHasUniformBars is ADR-029 made checkable. +// +// The decision the commissioning party took: stop putting the two prices ON the bars, +// stack the text instead, and put the symbol below it. The bars become uniform over +// the full width AND taller in usable terms. +func TestIdenticalTemplateHasUniformBars(t *testing.T) { + template := IdenticalTemplate() + + // 1. Nothing overlaps the symbol any more — which is what makes rule 5 + // satisfiable for the production template at all. + if faults := template.Validate(2); len(faults) != 0 { + t.Errorf("double tarif : %d fautes", len(faults)) + for _, f := range faults { + t.Errorf(" %s", f) + } + } + if faults := template.Validate(1); len(faults) != 0 { + t.Errorf("mono-tarif : %d fautes", len(faults)) + for _, f := range faults { + t.Errorf(" %s", f) + } + } + + // 2. The usable bar height BEATS what the current label achieves. Measured on the + // test PDF: 11 722 um of bars declared, of which the solidarity price eats + // 1 192 um and the member price 3 381 um, leaving 8 341 um clean. + const cleanBarsToday = 10_875 - 2_534 // 8 341 um, spelled out from the measurement + if got := template.Symbol.BarHeightUM; got <= cleanBarsToday { + t.Errorf("barres = %d µm, want plus que les %d µm réellement propres aujourd'hui", + got, cleanBarsToday) + } + // 11 875 and not 10 875 since 30/07/2026, when the commissioning party reopened A1. + // The extra 1 000 um came from the leading (277 -> 150) and the HRI band + // (2 930 -> 2 200), both inherited from the Access report, and NOT from the text: + // the solidarity price had just been raised to 11 pt for legibility. + if got := template.Symbol.BarHeightUM; got != 11_375 { + t.Errorf("barres = %d µm, want 11375 (91 dots exactement à 8 dots/mm)", got) + } + // 95 dots exactly: a whole number of dots, so no bar is a fraction of a scan line. + if milliDots := template.Media.MilliDots(template.Symbol.BarHeightUM); milliDots%1000 != 0 { + t.Errorf("hauteur de barres = %d milli-dots : elle doit être un compte entier de dots", milliDots) + } + + // 3. The module has NOT moved, and the reason changed on 30/07/2026: it is no + // longer "A1 freezes it" but "no integer module lands in the GS1 range at this + // pitch either" (ADR-002). The number survived its own justification. + if got := template.Symbol.ModuleMilliDots; got != 2_344 { + t.Errorf("module = %d, want 2344", got) + } + if got := template.Symbol.TotalWidthMilliDots(); got != 264_872 { + t.Errorf("hors-tout = %d milli-dots, want 264872 (33,109 mm)", got) + } + // And it is inside the GS1 range measured against the head, which is what rule 9 + // now checks rather than a milli-dot pair that meant nothing physical. + if um := template.Symbol.ModuleUM(template.Media.DotsPerMM); um < GS1MinModuleUM || um > GS1MaxModuleUM { + t.Errorf("module = %d µm, hors de la plage GS1 [%d ; %d]", um, GS1MinModuleUM, GS1MaxModuleUM) + } + + // 4. The HRI survives: it is printed today, and dropping it would take away the + // cashier's fallback. Its BAND shrank on 30/07/2026 — 2 930 um was the descent + // of the "Code EAN13" font at 34 pt, inherited exactly like the module — but it + // stays above the guard descent, without which HeightUM's max() swings back and + // the 730 um are given away for nothing. + if template.Symbol.HRIHeightUM != 2_700 { + t.Errorf("HRI = %d µm, want 2700", template.Symbol.HRIHeightUM) + } + if template.Symbol.HRIHeightUM <= template.Symbol.GuardDescentUM { + t.Errorf("bande HRI %d µm <= descente des gardes %d µm : le gain de hauteur s'évapore", + template.Symbol.HRIHeightUM, template.Symbol.GuardDescentUM) + } + + // 5. The truncation stays a documented decision, so the admin diagnostic stays + // informative rather than amber (ADR-003). + if !template.TruncationAccepted { + t.Error("truncation_accepted doit rester levé : la troncature est une décision, pas un défaut") + } + + bottom, right := template.inkedExtent(2) + t.Logf("contenu encré : %d,%03d dots de haut, %d,%03d de large (limites %d et %d)", + bottom/1000, bottom%1000, right/1000, right%1000, ReferenceInkedHeightDots, ReferenceInkedWidthDots) + t.Logf("barres uniformes : %d µm contre %d µm propres aujourd'hui (+%.0f %%)", + template.Symbol.BarHeightUM, cleanBarsToday, + 100*float64(int(template.Symbol.BarHeightUM)-cleanBarsToday)/cleanBarsToday) +} + +// TestIdenticalTemplateTextDoesNotReachTheSymbol is the geometric heart of ADR-029, +// asserted on the ink rather than on a rule verdict. +func TestIdenticalTemplateTextDoesNotReachTheSymbol(t *testing.T) { + template := IdenticalTemplate() + symbolTop := template.Symbol.YUM + + for _, e := range template.Elements { + if e.Field == FieldBarcode { + continue + } + if e.Bottom() > symbolTop { + t.Errorf("le champ %s descend à %d µm, le symbole commence à %d µm : "+ + "le texte recouvre encore les barres", e.Field, e.Bottom(), symbolTop) + } + } + + // And the two prices share a BASELINE, which the legacy report did not do — its + // two prices sat 774 um apart for no reason anyone could state. + var secondary, primary Element + for _, e := range template.Elements { + switch e.Field { + case FieldSecondaryTotalPrice: + secondary = e + case FieldPrimaryTotalPrice: + primary = e + } + } + const ascent = 750 // per mille + secondaryBaseline := secondary.YUM + secondary.FontSizeUM*ascent/1000 + primaryBaseline := primary.YUM + primary.FontSizeUM*ascent/1000 + if drift := secondaryBaseline - primaryBaseline; drift > 2 || drift < -2 { + t.Errorf("lignes de base : solidaire à %d µm, adhérent à %d µm (écart %d) — "+ + "les deux prix doivent partager leur ligne de base", + secondaryBaseline, primaryBaseline, drift) + } +} + +// TestIdenticalTemplateIsTheShippedDefault: config-lacagette.json selects it, so a +// rename must break here rather than at start-up on a station. +func TestIdenticalTemplateIsTheShippedDefault(t *testing.T) { + shipped := ShippedTemplates() + template, ok := shipped[DefaultTemplateName] + if !ok { + t.Fatalf("%q absent des gabarits livrés : %v", DefaultTemplateName, shipped) + } + if template.Name != DefaultTemplateName { + t.Errorf("le gabarit livré sous %q se nomme %q", DefaultTemplateName, template.Name) + } + if len(shipped) != 2 { + t.Errorf("%d gabarits livrés, want 2 (identical, neutral_single)", len(shipped)) + } + // Every shipped template names itself the way it is keyed. + for name, template := range shipped { + if template.Name != name { + t.Errorf("le gabarit %q se nomme %q", name, template.Name) + } + } +} diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go index 8eccfa2..8a3eef1 100644 --- a/internal/domain/template_test.go +++ b/internal/domain/template_test.go @@ -1,9 +1,13 @@ +// This file holds the ARITHMETIC of a layout: micrometres into milli-dots, the one +// definition of where the symbol block ends, the 113 modules of its over-all width, +// and how far a volunteer's offset may still go. +// +// What REFUSES a layout is template_validate_test.go; what the shipped ones are +// worth is template_shipped_test.go. + package domain -import ( - "strings" - "testing" -) +import "testing" func faultFields(faults []Fault) []string { out := make([]string, len(faults)) @@ -22,48 +26,6 @@ func hasFault(faults []Fault, field string) bool { return false } -// TestShippedTemplatesPassEveryRule: whatever else changes, a template we ship must -// load. The CI fails otherwise (§7.5). -func TestShippedTemplatesPassEveryRule(t *testing.T) { - for name, template := range ShippedTemplates() { - for _, tierCount := range []int{1, 2, 3} { - if faults := template.Validate(tierCount); len(faults) != 0 { - t.Errorf("%s with %d tier(s): %d faults", name, tierCount, len(faults)) - for _, f := range faults { - t.Errorf(" %s", f) - } - } - } - } -} - -// TestNeutralTemplateInkedExtentHasMargin: rule 3 is the one that bit before, when -// the document compared an Access box to an assumed media and failed the CI on the -// very template A1 requires. Here we assert the margin, so a future edit that eats -// it is visible. -func TestNeutralTemplateInkedExtentHasMargin(t *testing.T) { - template := NeutralSingleTemplate() - bottom, right := template.inkedExtent(2) - - bottomDots, rightDots := bottom/1000, right/1000 - t.Logf("contenu encré : %d dots de haut, %d dots de large (limites %d et %d)", - bottomDots, rightDots, ReferenceInkedHeightDots, ReferenceInkedWidthDots) - - if bottomDots > ReferenceInkedHeightDots { - t.Errorf("hauteur encrée %d > %d", bottomDots, ReferenceInkedHeightDots) - } - if rightDots > ReferenceInkedWidthDots { - t.Errorf("largeur encrée %d > %d", rightDots, ReferenceInkedWidthDots) - } - // The bottom is dominated by the symbol block, as on the production label. - symbolBottom := template.Media.MilliDots(template.Symbol.YUM + template.Symbol.HeightUM()) - if bottom != symbolBottom { - t.Errorf("le bas encré (%d) devrait être celui du symbole (%d)", bottom, symbolBottom) - } -} - -// --- The head declares the geometry, the core reads it --------------------------- - // ws412Head is a print head this parc does not own: 12 dots/mm, and the SAME label — // 35 × 25 mm — which at that pitch is 420 × 300 dots of ink. func ws412Head() PrinterCapabilities { @@ -84,96 +46,6 @@ func twelveDotTemplate() Template { return template } -// TestRulesThreeAndFourBearOnTheHeadAndNotOnAConstant: a station whose printer is not -// the WS408 of the parc used to fail its own validation AT START-UP, on a template -// nobody could make it accept — the two figures rules 3 and 4 compare to were held by -// the core. -func TestRulesThreeAndFourBearOnTheHeadAndNotOnAConstant(t *testing.T) { - template := twelveDotTemplate() - if faults := template.ValidateOn(ws412Head(), 2); len(faults) != 0 { - t.Fatalf("un gabarit à 12 dots/mm sur une tête à 12 dots/mm est refusé :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } - // The same layout is 419,736 dots wide at that pitch, so a head that inks 280 dots - // refuses it — which is the proof that the DECLARATION is what is read. - if faults := template.ValidateOn(ReferenceHead(), 2); len(faults) == 0 { - t.Fatal("un gabarit à 12 dots/mm passe sur une WS408") - } -} - -// TestRuleThreeMeasuresTheInkAgainstTheDeclaredHead: a head that inks a smaller area -// refuses a template the WS408 accepts, and it does so without a line of the core -// naming a number of dots. -func TestRuleThreeMeasuresTheInkAgainstTheDeclaredHead(t *testing.T) { - narrow := PrinterCapabilities{DotsPerMM: 8, InkedWidthDots: 200, InkedHeightDots: 200} - template := NeutralSingleTemplate() - - if faults := template.Validate(2); len(faults) != 0 { - t.Fatalf("le gabarit livré doit passer sur la tête du parc : %v", faults) - } - faults := template.ValidateOn(narrow, 2) - if !hasFault(faults, "inked_content") && !hasFault(faults, "symbol.module_milli_dots") { - t.Fatalf("une tête plus étroite accepte le gabarit : %v", faultFields(faults)) - } - for _, fault := range faults { - if strings.Contains(fault.Message, "200 dots") { - return - } - } - t.Errorf("aucun message ne nomme la largeur encrée déclarée par la tête :\n%s", - strings.Join(fieldsOf(faults), "\n")) -} - -// TestATemplateAndAHeadOfDifferentPitchesAreRefusedByName. -// -// A template that does not say which head it was measured for changes magnification in -// SILENCE: the module is the one length expressed in dots, so the same 2 344 milli-dots -// print 0.293 mm on a WS408 and 0.195 mm on a WS412 — under every GS1 floor, and no -// byte of the frame says so. The refusal names both figures, because a volunteer has to -// know which of the two to change. -func TestATemplateAndAHeadOfDifferentPitchesAreRefusedByName(t *testing.T) { - template := twelveDotTemplate() - faults := template.ValidateOn(ReferenceHead(), 2) - - fault := findFault(faults, "media.dots_per_mm") - if fault == nil { - t.Fatalf("l'attelage gabarit/tête n'est pas refusé : %v", faultFields(faults)) - } - for _, figure := range []string{"12 dots/mm", "8 dots/mm"} { - if !strings.Contains(fault.Message, figure) { - t.Errorf("le message ne nomme pas %s : %q", figure, fault.Message) - } - } - // And it stands INSTEAD of the geometric cascade it would cause: rules 3 and 4 - // compare dots counted at two different pitches, so their verdict would be noise. - if hasFault(faults, "inked_content") { - t.Errorf("les conséquences sont énumérées avec la cause :\n%s", - strings.Join(fieldsOf(faults), "\n")) - } -} - -// TestAHeadThatDeclaresNothingIsMeasuredAgainstTheParc: `preview` inks no paper, so it -// declares no geometry. The rules must not be suspended for it — the preview screen is -// where a volunteer sets the ±1 dot offsets, and one that accepted everything would let -// them settle on an adjustment the production driver refuses. -func TestAHeadThatDeclaresNothingIsMeasuredAgainstTheParc(t *testing.T) { - inksNothing := PrinterCapabilities{Raster: true} - template := NeutralSingleTemplate() - template.Symbol.YUM = 20_000 // squarely past the bottom of the label - - silent := template.ValidateOn(inksNothing, 2) - parc := template.ValidateOn(ReferenceHead(), 2) - if len(silent) != len(parc) || len(silent) == 0 { - t.Fatalf("un driver qui n'imprime rien ne valide pas comme la tête du parc :\n%s\n--- contre ---\n%s", - strings.Join(fieldsOf(silent), "\n"), strings.Join(fieldsOf(parc), "\n")) - } - for i := range silent { - if silent[i].String() != parc[i].String() { - t.Errorf("faute %d : %s\nattendu : %s", i, silent[i], parc[i]) - } - } -} - // TestMaxOffsetDotsAnswersForTheHeadInService: the ±1 dot arrows are bounded by the // geometry, and on a taller head there is more room. A margin computed against a // constant would refuse an adjustment the printer would have accepted. @@ -240,289 +112,6 @@ func TestSymbolTotalWidthIsOneHundredAndThirteenModules(t *testing.T) { } } -// TestRuleThreeRefusesContentBelowTheInkedHeight is rule 3 exercised in the -// direction that matters: a symbol pushed too low. -func TestRuleThreeRefusesContentBelowTheInkedHeight(t *testing.T) { - template := NeutralSingleTemplate() - // 200 dots is 25 000 um. The block is 13 805 um tall, so a symbol at 11 500 um ends - // at 25 305 um: a little over two dots past the paper. - template.Symbol.YUM = 11_500 - template.Elements[5].YUM = 11_500 - - faults := template.Validate(2) - if !hasFault(faults, "inked_content") { - t.Fatalf("faults = %v, want inked_content", faultFields(faults)) - } - for _, f := range faults { - if f.Field == "inked_content" { - if !strings.Contains(f.Message, "hauteur encrée") { - t.Errorf("message = %q, want the inked HEIGHT named", f.Message) - } - // The message must name the geometry of the EXISTING label, not a media. - if strings.Contains(f.Message, "média") { - t.Errorf("message = %q : aucune règle ne dépend du média déclaré", f.Message) - } - } - } -} - -// TestRuleFourRefusesASymbolWiderThanTheLabel: the over-all width, quiet zones -// included. -func TestRuleFourRefusesASymbolWiderThanTheLabel(t *testing.T) { - template := NeutralSingleTemplate() - // 113 modules must stay within 280 dots, so the module must stay under - // 2 477 milli-dots. 3 000 is well past it and still inside rule 9's bounds. - template.Symbol.ModuleMilliDots = 3_000 - - faults := template.Validate(2) - if !hasFault(faults, "symbol.module_milli_dots") { - t.Fatalf("faults = %v, want symbol.module_milli_dots", faultFields(faults)) - } -} - -// TestRuleFiveProtectsTheQuietZones: a field overlapping a quiet zone looks fine on -// a preview and makes the symbol unreadable at the till. -func TestRuleFiveProtectsTheQuietZones(t *testing.T) { - template := NeutralSingleTemplate() - // Drop the total price onto the top of the symbol. - template.Elements[4].YUM = 10_500 - - faults := template.Validate(2) - if len(faults) == 0 { - t.Fatal("un champ posé sur le symbole doit être refusé") - } - found := false - for _, f := range faults { - if strings.Contains(f.Message, "recouvre le symbole") { - found = true - } - } - if !found { - t.Errorf("faults = %v, want a fault naming the symbol overlap", faults) - } -} - -// TestRuleFiveCountsTheQuietZoneAndNotJustTheBars: the right quiet zone is 7 modules -// of nothing, and a field placed there breaks the symbol just as surely as one on a -// bar. -func TestRuleFiveCountsTheQuietZoneAndNotJustTheBars(t *testing.T) { - template := NeutralSingleTemplate() - symbol := template.Symbol - - // The bars end at 11 + 95 = 106 modules; the block ends at 113. Place a narrow - // field inside that last stretch, vertically level with the symbol. - barsEndUM := Micrometers(float64(106*symbol.ModuleMilliDots) / template.Media.DotsPerMM) - template.Elements = append(template.Elements, Element{ - Field: FieldQuantity, // any field: rule 7 sees a duplicate, rule 5 the overlap - XUM: barsEndUM + 100, YUM: symbol.YUM + 1_000, - WidthUM: 500, HeightUM: 500, FontSizeUM: 1_800, Align: AlignLeft, - }) - - faults := template.Validate(2) - overlap := false - for _, f := range faults { - if strings.Contains(f.Message, "zones de silence") || strings.Contains(f.Message, "recouvre le symbole") { - overlap = true - } - } - if !overlap { - t.Errorf("faults = %v, want the quiet zone to be protected", faults) - } -} - -// TestRuleSixAppliesTheOffsetBeforeValidating: the ±1 dot arrows must be bounded by -// the geometry, not merely by ±99. -func TestRuleSixAppliesTheOffsetBeforeValidating(t *testing.T) { - template := NeutralSingleTemplate() - - // The shipped template has a little room, so a small shift is fine. - template.OffsetYDots = 1 - if faults := template.Validate(2); len(faults) != 0 { - t.Errorf("un décalage de 1 dot doit passer : %v", faults) - } - - // A shift past the remaining margin must be refused, with the margin named. - unshifted := NeutralSingleTemplate() - maxX, maxY := unshifted.MaxOffsetDots(2) - t.Logf("marge disponible : %d dots en X, %d dots en Y", maxX, maxY) - if maxY <= 0 { - t.Fatal("le gabarit livré doit avoir de la marge verticale") - } - template.OffsetYDots = maxY + 2 - faults := template.Validate(2) - if !hasFault(faults, "inked_content") { - t.Errorf("un décalage de %d dots doit être refusé : %v", template.OffsetYDots, faultFields(faults)) - } - - // The offset must NOT mutate the receiver: validating twice must not shift twice. - before := template.Elements[0].YUM - template.Validate(2) - if template.Elements[0].YUM != before { - t.Error("Validate a déplacé les éléments du gabarit") - } -} - -// TestRuleSevenClosesTheFieldAndConditionLists: no template engine, so no injection -// and no rendering error at print time. -func TestRuleSevenClosesTheFieldAndConditionLists(t *testing.T) { - cases := []struct { - name string - mutate func(*Template) - field string - }{ - {"champ inconnu", func(t *Template) { t.Elements[0].Field = "product_photo" }, "elements[0].field"}, - {"condition inconnue", func(t *Template) { t.Elements[0].When = "if_expensive" }, "elements[0].when"}, - {"alignement inconnu", func(t *Template) { t.Elements[0].Align = "center" }, "elements[0].align"}, - {"champ en double", func(t *Template) { t.Elements[1].Field = FieldProductName }, "elements[1].field"}, - {"boîte de largeur nulle", func(t *Template) { t.Elements[0].WidthUM = 0 }, "elements[0]"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - template := NeutralSingleTemplate() - c.mutate(&template) - faults := template.Validate(2) - if !hasFault(faults, c.field) { - t.Errorf("faults = %v, want a fault on %s", faultFields(faults), c.field) - } - // A closed list must SAY what it accepts: a volunteer reading "champ - // inconnu" with no list has nowhere to go. - for _, f := range faults { - if f.Field == c.field && strings.Contains(f.Message, "inconnu") && len(f.Values) == 0 { - t.Errorf("la faute %q ne propose aucune valeur admissible", f.Message) - } - } - }) - } -} - -// TestRuleEightRefusesOverlappingFields: two fields on top of each other print as an -// unreadable smudge. -func TestRuleEightRefusesOverlappingFields(t *testing.T) { - template := NeutralSingleTemplate() - // Put the quantity on top of the product name. - template.Elements[1].YUM = 1_000 - - faults := template.Validate(2) - found := false - for _, f := range faults { - if strings.Contains(f.Message, "recouvre le champ") { - found = true - } - } - if !found { - t.Errorf("faults = %v, want an overlap fault", faults) - } -} - -// TestTouchingEdgesDoNotOverlap: the shipped template lays the quantity and the unit -// price side by side, and abutting boxes must be legal. -func TestTouchingEdgesDoNotOverlap(t *testing.T) { - template := NeutralSingleTemplate() - quantity, unitPrice := template.Elements[1], template.Elements[2] - - // Make them exactly abut. - template.Elements[2].XUM = quantity.Right() - template.Elements[2].WidthUM = unitPrice.Right() - quantity.Right() - - if faults := template.Validate(2); len(faults) != 0 { - t.Errorf("des boîtes jointives doivent être légales : %v", faults) - } -} - -// TestConditionalFieldIsIgnoredWhenInactive: a mono-tarif station must not be refused -// a template because of a field that will never be drawn on it. -func TestConditionalFieldIsIgnoredWhenInactive(t *testing.T) { - template := NeutralSingleTemplate() - // Put the conditional secondary price somewhere illegal. - for i := range template.Elements { - if template.Elements[i].Field == FieldSecondaryTotalPrice { - template.Elements[i].YUM = 11_000 // squarely on the symbol - } - } - - if faults := template.Validate(1); len(faults) != 0 { - t.Errorf("mono-tarif : le champ conditionnel n'est pas dessiné, donc il ne peut pas fauter : %v", faults) - } - if faults := template.Validate(2); len(faults) == 0 { - t.Error("double tarif : le même champ est dessiné, et il doit fauter") - } -} - -// TestRuleNineBoundsTheModuleAndTheTypeSize. -func TestRuleNineBoundsTheModuleAndTheTypeSize(t *testing.T) { - for _, c := range []struct { - name string - mutate func(*Template) - field string - }{ - // 2112 milli-dots = 264 um at 8 dots/mm, the GS1 floor; 5280 = 660 um, the - // ceiling. One milli-dot outside either is one micrometre outside the range, - // which is what rule 9 now measures. - {"module sous le plancher GS1", func(t *Template) { t.Symbol.ModuleMilliDots = 2_100 }, "symbol.module_milli_dots"}, - {"module au-dessus du plafond GS1", func(t *Template) { t.Symbol.ModuleMilliDots = 5_300 }, "symbol.module_milli_dots"}, - // Gabarit B, retired on 30/07/2026: 2 dots is 250 um, 75.8 % magnification. - // It used to be a SHIPPED template that Validate accepted. - {"module entier de 2 dots", func(t *Template) { t.Symbol.ModuleMilliDots = 2_000 }, "symbol.module_milli_dots"}, - {"corps sous le plancher", func(t *Template) { t.Elements[0].FontSizeUM = MinFontSizeUM - 1 }, "elements[0].font_size_um"}, - {"corps minimal sous le plancher", func(t *Template) { t.Elements[0].MinFontSizeUM = 100 }, "elements[0].min_font_size_um"}, - {"corps minimal au-dessus du nominal", func(t *Template) { t.Elements[0].MinFontSizeUM = 9_000 }, "elements[0].min_font_size_um"}, - } { - t.Run(c.name, func(t *testing.T) { - template := NeutralSingleTemplate() - c.mutate(&template) - if faults := template.Validate(2); !hasFault(faults, c.field) { - t.Errorf("faults = %v, want %s", faultFields(faults), c.field) - } - }) - } - - // The bounds themselves are reachable: a rule nobody can satisfy is a bug. - template := NeutralSingleTemplate() - template.Elements[0].FontSizeUM = MinFontSizeUM - template.Elements[0].MinFontSizeUM = MinFontSizeUM - if faults := template.Validate(2); len(faults) != 0 { - t.Errorf("le plancher exact doit être admis : %v", faults) - } -} - -// TestRulesOneAndTwoBearOnTheDeclaredGeometry: unlike rules 3 and 4, which bear on -// measured ink, these two check the operator's own statement about their printer. -func TestRulesOneAndTwoBearOnTheDeclaredGeometry(t *testing.T) { - template := NeutralSingleTemplate() - template.PrintableWidthUM = 50_000 // wider than the media - if faults := template.Validate(2); !hasFault(faults, "printable_area") { - t.Errorf("faults = %v, want printable_area", faultFields(faults)) - } - - template = NeutralSingleTemplate() - template.PrintableHeightUM = 12_000 // half the label - faults := template.Validate(2) - if len(faults) == 0 { - t.Error("des champs hors de la zone imprimable doivent être refusés") - } - for _, f := range faults { - if strings.Contains(f.Message, "zone imprimable") { - return - } - } - t.Errorf("faults = %v, want a printable-area fault", faults) -} - -// TestValidateRefusesAnAbsurdResolutionFirst: every geometric rule divides the world -// by dots_per_mm, so a zero there must produce ONE fault and not a page of them. -func TestValidateRefusesAnAbsurdResolutionFirst(t *testing.T) { - template := NeutralSingleTemplate() - template.Media.DotsPerMM = 0 - - faults := template.Validate(2) - if len(faults) != 1 { - t.Errorf("%d fautes, want 1 : une résolution nulle doit nommer sa cause, pas ses conséquences", len(faults)) - } - if !hasFault(faults, "media.dots_per_mm") { - t.Errorf("faults = %v, want media.dots_per_mm", faultFields(faults)) - } -} - // TestMilliDotConversionIsAPlainMultiplication: a micrometre is a thousandth of a // millimetre, so um x dots/mm is already thousandths of a dot. This is what keeps // rules 3 and 4 free of any rounding argument. @@ -548,99 +137,6 @@ func TestMilliDotConversionIsAPlainMultiplication(t *testing.T) { } } -// TestGabaritBIsRetiredAndCannotComeBack is the other half of removing it: a template -// that no rule refuses comes back the day someone needs a quick experiment, and gabarit -// B's whole problem was that its winning arm could never be adopted (75.8 %, below the -// GS1 floor). Rule 9 now says so, so the removal is enforced rather than merely done. -func TestGabaritBIsRetiredAndCannotComeBack(t *testing.T) { - shipped := ShippedTemplates() - if _, found := shipped["weighing_integer_module"]; found { - t.Error("weighing_integer_module est encore livré") - } - if len(shipped) != 2 { - t.Errorf("%d gabarits livrés, attendu 2 : %v", len(shipped), shipped) - } - - b := NeutralSingleTemplate() - b.Symbol.ModuleMilliDots = 2_000 // 2 dots exactly = 250 um at 8 dots/mm - faults := b.Validate(1) - if len(faults) == 0 { - t.Fatal("un module de 2 dots vaut 250 µm, soit 75,8 % : la règle 9 doit le refuser") - } - t.Logf("refusé, et la raison est lisible : %v", faults[0]) -} - -// TestRuleNineMeasuresTheModuleInMicrometresNotDots is what the [1500, 6000] milli-dot -// pair could not do. The SAME template is conforming on one head and not on the other, -// and a rule written in units of resolution cannot tell them apart. -func TestRuleNineMeasuresTheModuleInMicrometresNotDots(t *testing.T) { - sym := SymbolGeometry{ModuleMilliDots: 2_344} - if um := sym.ModuleUM(8); um != 293 { - t.Errorf("2 344 milli-dots à 8 dots/mm = %d µm, attendu 293", um) - } - if um := sym.ModuleUM(12); um != 195 { - t.Errorf("2 344 milli-dots à 12 dots/mm = %d µm, attendu 195", um) - } - // 293 um is 88.8 %, inside the range; 195 um is 59.1 %, under every GS1 floor — - // and the old bounds accepted 2 344 on both heads without a word. - if m := sym.Magnification(8); m < 0.887 || m > 0.889 { - t.Errorf("grandissement à 8 dots/mm = %.4f, attendu ~0,888", m) - } - if sym.ModuleUM(8) < GS1MinModuleUM { - t.Error("293 µm doit être dans la plage GS1") - } - if sym.ModuleUM(12) >= GS1MinModuleUM { - t.Error("195 µm doit être sous le plancher GS1") - } -} - -// TestMeasuredProductionGeometry freezes what was read out of the test PDF. -// -// reference/test_etiquette_EtataImprimer.pdf was decompressed and its content stream -// parsed. The six control boxes of §7.2 are confirmed to within 40 um — under a third -// of a dot. Two things the document does not say came out of it, and both belong to -// L4: -// -// - the symbol does NOT start at the top of its control box. §7.2 gives 8 996 um, -// which is the box; the glyph's baseline is at 21 326 um and rises 0.977 em, so -// the bars begin at 9 604 um. The block height is exactly the stated 14 650 um — -// it is the origin that is off by 608 um, and the inked content therefore reaches -// 194 dots rather than 189; -// - the production boxes overlap each other and the symbol box, because an Access -// control carries its leading inside its height. -// -// This test does not exercise production code. It exists so the figures survive in -// the repository rather than in a terminal, and so that L4 starts from a measurement -// instead of from a transcription. -func TestMeasuredProductionGeometry(t *testing.T) { - const ( - measuredBaselineUM = 21_326 // baseline of the 34 pt barcode glyph - measuredBarTopUM = 9_604 // baseline - 0.977 em - measuredHRIBottom = 24_254 // baseline + 0.244 em - documentedOriginUM = 8_996 // §7.2: the top of the CodeBarre control box - ) - - if got := measuredHRIBottom - measuredBarTopUM; got != 14_650 { - t.Errorf("hauteur mesurée du bloc = %d µm, la documentation dit 14650", got) - } - if drift := measuredBarTopUM - documentedOriginUM; drift != 608 { - t.Errorf("écart origine documentée / origine mesurée = %d µm, want 608", drift) - } - - // What that means for rule 3, on the production geometry: 194 dots, not 189. - inked := Media{DotsPerMM: 8}.MilliDots(measuredHRIBottom) / 1000 - if inked != 194 { - t.Errorf("contenu encré mesuré = %d dots, want 194", inked) - } - if inked > ReferenceInkedHeightDots { - t.Errorf("le contenu encré mesuré (%d dots) dépasse la hauteur encrée de l'étiquette (%d)", - inked, ReferenceInkedHeightDots) - } - t.Logf("géométrie de production mesurée : symbole de %d à %d µm, contenu encré %d dots, "+ - "marge %d dots sous les %d dots de l'étiquette", - measuredBarTopUM, measuredHRIBottom, inked, ReferenceInkedHeightDots-int(inked), ReferenceInkedHeightDots) -} - // TestFaultStringNamesItsField: a fault reaches a volunteer through the admin // screen and reaches whoever answers the telephone through a log line. Both need // the field named. @@ -686,168 +182,3 @@ func TestFormatMilliDotsReadsAsDots(t *testing.T) { } } } - -// TestNegativeCoordinatesAreRefused: an offset can push an element off the top-left -// of the label, and rule 1 must say so rather than letting the renderer clip it. -func TestNegativeCoordinatesAreRefused(t *testing.T) { - template := NeutralSingleTemplate() - template.OffsetYDots = -5 - - faults := template.Validate(2) - if len(faults) == 0 { - t.Fatal("un décalage négatif qui sort de l'étiquette doit être refusé") - } - named := false - for _, f := range faults { - if strings.Contains(f.Message, "hors de l'étiquette") { - named = true - } - } - if !named { - t.Errorf("faults = %v, want a fault naming the element as being off the label", faults) - } -} - -// TestIdenticalTemplateHasUniformBars is ADR-029 made checkable. -// -// The decision the commissioning party took: stop putting the two prices ON the bars, -// stack the text instead, and put the symbol below it. The bars become uniform over -// the full width AND taller in usable terms. -func TestIdenticalTemplateHasUniformBars(t *testing.T) { - template := IdenticalTemplate() - - // 1. Nothing overlaps the symbol any more — which is what makes rule 5 - // satisfiable for the production template at all. - if faults := template.Validate(2); len(faults) != 0 { - t.Errorf("double tarif : %d fautes", len(faults)) - for _, f := range faults { - t.Errorf(" %s", f) - } - } - if faults := template.Validate(1); len(faults) != 0 { - t.Errorf("mono-tarif : %d fautes", len(faults)) - for _, f := range faults { - t.Errorf(" %s", f) - } - } - - // 2. The usable bar height BEATS what the current label achieves. Measured on the - // test PDF: 11 722 um of bars declared, of which the solidarity price eats - // 1 192 um and the member price 3 381 um, leaving 8 341 um clean. - const cleanBarsToday = 10_875 - 2_534 // 8 341 um, spelled out from the measurement - if got := template.Symbol.BarHeightUM; got <= cleanBarsToday { - t.Errorf("barres = %d µm, want plus que les %d µm réellement propres aujourd'hui", - got, cleanBarsToday) - } - // 11 875 and not 10 875 since 30/07/2026, when the commissioning party reopened A1. - // The extra 1 000 um came from the leading (277 -> 150) and the HRI band - // (2 930 -> 2 200), both inherited from the Access report, and NOT from the text: - // the solidarity price had just been raised to 11 pt for legibility. - if got := template.Symbol.BarHeightUM; got != 11_375 { - t.Errorf("barres = %d µm, want 11375 (91 dots exactement à 8 dots/mm)", got) - } - // 95 dots exactly: a whole number of dots, so no bar is a fraction of a scan line. - if milliDots := template.Media.MilliDots(template.Symbol.BarHeightUM); milliDots%1000 != 0 { - t.Errorf("hauteur de barres = %d milli-dots : elle doit être un compte entier de dots", milliDots) - } - - // 3. The module has NOT moved, and the reason changed on 30/07/2026: it is no - // longer "A1 freezes it" but "no integer module lands in the GS1 range at this - // pitch either" (ADR-002). The number survived its own justification. - if got := template.Symbol.ModuleMilliDots; got != 2_344 { - t.Errorf("module = %d, want 2344", got) - } - if got := template.Symbol.TotalWidthMilliDots(); got != 264_872 { - t.Errorf("hors-tout = %d milli-dots, want 264872 (33,109 mm)", got) - } - // And it is inside the GS1 range measured against the head, which is what rule 9 - // now checks rather than a milli-dot pair that meant nothing physical. - if um := template.Symbol.ModuleUM(template.Media.DotsPerMM); um < GS1MinModuleUM || um > GS1MaxModuleUM { - t.Errorf("module = %d µm, hors de la plage GS1 [%d ; %d]", um, GS1MinModuleUM, GS1MaxModuleUM) - } - - // 4. The HRI survives: it is printed today, and dropping it would take away the - // cashier's fallback. Its BAND shrank on 30/07/2026 — 2 930 um was the descent - // of the "Code EAN13" font at 34 pt, inherited exactly like the module — but it - // stays above the guard descent, without which HeightUM's max() swings back and - // the 730 um are given away for nothing. - if template.Symbol.HRIHeightUM != 2_700 { - t.Errorf("HRI = %d µm, want 2700", template.Symbol.HRIHeightUM) - } - if template.Symbol.HRIHeightUM <= template.Symbol.GuardDescentUM { - t.Errorf("bande HRI %d µm <= descente des gardes %d µm : le gain de hauteur s'évapore", - template.Symbol.HRIHeightUM, template.Symbol.GuardDescentUM) - } - - // 5. The truncation stays a documented decision, so the admin diagnostic stays - // informative rather than amber (ADR-003). - if !template.TruncationAccepted { - t.Error("truncation_accepted doit rester levé : la troncature est une décision, pas un défaut") - } - - bottom, right := template.inkedExtent(2) - t.Logf("contenu encré : %d,%03d dots de haut, %d,%03d de large (limites %d et %d)", - bottom/1000, bottom%1000, right/1000, right%1000, ReferenceInkedHeightDots, ReferenceInkedWidthDots) - t.Logf("barres uniformes : %d µm contre %d µm propres aujourd'hui (+%.0f %%)", - template.Symbol.BarHeightUM, cleanBarsToday, - 100*float64(int(template.Symbol.BarHeightUM)-cleanBarsToday)/cleanBarsToday) -} - -// TestIdenticalTemplateTextDoesNotReachTheSymbol is the geometric heart of ADR-029, -// asserted on the ink rather than on a rule verdict. -func TestIdenticalTemplateTextDoesNotReachTheSymbol(t *testing.T) { - template := IdenticalTemplate() - symbolTop := template.Symbol.YUM - - for _, e := range template.Elements { - if e.Field == FieldBarcode { - continue - } - if e.Bottom() > symbolTop { - t.Errorf("le champ %s descend à %d µm, le symbole commence à %d µm : "+ - "le texte recouvre encore les barres", e.Field, e.Bottom(), symbolTop) - } - } - - // And the two prices share a BASELINE, which the legacy report did not do — its - // two prices sat 774 um apart for no reason anyone could state. - var secondary, primary Element - for _, e := range template.Elements { - switch e.Field { - case FieldSecondaryTotalPrice: - secondary = e - case FieldPrimaryTotalPrice: - primary = e - } - } - const ascent = 750 // per mille - secondaryBaseline := secondary.YUM + secondary.FontSizeUM*ascent/1000 - primaryBaseline := primary.YUM + primary.FontSizeUM*ascent/1000 - if drift := secondaryBaseline - primaryBaseline; drift > 2 || drift < -2 { - t.Errorf("lignes de base : solidaire à %d µm, adhérent à %d µm (écart %d) — "+ - "les deux prix doivent partager leur ligne de base", - secondaryBaseline, primaryBaseline, drift) - } -} - -// TestIdenticalTemplateIsTheShippedDefault: config-lacagette.json selects it, so a -// rename must break here rather than at start-up on a station. -func TestIdenticalTemplateIsTheShippedDefault(t *testing.T) { - shipped := ShippedTemplates() - template, ok := shipped[DefaultTemplateName] - if !ok { - t.Fatalf("%q absent des gabarits livrés : %v", DefaultTemplateName, shipped) - } - if template.Name != DefaultTemplateName { - t.Errorf("le gabarit livré sous %q se nomme %q", DefaultTemplateName, template.Name) - } - if len(shipped) != 2 { - t.Errorf("%d gabarits livrés, want 2 (identical, neutral_single)", len(shipped)) - } - // Every shipped template names itself the way it is keyed. - for name, template := range shipped { - if template.Name != name { - t.Errorf("le gabarit %q se nomme %q", name, template.Name) - } - } -} diff --git a/internal/domain/template_validate.go b/internal/domain/template_validate.go new file mode 100644 index 0000000..898a8c4 --- /dev/null +++ b/internal/domain/template_validate.go @@ -0,0 +1,220 @@ +package domain + +import "fmt" + +// This file holds the NINE HARD RULES of §7.5 -- everything a label layout has to +// satisfy before it may be loaded, measured against the head that will print it. +// +// A template naming a field that does not exist, or placing one over a quiet zone, +// is refused when it is LOADED and never when a customer is waiting. The geometry +// the rules read is template.go's; what they decide about it is here. + +// Validate reports every hard rule the template breaks ON THE HEAD OF THE FLEET; an +// empty slice means the template may be loaded. +// +// tierCount decides which conditional elements are active, because rules 3, 5 and 8 +// are about what is actually INKED: a mono-tarif station must not be refused a +// template because of a field that will never be drawn on it. +// +// A station validates against the head ITS OWN DRIVER declares (ValidateOn); this form +// is for every caller that has no descriptor in hand, and it answers for the WS408. +func (t *Template) Validate(tierCount int) []Fault { + return t.ValidateOn(ReferenceHead(), tierCount) +} + +// ValidateOn reports every hard rule the template breaks ON THIS HEAD. +// +// Rules 3 and 4 are the two that need one: they bound the ink by what the head can +// print, and holding that bound as a constant of the core made every station whose +// printer is not the WS408 of the parc fail its own validation at start-up. +// +// A head that declares nothing is measured against ReferenceHead, so a caller with no +// descriptor in hand — a test, a preview — validates exactly what it validated before. +func (t *Template) ValidateOn(head PrinterCapabilities, tierCount int) []Fault { + head = head.orReference() + var faults []Fault + fail := func(field, format string, args ...any) { + faults = append(faults, Fault{Field: field, Message: fmt.Sprintf(format, args...)}) + } + + if t.Media.DotsPerMM <= 0 { + fail("media.dots_per_mm", "doit être strictement positif (8 sur une WS408, 12 sur une WS412)") + // Every geometric rule below divides the world by this number. Carrying on + // would produce a page of faults that all say the same thing. + return faults + } + + // Rule 6: the offset is applied BEFORE validation, so a shift that would push a + // quiet zone off the label is refused rather than silently cropped. The ±1 dot + // arrows of the admin screen invite that adjustment; it must be bounded by the + // geometry and not merely by ±99. + shifted := t.withOffset() + + // The template and the head must count dots at the SAME pitch, and this is where a + // template measured for another one is caught. + // + // Symbol.ModuleMilliDots is the ONE length of a template expressed in units of + // resolution, deliberately: 0.293 mm is 2.344 dots, and that fractional module is + // the whole technical point of arbitration A2. The unit stays, so the AMBIGUITY has + // to go — the same 2 344 milli-dots print 0.293 mm on a WS408 and 0.195 mm on a + // WS412, under every GS1 floor, and no byte of the frame says so. The label simply + // comes out wrong. + pitchAgrees := head.DotsPerMM == t.Media.DotsPerMM + if !pitchAgrees { + fail("media.dots_per_mm", + "le gabarit est mesuré pour une tête de %g dots/mm et la tête d'impression en fait "+ + "%g dots/mm : à ce module le symbole sortirait à un autre grandissement", + t.Media.DotsPerMM, head.DotsPerMM) + } + + inkedWidth := int64(head.InkedWidthDots) * 1000 + inkedHeight := int64(head.InkedHeightDots) * 1000 + + // Rule 9, checked first: a template with an absurd module or an unreadable type + // size produces cascades of geometric faults, and naming the cause is kinder + // than naming ten consequences. + // + // The module is measured against the pitch the HEAD declares, so a template whose + // module is legal here is legal in millimetres, not merely in dots. Skipped when + // the two pitches disagree: the module would then be read at a resolution this + // template was never measured for, and naming ten consequences of a cause already + // named helps nobody. + if pitchAgrees { + if um := shifted.Symbol.ModuleUM(head.DotsPerMM); um < GS1MinModuleUM || um > GS1MaxModuleUM { + fail("symbol.module_milli_dots", + "%d milli-dots valent %d µm à %g dots/mm, soit un grandissement de %.1f %% : "+ + "hors de la plage GS1 [%d ; %d] µm (80 %% à 200 %% de la nominale de 330 µm)", + shifted.Symbol.ModuleMilliDots, um, head.DotsPerMM, + shifted.Symbol.Magnification(head.DotsPerMM)*100, GS1MinModuleUM, GS1MaxModuleUM) + } + } + for i, e := range shifted.Elements { + if e.Field == FieldBarcode { + continue // the symbol carries no type size of its own + } + if e.FontSizeUM < MinFontSizeUM { + fail(fmt.Sprintf("elements[%d].font_size_um", i), + "%d µm est sous le plancher de lisibilité de %d µm (%s)", + e.FontSizeUM, MinFontSizeUM, e.Field) + } + if e.MinFontSizeUM != 0 && e.MinFontSizeUM < MinFontSizeUM { + fail(fmt.Sprintf("elements[%d].min_font_size_um", i), + "%d µm est sous le plancher de %d µm", e.MinFontSizeUM, MinFontSizeUM) + } + if e.MinFontSizeUM > e.FontSizeUM { + fail(fmt.Sprintf("elements[%d].min_font_size_um", i), + "%d µm dépasse le corps nominal de %d µm", e.MinFontSizeUM, e.FontSizeUM) + } + } + + // Rule 7: closed lists. No template engine, so no injection and no rendering + // error at print time. + seen := make(map[string]bool, len(shifted.Elements)) + for i, e := range shifted.Elements { + if !known(knownFields, e.Field) { + fail(fmt.Sprintf("elements[%d].field", i), "champ inconnu %q", e.Field) + faults[len(faults)-1].Values = knownFields + } + if !known(knownConditions, e.When) { + fail(fmt.Sprintf("elements[%d].when", i), "condition inconnue %q", e.When) + faults[len(faults)-1].Values = knownConditions + } + if e.Field != FieldBarcode && !known(knownAlignments, e.Align) { + fail(fmt.Sprintf("elements[%d].align", i), "alignement inconnu %q", e.Align) + faults[len(faults)-1].Values = knownAlignments + } + if seen[e.Field] { + fail(fmt.Sprintf("elements[%d].field", i), "le champ %q est placé deux fois", e.Field) + } + seen[e.Field] = true + if e.WidthUM <= 0 || e.HeightUM <= 0 { + fail(fmt.Sprintf("elements[%d]", i), "le champ %q a une boîte de %d × %d µm", + e.Field, e.WidthUM, e.HeightUM) + } + } + + // Rules 1 and 2: boxes inside the printable area, printable area inside the + // media. Both bear on the DECLARED geometry, which is the operator's own + // statement about their printer -- unlike rules 3 and 4, which bear on measured + // ink. + if t.PrintableWidthUM > t.Media.WidthUM || t.PrintableHeightUM > t.Media.HeightUM { + fail("printable_area", "la zone imprimable (%d × %d µm) sort du média (%d × %d µm)", + t.PrintableWidthUM, t.PrintableHeightUM, t.Media.WidthUM, t.Media.HeightUM) + } + for i, e := range shifted.Elements { + if !e.Active(tierCount) { + continue + } + if e.XUM < 0 || e.YUM < 0 { + fail(fmt.Sprintf("elements[%d]", i), "le champ %q est placé en (%d ; %d) µm, hors de l'étiquette", + e.Field, e.XUM, e.YUM) + continue + } + if t.PrintableWidthUM > 0 && e.Right() > t.PrintableWidthUM { + fail(fmt.Sprintf("elements[%d]", i), "le champ %q dépasse la zone imprimable en largeur (%d > %d µm)", + e.Field, e.Right(), t.PrintableWidthUM) + } + if t.PrintableHeightUM > 0 && e.Bottom() > t.PrintableHeightUM { + fail(fmt.Sprintf("elements[%d]", i), "le champ %q dépasse la zone imprimable en hauteur (%d > %d µm)", + e.Field, e.Bottom(), t.PrintableHeightUM) + } + } + + // Rules 3 and 4 compare dots to dots, so they only mean anything once the two + // pitches agree. Enumerating them on top of a mismatch would name ten consequences + // of a cause already named, which is the reasoning the zero resolution above + // follows. + if pitchAgrees { + // Rule 3: THE INKED CONTENT FITS THE GEOMETRY OF THE EXISTING LABEL. + bottom, right := shifted.inkedExtent(tierCount) + if bottom > inkedHeight { + fail("inked_content", "le contenu encré descend à %s dots, au-delà des %d dots de hauteur encrée de l'étiquette", + formatMilliDots(bottom), head.InkedHeightDots) + } + if right > inkedWidth { + fail("inked_content", "le contenu encré s'étend à %s dots, au-delà des %d dots de largeur encrée de l'étiquette", + formatMilliDots(right), head.InkedWidthDots) + } + + // Rule 4: the over-all width of the symbol, quiet zones included. + if width := shifted.Symbol.TotalWidthMilliDots(); width > inkedWidth { + fail("symbol.module_milli_dots", + "le hors-tout du symbole (113 modules = %s dots) dépasse les %d dots de largeur encrée", + formatMilliDots(width), head.InkedWidthDots) + } + } + + // Rule 5: nothing may intersect the symbol, QUIET ZONES INCLUDED. A field + // overlapping a quiet zone does not look wrong on a preview; it makes the + // symbol unreadable at the till, which is far worse. + symbolBox := shifted.symbolBox() + for i, e := range shifted.Elements { + if e.Field == FieldBarcode || !e.Active(tierCount) { + continue + } + if overlaps(shifted.box(e), symbolBox) { + fail(fmt.Sprintf("elements[%d]", i), + "le champ %q recouvre le symbole ou l'une de ses zones de silence", e.Field) + } + } + + // Rule 8: no overlap between two active elements. Two fields on top of each + // other print as an unreadable smudge, and the preview is the only place anyone + // would notice. + for i := range shifted.Elements { + for j := i + 1; j < len(shifted.Elements); j++ { + a, b := shifted.Elements[i], shifted.Elements[j] + if a.Field == FieldBarcode || b.Field == FieldBarcode { + continue // rule 5 owns the symbol + } + if !a.Active(tierCount) || !b.Active(tierCount) { + continue + } + if overlaps(shifted.box(a), shifted.box(b)) { + fail(fmt.Sprintf("elements[%d]", j), "le champ %q recouvre le champ %q", b.Field, a.Field) + } + } + } + + return faults +} diff --git a/internal/domain/template_validate_test.go b/internal/domain/template_validate_test.go new file mode 100644 index 0000000..67b8763 --- /dev/null +++ b/internal/domain/template_validate_test.go @@ -0,0 +1,432 @@ +// This file holds the NINE HARD RULES of §7.5, one test per rule, plus the two +// things the rules are measured against: the head the DRIVER declares, and the +// pitch the template was measured for. +// +// A template and a head of different pitches are refused BY NAME, and that is the +// whole of ADR-045: the same 2 344 milli-dots print 0,293 mm on a WS408 and +// 0,195 mm on a WS412, under every GS1 floor, with no byte of the frame saying so. + +package domain + +import ( + "strings" + "testing" +) + +// TestRulesThreeAndFourBearOnTheHeadAndNotOnAConstant: a station whose printer is not +// the WS408 of the parc used to fail its own validation AT START-UP, on a template +// nobody could make it accept — the two figures rules 3 and 4 compare to were held by +// the core. +func TestRulesThreeAndFourBearOnTheHeadAndNotOnAConstant(t *testing.T) { + template := twelveDotTemplate() + if faults := template.ValidateOn(ws412Head(), 2); len(faults) != 0 { + t.Fatalf("un gabarit à 12 dots/mm sur une tête à 12 dots/mm est refusé :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } + // The same layout is 419,736 dots wide at that pitch, so a head that inks 280 dots + // refuses it — which is the proof that the DECLARATION is what is read. + if faults := template.ValidateOn(ReferenceHead(), 2); len(faults) == 0 { + t.Fatal("un gabarit à 12 dots/mm passe sur une WS408") + } +} + +// TestRuleThreeMeasuresTheInkAgainstTheDeclaredHead: a head that inks a smaller area +// refuses a template the WS408 accepts, and it does so without a line of the core +// naming a number of dots. +func TestRuleThreeMeasuresTheInkAgainstTheDeclaredHead(t *testing.T) { + narrow := PrinterCapabilities{DotsPerMM: 8, InkedWidthDots: 200, InkedHeightDots: 200} + template := NeutralSingleTemplate() + + if faults := template.Validate(2); len(faults) != 0 { + t.Fatalf("le gabarit livré doit passer sur la tête du parc : %v", faults) + } + faults := template.ValidateOn(narrow, 2) + if !hasFault(faults, "inked_content") && !hasFault(faults, "symbol.module_milli_dots") { + t.Fatalf("une tête plus étroite accepte le gabarit : %v", faultFields(faults)) + } + for _, fault := range faults { + if strings.Contains(fault.Message, "200 dots") { + return + } + } + t.Errorf("aucun message ne nomme la largeur encrée déclarée par la tête :\n%s", + strings.Join(fieldsOf(faults), "\n")) +} + +// TestATemplateAndAHeadOfDifferentPitchesAreRefusedByName. +// +// A template that does not say which head it was measured for changes magnification in +// SILENCE: the module is the one length expressed in dots, so the same 2 344 milli-dots +// print 0.293 mm on a WS408 and 0.195 mm on a WS412 — under every GS1 floor, and no +// byte of the frame says so. The refusal names both figures, because a volunteer has to +// know which of the two to change. +func TestATemplateAndAHeadOfDifferentPitchesAreRefusedByName(t *testing.T) { + template := twelveDotTemplate() + faults := template.ValidateOn(ReferenceHead(), 2) + + fault := findFault(faults, "media.dots_per_mm") + if fault == nil { + t.Fatalf("l'attelage gabarit/tête n'est pas refusé : %v", faultFields(faults)) + } + for _, figure := range []string{"12 dots/mm", "8 dots/mm"} { + if !strings.Contains(fault.Message, figure) { + t.Errorf("le message ne nomme pas %s : %q", figure, fault.Message) + } + } + // And it stands INSTEAD of the geometric cascade it would cause: rules 3 and 4 + // compare dots counted at two different pitches, so their verdict would be noise. + if hasFault(faults, "inked_content") { + t.Errorf("les conséquences sont énumérées avec la cause :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +// TestAHeadThatDeclaresNothingIsMeasuredAgainstTheParc: `preview` inks no paper, so it +// declares no geometry. The rules must not be suspended for it — the preview screen is +// where a volunteer sets the ±1 dot offsets, and one that accepted everything would let +// them settle on an adjustment the production driver refuses. +func TestAHeadThatDeclaresNothingIsMeasuredAgainstTheParc(t *testing.T) { + inksNothing := PrinterCapabilities{Raster: true} + template := NeutralSingleTemplate() + template.Symbol.YUM = 20_000 // squarely past the bottom of the label + + silent := template.ValidateOn(inksNothing, 2) + parc := template.ValidateOn(ReferenceHead(), 2) + if len(silent) != len(parc) || len(silent) == 0 { + t.Fatalf("un driver qui n'imprime rien ne valide pas comme la tête du parc :\n%s\n--- contre ---\n%s", + strings.Join(fieldsOf(silent), "\n"), strings.Join(fieldsOf(parc), "\n")) + } + for i := range silent { + if silent[i].String() != parc[i].String() { + t.Errorf("faute %d : %s\nattendu : %s", i, silent[i], parc[i]) + } + } +} + +// TestRuleThreeRefusesContentBelowTheInkedHeight is rule 3 exercised in the +// direction that matters: a symbol pushed too low. +func TestRuleThreeRefusesContentBelowTheInkedHeight(t *testing.T) { + template := NeutralSingleTemplate() + // 200 dots is 25 000 um. The block is 13 805 um tall, so a symbol at 11 500 um ends + // at 25 305 um: a little over two dots past the paper. + template.Symbol.YUM = 11_500 + template.Elements[5].YUM = 11_500 + + faults := template.Validate(2) + if !hasFault(faults, "inked_content") { + t.Fatalf("faults = %v, want inked_content", faultFields(faults)) + } + for _, f := range faults { + if f.Field == "inked_content" { + if !strings.Contains(f.Message, "hauteur encrée") { + t.Errorf("message = %q, want the inked HEIGHT named", f.Message) + } + // The message must name the geometry of the EXISTING label, not a media. + if strings.Contains(f.Message, "média") { + t.Errorf("message = %q : aucune règle ne dépend du média déclaré", f.Message) + } + } + } +} + +// TestRuleFourRefusesASymbolWiderThanTheLabel: the over-all width, quiet zones +// included. +func TestRuleFourRefusesASymbolWiderThanTheLabel(t *testing.T) { + template := NeutralSingleTemplate() + // 113 modules must stay within 280 dots, so the module must stay under + // 2 477 milli-dots. 3 000 is well past it and still inside rule 9's bounds. + template.Symbol.ModuleMilliDots = 3_000 + + faults := template.Validate(2) + if !hasFault(faults, "symbol.module_milli_dots") { + t.Fatalf("faults = %v, want symbol.module_milli_dots", faultFields(faults)) + } +} + +// TestRuleFiveProtectsTheQuietZones: a field overlapping a quiet zone looks fine on +// a preview and makes the symbol unreadable at the till. +func TestRuleFiveProtectsTheQuietZones(t *testing.T) { + template := NeutralSingleTemplate() + // Drop the total price onto the top of the symbol. + template.Elements[4].YUM = 10_500 + + faults := template.Validate(2) + if len(faults) == 0 { + t.Fatal("un champ posé sur le symbole doit être refusé") + } + found := false + for _, f := range faults { + if strings.Contains(f.Message, "recouvre le symbole") { + found = true + } + } + if !found { + t.Errorf("faults = %v, want a fault naming the symbol overlap", faults) + } +} + +// TestRuleFiveCountsTheQuietZoneAndNotJustTheBars: the right quiet zone is 7 modules +// of nothing, and a field placed there breaks the symbol just as surely as one on a +// bar. +func TestRuleFiveCountsTheQuietZoneAndNotJustTheBars(t *testing.T) { + template := NeutralSingleTemplate() + symbol := template.Symbol + + // The bars end at 11 + 95 = 106 modules; the block ends at 113. Place a narrow + // field inside that last stretch, vertically level with the symbol. + barsEndUM := Micrometers(float64(106*symbol.ModuleMilliDots) / template.Media.DotsPerMM) + template.Elements = append(template.Elements, Element{ + Field: FieldQuantity, // any field: rule 7 sees a duplicate, rule 5 the overlap + XUM: barsEndUM + 100, YUM: symbol.YUM + 1_000, + WidthUM: 500, HeightUM: 500, FontSizeUM: 1_800, Align: AlignLeft, + }) + + faults := template.Validate(2) + overlap := false + for _, f := range faults { + if strings.Contains(f.Message, "zones de silence") || strings.Contains(f.Message, "recouvre le symbole") { + overlap = true + } + } + if !overlap { + t.Errorf("faults = %v, want the quiet zone to be protected", faults) + } +} + +// TestRuleSixAppliesTheOffsetBeforeValidating: the ±1 dot arrows must be bounded by +// the geometry, not merely by ±99. +func TestRuleSixAppliesTheOffsetBeforeValidating(t *testing.T) { + template := NeutralSingleTemplate() + + // The shipped template has a little room, so a small shift is fine. + template.OffsetYDots = 1 + if faults := template.Validate(2); len(faults) != 0 { + t.Errorf("un décalage de 1 dot doit passer : %v", faults) + } + + // A shift past the remaining margin must be refused, with the margin named. + unshifted := NeutralSingleTemplate() + maxX, maxY := unshifted.MaxOffsetDots(2) + t.Logf("marge disponible : %d dots en X, %d dots en Y", maxX, maxY) + if maxY <= 0 { + t.Fatal("le gabarit livré doit avoir de la marge verticale") + } + template.OffsetYDots = maxY + 2 + faults := template.Validate(2) + if !hasFault(faults, "inked_content") { + t.Errorf("un décalage de %d dots doit être refusé : %v", template.OffsetYDots, faultFields(faults)) + } + + // The offset must NOT mutate the receiver: validating twice must not shift twice. + before := template.Elements[0].YUM + template.Validate(2) + if template.Elements[0].YUM != before { + t.Error("Validate a déplacé les éléments du gabarit") + } +} + +// TestRuleSevenClosesTheFieldAndConditionLists: no template engine, so no injection +// and no rendering error at print time. +func TestRuleSevenClosesTheFieldAndConditionLists(t *testing.T) { + cases := []struct { + name string + mutate func(*Template) + field string + }{ + {"champ inconnu", func(t *Template) { t.Elements[0].Field = "product_photo" }, "elements[0].field"}, + {"condition inconnue", func(t *Template) { t.Elements[0].When = "if_expensive" }, "elements[0].when"}, + {"alignement inconnu", func(t *Template) { t.Elements[0].Align = "center" }, "elements[0].align"}, + {"champ en double", func(t *Template) { t.Elements[1].Field = FieldProductName }, "elements[1].field"}, + {"boîte de largeur nulle", func(t *Template) { t.Elements[0].WidthUM = 0 }, "elements[0]"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + template := NeutralSingleTemplate() + c.mutate(&template) + faults := template.Validate(2) + if !hasFault(faults, c.field) { + t.Errorf("faults = %v, want a fault on %s", faultFields(faults), c.field) + } + // A closed list must SAY what it accepts: a volunteer reading "champ + // inconnu" with no list has nowhere to go. + for _, f := range faults { + if f.Field == c.field && strings.Contains(f.Message, "inconnu") && len(f.Values) == 0 { + t.Errorf("la faute %q ne propose aucune valeur admissible", f.Message) + } + } + }) + } +} + +// TestRuleEightRefusesOverlappingFields: two fields on top of each other print as an +// unreadable smudge. +func TestRuleEightRefusesOverlappingFields(t *testing.T) { + template := NeutralSingleTemplate() + // Put the quantity on top of the product name. + template.Elements[1].YUM = 1_000 + + faults := template.Validate(2) + found := false + for _, f := range faults { + if strings.Contains(f.Message, "recouvre le champ") { + found = true + } + } + if !found { + t.Errorf("faults = %v, want an overlap fault", faults) + } +} + +// TestTouchingEdgesDoNotOverlap: the shipped template lays the quantity and the unit +// price side by side, and abutting boxes must be legal. +func TestTouchingEdgesDoNotOverlap(t *testing.T) { + template := NeutralSingleTemplate() + quantity, unitPrice := template.Elements[1], template.Elements[2] + + // Make them exactly abut. + template.Elements[2].XUM = quantity.Right() + template.Elements[2].WidthUM = unitPrice.Right() - quantity.Right() + + if faults := template.Validate(2); len(faults) != 0 { + t.Errorf("des boîtes jointives doivent être légales : %v", faults) + } +} + +// TestConditionalFieldIsIgnoredWhenInactive: a mono-tarif station must not be refused +// a template because of a field that will never be drawn on it. +func TestConditionalFieldIsIgnoredWhenInactive(t *testing.T) { + template := NeutralSingleTemplate() + // Put the conditional secondary price somewhere illegal. + for i := range template.Elements { + if template.Elements[i].Field == FieldSecondaryTotalPrice { + template.Elements[i].YUM = 11_000 // squarely on the symbol + } + } + + if faults := template.Validate(1); len(faults) != 0 { + t.Errorf("mono-tarif : le champ conditionnel n'est pas dessiné, donc il ne peut pas fauter : %v", faults) + } + if faults := template.Validate(2); len(faults) == 0 { + t.Error("double tarif : le même champ est dessiné, et il doit fauter") + } +} + +// TestRuleNineBoundsTheModuleAndTheTypeSize. +func TestRuleNineBoundsTheModuleAndTheTypeSize(t *testing.T) { + for _, c := range []struct { + name string + mutate func(*Template) + field string + }{ + // 2112 milli-dots = 264 um at 8 dots/mm, the GS1 floor; 5280 = 660 um, the + // ceiling. One milli-dot outside either is one micrometre outside the range, + // which is what rule 9 now measures. + {"module sous le plancher GS1", func(t *Template) { t.Symbol.ModuleMilliDots = 2_100 }, "symbol.module_milli_dots"}, + {"module au-dessus du plafond GS1", func(t *Template) { t.Symbol.ModuleMilliDots = 5_300 }, "symbol.module_milli_dots"}, + // Gabarit B, retired on 30/07/2026: 2 dots is 250 um, 75.8 % magnification. + // It used to be a SHIPPED template that Validate accepted. + {"module entier de 2 dots", func(t *Template) { t.Symbol.ModuleMilliDots = 2_000 }, "symbol.module_milli_dots"}, + {"corps sous le plancher", func(t *Template) { t.Elements[0].FontSizeUM = MinFontSizeUM - 1 }, "elements[0].font_size_um"}, + {"corps minimal sous le plancher", func(t *Template) { t.Elements[0].MinFontSizeUM = 100 }, "elements[0].min_font_size_um"}, + {"corps minimal au-dessus du nominal", func(t *Template) { t.Elements[0].MinFontSizeUM = 9_000 }, "elements[0].min_font_size_um"}, + } { + t.Run(c.name, func(t *testing.T) { + template := NeutralSingleTemplate() + c.mutate(&template) + if faults := template.Validate(2); !hasFault(faults, c.field) { + t.Errorf("faults = %v, want %s", faultFields(faults), c.field) + } + }) + } + + // The bounds themselves are reachable: a rule nobody can satisfy is a bug. + template := NeutralSingleTemplate() + template.Elements[0].FontSizeUM = MinFontSizeUM + template.Elements[0].MinFontSizeUM = MinFontSizeUM + if faults := template.Validate(2); len(faults) != 0 { + t.Errorf("le plancher exact doit être admis : %v", faults) + } +} + +// TestRulesOneAndTwoBearOnTheDeclaredGeometry: unlike rules 3 and 4, which bear on +// measured ink, these two check the operator's own statement about their printer. +func TestRulesOneAndTwoBearOnTheDeclaredGeometry(t *testing.T) { + template := NeutralSingleTemplate() + template.PrintableWidthUM = 50_000 // wider than the media + if faults := template.Validate(2); !hasFault(faults, "printable_area") { + t.Errorf("faults = %v, want printable_area", faultFields(faults)) + } + + template = NeutralSingleTemplate() + template.PrintableHeightUM = 12_000 // half the label + faults := template.Validate(2) + if len(faults) == 0 { + t.Error("des champs hors de la zone imprimable doivent être refusés") + } + for _, f := range faults { + if strings.Contains(f.Message, "zone imprimable") { + return + } + } + t.Errorf("faults = %v, want a printable-area fault", faults) +} + +// TestValidateRefusesAnAbsurdResolutionFirst: every geometric rule divides the world +// by dots_per_mm, so a zero there must produce ONE fault and not a page of them. +func TestValidateRefusesAnAbsurdResolutionFirst(t *testing.T) { + template := NeutralSingleTemplate() + template.Media.DotsPerMM = 0 + + faults := template.Validate(2) + if len(faults) != 1 { + t.Errorf("%d fautes, want 1 : une résolution nulle doit nommer sa cause, pas ses conséquences", len(faults)) + } + if !hasFault(faults, "media.dots_per_mm") { + t.Errorf("faults = %v, want media.dots_per_mm", faultFields(faults)) + } +} + +// TestRuleNineMeasuresTheModuleInMicrometresNotDots is what the [1500, 6000] milli-dot +// pair could not do. The SAME template is conforming on one head and not on the other, +// and a rule written in units of resolution cannot tell them apart. +func TestRuleNineMeasuresTheModuleInMicrometresNotDots(t *testing.T) { + sym := SymbolGeometry{ModuleMilliDots: 2_344} + if um := sym.ModuleUM(8); um != 293 { + t.Errorf("2 344 milli-dots à 8 dots/mm = %d µm, attendu 293", um) + } + if um := sym.ModuleUM(12); um != 195 { + t.Errorf("2 344 milli-dots à 12 dots/mm = %d µm, attendu 195", um) + } + // 293 um is 88.8 %, inside the range; 195 um is 59.1 %, under every GS1 floor — + // and the old bounds accepted 2 344 on both heads without a word. + if m := sym.Magnification(8); m < 0.887 || m > 0.889 { + t.Errorf("grandissement à 8 dots/mm = %.4f, attendu ~0,888", m) + } + if sym.ModuleUM(8) < GS1MinModuleUM { + t.Error("293 µm doit être dans la plage GS1") + } + if sym.ModuleUM(12) >= GS1MinModuleUM { + t.Error("195 µm doit être sous le plancher GS1") + } +} + +// TestNegativeCoordinatesAreRefused: an offset can push an element off the top-left +// of the label, and rule 1 must say so rather than letting the renderer clip it. +func TestNegativeCoordinatesAreRefused(t *testing.T) { + template := NeutralSingleTemplate() + template.OffsetYDots = -5 + + faults := template.Validate(2) + if len(faults) == 0 { + t.Fatal("un décalage négatif qui sort de l'étiquette doit être refusé") + } + named := false + for _, f := range faults { + if strings.Contains(f.Message, "hors de l'étiquette") { + named = true + } + } + if !named { + t.Errorf("faults = %v, want a fault naming the element as being off the label", faults) + } +} diff --git a/internal/domain/transition_invariants_test.go b/internal/domain/transition_invariants_test.go new file mode 100644 index 0000000..d8b0c83 --- /dev/null +++ b/internal/domain/transition_invariants_test.go @@ -0,0 +1,477 @@ +// This file holds the invariants of §6.7 -- the properties that must hold whatever +// the scenario, and that no single scenario can establish. +// +// 1: Cancel clears the selection from every state. +// 2: exactly one label per cycle, and a PrintEffect only from Validating or a +// reprint. +// 3: the frozen weight never moves once Validating was entered. +// 4: no second cycle without passing through Idle. +// 8: arming is bounded, and an empty plate always brings the station home. + +package domain + +import ( + "testing" + "time" +) + +// TestTransitionCancelAlwaysClearsTheSelection is invariant 1 of §6.7: from any +// state, Cancel leads to a model where CurrentProduct is nil and Label is nil. +// +// It is checked on the FULL seeds -- the ones that actually carry a product and a +// label -- because on an empty model the invariant is true of nothing. +func TestTransitionCancelAlwaysClearsTheSelection(t *testing.T) { + ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} + states := map[State]bool{} + for _, seed := range modelSeeds(t) { + if seed.CurrentProduct == nil { + continue // the bare seeds prove nothing here + } + states[seed.State] = true + next, _ := Transition(seed, Cancel{}, ctx) + if next.CurrentProduct != nil { + t.Errorf("Cancel from %s left a product selected", seed.State) + } + if next.Label != nil { + t.Errorf("Cancel from %s left a label", seed.State) + } + if next.Tare != 0 || next.Diagnostics != nil { + t.Errorf("Cancel from %s left a tare or diagnostics behind", seed.State) + } + switch seed.State { + case OutOfService, ScaleLost: + // Publishing Idle would say "ready to weigh" about a station that is + // not: OutOfService is terminal and ScaleLost still has no scale. + if next.State != seed.State { + t.Errorf("Cancel moved %s to %s", seed.State, next.State) + } + default: + if next.State != Idle { + t.Errorf("Cancel from %s reached %s, want idle", seed.State, next.State) + } + } + } + if len(states) != 16 { + t.Fatalf("Cancel was exercised from %d states, want 16", len(states)) + } +} + +// TestTransitionCancelKeepsTheReprintBarAlive: a cancelled selection is not a +// cancelled label. The bottom bar is PERMANENT (§14.3), so what outlives the cycle +// has to outlive Cancel too. +func TestTransitionCancelKeepsTheReprintBarAlive(t *testing.T) { + r := nominalCycle(t) + before := *r.m.LastLabel + r.send(Cancel{}) + if r.m.LastLabel == nil || r.m.LastLabel.Barcode != before.Barcode { + t.Fatalf("Cancel forgot the last label") + } + if !r.m.LastPrintedAt.Equal(r.ctx.Now) && r.m.LastPrintedAt.IsZero() { + t.Fatal("Cancel forgot when the last label was printed") + } +} + +// TestTransitionNominalCycleReproducesTheReferenceVector is the numeric contract of +// §16.1 walked through the machine rather than through Price alone: 1,236 kg of +// garlic at 5,32 €/kg solidarity gives 6,58 / 5,92 / 4,79, and the barcode is +// 0493021012365. +func TestTransitionNominalCycleReproducesTheReferenceVector(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + effects := r.at(400*time.Millisecond).tap("894", "01J-TAP") + + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("no PrintEffect: %s, %#v", r.m.State, effects) + } + if got := print.Label.Barcode; got != "0493021012365" { + t.Errorf("barcode %s, want 0493021012365", got) + } + if print.Reprint { + t.Error("a first print is not a reprint") + } + for _, want := range []struct { + code string + unitPrice Cents + amount Cents + }{ + {"MEMBER", 479, 592}, + {"SOLIDARITY", 532, 658}, + } { + line := print.Label.Find(want.code) + if line == nil { + t.Fatalf("tier %s missing from the label", want.code) + } + if line.UnitPrice != want.unitPrice || line.Amount != want.amount { + t.Errorf("%s: %d cents/kg and %d cents, want %d and %d", + want.code, line.UnitPrice, line.Amount, want.unitPrice, want.amount) + } + } + if print.Label.NetWeight != 1236 { + t.Errorf("net weight %d g, want 1236", print.Label.NetWeight) + } + // The frozen weight is the one that was printed, and the job id is the key the + // front generated on pointerdown. + if r.m.LatchedWeight.Gross != 1236 { + t.Errorf("frozen gross %d g, want 1236", r.m.LatchedWeight.Gross) + } + if print.Label.JobID != "01J-TAP" { + t.Errorf("job id %q, want the idempotency key", print.Label.JobID) + } + ack, ok := findEffect[AckEffect](effects) + if !ok || !ack.Ack.Accepted || ack.Ack.JobID != "01J-TAP" { + t.Errorf("the accepted ack does not carry the job id: %#v", ack) + } +} + +// TestTransitionPrintsExactlyOneLabelPerCycle is invariant 2 of §6.7: one +// PrintEffect per cycle, and it comes out of Validating. +// +// The repeats matter more than the single print does: a measurement that keeps +// arriving while the label is being printed, and a second tap on the same bag, are +// the two ways a station hands a customer two labels for one weighing. +func TestTransitionPrintsExactlyOneLabelPerCycle(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + prints := countEffect[PrintEffect](r.at(400*time.Millisecond).tap("894", "01J-TAP")) + if prints != 1 { + t.Fatalf("the tap emitted %d PrintEffect, want 1", prints) + } + + extra := 0 + for i := 1; i <= 5; i++ { + at := time.Duration(400+i*100) * time.Millisecond + extra += countEffect[PrintEffect](r.at(at).measure(1236, Stable)) + extra += countEffect[PrintEffect](r.at(at).tap("894", "01J-AGAIN")) + extra += countEffect[PrintEffect](r.at(at).send(Tick{})) + } + if extra != 0 { + t.Fatalf("%d extra labels came out while printing", extra) + } + + r.at(time.Second).send(PrintFinished{JobID: "01J-TAP", Duration: 30 * time.Millisecond}) + for i := 1; i <= 5; i++ { + at := time.Second + time.Duration(i*100)*time.Millisecond + extra += countEffect[PrintEffect](r.at(at).measure(1236, Stable)) + extra += countEffect[PrintEffect](r.at(at).tap("894", "01J-AGAIN")) + } + if extra != 0 { + t.Fatalf("%d extra labels came out on the same bag after success", extra) + } +} + +// TestTransitionEmitsPrintEffectOnlyFromValidatingOrAReprint walks the whole +// cartesian product and checks WHERE a PrintEffect can come from. +// +// The reprint is the one exception, and it is written down rather than tolerated: a +// reprint is a deliberate duplicate of an ALREADY VALIDATED label, it carries the +// RÉIMPRESSION mention, and re-validating it would refuse it for +// MEASUREMENT_EXPIRED -- the very code that protects the first print. +func TestTransitionEmitsPrintEffectOnlyFromValidatingOrAReprint(t *testing.T) { + ctx := TransitionContext{ + Cfg: machineConfig(), Now: origin.Add(200 * time.Millisecond), + LastMeasurement: Measurement{Gross: 1236, Stability: Stable, Timestamp: origin, Seq: 4}, + MeasurementAge: 200 * time.Millisecond, Expiry: 1200 * time.Millisecond, + Catalog: machineCatalog(t), + } + reprints, validations := 0, 0 + for _, ev := range allEvents(t) { + for _, seed := range modelSeeds(t) { + next, effects := Transition(seed, ev, ctx) + print, ok := findEffect[PrintEffect](effects) + if !ok { + continue + } + if next.State != Printing { + t.Errorf("(%s, %T) emitted a label while reaching %s", seed.State, ev, next.State) + } + if print.Reprint { + reprints++ + if _, isReprint := ev.(ReprintRequested); !isReprint { + t.Errorf("(%s, %T) emitted a reprint", seed.State, ev) + } + continue + } + validations++ + switch ev.(type) { + case ProductTapped, MeasurementReceived, ManualWeightConfirmed, Tick: + default: + t.Errorf("(%s, %T) emitted a first label outside a validating trigger", + seed.State, ev) + } + } + } + if validations == 0 || reprints == 0 { + t.Fatalf("the walk found %d validations and %d reprints: it proves nothing", + validations, reprints) + } +} + +// TestTransitionNeverChangesTheFrozenWeightAfterValidating is invariant 3 of §6.7. +// +// Twenty different readings arrive after the label was built -- the customer leans +// on the counter, the bag settles, the plate drifts -- and not one of them may +// reach the weight the label carries. +func TestTransitionNeverChangesTheFrozenWeightAfterValidating(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-TAP") + frozen := r.m.LatchedWeight + if frozen.Gross != 1236 { + t.Fatalf("the frozen gross is %d g, want 1236", frozen.Gross) + } + + for i := 1; i <= 20; i++ { + r.at(time.Duration(400+i*50)*time.Millisecond).measure(Grams(1200+i*7), Unstable) + if r.m.LatchedWeight != frozen { + t.Fatalf("reading %d changed the frozen weight: %+v", i, r.m.LatchedWeight) + } + if r.m.Label == nil || r.m.Label.NetWeight != 1236 { + t.Fatalf("reading %d changed the label", i) + } + } + r.send(PrintFinished{JobID: r.m.Label.JobID, Duration: 30 * time.Millisecond}) + if r.m.LatchedWeight != frozen { + t.Fatalf("the print result changed the frozen weight: %+v", r.m.LatchedWeight) + } + for i := 1; i <= 5; i++ { + r.at(time.Duration(2000+i*50)*time.Millisecond).measure(Grams(1300+i), Stable) + if r.m.LatchedWeight != frozen { + t.Fatalf("a reading after success changed the frozen weight: %+v", r.m.LatchedWeight) + } + } +} + +// TestTransitionHasNoCycleWithoutIdle is invariant 4 of §6.7: no burst of labels on +// one bag. The plate has to come back to the empty band, which is the signal the +// machine already owns. +func TestTransitionHasNoCycleWithoutIdle(t *testing.T) { + r := nominalCycle(t) + + for i := 1; i <= 4; i++ { + effects := r.at(time.Duration(1000+i*100)*time.Millisecond).tap("894", "01J-BURST") + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("tap %d printed %d labels without the bag ever leaving the plate", i, n) + } + if r.m.State != Succeeded { + t.Fatalf("tap %d moved the station to %s", i, r.m.State) + } + } + + // The bag leaves: THAT is what ends the cycle. + r.at(2*time.Second).measure(0, Stable) + if r.m.State != Idle { + t.Fatalf("an empty plate reached %s, want idle", r.m.State) + } + if r.m.CurrentProduct != nil || r.m.Label != nil || r.m.LatchedWeight.Gross != 0 { + t.Fatalf("the model was not reset on the way back to idle: %+v", r.m) + } + + r.at(3*time.Second).measure(2400, Stable) + if n := countEffect[PrintEffect](r.at(3500*time.Millisecond).tap("894", "01J-NEXT")); n != 1 { + t.Fatalf("the next customer got %d labels, want 1", n) + } +} + +// TestArmingExpiresBeforeNextCustomerBag is invariant 8 of §6.7 and failure test +// 17: no selection survives the departure of a customer. +// +// Wall-clock duration well under 5 ms, because every instant below is a literal. +func TestArmingExpiresBeforeNextCustomerBag(t *testing.T) { + t.Run("expired arming prints nothing for the next bag", func(t *testing.T) { + r := newRun(t) + effects := r.at(0).tap("894", "01J-ARM") + if r.m.State != ProductArmed { + t.Fatalf("a tap on an empty scale reached %s, want product_armed", r.m.State) + } + message, ok := findEffect[MessageEffect](effects) + if !ok || message.Text != "Posez votre produit." { + t.Errorf("arming said %q", message.Text) + } + timer, ok := findEffect[ArmTimerEffect](effects) + if !ok || timer.Duration != MaxArmingTime { + t.Errorf("the arming timer is %v, want %v", timer.Duration, MaxArmingTime) + } + + // The customer walks away. Ten seconds and one tick later, in silence. + if n := len(r.at(9900 * time.Millisecond).send(Tick{})); n != 0 { + t.Errorf("a tick before the deadline produced %d effects", n) + } + if r.m.State != ProductArmed { + t.Fatalf("the arming died at 9,9 s") + } + effects = r.at(10100 * time.Millisecond).send(Tick{}) + if len(effects) != 0 { + t.Errorf("the disarming is not silent: %#v", effects) + } + if r.m.State != Idle || r.m.CurrentProduct != nil { + t.Fatalf("after expiry: state %s, product %v", r.m.State, r.m.CurrentProduct) + } + + // The next customer puts an 800 g bag down: NOTHING is printed. + effects = r.at(12*time.Second).measure(800, Stable) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("the next customer's bag produced %d labels", n) + } + if r.m.State != WeightPresent { + t.Fatalf("the bag put the station in %s, want weight_present", r.m.State) + } + }) + + t.Run("a: bag at 9,9 s prints one label of the right product", func(t *testing.T) { + r := newRun(t) + r.at(0).tap("894", "01J-ARM") + effects := r.at(9900*time.Millisecond).measure(1236, Stable) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the bag at 9,9 s printed nothing: %s", r.m.State) + } + if countEffect[PrintEffect](effects) != 1 { + t.Fatal("more than one label") + } + if print.Label.Product.ID != "894" { + t.Errorf("the label carries product %s, want 894", print.Label.Product.ID) + } + if print.Label.Barcode != "0493021012365" { + t.Errorf("barcode %s", print.Label.Barcode) + } + }) + + t.Run("b: a second product at 5 s wins and re-arms the timer", func(t *testing.T) { + r := newRun(t) + r.at(0).tap("894", "01J-FIRST") + effects := r.at(5*time.Second).tap("5209", "01J-SECOND") + if r.m.State != Printing { + // eggs are by unit: they print at once. Re-arming is proven by the + // by-weight case below. + t.Fatalf("tapping the by-unit product reached %s", r.m.State) + } + if print, _ := findEffect[PrintEffect](effects); print.Label.Product.ID != "5209" { + t.Errorf("the label carries %s, want the second product", print.Label.Product.ID) + } + + // Same scenario with two by-weight products, which is what re-arming is for. + second := machineGarlic(t) + second.ID, second.Name = "973", "PATATE DOUCE SAF" + second.Reference = mustCompose(t, "049310000000") + second.UnitPrice = 467 + r = newRun(t) + r.ctx.Catalog = NewCatalog([]Product{machineGarlic(t), second}, nil) + + r.at(0).tap("894", "01J-FIRST") + effects = r.at(5*time.Second).tap("973", "01J-SECOND") + if r.m.State != ProductArmed || r.m.CurrentProduct.ID != "973" { + t.Fatalf("re-arming left state %s on product %v", r.m.State, r.m.CurrentProduct) + } + if timer, ok := findEffect[ArmTimerEffect](effects); !ok || timer.Duration != MaxArmingTime { + t.Error("the timer was not re-armed") + } + // The first product's deadline (10 s) passes and the arming SURVIVES, + // because the deadline that counts is the second product's (15 s). + if r.at(10500 * time.Millisecond).send(Tick{}); r.m.State != ProductArmed { + t.Fatal("the re-armed selection died on the first product's deadline") + } + print, ok := findEffect[PrintEffect](r.at(14*time.Second).measure(1236, Stable)) + if !ok { + t.Fatalf("the bag at 14 s printed nothing: %s", r.m.State) + } + if print.Label.Product.ID != "973" { + t.Errorf("the label carries %s, want the second product", print.Label.Product.ID) + } + }) + + t.Run("c: Cancel during arming returns to idle at once", func(t *testing.T) { + r := newRun(t) + r.at(0).tap("894", "01J-ARM") + r.at(3 * time.Second).send(Cancel{}) + if r.m.State != Idle || r.m.CurrentProduct != nil || r.m.Label != nil { + t.Fatalf("Cancel left state %s, product %v", r.m.State, r.m.CurrentProduct) + } + if n := countEffect[PrintEffect](r.at(4*time.Second).measure(1236, Stable)); n != 0 { + t.Fatalf("a cancelled arming still printed %d labels", n) + } + }) + + t.Run("d: after expiry the bag prints nothing at all", func(t *testing.T) { + r := newRun(t) + r.at(0).tap("894", "01J-ARM") + r.at(10100 * time.Millisecond).send(Tick{}) + total := 0 + for i := 0; i < 6; i++ { + at := 11*time.Second + time.Duration(i*400)*time.Millisecond + total += countEffect[PrintEffect](r.at(at).measure(800, Stable)) + } + if total != 0 { + t.Fatalf("%d labels were printed after the arming expired", total) + } + }) +} + +// TestArmingIsBoundedByACodeConstant pins the number itself. Ten seconds is more +// than the time it takes to open a bag and less than the time it takes to change +// customer; it is a code constant and not a setting (ADR-022, ADR-025). +func TestArmingIsBoundedByACodeConstant(t *testing.T) { + if MaxArmingTime != 10*time.Second { + t.Errorf("MaxArmingTime is %v, §6.6 says 10 s", MaxArmingTime) + } + if MaxSwitchIdle != 10*time.Second { + t.Errorf("MaxSwitchIdle is %v, §10.8 says 10 s", MaxSwitchIdle) + } + // The deadline is inclusive: at exactly MaxArmingTime the arming is over. + r := newRun(t) + r.at(0).tap("894", "01J-ARM") + r.at(MaxArmingTime).send(Tick{}) + if r.m.State != Idle { + t.Errorf("at exactly %v the state is %s, want idle", MaxArmingTime, r.m.State) + } +} + +// TestTransitionEmptyPlateAlwaysBringsTheStationHome walks the states a mass can be +// present in and checks the ONE signal that ends them all: the plate coming back to +// the empty band. It is the signal the machine already owns, it is exact, and it +// waits for nothing (§14.3). +func TestTransitionEmptyPlateAlwaysBringsTheStationHome(t *testing.T) { + t.Run("weight_present", func(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(200*time.Millisecond).measure(0, Stable) + if r.m.State != Idle { + t.Fatalf("state %s", r.m.State) + } + }) + + t.Run("awaiting_stability", func(t *testing.T) { + r := newRun(t) + r.ctx.Cfg.Stability.Mode = ModeBlocking + r.at(0).measure(1236, Unstable) + r.at(100*time.Millisecond).tap("894", "01J-WAIT") + if r.m.State != AwaitingStability { + t.Fatalf("state %s", r.m.State) + } + // The customer gives up and takes the bag back. + r.at(500*time.Millisecond).measure(0, Stable) + if r.m.State != Idle || r.m.CurrentProduct != nil { + t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) + } + }) + + t.Run("rejected", func(t *testing.T) { + r := newRun(t) + r.ctx.Cfg.Limits.MinWeight = 2000 + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-LIGHT") + if r.m.State != Rejected { + t.Fatalf("state %s", r.m.State) + } + if n := len(r.at(600*time.Millisecond).measure(1240, Stable)); n != 0 { + t.Error("a reading that keeps the bag on the plate produced an effect") + } + if r.m.State != Rejected { + t.Fatalf("the refusal was cleared by a reading: %s", r.m.State) + } + r.at(time.Second).measure(0, Stable) + if r.m.State != Idle || r.m.Diagnostics != nil { + t.Fatalf("state %s, diagnostics %v", r.m.State, r.m.Diagnostics) + } + }) +} diff --git a/internal/domain/transition_outcome.go b/internal/domain/transition_outcome.go new file mode 100644 index 0000000..51a11a2 --- /dev/null +++ b/internal/domain/transition_outcome.go @@ -0,0 +1,207 @@ +package domain + +// This file holds the states a weighing ENDS in: Validating, Printing, and the four +// the customer reads -- Succeeded, Rejected, Faulted -- plus ScaleLost, which a +// station falls into from anywhere and serves whatever it still can. +// +// They share one trait, and it is what puts them together: none of them can start a +// new weighing on its own. What brings the station back to Idle is the PHYSICAL +// SIGNAL -- the bag leaves the plate -- and never a stopwatch. + +// validating exists for a model that CLAIMS to be validating. +// +// Validating is transient: it is entered and left inside one call, so no model the +// Hub publishes ever carries it. A hand-written value or a truncated replay can, +// and Transition still has to answer. A Tick finishes the pending validation -- +// the model already holds everything it needs -- and every other event is ignored +// rather than allowed to start a second cycle over the same frozen weight. +func validating(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + if _, ok := ev.(Tick); !ok { + return m, nil + } + if m.CurrentProduct == nil { + return m.clear(Idle), nil + } + return validate(m, frozenWeight{ + Measurement: m.LatchedWeight, Source: m.Source, + Age: ctx.MeasurementAge, StabilityBlocks: blockingStability(ctx.Cfg), + }, ctx) +} + +// printing serves the wait for the print worker. +func printing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case PrintFinished: + return printFinished(m, e, ctx) + + case MeasurementReceived: + // The weight keeps being displayed, but the frozen one is untouchable: + // invariant 3 of §6.7 lives in fold, which never writes LatchedWeight. + return m.fold(e.M, ctx.Cfg.Stability), nil + } + return m, nil +} + +// printFinished turns the outcome of a print job into a journal row. +func printFinished(m Model, ev PrintFinished, ctx TransitionContext) (Model, []Effect) { + if m.Label == nil { + return m, nil + } + if ev.JobID != "" && ev.JobID != m.Label.JobID { + // A result belonging to another job: a late answer from a job the customer + // has already forgotten. Acting on it would move a cycle that is not the + // one it names. + return m, []Effect{TechnicalLogEffect{ + Level: LevelWarn, Source: "printer", Code: "", + Message: "Résultat d'impression arrivé hors de son cycle.", + Detail: "reçu " + ev.JobID + ", attendu " + m.Label.JobID, + }} + } + + duration := int(ev.Duration.Milliseconds()) + if ev.Err != nil { + next := m + next.State, next.FaultCode = Faulted, "ERR-PRN-01" + record := m.record(*m.Label, ResultFailed, ev.Err.Error(), duration, ctx) + return next, []Effect{ + RecordEffect{Weighing: record}, + MessageEffect{ + Level: LevelError, Code: "ERR-PRN-01", + Text: "L'imprimante ne répond pas. Prévenez un responsable.", + }, + TechnicalLogEffect{ + Level: LevelError, Source: "printer", Code: "ERR-PRN-01", + Message: "Impression échouée.", Detail: ev.Err.Error(), + }, + } + } + + next := m + next.State = Succeeded + next.LastLabel, next.LastPrintedAt = m.Label, ctx.Now + if m.Reprinted { + // A reprint does not reopen the right to reprint. + next.LastLabel, next.LastPrintedAt = m.LastLabel, m.LastPrintedAt + } + result := ResultSent + if m.Reprinted { + result = ResultReprint + } + effects := []Effect{ + RecordEffect{Weighing: m.record(*m.Label, result, "", duration, ctx)}, + MessageEffect{ + Level: LevelInfo, Code: "", Text: "Étiquette envoyée.", + Duration: SuccessMessageDuration, + }, + } + if ctx.Cfg.UI.Sound { + effects = append(effects, SoundEffect{Name: "ok"}) + } + return next, effects +} + +// succeeded serves the discreet acknowledgement in the banner. The grid stays +// visible and nothing has to be closed (§14.3, ADR-023). +func succeeded(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + // The customer takes the bag off: that is the signal the machine + // already owns, and it is more accurate than a guessed delay. + return next.clear(Idle), nil + } + return next, nil + + case ReprintRequested: + return reprint(m, e, ctx) + + // ProductTapped is deliberately absent. A second label on the same bag is + // exactly the burst invariant 4 of §6.7 forbids: the mass has to leave the + // plate, which brings the station back to Idle, before anything can be + // weighed again. + } + return m, nil +} + +// rejected serves a refusal. It falls back on the same physical signal as a +// success, and it lets the customer CORRECT without having anything to close. +func rejected(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + return next.clear(Idle), nil + } + return next, nil + + case ProductTapped: + // Nothing was printed, so nothing forbids another attempt. The cycle is + // cleared first, which is what keeps invariant 3 unambiguous: a frozen + // weight belongs to ONE cycle and a new cycle freezes its own. + return tapOnWeight(m.clear(m.State), e, ctx) + + case ReprintRequested: + return reprint(m, e, ctx) + } + return m, nil +} + +// faulted serves the full-screen fault. Only an acknowledgement leaves it. +func faulted(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + if _, ok := ev.(Dismiss); !ok { + return m, nil + } + next := m.clear(Idle) + return next, []Effect{AckEffect{Ack: Ack{Accepted: true, State: Idle}}} +} + +// scaleLost serves a station whose scale stopped answering. +func scaleLost(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case ScaleReconnected: + next := m.clear(Idle) + // A weight measured before the outage must not be able to latch after it, + // and intervals measured across it describe the outage, not the cadence. + next.Latch, next.LatchState = WeightLatch{}, LatchState{} + return next, []Effect{TechnicalLogEffect{ + Level: LevelInfo, Source: "scale", Code: "", + Message: "La balance répond de nouveau.", + }} + + case MeasurementReceived: + // A driver that resumes emitting without announcing itself. Refusing the + // measurement would leave the station dead for a reason nobody can name. + next := m.clear(Idle) + next.Latch, next.LatchState = WeightLatch{}, LatchState{} + next = next.fold(e.M, ctx.Cfg.Stability) + if !emptyZone(e.M.Gross, ctx.Cfg.Limits) { + next.State = presentOrStable(next) + } + return next, nil + + case CatalogReady: + // A catalog does NOT need a scale to take service, and refusing it here loses + // it for good: the source deletes the file once the batch is acknowledged — + // the deletion IS the acknowledgement (§10.1) — so a batch this machine + // ignores is a catalog nobody will offer again until somebody drops another + // file. A station whose scale did not answer at start-up sat in this state + // showing « Catalogue vide » while its 331 tiles were already in the base. + // + // The state does NOT change: the scale is still missing, and that is what the + // screen must keep saying. Only the grid behind the message is filled. + if e.Catalog == nil || e.Catalog.Len() == 0 { + return m, nil + } + return m, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} + + case ProductTapped: + // The manual entry a volunteer reaches through the troubleshooting button + // of §15.4: "you can type the weight in". + if !ctx.Cfg.Scale.ManualEntryAllowed { + return m, nil + } + return tapWithoutScale(m, e, ctx) + } + return m, nil +} diff --git a/internal/domain/transition_outcome_test.go b/internal/domain/transition_outcome_test.go new file mode 100644 index 0000000..516a8d1 --- /dev/null +++ b/internal/domain/transition_outcome_test.go @@ -0,0 +1,356 @@ +// This file holds the scenarios that follow the label: what the print worker +// answers, what the journal keeps, and the two states a station can be stuck in -- +// ScaleLost, which still serves what it can, and OutOfService, which one event +// alone leaves. + +package domain + +import ( + "errors" + "testing" + "time" +) + +// TestTransitionManualEntryIsReachableFromALostScale is the "you can type the +// weight in" button of §15.4, on a station whose scale died mid-service. +func TestTransitionManualEntryIsReachableFromALostScale(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(time.Second).send(ScaleDisconnected{Err: errors.New("COM8: i/o timeout")}) + if r.m.State != ScaleLost { + t.Fatalf("state %s, want scale_lost", r.m.State) + } + + r.at(2*time.Second).tap("894", "01J-DEGRADED") + if r.m.State != EnteringWeight { + t.Fatalf("a tap on a lost scale reached %s", r.m.State) + } + effects := r.at(3 * time.Second).send(ManualWeightConfirmed{Weight: 900, Key: "01J-HAND"}) + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("the typed weight produced %d labels: %+v", n, r.m.Diagnostics) + } + + // Without the operator switch, the same tap does nothing at all. + r = newRun(t) + r.ctx.Cfg.Scale.ManualEntryAllowed = false + r.at(0).send(ScaleDisconnected{}) + if n := len(r.at(time.Second).tap("894", "01J-NO")); n != 0 { + t.Errorf("manual entry is forbidden and the tap produced %d effects", n) + } +} + +// TestTransitionScaleLossIsIdempotent is failure test 1: twenty consecutive +// StatusDisconnected from the reconnection backoff cost ONE transition. +func TestTransitionScaleLossIsIdempotent(t *testing.T) { + for _, name := range []string{"with an error", "with a nil error"} { + r := newRun(t) + r.at(0).measure(1236, Stable) + ev := ScaleDisconnected{Err: errors.New("COM8: i/o timeout")} + if name == "with a nil error" { + ev = ScaleDisconnected{} + } + effects := r.at(time.Second).send(ev) + if r.m.State != ScaleLost { + t.Fatalf("%s: state %s, want scale_lost", name, r.m.State) + } + if _, ok := findEffect[MessageEffect](effects); !ok { + t.Errorf("%s: the loss said nothing to the customer", name) + } + if r.m.CurrentProduct != nil || r.m.Label != nil { + t.Errorf("%s: the cycle survived the loss of the scale", name) + } + + for i := 0; i < 20; i++ { + at := time.Second + time.Duration(i+1)*time.Second + if n := len(r.at(at).send(ev)); n != 0 { + t.Fatalf("%s: repetition %d produced %d effects", name, i+1, n) + } + } + + effects = r.at(30 * time.Second).send(ScaleReconnected{}) + if r.m.State != Idle { + t.Errorf("%s: reconnection reached %s", name, r.m.State) + } + if _, ok := findEffect[TechnicalLogEffect](effects); !ok { + t.Errorf("%s: the reconnection was not logged", name) + } + // A weight measured before the outage must not latch after it. + if r.m.LatchState.Latched { + t.Errorf("%s: the latch survived the outage", name) + } + } +} + +// TestTransitionScaleLossIsIgnoredOutOfService keeps the note of §6.6 honest: the +// only state the loss of the scale does not reach is the terminal one. +func TestTransitionScaleLossIsIgnoredOutOfService(t *testing.T) { + ctx := TransitionContext{Cfg: machineConfig(), Now: origin} + next, effects := Transition(Model{State: OutOfService}, ScaleDisconnected{}, ctx) + if next.State != OutOfService || len(effects) != 0 { + t.Fatalf("out of service reacted to the loss of the scale: %s, %#v", next.State, effects) + } + reached := 0 + for _, s := range allStates { + if s == OutOfService || s == ScaleLost { + continue + } + next, _ := Transition(Model{State: s}, ScaleDisconnected{}, ctx) + if next.State != ScaleLost { + t.Errorf("%s did not reach scale_lost", s) + continue + } + reached++ + } + if reached != 14 { + t.Fatalf("%d states reached scale_lost, want 14", reached) + } +} + +// TestTransitionPrintFailureFaultsAndKeepsTheCode is failure test 4 seen from the +// machine: the full screen carries the ERR code a volunteer reads over the phone. +func TestTransitionPrintFailureFaultsAndKeepsTheCode(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-TAP") + effects := r.at(time.Second).send(PrintFinished{ + JobID: "01J-TAP", Err: errors.New("winspool: StartDocPrinter: file not found"), + }) + if r.m.State != Faulted { + t.Fatalf("a failed print reached %s, want faulted", r.m.State) + } + if r.m.FaultCode != "ERR-PRN-01" { + t.Errorf("fault code %q, want ERR-PRN-01", r.m.FaultCode) + } + record, ok := findEffect[RecordEffect](effects) + if !ok || record.Weighing.Result != ResultFailed { + t.Errorf("a failed print was journalled %q, want %q", record.Weighing.Result, ResultFailed) + } + if _, ok := findEffect[TechnicalLogEffect](effects); !ok { + t.Error("a failed print left no technical trace") + } + + // Only an acknowledgement leaves the full screen. + if n := len(r.at(2*time.Second).measure(0, Stable)); n != 0 { + t.Error("an empty plate cleared a fault screen") + } + if r.m.State != Faulted { + t.Fatalf("state %s after a measurement, want faulted", r.m.State) + } + r.at(3 * time.Second).send(Dismiss{}) + if r.m.State != Idle || r.m.FaultCode != "" { + t.Fatalf("Dismiss left state %s and code %q", r.m.State, r.m.FaultCode) + } +} + +// TestTransitionIgnoresAPrintResultFromAnotherJob: a late answer names a job the +// customer has already forgotten, and acting on it would move a cycle it is not +// about. +func TestTransitionIgnoresAPrintResultFromAnotherJob(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-TAP") + effects := r.at(time.Second).send(PrintFinished{JobID: "01J-SOMETHING-ELSE"}) + if r.m.State != Printing { + t.Fatalf("a foreign result moved the station to %s", r.m.State) + } + if _, ok := findEffect[RecordEffect](effects); ok { + t.Error("a foreign result was journalled") + } + if _, ok := findEffect[TechnicalLogEffect](effects); !ok { + t.Error("a foreign result left no technical trace") + } +} + +// TestTransitionRejectedLetsTheCustomerCorrect is §14.3: the message lives in the +// banner, the grid stays visible, and the customer corrects without closing +// anything. Nothing was printed, so nothing forbids a second attempt. +func TestTransitionRejectedLetsTheCustomerCorrect(t *testing.T) { + r := newRun(t) + r.ctx.Cfg.Limits.MinWeight = 2000 // the garlic at 1 236 g is too light + r.at(0).measure(1236, Stable) + effects := r.at(400*time.Millisecond).tap("894", "01J-LIGHT") + if r.m.State != Rejected { + t.Fatalf("a too-light weighing reached %s", r.m.State) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Code != CodeWeightTooLow { + t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeWeightTooLow) + } + + // The customer adds to the bag and taps again: the second attempt goes through + // and it freezes ITS OWN weight. + r.at(2*time.Second).measure(2400, Stable) + effects = r.at(2500*time.Millisecond).tap("894", "01J-HEAVIER") + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the corrected weighing printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) + } + if print.Label.NetWeight != 2400 { + t.Errorf("the label carries %d g, want 2400", print.Label.NetWeight) + } + if print.Label.JobID != "01J-HEAVIER" { + t.Errorf("job id %q, want the key of the second tap", print.Label.JobID) + } +} + +// TestTransitionJournalRowCarriesWhatTheLabelCarried: a row whose net weight +// differs from the printed one is unusable at the till, and the till is the only +// reason the row exists. +func TestTransitionJournalRowCarriesWhatTheLabelCarried(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-TAP") + label := *r.m.Label + + effects := r.at(1400 * time.Millisecond).send(PrintFinished{ + JobID: label.JobID, Duration: 40 * time.Millisecond, + }) + record, ok := findEffect[RecordEffect](effects) + if !ok { + t.Fatal("a successful print was not journalled") + } + w := record.Weighing + if w.Result != ResultSent { + t.Errorf("result %q, want %q -- there is no 'ok'", w.Result, ResultSent) + } + if w.NetWeight != label.NetWeight || w.GrossWeight != label.GrossWeight || + w.Barcode != label.Barcode { + t.Errorf("the row and the label disagree: %+v vs %+v", w, label) + } + if w.JobID != "01J-TAP" || w.IdempotencyKey != "01J-TAP" { + t.Errorf("job id %q, key %q", w.JobID, w.IdempotencyKey) + } + if w.ProductID != "894" || w.ProductName != "AIL BLANC SAF" || w.Mode != ByWeight { + t.Errorf("the row does not name the product: %+v", w) + } + if w.BaseUnitPrice != 532 { + t.Errorf("base unit price %d, want the catalog price 532", w.BaseUnitPrice) + } + if w.Station != 1 { + t.Errorf("station %d, want 1", w.Station) + } + if w.Source != SourceScale || w.Stability != Stable { + t.Errorf("source %q, stability %s", w.Source, w.Stability) + } + if w.DurationMS != 40 { + t.Errorf("duration %d ms, want the 40 the printer reported", w.DurationMS) + } + if len(w.Lines) != 2 { + t.Fatalf("%d journal lines, want one per tier", len(w.Lines)) + } + if line := w.Line("MEMBER"); line == nil || line.Amount != 592 || line.UnitPrice != 479 { + t.Errorf("the member line is %+v", line) + } + // rate_ms and frame belong to the Hub: a pure function reaches neither. + if w.RateMS != 0 || w.Frame != "" { + t.Errorf("the domain filled rate_ms or frame: %d, %q", w.RateMS, w.Frame) + } + if !w.OccurredAt.Equal(r.ctx.Now) { + t.Errorf("occurred at %v, want the injected instant %v", w.OccurredAt, r.ctx.Now) + } +} + +// TestTransitionSoundFollowsTheConfiguration: the browser plays the sound and the +// backend does no audio I/O, so the only question here is whether it is asked for. +func TestTransitionSoundFollowsTheConfiguration(t *testing.T) { + for _, on := range []bool{true, false} { + r := newRun(t) + r.ctx.Cfg.UI.Sound = on + r.at(0).measure(1236, Stable) + r.at(400*time.Millisecond).tap("894", "01J-TAP") + effects := r.at(time.Second).send(PrintFinished{JobID: "01J-TAP"}) + sound, played := findEffect[SoundEffect](effects) + if played != on { + t.Errorf("ui.sound=%v produced a sound: %v", on, played) + } + if on && sound.Name != "ok" { + t.Errorf("the sound is %q, want ok", sound.Name) + } + } +} + +// TestTransitionOutOfServiceIsTerminal: nothing in the machine enters it, and +// nothing but Cancel and ConfigurationRepaired is answered from it. +func TestTransitionOutOfServiceIsTerminal(t *testing.T) { + ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} + for _, ev := range allEvents(t) { + next, effects := Transition(Model{State: OutOfService}, ev, ctx) + if _, isCancel := ev.(Cancel); isCancel { + continue + } + if _, repaired := ev.(ConfigurationRepaired); repaired { + continue + } + if next.State != OutOfService { + t.Errorf("%T left out_of_service for %s", ev, next.State) + } + if len(effects) != 0 { + t.Errorf("%T produced %d effects out of service", ev, len(effects)) + } + } + // And no event reaches it either. + for _, s := range allStates { + for _, ev := range allEvents(t) { + next, _ := Transition(Model{State: s, ArmedAt: origin}, ev, ctx) + if next.State == OutOfService && s != OutOfService { + t.Errorf("(%s, %T) entered out_of_service", s, ev) + } + } + } +} + +// TestTransitionRepairedIsTheONEWayOutOfOutOfService. +// +// §11.3 puts a station in the terminal state from OUTSIDE the machine, when the file it +// read is unusable. §11.4 promises that no configuration block requires a restart of the +// process — and that promise was false for exactly this station: it could be repaired +// from the administration screen and would keep showing « Poste hors service » until +// somebody restarted a service the screen has no button for. +func TestTransitionRepairedIsTheONEWayOutOfOutOfService(t *testing.T) { + ctx := TransitionContext{Cfg: machineConfig(), Now: origin, Catalog: machineCatalog(t)} + + // With a catalog in memory the station is ready to serve, and saying « Catalogue vide » + // about a grid that holds 331 tiles would be the second wrong screen in a row. + served, effects := Transition(Model{State: OutOfService}, ConfigurationRepaired{}, ctx) + if served.State != Idle { + t.Errorf("poste réparé avec catalogue = %s, attendu idle", served.State) + } + if len(effects) != 0 { + t.Errorf("la réparation produit %d effets, elle n'en produit aucun", len(effects)) + } + + // Without one, it goes back to waiting for its first flv_.csv (§15.4). + empty := TransitionContext{Cfg: machineConfig(), Now: origin} + waiting, _ := Transition(Model{State: OutOfService}, ConfigurationRepaired{}, empty) + if waiting.State != Initializing { + t.Errorf("poste réparé sans catalogue = %s, attendu initializing", waiting.State) + } + + // And it is INERT everywhere else: a configuration saved while a customer is mid-cycle + // must not cancel the weighing under their finger. + for _, state := range allStates { + if state == OutOfService { + continue + } + before := Model{State: state, ArmedAt: origin} + after, produced := Transition(before, ConfigurationRepaired{}, ctx) + if after.State != state || len(produced) != 0 { + t.Errorf("(%s, ConfigurationRepaired) = %s avec %d effets : la réparation "+ + "doit être sans effet hors de out_of_service", state, after.State, len(produced)) + } + } +} + +// TestTransitionLostScaleRefusesAProductItDoesNotOffer keeps the degraded path as +// strict as the nominal one: losing the scale does not open the grid. +func TestTransitionLostScaleRefusesAProductItDoesNotOffer(t *testing.T) { + r := newRun(t) + r.at(0).send(ScaleDisconnected{}) + effects := r.at(time.Second).tap("5115", "01J-HIDDEN") + if r.m.State != ScaleLost { + t.Fatalf("state %s", r.m.State) + } + if ack, ok := findEffect[AckEffect](effects); !ok || ack.Ack.Code != CodeProductWithdrawn { + t.Errorf("ack %+v", ack.Ack) + } +} diff --git a/internal/domain/transition_stability_test.go b/internal/domain/transition_stability_test.go new file mode 100644 index 0000000..fc30ff0 --- /dev/null +++ b/internal/domain/transition_stability_test.go @@ -0,0 +1,304 @@ +// This file holds what the SCALE says and what the machine does about it: the two +// stability modes and the three answers to a timeout, an expired measurement, an +// overload, and the latch that freezes the ANCHOR rather than the last frame. +// +// Not one of them sleeps. Every instant is a literal offset from `origin`, which is +// what makes a rule about five hundred milliseconds testable at all. + +package domain + +import ( + "testing" + "time" +) + +// TestTransitionRefusesAnExpiredMeasurement is the domain half of failure test +// 3 ter: the scale goes quiet after a valid reading and the weight must not be +// printed. The boundary is `age > Expiry`, not `>=`. +func TestTransitionRefusesAnExpiredMeasurement(t *testing.T) { + for _, tc := range []struct { + name string + age time.Duration + print bool + }{ + {"one millisecond before the expiry", 1199 * time.Millisecond, true}, + {"at exactly the expiry", 1200 * time.Millisecond, true}, + {"one millisecond after the expiry", 1201 * time.Millisecond, false}, + } { + for _, mode := range []string{ModeAdvisory, ModeBlocking} { + r := newRun(t) + r.ctx.Cfg.Stability.Mode = mode + r.at(0).measure(1236, Stable) + // The latch holds, so blocking mode does not divert to + // AwaitingStability and the two modes compare like for like. The + // scale then goes quiet, and the age is counted from THAT frame. + r.at(400*time.Millisecond).measure(1236, Stable) + effects := r.at(400*time.Millisecond+tc.age).tap("894", "01J-OLD") + + printed := countEffect[PrintEffect](effects) == 1 + if printed != tc.print { + t.Errorf("%s in %s mode: printed=%v, want %v", tc.name, mode, printed, tc.print) + } + if tc.print { + continue + } + if r.m.State != Rejected { + t.Errorf("%s in %s mode: state %s, want rejected", tc.name, mode, r.m.State) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Code != CodeMeasurementExpired { + t.Errorf("%s in %s mode: refused on %q, want %s", + tc.name, mode, ack.Ack.Code, CodeMeasurementExpired) + } + if r.m.Label != nil { + t.Errorf("%s in %s mode: a label was built for an expired weight", tc.name, mode) + } + } + } +} + +// TestTransitionAdvisoryStabilityPrintsAnUnstableWeight is failure test 3: a scale +// that never says ST still serves customers, and the journal says so (A3). +func TestTransitionAdvisoryStabilityPrintsAnUnstableWeight(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Unstable) + effects := r.at(200*time.Millisecond).tap("894", "01J-US") + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("advisory mode printed %d labels on an unstable weight", n) + } + found := false + for _, d := range r.m.Diagnostics { + if d.Code == CodeWeightUnstable { + found = true + if d.Blocks() { + t.Error("rule 6 blocked in advisory mode") + } + } + } + if !found { + t.Error("the instability was not recorded") + } + // The journal keeps the stability of the FROZEN reading and not of the last + // frame, which is what makes "enable blocking mode?" answerable on evidence + // later on (A3). + effects = r.at(300 * time.Millisecond).send(PrintFinished{JobID: r.m.Label.JobID}) + record, ok := findEffect[RecordEffect](effects) + if !ok { + t.Fatal("the weighing was not journalled") + } + if record.Weighing.Stability != Unstable { + t.Errorf("journalled stability %s, want unstable", record.Weighing.Stability) + } + if r.m.LatchedWeight.Stability != Unstable { + t.Errorf("frozen stability %s, want unstable", r.m.LatchedWeight.Stability) + } +} + +// TestTransitionBlockingStabilityWaitsThenActsOnItsTimeout covers the three +// on_timeout answers of §6.5, and the nominal case where the weight settles. +func TestTransitionBlockingStabilityWaitsThenActsOnItsTimeout(t *testing.T) { + blocking := func(t *testing.T, onTimeout string) *run { + t.Helper() + r := newRun(t) + r.ctx.Cfg.Stability.Mode = ModeBlocking + r.ctx.Cfg.Stability.OnTimeout = onTimeout + r.at(0).measure(1236, Unstable) + effects := r.at(100*time.Millisecond).tap("894", "01J-WAIT") + if r.m.State != AwaitingStability { + t.Fatalf("blocking mode on an unlatched weight reached %s", r.m.State) + } + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("blocking mode printed %d labels before stability", n) + } + if timer, ok := findEffect[ArmTimerEffect](effects); !ok || + timer.Duration != time.Duration(r.ctx.Cfg.Stability.Timeout) { + t.Error("the wait declares no timer") + } + return r + } + + // wobble keeps the scale TALKING while the mass refuses to settle, which is + // what a real wait looks like. A scale that goes silent instead is a different + // failure, and the machine answers it differently -- MEASUREMENT_EXPIRED -- + // which is why the wait has to be fed to test the timeout at all. + wobble := func(r *run, until time.Duration) { + for d := 500 * time.Millisecond; d <= until; d += 400 * time.Millisecond { + r.at(d).measure(1236, Unstable) + } + } + + t.Run("the weight settles", func(t *testing.T) { + r := blocking(t, OnTimeoutWarnAndPrint) + r.at(200*time.Millisecond).measure(1236, Stable) + effects := r.at(600*time.Millisecond).measure(1237, Stable) + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("a latched weight printed %d labels: %s", n, r.m.State) + } + // The ANCHOR is printed, not the last frame (§6.5). + print, _ := findEffect[PrintEffect](effects) + if print.Label.NetWeight != 1236 { + t.Errorf("the label carries %d g, want the anchor 1236", print.Label.NetWeight) + } + }) + + t.Run("warn_and_print", func(t *testing.T) { + r := blocking(t, OnTimeoutWarnAndPrint) + wobble(r, 2*time.Second) + if n := len(r.at(2 * time.Second).send(Tick{})); n != 0 { + t.Error("the timeout fired early") + } + wobble(r, 3100*time.Millisecond) + effects := r.at(3200 * time.Millisecond).send(Tick{}) + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("warn_and_print produced %d labels: %s", n, r.m.State) + } + }) + + t.Run("reject", func(t *testing.T) { + r := blocking(t, OnTimeoutReject) + wobble(r, 3100*time.Millisecond) + effects := r.at(3200 * time.Millisecond).send(Tick{}) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("reject printed %d labels", n) + } + if r.m.State != Rejected { + t.Fatalf("reject reached %s", r.m.State) + } + record, ok := findEffect[RecordEffect](effects) + if !ok || record.Weighing.Result != ResultRejected { + t.Error("the refusal was not journalled") + } + }) + + t.Run("manual_entry", func(t *testing.T) { + r := blocking(t, OnTimeoutManualEntry) + wobble(r, 3100*time.Millisecond) + r.at(3200 * time.Millisecond).send(Tick{}) + if r.m.State != EnteringWeight { + t.Fatalf("manual_entry reached %s", r.m.State) + } + effects := r.at(4 * time.Second).send(ManualWeightConfirmed{Weight: 1236, Key: "01J-HAND"}) + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("the typed weight produced %d labels: %s", n, r.m.State) + } + }) +} + +// TestTransitionManualEntryIsNeverRefusedForAnAgedFrame is the reason a typed +// weight carries an age of zero. +// +// The scale has been quiet for an hour -- which is the only situation manual entry +// exists for. Passing the age of the last frame would make safeguard rule 2 refuse +// every single manual weighing, in exactly the case the feature was written for. +func TestTransitionManualEntryIsNeverRefusedForAnAgedFrame(t *testing.T) { + r := newRun(t) + r.ctx.Cfg.Scale.Present = false + r.at(0).measure(0, Stable) + r.at(time.Hour).send(Tick{}) + if r.m.State != ManualMode { + t.Fatalf("a station without a scale rests in %s, want manual_mode", r.m.State) + } + + effects := r.tap("894", "01J-HANDTAP") + if r.m.State != EnteringWeight { + t.Fatalf("a tap in manual mode reached %s", r.m.State) + } + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatal("the tap printed before a weight was typed") + } + + effects = r.at(time.Hour + time.Second).send( + ManualWeightConfirmed{Weight: 1236, Key: "01J-HAND"}) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the typed weight printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) + } + if print.Label.Barcode != "0493021012365" { + t.Errorf("barcode %s", print.Label.Barcode) + } + if r.m.Source != SourceManual { + t.Errorf("source %q, want %q", r.m.Source, SourceManual) + } + if r.m.LatchedWeight.Stability != StabilityNotApplicable { + t.Errorf("a typed weight reports %s, want not_applicable", r.m.LatchedWeight.Stability) + } + effects = r.send(PrintFinished{JobID: print.Label.JobID, Duration: 20 * time.Millisecond}) + record, ok := findEffect[RecordEffect](effects) + if !ok || record.Weighing.Source != SourceManual { + t.Errorf("the journal row says the weight came from %q", record.Weighing.Source) + } +} + +// TestTransitionOverloadAndAnEmptyPlateAreRefused walks the two safeguards a +// customer meets most often, through the machine rather than through Evaluate. +func TestTransitionOverloadAndAnEmptyPlateAreRefused(t *testing.T) { + // The scale itself declares it is over capacity: no arithmetic on the mass can + // replace the flag. + r := newRun(t) + r.seq++ + msr := Measurement{Gross: 4000, Overload: true, Timestamp: origin, Seq: 1} + r.ctx.LastMeasurement = msr + r.send(MeasurementReceived{M: msr}) + effects := r.at(400*time.Millisecond).tap("894", "01J-OL") + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("an overloaded scale printed %d labels", n) + } + if ack, _ := findEffect[AckEffect](effects); ack.Ack.Code != CodeOverload { + t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeOverload) + } + + // A by-weight product typed at 0 g by hand: rule 4 is still evaluated for the + // derived paths, which is exactly what §6.4 keeps it for. + r = newRun(t) + r.ctx.Cfg.Scale.Present = false + r.at(0).send(Tick{}) + r.tap("894", "01J-ZERO") + effects = r.send(ManualWeightConfirmed{Weight: 0, Key: "01J-ZEROW"}) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a manual weight of 0 g printed %d labels", n) + } + if ack, _ := findEffect[AckEffect](effects); ack.Ack.Code != CodeScaleEmpty { + t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeScaleEmpty) + } +} + +// TestTransitionLatchesTheAnchorAndNotTheLastFrame is §6.5 seen from the machine: +// inside a window that holds to within the tolerance we want a reproducible value. +func TestTransitionLatchesTheAnchorAndNotTheLastFrame(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + if r.m.State != WeightPresent { + t.Fatalf("the first frame reached %s", r.m.State) + } + r.at(200*time.Millisecond).measure(1237, Stable) + if r.m.State != WeightPresent { + t.Fatalf("200 ms is below min_duration and the state is %s", r.m.State) + } + r.at(400*time.Millisecond).measure(1235, Stable) + if r.m.State != WeightStable { + t.Fatalf("after 400 ms the state is %s, want weight_stable", r.m.State) + } + if r.m.LatchState.Gross != 1236 { + t.Errorf("the anchor is %d g, want the first frame 1236", r.m.LatchState.Gross) + } + print, ok := findEffect[PrintEffect](r.at(500*time.Millisecond).tap("894", "01J-ANCHOR")) + if !ok { + t.Fatalf("no label: %s", r.m.State) + } + if print.Label.NetWeight != 1236 { + t.Errorf("the label carries %d g, want the anchor", print.Label.NetWeight) + } + + // A mass that walks away breaks the window: the state falls back -- once the + // print job has answered, because Printing waits for its result and for + // nothing else. + r.at(550 * time.Millisecond).send(PrintFinished{JobID: print.Label.JobID}) + r.at(600*time.Millisecond).measure(0, Stable) + if r.m.State != Idle { + t.Fatalf("an empty plate reached %s", r.m.State) + } + r.at(700*time.Millisecond).measure(3000, Stable) + if r.m.State != WeightPresent { + t.Fatalf("a new mass reached %s, want weight_present", r.m.State) + } +} diff --git a/internal/domain/transition_weighing.go b/internal/domain/transition_weighing.go new file mode 100644 index 0000000..e8bd25c --- /dev/null +++ b/internal/domain/transition_weighing.go @@ -0,0 +1,418 @@ +package domain + +import ( + "fmt" + "time" +) + +// This file holds the states a weighing is BUILT UP in: from the resting grid to the +// instant a product and a mass are both known. Initializing, Idle, ProductArmed, +// WeightPresent, WeightStable, AwaitingStability, the two keypads and ManualMode. +// +// Every one of them ends the same way -- by calling validate (cycle.go), which is +// where the weight is frozen -- or by waiting for the gesture that is missing. What +// happens AFTER the label is handed over is transition_outcome.go. + +// initializing serves the state before the first catalog. The station cannot +// weigh, so it answers one event and ignores the rest. +func initializing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + e, ok := ev.(CatalogReady) + if !ok || e.Catalog == nil || e.Catalog.Len() == 0 { + return m, nil + } + next := m.clear(Idle) + return next, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} +} + +// idle serves the resting state: scale empty, nothing selected. +func idle(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case ProductTapped: + return tapFromIdle(m, e, ctx) + + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + return next, nil + } + next.State = presentOrStable(next) + return next, nil + + case TareTapped: + return openTareKeypad(m, ctx) + + case ReprintRequested: + return reprint(m, e, ctx) + + case CatalogReady: + if e.Catalog == nil || e.Catalog.Len() == 0 { + return m, nil + } + return m, []Effect{ApplyCatalogEffect{Catalog: e.Catalog, ImportedAt: e.ImportedAt}} + + case Tick: + // A station that declares it has no scale and allows manual entry has no + // resting state of its own: ManualMode IS its resting state. + if manualOnly(ctx.Cfg) { + next := m + next.State = ManualMode + return next, nil + } + return m, nil + } + return m, nil +} + +// tapFromIdle is the transition that ADR-022 is about. +// +// Touching a by-weight product on an empty scale ARMS the selection instead of +// refusing it. The legacy application imposed the opposite order, and not for +// ergonomic reasons: printing was triggered synchronously by the click, which +// re-read the caption of the banner at that very instant, so there was NOWHERE to +// remember a pending selection. This architecture has somewhere. +func tapFromIdle(m Model, ev ProductTapped, ctx TransitionContext) (Model, []Effect) { + product, ok := offered(ctx.Catalog, ev.ProductID) + if !ok { + return m, refuseProduct(m.State) + } + next := m.startCycle(product, ev, ctx) + if product.Mode == ByUnit { + // One tap, one label, for one unit -- no weight is read at all (ADR-023). + return validate(next, byUnit(next.Units, SourceScale, ctx), ctx) + } + if manualOnly(ctx.Cfg) { + next.State = EnteringWeight + return next, []Effect{ + ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, + AckEffect{Key: ev.Key, Ack: Ack{Accepted: true, State: EnteringWeight}}, + } + } + next.State = ProductArmed + return next, armEffects(ev.Key) +} + +// armed serves ProductArmed: a product is chosen, the bag is not there yet. +func armed(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + return next, nil + } + // The first valid measurement is what triggers the print. The stability + // rule is the SAME as the one that applies when the two gestures happen in + // the other order (see weighing): the order of the gestures must not change + // the outcome, which is the whole point of ADR-022. + if blockingStability(ctx.Cfg) && !next.LatchState.Latched { + return awaitStability(next, ctx) + } + return validate(next, fromScale(next, ctx), ctx) + + case ProductTapped: + // The last intention expressed wins, always: another tile re-arms on the + // new product and restarts the timer. + return tapFromIdle(m.clear(Idle), e, ctx) + + case TareTapped: + return openTareKeypad(m, ctx) + + case Tick: + if ctx.Now.Sub(m.ArmedAt) < MaxArmingTime { + return m, nil + } + // SILENT disarming: there is nobody in front of the screen to read a + // message, and a screen that talks to itself in an empty shop is noise. + return m.clear(Idle), nil + } + return m, nil +} + +// weighing serves WeightPresent and WeightStable, which differ only by what the +// latch says. They answer the same events, so they share one function. +func weighing(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + return next.clear(Idle), nil + } + next.State = presentOrStable(next) + return next, nil + + case ProductTapped: + return tapOnWeight(m, e, ctx) + + case TareTapped: + return openTareKeypad(m, ctx) + + case ReprintRequested: + return reprint(m, e, ctx) + } + return m, nil +} + +// tapOnWeight serves a tap while a mass is on the plate -- the nominal order of +// the gestures. +func tapOnWeight(m Model, ev ProductTapped, ctx TransitionContext) (Model, []Effect) { + product, ok := offered(ctx.Catalog, ev.ProductID) + if !ok { + return m, refuseProduct(m.State) + } + next := m.startCycle(product, ev, ctx) + if product.Mode == ByUnit { + return validate(next, byUnit(next.Units, SourceScale, ctx), ctx) + } + if changed, seen, now := weightMoved(next, ev, ctx); changed { + // The customer touched a weight that is no longer there. Printing the + // current mass would hand them a price they never saw, and printing the + // one they saw would hand them a mass that is not on the plate. So we + // print neither: the tile comes back and the next tap lands on a fresh + // weight. + return m, []Effect{ + MessageEffect{ + Level: LevelInfo, Code: CodeWeightUnstable, + Text: DefaultMessage(CodeWeightUnstable), Duration: SuccessMessageDuration, + }, + TechnicalLogEffect{ + Level: LevelWarn, Source: "ui", Code: "", + Message: "Toucher sur un poids qui avait déjà changé.", + Detail: fmt.Sprintf("vu %d g, mesuré %d g", seen, now), + }, + AckEffect{Key: ev.Key, Ack: Ack{ + Accepted: false, State: m.State, Code: CodeWeightUnstable, + Message: DefaultMessage(CodeWeightUnstable), + }}, + } + } + if blockingStability(ctx.Cfg) && !next.LatchState.Latched { + return awaitStability(next, ctx) + } + return validate(next, fromScale(next, ctx), ctx) +} + +// awaitingStability serves the blocking mode only (A3). The shipped default never +// reaches it. +func awaitingStability(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case MeasurementReceived: + next := m.fold(e.M, ctx.Cfg.Stability) + if emptyZone(e.M.Gross, ctx.Cfg.Limits) { + return next.clear(Idle), nil + } + if next.LatchState.Latched { + return validate(next, fromScale(next, ctx), ctx) + } + return next, nil + + case Tick: + if ctx.Now.Sub(m.ArmedAt) < time.Duration(ctx.Cfg.Stability.Timeout) { + return m, nil + } + switch ctx.Cfg.Stability.OnTimeout { + case OnTimeoutReject: + return rejectUnstable(m, ctx) + case OnTimeoutManualEntry: + next := m + next.State, next.ArmedAt = EnteringWeight, ctx.Now + return next, []Effect{ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}} + default: + // warn_and_print, the shipped answer: the label comes out and the + // journal records stability='unstable'. + // + // The effective severity of rule 6 is lowered HERE, and that is the + // whole content of the answer: were it still blocking at this instant, + // warn_and_print would warn and print NOTHING -- the timeout would walk + // into the validation and be refused there for the very reason the + // operator chose to forgive. The other thirteen safeguards are + // untouched, an expired weight included. + frozen := fromScale(m, ctx) + frozen.StabilityBlocks = false + return validate(m, frozen, ctx) + } + } + return m, nil +} + +// enteringTare serves the tare keypad. The scale stays visible during the whole +// entry, so measurements keep being folded in (§14.3). +func enteringTare(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case TareConfirmed: + next := m + next.Tare, next.State = e.Tare, Idle + return next, []Effect{AckEffect{Key: e.Key, Ack: Ack{Accepted: true, State: Idle}}} + + case MeasurementReceived: + return m.fold(e.M, ctx.Cfg.Stability), nil + + case Tick: + return abandonEntry(m, ctx) + } + return m, nil +} + +// enteringWeight serves the manual weight keypad -- degraded paths only. +func enteringWeight(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case ManualWeightConfirmed: + if m.CurrentProduct == nil { + return m, nil + } + next := m + next.IdempotencyKey, next.JobID = e.Key, deriveJobID(e.Key, ctx) + msr := Measurement{ + Gross: e.Weight, Tare: m.Tare, Quantity: m.Units, + // The manual weight source DOES NOT LIE about stability: an entry is + // latched by construction, and the engine needs no special case (§6.5). + Stability: StabilityNotApplicable, + Timestamp: ctx.Now, + } + // A typed weight has an age of ZERO whatever the scale is doing. Passing + // the age of the last frame instead would make safeguard rule 2 refuse + // every manual entry on a station whose scale is silent -- that is, in the + // only situation manual entry exists for. + return validate(next, frozenWeight{ + Measurement: msr, Source: SourceManual, + StabilityBlocks: blockingStability(ctx.Cfg), + }, ctx) + + case MeasurementReceived: + return m.fold(e.M, ctx.Cfg.Stability), nil + + case Tick: + return abandonEntry(m, ctx) + } + return m, nil +} + +// manualMode serves a station that declares it has no scale. +func manualMode(m Model, ev Event, ctx TransitionContext) (Model, []Effect) { + switch e := ev.(type) { + case ProductTapped: + return tapWithoutScale(m, e, ctx) + + case ScaleReconnected: + return m.clear(Idle), nil + + case ReprintRequested: + return reprint(m, e, ctx) + + case Tick: + if !manualOnly(ctx.Cfg) { + next := m + next.State = Idle + return next, nil + } + return m, nil + } + return m, nil +} + +// openTareKeypad opens the tare keypad, from the three states that offer it. +// +// Idle, ProductArmed and the two weight states answer TareTapped with the SAME six +// lines, and they have to: the keypad is anchored under the banner and the scale +// stays visible whatever was going on (§14.3), so putting a bag down mid-entry +// changes nothing about the entry. Three copies is how one of them would eventually +// forget to restart the idle timer. +func openTareKeypad(m Model, ctx TransitionContext) (Model, []Effect) { + next := m + next.State, next.ArmedAt = EnteringTare, ctx.Now + return next, []Effect{ + ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, + AckEffect{Ack: Ack{Accepted: true, State: EnteringTare}}, + } +} + +// tapWithoutScale serves a tap on a station that cannot read a mass -- one that +// declares no scale, and one whose scale stopped answering. +// +// The two reach it by different roads and ask the same thing of it: a by-unit +// product prints at once, source MANUAL, and anything sold by weight goes to the +// keypad. Only the road differs, and each caller keeps its own: ManualMode is a +// resting state, ScaleLost first asks whether manual entry is allowed at all. +func tapWithoutScale(m Model, ev ProductTapped, ctx TransitionContext) (Model, []Effect) { + product, ok := offered(ctx.Catalog, ev.ProductID) + if !ok { + return m, refuseProduct(m.State) + } + next := m.startCycle(product, ev, ctx) + if product.Mode == ByUnit { + return validate(next, byUnit(next.Units, SourceManual, ctx), ctx) + } + next.State = EnteringWeight + return next, []Effect{ + ArmTimerEffect{Duration: idleTimeout(ctx.Cfg)}, + AckEffect{Key: ev.Key, Ack: Ack{Accepted: true, State: EnteringWeight}}, + } +} + +// armEffects is what entering ProductArmed tells the outside world. +// +// The wording is safeguard 4's, taken from the table of §6.4 rather than written +// again here: "the scale is empty" and "put your product down" are the same +// sentence said to a customer, and one French string with one owner cannot drift +// from the other. +func armEffects(key string) []Effect { + return []Effect{ + MessageEffect{ + Level: LevelInfo, Code: CodeScaleEmpty, + Text: DefaultMessage(CodeScaleEmpty), Duration: MaxArmingTime, + }, + ArmTimerEffect{Duration: MaxArmingTime}, + AckEffect{Key: key, Ack: Ack{Accepted: true, State: ProductArmed}}, + } +} + +// awaitStability enters the blocking-mode wait. +func awaitStability(m Model, ctx TransitionContext) (Model, []Effect) { + next := m + next.State, next.ArmedAt = AwaitingStability, ctx.Now + timeout := time.Duration(ctx.Cfg.Stability.Timeout) + return next, []Effect{ + MessageEffect{ + Level: LevelInfo, Code: CodeWeightUnstable, + Text: DefaultMessage(CodeWeightUnstable), Duration: timeout, + }, + ArmTimerEffect{Duration: timeout}, + AckEffect{Key: m.IdempotencyKey, Ack: Ack{ + Accepted: true, State: AwaitingStability, Code: CodeWeightUnstable, + Message: DefaultMessage(CodeWeightUnstable), + }}, + } +} + +// abandonEntry clears a keypad entry nobody came back to. +// +// This is all that is left of idle_timeout_s (§14.3): no report is ever chased off +// the screen by a stopwatch, but a customer who walks away never leaves a +// half-typed figure for the next one. It is silent, for the same reason the +// disarming is. +func abandonEntry(m Model, ctx TransitionContext) (Model, []Effect) { + if ctx.Now.Sub(m.ArmedAt) < idleTimeout(ctx.Cfg) { + return m, nil + } + next := m.clear(Idle) + if manualOnly(ctx.Cfg) { + next.State = ManualMode + } + return next, nil +} + +// refuseProduct answers a tap on a product the published catalog does not offer. +// +// It reuses the wording of safeguard 14: from the customer's side, a product +// absent from the snapshot and a product withdrawn by a volunteer are the same +// sentence, and inventing a fifteenth code would only add a string to translate. +func refuseProduct(state State) []Effect { + return []Effect{ + MessageEffect{ + Level: LevelWarn, Code: CodeProductWithdrawn, + Text: DefaultMessage(CodeProductWithdrawn), Duration: RejectMessageDuration, + }, + AckEffect{Ack: Ack{ + Accepted: false, State: state, Code: CodeProductWithdrawn, + Message: DefaultMessage(CodeProductWithdrawn), + }}, + } +} diff --git a/internal/domain/transition_weighing_test.go b/internal/domain/transition_weighing_test.go new file mode 100644 index 0000000..d45cdde --- /dev/null +++ b/internal/domain/transition_weighing_test.go @@ -0,0 +1,336 @@ +// This file holds the GESTURES a weighing is built from: the tap on a tile, the +// tare, the quantity, the catalog arriving under a customer's finger, and the +// keypad nobody came back to. +// +// What the SCALE says -- stability, expiry, overload, the latch -- is +// transition_stability_test.go. + +package domain + +import ( + "testing" + "time" +) + +// TestTransitionByUnitProductPrintsAtFirstTapForOneUnit is ADR-023: the same +// gesture and the same immediacy as a product sold by weight, on an EMPTY plate. +// +// This is the scenario safeguard rule 4 would refuse if the by-unit path were fed +// the state of the scale: SCALE_EMPTY is blocking, and the plate is empty by +// design here. +func TestTransitionByUnitProductPrintsAtFirstTapForOneUnit(t *testing.T) { + r := newRun(t) + effects := r.at(0).tap("5209", "01J-EGGS") + + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("a by-unit tap printed nothing: %s, %#v", r.m.State, effects) + } + if print.Label.Quantity != 1 { + t.Errorf("quantity %d, want 1", print.Label.Quantity) + } + if got := print.Label.Barcode; got != mustCompose(t, "049912345601") { + t.Errorf("barcode %s, want the pattern with a payload of 01", got) + } + if line := print.Label.Find("MEMBER"); line == nil || line.Amount != 284 { + t.Errorf("member amount %v, want 284 cents (315 x 9/10 = 283,5 -> 284)", line) + } + + // A multiple quantity is a field of the POST, not a state of the machine. + r = newRun(t) + effects = r.send(ProductTapped{ProductID: "5209", Units: 3, Key: "01J-THREE"}) + print, ok = findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("three units printed nothing: %s", r.m.State) + } + if print.Label.Quantity != 3 { + t.Errorf("quantity %d, want 3", print.Label.Quantity) + } + if got := print.Label.Barcode; got != mustCompose(t, "049912345603") { + t.Errorf("barcode %s, want a payload of 03", got) + } + if line := print.Label.Find("SOLIDARITY"); line == nil || line.Amount != 945 { + t.Errorf("solidarity amount %v, want 945 cents (315 x 3)", line) + } +} + +// TestTransitionRefusesAQuantityOutsideItsBounds keeps safeguard 10 reachable from +// the machine even though the quantity stopped being a state (§6.6). +func TestTransitionRefusesAQuantityOutsideItsBounds(t *testing.T) { + r := newRun(t) + effects := r.send(ProductTapped{ProductID: "5209", Units: 120, Key: "01J-MANY"}) + if r.m.State != Rejected { + t.Fatalf("120 units reached %s, want rejected", r.m.State) + } + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("120 units printed %d labels", n) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Accepted || ack.Ack.Code != CodeUnitsOutOfRange { + t.Errorf("the ack says %+v, want a refusal on %s", ack.Ack, CodeUnitsOutOfRange) + } + record, ok := findEffect[RecordEffect](effects) + if !ok || record.Weighing.Result != ResultRejected { + t.Error("a refused weighing is a journal row too") + } + if len(record.Weighing.Lines) == 0 { + t.Error("weighing_lines is mandatory, even on a refusal (§12.3)") + } + if record.Weighing.Barcode != "" { + t.Error("a refused weighing carries no barcode: nothing was printed") + } +} + +// TestTransitionRefusesAProductTheCatalogDoesNotOffer covers both a product absent +// from the snapshot and one the qualification kept out of the grid. From the +// customer's side they are the same sentence. +func TestTransitionRefusesAProductTheCatalogDoesNotOffer(t *testing.T) { + for _, id := range []string{"5115", "does-not-exist"} { + r := newRun(t) + r.at(0).measure(1236, Stable) + effects := r.at(400*time.Millisecond).tap(id, "01J-NOPE") + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("product %q printed %d labels", id, n) + } + if r.m.State != WeightPresent { + t.Errorf("product %q moved the station to %s", id, r.m.State) + } + ack, ok := findEffect[AckEffect](effects) + if !ok || ack.Ack.Accepted || ack.Ack.Code != CodeProductWithdrawn { + t.Errorf("product %q: ack %+v", id, ack.Ack) + } + message, _ := findEffect[MessageEffect](effects) + if message.Text != "Ce produit n'est pas disponible." { + t.Errorf("product %q says %q", id, message.Text) + } + } +} + +// TestTransitionIgnoresATapOnAWeightThatMoved: printing the current mass would +// hand the customer a price they never saw, and printing the one they saw would +// hand them a mass that is not on the plate. So neither is printed. +func TestTransitionIgnoresATapOnAWeightThatMoved(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + effects := r.at(400 * time.Millisecond).send(ProductTapped{ + ProductID: "894", SeenWeight: 800, MeasurementSeq: 1, Key: "01J-STALE", + }) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a stale tap printed %d labels", n) + } + if r.m.State != WeightPresent { + t.Errorf("a stale tap moved the station to %s", r.m.State) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Accepted { + t.Error("a stale tap was accepted") + } + if _, ok := findEffect[TechnicalLogEffect](effects); !ok { + t.Error("a stale tap left no technical trace") + } + + // Inside the latch tolerance the two frames describe the same bag, and + // refusing them would refuse every legitimate tap. + r = newRun(t) + r.at(0).measure(1236, Stable) + effects = r.at(400 * time.Millisecond).send(ProductTapped{ + ProductID: "894", SeenWeight: 1235, MeasurementSeq: 1, Key: "01J-FRESH", + }) + if n := countEffect[PrintEffect](effects); n != 1 { + t.Fatalf("a one-gram drift refused the tap: %s", r.m.State) + } +} + +// TestTransitionAbandonedEntryIsClearedSilently is all that is left of +// idle_timeout_s (§14.3): a customer who walks away never leaves a half-typed +// figure for the next one, and no report is ever chased off the screen. +func TestTransitionAbandonedEntryIsClearedSilently(t *testing.T) { + r := newRun(t) + effects := r.at(0).send(TareTapped{}) + if r.m.State != EnteringTare { + t.Fatalf("TareTapped reached %s", r.m.State) + } + if timer, ok := findEffect[ArmTimerEffect](effects); !ok || timer.Duration != 45*time.Second { + t.Errorf("the entry declares a timer of %v, want 45 s", timer.Duration) + } + + // The scale stays visible during the whole entry (§14.3). + if n := len(r.at(2*time.Second).measure(1236, Stable)); n != 0 { + t.Error("a measurement during a tare entry produced an effect") + } + if r.m.State != EnteringTare { + t.Fatalf("a measurement left the tare entry: %s", r.m.State) + } + + if n := len(r.at(44 * time.Second).send(Tick{})); n != 0 || r.m.State != EnteringTare { + t.Errorf("the entry died at 44 s: %s", r.m.State) + } + if n := len(r.at(46 * time.Second).send(Tick{})); n != 0 { + t.Errorf("the abandoned entry is not silent: %d effects", n) + } + if r.m.State != Idle || r.m.Tare != 0 { + t.Fatalf("after the timeout: state %s, tare %d", r.m.State, r.m.Tare) + } +} + +// TestTransitionTareTravelsWithTheTapAndReachesTheLabel: rule 7 is the single +// place that says whether a tare is usable, and it says it against the weight it +// will be applied to. +func TestTransitionTareTravelsWithTheTapAndReachesTheLabel(t *testing.T) { + r := newRun(t) + r.at(0).send(TareTapped{}) + effects := r.at(3 * time.Second).send(TareConfirmed{Tare: 236, Key: "01J-TARE"}) + if r.m.State != Idle || r.m.Tare != 236 { + t.Fatalf("after the tare: state %s, tare %d", r.m.State, r.m.Tare) + } + if ack, ok := findEffect[AckEffect](effects); !ok || !ack.Ack.Accepted { + t.Error("the confirmed tare was not acknowledged") + } + + r.at(4*time.Second).measure(1472, Stable) + effects = r.at(4500 * time.Millisecond).send(ProductTapped{ + ProductID: "894", Tare: 236, Key: "01J-TARED", + }) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("the tared weighing printed nothing: %s, %+v", r.m.State, r.m.Diagnostics) + } + if print.Label.Tare != 236 || print.Label.NetWeight != 1236 { + t.Errorf("tare %d and net %d, want 236 and 1236", print.Label.Tare, print.Label.NetWeight) + } + if print.Label.Barcode != "0493021012365" { + t.Errorf("barcode %s: the payload carries the NET weight", print.Label.Barcode) + } + + // A tare heavier than the weighing is refused by rule 7, not by the machine. + r = newRun(t) + r.at(0).measure(200, Stable) + effects = r.at(400 * time.Millisecond).send(ProductTapped{ + ProductID: "894", Tare: 300, Key: "01J-BADTARE", + }) + if n := countEffect[PrintEffect](effects); n != 0 { + t.Fatalf("a tare heavier than the weighing printed %d labels", n) + } + ack, _ := findEffect[AckEffect](effects) + if ack.Ack.Code != CodeTareInvalid { + t.Errorf("refused on %q, want %s", ack.Ack.Code, CodeTareInvalid) + } +} + +// TestTransitionCatalogArrivesOnlyWhereItIsSafe: a swap from a weighing state would +// reorder the tiles under a customer's finger, which is what the deferred swap of +// §10.8 exists to prevent. +func TestTransitionCatalogArrivesOnlyWhereItIsSafe(t *testing.T) { + catalog := machineCatalog(t) + r := &run{t: t, m: Model{State: Initializing}, + ctx: TransitionContext{Cfg: machineConfig(), Now: origin}} + + if n := len(r.send(CatalogReady{})); n != 0 || r.m.State != Initializing { + t.Fatalf("an empty catalog started the station: %s", r.m.State) + } + effects := r.send(CatalogReady{Catalog: catalog}) + if r.m.State != Idle { + t.Fatalf("the first catalog reached %s, want idle", r.m.State) + } + apply, ok := findEffect[ApplyCatalogEffect](effects) + if !ok || apply.Catalog != catalog { + t.Error("the catalog was not applied") + } + r.ctx.Catalog = catalog + + if _, ok := findEffect[ApplyCatalogEffect](r.send(CatalogReady{Catalog: catalog})); !ok { + t.Error("a catalog arriving at rest was not applied") + } + + r.at(0).measure(1236, Stable) + if _, ok := findEffect[ApplyCatalogEffect](r.send(CatalogReady{Catalog: catalog})); ok { + t.Error("a catalog was applied while a bag was on the plate") + } +} + +// TestTransitionArmingSurvivesAHandBrushingThePlate: the arming ends on a MASS, not +// on any reading at all. A hand steadying the plate, a draught, a zero that drifts +// by a gram must not consume the ten seconds a customer has to open their bag. +func TestTransitionArmingSurvivesAHandBrushingThePlate(t *testing.T) { + r := newRun(t) + r.at(0).tap("894", "01J-ARM") + for i := 1; i <= 5; i++ { + effects := r.at(time.Duration(i)*time.Second).measure(Grams(i-3), Stable) + if len(effects) != 0 { + t.Fatalf("a reading inside the empty band at %d s produced %d effects", i, len(effects)) + } + if r.m.State != ProductArmed { + t.Fatalf("a reading inside the empty band at %d s left %s", i, r.m.State) + } + } + if n := countEffect[PrintEffect](r.at(6*time.Second).measure(1236, Stable)); n != 1 { + t.Fatalf("the bag produced %d labels", n) + } +} + +// TestTransitionByUnitSaleIgnoresWhatIsOnThePlate: a customer weighing vegetables +// who then taps a by-unit tile gets a label for the items and nothing about the +// mass -- the sale does not use the plate (ADR-023). +func TestTransitionByUnitSaleIgnoresWhatIsOnThePlate(t *testing.T) { + r := newRun(t) + r.at(0).measure(1236, Stable) + effects := r.at(400 * time.Millisecond).send(ProductTapped{ + ProductID: "5209", Units: 2, Key: "01J-EGGS", + }) + print, ok := findEffect[PrintEffect](effects) + if !ok { + t.Fatalf("no label: %s, %+v", r.m.State, r.m.Diagnostics) + } + if print.Label.GrossWeight != 0 || print.Label.NetWeight != 0 { + t.Errorf("a by-unit label carries a mass: %+v", print.Label) + } + if print.Label.Quantity != 2 { + t.Errorf("quantity %d, want 2", print.Label.Quantity) + } + + // Same from a station with no scale at all, and from one that lost it. + for _, arrange := range []func(*run){ + func(r *run) { r.ctx.Cfg.Scale.Present = false; r.at(0).send(Tick{}) }, + func(r *run) { r.at(0).send(ScaleDisconnected{}) }, + } { + r := newRun(t) + arrange(r) + if n := countEffect[PrintEffect](r.send(ProductTapped{ProductID: "5209", Key: "01J-E"})); n != 1 { + t.Fatalf("a by-unit sale printed %d labels from %s", n, r.m.State) + } + if r.m.Source != SourceManual { + t.Errorf("source %q, want %q on a station with no weight", r.m.Source, SourceManual) + } + } +} + +// TestTransitionAbandonedEntryReturnsToManualModeWhereThatIsHome: a station with no +// scale has no resting state other than manual entry, and an abandoned keypad must +// not leave it somewhere nothing can be tapped. +func TestTransitionAbandonedEntryReturnsToManualModeWhereThatIsHome(t *testing.T) { + r := newRun(t) + r.ctx.Cfg.Scale.Present = false + r.at(0).send(Tick{}) + r.tap("894", "01J-HANDTAP") + if r.m.State != EnteringWeight { + t.Fatalf("state %s", r.m.State) + } + r.at(50 * time.Second).send(Tick{}) + if r.m.State != ManualMode || r.m.CurrentProduct != nil { + t.Fatalf("state %s, product %v", r.m.State, r.m.CurrentProduct) + } +} + +// TestTransitionIgnoresACatalogItCannotUse: an empty snapshot is not a catalog, and +// applying it would empty the grid of a station that was serving customers. +func TestTransitionIgnoresACatalogItCannotUse(t *testing.T) { + r := newRun(t) + for _, ev := range []Event{CatalogReady{}, CatalogReady{Catalog: NewCatalog(nil, nil)}} { + if n := len(r.send(ev)); n != 0 { + t.Errorf("%#v was applied", ev) + } + if r.m.State != Idle { + t.Fatalf("state %s", r.m.State) + } + } +} diff --git a/internal/domain/validate.go b/internal/domain/validate.go new file mode 100644 index 0000000..bae4617 --- /dev/null +++ b/internal/domain/validate.go @@ -0,0 +1,380 @@ +package domain + +import ( + "fmt" + "strings" +) + +// This file holds the ENTRY POINT of the 48 controls a configuration has to pass, +// and the ones that judge THE HARDWARE OF THE STATION AND THE LABEL IT PRINTS: the +// number it answers to, the address it listens on, the three drivers it names, the +// options they declare, the numbering plan, and the template with its offsets. +// +// What judges the SETTINGS a cooperative chose -- the price grid, the weighing +// bounds, the catalog, the retention -- is validate_settings.go. The two halves are +// one list: Validate calls them in the order §11.3 numbers them, and never in the +// order they are written. +// +// THE ORDER IS PART OF THE CONTRACT. A volunteer reads the faults top to bottom and +// §11.3 names its controls by number, so the sequence Validate produces is what a +// screen, a test and a piece of documentation all agree on. Two numbers -- 37 and 47 +// -- are holes left on purpose, and each says at its place why it was removed. +// +// Nothing here reads a clock, opens a file or a socket. The two questions a pure +// function cannot answer -- "does this path exist?", "is this print queue really +// enumerated?" -- arrive through Registries. + +// retiredScaleTypes are the two values that LEFT the scale enumeration (§9.3), +// each with the reason it left. +// +// The previous version mixed two protocols, a DEGRADED MODE and a TEST TOOL in one +// drop-down list shown to a volunteer. The same state was then reachable through +// three doors -- a configuration value, an automatic fallback, a troubleshooting +// button -- which made the only question that matters on a bad morning undecidable: +// why is this station in manual entry? Refusing the two values is what keeps the +// three questions separate. +var retiredScaleTypes = map[string]string{ + SourceManual: "« manual » est un ÉTAT, pas un protocole : un poste sans balance se déclare avec scale.present = false, et la saisie à la main s'autorise avec manual_entry_allowed", + SourceReplay: "« replay » est un outil de diagnostic (openscale capture / openscale replay, bouton « Rejouer cette trame »), il n'a rien à faire dans la liste du matériel de pesée", +} + +// serialTransports are the transport names control 42 refuses for a printer. +var serialTransports = []string{"serial", "rs232", "rs-232", "com"} + +// faultList accumulates the faults one group of controls raises. +// +// It carries the two shorthands Validate used to open as closures over its own +// slice, and it is what lets each group of controls be a function of its own +// without every one of them re-declaring them. +type faultList []Fault + +// add appends a fault, naming the field that carries it. +func (f *faultList) add(field, format string, args ...any) { + *f = append(*f, Fault{Field: field, Message: fmt.Sprintf(format, args...)}) +} + +// addChoice appends a fault that also names the values the field would accept. +func (f *faultList) addChoice(field string, values []string, format string, args ...any) { + *f = append(*f, Fault{ + Field: field, Message: fmt.Sprintf(format, args...), Values: values, + }) +} + +// Validate returns ALL the faults, not the first one: the administration screen is +// used by volunteers, it must report everything at once, in French, with the +// offending field named and, whenever possible, the list of available values in +// Fault.Values. +// +// reg carries the driver descriptors, which is what allows the options of each +// driver to be validated instead of just its type; an empty registry validates the +// form and not the existence. +// +// An invalid configuration NEVER kills the process (§11.3): the server starts in +// "invalid configuration" mode, loads NeutralProfile in memory WITHOUT writing, +// serves this list of faults and shows a full-screen « Poste en configuration +// d'usine (ERR-CFG-01) ». A broken configuration must never produce a black screen. +func (c *Config) Validate(reg Registries) []Fault { + // Controls 21, 29 and 38 all read the label geometry, and twenty controls sit + // between the first and the last: it is resolved once, here, and handed down. + label := c.labelGeometry(reg) + + var faults []Fault + faults = append(faults, c.validateStation()...) // 1 + faults = append(faults, c.validateNetwork()...) // 2 + faults = append(faults, c.validateDeclaredDrivers(reg)...) // 3-5 + faults = append(faults, c.validateDriverOptions(reg)...) // 6-9 + faults = append(faults, c.validatePricing()...) // 10-16 + faults = append(faults, validateNumberingPlan(internalPlan)...) // 17-19 + faults = append(faults, c.validateRetiredKeys()...) // 20 + faults = append(faults, c.validateResolution(label)...) // 21 + faults = append(faults, c.validateLimits()...) // 22-25 + faults = append(faults, c.validateStability()...) // 26-28 + faults = append(faults, c.validateTemplate(reg, label)...) // 29 + faults = append(faults, c.validateJournal()...) // 30 + faults = append(faults, c.validateAdminSecrets()...) // 31 + faults = append(faults, c.validateCatalogShelving()...) // 32-36 + faults = append(faults, c.validateLabelOffsets(label)...) // 38 + faults = append(faults, c.validateCatalogGuards(reg)...) // 39-40 + faults = append(faults, c.validatePrinterDevice()...) // 41-42 + faults = append(faults, CheckPrice("limits.max_amount_cents", c.Limits.MaxAmount)...) // 43 + faults = append(faults, c.validateCatalogImages(reg)...) // 44-45 + faults = append(faults, c.validateDropDirectory(reg)...) // 46 + faults = append(faults, c.validateUpdate()...) // 48 + faults = append(faults, c.validateGrid()...) // 49 + return faults +} + +// labelGeometry is what controls 21, 29 and 38 read about the label this station +// prints: the template printer.template names, whether its resolution can be +// divided by, and the head the printer driver declares. +// +// Resolving it is a pair of registry lookups and nothing else -- no control judges +// anything here, the three that need it still do. +type labelGeometry struct { + template Template + // exists is whether the registry carries a layout under that name at all. + exists bool + // resolutionUsable is what control 21 answers: a template that exists AND declares + // a pitch every geometric rule can divide the world by. + resolutionUsable bool + // head is the geometry the printer driver declares, with every figure it left + // unsaid filled in from the WS408 of the parc. + head PrinterCapabilities +} + +func (c *Config) labelGeometry(reg Registries) labelGeometry { + template, exists := reg.Template(c.Printer.Template) + return labelGeometry{ + template: template, + exists: exists, + resolutionUsable: exists && template.Media.DotsPerMM > 0, + head: reg.PrinterHead(c.Printer.Type).orReference(), + } +} + +// validateStation is control 1: station.number ∈ [1,99]. It is what the watched +// file name derives from. +func (c *Config) validateStation() []Fault { + var faults faultList + if c.Station.Number < 1 || c.Station.Number > 99 { + faults.add("station.number", "%d hors bornes [1, 99] : c'est de ce numéro que dérive le nom du fichier surveillé, flv_.csv", + c.Station.Number) + } + return faults +} + +// validateNetwork is control 2: network.listen parseable. +func (c *Config) validateNetwork() []Fault { + var faults faultList + if err := checkHostPort(c.Network.Listen); err != nil { + faults.add("network.listen", "%q n'est pas une adresse hôte:port valide (%s)", c.Network.Listen, err) + } + return faults +} + +// validateDeclaredDrivers is controls 3 to 5: the three type names a station +// declares are ones this binary carries. +func (c *Config) validateDeclaredDrivers(reg Registries) []Fault { + var faults faultList + + // 3. scale.type known -- EXACTLY the protocols of the registry (§9.3). WHICH + // OPTIONS IT NEEDS IS NOT DECIDED HERE: control 6 asks the schema the chosen + // driver declares. + // + // This control used to demand the literal key `scale.options.port` of every + // station whose scale.present was raised, whatever its scale.type. A driver + // reached by an ADDRESS -- TCP, USB -- was therefore refused before it was ever + // asked, on a key its own schema does not carry, and adding one would have meant + // editing this function: exactly the coupling §5.2 removes. Nothing moves for the + // parc, whose serial drivers declare `port` Required in serial.OptionSchema, and + // the volunteer gains a line -- the field counted DOUBLE, once for this rule and + // once for the schema. + switch { + case c.Scale.Type == "" && c.Scale.Present: + faults.addChoice("scale.type", reg.ScaleTypes(), "aucun protocole n'est déclaré alors que le poste déclare une balance") + case c.Scale.Type == "": + // A station that declares it has no scale names no protocol, and that is + // deliberate: the neutral profile must not name a piece of hardware. + default: + if reason, retired := retiredScaleTypes[c.Scale.Type]; retired { + faults.addChoice("scale.type", reg.ScaleTypes(), "%q n'est plus une valeur de scale.type : %s", c.Scale.Type, reason) + } else if available := reg.ScaleTypes(); len(available) > 0 && !known(available, c.Scale.Type) { + faults.addChoice("scale.type", available, "protocole inconnu %q", c.Scale.Type) + } + } + + // 4. printer.type known -- exactly the three registered descriptors, raster by + // default, sbpl and preview (§8.1, §8.2). + if c.Printer.Type == "" { + faults.addChoice("printer.type", reg.PrinterTypes(), "aucun driver d'impression n'est déclaré") + } else if available := reg.PrinterTypes(); len(available) > 0 && !known(available, c.Printer.Type) { + faults.addChoice("printer.type", available, "driver d'impression inconnu %q", c.Printer.Type) + } + + // 5. catalog.type known. "manual" is NOT a source: the drag and drop of the + // administration screen writes into local_drop (A4, §10.1). + switch { + case c.Catalog.Type == "": + faults.addChoice("catalog.type", reg.CatalogSourceNames(), "aucune source de catalogue n'est déclarée") + case c.Catalog.Type == CatalogSourceManual: + faults.addChoice("catalog.type", reg.CatalogSourceNames(), + "%q n'est pas une source : le glisser-déposer de l'administration écrit dans %s, et la scrutation fait le reste", + CatalogSourceManual, CatalogSourceLocalDrop) + default: + if available := reg.CatalogSourceNames(); len(available) > 0 && !known(available, c.Catalog.Type) { + faults.addChoice("catalog.type", available, "source de catalogue inconnue %q", c.Catalog.Type) + } + } + return faults +} + +// validateDriverOptions is controls 6 to 9: each option map judged by the schema +// THE CHOSEN DRIVER declares, and the transport named among the registered ones. +func (c *Config) validateDriverOptions(reg Registries) []Fault { + var faults faultList + + // 6. scale.options validated by the schema the scale driver declares. + faults = append(faults, validateOptions("scale.options", c.Scale.Options, + descriptorByID(reg.Scales, c.Scale.Type), reg.Scales)...) + + // 7. printer.options validated by the schema the printer driver declares. + faults = append(faults, validateOptions("printer.options", c.Printer.Options, + descriptorByID(reg.Printers, c.Printer.Type), reg.Printers)...) + + // 8. printer.options.transport is one of the registered transports. + transport, hasTransport := c.Printer.Options.Text("transport") + if hasTransport && transport != "" { + if available := reg.TransportNames(); len(available) > 0 && !known(available, transport) { + faults.addChoice("printer.options.transport", available, "transport inconnu %q", transport) + } + } + + // 9. catalog.options validated by the schema the source declares. + faults = append(faults, validateOptions("catalog.options", c.Catalog.Options, + descriptorByID(reg.CatalogSources, c.Catalog.Type), reg.CatalogSources)...) + return faults +} + +// validateNumberingPlan reports the faults of controls 17 to 19 on a numbering +// plan. +// +// The internal numbering plan SELF-CHECKS at start-up (§6.2, ADR-028): every declared prefix is +// exactly four digits, 4 + ref + payload + 1 = 13, and no prefix is declared twice. +// init() already panics on a broken plan, so these three can only fail in a test that +// hands over a broken table -- which is exactly why they are a function and not inline +// code: an inconsistent plan must stop the process AT START-UP, never at print time. +// +// It reuses the very check init() runs, so the two can never diverge: what stops the +// process is what the administration screen would explain. +func validateNumberingPlan(plan map[string]PrefixPlan) []Fault { + if err := validatePlan(plan); err != nil { + return []Fault{{ + Field: "barcode.plan", + Message: fmt.Sprintf("le plan de numérotation interne est incohérent : %s", err), + }} + } + return nil +} + +// validateRetiredKeys is control 20: a configuration still carrying a retired key +// -- numbering plan or pricing coefficient -- is REFUSED. +func (c *Config) validateRetiredKeys() []Fault { + var faults faultList + for _, path := range c.retired { + faults.add(path, "clé supprimée : %s", RetiredKeyReason(path)) + } + return faults +} + +// validateResolution is control 21: template.media.dots_per_mm is the SINGLE source +// of resolution (mineur-3). +// +// barcode.resolution_dpi is gone, and every geometric rule divides the world by this +// number. +func (c *Config) validateResolution(label labelGeometry) []Fault { + var faults faultList + if label.exists && !label.resolutionUsable { + faults.add("template.media.dots_per_mm", + "le gabarit %q ne déclare aucune résolution utilisable (8 sur une WS408, 12 sur une WS412)", + c.Printer.Template) + } + return faults +} + +// validateTemplate is control 29: the template EXISTS and Template.Validate() +// passes -- the nine hard rules of §7.5, on the geometry RECOMPOSED with the +// operator's offsets. +// +// They bear on the head THE DRIVER DECLARES: held as constants of the core, the +// inked width and height were counted at 8 dots/mm, so a station whose printer is +// not the WS408 of the parc failed this very control at start-up — §11.3 puts it out +// of service — on a template nobody could make it accept. +func (c *Config) validateTemplate(reg Registries, label labelGeometry) []Fault { + var faults faultList + if !label.exists { + faults.addChoice("printer.template", reg.TemplateNames(), "gabarit inconnu %q", c.Printer.Template) + } else if label.resolutionUsable { + shifted := label.template + shifted.OffsetXDots, _ = intOption(c.Printer.Options, "offset_x") + shifted.OffsetYDots, _ = intOption(c.Printer.Options, "offset_y") + for _, fault := range shifted.ValidateOn(label.head, len(c.Pricing.Tiers)) { + fault.Field = "printer.template." + fault.Field + faults = append(faults, fault) + } + } + return faults +} + +// 37. REMOVED, and its number left as a hole (ADR-044). It bounded printer.options.copies. +// +// The bound is now declared by the driver that owns the key and applied by control 7, +// which checks printer.options against the schema THAT driver declares. +// +// Held here, it named a key of a driver the core cannot see, and it was one of THREE +// bounds on one figure: this rule and the option schema said [1, 10], while +// raster.Settings.Validate accepted anything up to the six digits of the field. The +// same number therefore got two different answers depending on whether it was checked as +// a configuration or as a setting, and the disagreement could only be found by reading +// all three. There is now one constant, raster.MaxConfiguredCopies, declared beside the +// other bounds of the manual, and nothing moves for the parc. +// +// What is given up is what control 3 gave up on `port`: on an EMPTY registry -- +// `openscale config validate` on a laptop -- the schema check is skipped altogether, so +// the bound is no longer applied at validation time. It is still applied where it decides +// something, at the construction of the driver, and a bound that only a printer's own +// package can state is worth more than one the core repeats (§5.2, E1). + +// validateLabelOffsets is control 38: offset_x/y RECOMPOSED with the geometry of +// the template (mineur-2). +// +// The ±1 dot arrows of the admin screen invite that adjustment, so it must be +// bounded by the geometry and not merely by ±99. The message names the admissible +// maximum instead of just saying no. The margin is the one THIS head leaves: a bound +// counted at another pitch would refuse an adjustment the printer would have accepted. +func (c *Config) validateLabelOffsets(label labelGeometry) []Fault { + var faults faultList + if !label.exists || !label.resolutionUsable || label.head.DotsPerMM != label.template.Media.DotsPerMM { + return faults + } + maxX, maxY := label.template.MaxOffsetDotsOn(label.head, len(c.Pricing.Tiers)) + if offset, ok := intOption(c.Printer.Options, "offset_x"); ok && (offset < 0 || offset > maxX) { + faults.add("printer.options.offset_x", + "%d dots hors bornes [0, %d] pour le gabarit %q : au-delà, le contenu encré sortirait de l'étiquette", + offset, maxX, c.Printer.Template) + } + if offset, ok := intOption(c.Printer.Options, "offset_y"); ok && (offset < 0 || offset > maxY) { + faults.add("printer.options.offset_y", + "%d dots hors bornes [0, %d] pour le gabarit %q : au-delà, le contenu encré sortirait de l'étiquette", + offset, maxY, c.Printer.Template) + } + return faults +} + +// validatePrinterDevice is controls 41 and 42: a roll count worth alerting on, and +// the transport a label cannot travel over. +func (c *Config) validatePrinterDevice() []Fault { + var faults faultList + + // 41. roll_capacity ≥ 50. Below that the 90 % alert would fire on the first + // labels of a fresh roll and teach a volunteer to ignore it. + if capacity, ok := c.Printer.Options.Int("roll_capacity"); ok && capacity < 50 { + faults.add("printer.options.roll_capacity", "%d est sous le plancher de 50 étiquettes", capacity) + } + + // 42. A SERIAL transport is forbidden for the printer: a label weighs 16 ko, that + // is about 17 s at 9 600 bauds (§8.3). + transport, hasTransport := c.Printer.Options.Text("transport") + if hasTransport && known(serialTransports, strings.ToLower(transport)) { + faults.addChoice("printer.options.transport", + []string{TransportWinspool, TransportDevfile, TransportTCP, TransportFile}, + "un transport série est interdit pour l'imprimante : une étiquette pèse 16 ko, soit environ 17 s à 9 600 bauds") + } + return faults +} + +// intOption reads an option that must be a whole number of dots, and reports +// whether it was there and readable. +func intOption(options DriverOptions, key string) (int, bool) { + value, ok := options.Int(key) + return int(value), ok +} diff --git a/internal/domain/validate_corpus_test.go b/internal/domain/validate_corpus_test.go new file mode 100644 index 0000000..5a6af88 --- /dev/null +++ b/internal/domain/validate_corpus_test.go @@ -0,0 +1,410 @@ +// This file holds THE CORPUS: one broken configuration per numbered control of +// §11.3, and the three tests that read it. +// +// One table rather than forty-eight functions, and the last test is why: it checks +// that the corpus covers the controls, so a control added without its case is a +// failing test rather than a silence. + +package domain + +import ( + "strings" + "testing" + "time" +) + +// brokenConfiguration is one wrong configuration and the field the volunteer must +// see named. +type brokenConfiguration struct { + control string + name string + mutate func(*testing.T, *Config) + // registries overrides the drivers and templates, for the two controls that bear + // on a template rather than on a value of the file. + registries func(Registries) Registries + field string +} + +// brokenConfigurations is the corpus of §11.3: at least 26 wrong configurations, +// each of them checking that the RIGHT field is named. +func brokenConfigurations() []brokenConfiguration { + return []brokenConfiguration{ + { + control: "1", name: "numéro de poste hors bornes", + mutate: func(_ *testing.T, c *Config) { c.Station.Number = 0 }, + field: "station.number", + }, { + control: "2", name: "adresse d'écoute illisible", + mutate: func(_ *testing.T, c *Config) { c.Network.Listen = "127.0.0.1" }, + field: "network.listen", + }, { + control: "3", name: "la balance « manual » a quitté l'énumération", + mutate: func(_ *testing.T, c *Config) { c.Scale.Type = SourceManual }, + field: "scale.type", + }, { + control: "3", name: "la balance « replay » a quitté l'énumération", + mutate: func(_ *testing.T, c *Config) { c.Scale.Type = SourceReplay }, + field: "scale.type", + }, { + control: "3", name: "protocole de balance inconnu", + mutate: func(_ *testing.T, c *Config) { c.Scale.Type = "gram-xfoc-turbo" }, + field: "scale.type", + }, { + // Control 6 and no longer 3: the key is named by the schema the GRAM driver + // declares, not by the core. + control: "6", name: "poste avec balance sans port série", + mutate: func(_ *testing.T, c *Config) { delete(c.Scale.Options, "port") }, + field: "scale.options.port", + }, { + control: "4", name: "driver d'impression inconnu", + mutate: func(_ *testing.T, c *Config) { c.Printer.Type = "gdi" }, + field: "printer.type", + }, { + control: "5", name: "« manual » n'est pas une source de catalogue", + mutate: func(_ *testing.T, c *Config) { c.Catalog.Type = CatalogSourceManual }, + field: "catalog.type", + }, { + control: "5", name: "source de catalogue inconnue", + mutate: func(_ *testing.T, c *Config) { c.Catalog.Type = "ftp" }, + field: "catalog.type", + }, { + control: "6", name: "option de balance du mauvais type", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Scale.Options, "baud", "rapide") + }, + field: "scale.options.baud", + }, { + control: "7", name: "option d'imprimante inconnue du driver", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "noircissement", 3) + }, + field: "printer.options.noircissement", + }, { + control: "8", name: "transport inconnu du registre", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "transport", "smb") + }, + field: "printer.options.transport", + }, { + control: "9", name: "url webdav qui n'est pas une URL", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "url", "dav.example.org:8001") + }, + field: "catalog.options.url", + }, { + control: "10", name: "grille de tarifs vide", + mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers = nil }, + field: "pricing.tiers", + }, { + control: "11", name: "une remise sur le tarif de référence", + mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[1].Discount = 200 }, + field: "pricing.tiers[1].discount_percent", + }, { + control: "12", name: "code de tarif déclaré deux fois", + mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[1].Code = c.Pricing.Tiers[0].Code }, + field: "pricing.tiers[1].code", + }, { + control: "13", name: "remise négative", + mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[0].Discount = -1 }, + field: "pricing.tiers[0].discount_percent", + }, { + control: "13", name: "remise au-dessus de 100 %", + mutate: func(_ *testing.T, c *Config) { c.Pricing.Tiers[0].Discount = FullDiscount + 1 }, + field: "pricing.tiers[0].discount_percent", + }, { + control: "14", name: "primary_code hors grille", + mutate: func(_ *testing.T, c *Config) { c.Pricing.PrimaryCode = "GHOST" }, + field: "pricing.primary_code", + }, { + control: "15", name: "reference_code hors grille", + mutate: func(_ *testing.T, c *Config) { c.Pricing.ReferenceCode = "GHOST" }, + field: "pricing.reference_code", + }, { + control: "16", name: "code secondaire hors grille", + mutate: func(_ *testing.T, c *Config) { c.Pricing.SecondaryCodes = []string{"GHOST"} }, + field: "pricing.secondary_codes[0]", + }, { + control: "21", name: "gabarit sans résolution", + mutate: func(_ *testing.T, c *Config) {}, + registries: func(reg Registries) Registries { + broken := IdenticalTemplate() + broken.Media.DotsPerMM = 0 + reg.Templates = map[string]Template{DefaultTemplateName: broken} + return reg + }, + field: "template.media.dots_per_mm", + }, { + control: "22", name: "fenêtre du panier inversée", + mutate: func(_ *testing.T, c *Config) { c.Limits.BasketMin, c.Limits.BasketMax = -270, -282 }, + field: "limits.basket_min_g", + }, { + control: "22", name: "fenêtre du panier positive", + mutate: func(_ *testing.T, c *Config) { c.Limits.BasketMin, c.Limits.BasketMax = 270, 282 }, + field: "limits.basket_min_g", + }, { + control: "23", name: "poids maximal au-delà de la capacité du champ NNDDD", + mutate: func(_ *testing.T, c *Config) { c.Limits.MaxWeight = 100_000 }, + field: "limits.max_weight_g", + }, { + control: "24", name: "plus de 99 unités", + mutate: func(_ *testing.T, c *Config) { c.Limits.MaxUnits = 100 }, + field: "limits.max_units", + }, { + control: "25", name: "montant maximal au-delà du champ de prix", + mutate: func(_ *testing.T, c *Config) { c.Limits.MaxAmount = 100_000 }, + field: "limits.max_amount_cents", + }, { + control: "26", name: "timeout sous la durée de stabilité exigée", + mutate: func(_ *testing.T, c *Config) { c.Stability.Timeout = Duration(200 * time.Millisecond) }, + field: "stability.timeout_ms", + }, { + control: "27", name: "plancher de péremption sous la seconde", + mutate: func(_ *testing.T, c *Config) { c.Stability.ExpiryFloor = Duration(800 * time.Millisecond) }, + field: "stability.expiry_floor_ms", + }, { + control: "27", name: "plancher de péremption au-dessus du plafond", + mutate: func(_ *testing.T, c *Config) { c.Stability.ExpiryFloor = Duration(6 * time.Second) }, + field: "stability.expiry_ceiling_ms", + }, { + control: "28", name: "mode de stabilité en français", + mutate: func(_ *testing.T, c *Config) { c.Stability.Mode = "informatif" }, + field: "stability.mode", + }, { + control: "28", name: "action de timeout inconnue", + mutate: func(_ *testing.T, c *Config) { c.Stability.OnTimeout = "avertir_et_imprimer" }, + field: "stability.on_timeout", + }, { + control: "29", name: "gabarit inexistant", + mutate: func(_ *testing.T, c *Config) { c.Printer.Template = "weighing_imaginaire" }, + field: "printer.template", + }, { + control: "29", name: "gabarit qui viole les neuf règles dures", + mutate: func(_ *testing.T, c *Config) {}, + registries: func(reg Registries) Registries { + broken := IdenticalTemplate() + // A module below the readability floor: no scanner reads it (rule 9). + broken.Symbol.ModuleMilliDots = 900 + reg.Templates = map[string]Template{DefaultTemplateName: broken} + return reg + }, + field: "printer.template.symbol.module_milli_dots", + }, { + control: "30", name: "journal sous le plancher de 100 pesées", + mutate: func(_ *testing.T, c *Config) { c.Journal.MaxRows = 50 }, + field: "journal.max_rows", + }, { + // Le remplissage RÉEL que la configuration livrée a porté. Il passe la + // vérification de forme, et son corps fait EXACTEMENT les 32 octets + // d'argon2id : seule la nature de ces octets le trahit. + control: "31", name: "empreinte de remplissage, tapée à la main", + mutate: func(_ *testing.T, c *Config) { + c.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" + }, + field: "admin.password_hash", + }, { + control: "31", name: "mot de passe en clair au lieu d'une empreinte argon2id", + mutate: func(_ *testing.T, c *Config) { c.Admin.PasswordHash = "admin" }, + field: "admin.password_hash", + }, { + control: "31", name: "empreinte de code de secours malformée", + mutate: func(_ *testing.T, c *Config) { c.Admin.RecoveryCodeHash = "$argon2id$v=19$sel$empreinte" }, + field: "admin.recovery_code_hash", + }, { + control: "32", name: "catégorie de repli hors liste", + mutate: func(_ *testing.T, c *Config) { c.Catalog.FallbackCategory = "divers" }, + field: "catalog.fallback_category", + }, { + control: "33", name: "code de catégorie déclaré deux fois", + mutate: func(_ *testing.T, c *Config) { c.Catalog.Categories[1].Code = c.Catalog.Categories[0].Code }, + field: "catalog.categories[1].code", + }, { + control: "34", name: "taux de lisibilité au-delà de 1", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "min_readable_ratio", 1.5) + }, + field: "catalog.options.min_readable_ratio", + }, { + control: "35", name: "couleur de catégorie en français", + mutate: func(_ *testing.T, c *Config) { c.Catalog.Categories[0].Color = "rouge" }, + field: "catalog.categories[0].color", + }, { + control: "36", name: "scrutation à zéro seconde", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "poll_interval_s", 0) + }, + field: "catalog.options.poll_interval_s", + }, { + control: "7", name: "onze exemplaires, hors des bornes que le driver déclare", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "copies", 11) + }, + field: "printer.options.copies", + }, { + control: "38", name: "décalage qui sortirait le contenu encré de l'étiquette", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "offset_x", 5) + }, + field: "printer.options.offset_x", + }, { + control: "39", name: "hôte HTTPS derrière un chemin de dépôt", + mutate: func(t *testing.T, c *Config) { + c.Catalog.Type = CatalogSourceLocalDrop + setOption(t, c.Catalog.Options, "url", "https://dav.example.org:8001/") + }, + field: "catalog.options.url", + }, { + control: "39", name: "mot de passe sur un répertoire qu'on possède", + mutate: func(t *testing.T, c *Config) { + c.Catalog.Type = CatalogSourceLocalDrop + delete(c.Catalog.Options, "url") + delete(c.Catalog.Options, "username") + }, + field: "catalog.options.password", + }, { + control: "40", name: "baisse de pesables au-delà de la moitié", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "max_weighable_drop", 0.9) + }, + field: "catalog.options.max_weighable_drop", + }, { + control: "41", name: "rouleau de 20 étiquettes", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "roll_capacity", 20) + }, + field: "printer.options.roll_capacity", + }, { + control: "42", name: "transport série pour l'imprimante", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Printer.Options, "transport", "serial") + }, + field: "printer.options.transport", + }, { + control: "43", name: "prix négatif dans un fichier livré", + mutate: func(_ *testing.T, c *Config) { c.Limits.MaxAmount = -1 }, + field: "limits.max_amount_cents", + }, { + control: "44", name: "source d'images inconnue", + mutate: func(_ *testing.T, c *Config) { c.Catalog.Images.Source = "jpeg" }, + field: "catalog.images.source", + }, { + control: "44", name: "répertoire d'images illisible depuis le service", + mutate: func(_ *testing.T, c *Config) { + c.Catalog.Images.Source = ImageSourceDirectory + c.Catalog.Images.Path = `Z:\photos` + }, + registries: func(reg Registries) Registries { + reg.Paths = unreadablePaths{} + return reg + }, + field: "catalog.images.path", + }, { + control: "45", name: "image plafonnée sous 16 ko", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "max_image_size_kb", 8) + }, + field: "catalog.options.max_image_size_kb", + }, { + control: "45", name: "image autorisée à dépasser le fichier qui la contient", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "max_image_size_kb", 4000) + setOption(t, c.Catalog.Options, "max_file_size_mb", 1) + }, + field: "catalog.options.max_image_size_kb", + }, { + control: "46", name: "répertoire de dépôt hors de portée du service", + mutate: func(t *testing.T, c *Config) { + c.Catalog.Type = CatalogSourceLocalDrop + setOption(t, c.Catalog.Options, "directory", `Z:\catalogue`) + }, + registries: func(reg Registries) Registries { + reg.Paths = unreadablePaths{} + return reg + }, + field: "catalog.options.directory", + }, { + control: "47", name: "répertoire de dépôt derrière un partage WebDAV", + mutate: func(t *testing.T, c *Config) { + setOption(t, c.Catalog.Options, "directory", `D:\catalogue`) + }, + field: "catalog.options.directory", + }, { + control: "48", name: "le dépôt suivi est une adresse web", + mutate: func(_ *testing.T, c *Config) { + c.Update.Repository = "https://github.com/lostmind84/OpenScale" + }, + field: "update.repository", + }, + } +} + +// TestValidateAcceptsAFreeTier is the other edge of check 13 (config.go:914): a +// hundred percent off is a discount a cooperative may legitimately declare, not +// merely the value the "remise au-dessus de 100 %" case in brokenConfigurations +// stops just short of. The zero edge is already pinned by +// TestDeliveredConfigurationValidatesWithoutAFault, whose SOLIDARITY tier carries +// no discount at all. +func TestValidateAcceptsAFreeTier(t *testing.T) { + config := loadDelivered(t) + config.Pricing.Tiers[0].Discount = FullDiscount + if fault := findFault(config.Validate(testRegistries()), "pricing.tiers[0].discount_percent"); fault != nil { + t.Errorf("une remise de 100 %% est refusée : %s", fault.Message) + } +} + +func TestValidateNamesTheRightField(t *testing.T) { + for _, testCase := range brokenConfigurations() { + t.Run("contrôle "+testCase.control+" — "+testCase.name, func(t *testing.T) { + config := loadDelivered(t) + testCase.mutate(t, &config) + registries := testRegistries() + if testCase.registries != nil { + registries = testCase.registries(registries) + } + faults := config.Validate(registries) + if findFault(faults, testCase.field) == nil { + t.Fatalf("aucune faute sur %q ; obtenu :\n%s", + testCase.field, strings.Join(fieldsOf(faults), "\n")) + } + }) + } +} + +// TestTheCorpusCoversTheControls is a guard on the test suite itself: a table that +// quietly shrank would be a validation that quietly stopped being exercised. +// +// Controls 17 to 19 bear on the COMPILED plan and 20 on the RAW file: neither can be +// provoked from a Config structure, so both have their own test and neither belongs +// to this corpus. +// +// 37 is a GAP in the numbering and not a control that stopped being tested: the copy +// count is bounded by the schema the printer driver declares, and the eleven copies that +// used to provoke it are still in the corpus, under control 7. The number is left unused +// rather than reassigned — the numbering is what docs/02-architecture.md §11.3 refers to, +// and a renumbering would silently change what a paragraph names. +func TestTheCorpusCoversTheControls(t *testing.T) { + const wrongConfigurationsFloor = 26 + + corpus := brokenConfigurations() + if len(corpus) < wrongConfigurationsFloor { + t.Fatalf("%d configurations fausses, plancher %d", len(corpus), wrongConfigurationsFloor) + } + covered := map[string]bool{} + for _, testCase := range corpus { + covered[testCase.control] = true + } + for _, control := range []string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", + "16", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", + "33", "34", "35", "36", "38", "39", "40", "41", "42", "43", "44", "45", + "46", "47", "48", + } { + if !covered[control] { + t.Errorf("le contrôle %s n'a aucune configuration fausse", control) + } + } + for _, control := range []string{"17", "18", "19", "20"} { + if covered[control] { + t.Errorf("le contrôle %s ne se provoque pas depuis une structure Config", control) + } + } +} diff --git a/internal/domain/validate_options.go b/internal/domain/validate_options.go new file mode 100644 index 0000000..7c48fb5 --- /dev/null +++ b/internal/domain/validate_options.go @@ -0,0 +1,174 @@ +package domain + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// This file holds controls 6, 7 and 9 -- the ones that judge an option map against +// the schema THE CHOSEN DRIVER declares, rather than against anything this package +// knows. +// +// It is separate from validate.go for that reason: the 48 controls know what a +// configuration means, these know only what a driver said about itself. + +// validateOptions reports every fault the options of one driver break against the +// schema THE DRIVER DECLARES. +// +// An unregistered driver -- no descriptor at all -- yields no fault: inventing a +// schema for a driver that has not been written yet would be a second source of +// truth for something the driver owns (ADR-025). +// +// family is the whole list the descriptor was drawn from — every scale, every printer, +// every catalog source this binary carries. It is read for ONE purpose: telling somebody +// which driver declares the key they typed under the wrong one. +func validateOptions(field string, options DriverOptions, descriptor *DriverDescriptor, + family []DriverDescriptor) []Fault { + if descriptor == nil { + return nil + } + var faults []Fault + declared := make(map[string]bool, len(descriptor.Options)) + names := make([]string, 0, len(descriptor.Options)) + for _, schema := range descriptor.Options { + declared[schema.Key] = true + names = append(names, schema.Key) + } + sort.Strings(names) + + for _, schema := range descriptor.Options { + path := field + "." + schema.Key + raw, ok := options[schema.Key] + if !ok || (schema.Required && isEmptyText(raw)) { + if schema.Required { + faults = append(faults, Fault{ + Field: path, + Message: fmt.Sprintf("option exigée par le driver %q", descriptor.ID), + }) + } + continue + } + faults = append(faults, schema.check(path, raw)...) + } + for _, key := range options.Keys() { + if declared[key] { + continue + } + // A key nobody declared is a refusal; a key ANOTHER driver of the same family + // declares is a piece of advice, and it is the one that matters — `directory` + // under a WebDAV share, `username` under a local drop, `queue` under a TCP + // transport are all the same mistake: the right key, the wrong driver. Saying so + // is what the two dedicated controls that used to name `local_drop` and `webdav` + // by hand were really worth (ADR-052). + message := fmt.Sprintf("option inconnue du driver %q", descriptor.ID) + if declaredBy := driversDeclaring(family, key, descriptor.ID); len(declaredBy) > 0 { + message = fmt.Sprintf("%s : c'est %s qui la déclare", message, + quotedList(declaredBy)) + } + faults = append(faults, Fault{Field: field + "." + key, Message: message, Values: names}) + } + return faults +} + +// quotedList spells a list of driver names the way a fault reads it aloud. +func quotedList(names []string) string { + quoted := make([]string, 0, len(names)) + for _, name := range names { + quoted = append(quoted, fmt.Sprintf("%q", name)) + } + if len(quoted) < 2 { + return strings.Join(quoted, "") + } + return strings.Join(quoted[:len(quoted)-1], ", ") + " ou " + quoted[len(quoted)-1] +} + +// isEmptyText reports whether a raw option value is the empty string, which is how a +// file spells a field nobody filled in. +// +// It is what makes a REQUIRED option refuse `"port": ""` the way it refuses a missing +// key: the two are the same thing for whoever is standing in front of the station, and +// the schema check alone would accept the empty string as a perfectly good text value. +// An optional option, on the contrary, is legitimately empty — `address` is empty on +// every station whose transport is winspool. +func isEmptyText(raw json.RawMessage) bool { + value, ok := DriverOptions{"": raw}.Text("") + return ok && value == "" +} + +// check reports the faults one raw value breaks against this schema entry. +func (s OptionSchema) check(field string, raw json.RawMessage) []Fault { + fault := func(format string, args ...any) []Fault { + return []Fault{{Field: field, Message: fmt.Sprintf(format, args...)}} + } + single := DriverOptions{s.Key: raw} + switch s.Kind { + case OptionText: + if _, ok := single.Text(s.Key); !ok { + return fault("attendu : %s", s.Kind) + } + case OptionBool: + if _, ok := single.Bool(s.Key); !ok { + return fault("attendu : %s", s.Kind) + } + case OptionInt: + value, ok := single.Int(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + if s.Max != 0 && (value < s.Min || value > s.Max) { + return fault("%d hors bornes [%d, %d]", value, s.Min, s.Max) + } + case OptionRatio: + value, ok := single.Ratio(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + // The bounds are declared IN PER MILLE, so no float ever enters a + // declaration; the comparison converts once, here. + if s.Max != 0 && (value < float64(s.Min)/1000 || value > float64(s.Max)/1000) { + return fault("%v hors bornes [%v, %v]", value, float64(s.Min)/1000, float64(s.Max)/1000) + } + case OptionEnum: + value, ok := single.Text(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + if len(s.Values) > 0 && !known(s.Values, value) { + return []Fault{{ + Field: field, + Message: fmt.Sprintf("valeur inconnue %q", value), + Values: s.Values, + }} + } + case OptionHostPort: + value, ok := single.Text(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + if value == "" { + return nil // an unused option, such as address when the transport is winspool + } + if err := checkHostPort(value); err != nil { + return fault("%q n'est pas une adresse hôte:port valide (%s)", value, err) + } + case OptionURL: + value, ok := single.Text(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + if value != "" && !isHTTPURL(value) { + return fault("%q n'est pas une URL http ou https absolue", value) + } + case OptionGroup: + nested, ok := single.Group(s.Key) + if !ok { + return fault("attendu : %s", s.Kind) + } + // A nested group has no family: the only driver that could declare its keys is the + // one that declared the group, so there is nobody to point at. + return validateOptions(field, nested, &DriverDescriptor{ID: s.Key, Options: s.Options}, nil) + } + return nil +} diff --git a/internal/domain/validate_options_test.go b/internal/domain/validate_options_test.go new file mode 100644 index 0000000..ca09e24 --- /dev/null +++ b/internal/domain/validate_options_test.go @@ -0,0 +1,189 @@ +// This file holds what an OPTION MAP is judged by: the schema its driver declares, +// kind by kind, and the three ways a key can be wrong -- absent when required, +// empty when required, or declared by another driver of the same family. + +package domain + +import ( + "strings" + "testing" +) + +func TestNestedOptionGroupIsValidated(t *testing.T) { + config := loadDelivered(t) + fallback, ok := config.Printer.Options.Group("fallback") + if !ok { + t.Fatal("le fichier livré doit porter un groupe fallback") + } + setOption(t, fallback, "transport", "smb") + setOption(t, config.Printer.Options, "fallback", fallback) + + faults := config.Validate(testRegistries()) + // The path names the GROUP as well as the key: "printer.options.transport" and + // "printer.options.fallback.transport" are two different settings, and a volunteer + // must be told which of the two is wrong. + if findFault(faults, "printer.options.fallback.transport") == nil { + t.Fatalf("le transport de secours doit être validé ; obtenu :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +func TestOptionKindNamesItselfInFrench(t *testing.T) { + for kind, wanted := range map[OptionKind]string{ + OptionText: "texte", + OptionInt: "nombre entier", + OptionBool: "vrai ou faux", + OptionRatio: "nombre", + OptionEnum: "valeur d'une liste", + OptionHostPort: "hôte:port", + OptionURL: "URL http ou https", + OptionGroup: "objet", + OptionKind(99): "inconnu", + } { + if got := kind.String(); got != wanted { + t.Errorf("OptionKind(%d) = %q, attendu %q", kind, got, wanted) + } + } +} + +// TestOptionSchemaChecksEveryKind exercises the schema-driven half of controls 6 to +// 9: the point of Registries is that a driver DECLARES its options and the file is +// checked against that declaration, not against a hard-coded list of key names. +func TestOptionSchemaChecksEveryKind(t *testing.T) { + cases := []struct { + name string + schema OptionSchema + value any + faulty bool + }{ + {"texte", OptionSchema{Key: "queue", Kind: OptionText}, "SATO WS408_1", false}, + {"texte reçoit un nombre", OptionSchema{Key: "queue", Kind: OptionText}, 4, true}, + {"booléen", OptionSchema{Key: "invert_bits", Kind: OptionBool}, true, false}, + {"booléen reçoit un texte", OptionSchema{Key: "invert_bits", Kind: OptionBool}, "oui", true}, + {"entier", OptionSchema{Key: "baud", Kind: OptionInt}, 9600, false}, + {"entier reçoit un texte", OptionSchema{Key: "baud", Kind: OptionInt}, "9600", true}, + {"entier dans ses bornes", OptionSchema{Key: "darkness", Kind: OptionInt, Max: 5}, 3, false}, + {"entier hors bornes", OptionSchema{Key: "darkness", Kind: OptionInt, Max: 5}, 9, true}, + {"ratio", OptionSchema{Key: "ratio", Kind: OptionRatio, Max: 1000}, 0.9, false}, + {"ratio hors bornes", OptionSchema{Key: "ratio", Kind: OptionRatio, Max: 1000}, 1.4, true}, + {"ratio reçoit un texte", OptionSchema{Key: "ratio", Kind: OptionRatio}, "0,9", true}, + {"énumération", OptionSchema{Key: "parity", Kind: OptionEnum, Values: []string{"N", "E"}}, "N", false}, + {"énumération hors liste", OptionSchema{Key: "parity", Kind: OptionEnum, Values: []string{"N", "E"}}, "P", true}, + {"énumération reçoit un nombre", OptionSchema{Key: "parity", Kind: OptionEnum}, 8, true}, + {"hôte:port", OptionSchema{Key: "address", Kind: OptionHostPort}, "192.168.1.40:9100", false}, + {"hôte:port vide, option inutilisée", OptionSchema{Key: "address", Kind: OptionHostPort}, "", false}, + {"hôte:port sans port", OptionSchema{Key: "address", Kind: OptionHostPort}, "192.168.1.40", true}, + {"hôte:port reçoit un nombre", OptionSchema{Key: "address", Kind: OptionHostPort}, 9100, true}, + {"URL", OptionSchema{Key: "url", Kind: OptionURL}, "https://dav.example.org:8001/", false}, + {"URL vide, option inutilisée", OptionSchema{Key: "url", Kind: OptionURL}, "", false}, + {"URL sans schéma", OptionSchema{Key: "url", Kind: OptionURL}, "dav.example.org", true}, + {"URL reçoit un booléen", OptionSchema{Key: "url", Kind: OptionURL}, true, true}, + {"groupe reçoit un nombre", OptionSchema{Key: "fallback", Kind: OptionGroup}, 1, true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + options := DriverOptions{} + setOption(t, options, testCase.schema.Key, testCase.value) + descriptor := DriverDescriptor{ID: "essai", Options: []OptionSchema{testCase.schema}} + faults := validateOptions("bloc.options", options, &descriptor, nil) + if testCase.faulty && len(faults) == 0 { + t.Fatalf("%v doit être refusé", testCase.value) + } + if !testCase.faulty && len(faults) != 0 { + t.Fatalf("%v doit passer, obtenu :\n%s", testCase.value, strings.Join(fieldsOf(faults), "\n")) + } + }) + } +} + +func TestRequiredOptionIsNamedWhenAbsent(t *testing.T) { + descriptor := DriverDescriptor{ID: "gram-xfoc-plus", Options: []OptionSchema{ + {Key: "port", Kind: OptionText, Required: true}, + {Key: "baud", Kind: OptionInt}, + }} + faults := validateOptions("scale.options", DriverOptions{}, &descriptor, nil) + if findFault(faults, "scale.options.port") == nil { + t.Fatalf("l'option exigée doit être nommée ; obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) + } + // An unregistered driver yields nothing: inventing a schema for a driver nobody + // has written yet would be a second source of truth. + if faults := validateOptions("scale.options", DriverOptions{}, nil, nil); len(faults) != 0 { + t.Fatalf("un driver non enregistré ne produit aucune faute, obtenu %v", fieldsOf(faults)) + } +} + +// TestARequiredOptionLeftEmptyIsAsAbsentAsAMissingKey. +// +// A required option is required to CARRY something. `"port": ""` parses as a text +// value, so the schema check was happy with it and only control 3 — which named the +// key `port` in the core — refused it. Now that the driver's own schema is the single +// voice on the subject, the empty string has to be refused there. +func TestARequiredOptionLeftEmptyIsAsAbsentAsAMissingKey(t *testing.T) { + descriptor := DriverDescriptor{ID: "gram-xfoc-plus", Options: []OptionSchema{ + {Key: "port", Kind: OptionText, Required: true}, + }} + options := DriverOptions{} + setOption(t, options, "port", "") + + faults := validateOptions("scale.options", options, &descriptor, nil) + if findFault(faults, "scale.options.port") == nil { + t.Fatalf("une option exigée laissée vide est acceptée ; obtenu :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +// TestAnOptionalOptionMayStayEmpty is the other half: `address` is empty on every +// station whose transport is winspool, and emptiness is how an unused option is +// spelled. +func TestAnOptionalOptionMayStayEmpty(t *testing.T) { + descriptor := DriverDescriptor{ID: "raster", Options: []OptionSchema{ + {Key: "address", Kind: OptionHostPort}, + {Key: "queue", Kind: OptionText}, + }} + options := DriverOptions{} + setOption(t, options, "address", "") + setOption(t, options, "queue", "") + + if faults := validateOptions("printer.options", options, &descriptor, nil); len(faults) != 0 { + t.Fatalf("une option facultative vide est refusée :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +func TestDriverOptionsRefuseAValueOfTheWrongShape(t *testing.T) { + options := DriverOptions{} + setOption(t, options, "queue", 8) + setOption(t, options, "invert_bits", "faux") + setOption(t, options, "ratio", "0,9") + setOption(t, options, "fallback", 3) + + if _, ok := options.Text("queue"); ok { + t.Error("un nombre ne se lit pas comme un texte") + } + if _, ok := options.Int("invert_bits"); ok { + t.Error("un texte ne se lit pas comme un entier") + } + if _, ok := options.Bool("invert_bits"); ok { + t.Error("un texte ne se lit pas comme un booléen") + } + if _, ok := options.Ratio("ratio"); ok { + t.Error("une virgule décimale n'est pas un nombre JSON") + } + if _, ok := options.Group("fallback"); ok { + t.Error("un nombre ne se lit pas comme un groupe d'options") + } + for _, absent := range []func() bool{ + func() bool { _, ok := options.Bool("absente"); return ok }, + func() bool { _, ok := options.Group("absente"); return ok }, + func() bool { _, ok := options.Ratio("absente"); return ok }, + func() bool { _, ok := options.Int("absente"); return ok }, + } { + if absent() { + t.Error("une option absente ne se lit pas") + } + } + var nothing DriverOptions + if nothing.clone() != nil || nothing.Keys() != nil { + t.Error("des options nulles restent nulles") + } +} diff --git a/internal/domain/validate_order_test.go b/internal/domain/validate_order_test.go new file mode 100644 index 0000000..0f6d958 --- /dev/null +++ b/internal/domain/validate_order_test.go @@ -0,0 +1,164 @@ +package domain + +import "testing" + +// TestValidateReportsItsFaultsInTheOrderTheControlsAreNumbered holds the SEQUENCE +// Config.Validate returns, and not merely its contents. +// +// # Why the order is a contract and not a detail +// +// The slice this test freezes is displayed, in this order, to a human being: +// `openscale doctor` prints it, the administration screen lists it, `openscale +// config validate` writes it to a terminal, and a station whose configuration was +// refused shows « Poste en configuration d'usine (ERR-CFG-01) » with these fields +// under it. A volunteer reads that list top to bottom, over the telephone, on a bad +// morning. §11.3 also names its controls BY NUMBER -- « control 20 », « control 46 » +// -- so the order the faults come out in is what a screen, a test and a paragraph of +// the architecture all agree on. +// +// Nothing else in this package holds it. Every other test looks a fault up by its +// field (findFault), which passes just as happily when two groups of controls have +// swapped places: that was measured on 03/08/2026 by interverting controls 22-25 +// with 26-28, and the whole suite stayed green. +// +// # If you are reading this because the test is red +// +// Ask one question: did you MEAN to change the order faults come out in? +// +// - If you added a control, it belongs at the end of the numbering (§11.3 leaves +// 37 and 47 as holes rather than renumbering), and its field belongs at the +// matching place in the lists below. Add it and move on. +// - If you moved an existing control, or reordered the calls in Config.Validate, +// that is the change this test exists to stop. The numbering is published; a +// station and a document that disagree about which control is 46 cost more than +// the tidiness gained. +// +// The two cases below are deliberately different in nature. The first breaks thirty +// fields at once and walks the whole numbering from control 1 to control 49. The +// second is the one a lookup by field could never hold: printer.options.transport is +// reported THREE times, by controls 7, 8 and 42, and only its POSITION says which. +func TestValidateReportsItsFaultsInTheOrderTheControlsAreNumbered(t *testing.T) { + for _, testCase := range []struct { + name string + break_ func(*Config, *Registries) + want []string + }{ + { + name: "trente champs cassés, du contrôle 1 au contrôle 49", + break_: func(c *Config, _ *Registries) { + c.Station.Number = 0 // 1 + c.Network.Listen = "pas une adresse" // 2 + c.Scale.Type = "gram-xfoc-turbo" // 3 + c.Printer.Type = "" // 4 + c.Catalog.Type = CatalogSourceManual // 5 + c.Pricing.PrimaryCode = "GHOST" // 14 + c.Pricing.ReferenceCode = "GHOST2" // 15 + c.Pricing.SecondaryCodes = []string{"NOPE"} // 16 + c.Limits.BasketMin, c.Limits.BasketMax = 50, 100 // 22 + c.Limits.MinWeight, c.Limits.MaxWeight = 9000, 10 // 23 + c.Limits.MinUnits, c.Limits.MaxUnits = 5, 500 // 24 + c.Limits.MaxAmount = 999_999 // 25 + c.Stability.Timeout = 0 // 26 + c.Stability.ExpiryFloor, c.Stability.ExpiryCeiling = 1, 0 // 27 + c.Stability.Mode = "bloquant" // 28 + c.Stability.OnTimeout = "refuser" // 28 + c.Printer.Template = "inconnu" // 29 + c.Journal.MaxRows = 1 // 30 + // 31, twice: a hash that parses but matches nothing, then one that does + // not even parse. + c.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" + c.Admin.RecoveryCodeHash = "pas-du-tout-argon2id" + c.Catalog.FallbackCategory = "divers" // 32 + c.Catalog.Categories[1].Code = c.Catalog.Categories[0].Code // 33 + c.Catalog.Categories[0].Color = "vert" // 35 + c.Catalog.Images.Source = "jpeg" // 44 + c.Update.Repository = "https://exemple.test/depot" // 48 + c.UI.GridColumns = 1 // 49 + }, + want: []string{ + "station.number", // 1 + "network.listen", // 2 + "scale.type", // 3 + "printer.type", // 4 + "catalog.type", // 5 + "pricing.primary_code", // 14 + "pricing.reference_code", // 15 + "pricing.secondary_codes[0]", // 16 + "limits.basket_min_g", // 22 + "limits.max_weight_g", // 23 + "limits.max_units", // 24 + "limits.max_amount_cents", // 25 + "stability.timeout_ms", // 26 + "stability.expiry_floor_ms", // 27 + "stability.expiry_ceiling_ms", // 27 + "stability.mode", // 28 + "stability.on_timeout", // 28 + "printer.template", // 29 + "journal.max_rows", // 30 + "admin.password_hash", // 31 + "admin.recovery_code_hash", // 31 + "catalog.fallback_category", // 32 + "catalog.categories[1].code", // 33 + "catalog.categories[0].color", // 35 + "catalog.images.source", // 44 + "update.repository", // 48 + "ui.grid_columns", // 49 + }, + }, + { + name: "les options et les chemins, où un même champ est nommé trois fois", + break_: func(c *Config, reg *Registries) { + reg.Paths = unreadablePaths{} + c.Catalog.Type = CatalogSourceLocalDrop + c.Catalog.Images.Source = ImageSourceDirectory + c.Catalog.Images.Path = `Z:\images` + c.Catalog.Options = c.Catalog.Options.WithText("directory", "https://nas.test/depot") + c.Catalog.Options = c.Catalog.Options.WithText("inconnue", "x") + c.Printer.Options = c.Printer.Options.WithText("transport", "rs232") + c.Printer.Options = c.Printer.Options.WithText("queue_bis", "x") + }, + want: []string{ + "printer.options.transport", // 7, the schema of the raster driver + "printer.options.queue_bis", // 7, a key no driver declares + "printer.options.transport", // 8, not a registered transport + "catalog.options.inconnue", // 9 + "catalog.options.password", // 9, declared by webdav and not by local_drop + "catalog.options.url", // 9 + "catalog.options.username", // 9 + "catalog.options.directory", // 39, an HTTP host behind a drop path + "printer.options.transport", // 42, a serial transport for the printer + "catalog.images.path", // 44, unreadable from the service + "catalog.options.directory", // 46, the service cannot write there + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + config := loadDelivered(t) + registries := testRegistries() + testCase.break_(&config, ®istries) + + got := config.Validate(registries) + fields := make([]string, len(got)) + for i, fault := range got { + fields[i] = fault.Field + } + // The FIRST divergence is reported and the comparison stops there. One + // control moved out of place shifts every fault after it, so listing all + // of them would bury the one line that says where the sequence broke. + for i := range fields { + if i >= len(testCase.want) { + t.Fatalf("faute n° %d en trop : %q\nobtenu %v\nattendu %v", + i+1, fields[i], fields, testCase.want) + } + if fields[i] != testCase.want[i] { + t.Fatalf("faute n° %d : %q, attendu %q\nobtenu %v\nattendu %v", + i+1, fields[i], testCase.want[i], fields, testCase.want) + } + } + if len(fields) < len(testCase.want) { + t.Fatalf("faute n° %d manquante : %q\nobtenu %v\nattendu %v", + len(fields)+1, testCase.want[len(fields)], fields, testCase.want) + } + }) + } +} diff --git a/internal/domain/validate_settings.go b/internal/domain/validate_settings.go new file mode 100644 index 0000000..5c56337 --- /dev/null +++ b/internal/domain/validate_settings.go @@ -0,0 +1,452 @@ +package domain + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// This file holds the controls that judge WHAT A COOPERATIVE SETTLED: the price +// grid, the weighing bounds, the stability policy, what the station keeps, who may +// write to it, where the products come from and where it looks for a newer version +// of itself. +// +// They are the other half of the list validate.go opens, and they carry the numbers +// §11.3 gave them: 10 to 16, 22 to 28, 30 to 36, 39 and 40, 43 to 46, 48 and 49. +// Validate calls both halves in numeric order, and never in the order either file +// writes them. +// +// Every one of them bears on a decision somebody TOOK, which is why their messages +// name a shop and not a machine -- « la grille de tarifs est vide », « une lettre +// hors F/L/V/A n'aurait plus où atterrir ». + +// repositoryShape is control 48: an owner and a repository, nothing else. No +// scheme, no host, no dots that climb, no third segment. +var repositoryShape = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,39}/[A-Za-z0-9_.-]{1,100}$`) + +// validatePricing is controls 10 to 16: the grid holds at least one tier, its codes +// are unique and bounded, and the three roles name tiers that exist. +func (c *Config) validatePricing() []Fault { + var faults faultList + + // 10. At least one tier. Dual pricing is not a boolean, it is the cardinality of + // the grid (§6.3). + if len(c.Pricing.Tiers) == 0 { + faults.add("pricing.tiers", "la grille de tarifs est vide : il en faut au moins un") + } + codes := make(map[string]bool, len(c.Pricing.Tiers)) + for i, tier := range c.Pricing.Tiers { + // 11. The tier reference_code names is the catalog price -- the one the + // till charges. Its discount is not a setting, it is zero by + // definition, so a file that gives it one is REFUSED rather than + // quietly obeyed (ADR-034). + if tier.Code == c.Pricing.ReferenceCode && tier.Discount != 0 { + faults.add(fmt.Sprintf("pricing.tiers[%d].discount_percent", i), + "le tarif de référence est le prix du catalogue : il ne porte pas de remise") + } + // 12. Codes unique: the code is the key of a tier, in the file, on the label + // and in the journal. + if codes[tier.Code] { + faults.add(fmt.Sprintf("pricing.tiers[%d].code", i), "le code %q est déclaré deux fois", tier.Code) + } + codes[tier.Code] = true + // 13. A discount is a percentage between 0 and 100. A hundred is free, and + // that is a grid a cooperative may legitimately declare. + if tier.Discount < 0 || tier.Discount > FullDiscount { + faults.add(fmt.Sprintf("pricing.tiers[%d].discount_percent", i), + "%s %% n'est pas une remise entre 0 et 100 %%", tier.Discount) + } + } + tierCodes := make([]string, 0, len(c.Pricing.Tiers)) + for _, tier := range c.Pricing.Tiers { + tierCodes = append(tierCodes, tier.Code) + } + + // 14. primary_code belongs to the grid: it is the price printed LARGE (A7). + if !codes[c.Pricing.PrimaryCode] { + faults.addChoice("pricing.primary_code", tierCodes, "%q ne désigne aucun tarif de la grille", c.Pricing.PrimaryCode) + } + // 15. reference_code belongs to the grid: it is the one encoded when the payload + // carries a price, and the till must never under-charge. + if !codes[c.Pricing.ReferenceCode] { + faults.addChoice("pricing.reference_code", tierCodes, "%q ne désigne aucun tarif de la grille", c.Pricing.ReferenceCode) + } + // 16. Each secondary code belongs to the grid. + for i, code := range c.Pricing.SecondaryCodes { + if !codes[code] { + faults.addChoice(fmt.Sprintf("pricing.secondary_codes[%d]", i), tierCodes, + "%q ne désigne aucun tarif de la grille", code) + } + } + return faults +} + +// validateLimits is controls 22 to 25: the weighing windows, each bounded by what +// a field of the barcode can carry rather than by plausibility. +func (c *Config) validateLimits() []Fault { + var faults faultList + + // 22. basket_min_g ≤ basket_max_g ≤ 0: the window means "the customer lifted off + // a basket the scale was tared for", so it is NEGATIVE by nature. + if c.Limits.BasketMin > c.Limits.BasketMax || c.Limits.BasketMax > 0 { + faults.add("limits.basket_min_g", + "la fenêtre du panier (%d ≤ %d ≤ 0) est incohérente : elle décrit un poids négatif", + c.Limits.BasketMin, c.Limits.BasketMax) + } + + // 23. min_weight_g < max_weight_g ≤ 99999. The ceiling is the CAPACITY of the + // NNDDD field of the barcode, not a plausibility threshold. + if c.Limits.MinWeight >= c.Limits.MaxWeight || c.Limits.MaxWeight > MaxWeight { + faults.add("limits.max_weight_g", + "les bornes de poids (%d < %d ≤ %d) sont incohérentes : %d g est la capacité du champ NNDDD du code-barres", + c.Limits.MinWeight, c.Limits.MaxWeight, MaxWeight, MaxWeight) + } + + // 24. min_units ≤ max_units ≤ 99: two digits in the payload of prefix 0499. + if c.Limits.MinUnits > c.Limits.MaxUnits || c.Limits.MaxUnits > 99 { + faults.add("limits.max_units", + "les bornes d'unités (%d ≤ %d ≤ 99) sont incohérentes : la charge utile du préfixe à l'unité fait deux chiffres", + c.Limits.MinUnits, c.Limits.MaxUnits) + } + + // 25. max_amount_cents ≤ 99999. + if c.Limits.MaxAmount > 99_999 { + faults.add("limits.max_amount_cents", "%d dépasse la capacité du champ de prix du code-barres (99 999 centimes)", + c.Limits.MaxAmount) + } + return faults +} + +// validateStability is controls 26 to 28: a stability window that can actually be +// held, an expiry that outlives one measurement, and two words of a closed list. +func (c *Config) validateStability() []Fault { + var faults faultList + + // 26. timeout_ms > min_duration_ms: a window that expires before it can hold + // would time out every single weighing. + if c.Stability.Timeout <= c.Stability.MinDuration { + faults.add("stability.timeout_ms", "%s doit dépasser la durée de stabilité exigée (%s)", + c.Stability.Timeout, c.Stability.MinDuration) + } + + // 27. expiry_floor_ms ≥ 1000 and < expiry_ceiling_ms. + if c.Stability.ExpiryFloor < Duration(time.Second) { + faults.add("stability.expiry_floor_ms", "%s est sous le plancher de 1 s : le poids serait déclaré périmé avant la mesure suivante", + c.Stability.ExpiryFloor) + } + if c.Stability.ExpiryFloor >= c.Stability.ExpiryCeiling { + faults.add("stability.expiry_ceiling_ms", "%s doit dépasser le plancher de péremption (%s)", + c.Stability.ExpiryCeiling, c.Stability.ExpiryFloor) + } + + // 28. stability.mode and on_timeout in the list (A3). + if !known(stabilityModes(), c.Stability.Mode) { + faults.addChoice("stability.mode", stabilityModes(), "mode inconnu %q", c.Stability.Mode) + } + if !known(timeoutActions(), c.Stability.OnTimeout) { + faults.addChoice("stability.on_timeout", timeoutActions(), "action inconnue %q", c.Stability.OnTimeout) + } + return faults +} + +// validateJournal is control 30: journal.max_rows ≥ 100. +// +// Below that a purge would erase the day's weighings, which are the only data of a +// station that cannot be rebuilt. +func (c *Config) validateJournal() []Fault { + var faults faultList + if c.Journal.MaxRows < 100 { + faults.add("journal.max_rows", "%d est sous le plancher de 100 pesées conservées", c.Journal.MaxRows) + } + return faults +} + +// validateAdminSecrets is control 31: admin.password_hash and +// admin.recovery_code_hash are USABLE when present. +// +// # Empty is not a fault, and that is a correction +// +// A station is installed WITHOUT a password: §14.4 says the delivered configuration +// is the export of §11.5, "qui ne porte aucun secret", and the first access is the +// recovery code printed on the installation sheet. Refusing an empty field put such +// a station OUT OF SERVICE (§11.3), so it could not weigh either — and weighing is +// the one thing it must do whatever else is wrong. What answers "aucun mot de passe +// n'est posé" is now the administration itself, which offers the recovery code. +// +// # What IS a fault: a hash nothing can match +// +// The delivered file carried « for-the-delivered-configurationg ». It parses, and its +// payload is EXACTLY the 32 bytes argon2id produces — so a length check would not +// have caught it either. It matches no password at all, `config validate` and +// `doctor` both declared it sound, and install.ps1, seeing a non-empty recovery +// field, skipped drawing a real code: the installation sheet went out blank and the +// station was locked out for good. +func (c *Config) validateAdminSecrets() []Fault { + var faults faultList + for _, secret := range []struct{ field, hash string }{ + {"admin.password_hash", c.Admin.PasswordHash}, + {"admin.recovery_code_hash", c.Admin.RecoveryCodeHash}, + } { + switch { + case secret.hash == "": + // Documented state of a station between its installation and its first access. + case !wellFormedArgon2id(secret.hash): + faults.add(secret.field, "l'empreinte n'est pas une chaîne argon2id de la forme $argon2id$v=19$m=…,t=…,p=…$sel$empreinte") + case !usableArgon2id(secret.hash): + faults.add(secret.field, "l'empreinte est un remplissage : son corps est du texte, là où argon2id produit des octets tirés au sort — aucun mot de passe ne peut y correspondre") + } + } + return faults +} + +// validateCatalogShelving is controls 32 to 36: where a product lands, under which +// unique and legibly coloured category, and how often the source is looked at. +func (c *Config) validateCatalogShelving() []Fault { + var faults faultList + + // 32. catalog.fallback_category belongs to the categories. It is what makes "the + // grid is empty because of an unexpected letter" impossible (§10.2 bis). + categoryCodes := make([]string, 0, len(c.Catalog.Categories)) + present := make(map[string]bool, len(c.Catalog.Categories)) + for _, category := range c.Catalog.Categories { + categoryCodes = append(categoryCodes, category.Code) + present[category.Code] = true + } + if !present[c.Catalog.FallbackCategory] { + faults.addChoice("catalog.fallback_category", categoryCodes, + "%q ne désigne aucune catégorie : une lettre hors F/L/V/A n'aurait plus où atterrir", + c.Catalog.FallbackCategory) + } + + // 33. Category codes unique. + seen := make(map[string]bool, len(c.Catalog.Categories)) + for i, category := range c.Catalog.Categories { + if seen[category.Code] { + faults.add(fmt.Sprintf("catalog.categories[%d].code", i), "le code %q est déclaré deux fois", category.Code) + } + seen[category.Code] = true + } + + // 34. min_readable_ratio ∈ [0,1] -- the ABSOLUTE guard, on UNREADABLE rows + // (§10.4a). + if ratio, ok := c.Catalog.Options.Ratio("min_readable_ratio"); ok && (ratio < 0 || ratio > 1) { + faults.add("catalog.options.min_readable_ratio", "%v hors bornes [0, 1] : c'est une proportion de lignes lisibles", ratio) + } + + // 35. Colours as #RRGGBB. + for i, category := range c.Catalog.Categories { + if !wellFormedColor(category.Color) { + faults.add(fmt.Sprintf("catalog.categories[%d].color", i), "%q n'est pas une couleur #RRGGBB", category.Color) + } + } + + // 36. poll_interval_s ≥ 1. The stability check needs two consecutive polls, so a + // zero interval would read a file while the producer is still writing it. + if interval, ok := c.Catalog.Options.Int("poll_interval_s"); ok && interval < 1 { + faults.add("catalog.options.poll_interval_s", "%d est sous le plancher d'une seconde", interval) + } + return faults +} + +// validateCatalogGuards is controls 39 and 40: what a drop path may point at, and +// how far a batch is allowed to shrink the shop. +func (c *Config) validateCatalogGuards(reg Registries) []Fault { + var faults faultList + + // 39. No HTTP(S) host behind a DROP DIRECTORY (important-11). A source that declares + // one watches a directory it can list and delete from; one that demands an account + // and a password is a different source, with a different acknowledgement — and a + // "local" directory reached that way would be the Z: drive of the legacy + // application under another name. + // + // The rule reads the SCHEMA and names no source. It used to be an `if` on + // `local_drop`, which was true only because `local_drop` was the only source that + // watched a directory; it now holds for the next one without this file being + // edited (ADR-052). + // + // Its second half — « local_drop carries neither user nor password » — is GONE + // and not lost: control 9 already refuses a key the chosen source does not + // declare, and it now names the source that does. Two controls for one fact is how + // a third source ends up refused by the one nobody remembered to extend. + for _, schema := range optionsUsedAs(reg.CatalogSources, c.Catalog.Type, UseDropDirectory) { + if value, ok := c.Catalog.Options.Text(schema.Key); ok && isHTTPURL(value) { + faults.addChoice("catalog.options."+schema.Key, sourcesFetchingByURL(reg.CatalogSources), + "%q est un hôte HTTP(S) derrière un chemin de dépôt : c'est une source qui va chercher le fichier sur un partage qu'il faut choisir", + value) + } + } + + // 40. max_weighable_drop ∈ [0, 0.5] -- the RELATIVE guard, on WEIGHABLE products + // (§10.4b, important-13). + if drop, ok := c.Catalog.Options.Ratio("max_weighable_drop"); ok && (drop < 0 || drop > 0.5) { + faults.add("catalog.options.max_weighable_drop", "%v hors bornes [0, 0,5] : c'est une baisse relative du nombre de produits pesables", drop) + } + return faults +} + +// CheckPrice reports the fault a price carried by a delivered configuration file +// breaks, or nothing. +// +// It is control 43: every price carried by a DELIVERED configuration file verifies +// 0 ≤ price ≤ 999 999 cents -- the third and last imposition of MaxUnitPrice, with +// the DDL (§12.3) and the price rule of §10.3. Since §11.5 it is an ORDINARY +// configuration control, applied to a file like any other; it used to validate +// compiled values, that is, source code (ADR-026). +// +// It is the SINGLE implementation of that control, called by Config.Validate and by +// whoever loads the demonstration products and flv_demo.csv: three files, one rule, +// so that MaxUnitPrice cannot be enforced differently in three places. +func CheckPrice(field string, price Cents) []Fault { + if price < 0 || price > MaxUnitPrice { + return []Fault{{ + Field: field, + Message: fmt.Sprintf("%d hors bornes [0, %d] centimes", price, MaxUnitPrice), + }} + } + return nil +} + +// validateCatalogImages is controls 44 and 45: where the pictures come from, and +// how big one of them is allowed to be. +func (c *Config) validateCatalogImages(reg Registries) []Fault { + var faults faultList + + // 44. catalog.images.source in the list, and path readable FROM THE CONTEXT OF THE + // SERVICE when the source is image_directory. + if !known(imageSources(), c.Catalog.Images.Source) { + faults.addChoice("catalog.images.source", imageSources(), "source d'images inconnue %q", c.Catalog.Images.Source) + } + if c.Catalog.Images.Source == ImageSourceDirectory { + switch { + case c.Catalog.Images.Path == "": + // Empty is legitimate: it means /product_images/, a directory the + // service owns. + case reg.Paths == nil: + // No probe: we validate the form, we cannot validate the existence. + default: + if err := reg.Paths.Readable(c.Catalog.Images.Path); err != nil { + faults.add("catalog.images.path", "%q n'est pas lisible depuis le contexte du service (%s)", + c.Catalog.Images.Path, err) + } + } + } + + // 45. max_image_size_kb ∈ [16, 4096] AND max_image_size_kb × 1024 ≤ + // max_file_size_mb × 1 048 576: an image cannot be allowed to exceed the file + // that contains it (§10.7). The largest image really observed is 11 kB, the + // real file 527 kB. + imageKB, hasImageKB := c.Catalog.Options.Int("max_image_size_kb") + if hasImageKB && (imageKB < 16 || imageKB > 4096) { + faults.add("catalog.options.max_image_size_kb", "%d ko hors bornes [16, 4096]", imageKB) + } + if fileMB, ok := c.Catalog.Options.Int("max_file_size_mb"); ok && hasImageKB { + if imageKB*1024 > fileMB*1_048_576 { + faults.add("catalog.options.max_image_size_kb", + "%d ko dépasse le plafond du fichier qui la contient (%d Mo) : une image ne peut pas être plus grosse que son catalogue", + imageKB, fileMB) + } + } + return faults +} + +// validateDropDirectory is control 46: a NAMED drop directory must be one the +// SERVICE can really work in (§10.1). +// +// Empty is the shipped case -- /catalog/incoming, which the service owns and +// creates -- so there is nothing to probe. A nil probe means "we cannot know": +// `openscale config validate` on a laptop validates the form and not the existence, +// exactly like control 44 on catalog.images.path. +// +// Like 39 it reads the schema: WHICH key names a directory is the source's +// declaration, and the validation has no business holding a second copy of it. +func (c *Config) validateDropDirectory(reg Registries) []Fault { + var faults faultList + if reg.Paths == nil { + return faults + } + for _, schema := range optionsUsedAs(reg.CatalogSources, c.Catalog.Type, UseDropDirectory) { + directory, ok := c.Catalog.Options.Text(schema.Key) + if !ok { + continue + } + if named := strings.TrimSpace(directory); named != "" { + if err := reg.Paths.Droppable(named); err != nil { + faults.add("catalog.options."+schema.Key, "%s", err) + } + } + } + return faults +} + +// 47. REMOVED, and its number left as a hole the way 37's was (ADR-044): §11.3 names +// +// its controls by number, so renumbering what follows would falsify every reference +// written elsewhere. +// +// It said « a drop directory means nothing to a WebDAV share ». That was true, and it +// was already what control 9 refuses — a key the chosen source does not declare — for +// every source, present and to come. The only thing 47 added was its sentence, and that +// sentence moved into control 9, which now NAMES the source that does declare the key. + +// validateUpdate is control 48: update.repository is an owner/repo PAIR, never a URL. +// +// This is the only field of the file that says where privileged code will come from: +// the station downloads that repository's release and runs it as LocalSystem. +// Accepting a whole address here would make writing the configuration equivalent to +// running arbitrary code on the four stations. The host is compiled in; see +// UpdateConfig. +func (c *Config) validateUpdate() []Fault { + var faults faultList + if !repositoryShape.MatchString(c.Update.Repository) { + faults.add("update.repository", + "%q n'est pas un dépôt de la forme propriétaire/projet : ce champ ne prend pas d'adresse web", + c.Update.Repository) + } + return faults +} + +// validateGrid is control 49: ui.grid_columns is GridColumnsAutomatic, or a count +// between MinGridColumns and MaxGridColumns. +// +// The fault carries BOTH the range and the meaning of zero, because the two are of +// different natures and only one of them is a number of columns. Somebody who writes +// 1 is asking for a denser grid; if the refusal only named the interval, they would +// read « 1 est hors de [3, 12] » and never learn that the grid they had back is +// written 0 -- which looks, on a file, exactly like « aucune colonne ». +func (c *Config) validateGrid() []Fault { + var faults faultList + if c.UI.GridColumns != GridColumnsAutomatic && + (c.UI.GridColumns < MinGridColumns || c.UI.GridColumns > MaxGridColumns) { + faults.addChoice("ui.grid_columns", gridColumnChoices(), + "%d n'est pas un nombre de colonnes que la grille sait montrer", c.UI.GridColumns) + } + return faults +} + +// stabilityModes reports the two admissible values of stability.mode. +func stabilityModes() []string { return []string{ModeAdvisory, ModeBlocking} } + +// timeoutActions reports the three admissible values of stability.on_timeout. +func timeoutActions() []string { + return []string{OnTimeoutWarnAndPrint, OnTimeoutReject, OnTimeoutManualEntry} +} + +// imageSources reports the three admissible values of catalog.images.source. +func imageSources() []string { + return []string{ImageSourceCSV, ImageSourceDirectory, ImageSourceNone} +} + +// gridColumnChoices reports what control 49 accepts, in the two natures the value +// has. +// +// It says a range in words where the other lists of this file enumerate values, and +// that is the point rather than an oversight: « Automatique » is not one more notch at +// the end of a slider, it is a different kind of answer, and a bare list of eleven +// numbers would spell zero like the other ten. +func gridColumnChoices() []string { + return []string{ + fmt.Sprintf("%d — automatique : la grille suit l'écran, comme aujourd'hui", GridColumnsAutomatic), + fmt.Sprintf("%d à %d — ce nombre de colonnes sur tous les écrans", MinGridColumns, MaxGridColumns), + } +} diff --git a/internal/domain/validate_shapes.go b/internal/domain/validate_shapes.go new file mode 100644 index 0000000..431b5f7 --- /dev/null +++ b/internal/domain/validate_shapes.go @@ -0,0 +1,134 @@ +package domain + +import ( + "encoding/base64" + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// This file holds the SHAPE predicates the controls lean on: is this a host:port, +// an absolute web address, a #RRGGBB colour, an argon2id fingerprint? +// +// They answer about a value alone, knowing nothing of the block that carries it, +// which is why several controls and one option schema can share the same one -- +// network.listen and an OptionHostPort are judged by the very same function, and a +// station must never refuse an address its own administration screen accepts. + +// CheckListenAddress reports why an address cannot be listened on, and nil when it can. +// +// It is exported so that whoever accepts a listening address from OUTSIDE the file — +// `serve --listen`, and nothing else so far — judges it by the very rule control 2 +// judges network.listen by. A second implementation in the command layer would drift, +// and the station would end up refusing an address its own administration screen +// accepts, or the other way round. +func CheckListenAddress(address string) error { return checkHostPort(address) } + +// checkHostPort reports why an address is not a usable host:port. +func checkHostPort(address string) error { + if address == "" { + return fmt.Errorf("adresse vide") + } + host, port, err := net.SplitHostPort(address) + if err != nil { + return err + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return fmt.Errorf("port %q hors bornes [1, 65535]", port) + } + // An empty host is legitimate: ":8085" listens on every interface, which is what + // admin_on_lan describes. + _ = host + return nil +} + +// isHTTPURL reports whether a value is an absolute http or https URL. +func isHTTPURL(value string) bool { + parsed, err := url.Parse(value) + if err != nil { + return false + } + return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +// wellFormedColor reports whether a colour is spelled #RRGGBB. +func wellFormedColor(color string) bool { + if len(color) != 7 || color[0] != '#' { + return false + } + for i := 1; i < len(color); i++ { + c := color[i] + hex := c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' + if !hex { + return false + } + } + return true +} + +// wellFormedArgon2id reports whether a hash is an argon2id PHC string. +// +// The shape is checked, never the cost: raising m, t or p is a legitimate +// hardening, and a validation that froze them would refuse a configuration that is +// SAFER than the one it was written against. +func wellFormedArgon2id(hash string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return false + } + if !strings.HasPrefix(parts[2], "v=") { + return false + } + for _, parameter := range []string{"m=", "t=", "p="} { + if !strings.Contains(parts[3], parameter) { + return false + } + } + return isBase64Raw(parts[4], 8) && isBase64Raw(parts[5], 16) +} + +// usableArgon2id reports whether a hash could have come out of argon2id at all. +// +// Being well formed is not enough, and the delivered configuration is the proof: its +// payload decoded to « for-the-delivered-configurationg », thirty-two bytes of typed +// text where argon2id writes thirty-two bytes drawn at random. What gives a placeholder +// away is therefore not its length but its ALPHABET — thirty-two random bytes are all +// printable ASCII once in 10^14, which is never. +// +// It is not this check that repairs the defect: emptying the field does. This is what +// stops the same gesture from coming back without a sound. +func usableArgon2id(hash string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 { + return false + } + key, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(key) == 0 { + return false + } + for _, b := range key { + if b < 0x20 || b > 0x7e { + return true + } + } + return false +} + +// isBase64Raw reports whether s is unpadded base64 of at least minimum characters. +func isBase64Raw(s string, minimum int) bool { + if len(s) < minimum { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '+' || c == '/' || c == '-' || c == '_' + if !ok { + return false + } + } + return true +} diff --git a/internal/domain/validate_shapes_test.go b/internal/domain/validate_shapes_test.go new file mode 100644 index 0000000..ace5277 --- /dev/null +++ b/internal/domain/validate_shapes_test.go @@ -0,0 +1,73 @@ +// This file holds the SHAPE predicates: an argon2id fingerprint, a host:port, a +// #RRGGBB colour, an absolute web address, unpadded base64. +// +// They answer about a value alone, so they are tested about a value alone. + +package domain + +import "testing" + +func TestArgon2idShapeIsCheckedAndTheCostIsNot(t *testing.T) { + // Raising the cost is a legitimate hardening: a validation that froze m, t and p + // would refuse a configuration SAFER than the one it was written against. + hardened := "$argon2id$v=19$m=262144,t=6,p=4$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" + if !wellFormedArgon2id(hardened) { + t.Error("un coût plus élevé doit rester accepté") + } + for _, malformed := range []string{ + "", "admin", "$argon2i$v=19$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", + "$argon2id$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", + "$argon2id$19$m=65536,t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", + "$argon2id$v=19$t=3,p=2$c2VsMTIzNDU2Nzg$ZW1wcmVpbnRlMTIzNDU2Nzg5MA", + "$argon2id$v=19$m=65536,t=3,p=2$sel$empreinte", + } { + if wellFormedArgon2id(malformed) { + t.Errorf("%q ne doit pas passer pour une empreinte argon2id", malformed) + } + } +} + +func TestHostPortAndColourShapes(t *testing.T) { + for _, valid := range []string{"127.0.0.1:8085", ":8085", "[::1]:8085", "poste2.local:8085"} { + if err := checkHostPort(valid); err != nil { + t.Errorf("%q doit être une adresse valide : %v", valid, err) + } + } + for _, invalid := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:99999", "127.0.0.1:http"} { + if err := checkHostPort(invalid); err == nil { + t.Errorf("%q ne doit pas être une adresse valide", invalid) + } + } + for _, valid := range []string{"#C0392B", "#27ae60", "#000000"} { + if !wellFormedColor(valid) { + t.Errorf("%q doit être une couleur valide", valid) + } + } + for _, invalid := range []string{"", "rouge", "#C0392", "#C0392BB", "C0392B", "#GGGGGG"} { + if wellFormedColor(invalid) { + t.Errorf("%q ne doit pas être une couleur valide", invalid) + } + } +} + +func TestIsHTTPURLRefusesAMalformedURL(t *testing.T) { + for _, invalid := range []string{"", "://", "http://[::1", "file:///etc/passwd", "dav.example.org"} { + if isHTTPURL(invalid) { + t.Errorf("%q ne doit pas passer pour une URL http(s)", invalid) + } + } + for _, valid := range []string{"http://poste2.local/", "https://dav.example.org:8001/"} { + if !isHTTPURL(valid) { + t.Errorf("%q doit passer pour une URL http(s)", valid) + } + } +} + +func TestBase64ShapeRefusesAnImpossibleCharacter(t *testing.T) { + if isBase64Raw("sel!!!!!!!!!!", 8) { + t.Error("un point d'exclamation n'est pas du base64") + } + if !isBase64Raw("b3BlbnNjYWxlLXNhbHQxMg", 8) { + t.Error("un sel base64 non paddé doit passer") + } +} diff --git a/internal/domain/validate_test.go b/internal/domain/validate_test.go new file mode 100644 index 0000000..63eec0b --- /dev/null +++ b/internal/domain/validate_test.go @@ -0,0 +1,468 @@ +// This file holds what the 48 controls answer BEYOND the corpus: everything at +// once, the admissible values a fault carries, what an EMPTY registry may and may +// not say, and the controls whose behaviour needs a station of its own to show -- +// 3, 29, 43, 44, 46, 48 and 49. + +package domain + +import ( + "strconv" + "strings" + "testing" +) + +// TestADirectoryOnWebDAVNamesTheSourceThatWatchesOne was control 47, and it is now +// control 9 doing the same work for every driver family at once. +// +// A key that means nothing for the source declared is a mistake and not a value to ignore +// in silence — that much has not changed. What has changed is who says so: control 47 +// spelled `directory`, `webdav` and `local_drop` by hand inside this package, so no third +// source could be added without editing it. Control 9 reads the SCHEMAS, refuses the key, +// and names whichever driver declares it (ADR-052). +// +// The registries therefore have to be the REAL ones here, where control 47 needed them +// empty to be heard alone: it is the registry that carries the answer now. +func TestADirectoryOnWebDAVNamesTheSourceThatWatchesOne(t *testing.T) { + config := loadDelivered(t) + setOption(t, config.Catalog.Options, "directory", `D:\catalogue`) + + fault := findFault(config.Validate(testRegistries()), "catalog.options.directory") + if fault == nil { + t.Fatal("un répertoire de dépôt déclaré sur webdav doit être refusé") + } + if !strings.Contains(fault.Message, CatalogSourceLocalDrop) { + t.Errorf("le refus ne nomme pas la source qui surveille un répertoire : %s", fault.Message) + } +} + +// TestWithoutAProbeTheFormIsCheckedAndExistenceIsNot: `openscale config validate` on +// a laptop cannot know what the service account sees, and must not invent a refusal. +func TestWithoutAProbeTheFormIsCheckedAndExistenceIsNot(t *testing.T) { + config := loadDelivered(t) + config.Catalog.Type = CatalogSourceLocalDrop + setOption(t, config.Catalog.Options, "directory", `Z:\catalogue`) + + // testRegistries carries no PathChecker, which is the state of a validation run + // outside the service. + if fault := findFault(config.Validate(testRegistries()), "catalog.options.directory"); fault != nil { + t.Fatalf("sans sonde, l'existence n'est pas vérifiée : %s", fault.Message) + } +} + +// TestAnEmptyDirectoryIsNeverProbed: the shipped case names no directory at all, and +// a field somebody opened and left with a space in it names none either. +func TestAnEmptyDirectoryIsNeverProbed(t *testing.T) { + for _, written := range []string{"", " "} { + t.Run(strconv.Quote(written), func(t *testing.T) { + config := loadDelivered(t) + config.Catalog.Type = CatalogSourceLocalDrop + setOption(t, config.Catalog.Options, "directory", written) + registries := testRegistries() + registries.Paths = unreadablePaths{} + + if fault := findFault(config.Validate(registries), "catalog.options.directory"); fault != nil { + t.Fatalf("un répertoire vide est celui du poste, il n'y a rien à sonder : %s", fault.Message) + } + }) + } +} + +func TestControls17To19OnTheCompiledPlan(t *testing.T) { + if faults := validateNumberingPlan(internalPlan); len(faults) != 0 { + t.Fatalf("le plan livré doit être cohérent, obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) + } + + cases := map[string]map[string]PrefixPlan{ + "préfixe de trois chiffres": { + "049": {"049", ByWeight, 3, 5, 3, " €/kg"}, + }, + "4 + ref + charge + 1 ne fait pas 13": { + "0493": {"0493", ByWeight, 4, 5, 3, " €/kg"}, + }, + "préfixe déclaré sous une autre clé": { + "0493": {"0499", ByWeight, 3, 5, 3, " €/kg"}, + }, + } + for name, plan := range cases { + t.Run(name, func(t *testing.T) { + faults := validateNumberingPlan(plan) + if findFault(faults, "barcode.plan") == nil { + t.Fatalf("aucune faute sur barcode.plan ; obtenu :\n%s", strings.Join(fieldsOf(faults), "\n")) + } + }) + } +} + +// TestValidateReportsEveryFaultAtOnce is the property the administration screen +// depends on: a volunteer must not have to fix one line, save, and discover the +// next one. +func TestValidateReportsEveryFaultAtOnce(t *testing.T) { + config := loadDelivered(t) + config.Station.Number = 0 // 1 + config.Network.Listen = "pas une adresse" // 2 + config.Pricing.Tiers[1].Discount = 200 // 11 + config.Pricing.PrimaryCode = "GHOST" // 14 + config.Limits.MaxUnits = 500 // 24 + config.Stability.Mode = "bloquant" // 28 + config.Journal.MaxRows = 1 // 30 + // 31 : un remplissage tapé à la main, qui passe la vérification de forme. + config.Admin.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=2$b3BlbnNjYWxlLXNhbHQxMg$Zm9yLXRoZS1kZWxpdmVyZWQtY29uZmlndXJhdGlvbmc" + config.Catalog.FallbackCategory = "divers" // 32 + config.Catalog.Categories[0].Color = "vert" // 35 + setOption(t, config.Printer.Options, "copies", 99) // 7, sur les bornes du driver + setOption(t, config.Printer.Options, "offset_y", 9) + + faults := config.Validate(testRegistries()) + wanted := []string{ + "station.number", "network.listen", "pricing.tiers[1].discount_percent", + "pricing.primary_code", "limits.max_units", "stability.mode", + "journal.max_rows", "admin.password_hash", "catalog.fallback_category", + "catalog.categories[0].color", "printer.options.copies", "printer.options.offset_y", + } + for _, field := range wanted { + if findFault(faults, field) == nil { + t.Errorf("faute manquante sur %q", field) + } + } + if len(faults) < len(wanted) { + t.Fatalf("%d fautes remontées pour %d erreurs semées :\n%s", + len(faults), len(wanted), strings.Join(fieldsOf(faults), "\n")) + } +} + +// TestFaultsCarryTheAdmissibleValues checks the second half of the contract: when a +// value is wrong and the list of right ones is known, the screen shows the list. +func TestFaultsCarryTheAdmissibleValues(t *testing.T) { + config := loadDelivered(t) + config.Scale.Type = "gram-xfoc-turbo" + config.Stability.OnTimeout = "refuser" + config.Catalog.Images.Source = "jpeg" + + faults := config.Validate(testRegistries()) + for field, wanted := range map[string][]string{ + "scale.type": {"gram-xfoc-plus", "gram-xfoc-rs"}, + "stability.on_timeout": {OnTimeoutWarnAndPrint, OnTimeoutReject, OnTimeoutManualEntry}, + "catalog.images.source": {ImageSourceCSV, ImageSourceDirectory, ImageSourceNone}, + } { + fault := findFault(faults, field) + if fault == nil { + t.Errorf("aucune faute sur %q", field) + continue + } + if len(fault.Values) != len(wanted) { + t.Errorf("%s : valeurs admissibles = %v, attendu %v", field, fault.Values, wanted) + continue + } + for _, value := range wanted { + if !known(fault.Values, value) { + t.Errorf("%s : %q absent des valeurs admissibles %v", field, value, fault.Values) + } + } + } +} + +// TestEmptyRegistriesValidateTheFormNotTheExistence is the behaviour L3 and L5 need +// before a single driver exists. +func TestEmptyRegistriesValidateTheFormNotTheExistence(t *testing.T) { + config := loadDelivered(t) + // A protocol no registry declares: with no registry, nobody can say it is wrong. + config.Scale.Type = "gram-xfoc-turbo" + config.Printer.Type = "gdi" + config.Catalog.Type = "sftp" + + if faults := config.Validate(Registries{}); len(faults) != 0 { + t.Fatalf("un registre vide ne valide que la forme, obtenu :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } + + // The FORM is still validated, and so are the values that were RETIRED with a + // written reason: those do not depend on any registry. + config.Scale.Type = SourceManual + if findFault(config.Validate(Registries{}), "scale.type") == nil { + t.Error("« manual » doit être refusé même sans registre : c'est un état, pas un protocole") + } + config.Scale.Type = "" + config.Printer.Type = "" + faults := config.Validate(Registries{}) + if findFault(faults, "scale.type") == nil { + t.Error("un poste qui déclare une balance doit nommer son protocole") + } + if findFault(faults, "printer.type") == nil { + t.Error("un driver d'impression vide est une faute de forme") + } +} + +func TestCheckPriceIsTheThirdImpositionOfMaxUnitPrice(t *testing.T) { + for _, price := range []Cents{0, 1, MaxUnitPrice} { + if faults := CheckPrice("demo.price", price); len(faults) != 0 { + t.Errorf("%d centimes doit passer, obtenu %v", price, fieldsOf(faults)) + } + } + for _, price := range []Cents{-1, MaxUnitPrice + 1} { + faults := CheckPrice("demo.price", price) + if len(faults) != 1 || faults[0].Field != "demo.price" { + t.Errorf("%d centimes doit être refusé, obtenu %v", price, fieldsOf(faults)) + } + } +} + +// TestAScaleReachedByAnAddressNeedsNoPortKey. +// +// Control 3 demanded the literal key `scale.options.port` of every station declaring +// a scale, WHATEVER its protocol. A driver reached by an address — TCP, USB — was +// therefore refused before it was ever asked, on a key its own schema does not carry. +// What is required is what the chosen driver declares required, and nothing else. +func TestAScaleReachedByAnAddressNeedsNoPortKey(t *testing.T) { + const overIP = "gram-over-ip" + + config := loadDelivered(t) + config.Scale.Type = overIP + config.Scale.Options = DriverOptions{} + setOption(t, config.Scale.Options, "address", "192.168.1.50:4001") + + registries := testRegistries() + registries.Scales = append(registries.Scales, DriverDescriptor{ + ID: overIP, Label: "GRAM sur IP", + Options: []OptionSchema{{Key: "address", Kind: OptionHostPort, Required: true}}, + }) + + if faults := config.Validate(registries); len(faults) != 0 { + t.Fatalf("un driver atteint par adresse est refusé :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +// TestAScaleDriverStillGetsTheOptionsItDeclaresRequired: the same seam in the +// direction that protects the parc. The GRAM declares `port` required in its own +// schema, so a station that does not name one is still refused. +func TestAScaleDriverStillGetsTheOptionsItDeclaresRequired(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(*testing.T, *Config) + }{ + {"la clé manque", func(_ *testing.T, c *Config) { delete(c.Scale.Options, "port") }}, + {"la clé est vide", func(t *testing.T, c *Config) { setOption(t, c.Scale.Options, "port", "") }}, + } { + t.Run(testCase.name, func(t *testing.T) { + config := loadDelivered(t) + testCase.mutate(t, &config) + + faults := config.Validate(testRegistries()) + var named []Fault + for _, fault := range faults { + if fault.Field == "scale.options.port" { + named = append(named, fault) + } + } + if len(named) == 0 { + t.Fatalf("un poste GRAM sans port est accepté ; obtenu :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } + // ONE line and not two. A volunteer in front of the screen does not count + // broken rules, they count FIELDS TO FILL IN, and `scale.options.port` + // counted double — once for control 3, once for the schema (SUIVI, + // 29/07/2026). + if len(named) != 1 { + t.Errorf("%d fautes sur un seul champ à remplir :\n%s", + len(named), strings.Join(fieldsOf(named), "\n")) + } + // And the remaining line says WHO is asking, which is what tells a + // volunteer that changing the protocol is the other way out. + if !strings.Contains(named[0].Message, config.Scale.Type) { + t.Errorf("le message ne nomme pas le driver qui exige la clé : %q", named[0].Message) + } + }) + } +} + +// TestControl29ValidatesTheTemplateOnTheHeadTheDriverDeclares. +// +// The two figures rules 3 and 4 bear on were constants of the core, counted at +// 8 dots/mm. Any station whose printer is not the WS408 of the parc therefore failed +// its own validation AT START-UP — §11.3 puts it out of service — on a template nobody +// could make it accept: at 12 dots/mm the very same label is 420 dots wide. +func TestControl29ValidatesTheTemplateOnTheHeadTheDriverDeclares(t *testing.T) { + config := loadDelivered(t) + finer := twelveDotTemplate() + finer.Name = config.Printer.Template + + registries := testRegistries() + registries.Templates = map[string]Template{finer.Name: finer} + + // On the WS408 the parc runs, the pairing is refused, in French, naming the two + // figures — a volunteer has to know which of the two to change. + fault := findFault(config.Validate(registries), "printer.template.media.dots_per_mm") + if fault == nil { + t.Fatalf("un gabarit mesuré pour une autre tête est accepté ; obtenu :\n%s", + strings.Join(fieldsOf(config.Validate(registries)), "\n")) + } + for _, figure := range []string{"12 dots/mm", "8 dots/mm"} { + if !strings.Contains(fault.Message, figure) { + t.Errorf("le message ne nomme pas %s : %q", figure, fault.Message) + } + } + + // Declare the head that goes with it and the same station validates. + for i := range registries.Printers { + if registries.Printers[i].ID == config.Printer.Type { + registries.Printers[i].Capabilities = ws412Head() + } + } + if faults := config.Validate(registries); len(faults) != 0 { + t.Fatalf("un poste à 12 dots/mm avec un gabarit mesuré pour 12 dots/mm est refusé :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +// TestTheDeliveredStationIsValidatedOnTheWS408: the recette criterion of E0 — the +// shipped template and the head of the parc produce EXACTLY the figures they produced +// before, whether the head is declared or left unsaid. +func TestTheDeliveredStationIsValidatedOnTheWS408(t *testing.T) { + config := loadDelivered(t) + + declared := testRegistries() + silent := testRegistries() + for i := range silent.Printers { + silent.Printers[i].Capabilities = PrinterCapabilities{} + } + + if faults := config.Validate(declared); len(faults) != 0 { + t.Fatalf("le poste livré est refusé par la tête qu'il déclare :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } + if faults := config.Validate(silent); len(faults) != 0 { + t.Fatalf("le poste livré est refusé quand aucune tête ne se déclare :\n%s", + strings.Join(fieldsOf(faults), "\n")) + } +} + +func TestNoCatalogSourceDeclaredIsAFault(t *testing.T) { + config := loadDelivered(t) + config.Catalog.Type = "" + if findFault(config.Validate(testRegistries()), "catalog.type") == nil { + t.Fatal("une source de catalogue vide est une faute de forme") + } +} + +func TestControl44AcceptsWhatItCannotProbe(t *testing.T) { + config := loadDelivered(t) + config.Catalog.Images.Source = ImageSourceDirectory + + // An empty path is legitimate: it means /product_images/, a directory the + // service owns. + if fault := findFault(config.Validate(testRegistries()), "catalog.images.path"); fault != nil { + t.Errorf("un chemin vide est légitime : %s", fault) + } + // A path and no probe: `openscale config validate` on a laptop cannot know what + // the service account sees. + config.Catalog.Images.Path = `D:\photos` + if fault := findFault(config.Validate(testRegistries()), "catalog.images.path"); fault != nil { + t.Errorf("sans sonde, l'existence n'est pas validée : %s", fault) + } +} + +// TestControl48RefusesAnythingThatIsNotAnOwnerRepoPair is the control that keeps +// « save the configuration » from becoming « run code from anywhere ». +// +// The host lives in the binary. A field that took a whole URL would hand the +// station's LocalSystem process to whoever can write the configuration file -- +// and writing that file is what the administration screen exists to do. +func TestControl48RefusesAnythingThatIsNotAnOwnerRepoPair(t *testing.T) { + for _, wrong := range []string{ + "https://github.com/lostmind84/OpenScale", + "git@github.com:lostmind84/OpenScale.git", + "lostmind84/OpenScale/extra", + "../../etc/passwd", + "lostmind84", + "lostmind84/", + "/OpenScale", + "lost mind/OpenScale", + "lostmind84/Open;Scale", + "lostmind84/Open Scale", + } { + config := loadDelivered(t) + config.Update.Repository = wrong + if findFault(config.Validate(testRegistries()), "update.repository") == nil { + t.Errorf("%q est accepté par le contrôle 48", wrong) + } + } +} + +// TestControl48AcceptsAForkOfTheProject: the code is AGPL, and a cooperative +// following its own fork is the case this field exists for. +func TestControl48AcceptsAForkOfTheProject(t *testing.T) { + for _, right := range []string{ + "lostmind84/OpenScale", + "la-cagette/openscale", + "coop_2/Open.Scale-2", + } { + config := loadDelivered(t) + config.Update.Repository = right + if fault := findFault(config.Validate(testRegistries()), "update.repository"); fault != nil { + t.Errorf("%q est refusé par le contrôle 48 : %s", right, fault.Message) + } + } +} + +// TestControl49RefusesAColumnCountOutsideTheRange guards the value NOBODY MEANT TO +// WRITE, and nothing more. +// +// The bounds are guard rails and not a calculation: the same N is comfortable on a +// 4K and absurd on a 15", so no pair of bounds can be right for the whole fleet. +// What protects the operator inside them is the administration screen, which shows +// the result before the file is saved -- and the fact that getting it wrong is +// repaired by coming back. +func TestControl49RefusesAColumnCountOutsideTheRange(t *testing.T) { + for _, refused := range []int{-1, 1, MinGridColumns - 1, MaxGridColumns + 1, 100} { + t.Run(strconv.Itoa(refused), func(t *testing.T) { + config := loadDelivered(t) + config.UI.GridColumns = refused + faults := config.Validate(testRegistries()) + if findFault(faults, "ui.grid_columns") == nil { + t.Fatalf("%d est accepté par le contrôle 49 ; obtenu :\n%s", + refused, strings.Join(fieldsOf(faults), "\n")) + } + }) + } +} + +// TestControl49AcceptsAutomaticAndEveryColumnCountOfTheRange: zero is the delivered +// behaviour and 3 to 12 are the whole offer of the administration screen. A control +// that refused one of them would refuse a value the screen itself proposes. +func TestControl49AcceptsAutomaticAndEveryColumnCountOfTheRange(t *testing.T) { + accepted := []int{GridColumnsAutomatic} + for columns := MinGridColumns; columns <= MaxGridColumns; columns++ { + accepted = append(accepted, columns) + } + for _, columns := range accepted { + t.Run(strconv.Itoa(columns), func(t *testing.T) { + config := loadDelivered(t) + config.UI.GridColumns = columns + if fault := findFault(config.Validate(testRegistries()), "ui.grid_columns"); fault != nil { + t.Fatalf("%d est refusé par le contrôle 49 : %s", columns, fault.Message) + } + }) + } +} + +// TestControl49SaysWhatZeroMeans: refusing is only half of it. Somebody who writes +// `1` has to read WHY, and above all that `0` is not « aucune colonne » but the +// automatic grid -- the very value they would have to write to get their screen +// back. +func TestControl49SaysWhatZeroMeans(t *testing.T) { + config := loadDelivered(t) + config.UI.GridColumns = 1 + fault := findFault(config.Validate(testRegistries()), "ui.grid_columns") + if fault == nil { + t.Fatal("1 colonne n'est pas refusée : le reste du contrôle n'a plus d'objet") + } + spelled := strings.Join(fault.Values, " | ") + if !strings.Contains(spelled, "automatique") { + t.Errorf("valeurs = %q, elles doivent dire que 0 est le mode automatique", spelled) + } + for _, bound := range []int{MinGridColumns, MaxGridColumns} { + if !strings.Contains(spelled, strconv.Itoa(bound)) { + t.Errorf("valeurs = %q, elles doivent porter la borne %d", spelled, bound) + } + } +} diff --git a/internal/kiosk/rescue.go b/internal/kiosk/rescue.go index dd72bf9..8389c83 100644 --- a/internal/kiosk/rescue.go +++ b/internal/kiosk/rescue.go @@ -74,7 +74,7 @@ func fileURL(path string) string { // metres away. func rescueHTML(reason RescueReason, address string, shortLives int) string { title := rescueTitle(reason) - message, instruction := "", "" + var message, instruction string switch reason { case RescueCrashLoop: message = CodeCrashLoop + " — l'affichage n'arrive pas à rester ouvert " + diff --git a/internal/kiosk/screenwatch.go b/internal/kiosk/screenwatch.go new file mode 100644 index 0000000..1a86225 --- /dev/null +++ b/internal/kiosk/screenwatch.go @@ -0,0 +1,112 @@ +package kiosk + +import ( + "context" + "time" + + "openscale/internal/station/ports" +) + +// This file watches for the ONE failure a process watch cannot see: the browser is +// alive, and it is no longer showing the client screen. Nothing died, so nothing is +// relaunched — until a screen that HAS been seen has been gone for longer than the +// grace an EventSource needs to reconnect on its own. + +// screenWatch is what the supervisor remembers between two presence questions. +// +// It lives for ONE showing of the browser and is dropped with it: a relaunch starts from +// « no screen has been seen yet », which is exactly what a browser that has just been +// started is. +type screenWatch struct { + // seen is true once a client screen has been attached during this showing. + // + // Without it, the fifteen seconds of grace would be counted from the launch of the + // browser — and a station slow enough to spend them opening the page would kill the + // browser that was about to appear, then do it again, and again. The watch is for a + // screen that WAS there and went away, and nothing else. + seen bool + // absentSince is when the last attached screen went away. Zero while one is there. + absentSince time.Time +} + +// watch waits for the browser to die, for the station to come back, for the client screen +// to leave the application, or for the supervisor to be stopped. +// +// The two questions the ticker asks are EXCLUSIVE, and which one it asks is which page is +// showing. On the rescue page, « is the station back? » ends the wait; on the client +// screen, that question has no meaning — the station is answering, that is why the screen +// is up — and the one worth asking is « is anybody still looking at it? ». +func (s *Supervisor) watch(ctx context.Context, process Process, exited <-chan struct{}, onRescue bool) outcome { + // The ticker only exists when one of the two questions is live: a ticker nobody + // reads is a timer that leaks. + watching := !onRescue && s.options.Attached != nil + var recheck <-chan time.Time + if onRescue || watching { + ticks, stop := s.options.Clock.Ticker(StationRecheck) + defer stop() + recheck = ticks + } + screen := screenWatch{} + + for { + select { + case <-exited: + return died + case <-ctx.Done(): + _ = process.Kill() + <-exited + s.logf("superviseur arrêté") + return stopped + case <-recheck: + if onRescue { + if s.answering(ctx) { + s.logf("le poste répond de nouveau : retour à l'écran client") + _ = process.Kill() + <-exited + return switched + } + continue + } + if s.screenLeft(ctx, &screen) { + _ = process.Kill() + <-exited + return wandered + } + } + } +} + +// screenLeft reports whether the browser has stopped showing the client screen for longer +// than the grace. +// +// It is written to be WRONG IN ONE DIRECTION ONLY. Every uncertainty — a station that did +// not answer, a screen that has never attached during this showing — resets or holds the +// count, because the cost of not firing is a page a volunteer closes by hand, and the cost +// of firing wrongly is a browser killed in front of a customer in the middle of weighing. +func (s *Supervisor) screenLeft(ctx context.Context, screen *screenWatch) bool { + probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) + defer cancel() + + attached, answered := s.options.Attached(probeCtx) + if !answered { + // Nothing is known about the screen. A station that is restarting must not have + // its browser killed on top of it — and when the station really is gone, the + // rescue page of target() is what covers the customer, not this. + screen.absentSince = time.Time{} + return false + } + if attached > 0 { + screen.seen = true + screen.absentSince = time.Time{} + return false + } + if !screen.seen { + return false + } + now := s.options.Clock.Now() + if screen.absentSince.IsZero() { + screen.absentSince = now + return false + } + return now.Sub(screen.absentSince) >= AbsenceGrace +} diff --git a/internal/kiosk/showing.go b/internal/kiosk/showing.go new file mode 100644 index 0000000..109cb4c --- /dev/null +++ b/internal/kiosk/showing.go @@ -0,0 +1,176 @@ +package kiosk + +import ( + "context" + + "openscale/internal/station/ports" +) + +// This file is ONE showing of the browser: what it opens — the client screen, the +// waiting page or the crash-loop page — and the four ways it can end. It also holds +// the grace §15.2 gives a station that has never answered, during which the screen +// stays black rather than showing a page that would be replaced seconds later. + +// awaitStation gives a station that has never answered StartGrace to come up, showing +// nothing at all in the meantime. +// +// It runs ONCE, before the first browser of this supervisor. Afterwards a station that +// goes silent gets the waiting page inside the two seconds §15.2 promises: the grace +// covers a boot, not a failure, and re-serving it later would turn a browser that died at +// noon into twenty seconds of black screen in front of the queue. +func (s *Supervisor) awaitStation(ctx context.Context) { + if s.answering(ctx) { + return + } + s.logf("le poste ne répond pas encore : %s d'attente avant d'afficher quoi que ce soit", StartGrace) + + ticks, stop := s.options.Clock.Ticker(StationRecheck) + defer stop() + grace := s.options.Clock.After(StartGrace) + for { + select { + case <-ctx.Done(): + return + case <-grace: + s.logf("le poste n'a pas répondu en %s : page de démarrage", StartGrace) + return + case <-ticks: + if s.answering(ctx) { + return + } + } + } +} + +// answering reports whether the station serves, and remembers a yes for good. +// +// The budget is spent in the kernel's TCP stack and bounds one question, never a business +// decision — the same ProbeBudget the liveness probe carries on its own client. +func (s *Supervisor) answering(ctx context.Context) bool { + probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) + defer cancel() + if !s.options.Alive(probeCtx) { + return false + } + s.answered = true + return true +} + +// showOnce launches the browser once and returns when it has died — or when the +// station came back while the rescue page was showing. +func (s *Supervisor) showOnce(ctx context.Context) { + target, onRescue := s.target(ctx) + process, err := s.options.Launch(ctx, s.options.Browser, + Arguments(s.options.Browser, target, s.options.ProfileDir)) + if err != nil { + s.logf("le navigateur n'a pas pu être lancé : %v", err) + return + } + + started := s.options.Clock.Now() + died := make(chan struct{}) + go func() { + defer close(died) + _ = process.Wait() + }() + + switch s.watch(ctx, process, died, onRescue) { + case stopped, switched: + // Neither is a crash: one is the supervisor being stopped, the other is a + // browser WE killed because the station came back. Counting either would walk a + // station that recovered normally into the rescue page. + return + case wandered: + // Nor is this one, and it is the one that would hurt most: a station whose screen + // keeps being brought back would count its own repairs as failures and end up on + // ERR-KSK-02 — « prévenez un responsable » about a poste that repaired itself. + s.logf("plus aucun écran client attaché depuis %s : le navigateur a quitté l'application, relance dans %s", + AbsenceGrace, RelaunchDelay) + return + } + + lifetime := s.options.Clock.Now().Sub(started) + if s.crashes.Record(s.options.Clock.Now(), lifetime) { + s.enterRescue() + return + } + s.logf("navigateur arrêté après %s, relance dans %s", lifetime, RelaunchDelay) +} + +// outcome is how one showing of the browser ended. +type outcome int + +const ( + // died is the browser exiting on its own — a crash, an Alt+F4, a customer's child. + died outcome = iota + // stopped is the supervisor itself being asked to stop. + stopped + // switched is us killing the browser because the station started answering while + // the rescue page was showing. + switched + // wandered is us killing the browser because it is no longer showing the client + // screen — the one failure a process watch cannot see, since nothing died. + wandered +) + +// target decides what the browser opens, and reports whether the supervisor should +// watch for the station coming back while it is open. +// +// The answer is no in crash-loop mode, and that is the whole reason the two rescue +// reasons are not one: on the WAITING page, the station coming back is what ends the +// wait; on the ERR-KSK-02 page, the station is answering perfectly well and it is the +// page that kills the browser — switching back to it every second is the flickering +// §15.2 opened this page to stop. +func (s *Supervisor) target(ctx context.Context) (string, bool) { + if s.rescueMode { + return s.rescue, false + } + if s.answering(ctx) { + return s.options.URL, false + } + reason := RescueStarting + if s.answered { + reason = RescueWaiting + } + s.logf("le poste ne répond pas sur %s : %s", s.options.URL, rescueTitle(reason)) + s.showRescue(reason) + return s.rescue, true +} + +// showRescue rewrites the local page when what it has to say has changed. +// +// When it has changed, and not before every launch: the page is rewritten twice in the +// life of an ordinary station — never, or once when a station that had answered goes +// silent — and a file rewritten every second would be a disk woken up for nothing. +func (s *Supervisor) showRescue(reason RescueReason) { + if s.rescueReason == reason { + return + } + page, err := WriteRescuePage(s.options.ProfileDir, reason, s.options.URL, s.crashes.ShortLives()) + if err != nil { + // The page already on disk carries the other wording, which is still true enough + // to read: showing it beats showing the browser's own error page. + s.logf("la page locale n'a pas pu être réécrite : %v", err) + return + } + s.rescue, s.rescueReason = page, reason +} + +// enterRescue rewrites the local page with the crash-loop wording of §15.2 and points +// the supervisor at it. +// +// From here on the browser is still relaunched — a station whose browser is closed by +// hand must come back — but it comes back on a STILL page carrying ERR-KSK-02 instead +// of flickering in front of the queue. +// +// The honest limit of the mechanism, said here rather than discovered on site: a local +// page only helps when what kills the browser is the PAGE — a fault loop on the client +// screen, a canvas the graphics driver refuses. A browser that dies on start whatever +// it is given cannot display this page either, and what names that case is control 2 of +// `openscale doctor` plus the log lines above. +func (s *Supervisor) enterRescue() { + s.rescueMode = true + s.logf("%s : %d arrêts de moins de %s dans la dernière heure — page de secours", + CodeCrashLoop, s.crashes.ShortLives(), ShortLife) + s.showRescue(RescueCrashLoop) +} diff --git a/internal/kiosk/supervisor.go b/internal/kiosk/supervisor.go index 484acad..ce1f443 100644 --- a/internal/kiosk/supervisor.go +++ b/internal/kiosk/supervisor.go @@ -12,6 +12,13 @@ import ( "openscale/internal/station/ports" ) +// This file is the supervisor itself: the periods of §15.2, what it is given, and the +// loop that keeps a browser in front of the customer — plus the two things that loop +// does for the whole session, wiping the profile and holding the machine awake. +// +// ONE showing of the browser is in showing.go; the failure a process watch cannot see +// — a browser alive but no longer on the client screen — is in screenwatch.go. + // The two periods of §15.2. const ( // RelaunchDelay is what the supervisor waits before starting the browser again. @@ -188,269 +195,6 @@ func (s *Supervisor) Run(ctx context.Context) error { return nil } -// awaitStation gives a station that has never answered StartGrace to come up, showing -// nothing at all in the meantime. -// -// It runs ONCE, before the first browser of this supervisor. Afterwards a station that -// goes silent gets the waiting page inside the two seconds §15.2 promises: the grace -// covers a boot, not a failure, and re-serving it later would turn a browser that died at -// noon into twenty seconds of black screen in front of the queue. -func (s *Supervisor) awaitStation(ctx context.Context) { - if s.answering(ctx) { - return - } - s.logf("le poste ne répond pas encore : %s d'attente avant d'afficher quoi que ce soit", StartGrace) - - ticks, stop := s.options.Clock.Ticker(StationRecheck) - defer stop() - grace := s.options.Clock.After(StartGrace) - for { - select { - case <-ctx.Done(): - return - case <-grace: - s.logf("le poste n'a pas répondu en %s : page de démarrage", StartGrace) - return - case <-ticks: - if s.answering(ctx) { - return - } - } - } -} - -// answering reports whether the station serves, and remembers a yes for good. -// -// The budget is spent in the kernel's TCP stack and bounds one question, never a business -// decision — the same ProbeBudget the liveness probe carries on its own client. -func (s *Supervisor) answering(ctx context.Context) bool { - probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) - defer cancel() - if !s.options.Alive(probeCtx) { - return false - } - s.answered = true - return true -} - -// showOnce launches the browser once and returns when it has died — or when the -// station came back while the rescue page was showing. -func (s *Supervisor) showOnce(ctx context.Context) { - target, onRescue := s.target(ctx) - process, err := s.options.Launch(ctx, s.options.Browser, - Arguments(s.options.Browser, target, s.options.ProfileDir)) - if err != nil { - s.logf("le navigateur n'a pas pu être lancé : %v", err) - return - } - - started := s.options.Clock.Now() - died := make(chan struct{}) - go func() { - defer close(died) - _ = process.Wait() - }() - - switch s.watch(ctx, process, died, onRescue) { - case stopped, switched: - // Neither is a crash: one is the supervisor being stopped, the other is a - // browser WE killed because the station came back. Counting either would walk a - // station that recovered normally into the rescue page. - return - case wandered: - // Nor is this one, and it is the one that would hurt most: a station whose screen - // keeps being brought back would count its own repairs as failures and end up on - // ERR-KSK-02 — « prévenez un responsable » about a poste that repaired itself. - s.logf("plus aucun écran client attaché depuis %s : le navigateur a quitté l'application, relance dans %s", - AbsenceGrace, RelaunchDelay) - return - } - - lifetime := s.options.Clock.Now().Sub(started) - if s.crashes.Record(s.options.Clock.Now(), lifetime) { - s.enterRescue() - return - } - s.logf("navigateur arrêté après %s, relance dans %s", lifetime, RelaunchDelay) -} - -// outcome is how one showing of the browser ended. -type outcome int - -const ( - // died is the browser exiting on its own — a crash, an Alt+F4, a customer's child. - died outcome = iota - // stopped is the supervisor itself being asked to stop. - stopped - // switched is us killing the browser because the station started answering while - // the rescue page was showing. - switched - // wandered is us killing the browser because it is no longer showing the client - // screen — the one failure a process watch cannot see, since nothing died. - wandered -) - -// screenWatch is what the supervisor remembers between two presence questions. -// -// It lives for ONE showing of the browser and is dropped with it: a relaunch starts from -// « no screen has been seen yet », which is exactly what a browser that has just been -// started is. -type screenWatch struct { - // seen is true once a client screen has been attached during this showing. - // - // Without it, the fifteen seconds of grace would be counted from the launch of the - // browser — and a station slow enough to spend them opening the page would kill the - // browser that was about to appear, then do it again, and again. The watch is for a - // screen that WAS there and went away, and nothing else. - seen bool - // absentSince is when the last attached screen went away. Zero while one is there. - absentSince time.Time -} - -// watch waits for the browser to die, for the station to come back, for the client screen -// to leave the application, or for the supervisor to be stopped. -// -// The two questions the ticker asks are EXCLUSIVE, and which one it asks is which page is -// showing. On the rescue page, « is the station back? » ends the wait; on the client -// screen, that question has no meaning — the station is answering, that is why the screen -// is up — and the one worth asking is « is anybody still looking at it? ». -func (s *Supervisor) watch(ctx context.Context, process Process, exited <-chan struct{}, onRescue bool) outcome { - // The ticker only exists when one of the two questions is live: a ticker nobody - // reads is a timer that leaks. - watching := !onRescue && s.options.Attached != nil - var recheck <-chan time.Time - if onRescue || watching { - ticks, stop := s.options.Clock.Ticker(StationRecheck) - defer stop() - recheck = ticks - } - screen := screenWatch{} - - for { - select { - case <-exited: - return died - case <-ctx.Done(): - _ = process.Kill() - <-exited - s.logf("superviseur arrêté") - return stopped - case <-recheck: - if onRescue { - if s.answering(ctx) { - s.logf("le poste répond de nouveau : retour à l'écran client") - _ = process.Kill() - <-exited - return switched - } - continue - } - if s.screenLeft(ctx, &screen) { - _ = process.Kill() - <-exited - return wandered - } - } - } -} - -// screenLeft reports whether the browser has stopped showing the client screen for longer -// than the grace. -// -// It is written to be WRONG IN ONE DIRECTION ONLY. Every uncertainty — a station that did -// not answer, a screen that has never attached during this showing — resets or holds the -// count, because the cost of not firing is a page a volunteer closes by hand, and the cost -// of firing wrongly is a browser killed in front of a customer in the middle of weighing. -func (s *Supervisor) screenLeft(ctx context.Context, screen *screenWatch) bool { - probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) - defer cancel() - - attached, answered := s.options.Attached(probeCtx) - if !answered { - // Nothing is known about the screen. A station that is restarting must not have - // its browser killed on top of it — and when the station really is gone, the - // rescue page of target() is what covers the customer, not this. - screen.absentSince = time.Time{} - return false - } - if attached > 0 { - screen.seen = true - screen.absentSince = time.Time{} - return false - } - if !screen.seen { - return false - } - now := s.options.Clock.Now() - if screen.absentSince.IsZero() { - screen.absentSince = now - return false - } - return now.Sub(screen.absentSince) >= AbsenceGrace -} - -// target decides what the browser opens, and reports whether the supervisor should -// watch for the station coming back while it is open. -// -// The answer is no in crash-loop mode, and that is the whole reason the two rescue -// reasons are not one: on the WAITING page, the station coming back is what ends the -// wait; on the ERR-KSK-02 page, the station is answering perfectly well and it is the -// page that kills the browser — switching back to it every second is the flickering -// §15.2 opened this page to stop. -func (s *Supervisor) target(ctx context.Context) (string, bool) { - if s.rescueMode { - return s.rescue, false - } - if s.answering(ctx) { - return s.options.URL, false - } - reason := RescueStarting - if s.answered { - reason = RescueWaiting - } - s.logf("le poste ne répond pas sur %s : %s", s.options.URL, rescueTitle(reason)) - s.showRescue(reason) - return s.rescue, true -} - -// showRescue rewrites the local page when what it has to say has changed. -// -// When it has changed, and not before every launch: the page is rewritten twice in the -// life of an ordinary station — never, or once when a station that had answered goes -// silent — and a file rewritten every second would be a disk woken up for nothing. -func (s *Supervisor) showRescue(reason RescueReason) { - if s.rescueReason == reason { - return - } - page, err := WriteRescuePage(s.options.ProfileDir, reason, s.options.URL, s.crashes.ShortLives()) - if err != nil { - // The page already on disk carries the other wording, which is still true enough - // to read: showing it beats showing the browser's own error page. - s.logf("la page locale n'a pas pu être réécrite : %v", err) - return - } - s.rescue, s.rescueReason = page, reason -} - -// enterRescue rewrites the local page with the crash-loop wording of §15.2 and points -// the supervisor at it. -// -// From here on the browser is still relaunched — a station whose browser is closed by -// hand must come back — but it comes back on a STILL page carrying ERR-KSK-02 instead -// of flickering in front of the queue. -// -// The honest limit of the mechanism, said here rather than discovered on site: a local -// page only helps when what kills the browser is the PAGE — a fault loop on the client -// screen, a canvas the graphics driver refuses. A browser that dies on start whatever -// it is given cannot display this page either, and what names that case is control 2 of -// `openscale doctor` plus the log lines above. -func (s *Supervisor) enterRescue() { - s.rescueMode = true - s.logf("%s : %d arrêts de moins de %s dans la dernière heure — page de secours", - CodeCrashLoop, s.crashes.ShortLives(), ShortLife) - s.showRescue(RescueCrashLoop) -} - // wipeProfile removes the dedicated browser profile. func (s *Supervisor) wipeProfile() error { if err := os.RemoveAll(s.options.ProfileDir); err != nil { diff --git a/internal/printing/annotate.go b/internal/printing/annotate.go new file mode 100644 index 0000000..5822c05 --- /dev/null +++ b/internal/printing/annotate.go @@ -0,0 +1,62 @@ +package printing + +// This file is the BENCH OVERLAY of RenderOptions.Annotate: the printable area, the two +// quiet zones of the symbol and a millimetre ruler. A printed label never carries any of +// it — it is what turns "the label looks slightly short" into a number on a screen. + +import ( + "image" + + "openscale/internal/domain" +) + +// annotate draws the bench overlay: the printable area, the two quiet zones of the +// symbol and a millimetre ruler. +// +// IT IS DRAWN AFTER THE THRESHOLDING, and that is the only order that works: an +// overlay laid down before would be dissolved by the very threshold it has to +// survive -- a grey rule above 0x68 comes out white. Drawn in pure black afterwards +// it is binary by construction, so the "nothing but 0x00 and 0xFF" invariant holds +// either way. +// +// It overlaps the label on purpose. An overlay is read OVER a rendering, and a +// ruler pushed into the margin would measure the margin. +func annotate(dst *image.Gray, g *domain.Template, o SymbolOptions) { + drawFrame(dst, image.Rect(0, 0, + roundDots(g.Media, g.PrintableWidthUM), roundDots(g.Media, g.PrintableHeightUM))) + + block := o.Bounds() + barsLeft := o.barsLeft() + drawFrame(dst, image.Rect(block.Min.X, block.Min.Y, barsLeft, block.Max.Y)) + drawFrame(dst, image.Rect(barsLeft+o.BarsWidthDots(), block.Min.Y, block.Max.X, block.Max.Y)) + + drawRuler(dst, g.Media.DotsPerMM) +} + +// drawRuler lays a millimetre scale along the top and left edges, ticks growing at +// every fifth and every tenth millimetre. +// +// It is what turns "the label looks slightly short" into a number, and it is the +// same scale the `ruler` self-test prints on a real roll (§8.6). +func drawRuler(dst *image.Gray, dotsPerMM float64) { + b := dst.Bounds() + for mm := 0; ; mm++ { + at := int(float64(mm)*dotsPerMM + 0.5) + if at >= b.Dx() && at >= b.Dy() { + return + } + length := 2 + switch { + case mm%10 == 0: + length = 6 + case mm%5 == 0: + length = 4 + } + if at < b.Dx() { + fill(dst, image.Rect(at, 0, at+1, length)) + } + if at < b.Dy() { + fill(dst, image.Rect(0, at, length, at+1)) + } + } +} diff --git a/internal/printing/annotate_test.go b/internal/printing/annotate_test.go new file mode 100644 index 0000000..06e00b9 --- /dev/null +++ b/internal/printing/annotate_test.go @@ -0,0 +1,55 @@ +package printing + +import ( + "testing" + + "openscale/internal/domain" +) + +// The test of annotate.go: the annotation is an OVERLAY and nothing more. It adds to the +// render without moving anything that was already there — otherwise an annotated label +// would no longer be the label the till reads. + +// --- The annotation -------------------------------------------------------- + +// TestTheAnnotationIsAnOverlayAndNothingMore: it adds dots, it never removes any, and +// it survives the thresholding it is drawn after. +func TestTheAnnotationIsAnOverlayAndNothingMore(t *testing.T) { + r, _ := newTestRasterizer(t) + template := domain.IdenticalTemplate() + label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) + + plain, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + marked, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{Annotate: true}) + if err != nil { + t.Fatalf("Rasterize annoté : %v", err) + } + + added := 0 + for y := plain.Bounds().Min.Y; y < plain.Bounds().Max.Y; y++ { + for x := plain.Bounds().Min.X; x < plain.Bounds().Max.X; x++ { + switch { + case isInk(plain, x, y) && !isInk(marked, x, y): + t.Fatalf("l'annotation a effacé le dot (%d ; %d) de l'étiquette", x, y) + case !isInk(plain, x, y) && isInk(marked, x, y): + added++ + } + } + } + if added == 0 { + t.Fatal("l'annotation n'a rien tracé") + } + + // The ruler starts at the origin of the label, which is what makes it useful for + // checking an offset: a tick sits on every millimetre of the top edge. + for mm := 1; mm <= 3; mm++ { + x := int(float64(mm)*template.Media.DotsPerMM + 0.5) + if !isInk(marked, x, 0) { + t.Errorf("aucune graduation en x=%d (%d mm) sur le bord haut", x, mm) + } + } + t.Logf("%d dots ajoutés par l'annotation", added) +} diff --git a/internal/printing/conformance/check_invariant.go b/internal/printing/conformance/check_invariant.go new file mode 100644 index 0000000..e8ef30c --- /dev/null +++ b/internal/printing/conformance/check_invariant.go @@ -0,0 +1,206 @@ +package conformance + +// This file holds clauses 14 to 17, the ones that bear on EVERY call rather than on one +// of them: the instants come from the injected clock, the device is reached one label at +// a time, no goroutine survives Close, and each sentence is written in the language of +// whoever reads it. + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "openscale/internal/fake" + "openscale/internal/printing" + "openscale/internal/station/ports" +) + +// checkTheClockIsTheOneTheDriverWasGiven is the only check that reaches code outside this +// repository. +// +// `go run ./tools/boundary` walks the AST of OUR files and fails on any call to time.Now; +// a contributor's driver is not in it. What stands in for it here is the injected clock: +// the suite anchors it far in the past and hands the driver a seam that moves it by a +// KNOWN amount, so the duration on the receipt is an arithmetic fact. A driver that timed +// itself on the wall clock cannot produce it — and when the seam charges nothing, it +// cannot produce a zero either (§5.3). +func checkTheClockIsTheOneTheDriverWasGiven(t *testing.T, r reporter, subject Subject) { + r.Helper() + clk := fake.NewClock(t0) + p := build(t, r, subject.New, clk) + defer closeAndForget(p) + + receipt, err, panicked := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC3")) + if panicked != nil { + r.Fatalf("Print PANICKED: %v", panicked) + } + if err != nil { + r.Fatalf("Print returned %v on the reference weighing", err) + } + if receipt.Duration != subject.JobAdvancesTheClock { + r.Errorf("PrintReceipt.Duration = %s while the clock the suite HANDED YOU moved by %s over the whole job (Subject.JobAdvancesTheClock). A driver that read time.Now cannot report that figure, and one that did read the injected clock cannot report any other. Failure test 6 — a printer hanging for 60 s — is instantaneous only because every budget is measured on this clock (§5.3, §16.4)", + receipt.Duration, subject.JobAdvancesTheClock) + } + if moved := clk.Now().Sub(t0); moved != subject.JobAdvancesTheClock { + r.Logf("the injected clock moved by %s over the job, and the subject declares %s", moved, subject.JobAdvancesTheClock) + } +} + +// checkPrintIsSerialised is §8.2: ONE label at a time, never interleaved. +// +// Two jobs inside the same driver at once is not a theoretical concern — the reprint bar, +// the troubleshooting screen and the weighing path all reach the same instance — and the +// legacy guard against it was an `If AllReports(...).IsLoaded Then Exit Sub` that silently +// ABANDONED the weighing. What is asserted here is that each caller gets ITS OWN receipt: +// a driver keeping the job in flight in a field of its own hands back somebody else's +// identifier, and that identifier is what the reprint bar reprints. +func checkPrintIsSerialised(t *testing.T, r reporter, subject Subject) { + r.Helper() + const jobs = 8 + clk := fake.NewClock(t0) + p := build(t, r, subject.New, clk) + defer closeAndForget(p) + + type outcome struct { + wanted string + receipt ports.PrintReceipt + err error + panicked any + } + results := make(chan outcome, jobs) + var wg sync.WaitGroup + for i := range jobs { + job := subject.referenceJob(r, fmt.Sprintf("01J9F2AD%d", i)) + wg.Add(1) + go func() { + defer wg.Done() + receipt, err, panicked := printQuietly(p, context.Background(), job) + results <- outcome{job.Label.JobID, receipt, err, panicked} + }() + } + wg.Wait() + close(results) + + for got := range results { + switch { + case got.panicked != nil: + r.Errorf("Print PANICKED while %d jobs were in flight: %v. The station reaches one driver from the weighing path, the reprint bar and the troubleshooting screen (§8.2)", jobs, got.panicked) + case got.err != nil: + r.Errorf("Print returned %v for job %q while %d were in flight. Serialising is a wait, never a refusal: the legacy application dropped the second weighing on the floor instead", got.err, got.wanted, jobs) + case got.receipt.JobID != got.wanted: + r.Errorf("the caller of job %q got back a receipt for %q. Two jobs crossed inside the driver: the acknowledgement on screen, the line in the journal and the reprint bar all name a label that belongs to somebody else (§8.2)", got.wanted, got.receipt.JobID) + case got.receipt.Duration != subject.JobAdvancesTheClock: + r.Errorf("job %q reports a duration of %s while one job moves the injected clock by %s. A window that covers more than its own job is a window that overlapped another one: the frames were interleaved on the way to the head (§8.2)", got.wanted, got.receipt.Duration, subject.JobAdvancesTheClock) + } + } + if delivered, known := subject.delivered(t, p); known && delivered != jobs { + r.Errorf("%d jobs were printed and %d label(s) reached the destination. One weighing is one label: a job that vanished under concurrency is a customer standing in front of a screen that said « envoyée »", jobs, delivered) + } +} + +// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count. +// +// An absolute number would be worthless: the test binary runs goroutines of its own, and +// the runtime may still be retiring those of the previous check. What is asserted is that +// the count comes back to where it was once Close has returned — §13.1 claims the +// inventory of goroutines is exhaustive, and it is only true if every driver takes its own +// away. +func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { + r.Helper() + before := settledGoroutines(subject.patience()) + + clk := fake.NewClock(t0) + p := build(t, r, subject.New, clk) + if _, err, panicked := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC4")); panicked != nil || err != nil { + r.Fatalf("Print returned (%v, %v) on the reference weighing", err, panicked) + } + if _, panicked := statusQuietly(p, context.Background()); panicked != nil { + r.Fatalf("Status PANICKED: %v", panicked) + } + if _, panicked := selfTestQuietly(p, context.Background(), string(printing.SelfTestLabel)); panicked != nil { + r.Fatalf("SelfTest PANICKED: %v", panicked) + } + closeAndForget(p) + + if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { + r.Errorf("goroutines went from %d to %d and stayed there for %s after a whole job and a Close. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every driver takes its own away:\n%s", + before, goroutines(), subject.patience(), goroutineDump()) + } + if _, tickers := clk.Pending(); tickers > 0 { + r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) + } +} + +// checkOperatorMessagesAreFrench collects everything this driver puts in front of a human +// and holds it to the language that human speaks. +// +// It is not a style rule. « invalid parameter » on the Dépannage page is a volunteer +// standing in a shop at 9 a.m. with a queue behind them and a sentence they cannot act +// on; the whole taxonomy of §8.5 exists so that a message can name the offending value AND +// what to do about it, and it can only do that in French. +func checkOperatorMessagesAreFrench(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + + read := func(what, sentence string) { + r.Helper() + if !looksFrench(sentence) { + r.Errorf("%s reads « %s ». It is shown to a volunteer, and every line of the administration and troubleshooting screens is French — identifiers in English, contents in French (§8.2)", what, sentence) + } + } + + unusable := subject.referenceJob(r, "01J9F2AC5") + unusable.Label.Barcode = unusableBarcode + if _, err, _ := printQuietly(p, context.Background(), unusable); err != nil { + if fault, ok := printErrorOf(r, "Print", err); ok { + read("the refusal of an unusable barcode", fault.Message) + } + } + if err, _ := selfTestQuietly(p, context.Background(), "mire"); err != nil { + if fault, ok := printErrorOf(r, "SelfTest", err); ok { + read("the refusal of an unknown self-test", fault.Message) + } + } + if status, _ := statusQuietly(p, context.Background()); status.Detail != "" { + read("Status().Detail", status.Detail) + } + + closeAndForget(p) + if _, err, _ := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC6")); err != nil { + if fault, ok := printErrorOf(r, "Print", err); ok { + read("the refusal of a job after Close", fault.Message) + } + } +} + +// checkADeveloperMessageStaysEnglish is the other half of clause 17, and the reason the +// rule reads « identifiers in English, contents in French » rather than « everything in +// French ». +// +// No configuration file can produce a nil transport or an empty directory: those come from +// a composition root, so the only person who can ever read that sentence is the one +// writing Go. Answering it in French would be answering the wrong audience — and it would +// blur the one distinction that tells an operator's fault from a developer's. +func checkADeveloperMessageStaysEnglish(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.MissingCollaborator == nil { + r.Skipf("Subject.MissingCollaborator is nil: the suite cannot see what your constructor answers when a collaborator is left out. Supply it — it is one call with one field missing, and it is what keeps « identifiers in English, contents in French » from drifting into « everything in French » (§8.2)") + } + err := subject.MissingCollaborator(t) + if err == nil { + r.Fatalf("the constructor ACCEPTED a missing collaborator. An inconsistency stops the process at start-up, never with a customer standing at the scale (§11.3)") + } + var fault *ports.PrintError + if errors.As(err, &fault) { + r.Logf("the constructor answered a ports.PrintError; only its Message is read below") + err = errors.New(fault.Message) + } + if !looksEnglish(err.Error()) { + r.Errorf("the constructor answered « %v » to a missing collaborator. No configuration file can produce one, so that sentence is read by a developer and stays English; the French is for what a volunteer can act on (§8.2).\n"+ + "HOW THIS CHECK DECIDES, because it is lexical and it will refuse a message that IS English: it looks for one of the function words a sentence cannot avoid — the, this, is, are, of, with, not, from, cannot, must — and it finds none in a telegram. « %s: New: nil sink » is English and fails here; « %s: New: no sink; this driver writes its frames somewhere and the composition root owns the destination » passes and also says what to do.\n"+ + "So write a SENTENCE, not a label. That is not a formality: the constructor error is the whole diagnosis a developer gets, and « nil sink » names the field they are already looking at while saying nothing about what should have filled it", + err, subject.Name, subject.Name) + } +} diff --git a/internal/printing/conformance/check_job.go b/internal/printing/conformance/check_job.go new file mode 100644 index 0000000..63903e2 --- /dev/null +++ b/internal/printing/conformance/check_job.go @@ -0,0 +1,232 @@ +package conformance + +// This file holds clauses 1 to 6: the identity a configuration file names, and everything +// ONE job has to satisfy on its way to the head — the copy count at both ends of its +// field, and the three refusals that must happen before a single dot is drawn. + +import ( + "context" + "fmt" + "strings" + "testing" + "unicode" + + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// checkDescriptor verifies the identity the registry, the configuration file and the +// admin form all read. +// +// Descriptor is called before anything is printed, because that is when the Hub calls +// it: the drop-down list of printer.type is built from drivers nobody has opened yet. +func checkDescriptor(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + descriptor := p.Descriptor() + switch { + case descriptor.ID == "": + r.Errorf("Descriptor().ID is empty. It is the key of the driver registry and the value of printer.type in config.json: an anonymous driver cannot be named by a configuration file, and the admin screen has nothing to generate its form from") + case descriptor.ID != subject.Name: + r.Errorf("Descriptor().ID = %q while the subject is submitted as %q. Those two are the same string in config.json, and a driver that answers to a name nobody registered is unreachable", descriptor.ID, subject.Name) + } + if descriptor.Label == "" { + r.Errorf("Descriptor().Label is empty. It is the line a volunteer picks in the drop-down list, and it has to say what the driver DOES: « Aperçu — écrit un fichier, n'imprime rien » is one wrong click away from the production path (§8.2)") + } + if i := strings.IndexFunc(descriptor.ID, unicode.IsSpace); i >= 0 { + r.Errorf("Descriptor().ID = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", descriptor.ID, i) + } + if i := strings.IndexFunc(descriptor.ID, unicode.IsUpper); i >= 0 { + r.Errorf("Descriptor().ID = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different driver", descriptor.ID, i, strings.ToLower(descriptor.ID)) + } + if again := p.Descriptor(); again != descriptor { + r.Errorf("Descriptor() answered %+v then %+v. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", descriptor, again) + } +} + +// checkCopiesStayInsideTheDeclaredBound holds a driver to the ceiling IT declared. +// +// The ceiling is a fact about the wire — MaxCopies is the width of the field, six +// digits — and not a shop policy. A job past it is refused with the value it was given, +// never rounded into something that prints: a count quietly turned into one is a volunteer +// pressing a button that no longer does what it says. +func checkCopiesStayInsideTheDeclaredBound(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + bound := p.Descriptor().Capabilities.MaxCopies + if bound < 1 { + r.Fatalf("Descriptor().Capabilities.MaxCopies = %d. A driver that cannot print ONE copy cannot print at all: the admin form would offer an empty range, and the print service has no count left to send", bound) + } + + job := subject.referenceJob(r, "01J9F2ABC") + job.Copies = bound + 1 + receipt, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED on a copy count of %d: %v. An out-of-range count is a caller's mistake, not a programming error, and a panic here takes the station down", job.Copies, panicked) + } + if err == nil { + if subject.Copies == nil { + r.Skipf("Print ACCEPTED %d copies while the driver declares a ceiling of %d, and Subject.Copies is nil: the suite cannot count what really left, so it took the receipt at its word. Supply it as soon as the destination can be counted — a driver that emits what it was asked for past its own bound is a field that overflows and a frame the printer cannot read", job.Copies, bound) + } + if got := subject.Copies(t, p); got > bound { + r.Errorf("Print accepted %d copies and %d really left, past the %d this driver declares. MaxCopies is the width of the field, six digits: a count past it is not a big print run, it is a malformed frame", job.Copies, got, bound) + } + _ = receipt + return + } + + fault, ok := printErrorOf(r, "Print", err) + if !ok { + return + } + if fault.Retryable() { + r.Errorf("Print refused %d copies as %s, which the print service RETRIES twice, 300 ms then 1 s (§8.5). A count out of range will not come back into range on the second attempt: it is two more seconds of a customer in front of a screen that was never going to print", job.Copies, fault.Kind) + } + if !strings.Contains(fault.Message, fmt.Sprint(job.Copies)) || !strings.Contains(fault.Message, fmt.Sprint(bound)) { + r.Errorf("Print refused %d copies with « %s », which names neither the count it was given nor the %d it accepts. « valeur invalide » tells nobody what to type instead (ports.PrintError.Message)", job.Copies, fault.Message, bound) + } + if delivered, known := subject.delivered(t, p); known && delivered > 0 { + r.Errorf("Print refused %d copies and %d label(s) reached the destination anyway. A refusal that already printed is worse than a print: the station reports a failure the customer is holding in their hand", job.Copies, delivered) + } +} + +// checkAJobWithoutACopyCountStillPrints is the other end of the same field, and it is the +// one a driver breaks by reading job.Copies literally. +// +// The print service builds its PrintJob WITHOUT a Copies field (§8.2). Zero therefore +// means « unspecified » and printer.options.copies is the answer; a driver that took it +// as « none » would print nothing at all while the screen says « Étiquette envoyée à +// l'imprimante ». +func checkAJobWithoutACopyCountStillPrints(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + job := subject.referenceJob(r, "01J9F2ABD") + receipt, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED on the reference weighing: %v", panicked) + } + if err != nil { + r.Fatalf("Print returned %v on a job the subject declares printable. Subject.New must build a driver that can print in the test environment — a recording transport, a temporary directory — and Copies is left at ZERO here on purpose: that is how the print service sends it (§8.2)", err) + } + if receipt.JobID != job.Label.JobID { + r.Errorf("the receipt carries JobID %q for a job submitted as %q. That identifier is what ties the acknowledgement on screen to the line in the journal and to the reprint bar (§8.2)", receipt.JobID, job.Label.JobID) + } + if receipt.Bytes <= 0 { + r.Errorf("the receipt reports %d bytes for a label that printed. The count is what the journal is kept for, and a zero says an encoder produced nothing", receipt.Bytes) + } + if delivered, known := subject.delivered(t, p); known && delivered == 0 { + r.Errorf("Print returned a receipt and NOTHING reached the destination. Copies == 0 means « unspecified » and takes printer.options.copies (§8.2): a driver that read it as « none » sends a customer away with a bag and no label, while the screen says one was printed") + } +} + +// checkAnUnusableBarcodeIsRefusedAsData is the classification of §8.5 in one job: the +// remedy is a product to fix in Odoo, so the kind is KindData, the product is flagged, +// and nothing is retried. +// +// BEFORE the render, and the destination is what proves it: a driver that drew the label +// first would burn the rendering budget of every unusable product, and — worse — would +// hand over a symbol nobody can scan if it also forgot to check. +func checkAnUnusableBarcodeIsRefusedAsData(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + job := subject.referenceJob(r, "01J9F2ABE") + job.Label.Barcode = unusableBarcode + _, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED on the barcode %q: %v. A catalog that carries an unusable reference is an ordinary Tuesday, not a programming error", string(unusableBarcode), panicked) + } + if err == nil { + r.Fatalf("Print ACCEPTED the barcode %q, which is not thirteen valid digits. What comes out is a label whose symbol no till can scan, and the customer discovers it at the checkout with a queue behind them (§8.5)", string(unusableBarcode)) + } + fault, ok := printErrorOf(r, "Print", err) + if !ok { + return + } + if fault.Kind != ports.KindData { + r.Errorf("Print refused the barcode %q as %s, want %s. The kind decides the ACTION (§8.5): KindData flags the product and sends somebody to Odoo, where %s points at a template, a setting or a device that has nothing to do with it", string(unusableBarcode), fault.Kind, ports.KindData, fault.Kind) + } + if !strings.Contains(fault.Message, string(unusableBarcode)) { + r.Errorf("the refusal says « %s » without naming the offending code %q. It is the value somebody has to go and correct in the catalog, and a message that omits it sends them looking through 355 products", fault.Message, string(unusableBarcode)) + } + if delivered, known := subject.delivered(t, p); known && delivered > 0 { + r.Errorf("the barcode was refused and %d label(s) reached the destination anyway: the check happened AFTER the render and after the write. An unusable reference is caught before anything is drawn (§8.5)", delivered) + } +} + +// checkAForeignTemplateIsRefused is the geometry clause, and it is a clause about a HEAD. +// +// The resolution of the whole application has one source, template.media.dots_per_mm +// (mineur-3), and a driver's capability is what it is COMPARED to. A 12 dots/mm template +// sent to a WS408 prints at two thirds of its size, with a symbol under every GS1 floor, +// and no byte of the frame says so: the label simply comes out wrong. +func checkAForeignTemplateIsRefused(t *testing.T, r reporter, subject Subject) { + r.Helper() + if !subject.DrivesAHead { + r.Skipf("Subject.DrivesAHead is false: this driver addresses no print head, so no template is foreign to it and the suite verifies nothing here. Set it for anything that burns dots — a template drawn for another pitch is the one fault that produces a WRONG label rather than no label at all") + } + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + job := subject.referenceJob(r, "01J9F2ABF") + job.Template = foreign(subject.template()) + _, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED on a template of %g dots/mm: %v", job.Template.Media.DotsPerMM, panicked) + } + if err == nil { + r.Fatalf("Print ACCEPTED a %g dots/mm template on a head this driver declares at %g. The label comes out at another scale, with a symbol below every GS1 floor, and nothing in the frame says so — it is the one fault a volunteer cannot see without a caliper", job.Template.Media.DotsPerMM, subject.template().Media.DotsPerMM) + } + fault, ok := printErrorOf(r, "Print", err) + if !ok { + return + } + if fault.Kind != ports.KindTemplate { + r.Errorf("Print refused a foreign template as %s, want %s. A template that does not fit will not fit any better on a second attempt, and §8.5 keeps the kinds apart by the action each one calls for", fault.Kind, ports.KindTemplate) + } + if fault.Retryable() { + r.Errorf("Print refused a foreign template with a RETRYABLE kind: the print service would try it twice more, 300 ms then 1 s, for a geometry that cannot change in between (§8.2)") + } + if delivered, known := subject.delivered(t, p); known && delivered > 0 { + r.Errorf("the template was refused and %d label(s) reached the destination anyway", delivered) + } +} + +// checkAShortWriteIsATransientFailure is the failure mode that costs the most, and the +// one a driver breaks by returning a receipt for what it handed over instead of comparing +// it to what it built. +// +// WritePrinter really does report a short count with no error of its own. The frame is +// truncated, the label prints blank or not at all, and the station journals result='sent' +// for a label nobody ever held (§8.3, §8.5). +func checkAShortWriteIsATransientFailure(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.Short == nil { + r.Skipf("Subject.Short is nil: this subject offers no way to make its destination accept fewer bytes than it is given. Supply it for anything that reaches a device — a short write with NO error is what WritePrinter does, and a driver that passes it on turns a lost label into a confirmed one") + } + p := build(t, r, subject.Short, fake.NewClock(t0)) + defer closeAndForget(p) + + job := subject.referenceJob(r, "01J9F2AC0") + receipt, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED on a short write: %v", panicked) + } + if err == nil { + r.Fatalf("the destination took fewer bytes than the frame holds and Print reported SUCCESS with %d bytes. A truncated frame prints blank, and the station would journal a label the customer never held (§8.3, §8.5)", receipt.Bytes) + } + fault, ok := printErrorOf(r, "Print", err) + if !ok { + return + } + if fault.Kind != ports.KindTransient { + r.Errorf("Print reported a short write as %s, want %s. It is the ONLY kind the print service retries — two attempts, 300 ms then 1 s (§8.2) — and a spooler that took a partial frame once usually takes the whole one next time", fault.Kind, ports.KindTransient) + } +} diff --git a/internal/printing/conformance/check_lifecycle.go b/internal/printing/conformance/check_lifecycle.go new file mode 100644 index 0000000..b2f0096 --- /dev/null +++ b/internal/printing/conformance/check_lifecycle.go @@ -0,0 +1,135 @@ +package conformance + +// This file holds clauses 7 to 10: what Status may claim about a device, and what a +// driver owes its caller once Close has run — an honest « je ne sais pas », a refusal +// that reopens nothing, and a Close the Hub may call twice. + +import ( + "context" + "testing" + + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// checkStatusNeverClaimsReadyWithoutProof is §14.5 in one line: readiness is a claim, and +// a claim needs the device's own words. +// +// PrinterReady means « answered and has NOTHING to report ». A driver with no return +// channel answers PrinterUnknown — that value exists exactly for it — and a green light +// over an open head is how a station reports itself healthy while every customer walks +// away empty-handed. +func checkStatusNeverClaimsReadyWithoutProof(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(p) + + declared := p.Descriptor().Capabilities.Status + status, panicked := statusQuietly(p, context.Background()) + if panicked != nil { + r.Fatalf("Status PANICKED: %v. It is called by the troubleshooting screen and by /readyz, on a station that is already in trouble", panicked) + } + + switch status.Health { + case ports.PrinterUnknown, ports.PrinterReady, ports.PrinterConsumable, ports.PrinterFaulted: + default: + r.Errorf("Status().Health = %d is outside the vocabulary. The four values are what the maintenance light and /readyz switch on (§14.5)", status.Health) + } + if status.Health == ports.PrinterReady && len(status.Raw) == 0 { + r.Errorf("Status() answered PrinterReady with an EMPTY Raw. Ready means « the device answered and has nothing to report »: without the frame it answered with, that is a guess, and it is the guess that puts /readyz at green over an empty roll (§14.5, §8.5)") + } + if !declared && status.Health != ports.PrinterUnknown { + r.Errorf("Descriptor() declares Capabilities.Status = false and Status() answered %d. A driver with no return channel knows nothing about the device: PrinterUnknown is the honest answer and the reason that value exists (§8.5, important-7)", status.Health) + } + if status.Detail == "" { + r.Errorf("Status().Detail is empty. It is the sentence a volunteer reads on the troubleshooting screen when nothing comes out, and « impression indisponible » with nothing after it sends them to the wrong printer") + } +} + +// checkStatusAfterCloseIsUnknown covers the window between a configuration reload and the +// next driver: the screen still asks, and the answer may not be a verdict about a device +// nobody holds any more. +func checkStatusAfterCloseIsUnknown(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + if _, panicked := closeQuietly(p); panicked != nil { + r.Fatalf("Close PANICKED: %v", panicked) + } + + status, panicked := statusQuietly(p, context.Background()) + if panicked != nil { + r.Fatalf("Status PANICKED after Close: %v. The troubleshooting screen polls it, and a reload is exactly when somebody is looking at that screen", panicked) + } + if status.Health != ports.PrinterUnknown { + r.Errorf("Status() answered %d after Close, want PrinterUnknown. A driver that has given up its device knows nothing about it: reporting a fault would send a volunteer to look at a healthy printer, and reporting readiness would put /readyz at green over a station that can no longer print (§14.5)", status.Health) + } + if !looksFrench(status.Detail) { + r.Errorf("Status().Detail after Close is « %s », and a volunteer reads it on the troubleshooting screen. Every line of that screen is French (§8.2)", status.Detail) + } +} + +// checkPrintAfterCloseIsRefused keeps a station that has given up from being brought back +// by a job that arrived late. +// +// KindInternal, and that is the taxonomy doing its work: a job sent to a closed driver is +// a bug in this binary, it is never retried, and it says so — where KindTransient would +// have the print service try twice more against a device nobody holds. +func checkPrintAfterCloseIsRefused(t *testing.T, r reporter, subject Subject) { + r.Helper() + p := build(t, r, subject.New, fake.NewClock(t0)) + if _, panicked := closeQuietly(p); panicked != nil { + r.Fatalf("Close PANICKED: %v", panicked) + } + + job := subject.referenceJob(r, "01J9F2AC1") + receipt, err, panicked := printQuietly(p, context.Background(), job) + if panicked != nil { + r.Fatalf("Print PANICKED after Close: %v. A reload is a close followed by a new driver (§11.4), and a job in flight over that boundary is a race, not a programming error", panicked) + } + if err == nil { + r.Fatalf("Print reported %d bytes and no error AFTER Close. A job that reopens the device behind the closed one prints on hardware the station believes it has released, and two drivers then race for the same handle (§11.4)", receipt.Bytes) + } + fault, ok := printErrorOf(r, "Print", err) + if !ok { + return + } + if fault.Kind != ports.KindInternal { + r.Errorf("Print after Close was refused as %s by %s, with « %s », and the kind should be %s.\nThe kind is the only field the print service reads, and it is what the ADMIN screen names: %s sends a volunteer to look at printer.template, which is not what is wrong. A driver that checks `closed` only where it hands its bytes over never reaches that check, because a job composed on resources Close already released fails EARLIER — and the failure it fails with is about whatever Close happened to release first (§8.5)", + fault.Kind, fault.Op, fault.Message, ports.KindInternal, fault.Kind) + } + if delivered, known := subject.delivered(t, p); known && delivered > 0 { + r.Errorf("Print was refused after Close and %d label(s) reached the destination anyway: the driver REOPENED what the station had released", delivered) + } +} + +// checkCloseIsIdempotent covers both calls the Hub really makes: the one on a +// configuration reload and the one on shutdown that follows it (§11.4, §13.4). +func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { + r.Helper() + + // A driver that never printed. The composition root reaches this every time a + // configuration is refused after the drivers were built. + idle := build(t, r, subject.New, fake.NewClock(t0)) + for call := 1; call <= 3; call++ { + if _, panicked := closeQuietly(idle); panicked != nil { + r.Fatalf("Close PANICKED on call %d of a driver that never printed: %v. Close releases what was taken and says nothing about what was not", call, panicked) + } + } + + // And a driver that did a job. Returning an error on the second call is allowed — a + // handle already released is not news — but a panic takes the whole station down. + used := build(t, r, subject.New, fake.NewClock(t0)) + if _, err, panicked := printQuietly(used, context.Background(), subject.referenceJob(r, "01J9F2AC2")); panicked != nil || err != nil { + r.Fatalf("Print returned (%v, %v) on the reference weighing", err, panicked) + } + for call := 1; call <= 3; call++ { + err, panicked := closeQuietly(used) + if panicked != nil { + r.Fatalf("Close PANICKED on call %d after a job: %v. The Hub closes on a reload and again on shutdown (§11.4, §13.4)", call, panicked) + } + if err != nil { + // Allowed and logged rather than judged: a handle already released is not news. + r.Logf("Close returned %v on call %d", err, call) + } + } +} diff --git a/internal/printing/conformance/check_selftest.go b/internal/printing/conformance/check_selftest.go new file mode 100644 index 0000000..4abe6a3 --- /dev/null +++ b/internal/printing/conformance/check_selftest.go @@ -0,0 +1,174 @@ +package conformance + +// This file holds clauses 11 to 13, all three about the catalogue of §8.6: every pattern +// answers as the registry entry DECLARED it, a name outside the catalogue is refused by +// naming the ones that exist, and no driver ever invents the demonstration label. + +import ( + "context" + "fmt" + "strings" + "testing" + + "openscale/internal/fake" + "openscale/internal/printing" + "openscale/internal/station/ports" +) + +// checkEverySelfTestAnswersAsDeclared holds a driver to the table of §8.6 AND to what its +// registry entry says it does with each line of it. +// +// Every name printing.LookupSelfTest accepts gets an ANSWER, and the declaration decides +// WHICH one. A pattern the driver DECLARES has to come out: that declaration is what the +// Matériel page draws its buttons from, so a refusal there is a button that fails on the +// click, in front of somebody already looking for why nothing prints. A pattern it does +// NOT declare has to be refused, and refused usefully — the sentence names the test and +// says why, « cet auto-test se lit sur une étiquette imprimée » being a complete answer. +// What it may never say is « auto-test inconnu » about a name the catalogue carries: that +// sends a volunteer hunting for a typo they did not make. +// +// The other direction matters as much and is the reason this check reads a declaration at +// all: a driver that prints a pattern it never declared has a self-test no screen offers, +// which is the same fault seen from the other side (ADR-025). +func checkEverySelfTestAnswersAsDeclared(t *testing.T, r reporter, subject Subject) { + r.Helper() + for _, known := range printing.SelfTests() { + p := build(t, r, subject.New, fake.NewClock(t0)) + what := string(known.ID) + err, panicked := selfTestQuietly(p, context.Background(), what) + delivered, deliveredKnown := subject.delivered(t, p) + closeAndForget(p) + + if panicked != nil { + r.Fatalf("SelfTest(%q) PANICKED: %v. It is a button on the Dépannage page, reachable without a password (ADR-018)", what, panicked) + } + if subject.honours(known.ID) { + checkADeclaredSelfTestPrinted(r, known, err, delivered, deliveredKnown) + continue + } + checkAnUndeclaredSelfTestWasRefused(r, known, err, delivered, deliveredKnown) + } +} + +// checkADeclaredSelfTestPrinted is the verdict on a pattern this driver said it honours. +func checkADeclaredSelfTestPrinted(r reporter, known printing.SelfTestInfo, err error, + delivered int, deliveredKnown bool) { + r.Helper() + what := string(known.ID) + if err != nil { + r.Errorf("SelfTest(%q) refused with %v while this driver DECLARES it in its registry entry. The %s screen builds the button « %s » from that declaration (§8.6): declaring a pattern and refusing it is exactly the button that fails on the click, which is what declaring them was for (ADR-025)", what, err, known.Access, known.Button) + return + } + if deliveredKnown && delivered == 0 { + r.Errorf("SelfTest(%q) reported SUCCESS and NOTHING reached the destination. This driver declares that pattern, so a volunteer pressing « %s » is told a label went out and stands in front of a printer that never moved", what, known.Button) + } +} + +// checkAnUndeclaredSelfTestWasRefused is the verdict on a pattern this driver left out of +// its declaration, and the refusal has to be one a volunteer could act on. +func checkAnUndeclaredSelfTestWasRefused(r reporter, known printing.SelfTestInfo, err error, + delivered int, deliveredKnown bool) { + r.Helper() + what := string(known.ID) + if err == nil { + r.Errorf("SelfTest(%q) ANSWERED a pattern this driver does not declare. The declaration is what the %s screen draws its buttons from, so a self-test that works and is not declared is one no volunteer can ever launch — and the day somebody needs it, the button is not there (§8.6, ADR-025)", what, known.Access) + return + } + fault, ok := printErrorOf(r, fmt.Sprintf("SelfTest(%q)", what), err) + if !ok { + return + } + if strings.Contains(fault.Message, unknownSelfTest) { + r.Errorf("SelfTest(%q) answered « %s » about a self-test the CATALOGUE carries: %s offers it as the button « %s » (§8.6). A driver that does not produce it says why — « il se lit sur une étiquette imprimée » — and never that it never heard of it. The route is reachable outside the screen, so this sentence is read by whoever typed the name", what, fault.Message, known.Access, known.Button) + } + if !strings.Contains(fault.Message, what) { + r.Errorf("SelfTest(%q) refused with « %s », which does not name the test it refused. A volunteer who pressed one of three buttons has to know which one answered", what, fault.Message) + } + if !looksFrench(fault.Message) { + r.Errorf("SelfTest(%q) refused with « %s ». That sentence is shown on the Dépannage page, in French (§8.2)", what, fault.Message) + } + if deliveredKnown && delivered > 0 { + r.Errorf("SelfTest(%q) was refused and %d label(s) reached the destination anyway. A refusal that already printed is worse than a print: the roll is spent and the screen reports a failure", what, delivered) + } +} + +// checkAnUnknownSelfTestNamesTheOnesThatExist is the same refusal from the other side: a +// name outside the table is refused, and the refusal is USEFUL. +// +// It names what exists, exactly as printing.LookupSelfTest does and as an unknown +// printer.type does (§11.3). A bare « inconnu » leaves whoever typed it with nothing to +// try next. +// +// What the refusal lists is THE CATALOGUE and not this driver's declaration, and the two +// are different lists on `preview`. The name that was typed is not in the table at all, so +// what the person needs is the three spellings that are — the one that fits their driver +// then answers, and the two that do not say why in their own words. +func checkAnUnknownSelfTestNamesTheOnesThatExist(t *testing.T, r reporter, subject Subject) { + r.Helper() + for _, what := range []string{"mire", ""} { + p := build(t, r, subject.New, fake.NewClock(t0)) + err, panicked := selfTestQuietly(p, context.Background(), what) + delivered, deliveredKnown := subject.delivered(t, p) + closeAndForget(p) + + if panicked != nil { + r.Fatalf("SelfTest(%q) PANICKED: %v. The name arrives from an HTTP query parameter, so anything can be in it", what, panicked) + } + if err == nil { + r.Errorf("SelfTest(%q) reported SUCCESS on a name no self-test answers to. The three of §8.6 are a closed table: a driver that accepts a fourth is one that would print whatever a mistyped URL asks for", what) + if deliveredKnown && delivered > 0 { + r.Errorf("SelfTest(%q) also burnt %d label(s) doing it", what, delivered) + } + continue + } + fault, ok := printErrorOf(r, fmt.Sprintf("SelfTest(%q)", what), err) + if !ok { + continue + } + for _, known := range printing.SelfTests() { + if !strings.Contains(fault.Message, string(known.ID)) { + r.Errorf("SelfTest(%q) refused with « %s », which does not name %q. A refusal that lists what EXISTS is what turns a mistyped name into a next attempt, and it is what printing.LookupSelfTest and an unknown printer.type both do (§11.3)", what, fault.Message, string(known.ID)) + } + } + } +} + +// checkTheDemonstrationLabelIsNeverInvented is the boundary of §8.6, and it is a boundary +// rather than a formality. +// +// A demonstration label carries a product, a unit price and a pricing grid, which are +// catalog and configuration. A printing driver that made up a price would be printing a +// number nobody could check — and somebody WILL lay that label over a real one on a light +// table and read the price off it. +func checkTheDemonstrationLabelIsNeverInvented(t *testing.T, r reporter, subject Subject) { + r.Helper() + if !subject.honours(printing.SelfTestLabel) { + r.Skipf("this driver does not declare the %q self-test, so the refusal it answers here is the one for an undeclared pattern and says nothing about an invented price. Nothing is verified: the clause belongs to a driver that really produces a demonstration label (§8.6)", printing.SelfTestLabel) + } + if subject.WithoutDemoLabel == nil { + r.Skipf("Subject.WithoutDemoLabel is nil: the suite cannot build this driver without the demonstration label it is normally given, so it took the refusal on trust. Supply it — it is one constructor call with one field left out, and it is the clause whose breach puts an invented price on a printed label (§8.6)") + } + p := build(t, r, subject.WithoutDemoLabel, fake.NewClock(t0)) + defer closeAndForget(p) + + err, panicked := selfTestQuietly(p, context.Background(), string(printing.SelfTestLabel)) + if panicked != nil { + r.Fatalf("SelfTest(%q) PANICKED with no demonstration label: %v. A station whose composition root supplied none is a configuration, not a bug", printing.SelfTestLabel, panicked) + } + if err == nil { + r.Fatalf("SelfTest(%q) reported SUCCESS with NO demonstration label wired in. Whatever came out carries a product and prices this driver invented, and the gesture that goes with this self-test is to lay the result over a real label and compare them (§8.6)", printing.SelfTestLabel) + } + fault, ok := printErrorOf(r, "SelfTest", err) + if !ok { + return + } + if fault.Kind != ports.KindConfig { + r.Errorf("the refusal is %s, want %s. Nothing is wrong with the catalog or the printer here: a collaborator is missing from the station's configuration, and that is the kind whose screen shows what is configured against what exists (§8.5)", fault.Kind, ports.KindConfig) + } + if !looksFrench(fault.Message) { + r.Errorf("the refusal reads « %s ». It appears on the Dépannage page under a button a volunteer just pressed, in French (§8.2)", fault.Message) + } + if delivered, known := subject.delivered(t, p); known && delivered > 0 { + r.Errorf("no demonstration label was supplied and %d label(s) came out anyway: this driver invented what it could not be given", delivered) + } +} diff --git a/internal/printing/conformance/conformance.go b/internal/printing/conformance/conformance.go index d38c012..5e75ceb 100644 --- a/internal/printing/conformance/conformance.go +++ b/internal/printing/conformance/conformance.go @@ -72,169 +72,20 @@ // count, and a second driver running beside it would make that number mean nothing. package conformance +// This file is the entry point and the LIST: Suite, the order the clauses are read in, +// and the two helpers every verdict goes through. The clauses themselves live one family +// per file — check_job.go, check_lifecycle.go, check_selftest.go, check_invariant.go — +// and what a contributor fills in is in subject.go. + import ( - "context" "errors" - "fmt" "strings" - "sync" "testing" - "time" - "unicode" - "openscale/internal/domain" "openscale/internal/fake" - "openscale/internal/printing" "openscale/internal/station/ports" ) -// Subject is the printer driver submitted to the suite. -// -// Two fields are mandatory, Name and New; every other one widens what the suite can -// reach. That asymmetry is the point: submitting a driver must cost one function -// literal, and a contributor who supplies nothing else still gets the checks that need -// no seam. -type Subject struct { - // Name is the driver under test, spelled as its registry key: "raster". It names the - // sub-test group, so it appears in every failure line, and Descriptor().ID must - // return it. - Name string - - // New returns ONE fresh driver, built and ready to print. - // - // It is called once per check — sometimes several times inside one — because a driver - // that has been closed is not a fair subject for the next clause. clk is the clock the - // driver MUST take its instants from: the suite hands over a fake one anchored far in - // the past and then reads the durations that come back. - // - // The driver it builds is COMPLETE, demonstration label included: wire - // conformance.DemoLabel into whatever option your driver takes for it, and the - // self-test checks then exercise a real print instead of a refusal. - New func(t *testing.T, clk ports.Clock) ports.Printer - - // SelfTests are the patterns of §8.6 this driver HONOURS, and the right value is the - // one its registry entry carries — `SelfTests: raster.Driver().SelfTests`. Handing over - // the same list twice is what makes the declaration verifiable instead of decorative: - // a pattern added to the entry and never implemented turns red here, and so does one - // implemented and never declared, which is a button the screen would not draw. - // - // NIL MEANS THE WHOLE CATALOGUE, which is the strongest reading and the one a - // production driver answers to: a subject that says nothing is held to all three. A - // driver that honours fewer says so, and the EMPTY slice — `[]printing.SelfTest{}` — - // is the assertion « none », never an omission. - SelfTests []printing.SelfTest - - // Template is the label layout the suite prints, and it must be one THIS driver - // accepts: its media resolution is compared against the DotsPerMM the descriptor - // declares before a single check runs. - // - // The zero value means domain.IdenticalTemplate(), the production label at 8 dots/mm, - // which is what the whole parc prints today. A driver for a 12 dots/mm head sets it. - Template domain.Template - - // JobAdvancesTheClock is how far ONE job moves the injected clock through the seam - // this subject supplies — the write delay a recording transport charges, the time a - // fake device takes. - // - // ZERO IS THE COMMON CASE AND THE STRONGEST FORM of clause 14: a seam that charges - // nothing means an honest driver reports a duration of EXACTLY zero, and a driver - // that timed itself on the wall clock cannot. - JobAdvancesTheClock time.Duration - - // Delivered reports how many complete labels reached the destination over the whole - // life of the driver New just built: the frames a transport accepted, the files that - // were written. - // - // It is what turns « Print returned nil » into an assertion, and « Print refused » - // into the proof that NOTHING was emitted before the refusal. Leave it nil when the - // destination cannot be read back — and know that those assertions then go - // unverified, and that the refusal checks are the weaker for it. - Delivered func(t *testing.T, p ports.Printer) int - - // Copies reports how many copies of the label the LAST job asked the destination for: - // the field of the frame, the number of files a preview wrote. - // - // It is only ever read when a job asking for MORE than the declared bound was - // ACCEPTED, which is the one case the returned error cannot settle. A driver that - // refuses out-of-range counts — the raster driver does — never needs it. - Copies func(t *testing.T, p ports.Printer) int - - // Short builds a driver whose destination accepts FEWER bytes than it is given, - // without an error of its own. That is WritePrinter's real behaviour, and clause 6 is - // the one a driver breaks by returning a receipt for a truncated frame. - // - // Leave it nil for a driver whose destination cannot be short — a preview writing a - // file has no such mode — and the check reports itself SKIPPED rather than passed. - Short func(t *testing.T, clk ports.Clock) ports.Printer - - // WithoutDemoLabel builds the SAME driver with no demonstration label wired into it, - // which is the configuration of a station whose composition root supplied none. - // - // Supply it: it is the clause whose breach puts an invented price on a printed label, - // and the driver that would do it is one `if` away from the one that refuses. - WithoutDemoLabel func(t *testing.T, clk ports.Clock) ports.Printer - - // MissingCollaborator calls your CONSTRUCTOR with a mandatory collaborator left out — - // no transport, no directory — and returns what it answered. - // - // It is the other half of clause 17: no configuration file can produce a nil - // transport, so that message is read by a developer and stays English, exactly as the - // messages a volunteer reads stay French. - MissingCollaborator func(t *testing.T) error - - // DrivesAHead declares that this driver addresses a REAL print head whose resolution - // is a hardware fact. - // - // At false, the geometry check reports itself SKIPPED and says what is left - // unverified. That is not a courtesy: `preview` writes a file at whatever pitch the - // job's template declares, so no template is foreign to it and refusing one would - // take a station in factory configuration out of the one thing it can still do. - DrivesAHead bool - - // Patience is how long the suite waits, ON THE WALL CLOCK, for a driver to do what it - // said it would: hand its bytes over, let its goroutines go. Zero means - // defaultPatience. - // - // Wall clock, in a repository where everything else runs on an injected fake, because - // what is bounded here is a goroutine leaving blocking OS I/O. Raise it for a - // destination that is genuinely slow; do not raise it to make a flaky driver pass. - Patience time.Duration -} - -// patience reports the wall-clock budget of one wait. -func (s Subject) patience() time.Duration { - if s.Patience > 0 { - return s.Patience - } - return defaultPatience -} - -// honours reports whether this subject declared that self-test. -// -// A nil declaration is the WHOLE catalogue and never « none »: a contributor who left the -// field out is held to the strongest clause, because the other reading would credit a -// silent subject with three checks nobody ran. -func (s Subject) honours(what printing.SelfTest) bool { - if s.SelfTests == nil { - return true - } - for _, declared := range s.SelfTests { - if declared == what { - return true - } - } - return false -} - -// template reports the layout the suite prints, which is the production label unless the -// subject named another. -func (s Subject) template() domain.Template { - if s.Template.Media.DotsPerMM > 0 { - return s.Template - } - return domain.IdenticalTemplate() -} - // Suite runs every conformance check against subject, each one as a sub-test of t. // // It is the whole public surface of this package, with DemoLabel: a driver's own test @@ -393,718 +244,6 @@ func requireTheTemplateFitsTheDriver(t *testing.T, r reporter, subject Subject) } } -// checkDescriptor verifies the identity the registry, the configuration file and the -// admin form all read. -// -// Descriptor is called before anything is printed, because that is when the Hub calls -// it: the drop-down list of printer.type is built from drivers nobody has opened yet. -func checkDescriptor(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - descriptor := p.Descriptor() - switch { - case descriptor.ID == "": - r.Errorf("Descriptor().ID is empty. It is the key of the driver registry and the value of printer.type in config.json: an anonymous driver cannot be named by a configuration file, and the admin screen has nothing to generate its form from") - case descriptor.ID != subject.Name: - r.Errorf("Descriptor().ID = %q while the subject is submitted as %q. Those two are the same string in config.json, and a driver that answers to a name nobody registered is unreachable", descriptor.ID, subject.Name) - } - if descriptor.Label == "" { - r.Errorf("Descriptor().Label is empty. It is the line a volunteer picks in the drop-down list, and it has to say what the driver DOES: « Aperçu — écrit un fichier, n'imprime rien » is one wrong click away from the production path (§8.2)") - } - if i := strings.IndexFunc(descriptor.ID, unicode.IsSpace); i >= 0 { - r.Errorf("Descriptor().ID = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", descriptor.ID, i) - } - if i := strings.IndexFunc(descriptor.ID, unicode.IsUpper); i >= 0 { - r.Errorf("Descriptor().ID = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different driver", descriptor.ID, i, strings.ToLower(descriptor.ID)) - } - if again := p.Descriptor(); again != descriptor { - r.Errorf("Descriptor() answered %+v then %+v. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", descriptor, again) - } -} - -// checkCopiesStayInsideTheDeclaredBound holds a driver to the ceiling IT declared. -// -// The ceiling is a fact about the wire — MaxCopies is the width of the field, six -// digits — and not a shop policy. A job past it is refused with the value it was given, -// never rounded into something that prints: a count quietly turned into one is a volunteer -// pressing a button that no longer does what it says. -func checkCopiesStayInsideTheDeclaredBound(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - bound := p.Descriptor().Capabilities.MaxCopies - if bound < 1 { - r.Fatalf("Descriptor().Capabilities.MaxCopies = %d. A driver that cannot print ONE copy cannot print at all: the admin form would offer an empty range, and the print service has no count left to send", bound) - } - - job := subject.referenceJob(r, "01J9F2ABC") - job.Copies = bound + 1 - receipt, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED on a copy count of %d: %v. An out-of-range count is a caller's mistake, not a programming error, and a panic here takes the station down", job.Copies, panicked) - } - if err == nil { - if subject.Copies == nil { - r.Skipf("Print ACCEPTED %d copies while the driver declares a ceiling of %d, and Subject.Copies is nil: the suite cannot count what really left, so it took the receipt at its word. Supply it as soon as the destination can be counted — a driver that emits what it was asked for past its own bound is a field that overflows and a frame the printer cannot read", job.Copies, bound) - } - if got := subject.Copies(t, p); got > bound { - r.Errorf("Print accepted %d copies and %d really left, past the %d this driver declares. MaxCopies is the width of the field, six digits: a count past it is not a big print run, it is a malformed frame", job.Copies, got, bound) - } - _ = receipt - return - } - - fault, ok := printErrorOf(r, "Print", err) - if !ok { - return - } - if fault.Retryable() { - r.Errorf("Print refused %d copies as %s, which the print service RETRIES twice, 300 ms then 1 s (§8.5). A count out of range will not come back into range on the second attempt: it is two more seconds of a customer in front of a screen that was never going to print", job.Copies, fault.Kind) - } - if !strings.Contains(fault.Message, fmt.Sprint(job.Copies)) || !strings.Contains(fault.Message, fmt.Sprint(bound)) { - r.Errorf("Print refused %d copies with « %s », which names neither the count it was given nor the %d it accepts. « valeur invalide » tells nobody what to type instead (ports.PrintError.Message)", job.Copies, fault.Message, bound) - } - if delivered, known := subject.delivered(t, p); known && delivered > 0 { - r.Errorf("Print refused %d copies and %d label(s) reached the destination anyway. A refusal that already printed is worse than a print: the station reports a failure the customer is holding in their hand", job.Copies, delivered) - } -} - -// checkAJobWithoutACopyCountStillPrints is the other end of the same field, and it is the -// one a driver breaks by reading job.Copies literally. -// -// The print service builds its PrintJob WITHOUT a Copies field (§8.2). Zero therefore -// means « unspecified » and printer.options.copies is the answer; a driver that took it -// as « none » would print nothing at all while the screen says « Étiquette envoyée à -// l'imprimante ». -func checkAJobWithoutACopyCountStillPrints(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - job := subject.referenceJob(r, "01J9F2ABD") - receipt, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED on the reference weighing: %v", panicked) - } - if err != nil { - r.Fatalf("Print returned %v on a job the subject declares printable. Subject.New must build a driver that can print in the test environment — a recording transport, a temporary directory — and Copies is left at ZERO here on purpose: that is how the print service sends it (§8.2)", err) - } - if receipt.JobID != job.Label.JobID { - r.Errorf("the receipt carries JobID %q for a job submitted as %q. That identifier is what ties the acknowledgement on screen to the line in the journal and to the reprint bar (§8.2)", receipt.JobID, job.Label.JobID) - } - if receipt.Bytes <= 0 { - r.Errorf("the receipt reports %d bytes for a label that printed. The count is what the journal is kept for, and a zero says an encoder produced nothing", receipt.Bytes) - } - if delivered, known := subject.delivered(t, p); known && delivered == 0 { - r.Errorf("Print returned a receipt and NOTHING reached the destination. Copies == 0 means « unspecified » and takes printer.options.copies (§8.2): a driver that read it as « none » sends a customer away with a bag and no label, while the screen says one was printed") - } -} - -// checkAnUnusableBarcodeIsRefusedAsData is the classification of §8.5 in one job: the -// remedy is a product to fix in Odoo, so the kind is KindData, the product is flagged, -// and nothing is retried. -// -// BEFORE the render, and the destination is what proves it: a driver that drew the label -// first would burn the rendering budget of every unusable product, and — worse — would -// hand over a symbol nobody can scan if it also forgot to check. -func checkAnUnusableBarcodeIsRefusedAsData(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - job := subject.referenceJob(r, "01J9F2ABE") - job.Label.Barcode = unusableBarcode - _, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED on the barcode %q: %v. A catalog that carries an unusable reference is an ordinary Tuesday, not a programming error", string(unusableBarcode), panicked) - } - if err == nil { - r.Fatalf("Print ACCEPTED the barcode %q, which is not thirteen valid digits. What comes out is a label whose symbol no till can scan, and the customer discovers it at the checkout with a queue behind them (§8.5)", string(unusableBarcode)) - } - fault, ok := printErrorOf(r, "Print", err) - if !ok { - return - } - if fault.Kind != ports.KindData { - r.Errorf("Print refused the barcode %q as %s, want %s. The kind decides the ACTION (§8.5): KindData flags the product and sends somebody to Odoo, where %s points at a template, a setting or a device that has nothing to do with it", string(unusableBarcode), fault.Kind, ports.KindData, fault.Kind) - } - if !strings.Contains(fault.Message, string(unusableBarcode)) { - r.Errorf("the refusal says « %s » without naming the offending code %q. It is the value somebody has to go and correct in the catalog, and a message that omits it sends them looking through 355 products", fault.Message, string(unusableBarcode)) - } - if delivered, known := subject.delivered(t, p); known && delivered > 0 { - r.Errorf("the barcode was refused and %d label(s) reached the destination anyway: the check happened AFTER the render and after the write. An unusable reference is caught before anything is drawn (§8.5)", delivered) - } -} - -// checkAForeignTemplateIsRefused is the geometry clause, and it is a clause about a HEAD. -// -// The resolution of the whole application has one source, template.media.dots_per_mm -// (mineur-3), and a driver's capability is what it is COMPARED to. A 12 dots/mm template -// sent to a WS408 prints at two thirds of its size, with a symbol under every GS1 floor, -// and no byte of the frame says so: the label simply comes out wrong. -func checkAForeignTemplateIsRefused(t *testing.T, r reporter, subject Subject) { - r.Helper() - if !subject.DrivesAHead { - r.Skipf("Subject.DrivesAHead is false: this driver addresses no print head, so no template is foreign to it and the suite verifies nothing here. Set it for anything that burns dots — a template drawn for another pitch is the one fault that produces a WRONG label rather than no label at all") - } - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - job := subject.referenceJob(r, "01J9F2ABF") - job.Template = foreign(subject.template()) - _, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED on a template of %g dots/mm: %v", job.Template.Media.DotsPerMM, panicked) - } - if err == nil { - r.Fatalf("Print ACCEPTED a %g dots/mm template on a head this driver declares at %g. The label comes out at another scale, with a symbol below every GS1 floor, and nothing in the frame says so — it is the one fault a volunteer cannot see without a caliper", job.Template.Media.DotsPerMM, subject.template().Media.DotsPerMM) - } - fault, ok := printErrorOf(r, "Print", err) - if !ok { - return - } - if fault.Kind != ports.KindTemplate { - r.Errorf("Print refused a foreign template as %s, want %s. A template that does not fit will not fit any better on a second attempt, and §8.5 keeps the kinds apart by the action each one calls for", fault.Kind, ports.KindTemplate) - } - if fault.Retryable() { - r.Errorf("Print refused a foreign template with a RETRYABLE kind: the print service would try it twice more, 300 ms then 1 s, for a geometry that cannot change in between (§8.2)") - } - if delivered, known := subject.delivered(t, p); known && delivered > 0 { - r.Errorf("the template was refused and %d label(s) reached the destination anyway", delivered) - } -} - -// checkAShortWriteIsATransientFailure is the failure mode that costs the most, and the -// one a driver breaks by returning a receipt for what it handed over instead of comparing -// it to what it built. -// -// WritePrinter really does report a short count with no error of its own. The frame is -// truncated, the label prints blank or not at all, and the station journals result='sent' -// for a label nobody ever held (§8.3, §8.5). -func checkAShortWriteIsATransientFailure(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.Short == nil { - r.Skipf("Subject.Short is nil: this subject offers no way to make its destination accept fewer bytes than it is given. Supply it for anything that reaches a device — a short write with NO error is what WritePrinter does, and a driver that passes it on turns a lost label into a confirmed one") - } - p := build(t, r, subject.Short, fake.NewClock(t0)) - defer closeAndForget(p) - - job := subject.referenceJob(r, "01J9F2AC0") - receipt, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED on a short write: %v", panicked) - } - if err == nil { - r.Fatalf("the destination took fewer bytes than the frame holds and Print reported SUCCESS with %d bytes. A truncated frame prints blank, and the station would journal a label the customer never held (§8.3, §8.5)", receipt.Bytes) - } - fault, ok := printErrorOf(r, "Print", err) - if !ok { - return - } - if fault.Kind != ports.KindTransient { - r.Errorf("Print reported a short write as %s, want %s. It is the ONLY kind the print service retries — two attempts, 300 ms then 1 s (§8.2) — and a spooler that took a partial frame once usually takes the whole one next time", fault.Kind, ports.KindTransient) - } -} - -// checkStatusNeverClaimsReadyWithoutProof is §14.5 in one line: readiness is a claim, and -// a claim needs the device's own words. -// -// PrinterReady means « answered and has NOTHING to report ». A driver with no return -// channel answers PrinterUnknown — that value exists exactly for it — and a green light -// over an open head is how a station reports itself healthy while every customer walks -// away empty-handed. -func checkStatusNeverClaimsReadyWithoutProof(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(p) - - declared := p.Descriptor().Capabilities.Status - status, panicked := statusQuietly(p, context.Background()) - if panicked != nil { - r.Fatalf("Status PANICKED: %v. It is called by the troubleshooting screen and by /readyz, on a station that is already in trouble", panicked) - } - - switch status.Health { - case ports.PrinterUnknown, ports.PrinterReady, ports.PrinterConsumable, ports.PrinterFaulted: - default: - r.Errorf("Status().Health = %d is outside the vocabulary. The four values are what the maintenance light and /readyz switch on (§14.5)", status.Health) - } - if status.Health == ports.PrinterReady && len(status.Raw) == 0 { - r.Errorf("Status() answered PrinterReady with an EMPTY Raw. Ready means « the device answered and has nothing to report »: without the frame it answered with, that is a guess, and it is the guess that puts /readyz at green over an empty roll (§14.5, §8.5)") - } - if !declared && status.Health != ports.PrinterUnknown { - r.Errorf("Descriptor() declares Capabilities.Status = false and Status() answered %d. A driver with no return channel knows nothing about the device: PrinterUnknown is the honest answer and the reason that value exists (§8.5, important-7)", status.Health) - } - if status.Detail == "" { - r.Errorf("Status().Detail is empty. It is the sentence a volunteer reads on the troubleshooting screen when nothing comes out, and « impression indisponible » with nothing after it sends them to the wrong printer") - } -} - -// checkStatusAfterCloseIsUnknown covers the window between a configuration reload and the -// next driver: the screen still asks, and the answer may not be a verdict about a device -// nobody holds any more. -func checkStatusAfterCloseIsUnknown(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - if _, panicked := closeQuietly(p); panicked != nil { - r.Fatalf("Close PANICKED: %v", panicked) - } - - status, panicked := statusQuietly(p, context.Background()) - if panicked != nil { - r.Fatalf("Status PANICKED after Close: %v. The troubleshooting screen polls it, and a reload is exactly when somebody is looking at that screen", panicked) - } - if status.Health != ports.PrinterUnknown { - r.Errorf("Status() answered %d after Close, want PrinterUnknown. A driver that has given up its device knows nothing about it: reporting a fault would send a volunteer to look at a healthy printer, and reporting readiness would put /readyz at green over a station that can no longer print (§14.5)", status.Health) - } - if !looksFrench(status.Detail) { - r.Errorf("Status().Detail after Close is « %s », and a volunteer reads it on the troubleshooting screen. Every line of that screen is French (§8.2)", status.Detail) - } -} - -// checkPrintAfterCloseIsRefused keeps a station that has given up from being brought back -// by a job that arrived late. -// -// KindInternal, and that is the taxonomy doing its work: a job sent to a closed driver is -// a bug in this binary, it is never retried, and it says so — where KindTransient would -// have the print service try twice more against a device nobody holds. -func checkPrintAfterCloseIsRefused(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - if _, panicked := closeQuietly(p); panicked != nil { - r.Fatalf("Close PANICKED: %v", panicked) - } - - job := subject.referenceJob(r, "01J9F2AC1") - receipt, err, panicked := printQuietly(p, context.Background(), job) - if panicked != nil { - r.Fatalf("Print PANICKED after Close: %v. A reload is a close followed by a new driver (§11.4), and a job in flight over that boundary is a race, not a programming error", panicked) - } - if err == nil { - r.Fatalf("Print reported %d bytes and no error AFTER Close. A job that reopens the device behind the closed one prints on hardware the station believes it has released, and two drivers then race for the same handle (§11.4)", receipt.Bytes) - } - fault, ok := printErrorOf(r, "Print", err) - if !ok { - return - } - if fault.Kind != ports.KindInternal { - r.Errorf("Print after Close was refused as %s by %s, with « %s », and the kind should be %s.\nThe kind is the only field the print service reads, and it is what the ADMIN screen names: %s sends a volunteer to look at printer.template, which is not what is wrong. A driver that checks `closed` only where it hands its bytes over never reaches that check, because a job composed on resources Close already released fails EARLIER — and the failure it fails with is about whatever Close happened to release first (§8.5)", - fault.Kind, fault.Op, fault.Message, ports.KindInternal, fault.Kind) - } - if delivered, known := subject.delivered(t, p); known && delivered > 0 { - r.Errorf("Print was refused after Close and %d label(s) reached the destination anyway: the driver REOPENED what the station had released", delivered) - } -} - -// checkCloseIsIdempotent covers both calls the Hub really makes: the one on a -// configuration reload and the one on shutdown that follows it (§11.4, §13.4). -func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { - r.Helper() - - // A driver that never printed. The composition root reaches this every time a - // configuration is refused after the drivers were built. - idle := build(t, r, subject.New, fake.NewClock(t0)) - for call := 1; call <= 3; call++ { - if _, panicked := closeQuietly(idle); panicked != nil { - r.Fatalf("Close PANICKED on call %d of a driver that never printed: %v. Close releases what was taken and says nothing about what was not", call, panicked) - } - } - - // And a driver that did a job. Returning an error on the second call is allowed — a - // handle already released is not news — but a panic takes the whole station down. - used := build(t, r, subject.New, fake.NewClock(t0)) - if _, err, panicked := printQuietly(used, context.Background(), subject.referenceJob(r, "01J9F2AC2")); panicked != nil || err != nil { - r.Fatalf("Print returned (%v, %v) on the reference weighing", err, panicked) - } - for call := 1; call <= 3; call++ { - err, panicked := closeQuietly(used) - if panicked != nil { - r.Fatalf("Close PANICKED on call %d after a job: %v. The Hub closes on a reload and again on shutdown (§11.4, §13.4)", call, panicked) - } - if err != nil { - // Allowed and logged rather than judged: a handle already released is not news. - r.Logf("Close returned %v on call %d", err, call) - } - } -} - -// checkEverySelfTestAnswersAsDeclared holds a driver to the table of §8.6 AND to what its -// registry entry says it does with each line of it. -// -// Every name printing.LookupSelfTest accepts gets an ANSWER, and the declaration decides -// WHICH one. A pattern the driver DECLARES has to come out: that declaration is what the -// Matériel page draws its buttons from, so a refusal there is a button that fails on the -// click, in front of somebody already looking for why nothing prints. A pattern it does -// NOT declare has to be refused, and refused usefully — the sentence names the test and -// says why, « cet auto-test se lit sur une étiquette imprimée » being a complete answer. -// What it may never say is « auto-test inconnu » about a name the catalogue carries: that -// sends a volunteer hunting for a typo they did not make. -// -// The other direction matters as much and is the reason this check reads a declaration at -// all: a driver that prints a pattern it never declared has a self-test no screen offers, -// which is the same fault seen from the other side (ADR-025). -func checkEverySelfTestAnswersAsDeclared(t *testing.T, r reporter, subject Subject) { - r.Helper() - for _, known := range printing.SelfTests() { - p := build(t, r, subject.New, fake.NewClock(t0)) - what := string(known.ID) - err, panicked := selfTestQuietly(p, context.Background(), what) - delivered, deliveredKnown := subject.delivered(t, p) - closeAndForget(p) - - if panicked != nil { - r.Fatalf("SelfTest(%q) PANICKED: %v. It is a button on the Dépannage page, reachable without a password (ADR-018)", what, panicked) - } - if subject.honours(known.ID) { - checkADeclaredSelfTestPrinted(r, known, err, delivered, deliveredKnown) - continue - } - checkAnUndeclaredSelfTestWasRefused(r, known, err, delivered, deliveredKnown) - } -} - -// checkADeclaredSelfTestPrinted is the verdict on a pattern this driver said it honours. -func checkADeclaredSelfTestPrinted(r reporter, known printing.SelfTestInfo, err error, - delivered int, deliveredKnown bool) { - r.Helper() - what := string(known.ID) - if err != nil { - r.Errorf("SelfTest(%q) refused with %v while this driver DECLARES it in its registry entry. The %s screen builds the button « %s » from that declaration (§8.6): declaring a pattern and refusing it is exactly the button that fails on the click, which is what declaring them was for (ADR-025)", what, err, known.Access, known.Button) - return - } - if deliveredKnown && delivered == 0 { - r.Errorf("SelfTest(%q) reported SUCCESS and NOTHING reached the destination. This driver declares that pattern, so a volunteer pressing « %s » is told a label went out and stands in front of a printer that never moved", what, known.Button) - } -} - -// checkAnUndeclaredSelfTestWasRefused is the verdict on a pattern this driver left out of -// its declaration, and the refusal has to be one a volunteer could act on. -func checkAnUndeclaredSelfTestWasRefused(r reporter, known printing.SelfTestInfo, err error, - delivered int, deliveredKnown bool) { - r.Helper() - what := string(known.ID) - if err == nil { - r.Errorf("SelfTest(%q) ANSWERED a pattern this driver does not declare. The declaration is what the %s screen draws its buttons from, so a self-test that works and is not declared is one no volunteer can ever launch — and the day somebody needs it, the button is not there (§8.6, ADR-025)", what, known.Access) - return - } - fault, ok := printErrorOf(r, fmt.Sprintf("SelfTest(%q)", what), err) - if !ok { - return - } - if strings.Contains(fault.Message, unknownSelfTest) { - r.Errorf("SelfTest(%q) answered « %s » about a self-test the CATALOGUE carries: %s offers it as the button « %s » (§8.6). A driver that does not produce it says why — « il se lit sur une étiquette imprimée » — and never that it never heard of it. The route is reachable outside the screen, so this sentence is read by whoever typed the name", what, fault.Message, known.Access, known.Button) - } - if !strings.Contains(fault.Message, what) { - r.Errorf("SelfTest(%q) refused with « %s », which does not name the test it refused. A volunteer who pressed one of three buttons has to know which one answered", what, fault.Message) - } - if !looksFrench(fault.Message) { - r.Errorf("SelfTest(%q) refused with « %s ». That sentence is shown on the Dépannage page, in French (§8.2)", what, fault.Message) - } - if deliveredKnown && delivered > 0 { - r.Errorf("SelfTest(%q) was refused and %d label(s) reached the destination anyway. A refusal that already printed is worse than a print: the roll is spent and the screen reports a failure", what, delivered) - } -} - -// checkAnUnknownSelfTestNamesTheOnesThatExist is the same refusal from the other side: a -// name outside the table is refused, and the refusal is USEFUL. -// -// It names what exists, exactly as printing.LookupSelfTest does and as an unknown -// printer.type does (§11.3). A bare « inconnu » leaves whoever typed it with nothing to -// try next. -// -// What the refusal lists is THE CATALOGUE and not this driver's declaration, and the two -// are different lists on `preview`. The name that was typed is not in the table at all, so -// what the person needs is the three spellings that are — the one that fits their driver -// then answers, and the two that do not say why in their own words. -func checkAnUnknownSelfTestNamesTheOnesThatExist(t *testing.T, r reporter, subject Subject) { - r.Helper() - for _, what := range []string{"mire", ""} { - p := build(t, r, subject.New, fake.NewClock(t0)) - err, panicked := selfTestQuietly(p, context.Background(), what) - delivered, deliveredKnown := subject.delivered(t, p) - closeAndForget(p) - - if panicked != nil { - r.Fatalf("SelfTest(%q) PANICKED: %v. The name arrives from an HTTP query parameter, so anything can be in it", what, panicked) - } - if err == nil { - r.Errorf("SelfTest(%q) reported SUCCESS on a name no self-test answers to. The three of §8.6 are a closed table: a driver that accepts a fourth is one that would print whatever a mistyped URL asks for", what) - if deliveredKnown && delivered > 0 { - r.Errorf("SelfTest(%q) also burnt %d label(s) doing it", what, delivered) - } - continue - } - fault, ok := printErrorOf(r, fmt.Sprintf("SelfTest(%q)", what), err) - if !ok { - continue - } - for _, known := range printing.SelfTests() { - if !strings.Contains(fault.Message, string(known.ID)) { - r.Errorf("SelfTest(%q) refused with « %s », which does not name %q. A refusal that lists what EXISTS is what turns a mistyped name into a next attempt, and it is what printing.LookupSelfTest and an unknown printer.type both do (§11.3)", what, fault.Message, string(known.ID)) - } - } - } -} - -// checkTheDemonstrationLabelIsNeverInvented is the boundary of §8.6, and it is a boundary -// rather than a formality. -// -// A demonstration label carries a product, a unit price and a pricing grid, which are -// catalog and configuration. A printing driver that made up a price would be printing a -// number nobody could check — and somebody WILL lay that label over a real one on a light -// table and read the price off it. -func checkTheDemonstrationLabelIsNeverInvented(t *testing.T, r reporter, subject Subject) { - r.Helper() - if !subject.honours(printing.SelfTestLabel) { - r.Skipf("this driver does not declare the %q self-test, so the refusal it answers here is the one for an undeclared pattern and says nothing about an invented price. Nothing is verified: the clause belongs to a driver that really produces a demonstration label (§8.6)", printing.SelfTestLabel) - } - if subject.WithoutDemoLabel == nil { - r.Skipf("Subject.WithoutDemoLabel is nil: the suite cannot build this driver without the demonstration label it is normally given, so it took the refusal on trust. Supply it — it is one constructor call with one field left out, and it is the clause whose breach puts an invented price on a printed label (§8.6)") - } - p := build(t, r, subject.WithoutDemoLabel, fake.NewClock(t0)) - defer closeAndForget(p) - - err, panicked := selfTestQuietly(p, context.Background(), string(printing.SelfTestLabel)) - if panicked != nil { - r.Fatalf("SelfTest(%q) PANICKED with no demonstration label: %v. A station whose composition root supplied none is a configuration, not a bug", printing.SelfTestLabel, panicked) - } - if err == nil { - r.Fatalf("SelfTest(%q) reported SUCCESS with NO demonstration label wired in. Whatever came out carries a product and prices this driver invented, and the gesture that goes with this self-test is to lay the result over a real label and compare them (§8.6)", printing.SelfTestLabel) - } - fault, ok := printErrorOf(r, "SelfTest", err) - if !ok { - return - } - if fault.Kind != ports.KindConfig { - r.Errorf("the refusal is %s, want %s. Nothing is wrong with the catalog or the printer here: a collaborator is missing from the station's configuration, and that is the kind whose screen shows what is configured against what exists (§8.5)", fault.Kind, ports.KindConfig) - } - if !looksFrench(fault.Message) { - r.Errorf("the refusal reads « %s ». It appears on the Dépannage page under a button a volunteer just pressed, in French (§8.2)", fault.Message) - } - if delivered, known := subject.delivered(t, p); known && delivered > 0 { - r.Errorf("no demonstration label was supplied and %d label(s) came out anyway: this driver invented what it could not be given", delivered) - } -} - -// checkTheClockIsTheOneTheDriverWasGiven is the only check that reaches code outside this -// repository. -// -// `go run ./tools/boundary` walks the AST of OUR files and fails on any call to time.Now; -// a contributor's driver is not in it. What stands in for it here is the injected clock: -// the suite anchors it far in the past and hands the driver a seam that moves it by a -// KNOWN amount, so the duration on the receipt is an arithmetic fact. A driver that timed -// itself on the wall clock cannot produce it — and when the seam charges nothing, it -// cannot produce a zero either (§5.3). -func checkTheClockIsTheOneTheDriverWasGiven(t *testing.T, r reporter, subject Subject) { - r.Helper() - clk := fake.NewClock(t0) - p := build(t, r, subject.New, clk) - defer closeAndForget(p) - - receipt, err, panicked := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC3")) - if panicked != nil { - r.Fatalf("Print PANICKED: %v", panicked) - } - if err != nil { - r.Fatalf("Print returned %v on the reference weighing", err) - } - if receipt.Duration != subject.JobAdvancesTheClock { - r.Errorf("PrintReceipt.Duration = %s while the clock the suite HANDED YOU moved by %s over the whole job (Subject.JobAdvancesTheClock). A driver that read time.Now cannot report that figure, and one that did read the injected clock cannot report any other. Failure test 6 — a printer hanging for 60 s — is instantaneous only because every budget is measured on this clock (§5.3, §16.4)", - receipt.Duration, subject.JobAdvancesTheClock) - } - if moved := clk.Now().Sub(t0); moved != subject.JobAdvancesTheClock { - r.Logf("the injected clock moved by %s over the job, and the subject declares %s", moved, subject.JobAdvancesTheClock) - } -} - -// checkPrintIsSerialised is §8.2: ONE label at a time, never interleaved. -// -// Two jobs inside the same driver at once is not a theoretical concern — the reprint bar, -// the troubleshooting screen and the weighing path all reach the same instance — and the -// legacy guard against it was an `If AllReports(...).IsLoaded Then Exit Sub` that silently -// ABANDONED the weighing. What is asserted here is that each caller gets ITS OWN receipt: -// a driver keeping the job in flight in a field of its own hands back somebody else's -// identifier, and that identifier is what the reprint bar reprints. -func checkPrintIsSerialised(t *testing.T, r reporter, subject Subject) { - r.Helper() - const jobs = 8 - clk := fake.NewClock(t0) - p := build(t, r, subject.New, clk) - defer closeAndForget(p) - - type outcome struct { - wanted string - receipt ports.PrintReceipt - err error - panicked any - } - results := make(chan outcome, jobs) - var wg sync.WaitGroup - for i := range jobs { - job := subject.referenceJob(r, fmt.Sprintf("01J9F2AD%d", i)) - wg.Add(1) - go func() { - defer wg.Done() - receipt, err, panicked := printQuietly(p, context.Background(), job) - results <- outcome{job.Label.JobID, receipt, err, panicked} - }() - } - wg.Wait() - close(results) - - for got := range results { - switch { - case got.panicked != nil: - r.Errorf("Print PANICKED while %d jobs were in flight: %v. The station reaches one driver from the weighing path, the reprint bar and the troubleshooting screen (§8.2)", jobs, got.panicked) - case got.err != nil: - r.Errorf("Print returned %v for job %q while %d were in flight. Serialising is a wait, never a refusal: the legacy application dropped the second weighing on the floor instead", got.err, got.wanted, jobs) - case got.receipt.JobID != got.wanted: - r.Errorf("the caller of job %q got back a receipt for %q. Two jobs crossed inside the driver: the acknowledgement on screen, the line in the journal and the reprint bar all name a label that belongs to somebody else (§8.2)", got.wanted, got.receipt.JobID) - case got.receipt.Duration != subject.JobAdvancesTheClock: - r.Errorf("job %q reports a duration of %s while one job moves the injected clock by %s. A window that covers more than its own job is a window that overlapped another one: the frames were interleaved on the way to the head (§8.2)", got.wanted, got.receipt.Duration, subject.JobAdvancesTheClock) - } - } - if delivered, known := subject.delivered(t, p); known && delivered != jobs { - r.Errorf("%d jobs were printed and %d label(s) reached the destination. One weighing is one label: a job that vanished under concurrency is a customer standing in front of a screen that said « envoyée »", jobs, delivered) - } -} - -// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count. -// -// An absolute number would be worthless: the test binary runs goroutines of its own, and -// the runtime may still be retiring those of the previous check. What is asserted is that -// the count comes back to where it was once Close has returned — §13.1 claims the -// inventory of goroutines is exhaustive, and it is only true if every driver takes its own -// away. -func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { - r.Helper() - before := settledGoroutines(subject.patience()) - - clk := fake.NewClock(t0) - p := build(t, r, subject.New, clk) - if _, err, panicked := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC4")); panicked != nil || err != nil { - r.Fatalf("Print returned (%v, %v) on the reference weighing", err, panicked) - } - if _, panicked := statusQuietly(p, context.Background()); panicked != nil { - r.Fatalf("Status PANICKED: %v", panicked) - } - if _, panicked := selfTestQuietly(p, context.Background(), string(printing.SelfTestLabel)); panicked != nil { - r.Fatalf("SelfTest PANICKED: %v", panicked) - } - closeAndForget(p) - - if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { - r.Errorf("goroutines went from %d to %d and stayed there for %s after a whole job and a Close. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every driver takes its own away:\n%s", - before, goroutines(), subject.patience(), goroutineDump()) - } - if _, tickers := clk.Pending(); tickers > 0 { - r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) - } -} - -// checkOperatorMessagesAreFrench collects everything this driver puts in front of a human -// and holds it to the language that human speaks. -// -// It is not a style rule. « invalid parameter » on the Dépannage page is a volunteer -// standing in a shop at 9 a.m. with a queue behind them and a sentence they cannot act -// on; the whole taxonomy of §8.5 exists so that a message can name the offending value AND -// what to do about it, and it can only do that in French. -func checkOperatorMessagesAreFrench(t *testing.T, r reporter, subject Subject) { - r.Helper() - p := build(t, r, subject.New, fake.NewClock(t0)) - - read := func(what, sentence string) { - r.Helper() - if !looksFrench(sentence) { - r.Errorf("%s reads « %s ». It is shown to a volunteer, and every line of the administration and troubleshooting screens is French — identifiers in English, contents in French (§8.2)", what, sentence) - } - } - - unusable := subject.referenceJob(r, "01J9F2AC5") - unusable.Label.Barcode = unusableBarcode - if _, err, _ := printQuietly(p, context.Background(), unusable); err != nil { - if fault, ok := printErrorOf(r, "Print", err); ok { - read("the refusal of an unusable barcode", fault.Message) - } - } - if err, _ := selfTestQuietly(p, context.Background(), "mire"); err != nil { - if fault, ok := printErrorOf(r, "SelfTest", err); ok { - read("the refusal of an unknown self-test", fault.Message) - } - } - if status, _ := statusQuietly(p, context.Background()); status.Detail != "" { - read("Status().Detail", status.Detail) - } - - closeAndForget(p) - if _, err, _ := printQuietly(p, context.Background(), subject.referenceJob(r, "01J9F2AC6")); err != nil { - if fault, ok := printErrorOf(r, "Print", err); ok { - read("the refusal of a job after Close", fault.Message) - } - } -} - -// checkADeveloperMessageStaysEnglish is the other half of clause 17, and the reason the -// rule reads « identifiers in English, contents in French » rather than « everything in -// French ». -// -// No configuration file can produce a nil transport or an empty directory: those come from -// a composition root, so the only person who can ever read that sentence is the one -// writing Go. Answering it in French would be answering the wrong audience — and it would -// blur the one distinction that tells an operator's fault from a developer's. -func checkADeveloperMessageStaysEnglish(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.MissingCollaborator == nil { - r.Skipf("Subject.MissingCollaborator is nil: the suite cannot see what your constructor answers when a collaborator is left out. Supply it — it is one call with one field missing, and it is what keeps « identifiers in English, contents in French » from drifting into « everything in French » (§8.2)") - } - err := subject.MissingCollaborator(t) - if err == nil { - r.Fatalf("the constructor ACCEPTED a missing collaborator. An inconsistency stops the process at start-up, never with a customer standing at the scale (§11.3)") - } - var fault *ports.PrintError - if errors.As(err, &fault) { - r.Logf("the constructor answered a ports.PrintError; only its Message is read below") - err = errors.New(fault.Message) - } - if !looksEnglish(err.Error()) { - r.Errorf("the constructor answered « %v » to a missing collaborator. No configuration file can produce one, so that sentence is read by a developer and stays English; the French is for what a volunteer can act on (§8.2).\n"+ - "HOW THIS CHECK DECIDES, because it is lexical and it will refuse a message that IS English: it looks for one of the function words a sentence cannot avoid — the, this, is, are, of, with, not, from, cannot, must — and it finds none in a telegram. « %s: New: nil sink » is English and fails here; « %s: New: no sink; this driver writes its frames somewhere and the composition root owns the destination » passes and also says what to do.\n"+ - "So write a SENTENCE, not a label. That is not a formality: the constructor error is the whole diagnosis a developer gets, and « nil sink » names the field they are already looking at while saying nothing about what should have filled it", - err, subject.Name, subject.Name) - } -} - -// referenceJob is the weighing every check prints: the demonstration product of §8.6, at -// the mass §8.6 states, on the template this subject declared. -func (s Subject) referenceJob(r reporter, jobID string) ports.PrintJob { - r.Helper() - label, err := DemoLabel() - if err != nil { - r.Fatalf("conformance: the demonstration label could not be built: %v", err) - } - label.JobID = jobID - return ports.PrintJob{ - Label: label, - Template: s.template(), - Locale: string(domain.LocaleFrench), - } -} - -// delivered reports what reached the destination, and whether the subject can say at all. -// -// The second result is not a detail. « Zero labels came out » is the verdict of half the -// refusal checks and the failure of the nominal one, so a subject with no way to look must -// not be read as either: it simply does not strengthen that half of the verdict. -func (s Subject) delivered(t *testing.T, p ports.Printer) (count int, known bool) { - if s.Delivered == nil { - return 0, false - } - return s.Delivered(t, p), true -} - // build calls one of the subject's constructors and refuses a nil driver, which would // otherwise surface as a nil dereference three frames deeper. func build(t *testing.T, r reporter, constructor func(*testing.T, ports.Clock) ports.Printer, diff --git a/internal/printing/conformance/harness.go b/internal/printing/conformance/harness.go index e31ce43..9e39df8 100644 --- a/internal/printing/conformance/harness.go +++ b/internal/printing/conformance/harness.go @@ -104,8 +104,8 @@ func DemoLabel() (domain.Label, error) { // the pitch of the head it was drawn for — which is precisely the fault no byte of the // frame reports. func foreign(t domain.Template) domain.Template { - t.Name = t.Name + "_foreign" - t.Media.DotsPerMM = t.Media.DotsPerMM * 1.5 + t.Name += "_foreign" + t.Media.DotsPerMM *= 1.5 return t } diff --git a/internal/printing/conformance/subject.go b/internal/printing/conformance/subject.go new file mode 100644 index 0000000..43ebd6f --- /dev/null +++ b/internal/printing/conformance/subject.go @@ -0,0 +1,190 @@ +package conformance + +// This file is what a CONTRIBUTOR fills in: the Subject a driver is submitted as, and the +// five readings the checks take from it. Two fields are mandatory and every other one +// widens what the suite can reach, so the doc of each field says what supplying it buys +// and what leaving it out leaves unverified. + +import ( + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/station/ports" +) + +// Subject is the printer driver submitted to the suite. +// +// Two fields are mandatory, Name and New; every other one widens what the suite can +// reach. That asymmetry is the point: submitting a driver must cost one function +// literal, and a contributor who supplies nothing else still gets the checks that need +// no seam. +type Subject struct { + // Name is the driver under test, spelled as its registry key: "raster". It names the + // sub-test group, so it appears in every failure line, and Descriptor().ID must + // return it. + Name string + + // New returns ONE fresh driver, built and ready to print. + // + // It is called once per check — sometimes several times inside one — because a driver + // that has been closed is not a fair subject for the next clause. clk is the clock the + // driver MUST take its instants from: the suite hands over a fake one anchored far in + // the past and then reads the durations that come back. + // + // The driver it builds is COMPLETE, demonstration label included: wire + // conformance.DemoLabel into whatever option your driver takes for it, and the + // self-test checks then exercise a real print instead of a refusal. + New func(t *testing.T, clk ports.Clock) ports.Printer + + // SelfTests are the patterns of §8.6 this driver HONOURS, and the right value is the + // one its registry entry carries — `SelfTests: raster.Driver().SelfTests`. Handing over + // the same list twice is what makes the declaration verifiable instead of decorative: + // a pattern added to the entry and never implemented turns red here, and so does one + // implemented and never declared, which is a button the screen would not draw. + // + // NIL MEANS THE WHOLE CATALOGUE, which is the strongest reading and the one a + // production driver answers to: a subject that says nothing is held to all three. A + // driver that honours fewer says so, and the EMPTY slice — `[]printing.SelfTest{}` — + // is the assertion « none », never an omission. + SelfTests []printing.SelfTest + + // Template is the label layout the suite prints, and it must be one THIS driver + // accepts: its media resolution is compared against the DotsPerMM the descriptor + // declares before a single check runs. + // + // The zero value means domain.IdenticalTemplate(), the production label at 8 dots/mm, + // which is what the whole parc prints today. A driver for a 12 dots/mm head sets it. + Template domain.Template + + // JobAdvancesTheClock is how far ONE job moves the injected clock through the seam + // this subject supplies — the write delay a recording transport charges, the time a + // fake device takes. + // + // ZERO IS THE COMMON CASE AND THE STRONGEST FORM of clause 14: a seam that charges + // nothing means an honest driver reports a duration of EXACTLY zero, and a driver + // that timed itself on the wall clock cannot. + JobAdvancesTheClock time.Duration + + // Delivered reports how many complete labels reached the destination over the whole + // life of the driver New just built: the frames a transport accepted, the files that + // were written. + // + // It is what turns « Print returned nil » into an assertion, and « Print refused » + // into the proof that NOTHING was emitted before the refusal. Leave it nil when the + // destination cannot be read back — and know that those assertions then go + // unverified, and that the refusal checks are the weaker for it. + Delivered func(t *testing.T, p ports.Printer) int + + // Copies reports how many copies of the label the LAST job asked the destination for: + // the field of the frame, the number of files a preview wrote. + // + // It is only ever read when a job asking for MORE than the declared bound was + // ACCEPTED, which is the one case the returned error cannot settle. A driver that + // refuses out-of-range counts — the raster driver does — never needs it. + Copies func(t *testing.T, p ports.Printer) int + + // Short builds a driver whose destination accepts FEWER bytes than it is given, + // without an error of its own. That is WritePrinter's real behaviour, and clause 6 is + // the one a driver breaks by returning a receipt for a truncated frame. + // + // Leave it nil for a driver whose destination cannot be short — a preview writing a + // file has no such mode — and the check reports itself SKIPPED rather than passed. + Short func(t *testing.T, clk ports.Clock) ports.Printer + + // WithoutDemoLabel builds the SAME driver with no demonstration label wired into it, + // which is the configuration of a station whose composition root supplied none. + // + // Supply it: it is the clause whose breach puts an invented price on a printed label, + // and the driver that would do it is one `if` away from the one that refuses. + WithoutDemoLabel func(t *testing.T, clk ports.Clock) ports.Printer + + // MissingCollaborator calls your CONSTRUCTOR with a mandatory collaborator left out — + // no transport, no directory — and returns what it answered. + // + // It is the other half of clause 17: no configuration file can produce a nil + // transport, so that message is read by a developer and stays English, exactly as the + // messages a volunteer reads stay French. + MissingCollaborator func(t *testing.T) error + + // DrivesAHead declares that this driver addresses a REAL print head whose resolution + // is a hardware fact. + // + // At false, the geometry check reports itself SKIPPED and says what is left + // unverified. That is not a courtesy: `preview` writes a file at whatever pitch the + // job's template declares, so no template is foreign to it and refusing one would + // take a station in factory configuration out of the one thing it can still do. + DrivesAHead bool + + // Patience is how long the suite waits, ON THE WALL CLOCK, for a driver to do what it + // said it would: hand its bytes over, let its goroutines go. Zero means + // defaultPatience. + // + // Wall clock, in a repository where everything else runs on an injected fake, because + // what is bounded here is a goroutine leaving blocking OS I/O. Raise it for a + // destination that is genuinely slow; do not raise it to make a flaky driver pass. + Patience time.Duration +} + +// patience reports the wall-clock budget of one wait. +func (s Subject) patience() time.Duration { + if s.Patience > 0 { + return s.Patience + } + return defaultPatience +} + +// honours reports whether this subject declared that self-test. +// +// A nil declaration is the WHOLE catalogue and never « none »: a contributor who left the +// field out is held to the strongest clause, because the other reading would credit a +// silent subject with three checks nobody ran. +func (s Subject) honours(what printing.SelfTest) bool { + if s.SelfTests == nil { + return true + } + for _, declared := range s.SelfTests { + if declared == what { + return true + } + } + return false +} + +// template reports the layout the suite prints, which is the production label unless the +// subject named another. +func (s Subject) template() domain.Template { + if s.Template.Media.DotsPerMM > 0 { + return s.Template + } + return domain.IdenticalTemplate() +} + +// referenceJob is the weighing every check prints: the demonstration product of §8.6, at +// the mass §8.6 states, on the template this subject declared. +func (s Subject) referenceJob(r reporter, jobID string) ports.PrintJob { + r.Helper() + label, err := DemoLabel() + if err != nil { + r.Fatalf("conformance: the demonstration label could not be built: %v", err) + } + label.JobID = jobID + return ports.PrintJob{ + Label: label, + Template: s.template(), + Locale: string(domain.LocaleFrench), + } +} + +// delivered reports what reached the destination, and whether the subject can say at all. +// +// The second result is not a detail. « Zero labels came out » is the verdict of half the +// refusal checks and the failure of the nominal one, so a subject with no way to look must +// not be read as either: it simply does not strengthen that half of the verdict. +func (s Subject) delivered(t *testing.T, p ports.Printer) (count int, known bool) { + if s.Delivered == nil { + return 0, false + } + return s.Delivered(t, p), true +} diff --git a/internal/printing/field.go b/internal/printing/field.go new file mode 100644 index 0000000..97a2524 --- /dev/null +++ b/internal/printing/field.go @@ -0,0 +1,150 @@ +package printing + +// This file gets ONE field of the label into its box: the automatic reduction of §7.3, +// which descends by 0.1 mm from the nominal body to the floor of the element and only +// then truncates with an ellipsis. Nothing here is ever silent — the caller always hears +// what was reduced, what was cut and which characters no embedded font carries. + +import ( + "fmt" + "image" + "strings" + + "golang.org/x/image/math/fixed" + + "openscale/internal/domain" +) + +// drawElement sets one field of the label inside its box. +func (r *Rasterizer) drawElement(dst *image.Gray, g *domain.Template, e domain.Element, label domain.Label, w words) error { + text, err := fieldText(e.Field, label, w) + if err != nil { + return err + } + if e.Framed { + drawFrame(dst, elementBox(g, e)) + } + if text == "" { + return nil + } + + box := textBox(g, e) + if box.Dx() <= 0 { + return fmt.Errorf("printing: le champ %q dispose de %d dots de large", e.Field, box.Dx()) + } + p, err := r.place(g, e, text, fixed.I(box.Dx())) + if err != nil { + return err + } + + pen := fixed.I(box.Min.X) + if e.Align == domain.AlignRight { + pen = fixed.I(box.Max.X) - p.width + } + drawRuns(dst, p.runs, pen, baselineDots(g, e)) + + if p.truncated { + r.anomaly(codeFieldTruncated, + fmt.Sprintf("le champ %q ne tient pas dans sa boîte, il a été tronqué", e.Field), + fmt.Sprintf("« %s » réduit de %d à %d µm puis coupé à « %s » pour %d dots", + text, e.FontSizeUM, p.sizeUM, p.text, box.Dx())) + } + if len(p.missing) > 0 { + r.anomaly(codeGlyphMissing, + fmt.Sprintf("des caractères du champ %q ne sont dans aucune police embarquée", e.Field), + fmt.Sprintf("« %s » : %s", text, describeRunes(p.missing))) + } + return nil +} + +// place runs the automatic reduction of §7.3 on one field. +// +// It descends by 0.1 mm from the nominal body to the floor of the element, and only +// when the floor itself does not fit does it truncate with an ellipsis. It never +// returns "it does not fit": something is always drawn, and the caller always hears +// about it. +func (r *Rasterizer) place(g *domain.Template, e domain.Element, text string, maxWidth fixed.Int26_6) (placement, error) { + floor := reductionFloor(e) + for size := e.FontSizeUM; ; size -= reductionStepUM { + if size < floor { + size = floor + } + p, err := r.compose(g, e, text, size) + if err != nil { + return placement{}, err + } + if p.width <= maxWidth { + return p, nil + } + if size == floor { + return r.truncate(g, e, text, size, maxWidth) + } + } +} + +// compose measures one field at one body, in the weight that body implies. +func (r *Rasterizer) compose(g *domain.Template, e domain.Element, text string, sizeUM domain.Micrometers) (placement, error) { + bold := isBold(g.Media, e, sizeUM) + primary, err := r.fonts.Face(labelFont, int(sizeUM), g.Media.DotsPerMM, bold) + if err != nil { + return placement{}, err + } + fallback, err := r.fonts.Face(fallbackFont, int(sizeUM), g.Media.DotsPerMM, bold) + if err != nil { + return placement{}, err + } + runs, missing := splitRuns(text, primary, fallback) + return placement{ + runs: runs, + width: runsWidth(runs), + sizeUM: sizeUM, + bold: bold, + text: text, + missing: missing, + }, nil +} + +// truncate cuts a field with an ellipsis at the smallest body its element allows. +// +// LAST RESORT, and never silent: the caller journals a technical anomaly naming the +// field, the bodies tried and what was kept. Truncating without a word is how a +// product name starts printing half-eaten and nobody finds out until a customer +// complains at the till. +func (r *Rasterizer) truncate(g *domain.Template, e domain.Element, text string, sizeUM domain.Micrometers, maxWidth fixed.Int26_6) (placement, error) { + runes := []rune(text) + for n := len(runes); n >= 0; n-- { + kept := strings.TrimRight(string(runes[:n]), " ") + ellipsis + p, err := r.compose(g, e, kept, sizeUM) + if err != nil { + return placement{}, err + } + if p.width <= maxWidth { + p.truncated = true + return p, nil + } + } + // Not even the ellipsis fits. A box that narrow is a template fault, not a data + // one, but the rest of the label still prints. + p, err := r.compose(g, e, "", sizeUM) + if err != nil { + return placement{}, err + } + p.truncated = true + return p, nil +} + +// describeRunes names the characters no embedded font carries, by code point as well +// as by shape: a message a volunteer forwards to the producer has to survive being +// pasted into a mail client that cannot display them either. +func describeRunes(runes []rune) string { + seen := make(map[rune]bool, len(runes)) + var out []string + for _, r := range runes { + if seen[r] { + continue + } + seen[r] = true + out = append(out, fmt.Sprintf("U+%04X %q", r, string(r))) + } + return strings.Join(out, ", ") +} diff --git a/internal/printing/field_test.go b/internal/printing/field_test.go new file mode 100644 index 0000000..a5e651c --- /dev/null +++ b/internal/printing/field_test.go @@ -0,0 +1,288 @@ +package printing + +import ( + "image" + "strings" + "testing" + + "golang.org/x/image/math/fixed" + "openscale/internal/domain" +) + +// The tests of field.go: what each field of the label CARRIES, and the typographic weight +// its template asks of it. Decision A7 says what goes where; these tests hold it to that, +// line by line, on a two-tier label as on a single-tier one. + +// --- The weight of the 7 pt field, both ways ------------------------------- + +// TestTheSevenPointFieldKeepsTheWeightItsTemplateAsksFor tests the automatic switch +// to bold in BOTH directions, because a rule with a named exception needs both. +// +// weighing_identical carries auto_bold:false on secondary_total_price: the source +// (reports/EtataImprimer.report, label LabelAPayer) carries no FontWeight, so the +// solidarity price prints in REGULAR, and bolding it would be the one visible +// departure from the original — which A1 forbids (§7.2). A template that does not +// opt out gets the rule. +func TestTheSevenPointFieldKeepsTheWeightItsTemplateAsksFor(t *testing.T) { + label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) + + for _, c := range []struct { + name string + autoBold bool + wantBold bool + }{ + {"auto_bold false, le gabarit qui s'en dispense", false, false}, + {"auto_bold true, un gabarit qui ne se prononce pas", true, true}, + } { + t.Run(c.name, func(t *testing.T) { + r, _ := newTestRasterizer(t) + // Le gabarit NEUTRE depuis le 29/07/2026 : le prix solidaire de + // weighing_identical est passe au corps du prix adherent a la demande du + // commanditaire, il n'est donc plus sous les 20 dots et n'exerce plus la + // regle. Le neutre garde un champ de 7 pt, et la regle porte sur le moteur, + // pas sur un gabarit en particulier. + template := domain.NeutralSingleTemplate() + index := elementIndex(t, &template, domain.FieldSecondaryTotalPrice) + element := &template.Elements[index] + element.AutoBold = c.autoBold + + // The premise of the whole rule: this field IS under the 20 dot mark. + if em := template.Media.MilliDots(element.FontSizeUM); em >= autoBoldBelowDots*1000 { + t.Fatalf("l'em du champ vaut %d milli-dots : il n'est plus sous les %d dots "+ + "et ce test ne démontre plus rien", em, autoBoldBelowDots) + } + + img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + box := elementBox(&template, *element) + regular := fieldOnItsOwn(t, r, &template, *element, label, false) + bold := fieldOnItsOwn(t, r, &template, *element, label, true) + + if sameInk(regular, bold, box) { + t.Fatal("le gras et le maigre sont indiscernables sur ce champ : ce test ne " + + "pourrait pas rougir") + } + want, other, wanted := regular, bold, "maigre" + if c.wantBold { + want, other, wanted = bold, regular, "gras" + } + if !sameInk(img, want, box) { + t.Errorf("le champ n'est pas rendu en %s", wanted) + } + if sameInk(img, other, box) { + t.Errorf("le champ est rendu dans l'autre graisse que %s", wanted) + } + }) + } +} + +// fieldOnItsOwn draws one element, in a forced weight, on an otherwise blank label of +// the same geometry. Comparing a render against a DRAWING rather than against a pixel +// count is what makes "it is not bold" mean something. +func fieldOnItsOwn(t *testing.T, r *Rasterizer, g *domain.Template, e domain.Element, label domain.Label, bold bool) *image.Gray { + t.Helper() + forced := e + forced.Bold = bold + forced.AutoBold = false + + w, _ := wordsFor(domain.LocaleFrench) + text, err := fieldText(e.Field, label, w) + if err != nil { + t.Fatalf("contenu du champ %q : %v", e.Field, err) + } + box := textBox(g, e) + p, err := r.place(g, forced, text, fixed.I(box.Dx())) + if err != nil { + t.Fatalf("placement : %v", err) + } + img := image.NewGray(image.Rect(0, 0, + roundDots(g.Media, g.Media.WidthUM), roundDots(g.Media, g.Media.HeightUM))) + for i := range img.Pix { + img.Pix[i] = 0xFF + } + pen := fixed.I(box.Min.X) + if e.Align == domain.AlignRight { + pen = fixed.I(box.Max.X) - p.width + } + drawRuns(img, p.runs, pen, baselineDots(g, e)) + applyThreshold(img, img.Bounds(), textThreshold(g)) + return img +} + +// sameInk reports whether two renders carry the same dots inside a box. +func sameInk(a, b *image.Gray, box image.Rectangle) bool { + box = box.Intersect(a.Bounds()).Intersect(b.Bounds()) + for y := box.Min.Y; y < box.Max.Y; y++ { + for x := box.Min.X; x < box.Max.X; x++ { + if isInk(a, x, y) != isInk(b, x, y) { + return false + } + } + } + return true +} + +// elementIndex finds a field in a template, and fails rather than return -1. +func elementIndex(t *testing.T, g *domain.Template, field string) int { + t.Helper() + for i, e := range g.Elements { + if e.Field == field { + return i + } + } + t.Fatalf("le gabarit %s ne place pas le champ %q", g.Name, field) + return -1 +} + +// --- Mono-tarif ------------------------------------------------------------ + +// TestAMonoTierLabelDropsTheSecondaryPrice: the field DISAPPEARS, and no `if` in the +// rendering code says so — Element.Active does (§7.2). +func TestAMonoTierLabelDropsTheSecondaryPrice(t *testing.T) { + r, _ := newTestRasterizer(t) + template := domain.IdenticalTemplate() + secondary := template.Elements[elementIndex(t, &template, domain.FieldSecondaryTotalPrice)] + box := elementBox(&template, secondary) + + // The template stays valid for a station that runs one tier: rules 3, 5 and 8 are + // about what is actually inked, so a mono-tarif poste must not be refused a + // template because of a field it will never draw. + if faults := template.Validate(1); len(faults) != 0 { + t.Errorf("le gabarit est refusé en mono-tarif : %v", faults) + } + + mono, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.SingleTierRules()), + domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize mono-tarif : %v", err) + } + if _, inked := inkBounds(mono, box); inked { + t.Errorf("la boîte du prix solidaire %v est encrée sur une étiquette mono-tarif", box) + } + + // And the same box IS inked when the grid has two tiers — without which the test + // above would pass on a renderer that draws nothing at all. + dual, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), + domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize double tarif : %v", err) + } + if _, inked := inkBounds(dual, box); !inked { + t.Errorf("la boîte du prix solidaire %v est vide en double tarif", box) + } + + // The rest of the label is untouched: the barcode block is identical either way. + o := NewSymbolOptions(template) + if !sameInk(mono, dual, o.Bounds()) { + t.Error("le symbole diffère entre mono-tarif et double tarif") + } +} + +// --- What the fields carry (A7) -------------------------------------------- + +// TestTheFieldsCarryWhatArbitrationSevenSays reproduces the three strings §7.2 spells +// out, from the example the legacy help screen states: garlic at 5,32 €/kg, 1,236 kg. +func TestTheFieldsCarryWhatArbitrationSevenSays(t *testing.T) { + garlic := domain.Product{ + ID: "1", Name: "AIL", Reference: "0493021000003", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 532, + } + label := weighing(t, garlic, referenceMass, domain.LaCagetteRules()) + w, _ := wordsFor(domain.LocaleFrench) + + for _, c := range []struct{ field, want string }{ + {domain.FieldProductName, "AIL"}, + {domain.FieldQuantity, "1,236 kg"}, + {domain.FieldPrimaryUnitPrice, "A: 4,79 €/kg"}, + {domain.FieldSecondaryTotalPrice, "S: 6,58 €"}, + {domain.FieldPrimaryTotalPrice, "A: 5,92 €"}, + } { + got, err := fieldText(c.field, label, w) + if err != nil { + t.Fatalf("%s : %v", c.field, err) + } + if got != c.want { + t.Errorf("%s = %q, attendu %q", c.field, got, c.want) + } + } +} + +// TestThePriceSuffixComesFromTheProduct: « €/kg » is not a constant of the template +// (§7.2). Two products, two suffixes, the same template. +func TestThePriceSuffixComesFromTheProduct(t *testing.T) { + w, _ := wordsFor(domain.LocaleFrench) + for _, suffix := range []string{" €/kg", " € le litre", " € l'unité"} { + product := domain.Product{ + Name: "PRODUIT", Reference: "0493021000003", Mode: domain.ByWeight, + PriceSuffix: suffix, UnitPrice: 532, + } + label := weighing(t, product, referenceMass, domain.LaCagetteRules()) + got, err := fieldText(domain.FieldPrimaryUnitPrice, label, w) + if err != nil { + t.Fatalf("%v", err) + } + if want := "A: 4,79" + suffix; got != want { + t.Errorf("prix unitaire = %q, attendu %q", got, want) + } + } +} + +// TestAMonoTierGridPrintsNoPrefix: with one tier the Abbrev is empty, and a bare +// « : » in front of a price would introduce nothing. +func TestAMonoTierGridPrintsNoPrefix(t *testing.T) { + label := weighing(t, celeryRow, referenceMass, domain.SingleTierRules()) + w, _ := wordsFor(domain.LocaleFrench) + got, err := fieldText(domain.FieldPrimaryTotalPrice, label, w) + if err != nil { + t.Fatalf("%v", err) + } + if strings.Contains(got, ":") { + t.Errorf("prix mono-tarif = %q : il porte un préfixe alors que l'Abbrev est vide", got) + } + if want := "4,14 €"; got != want { + t.Errorf("prix mono-tarif = %q, attendu %q", got, want) + } +} + +// TestAProductSoldByUnitCountsItsUnits: "1 unité", "3 unités" — the legacy wording, +// kept. +func TestAProductSoldByUnitCountsItsUnits(t *testing.T) { + w, _ := wordsFor(domain.LocaleFrench) + for _, c := range []struct { + quantity int + want string + }{{1, "1 unité"}, {3, "3 unités"}} { + label := domain.Label{Mode: domain.ByUnit, Quantity: c.quantity} + got, err := fieldText(domain.FieldQuantity, label, w) + if err != nil { + t.Fatalf("%v", err) + } + if got != c.want { + t.Errorf("quantité pour %d = %q, attendu %q", c.quantity, got, c.want) + } + } +} + +// TestAnUnknownLocaleFallsBackToFrenchAndSaysSo: a customer is waiting; a label in +// French beats no label, and silence beats neither. +func TestAnUnknownLocaleFallsBackToFrenchAndSaysSo(t *testing.T) { + r, log := newTestRasterizer(t) + template := domain.IdenticalTemplate() + if _, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), + domain.Locale("nl-BE"), RenderOptions{}); err != nil { + t.Fatalf("Rasterize : %v", err) + } + if log.find(codeUnknownLocale) == nil { + t.Errorf("aucune anomalie %s journalisée (journalisé : %v)", codeUnknownLocale, log.codes()) + } + + // And the empty locale IS French: a PrintJob whose field was never filled must + // print a label, not a fault. + empty, _ := newTestRasterizer(t) + if _, err := empty.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), + "", RenderOptions{}); err != nil { + t.Fatalf("Rasterize avec une langue vide : %v", err) + } +} diff --git a/internal/printing/fonts_test.go b/internal/printing/fonts_test.go new file mode 100644 index 0000000..8bcc78e --- /dev/null +++ b/internal/printing/fonts_test.go @@ -0,0 +1,89 @@ +package printing + +import ( + "strings" + "testing" + + "openscale/internal/domain" +) + +// The tests of fonts.go: what Carlito cannot draw, the fallback font draws — and a +// character no embedded font carries is JOURNALLED rather than rendered silently as an +// empty box. + +// --- The fallback font ----------------------------------------------------- + +// TestTheFallbackDrawsWhatCarlitoCannot is not a theoretical case: 127 of the 355 +// names of testdata/catalog/flv.csv carry U+2665, and Carlito has no glyph for it. +func TestTheFallbackDrawsWhatCarlitoCannot(t *testing.T) { + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + defer library.Close() + + carlito, err := library.Face(labelFont, 3175, 8, false) + if err != nil { + t.Fatalf("fonte : %v", err) + } + dejavu, err := library.Face(fallbackFont, 3175, 8, false) + if err != nil { + t.Fatalf("fonte de repli : %v", err) + } + const heart = '♥' + if hasGlyph(carlito, heart) { + t.Fatalf("Carlito dessine désormais U+%04X : ce repli n'a plus d'objet et le "+ + "commentaire de fallbackFont est faux", heart) + } + if !hasGlyph(dejavu, heart) { + t.Fatalf("DejaVu Sans Condensed ne dessine pas U+%04X non plus : un tiers du "+ + "catalogue s'imprimerait avec un trou dans son nom", heart) + } + + runs, missing := splitRuns(lentilRow.Name, carlito, dejavu) + if len(missing) != 0 { + t.Errorf("caractères perdus dans « %s » : %s", lentilRow.Name, describeRunes(missing)) + } + if len(runs) < 3 { + t.Errorf("« %s » découpé en %d plages : le cœur doit en ouvrir une à lui seul", + lentilRow.Name, len(runs)) + } + used := 0 + for _, run := range runs { + if run.face == dejavu { + used++ + } + } + if used != 1 { + t.Errorf("%d plages tracées en repli, attendu 1", used) + } + + // A string Carlito covers entirely stays ONE run, and that is what keeps the + // measurement kerned — the acceptance criterion of ADR-020 depends on it. + whole, _ := splitRuns("A: 4,32 €/kg", carlito, dejavu) + if len(whole) != 1 { + t.Errorf("« A: 4,32 €/kg » découpé en %d plages : mesurée par morceaux, la chaîne "+ + "perdrait son crénage et le critère d'ADR-020 se mettrait à échouer", len(whole)) + } +} + +// TestACharacterNoEmbeddedFontCarriesIsJournalled: dropped rather than drawn as a +// box, but never dropped quietly. +func TestACharacterNoEmbeddedFontCarriesIsJournalled(t *testing.T) { + r, log := newTestRasterizer(t) + template := domain.IdenticalTemplate() + product := celeryRow + product.Name = "CELERI 天 SAF" // a Han character neither embedded font carries + + if _, err := r.Rasterize(&template, weighing(t, product, referenceMass, domain.LaCagetteRules()), + domain.LocaleFrench, RenderOptions{}); err != nil { + t.Fatalf("Rasterize : %v", err) + } + entry := log.find(codeGlyphMissing) + if entry == nil { + t.Fatalf("aucune anomalie %s journalisée (journalisé : %v)", codeGlyphMissing, log.codes()) + } + if !strings.Contains(entry.detail, "U+5929") { + t.Errorf("le détail %q ne nomme pas le point de code fautif", entry.detail) + } +} diff --git a/internal/printing/harness_test.go b/internal/printing/harness_test.go new file mode 100644 index 0000000..8f42493 --- /dev/null +++ b/internal/printing/harness_test.go @@ -0,0 +1,362 @@ +package printing + +import ( + "context" + "image" + "runtime" + "sync" + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// What the tests of this package build their subject with: the four AUTHENTIC catalog +// rows, the journal that is read back, the bench rasteriser, and the one computation path +// that turns a weighing into a label. +// +// Nothing here is invented: the products are rows of testdata/catalog/flv.csv, the mass is +// the one of vector T1, and the price grid is domain.LaCagetteRules (A7). + +// --- Fixtures -------------------------------------------------------------- + +// The three real catalog rows the tests draw with, transcribed from +// testdata/catalog/flv.csv. +var ( + // celeryRow is row id 1153. Its reference carries the 021 the reference barcode + // of §18 is built on, so the label of the goldens shows the very symbol + // symbol_test.go decodes. + celeryRow = domain.Product{ + ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, + CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, + } + // lentilRow is row id 20. Its name carries U+2665, which Carlito has no glyph + // for: it is what puts the documented fallback in a golden instead of in a + // comment. + lentilRow = domain.Product{ + ID: "20", Name: "LENTILLES VERTES ♥ *", Reference: "0493171000007", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 789, + CategoryCode: "V", Qualification: domain.Weighable, CSVLine: 20, + } + // tommeRow is row id 3511, the LONGEST name of the authentic file at 69 + // characters. It is what the automatic reduction cannot save. + tommeRow = domain.Product{ + ID: "3511", Name: "♥AA-LA TOMME DES CROQUANTS AFFINE A LA LIQUEUR DE NOIX DU PERIGORD-MV", + Reference: "0493773000009", Mode: domain.ByWeight, PriceSuffix: " €/kg", + UnitPrice: 3269, CategoryCode: "A", Qualification: domain.Weighable, CSVLine: 3511, + } + // riceRow is row id 3526. Measured at 298 dots for a 280 dot box, it overflows by + // enough to need the reduction and by little enough for the reduction to save it. + riceRow = domain.Product{ + ID: "3526", Name: "Riz long complet BIO - Agidra", Reference: "0493777000005", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 467, + CategoryCode: "V", Qualification: domain.Weighable, CSVLine: 3526, + } +) + +// referenceMass is the 1,236 kg of test vector T1. +const referenceMass = domain.Grams(1236) + +// logEntry is one line a render wrote to its journal. +type logEntry struct{ level, source, code, message, detail string } + +// recordingLog is the journal a test hands a Rasterizer, so that "it journals" is an +// assertion and not a hope. +type recordingLog struct{ entries []logEntry } + +func (l *recordingLog) Technical(level, source, code, message, detail string) { + l.entries = append(l.entries, logEntry{level, source, code, message, detail}) +} + +// find returns the first entry carrying a code, or nil. +func (l *recordingLog) find(code string) *logEntry { + for i := range l.entries { + if l.entries[i].code == code { + return &l.entries[i] + } + } + return nil +} + +// codes lists what was journalled, for a failure message that says what happened +// instead of what did not. +func (l *recordingLog) codes() []string { + out := make([]string, 0, len(l.entries)) + for _, e := range l.entries { + out = append(out, e.code) + } + return out +} + +// newTestRasterizer builds a renderer whose journal a test can read back. +func newTestRasterizer(t *testing.T) (*Rasterizer, *recordingLog) { + t.Helper() + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + t.Cleanup(func() { library.Close() }) + log := &recordingLog{} + r, err := NewRasterizer(library, log) + if err != nil { + t.Fatalf("rastériseur : %v", err) + } + return r, log +} + +// weighing builds the Label one weighing produces, through the single calculation +// path of the application. +func weighing(t *testing.T, product domain.Product, mass domain.Grams, rules domain.PricingRules) domain.Label { + t.Helper() + label, err := domain.Price(product, domain.Measurement{Gross: mass}, rules) + if err != nil { + t.Fatalf("Price : %v", err) + } + plan, err := domain.PlanFor(product.Reference) + if err != nil { + t.Fatalf("plan du code %s : %v", product.Reference, err) + } + code, err := domain.Generate(product.Reference, int64(mass), plan.PayloadWidth) + if err != nil { + t.Fatalf("Generate : %v", err) + } + label.Barcode = code + label.JobID = "test" + return label +} + +// referenceCode is the vector T1 of §18: garlic, reference 021, 1.236 kg. +const referenceCode = "0493021012365" + +// isInk reports whether a dot is burnt. The head is binary and DrawEAN13 thresholds +// its own HRI, so there is nothing in between to arbitrate. +func isInk(img *image.Gray, x, y int) bool { + return img.GrayAt(x, y).Y < 0x80 +} + +// inkBounds reports the tight box around the ink inside r. +func inkBounds(img *image.Gray, r image.Rectangle) (image.Rectangle, bool) { + box := image.Rectangle{Min: image.Pt(r.Max.X, r.Max.Y), Max: image.Pt(r.Min.X, r.Min.Y)} + found := false + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + if !isInk(img, x, y) { + continue + } + found = true + box.Min.X = min(box.Min.X, x) + box.Min.Y = min(box.Min.Y, y) + box.Max.X = max(box.Max.X, x+1) + box.Max.Y = max(box.Max.Y, y+1) + } + } + return box, found +} + +// inkColumnRange reports the first and last inked column of r. +func inkColumnRange(img *image.Gray, r image.Rectangle) (first, last int, ok bool) { + box, found := inkBounds(img, r) + if !found { + return 0, 0, false + } + return box.Min.X, box.Max.X - 1, true +} + +func abs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func colourName(black bool) string { + if black { + return "barre" + } + return "espace" +} + +// testEpoch is where every clock in this file starts. Any instant does; a fixed one +// keeps a failure message reproducible. +var testEpoch = time.Date(2026, 7, 25, 14, 32, 5, 0, time.UTC) + +// transientError and permanentError are the two answers of the §8.5 taxonomy this +// service actually branches on, built as the ONE type a driver raises since the two +// copies were merged into ports.PrintError. +// +// They are named for the POLICY rather than for the kind, because the policy is what +// these tests are about: only a transient failure is tried again, and the choice of +// KindTemplate for the permanent one is arbitrary — any kind but transient would do, +// which is exactly the property under test. +func transientError(message string) error { + return &ports.PrintError{Kind: ports.KindTransient, Op: "stub.Print", Message: message} +} + +func permanentError(message string) error { + return &ports.PrintError{Kind: ports.KindTemplate, Op: "stub.Print", Message: message} +} + +// stubPrinter is a ports.Printer that records what it was asked and answers what a test +// told it to answer. +type stubPrinter struct { + id string + + mu sync.Mutex + jobs []ports.PrintJob + selfTests []string + // failures is consumed one per Print, front first. An exhausted list means success. + failures []error + status ports.PrinterStatus + statusCalls int + closes int + // hangs makes Print block until the context is done, which is failure test 6. + hangs bool + // attempts receives one token per Print call, so a test can step through retries + // without polling anything. + attempts chan struct{} +} + +func newStub(id string) *stubPrinter { + return &stubPrinter{id: id, attempts: make(chan struct{}, 8), + status: ports.PrinterStatus{Health: ports.PrinterUnknown}} +} + +func (p *stubPrinter) Descriptor() domain.PrinterDescriptor { + return domain.PrinterDescriptor{ID: p.id, Label: "stub " + p.id} +} + +func (p *stubPrinter) Print(ctx context.Context, job ports.PrintJob) (ports.PrintReceipt, error) { + p.mu.Lock() + hangs := p.hangs + var err error + if len(p.failures) > 0 { + err, p.failures = p.failures[0], p.failures[1:] + } + if err == nil && !hangs { + p.jobs = append(p.jobs, job) + } + p.mu.Unlock() + + select { + case p.attempts <- struct{}{}: + default: + } + if hangs { + <-ctx.Done() + return ports.PrintReceipt{}, ctx.Err() + } + if err != nil { + return ports.PrintReceipt{}, err + } + return ports.PrintReceipt{JobID: job.Label.JobID, Bytes: 16310}, nil +} + +func (p *stubPrinter) Status(context.Context) ports.PrinterStatus { + p.mu.Lock() + defer p.mu.Unlock() + p.statusCalls++ + return p.status +} + +func (p *stubPrinter) SelfTest(_ context.Context, what string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.selfTests = append(p.selfTests, what) + if len(p.failures) > 0 { + var err error + err, p.failures = p.failures[0], p.failures[1:] + return err + } + return nil +} + +func (p *stubPrinter) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + p.closes++ + return nil +} + +func (p *stubPrinter) printed() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.jobs) +} + +func (p *stubPrinter) statusAsked() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.statusCalls +} + +func (p *stubPrinter) setStatus(s ports.PrinterStatus) { + p.mu.Lock() + defer p.mu.Unlock() + p.status = s +} + +// serviceUnderTest wires a service over one main printer, with the clock, the counter +// and the journal a test can look into. +type serviceUnderTest struct { + *Service + main *stubPrinter + fallback *stubPrinter + clock *fake.Clock + log *recordedLog + roll *memoryRoll +} + +func newService(t *testing.T, withFallback bool) *serviceUnderTest { + t.Helper() + s := &serviceUnderTest{ + main: newStub("main"), + clock: fake.NewClock(testEpoch), + log: &recordedLog{}, + roll: &memoryRoll{}, + } + options := ServiceOptions{ + Main: s.main, + MainName: "file « SATO WS408_2 »", + Clock: s.clock, + Roll: NewRollCounter(s.roll, 1000, s.log), + Log: s.log, + } + if withFallback { + s.fallback = newStub("fallback") + options.Fallback = s.fallback + options.FallbackName = "file « SATO WS408_3 »" + } + service, err := NewService(options) + if err != nil { + t.Fatalf("NewService : %v", err) + } + t.Cleanup(func() { _ = service.Close() }) + s.Service = service + return s +} + +// aJob is one label to print. Nothing in this package looks inside it. +func aJob() ports.PrintJob { + return ports.PrintJob{Label: domain.Label{JobID: "01J9F2ABC"}} +} + +// waitForClockWaiters blocks until at least n waits are registered on the injected +// clock. Advancing before the code under test has asked the clock for anything delivers +// the tick to nobody. +func waitForClockWaiters(t *testing.T, clk *fake.Clock, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if waiters, _ := clk.Pending(); waiters >= n { + return + } + if time.Now().After(deadline) { + t.Fatalf("%d attente(s) sur l'horloge injectée, attendu %d : le délai est mesuré ailleurs", + func() int { w, _ := clk.Pending(); return w }(), n) + } + runtime.Gosched() + } +} diff --git a/internal/printing/layout_test.go b/internal/printing/layout_test.go new file mode 100644 index 0000000..dfc7a8c --- /dev/null +++ b/internal/printing/layout_test.go @@ -0,0 +1,312 @@ +package printing + +import ( + "image" + "strings" + "testing" + + "golang.org/x/image/math/fixed" + "openscale/internal/domain" +) + +// The tests of layout.go: the automatic reduction of a name that is too long — how far it +// saves, where it truncates, and the floor it never goes below — then the one-dot frame and +// the baseline the two prices share. + +// --- The automatic reduction, both outcomes -------------------------------- + +// TestALongNameIsReducedThenTruncated covers the two outcomes of §7.3, on the two +// real names of the authentic catalog that produce them. +func TestALongNameIsReducedThenTruncated(t *testing.T) { + template := domain.IdenticalTemplate() + nameElement := template.Elements[elementIndex(t, &template, domain.FieldProductName)] + box := textBox(&template, nameElement) + + t.Run("réduit et tient", func(t *testing.T) { + r, log := newTestRasterizer(t) + p := placeName(t, r, &template, nameElement, riceRow.Name) + + if p.sizeUM >= nameElement.FontSizeUM { + t.Fatalf("« %s » tient au corps nominal de %d µm : ce nom ne fait plus déborder "+ + "la boîte et ce cas ne teste plus la réduction", + riceRow.Name, nameElement.FontSizeUM) + } + if p.sizeUM < reductionFloor(nameElement) { + t.Errorf("corps %d µm sous le plancher %d µm", p.sizeUM, reductionFloor(nameElement)) + } + if p.truncated { + t.Errorf("« %s » a été tronqué alors que la réduction suffisait", riceRow.Name) + } + if p.text != riceRow.Name { + t.Errorf("le texte rendu est %q, attendu le nom entier", p.text) + } + if p.width > fixed.I(box.Dx()) { + t.Errorf("%.2f dots après réduction pour une boîte de %d", float64(p.width)/64, box.Dx()) + } + if len(log.entries) != 0 { + t.Errorf("une réduction qui aboutit ne journalise rien, or : %v", log.codes()) + } + + // A REDUCED FIELD STAYS ON ITS LINE. The baseline comes from the NOMINAL body, + // so shrinking a name must not drop it a dot below the line it shares with the + // rest of the label. The reference is drawn at the same reduced body but on the + // baseline of the ELEMENT, which is exactly the property under test. + label := weighing(t, riceRow, referenceMass, domain.LaCagetteRules()) + img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + onItsLine := fieldOnItsOwn(t, r, &template, nameElement, label, false) + if !sameInk(img, onItsLine, elementBox(&template, nameElement)) { + t.Error("le nom réduit n'est pas tracé sur la ligne de base de son élément : " + + "réduire un champ le décale de la ligne qu'il partage avec les autres") + } + + t.Logf("« %s » : %d µm → %d µm, %.2f dots pour %d", + riceRow.Name, nameElement.FontSizeUM, p.sizeUM, float64(p.width)/64, box.Dx()) + }) + + t.Run("réduit, ne tient pas, tronqué et journalisé", func(t *testing.T) { + r, log := newTestRasterizer(t) + label := weighing(t, tommeRow, referenceMass, domain.LaCagetteRules()) + img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + + entry := log.find(codeFieldTruncated) + if entry == nil { + t.Fatalf("aucune anomalie %s journalisée : §7.3 interdit de sortir en silence "+ + "(journalisé : %v)", codeFieldTruncated, log.codes()) + } + if entry.source != "printer" { + t.Errorf("source %q, attendu « printer »", entry.source) + } + if !strings.Contains(entry.message, domain.FieldProductName) { + t.Errorf("le message %q ne nomme pas le champ fautif", entry.message) + } + if !strings.Contains(entry.detail, tommeRow.Name) { + t.Errorf("le détail %q ne cite pas le nom d'origine", entry.detail) + } + + p := placeName(t, r, &template, nameElement, tommeRow.Name) + if !p.truncated { + t.Fatal("le plus long nom du fichier authentique n'a pas été tronqué") + } + if p.sizeUM != reductionFloor(nameElement) { + t.Errorf("tronqué au corps %d µm et non au plancher %d µm : §7.3 tronque au "+ + "DERNIER corps valide", p.sizeUM, reductionFloor(nameElement)) + } + if !strings.HasSuffix(p.text, ellipsis) { + t.Errorf("le texte tronqué %q ne porte pas d'ellipse", p.text) + } + if p.width > fixed.I(box.Dx()) { + t.Errorf("%.2f dots après troncature pour une boîte de %d", float64(p.width)/64, box.Dx()) + } + // The ink really stops inside the box: a truncation that only shortened the + // string would still overflow if the drawing ignored it. + if ink, ok := inkBounds(img, image.Rect(0, 0, img.Bounds().Dx(), box.Max.Y)); ok && ink.Max.X > box.Max.X { + t.Errorf("l'encre du nom s'étend jusqu'en x=%d, au-delà de la boîte %v", ink.Max.X, box) + } + t.Logf("« %s » → « %s » au corps %d µm", tommeRow.Name, p.text, p.sizeUM) + }) +} + +// placeName runs the reduction on one product name, which is what the two cases +// above differ by. +func placeName(t *testing.T, r *Rasterizer, g *domain.Template, e domain.Element, name string) placement { + t.Helper() + p, err := r.place(g, e, name, fixed.I(textBox(g, e).Dx())) + if err != nil { + t.Fatalf("placement de « %s » : %v", name, err) + } + return p +} + +// TestTheReductionNeverGoesBelowTheHardFloor: hard rule 9 sets 1800 µm for every +// field, and an element that declares no floor of its own does not escape it. +func TestTheReductionNeverGoesBelowTheHardFloor(t *testing.T) { + if got := reductionFloor(domain.Element{FontSizeUM: 3175}); got != domain.MinFontSizeUM { + t.Errorf("plancher %d µm pour un élément muet, attendu %d", got, domain.MinFontSizeUM) + } + if got := reductionFloor(domain.Element{FontSizeUM: 3175, MinFontSizeUM: 2200}); got != 2200 { + t.Errorf("plancher %d µm, attendu le 2200 déclaré par l'élément", got) + } + // A floor below the hard one is not honoured; a floor above the nominal body + // cannot be, since the reduction only ever goes down. + if got := reductionFloor(domain.Element{FontSizeUM: 3175, MinFontSizeUM: 500}); got != domain.MinFontSizeUM { + t.Errorf("plancher %d µm pour un élément qui déclare 500 µm, attendu %d", + got, domain.MinFontSizeUM) + } + if got := reductionFloor(domain.Element{FontSizeUM: 2000, MinFontSizeUM: 3000}); got != 2000 { + t.Errorf("plancher %d µm au-dessus du corps nominal", got) + } +} + +// TestABoxTooNarrowEvenForAnEllipsisStillPrintsTheRestOfTheLabel: a box that narrow +// is a template fault, not a data one, and the customer still gets a barcode. +func TestABoxTooNarrowEvenForAnEllipsisStillPrintsTheRestOfTheLabel(t *testing.T) { + r, log := newTestRasterizer(t) + template := domain.IdenticalTemplate() + index := elementIndex(t, &template, domain.FieldProductName) + template.Elements[index].WidthUM = 400 // 3.2 dots: not even "…" fits + + img, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), + domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + if log.find(codeFieldTruncated) == nil { + t.Errorf("aucune anomalie %s journalisée (journalisé : %v)", codeFieldTruncated, log.codes()) + } + box := elementBox(&template, template.Elements[index]) + if _, inked := inkBounds(img, box); inked { + t.Errorf("la boîte %v, trop étroite pour une ellipse, a quand même reçu de l'encre", box) + } + // The label is still a label: the symbol is there, whole. + o := NewSymbolOptions(template) + if first, last, ok := inkColumnRange(img, image.Rect(o.XDots, o.YDots, + o.XDots+o.TotalWidthDots(), o.YDots+o.BarHeightDots)); !ok || last-first+1 != 223 { + t.Error("le symbole a souffert d'un champ texte impossible à placer") + } +} + +// TestTheEngineRefusesToInventContent: three questions a field cannot answer, and +// none of them is answered by a plausible-looking string. +func TestTheEngineRefusesToInventContent(t *testing.T) { + w, _ := wordsFor(domain.LocaleFrench) + + if _, err := fieldText("prix_au_metre", domain.Label{}, w); err == nil { + t.Error("un champ inconnu a produit un contenu : la liste des FieldID est fermée (règle 7)") + } + mono := weighing(t, celeryRow, referenceMass, domain.SingleTierRules()) + if _, err := fieldText(domain.FieldSecondaryTotalPrice, mono, w); err == nil { + t.Error("le prix secondaire a été produit sur une grille mono-tarif : il n'existe pas") + } + if _, err := fieldText(domain.FieldQuantity, domain.Label{Mode: domain.SaleMode(9)}, w); err == nil { + t.Error("un mode de vente inconnu a produit une quantité") + } + if _, err := fieldText(domain.FieldPrimaryUnitPrice, domain.Label{}, w); err == nil { + t.Error("un prix principal a été produit sans tarif") + } +} + +// --- The frame ------------------------------------------------------------- + +// TestTheFramedFieldCarriesItsOneDotRule: a framed field draws a rule of ONE dot. +// +// Le gabarit de production ne s'en sert plus — le commanditaire a fait retirer la +// bordure du prix au kilo le 29/07/2026 —, mais `Framed` reste une fonction du moteur +// qu'un gabarit peut demander. Le test la pose donc lui-meme au lieu de compter sur un +// livrable pour l'exercer : c'est ce qui l'empeche de disparaitre avec une decision de +// mise en page. +func TestTheFramedFieldCarriesItsOneDotRule(t *testing.T) { + r, _ := newTestRasterizer(t) + label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) + + framed := domain.IdenticalTemplate() + index := elementIndex(t, &framed, domain.FieldPrimaryUnitPrice) + framed.Elements[index].Framed = true + box := elementBox(&framed, framed.Elements[index]) + + bare := domain.IdenticalTemplate() + bare.Elements[index].Framed = false + + with, err := r.Rasterize(&framed, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + without, err := r.Rasterize(&bare, label, domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize sans cadre : %v", err) + } + + // The four sides are inked over their whole length -- a rule with a gap is not a + // rule. + for _, side := range []struct { + name string + r image.Rectangle + }{ + {"haut", image.Rect(box.Min.X, box.Min.Y, box.Max.X, box.Min.Y+1)}, + {"bas", image.Rect(box.Min.X, box.Max.Y-1, box.Max.X, box.Max.Y)}, + {"gauche", image.Rect(box.Min.X, box.Min.Y, box.Min.X+1, box.Max.Y)}, + {"droite", image.Rect(box.Max.X-1, box.Min.Y, box.Max.X, box.Max.Y)}, + } { + for y := side.r.Min.Y; y < side.r.Max.Y; y++ { + for x := side.r.Min.X; x < side.r.Max.X; x++ { + if !isInk(with, x, y) { + t.Fatalf("le côté %s du cadre n'est pas encré en (%d ; %d)", side.name, x, y) + } + } + } + } + + // It really is the FRAME that draws them, and not the text: the left column and + // the four corners are places a right-aligned price never reaches, and they are + // blank as soon as framed is false. + left := image.Rect(box.Min.X, box.Min.Y, box.Min.X+1, box.Max.Y) + if _, inked := inkBounds(without, left); inked { + t.Errorf("la colonne gauche %v est encrée alors que framed est faux : ce test "+ + "confondrait le cadre avec le texte", left) + } + for _, corner := range []image.Point{ + {X: box.Min.X, Y: box.Min.Y}, {X: box.Max.X - 1, Y: box.Min.Y}, + {X: box.Min.X, Y: box.Max.Y - 1}, {X: box.Max.X - 1, Y: box.Max.Y - 1}, + } { + if isInk(without, corner.X, corner.Y) { + t.Errorf("le coin %v est encré sans cadre", corner) + } + } + + // One dot thick, and no more: the column just inside the left rule carries + // nothing between the two horizontal rules. The text of a right-aligned price + // starts far to the right of it, so anything found there is a second stroke. + inner := image.Rect(box.Min.X+1, box.Min.Y+1, box.Min.X+2, box.Max.Y-1) + if _, inked := inkBounds(with, inner); inked { + t.Errorf("la colonne %v contre le trait du cadre est encrée : le cadre fait plus "+ + "d'un dot", inner) + } +} + +// --- The shared baseline --------------------------------------------------- + +// TestTheTwoPricesShareABaseline is the guard on emAscentPerMille. +// +// A template that aligns two DIFFERENT bodies does it by subtracting 750/1000 of each +// em from a common baseline. If this package ever placed baselines with another ascent +// — face.Metrics().Ascent, say, which is 0.952 em for Carlito — the two fields would +// print on two different lines. +// +// The alignment is BUILT HERE and no longer read off weighing_identical: since the +// 29/07/2026 its two prices share a body, on the commissioning party's request, so +// they share a baseline whatever this package believes about ascents. The guard has to +// outlive that layout decision, so it carries its own two bodies. +func TestTheTwoPricesShareABaseline(t *testing.T) { + template := domain.IdenticalTemplate() + primaryIndex := elementIndex(t, &template, domain.FieldPrimaryTotalPrice) + secondaryIndex := elementIndex(t, &template, domain.FieldSecondaryTotalPrice) + + // Le petit corps de l'ancienne mise en page, replace comme le gabarit le faisait : + // meme ligne de base, ascendante soustraite de chaque em. + const smallBody = domain.Micrometers(2_473) // 7 pt + const ascentPerMille = 750 + big := template.Elements[primaryIndex] + baseline := big.YUM + big.FontSizeUM*ascentPerMille/1000 + + template.Elements[secondaryIndex].FontSizeUM = smallBody + template.Elements[secondaryIndex].HeightUM = smallBody + template.Elements[secondaryIndex].YUM = baseline - smallBody*ascentPerMille/1000 + + primary := template.Elements[primaryIndex] + secondary := template.Elements[secondaryIndex] + if primary.FontSizeUM == secondary.FontSizeUM { + t.Fatal("les deux prix sont au même corps : ce test ne démontre plus rien") + } + a, b := baselineDots(&template, primary), baselineDots(&template, secondary) + if a != b { + t.Errorf("le prix adhérent est sur la ligne de base %d et le solidaire sur %d : "+ + "le moteur ne place pas ses lignes de base avec l'ascendante que le gabarit "+ + "a utilisée pour les aligner", a, b) + } + t.Logf("les deux prix partagent la ligne de base %d dots", a) +} diff --git a/internal/printing/preview/driver.go b/internal/printing/preview/driver.go index da02bb4..6bc6715 100644 --- a/internal/printing/preview/driver.go +++ b/internal/printing/preview/driver.go @@ -280,11 +280,6 @@ func (p *Printer) compose(job ports.PrintJob) (*image.Gray, error) { return img, nil } -// write encodes the bitmap twice and reports how many bytes reached the disk. -// -// The PNG goes down FIRST and the PDF second, and both are written before the receipt is -// returned: the pair is what a volunteer is asked to send, and half of it is a support -// request that has to be made twice. // refuseWhenClosed reports the refusal a closed preview owes its caller, or nil. // // It exists so that Print can answer that refusal before it draws anything, while write @@ -299,6 +294,11 @@ func (p *Printer) refuseWhenClosed(op string) error { return nil } +// write encodes the bitmap twice and reports how many bytes reached the disk. +// +// The PNG goes down FIRST and the PDF second, and both are written before the receipt is +// returned: the pair is what a volunteer is asked to send, and half of it is a support +// request that has to be made twice. func (p *Printer) write(ctx context.Context, op, jobID string, img *image.Gray, media domain.Media) (ports.PrintReceipt, error) { p.mu.Lock() defer p.mu.Unlock() diff --git a/internal/printing/raster/bounds_test.go b/internal/printing/raster/bounds_test.go new file mode 100644 index 0000000..7b46415 --- /dev/null +++ b/internal/printing/raster/bounds_test.go @@ -0,0 +1,296 @@ +package raster + +import ( + "errors" + "image" + "image/color" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// What only THIS side of the border can check: a bitmap that came from another template, a +// setting out of bounds, a media larger than its field, a head outside the graphic field, a +// number of copies that overflows. +// +// All of them are REFUSED rather than quietly brought back into range: a clamped value +// prints a label askew, and nobody will know why. + +// --- What only this side of the border can check ---------------------------- + +// TestABitmapFromAnotherTemplateIsRefused is the check that stops a render made for +// one geometry from being sent as another. +// +// A frame declares its own dimensions, so the printer would accept a bitmap one dot +// short without a word and shift every row that follows. The encapsulation cannot make +// this check: it never sees the template, only the bitmap. +func TestABitmapFromAnotherTemplateIsRefused(t *testing.T) { + template := domain.IdenticalTemplate() + width, height := mediaDots(template.Media) + + for _, c := range []struct { + name string + width, height int + }{ + {"un dot de moins en largeur", width - 1, height}, + {"un dot de moins en hauteur", width, height - 1}, + {"un dot de plus en hauteur", width, height + 1}, + {"le gabarit neutre d'une autre tête", 480, 305}, + } { + t.Run(c.name, func(t *testing.T) { + img := image.NewGray(image.Rect(0, 0, c.width, c.height)) + _, err := encodeLabel(img, template, DefaultSettings(), WS408(), 1) + printError(t, err, ports.KindTemplate, "vient d'un autre gabarit") + }) + } + + t.Run("aucun rendu", func(t *testing.T) { + _, err := encodeLabel(nil, template, DefaultSettings(), WS408(), 1) + printError(t, err, ports.KindTemplate, "aucun rendu") + }) +} + +// TestTheThreeAdjustmentsEachChangeTheFrame is the assertion behind the three +// buttons: each one really reaches the printer, and none of them touches the ink. +func TestTheThreeAdjustmentsEachChangeTheFrame(t *testing.T) { + template, rendered := productionLabel(t) + + base := DefaultSettings() + darker := base + darker.Darkness = base.Darkness + 1 + faster := base + faster.Speed = base.Speed + 1 + shifted := base + // LEFT and not right: since the media was corrected to the paper the label really + // runs on, the ink fills its width to within a sixth of a dot, so the only + // horizontal dot still available goes the other way. + shifted.OffsetXDots = -1 + + frames := map[string]string{} + for name, settings := range map[string]Settings{ + "réglages livrés": base, + "noircissement+1": darker, + "vitesse+1": faster, + "décalage -1 dot": shifted, + } { + frame, err := encodeLabel(rendered, template, settings, WS408(), 1) + if err != nil { + t.Fatalf("%s : %v", name, err) + } + for other, seen := range frames { + if seen == string(frame) { + t.Errorf("« %s » et « %s » produisent la MÊME trame : un des trois boutons ne va nulle part", name, other) + } + } + frames[name] = string(frame) + + // The adjustments are printer settings: the dots are the same in all four. + compareDots(t, rendered, readFrame(t, frame).graphic) + } + + // And the offset really is the ±dddd of , sign included: V carries the vertical + // axis, H the horizontal one, which is the reverse of the (x;y) of everything else. + lifted := base + lifted.OffsetXDots, lifted.OffsetYDots = -1, -3 + frame, err := encodeLabel(rendered, template, lifted, WS408(), 1) + if err != nil { + t.Fatalf("décalage (-1;-3) : %v", err) + } + if got := commandArg(readFrame(t, frame), "A3"); got != "V-0003H-0001" { + t.Errorf("%s pour un décalage x=-1 y=-3, « V-0003H-0001 » attendu", got) + } +} + +// TestAnAdjustmentOutOfBoundsIsRefusedRatherThanClamped is the second half of the +// same promise. A darkness of 7 quietly turned into 5 is a knob that no longer moves, +// and the volunteer keeps turning it. +func TestAnAdjustmentOutOfBoundsIsRefusedRatherThanClamped(t *testing.T) { + template, rendered := productionLabel(t) + + for _, c := range []struct { + name string + settings func(Settings) Settings + kind ports.Kind + says string + }{ + {"noircissement 0", func(s Settings) Settings { s.Darkness = 0; return s }, ports.KindConfig, "noircissement 0"}, + {"noircissement 6", func(s Settings) Settings { s.Darkness = 6; return s }, ports.KindConfig, "noircissement 6"}, + {"vitesse 1", func(s Settings) Settings { s.Speed = 1; return s }, ports.KindConfig, "vitesse 1"}, + {"vitesse 7", func(s Settings) Settings { s.Speed = 7; return s }, ports.KindConfig, "vitesse 7"}, + {"décalage horizontal hors média", func(s Settings) Settings { s.OffsetXDots = 999; return s }, ports.KindConfig, "décalage horizontal"}, + {"décalage vertical hors média", func(s Settings) Settings { s.OffsetYDots = -999; return s }, ports.KindConfig, "décalage vertical"}, + } { + t.Run(c.name, func(t *testing.T) { + frame, err := encodeLabel(rendered, template, c.settings(DefaultSettings()), WS408(), 1) + if frame != nil { + t.Errorf("%d octets rendus alors que le réglage est refusé : rien ne doit partir", len(frame)) + } + printError(t, err, c.kind, c.says) + }) + } +} + +// TestTheOffsetIsBoundedByTheInkOfTheShippedLabel is the refusal a volunteer reads +// while nudging a label back into place: it names the range instead of saying no. +// +// The two ranges are WRITTEN OUT rather than asked of the code under test. They are a +// measurement of the shipped weighing_identical on the 280 × 200 media the L0 bench +// established, and stating them here is what makes this a test of the rule rather than +// a restatement of it. If the drawing of §7.3 moves, these numbers move, and a +// volunteer's arrows stop where they stopped yesterday. +// +// THE HORIZONTAL RANGE IS ONE DOT WIDE, and that is a consequence of the correction, +// not of the drawing: the text boxes are 34 978 um across on 35 000 um of paper, so +// there are 22 um of slack — a sixth of a dot. While the media was declared 40 mm the +// arrows had five millimetres to play with, and they were playing with paper that does +// not exist. Widening that range means narrowing the label, which is a decision about +// the drawing and is recorded as an open question rather than taken here. +func TestTheOffsetIsBoundedByTheInkOfTheShippedLabel(t *testing.T) { + const ( + lowX, highX = -1, 0 + lowY, highY = -3, 1 + ) + template, rendered := productionLabel(t) + + for _, c := range []struct { + name string + s Settings + refused bool + }{ + {"décalage nul", offsetXY(0, 0), false}, + {"dernier dot admis à droite", offsetXY(highX, 0), false}, + {"un dot de trop à droite", offsetXY(highX+1, 0), true}, + {"dernier dot admis à gauche", offsetXY(lowX, 0), false}, + {"un dot de trop à gauche", offsetXY(lowX-1, 0), true}, + {"dernier dot admis en bas", offsetXY(0, highY), false}, + {"un dot de trop en bas", offsetXY(0, highY+1), true}, + {"dernier dot admis en haut", offsetXY(0, lowY), false}, + {"un dot de trop en haut", offsetXY(0, lowY-1), true}, + } { + t.Run(c.name, func(t *testing.T) { + _, err := encodeLabel(rendered, template, c.s, WS408(), 1) + if !c.refused { + if err != nil { + t.Fatalf("décalage refusé alors qu'il tient sur le média : %v", err) + } + return + } + printError(t, err, ports.KindConfig, "admet de") + // The message names the range, because « décalage invalide » tells a + // volunteer nothing about which key to press next. + var refusal *ports.PrintError + errors.As(err, &refusal) + if !strings.Contains(refusal.Message, "-1") && !strings.Contains(refusal.Message, "+40") && + !strings.Contains(refusal.Message, "-3") && !strings.Contains(refusal.Message, "+3") { + t.Errorf("le message ne nomme aucune borne : %s", refusal.Message) + } + }) + } +} + +func offsetXY(x, y int) Settings { + s := DefaultSettings() + s.OffsetXDots, s.OffsetYDots = x, y + return s +} + +// TestInvertBitsFlipsThePolarityAndNothingElse covers the last SBPL unknown (§8.3), +// as this driver's boolean maps onto it. +func TestInvertBitsFlipsThePolarityAndNothingElse(t *testing.T) { + template, rendered := productionLabel(t) + settings := DefaultSettings() + settings.InvertBits = true + + direct, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), 1) + if err != nil { + t.Fatalf("encodage direct : %v", err) + } + inverted, err := encodeLabel(rendered, template, settings, WS408(), 1) + if err != nil { + t.Fatalf("encodage inversé : %v", err) + } + if string(direct) == string(inverted) { + t.Fatal("invert_bits ne change rien : le réglage qui lève la dernière inconnue SBPL ne va nulle part") + } + + // Read back through the inverse polarity: every dot comes home. + read := readFrame(t, inverted) + for y := 0; y < read.graphic.Bounds().Dy(); y++ { + for x := 0; x < read.graphic.Bounds().Dx(); x++ { + shade := uint8(0x00) + if read.graphic.GrayAt(x, y).Y < inkThreshold { + shade = 0xFF + } + read.graphic.SetGray(x, y, color.Gray{Y: shade}) + } + } + compareDots(t, rendered, read.graphic) +} + +// TestTheGraphicBlockRefusesWhatTheHeadCannotTake covers the two hard limits of the +// command: the width of the head, and 600 dots per block. +func TestTheGraphicBlockRefusesWhatTheHeadCannotTake(t *testing.T) { + for _, c := range []struct { + name string + media domain.Media + says string + }{ + {"plus large que la tête", domain.Media{WidthUM: 120_000, HeightUM: 25_400, DotsPerMM: 8}, "maximum 104"}, + {"plus haut qu'un bloc ", domain.Media{WidthUM: 40_000, HeightUM: 80_000, DotsPerMM: 8}, "maximum 600"}, + } { + t.Run(c.name, func(t *testing.T) { + template := domain.Template{Name: "essai", Media: c.media} + width, height := mediaDots(c.media) + img := image.NewGray(image.Rect(0, 0, width, height)) + _, err := encodeLabel(img, template, DefaultSettings(), WS408(), 1) + printError(t, err, ports.KindTemplate, c.says) + }) + } +} + +// TestAMediaBiggerThanItsFieldIsRefused covers the four digits of . It is not a +// theoretical bound: it is the first command after , so it is what refuses a +// template built in the wrong unit before anything else has a chance to. +func TestAMediaBiggerThanItsFieldIsRefused(t *testing.T) { + media := domain.Media{WidthUM: 1_250_000, HeightUM: 25_400, DotsPerMM: 8} // 10 000 dots + template := domain.Template{Name: "essai", Media: media} + width, height := mediaDots(media) + if width != 10_000 { + t.Fatalf("le média d'essai fait %d dots de large, 10 000 attendus", width) + } + _, err := encodeLabel(image.NewGray(image.Rect(0, 0, width, height)), template, + DefaultSettings(), WS408(), 1) + printError(t, err, ports.KindConfig, "hors bornes SBPL") +} + +// TestAHeadOutsideTheGraphicFieldIsRefused covers the border the other way: the model +// this driver declares travels into the encapsulation as a width, and a head that +// no three-digit field can express must be refused rather than truncated. +func TestAHeadOutsideTheGraphicFieldIsRefused(t *testing.T) { + template, rendered := productionLabel(t) + _, err := encodeLabel(rendered, template, DefaultSettings(), Head{DotsPerMM: 8, MaxWidthBytes: 1000}, 1) + printError(t, err, ports.KindConfig, "hors bornes du champ ") +} + +// TestTheCopyCountIsBoundedByItsField covers , six digits. +func TestTheCopyCountIsBoundedByItsField(t *testing.T) { + template, rendered := productionLabel(t) + + for _, copies := range []int{0, -1, MaxCopies + 1} { + if _, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), copies); err == nil { + t.Errorf("%d exemplaires acceptés : le champ porte six chiffres", copies) + } + } + for _, copies := range []int{1, 2, MaxCopies} { + frame, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), copies) + if err != nil { + t.Fatalf("%d exemplaires : %v", copies, err) + } + read := readFrame(t, frame) + if got := commandArg(read, "Q"); atoi(t, got) != copies { + t.Errorf("%s pour %d exemplaires", got, copies) + } + } +} diff --git a/internal/printing/raster/frame_test.go b/internal/printing/raster/frame_test.go index 6fc86be..354bcf6 100644 --- a/internal/printing/raster/frame_test.go +++ b/internal/printing/raster/frame_test.go @@ -3,17 +3,10 @@ package raster import ( "crypto/sha256" "encoding/hex" - "errors" "image" - "image/color" "os" "path/filepath" - "strings" "testing" - - "openscale/internal/domain" - "openscale/internal/printing" - "openscale/internal/station/ports" ) // The tests of what this driver does with the encapsulation of §8.3 — which, since the @@ -21,251 +14,17 @@ import ( // // # THE FRAME IS READ BACK BY A SECOND IMPLEMENTATION // -// readFrame below is a parser written from the ten lines of §8.3 and from nothing -// else: it knows the length of every field, it refuses a command it does not know, and -// it rebuilds the bitmap out of the hexadecimal. It shares NO code with the encoder, -// which is what makes "the image survives the round trip" an assertion instead of a -// tautology — the same method the 95 modules of the symbol were checked with, by a +// readFrame, in sbplreader_test.go, is a parser written from the ten lines of §8.3 and +// from nothing else: it knows the length of every field, it refuses a command it does not +// know, and it rebuilds the bitmap out of the hexadecimal. It shares NO code with the +// encoder, which is what makes "the image survives the round trip" an assertion instead of +// a tautology — the same method the 95 modules of the symbol were checked with, by a // decoder carrying its own tables (§16.1). // // A length check would prove nothing here: a frame of the right size with the rows // swapped, the bits reversed or the padding inverted is a label that comes out wrong, // and every one of those defects survives a byte count. -// inkThreshold is where a grey becomes a burnt dot, read exactly as -// printing.applyThreshold reads it: STRICTLY below is ink. It is the reader's own -// spelling of the rule, deliberately not borrowed from the encoder it checks. -const inkThreshold = 0x80 - -// The three real catalog rows are not needed here — one is. celeryRow is row 1153 of -// testdata/catalog/flv.csv, and its reference carries the 021 that the reference -// barcode of §18 and the symbol golden of §7.4 are both built on. -var celeryRow = domain.Product{ - ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, - CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, -} - -// referenceMass is the 1,236 kg of test vector T1. -const referenceMass = domain.Grams(1236) - -// --- The second implementation --------------------------------------------- - -// sbplCommand is one command as the reader found it: its name, and the characters -// that followed. -type sbplCommand struct { - name string - arg string -} - -// sbplFrame is what an independent reader makes of one frame. -type sbplFrame struct { - commands []sbplCommand - // graphic is the bitmap rebuilt from the block, widthBytes × 8 dots wide: the - // frame declares its width in BYTES, so the reader cannot know where the padding - // starts and does not pretend to. - graphic *image.Gray - widthBytes int - height int -} - -// argumentLengths is the length of the argument of every command of §8.3, in -// characters. is absent: its argument is measured from its own header. -// -// The order of the keys is irrelevant; the order of the SCAN is not, which is why -// commandNames below is longest-first. -var argumentLengths = map[string]int{ - "A": 0, // start of job - "A1": 8, // aaaabbbb - "A3": 12, // V±ddddH±dddd - "#E": 1, // darkness - "CS": 1, // speed - "%": 1, // rotation - "V": 4, // vertical position - "H": 4, // horizontal position - "Q": 6, // copies - "Z": 0, // end of job -} - -// commandNames is the scan order: a name that is the prefix of another comes AFTER -// it, or "A1" would be read as "A" followed by the digit 1. -var commandNames = []string{"A1", "A3", "GH", "#E", "CS", "A", "V", "H", "Q", "Z", "%"} - -// readFrame parses a whole frame the way the printer would have to, and fails the -// test on anything it cannot account for. -func readFrame(t *testing.T, frame []byte) sbplFrame { - t.Helper() - out := sbplFrame{} - // STX … ETX is the transmission framing of the standard protocol, which the L0 - // bench proved a real WS408 requires. It wraps the commands rather than being one, - // so it comes off before the scan — and its ABSENCE is a failure, because a frame - // without it prints nothing and takes the printer down until it is power-cycled. - rest := string(frame) - if !strings.HasPrefix(rest, "\x02") || !strings.HasSuffix(rest, "\x03") { - t.Fatalf("la trame n'est pas encadrée par STX … ETX : %#x … %#x", frame[0], frame[len(frame)-1]) - } - rest = rest[1 : len(rest)-1] - for len(rest) > 0 { - if rest[0] != 0x1B { - t.Fatalf("octet %#x hors commande : toute la trame est faite de commandes précédées d'ESC", rest[0]) - } - rest = rest[1:] - name := "" - for _, candidate := range commandNames { - if strings.HasPrefix(rest, candidate) { - name = candidate - break - } - } - if name == "" { - t.Fatalf("commande inconnue à « %.10q » : §8.3 en déclare onze, pas douze", rest) - } - rest = rest[len(name):] - - if name == "GH" { - var arg string - arg, rest = readGraphic(t, rest, &out) - out.commands = append(out.commands, sbplCommand{name: name, arg: arg}) - continue - } - size := argumentLengths[name] - if len(rest) < size { - t.Fatalf("commande %s tronquée : %d caractères d'argument, %d attendus", name, len(rest), size) - } - out.commands = append(out.commands, sbplCommand{name: name, arg: rest[:size]}) - rest = rest[size:] - } - return out -} - -// readGraphic reads a Hbbbccc block and rebuilds its bitmap. -func readGraphic(t *testing.T, rest string, out *sbplFrame) (header, remainder string) { - t.Helper() - if len(rest) < 6 { - t.Fatalf("en-tête tronqué : « %q »", rest) - } - header = rest[:6] - widthBytes := atoi(t, header[:3]) - // BOTH fields of abbbccc are byte counts — the SBPL reference says so of b and - // of c in the same words, and the L0 bench confirmed it on paper: a height sent in - // dots makes the printer wait for eight times the data and hang. The block - // therefore carries widthBytes × heightBytes × 8 bytes, and the rows past the real - // bitmap are the padding that fills the last byte. - heightBytes := atoi(t, header[3:]) - height := heightBytes * 8 - payload := 2 * widthBytes * height - if len(rest) < 6+payload { - t.Fatalf("bloc de %d × %d octets annoncé, %d caractères hexa présents sur %d attendus", - widthBytes, heightBytes, len(rest)-6, payload) - } - out.widthBytes, out.height = widthBytes, height - out.graphic = decodeGraphic(t, rest[6:6+payload], widthBytes, height) - return header, rest[6+payload:] -} - -// decodeGraphic turns the hexadecimal back into dots: a set bit is ink, most -// significant bit first, each row padded to a whole byte. -func decodeGraphic(t *testing.T, hexa string, widthBytes, height int) *image.Gray { - t.Helper() - img := image.NewGray(image.Rect(0, 0, widthBytes*8, height)) - for y := 0; y < height; y++ { - for b := 0; b < widthBytes; b++ { - value := hexByte(t, hexa[2*(y*widthBytes+b):2*(y*widthBytes+b)+2]) - for bit := 0; bit < 8; bit++ { - shade := uint8(0xFF) - if value&(0x80>>bit) != 0 { - shade = 0x00 - } - img.SetGray(b*8+bit, y, color.Gray{Y: shade}) - } - } - } - return img -} - -// readerAlphabet is the alphabet this reader accepts, and it is SPELLED OUT rather -// than borrowed from the encoder. -// -// A reader that shared the constant would accept whatever the encoder decided to -// write, lower case included, and the assertion "the frame is in the case the manual -// prints" would quietly stop existing. That case is check 2 of the bench list: a -// firmware that only takes one of them prints nothing, with no message. -const readerAlphabet = "0123456789ABCDEF" - -// hexByte reads two hexadecimal characters, and refuses lower case. -func hexByte(t *testing.T, pair string) byte { - t.Helper() - value := 0 - for _, c := range pair { - digit := strings.IndexRune(readerAlphabet, c) - if digit < 0 { - t.Fatalf("caractère %q hors de l'alphabet hexadécimal majuscule %q", c, readerAlphabet) - } - value = value<<4 | digit - } - return byte(value) -} - -// atoi reads a fixed-width decimal field of the frame. -func atoi(t *testing.T, field string) int { - t.Helper() - value := 0 - for _, c := range field { - if c < '0' || c > '9' { - t.Fatalf("champ numérique %q : %q n'est pas un chiffre", field, c) - } - value = value*10 + int(c-'0') - } - return value -} - -// --- Fixtures --------------------------------------------------------------- - -// productionLabel renders the label of the reference weighing through the single -// calculation path of the application: celery, 1,236 kg, the La Cagette grid. -func productionLabel(t *testing.T) (domain.Template, *image.Gray) { - t.Helper() - template := domain.IdenticalTemplate() - label, err := domain.Price(celeryRow, domain.Measurement{Gross: referenceMass}, domain.LaCagetteRules()) - if err != nil { - t.Fatalf("Price : %v", err) - } - plan, err := domain.PlanFor(celeryRow.Reference) - if err != nil { - t.Fatalf("plan du code %s : %v", celeryRow.Reference, err) - } - code, err := domain.Generate(celeryRow.Reference, int64(referenceMass), plan.PayloadWidth) - if err != nil { - t.Fatalf("Generate : %v", err) - } - label.Barcode = code - label.JobID = "01J9F2ABC" - - img, err := printing.Rasterize(&template, label, domain.LocaleFrench, printing.RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - return template, img -} - -// checkerboard is the synthetic bitmap that catches what a photograph of a label -// cannot: one dot on, one dot off, so that a bit reversed, a row swapped or a byte -// shifted shows up immediately. -func checkerboard(t domain.Template) *image.Gray { - width, height := mediaDots(t.Media) - img := image.NewGray(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - shade := uint8(0xFF) - if (x+y)%2 == 0 { - shade = 0x00 - } - img.SetGray(x, y, color.Gray{Y: shade}) - } - } - return img -} - // --- The frame a real printer accepted -------------------------------------- // The frame of the reference weighing, byte for byte. @@ -383,49 +142,6 @@ func TestTheEncapsulatedBitmapIsReadBackDotForDot(t *testing.T) { } } -// compareDots holds two bitmaps to bit-for-bit equality over the width AND height of -// the original, and requires the padding of the frame — the columns that fill the last -// byte of a row, and the rows that fill the last byte of the height — to be bare label. -// -// Both paddings burn the same way if they are forgotten: a black band down the right -// edge, or across the bottom, of every label the station prints. -func compareDots(t *testing.T, want, got *image.Gray) { - t.Helper() - paddedHeight := (want.Bounds().Dy() + 7) / 8 * 8 - if got.Bounds().Dy() != paddedHeight { - t.Fatalf("%d lignes relues, %d écrites complétées à %d — compte sa hauteur en octets", - got.Bounds().Dy(), want.Bounds().Dy(), paddedHeight) - } - if got.Bounds().Dx() < want.Bounds().Dx() { - t.Fatalf("%d colonnes relues, %d écrites : la trame en a perdu", - got.Bounds().Dx(), want.Bounds().Dx()) - } - for y := 0; y < want.Bounds().Dy(); y++ { - for x := 0; x < want.Bounds().Dx(); x++ { - inked := want.GrayAt(want.Bounds().Min.X+x, want.Bounds().Min.Y+y).Y < inkThreshold - back := got.GrayAt(x, y).Y < inkThreshold - if inked != back { - t.Fatalf("dot (%d;%d) : encré=%v à l'aller, %v au retour", x, y, inked, back) - } - } - } - for y := 0; y < want.Bounds().Dy(); y++ { - for x := want.Bounds().Dx(); x < got.Bounds().Dx(); x++ { - if got.GrayAt(x, y).Y < inkThreshold { - t.Fatalf("le bit de bourrage (%d;%d) est encré : la fin de ligne imprimerait une bande noire", x, y) - } - } - } - for y := want.Bounds().Dy(); y < got.Bounds().Dy(); y++ { - for x := 0; x < got.Bounds().Dx(); x++ { - if got.GrayAt(x, y).Y < inkThreshold { - t.Fatalf("la ligne de bourrage (%d;%d) est encrée : le bas de l'étiquette "+ - "imprimerait une bande noire", x, y) - } - } - } -} - // TestTheFrameIsTheElevenCommandsInTheOrderOfTheManual freezes what this driver asks // the encapsulation for, field by field. // @@ -498,318 +214,3 @@ func TestTheVolumeOfOneLabelIsTheOneTheDocumentAnnounces(t *testing.T) { "avec les dix autres commandes %d", len(frame), 14_000+encapsulationBytes, encapsulationBytes) } } - -// --- What only this side of the border can check ---------------------------- - -// TestABitmapFromAnotherTemplateIsRefused is the check that stops a render made for -// one geometry from being sent as another. -// -// A frame declares its own dimensions, so the printer would accept a bitmap one dot -// short without a word and shift every row that follows. The encapsulation cannot make -// this check: it never sees the template, only the bitmap. -func TestABitmapFromAnotherTemplateIsRefused(t *testing.T) { - template := domain.IdenticalTemplate() - width, height := mediaDots(template.Media) - - for _, c := range []struct { - name string - width, height int - }{ - {"un dot de moins en largeur", width - 1, height}, - {"un dot de moins en hauteur", width, height - 1}, - {"un dot de plus en hauteur", width, height + 1}, - {"le gabarit neutre d'une autre tête", 480, 305}, - } { - t.Run(c.name, func(t *testing.T) { - img := image.NewGray(image.Rect(0, 0, c.width, c.height)) - _, err := encodeLabel(img, template, DefaultSettings(), WS408(), 1) - printError(t, err, ports.KindTemplate, "vient d'un autre gabarit") - }) - } - - t.Run("aucun rendu", func(t *testing.T) { - _, err := encodeLabel(nil, template, DefaultSettings(), WS408(), 1) - printError(t, err, ports.KindTemplate, "aucun rendu") - }) -} - -// TestTheThreeAdjustmentsEachChangeTheFrame is the assertion behind the three -// buttons: each one really reaches the printer, and none of them touches the ink. -func TestTheThreeAdjustmentsEachChangeTheFrame(t *testing.T) { - template, rendered := productionLabel(t) - - base := DefaultSettings() - darker := base - darker.Darkness = base.Darkness + 1 - faster := base - faster.Speed = base.Speed + 1 - shifted := base - // LEFT and not right: since the media was corrected to the paper the label really - // runs on, the ink fills its width to within a sixth of a dot, so the only - // horizontal dot still available goes the other way. - shifted.OffsetXDots = -1 - - frames := map[string]string{} - for name, settings := range map[string]Settings{ - "réglages livrés": base, - "noircissement+1": darker, - "vitesse+1": faster, - "décalage -1 dot": shifted, - } { - frame, err := encodeLabel(rendered, template, settings, WS408(), 1) - if err != nil { - t.Fatalf("%s : %v", name, err) - } - for other, seen := range frames { - if seen == string(frame) { - t.Errorf("« %s » et « %s » produisent la MÊME trame : un des trois boutons ne va nulle part", name, other) - } - } - frames[name] = string(frame) - - // The adjustments are printer settings: the dots are the same in all four. - compareDots(t, rendered, readFrame(t, frame).graphic) - } - - // And the offset really is the ±dddd of , sign included: V carries the vertical - // axis, H the horizontal one, which is the reverse of the (x;y) of everything else. - lifted := base - lifted.OffsetXDots, lifted.OffsetYDots = -1, -3 - frame, err := encodeLabel(rendered, template, lifted, WS408(), 1) - if err != nil { - t.Fatalf("décalage (-1;-3) : %v", err) - } - if got := commandArg(readFrame(t, frame), "A3"); got != "V-0003H-0001" { - t.Errorf("%s pour un décalage x=-1 y=-3, « V-0003H-0001 » attendu", got) - } -} - -// TestAnAdjustmentOutOfBoundsIsRefusedRatherThanClamped is the second half of the -// same promise. A darkness of 7 quietly turned into 5 is a knob that no longer moves, -// and the volunteer keeps turning it. -func TestAnAdjustmentOutOfBoundsIsRefusedRatherThanClamped(t *testing.T) { - template, rendered := productionLabel(t) - - for _, c := range []struct { - name string - settings func(Settings) Settings - kind ports.Kind - says string - }{ - {"noircissement 0", func(s Settings) Settings { s.Darkness = 0; return s }, ports.KindConfig, "noircissement 0"}, - {"noircissement 6", func(s Settings) Settings { s.Darkness = 6; return s }, ports.KindConfig, "noircissement 6"}, - {"vitesse 1", func(s Settings) Settings { s.Speed = 1; return s }, ports.KindConfig, "vitesse 1"}, - {"vitesse 7", func(s Settings) Settings { s.Speed = 7; return s }, ports.KindConfig, "vitesse 7"}, - {"décalage horizontal hors média", func(s Settings) Settings { s.OffsetXDots = 999; return s }, ports.KindConfig, "décalage horizontal"}, - {"décalage vertical hors média", func(s Settings) Settings { s.OffsetYDots = -999; return s }, ports.KindConfig, "décalage vertical"}, - } { - t.Run(c.name, func(t *testing.T) { - frame, err := encodeLabel(rendered, template, c.settings(DefaultSettings()), WS408(), 1) - if frame != nil { - t.Errorf("%d octets rendus alors que le réglage est refusé : rien ne doit partir", len(frame)) - } - printError(t, err, c.kind, c.says) - }) - } -} - -// TestTheOffsetIsBoundedByTheInkOfTheShippedLabel is the refusal a volunteer reads -// while nudging a label back into place: it names the range instead of saying no. -// -// The two ranges are WRITTEN OUT rather than asked of the code under test. They are a -// measurement of the shipped weighing_identical on the 280 × 200 media the L0 bench -// established, and stating them here is what makes this a test of the rule rather than -// a restatement of it. If the drawing of §7.3 moves, these numbers move, and a -// volunteer's arrows stop where they stopped yesterday. -// -// THE HORIZONTAL RANGE IS ONE DOT WIDE, and that is a consequence of the correction, -// not of the drawing: the text boxes are 34 978 um across on 35 000 um of paper, so -// there are 22 um of slack — a sixth of a dot. While the media was declared 40 mm the -// arrows had five millimetres to play with, and they were playing with paper that does -// not exist. Widening that range means narrowing the label, which is a decision about -// the drawing and is recorded as an open question rather than taken here. -func TestTheOffsetIsBoundedByTheInkOfTheShippedLabel(t *testing.T) { - const ( - lowX, highX = -1, 0 - lowY, highY = -3, 1 - ) - template, rendered := productionLabel(t) - - for _, c := range []struct { - name string - s Settings - refused bool - }{ - {"décalage nul", offsetXY(0, 0), false}, - {"dernier dot admis à droite", offsetXY(highX, 0), false}, - {"un dot de trop à droite", offsetXY(highX+1, 0), true}, - {"dernier dot admis à gauche", offsetXY(lowX, 0), false}, - {"un dot de trop à gauche", offsetXY(lowX-1, 0), true}, - {"dernier dot admis en bas", offsetXY(0, highY), false}, - {"un dot de trop en bas", offsetXY(0, highY+1), true}, - {"dernier dot admis en haut", offsetXY(0, lowY), false}, - {"un dot de trop en haut", offsetXY(0, lowY-1), true}, - } { - t.Run(c.name, func(t *testing.T) { - _, err := encodeLabel(rendered, template, c.s, WS408(), 1) - if !c.refused { - if err != nil { - t.Fatalf("décalage refusé alors qu'il tient sur le média : %v", err) - } - return - } - printError(t, err, ports.KindConfig, "admet de") - // The message names the range, because « décalage invalide » tells a - // volunteer nothing about which key to press next. - var refusal *ports.PrintError - errors.As(err, &refusal) - if !strings.Contains(refusal.Message, "-1") && !strings.Contains(refusal.Message, "+40") && - !strings.Contains(refusal.Message, "-3") && !strings.Contains(refusal.Message, "+3") { - t.Errorf("le message ne nomme aucune borne : %s", refusal.Message) - } - }) - } -} - -func offsetXY(x, y int) Settings { - s := DefaultSettings() - s.OffsetXDots, s.OffsetYDots = x, y - return s -} - -// TestInvertBitsFlipsThePolarityAndNothingElse covers the last SBPL unknown (§8.3), -// as this driver's boolean maps onto it. -func TestInvertBitsFlipsThePolarityAndNothingElse(t *testing.T) { - template, rendered := productionLabel(t) - settings := DefaultSettings() - settings.InvertBits = true - - direct, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), 1) - if err != nil { - t.Fatalf("encodage direct : %v", err) - } - inverted, err := encodeLabel(rendered, template, settings, WS408(), 1) - if err != nil { - t.Fatalf("encodage inversé : %v", err) - } - if string(direct) == string(inverted) { - t.Fatal("invert_bits ne change rien : le réglage qui lève la dernière inconnue SBPL ne va nulle part") - } - - // Read back through the inverse polarity: every dot comes home. - read := readFrame(t, inverted) - for y := 0; y < read.graphic.Bounds().Dy(); y++ { - for x := 0; x < read.graphic.Bounds().Dx(); x++ { - shade := uint8(0x00) - if read.graphic.GrayAt(x, y).Y < inkThreshold { - shade = 0xFF - } - read.graphic.SetGray(x, y, color.Gray{Y: shade}) - } - } - compareDots(t, rendered, read.graphic) -} - -// TestTheGraphicBlockRefusesWhatTheHeadCannotTake covers the two hard limits of the -// command: the width of the head, and 600 dots per block. -func TestTheGraphicBlockRefusesWhatTheHeadCannotTake(t *testing.T) { - for _, c := range []struct { - name string - media domain.Media - says string - }{ - {"plus large que la tête", domain.Media{WidthUM: 120_000, HeightUM: 25_400, DotsPerMM: 8}, "maximum 104"}, - {"plus haut qu'un bloc ", domain.Media{WidthUM: 40_000, HeightUM: 80_000, DotsPerMM: 8}, "maximum 600"}, - } { - t.Run(c.name, func(t *testing.T) { - template := domain.Template{Name: "essai", Media: c.media} - width, height := mediaDots(c.media) - img := image.NewGray(image.Rect(0, 0, width, height)) - _, err := encodeLabel(img, template, DefaultSettings(), WS408(), 1) - printError(t, err, ports.KindTemplate, c.says) - }) - } -} - -// TestAMediaBiggerThanItsFieldIsRefused covers the four digits of . It is not a -// theoretical bound: it is the first command after , so it is what refuses a -// template built in the wrong unit before anything else has a chance to. -func TestAMediaBiggerThanItsFieldIsRefused(t *testing.T) { - media := domain.Media{WidthUM: 1_250_000, HeightUM: 25_400, DotsPerMM: 8} // 10 000 dots - template := domain.Template{Name: "essai", Media: media} - width, height := mediaDots(media) - if width != 10_000 { - t.Fatalf("le média d'essai fait %d dots de large, 10 000 attendus", width) - } - _, err := encodeLabel(image.NewGray(image.Rect(0, 0, width, height)), template, - DefaultSettings(), WS408(), 1) - printError(t, err, ports.KindConfig, "hors bornes SBPL") -} - -// TestAHeadOutsideTheGraphicFieldIsRefused covers the border the other way: the model -// this driver declares travels into the encapsulation as a width, and a head that -// no three-digit field can express must be refused rather than truncated. -func TestAHeadOutsideTheGraphicFieldIsRefused(t *testing.T) { - template, rendered := productionLabel(t) - _, err := encodeLabel(rendered, template, DefaultSettings(), Head{DotsPerMM: 8, MaxWidthBytes: 1000}, 1) - printError(t, err, ports.KindConfig, "hors bornes du champ ") -} - -// TestTheCopyCountIsBoundedByItsField covers , six digits. -func TestTheCopyCountIsBoundedByItsField(t *testing.T) { - template, rendered := productionLabel(t) - - for _, copies := range []int{0, -1, MaxCopies + 1} { - if _, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), copies); err == nil { - t.Errorf("%d exemplaires acceptés : le champ porte six chiffres", copies) - } - } - for _, copies := range []int{1, 2, MaxCopies} { - frame, err := encodeLabel(rendered, template, DefaultSettings(), WS408(), copies) - if err != nil { - t.Fatalf("%d exemplaires : %v", copies, err) - } - read := readFrame(t, frame) - if got := commandArg(read, "Q"); atoi(t, got) != copies { - t.Errorf("%s pour %d exemplaires", got, copies) - } - } -} - -// commandArg returns the argument of the first command of that name. -func commandArg(f sbplFrame, name string) string { - for _, c := range f.commands { - if c.name == name { - return c.arg - } - } - return "" -} - -// printError holds an error to being the typed, French, correctly classified failure -// the taxonomy of §8.5 promises. -func printError(t *testing.T, err error, kind ports.Kind, says string) { - t.Helper() - if err == nil { - t.Fatalf("aucune erreur, une *ports.PrintError{%s} était attendue", kind) - } - var printErr *ports.PrintError - if !errors.As(err, &printErr) { - t.Fatalf("erreur %T (%v), *ports.PrintError attendue : le service d'impression décide des réessais sur le Kind", err, err) - } - if printErr.Kind != kind { - t.Errorf("Kind = %s, attendu %s — c'est lui qui décide du message client et des réessais (§8.5) : %v", - printErr.Kind, kind, err) - } - if !strings.Contains(printErr.Message, says) { - t.Errorf("message « %s » : il devait contenir « %s ». Il est lu par un bénévole sur l'écran d'administration", - printErr.Message, says) - } - if printErr.Op == "" { - t.Error("Op est vide : c'est ce qui situe la panne dans un rapport de bug") - } - if kind != ports.KindTransient && printErr.Retryable() { - t.Errorf("Kind %s déclaré réessayable : réessayer deux fois une faute de gabarit, c'est deux secondes "+ - "de plus devant un écran qui n'imprimera pas", kind) - } -} diff --git a/internal/printing/raster/harness_test.go b/internal/printing/raster/harness_test.go new file mode 100644 index 0000000..c100f2b --- /dev/null +++ b/internal/printing/raster/harness_test.go @@ -0,0 +1,164 @@ +package raster + +import ( + "errors" + "image" + "image/color" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/station/ports" +) + +// What the tests of this package share: the production label, the ink threshold, the +// authentic catalog row, and the three readers that turn a failure into a sentence — which +// dot differs, which argument a command carries, what a print error says. +// +// They used to live in frame_test.go and already served four files. + +// inkThreshold is where a grey becomes a burnt dot, read exactly as +// printing.applyThreshold reads it: STRICTLY below is ink. It is the reader's own +// spelling of the rule, deliberately not borrowed from the encoder it checks. +const inkThreshold = 0x80 + +// The three real catalog rows are not needed here — one is. celeryRow is row 1153 of +// testdata/catalog/flv.csv, and its reference carries the 021 that the reference +// barcode of §18 and the symbol golden of §7.4 are both built on. +var celeryRow = domain.Product{ + ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, + CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, +} + +// referenceMass is the 1,236 kg of test vector T1. +const referenceMass = domain.Grams(1236) + +// --- Fixtures --------------------------------------------------------------- + +// productionLabel renders the label of the reference weighing through the single +// calculation path of the application: celery, 1,236 kg, the La Cagette grid. +func productionLabel(t *testing.T) (domain.Template, *image.Gray) { + t.Helper() + template := domain.IdenticalTemplate() + label, err := domain.Price(celeryRow, domain.Measurement{Gross: referenceMass}, domain.LaCagetteRules()) + if err != nil { + t.Fatalf("Price : %v", err) + } + plan, err := domain.PlanFor(celeryRow.Reference) + if err != nil { + t.Fatalf("plan du code %s : %v", celeryRow.Reference, err) + } + code, err := domain.Generate(celeryRow.Reference, int64(referenceMass), plan.PayloadWidth) + if err != nil { + t.Fatalf("Generate : %v", err) + } + label.Barcode = code + label.JobID = "01J9F2ABC" + + img, err := printing.Rasterize(&template, label, domain.LocaleFrench, printing.RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + return template, img +} + +// checkerboard is the synthetic bitmap that catches what a photograph of a label +// cannot: one dot on, one dot off, so that a bit reversed, a row swapped or a byte +// shifted shows up immediately. +func checkerboard(t domain.Template) *image.Gray { + width, height := mediaDots(t.Media) + img := image.NewGray(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + shade := uint8(0xFF) + if (x+y)%2 == 0 { + shade = 0x00 + } + img.SetGray(x, y, color.Gray{Y: shade}) + } + } + return img +} + +// compareDots holds two bitmaps to bit-for-bit equality over the width AND height of +// the original, and requires the padding of the frame — the columns that fill the last +// byte of a row, and the rows that fill the last byte of the height — to be bare label. +// +// Both paddings burn the same way if they are forgotten: a black band down the right +// edge, or across the bottom, of every label the station prints. +func compareDots(t *testing.T, want, got *image.Gray) { + t.Helper() + paddedHeight := (want.Bounds().Dy() + 7) / 8 * 8 + if got.Bounds().Dy() != paddedHeight { + t.Fatalf("%d lignes relues, %d écrites complétées à %d — compte sa hauteur en octets", + got.Bounds().Dy(), want.Bounds().Dy(), paddedHeight) + } + if got.Bounds().Dx() < want.Bounds().Dx() { + t.Fatalf("%d colonnes relues, %d écrites : la trame en a perdu", + got.Bounds().Dx(), want.Bounds().Dx()) + } + for y := 0; y < want.Bounds().Dy(); y++ { + for x := 0; x < want.Bounds().Dx(); x++ { + inked := want.GrayAt(want.Bounds().Min.X+x, want.Bounds().Min.Y+y).Y < inkThreshold + back := got.GrayAt(x, y).Y < inkThreshold + if inked != back { + t.Fatalf("dot (%d;%d) : encré=%v à l'aller, %v au retour", x, y, inked, back) + } + } + } + for y := 0; y < want.Bounds().Dy(); y++ { + for x := want.Bounds().Dx(); x < got.Bounds().Dx(); x++ { + if got.GrayAt(x, y).Y < inkThreshold { + t.Fatalf("le bit de bourrage (%d;%d) est encré : la fin de ligne imprimerait une bande noire", x, y) + } + } + } + for y := want.Bounds().Dy(); y < got.Bounds().Dy(); y++ { + for x := 0; x < got.Bounds().Dx(); x++ { + if got.GrayAt(x, y).Y < inkThreshold { + t.Fatalf("la ligne de bourrage (%d;%d) est encrée : le bas de l'étiquette "+ + "imprimerait une bande noire", x, y) + } + } + } +} + +// commandArg returns the argument of the first command of that name. +func commandArg(f sbplFrame, name string) string { + for _, c := range f.commands { + if c.name == name { + return c.arg + } + } + return "" +} + +// printError holds an error to being the typed, French, correctly classified failure +// the taxonomy of §8.5 promises. +func printError(t *testing.T, err error, kind ports.Kind, says string) { + t.Helper() + if err == nil { + t.Fatalf("aucune erreur, une *ports.PrintError{%s} était attendue", kind) + } + var printErr *ports.PrintError + if !errors.As(err, &printErr) { + t.Fatalf("erreur %T (%v), *ports.PrintError attendue : le service d'impression décide des réessais sur le Kind", err, err) + } + if printErr.Kind != kind { + t.Errorf("Kind = %s, attendu %s — c'est lui qui décide du message client et des réessais (§8.5) : %v", + printErr.Kind, kind, err) + } + if !strings.Contains(printErr.Message, says) { + t.Errorf("message « %s » : il devait contenir « %s ». Il est lu par un bénévole sur l'écran d'administration", + printErr.Message, says) + } + if printErr.Op == "" { + t.Error("Op est vide : c'est ce qui situe la panne dans un rapport de bug") + } + if kind != ports.KindTransient && printErr.Retryable() { + t.Errorf("Kind %s déclaré réessayable : réessayer deux fois une faute de gabarit, c'est deux secondes "+ + "de plus devant un écran qui n'imprimera pas", kind) + } +} diff --git a/internal/printing/raster/raster.go b/internal/printing/raster/raster.go index 9a5b427..300cbef 100644 --- a/internal/printing/raster/raster.go +++ b/internal/printing/raster/raster.go @@ -42,18 +42,19 @@ // moves. package raster +// This file is the driver itself: what it is given, what it declares, how one job +// becomes a frame, and how it lets go. What it answers about the device is in +// status.go, the three patterns of §8.6 and its answer to them in selftest.go, and +// what New refuses at construction in settings.go. + import ( "context" - "errors" "fmt" "image" - "strings" "sync" - "time" "openscale/internal/domain" "openscale/internal/printing" - "openscale/internal/printing/sbpl" "openscale/internal/station/ports" ) @@ -76,14 +77,6 @@ const Label = "Imprimante d'étiquettes (rendu image)" // Three copies is how a fourth diverges: one of them is renamed, the other two keep // answering the old word, and the button on the screen stops reaching the driver. -// statusBudget is how long a status probe waits for the printer to say something -// (§8.5, level N3). It bounds a transport that answers, never a weighing. -// -// It stays HERE, next to the driver that spends it, where the ENQ byte and the reading -// of the answer have gone to internal/printing/sbpl: how long a station is willing to -// wait is a policy of the station, and the SATO reference states no such delay. -const statusBudget = 500 * time.Millisecond - // Options is everything the driver needs, and nothing it could invent. type Options struct { // Transport is the byte layer that carries the frame to the head: a Windows queue @@ -315,7 +308,6 @@ func (p *Printer) copiesFor(job ports.PrintJob) (int, error) { return job.Copies, nil } -// send hands one finished frame to the transport and times it on the injected clock. // refuseWhenClosed reports the refusal a closed printer owes its caller, or nil. // // It exists so that Print can answer that refusal before it draws anything, while send @@ -330,6 +322,7 @@ func (p *Printer) refuseWhenClosed(op string) error { return nil } +// send hands one finished frame to the transport and times it on the injected clock. func (p *Printer) send(ctx context.Context, op, jobID string, frame []byte) (ports.PrintReceipt, error) { p.mu.Lock() defer p.mu.Unlock() @@ -357,107 +350,6 @@ func (p *Printer) send(ctx context.Context, op, jobID string, frame []byte) (por return ports.PrintReceipt{JobID: jobID, Bytes: n, Duration: elapsed}, nil } -// Status reports what the device says about itself, or an honest admission that we do -// not know (§8.5). -// -// It NEVER turns a silence into a failure. A transport that cannot ask answers -// PrinterUnknown, which is the whole reason that value exists, and a printer that -// stays quiet for 500 ms is reported as unknown rather than faulted: confirming a -// physical event with a probe that does not observe it is exactly the mistake -// important-7 removed. -func (p *Printer) Status(ctx context.Context) ports.PrinterStatus { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed { - return ports.PrinterStatus{Health: ports.PrinterUnknown, - Detail: "l'imprimante a été fermée par le poste."} - } - - answer, err := p.transport.Query(ctx, sbpl.Enquiry(), statusBudget) - switch { - case errors.Is(err, ports.ErrUnsupported): - return ports.PrinterStatus{Health: ports.PrinterUnknown, - Detail: fmt.Sprintf("état inconnu : %s ne peut pas interroger l'imprimante. "+ - "L'étiquette part, la réponse ne revient pas.", p.transport.Describe())} - case err != nil: - return ports.PrinterStatus{Health: ports.PrinterFaulted, Raw: answer, - Detail: fmt.Sprintf("l'imprimante n'a pas répondu (%s) : %v", p.transport.Describe(), err)} - case len(answer) == 0: - return ports.PrinterStatus{Health: ports.PrinterUnknown, - Detail: fmt.Sprintf("état inconnu : %s n'a rien renvoyé en %s.", - p.transport.Describe(), statusBudget)} - } - // The frame IS decoded now — the L0 bench captured it — but only far enough to name - // a FAULT. Read sbpl.FaultOfStatusFrame for why readiness is still never claimed. - if fault, named := sbpl.FaultOfStatusFrame(answer); named { - return ports.PrinterStatus{Health: fault.Health, Raw: answer, - Detail: fmt.Sprintf("%s (%s).", fault.Reason, p.transport.Describe())} - } - - // Any non-empty answer means the printer is ALIVE (§8.5) — and alive is not ready. - // PrinterReady means « answered and has NOTHING TO REPORT » (ports), and this - // printer does not answer that question when it is idle: see sbpl.FaultOfStatusFrame. - // Claiming ready here would be a green light on /readyz over an empty roll (§14.5). - // - // The detail names the TRANSPORT and stops there. What the answer means is the - // aggregation's sentence (internal/printing/status.go), and a driver that spelled - // the same conclusion produced it twice in a row on the troubleshooting screen. - return ports.PrinterStatus{Health: ports.PrinterUnknown, Raw: answer, - Detail: p.transport.Describe()} -} - -// SelfTest prints one built-in pattern (§8.6). -// -// `alignment` and `ruler` are drawn HERE, from the geometry of the template in -// service: they carry no business data, so nothing has to be injected for them to -// exist. `label` is a real label and therefore needs a real Label, which only the -// station can build. -func (p *Printer) SelfTest(ctx context.Context, what string) error { - switch printing.SelfTest(what) { - case printing.SelfTestLabel: - return p.printDemoLabel(ctx) - case printing.SelfTestAlignment: - return p.printPattern(ctx, "raster.SelfTest.alignment", alignmentPattern(p.template)) - case printing.SelfTestRuler: - return p.printPattern(ctx, "raster.SelfTest.ruler", rulerPattern(p.template)) - } - return &ports.PrintError{Kind: ports.KindConfig, Op: "raster.SelfTest", - Message: fmt.Sprintf("auto-test inconnu %q : les auto-tests disponibles sont %s, %s et %s", - what, printing.SelfTestLabel, printing.SelfTestAlignment, printing.SelfTestRuler)} -} - -// printDemoLabel prints the demonstration label of the `label` self-test. -func (p *Printer) printDemoLabel(ctx context.Context) error { - if p.demoLabel == nil { - return &ports.PrintError{Kind: ports.KindConfig, Op: "raster.SelfTest.label", - Message: "aucune étiquette de démonstration n'a été fournie à l'imprimante : " + - "l'étiquette de test porte un produit et des prix, qui viennent du catalogue et de la " + - "configuration du poste, jamais du driver"} - } - label, err := p.demoLabel() - if err != nil { - return &ports.PrintError{Kind: ports.KindData, Op: "raster.SelfTest.label", Err: err, - Message: fmt.Sprintf("l'étiquette de démonstration n'a pas pu être préparée : %v", err)} - } - _, err = p.Print(ctx, ports.PrintJob{ - Label: label, - Template: p.template, - Locale: string(domain.LocaleFrench), - Copies: 1, - }) - return err -} - -// printPattern encapsulates one built-in pattern and sends it. -func (p *Printer) printPattern(ctx context.Context, op string, img *image.Gray) error { - frame, err := encodeLabel(img, p.template, p.settings, p.head, 1) - if err != nil { - return err - } - _, err = p.send(ctx, op, op, frame) - return err -} - // Close releases the transport and the font faces the renderer memoised. // // It is idempotent: the Hub closes on a configuration reload and again on shutdown @@ -476,34 +368,3 @@ func (p *Printer) Close() error { } return err } - -// checkTemplateHead reports whether a template can be printed by this head. -// -// The resolution of the whole application has ONE source, template.media.dots_per_mm -// (mineur-3), and the capability of a driver is what it is COMPARED to. A 12 dots/mm -// template sent to a WS408 prints at two thirds of its size, with a symbol under every -// GS1 floor, and no byte of the frame says so: the label simply comes out wrong. -func checkTemplateHead(t domain.Template, h Head) []domain.Fault { - if t.Media.DotsPerMM == h.DotsPerMM { - return nil - } - if t.Media.DotsPerMM <= 0 { - return []domain.Fault{{Field: "printer.template", - Message: fmt.Sprintf("le gabarit %q ne déclare aucune résolution (media.dots_per_mm = %g) : "+ - "c'est elle qui donne au bitmap sa taille physique", t.Name, t.Media.DotsPerMM)}} - } - return []domain.Fault{{Field: "printer.template", - Message: fmt.Sprintf("le gabarit %q est dessiné pour une tête de %g dots/mm et cette imprimante "+ - "en fait %g : l'étiquette sortirait à une autre échelle", - t.Name, t.Media.DotsPerMM, h.DotsPerMM)}} -} - -// joinFaults gathers every fault into the single French message an operator reads on -// the administration screen, one per line, each naming its own key. -func joinFaults(faults []domain.Fault) string { - lines := make([]string, 0, len(faults)) - for _, f := range faults { - lines = append(lines, f.String()) - } - return strings.Join(lines, " ; ") -} diff --git a/internal/printing/raster/sbplreader_test.go b/internal/printing/raster/sbplreader_test.go new file mode 100644 index 0000000..b975266 --- /dev/null +++ b/internal/printing/raster/sbplreader_test.go @@ -0,0 +1,185 @@ +package raster + +import ( + "image" + "image/color" + "strings" + "testing" +) + +// A SECOND implementation, written here: an SBPL frame reader that reads back what this +// driver wrote, sharing nothing with it. +// +// This is what separates an assertion from a recording. Comparing the driver's output with +// a copy of its own output agrees with a wrong encoder; reading it back with an independent +// decoder does not. + +// --- The second implementation --------------------------------------------- + +// sbplCommand is one command as the reader found it: its name, and the characters +// that followed. +type sbplCommand struct { + name string + arg string +} + +// sbplFrame is what an independent reader makes of one frame. +type sbplFrame struct { + commands []sbplCommand + // graphic is the bitmap rebuilt from the block, widthBytes × 8 dots wide: the + // frame declares its width in BYTES, so the reader cannot know where the padding + // starts and does not pretend to. + graphic *image.Gray + widthBytes int + height int +} + +// argumentLengths is the length of the argument of every command of §8.3, in +// characters. is absent: its argument is measured from its own header. +// +// The order of the keys is irrelevant; the order of the SCAN is not, which is why +// commandNames below is longest-first. +var argumentLengths = map[string]int{ + "A": 0, // start of job + "A1": 8, // aaaabbbb + "A3": 12, // V±ddddH±dddd + "#E": 1, // darkness + "CS": 1, // speed + "%": 1, // rotation + "V": 4, // vertical position + "H": 4, // horizontal position + "Q": 6, // copies + "Z": 0, // end of job +} + +// commandNames is the scan order: a name that is the prefix of another comes AFTER +// it, or "A1" would be read as "A" followed by the digit 1. +var commandNames = []string{"A1", "A3", "GH", "#E", "CS", "A", "V", "H", "Q", "Z", "%"} + +// readFrame parses a whole frame the way the printer would have to, and fails the +// test on anything it cannot account for. +func readFrame(t *testing.T, frame []byte) sbplFrame { + t.Helper() + out := sbplFrame{} + // STX … ETX is the transmission framing of the standard protocol, which the L0 + // bench proved a real WS408 requires. It wraps the commands rather than being one, + // so it comes off before the scan — and its ABSENCE is a failure, because a frame + // without it prints nothing and takes the printer down until it is power-cycled. + rest := string(frame) + if !strings.HasPrefix(rest, "\x02") || !strings.HasSuffix(rest, "\x03") { + t.Fatalf("la trame n'est pas encadrée par STX … ETX : %#x … %#x", frame[0], frame[len(frame)-1]) + } + rest = rest[1 : len(rest)-1] + for len(rest) > 0 { + if rest[0] != 0x1B { + t.Fatalf("octet %#x hors commande : toute la trame est faite de commandes précédées d'ESC", rest[0]) + } + rest = rest[1:] + name := "" + for _, candidate := range commandNames { + if strings.HasPrefix(rest, candidate) { + name = candidate + break + } + } + if name == "" { + t.Fatalf("commande inconnue à « %.10q » : §8.3 en déclare onze, pas douze", rest) + } + rest = rest[len(name):] + + if name == "GH" { + var arg string + arg, rest = readGraphic(t, rest, &out) + out.commands = append(out.commands, sbplCommand{name: name, arg: arg}) + continue + } + size := argumentLengths[name] + if len(rest) < size { + t.Fatalf("commande %s tronquée : %d caractères d'argument, %d attendus", name, len(rest), size) + } + out.commands = append(out.commands, sbplCommand{name: name, arg: rest[:size]}) + rest = rest[size:] + } + return out +} + +// readGraphic reads a Hbbbccc block and rebuilds its bitmap. +func readGraphic(t *testing.T, rest string, out *sbplFrame) (header, remainder string) { + t.Helper() + if len(rest) < 6 { + t.Fatalf("en-tête tronqué : « %q »", rest) + } + header = rest[:6] + widthBytes := atoi(t, header[:3]) + // BOTH fields of abbbccc are byte counts — the SBPL reference says so of b and + // of c in the same words, and the L0 bench confirmed it on paper: a height sent in + // dots makes the printer wait for eight times the data and hang. The block + // therefore carries widthBytes × heightBytes × 8 bytes, and the rows past the real + // bitmap are the padding that fills the last byte. + heightBytes := atoi(t, header[3:]) + height := heightBytes * 8 + payload := 2 * widthBytes * height + if len(rest) < 6+payload { + t.Fatalf("bloc de %d × %d octets annoncé, %d caractères hexa présents sur %d attendus", + widthBytes, heightBytes, len(rest)-6, payload) + } + out.widthBytes, out.height = widthBytes, height + out.graphic = decodeGraphic(t, rest[6:6+payload], widthBytes, height) + return header, rest[6+payload:] +} + +// decodeGraphic turns the hexadecimal back into dots: a set bit is ink, most +// significant bit first, each row padded to a whole byte. +func decodeGraphic(t *testing.T, hexa string, widthBytes, height int) *image.Gray { + t.Helper() + img := image.NewGray(image.Rect(0, 0, widthBytes*8, height)) + for y := 0; y < height; y++ { + for b := 0; b < widthBytes; b++ { + value := hexByte(t, hexa[2*(y*widthBytes+b):2*(y*widthBytes+b)+2]) + for bit := 0; bit < 8; bit++ { + shade := uint8(0xFF) + if value&(0x80>>bit) != 0 { + shade = 0x00 + } + img.SetGray(b*8+bit, y, color.Gray{Y: shade}) + } + } + } + return img +} + +// readerAlphabet is the alphabet this reader accepts, and it is SPELLED OUT rather +// than borrowed from the encoder. +// +// A reader that shared the constant would accept whatever the encoder decided to +// write, lower case included, and the assertion "the frame is in the case the manual +// prints" would quietly stop existing. That case is check 2 of the bench list: a +// firmware that only takes one of them prints nothing, with no message. +const readerAlphabet = "0123456789ABCDEF" + +// hexByte reads two hexadecimal characters, and refuses lower case. +func hexByte(t *testing.T, pair string) byte { + t.Helper() + value := 0 + for _, c := range pair { + digit := strings.IndexRune(readerAlphabet, c) + if digit < 0 { + t.Fatalf("caractère %q hors de l'alphabet hexadécimal majuscule %q", c, readerAlphabet) + } + value = value<<4 | digit + } + return byte(value) +} + +// atoi reads a fixed-width decimal field of the frame. +func atoi(t *testing.T, field string) int { + t.Helper() + value := 0 + for _, c := range field { + if c < '0' || c > '9' { + t.Fatalf("champ numérique %q : %q n'est pas un chiffre", field, c) + } + value = value*10 + int(c-'0') + } + return value +} diff --git a/internal/printing/raster/selftest.go b/internal/printing/raster/selftest.go index 43287ab..e3f377e 100644 --- a/internal/printing/raster/selftest.go +++ b/internal/printing/raster/selftest.go @@ -1,13 +1,73 @@ package raster +// This file is the catalogue of §8.6 as this driver honours it: the answer SelfTest +// gives to each of the three names, and the two patterns it draws itself from the +// geometry of the template in service. + import ( + "context" + "fmt" "image" "image/color" "image/draw" "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/station/ports" ) +// SelfTest prints one built-in pattern (§8.6). +// +// `alignment` and `ruler` are drawn HERE, from the geometry of the template in +// service: they carry no business data, so nothing has to be injected for them to +// exist. `label` is a real label and therefore needs a real Label, which only the +// station can build. +func (p *Printer) SelfTest(ctx context.Context, what string) error { + switch printing.SelfTest(what) { + case printing.SelfTestLabel: + return p.printDemoLabel(ctx) + case printing.SelfTestAlignment: + return p.printPattern(ctx, "raster.SelfTest.alignment", alignmentPattern(p.template)) + case printing.SelfTestRuler: + return p.printPattern(ctx, "raster.SelfTest.ruler", rulerPattern(p.template)) + } + return &ports.PrintError{Kind: ports.KindConfig, Op: "raster.SelfTest", + Message: fmt.Sprintf("auto-test inconnu %q : les auto-tests disponibles sont %s, %s et %s", + what, printing.SelfTestLabel, printing.SelfTestAlignment, printing.SelfTestRuler)} +} + +// printDemoLabel prints the demonstration label of the `label` self-test. +func (p *Printer) printDemoLabel(ctx context.Context) error { + if p.demoLabel == nil { + return &ports.PrintError{Kind: ports.KindConfig, Op: "raster.SelfTest.label", + Message: "aucune étiquette de démonstration n'a été fournie à l'imprimante : " + + "l'étiquette de test porte un produit et des prix, qui viennent du catalogue et de la " + + "configuration du poste, jamais du driver"} + } + label, err := p.demoLabel() + if err != nil { + return &ports.PrintError{Kind: ports.KindData, Op: "raster.SelfTest.label", Err: err, + Message: fmt.Sprintf("l'étiquette de démonstration n'a pas pu être préparée : %v", err)} + } + _, err = p.Print(ctx, ports.PrintJob{ + Label: label, + Template: p.template, + Locale: string(domain.LocaleFrench), + Copies: 1, + }) + return err +} + +// printPattern encapsulates one built-in pattern and sends it. +func (p *Printer) printPattern(ctx context.Context, op string, img *image.Gray) error { + frame, err := encodeLabel(img, p.template, p.settings, p.head, 1) + if err != nil { + return err + } + _, err = p.send(ctx, op, op, frame) + return err +} + // The two patterns this driver draws itself (§8.6). They carry no product, no price // and no barcode, which is why they need nothing injected: a square and a ruler are // geometry, and the geometry is in the template. diff --git a/internal/printing/raster/settings.go b/internal/printing/raster/settings.go index 14fde68..74d3d1f 100644 --- a/internal/printing/raster/settings.go +++ b/internal/printing/raster/settings.go @@ -1,7 +1,12 @@ package raster +// This file is everything New refuses at construction: the bounds the manual imposes on +// the three adjustments, the head a frame is addressed to, and the comparison between +// that head and the template it is asked to burn. + import ( "fmt" + "strings" "openscale/internal/domain" ) @@ -178,3 +183,34 @@ func (h Head) Validate() []domain.Fault { } return faults } + +// checkTemplateHead reports whether a template can be printed by this head. +// +// The resolution of the whole application has ONE source, template.media.dots_per_mm +// (mineur-3), and the capability of a driver is what it is COMPARED to. A 12 dots/mm +// template sent to a WS408 prints at two thirds of its size, with a symbol under every +// GS1 floor, and no byte of the frame says so: the label simply comes out wrong. +func checkTemplateHead(t domain.Template, h Head) []domain.Fault { + if t.Media.DotsPerMM == h.DotsPerMM { + return nil + } + if t.Media.DotsPerMM <= 0 { + return []domain.Fault{{Field: "printer.template", + Message: fmt.Sprintf("le gabarit %q ne déclare aucune résolution (media.dots_per_mm = %g) : "+ + "c'est elle qui donne au bitmap sa taille physique", t.Name, t.Media.DotsPerMM)}} + } + return []domain.Fault{{Field: "printer.template", + Message: fmt.Sprintf("le gabarit %q est dessiné pour une tête de %g dots/mm et cette imprimante "+ + "en fait %g : l'étiquette sortirait à une autre échelle", + t.Name, t.Media.DotsPerMM, h.DotsPerMM)}} +} + +// joinFaults gathers every fault into the single French message an operator reads on +// the administration screen, one per line, each naming its own key. +func joinFaults(faults []domain.Fault) string { + lines := make([]string, 0, len(faults)) + for _, f := range faults { + lines = append(lines, f.String()) + } + return strings.Join(lines, " ; ") +} diff --git a/internal/printing/raster/status.go b/internal/printing/raster/status.go new file mode 100644 index 0000000..0cc6ae7 --- /dev/null +++ b/internal/printing/raster/status.go @@ -0,0 +1,72 @@ +package raster + +// This file is level N3 of §8.5 as this driver answers it: one ENQ over the transport, +// a budget spent on the injected clock, and a verdict that never turns a silence into a +// failure — nor an answer into a « prête » the device never said. + +import ( + "context" + "errors" + "fmt" + "time" + + "openscale/internal/printing/sbpl" + "openscale/internal/station/ports" +) + +// statusBudget is how long a status probe waits for the printer to say something +// (§8.5, level N3). It bounds a transport that answers, never a weighing. +// +// It stays HERE, next to the driver that spends it, where the ENQ byte and the reading +// of the answer have gone to internal/printing/sbpl: how long a station is willing to +// wait is a policy of the station, and the SATO reference states no such delay. +const statusBudget = 500 * time.Millisecond + +// Status reports what the device says about itself, or an honest admission that we do +// not know (§8.5). +// +// It NEVER turns a silence into a failure. A transport that cannot ask answers +// PrinterUnknown, which is the whole reason that value exists, and a printer that +// stays quiet for 500 ms is reported as unknown rather than faulted: confirming a +// physical event with a probe that does not observe it is exactly the mistake +// important-7 removed. +func (p *Printer) Status(ctx context.Context) ports.PrinterStatus { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return ports.PrinterStatus{Health: ports.PrinterUnknown, + Detail: "l'imprimante a été fermée par le poste."} + } + + answer, err := p.transport.Query(ctx, sbpl.Enquiry(), statusBudget) + switch { + case errors.Is(err, ports.ErrUnsupported): + return ports.PrinterStatus{Health: ports.PrinterUnknown, + Detail: fmt.Sprintf("état inconnu : %s ne peut pas interroger l'imprimante. "+ + "L'étiquette part, la réponse ne revient pas.", p.transport.Describe())} + case err != nil: + return ports.PrinterStatus{Health: ports.PrinterFaulted, Raw: answer, + Detail: fmt.Sprintf("l'imprimante n'a pas répondu (%s) : %v", p.transport.Describe(), err)} + case len(answer) == 0: + return ports.PrinterStatus{Health: ports.PrinterUnknown, + Detail: fmt.Sprintf("état inconnu : %s n'a rien renvoyé en %s.", + p.transport.Describe(), statusBudget)} + } + // The frame IS decoded now — the L0 bench captured it — but only far enough to name + // a FAULT. Read sbpl.FaultOfStatusFrame for why readiness is still never claimed. + if fault, named := sbpl.FaultOfStatusFrame(answer); named { + return ports.PrinterStatus{Health: fault.Health, Raw: answer, + Detail: fmt.Sprintf("%s (%s).", fault.Reason, p.transport.Describe())} + } + + // Any non-empty answer means the printer is ALIVE (§8.5) — and alive is not ready. + // PrinterReady means « answered and has NOTHING TO REPORT » (ports), and this + // printer does not answer that question when it is idle: see sbpl.FaultOfStatusFrame. + // Claiming ready here would be a green light on /readyz over an empty roll (§14.5). + // + // The detail names the TRANSPORT and stops there. What the answer means is the + // aggregation's sentence (internal/printing/status.go), and a driver that spelled + // the same conclusion produced it twice in a row on the troubleshooting screen. + return ports.PrinterStatus{Health: ports.PrinterUnknown, Raw: answer, + Detail: p.transport.Describe()} +} diff --git a/internal/printing/render.go b/internal/printing/render.go index 15ec3d3..b3f4b42 100644 --- a/internal/printing/render.go +++ b/internal/printing/render.go @@ -1,34 +1,22 @@ package printing +// This file is the RENDER ENGINE of §7.3: the two entry points that turn a label into the +// dots a print head burns, the journal a truncation is reported to, and the two ink +// primitives every stroke of this package goes through. What a single field does inside +// its box is in field.go, the final binarisation in threshold.go, the bench overlay in +// annotate.go. + import ( "fmt" "image" "image/color" "image/draw" "log/slog" - "strings" "sync" - "golang.org/x/image/math/fixed" - "openscale/internal/domain" ) -// The differentiated thresholds of §7.3, and the reason there are two of them. -const ( - // symbolThreshold is applied to the symbol block. The symbol is already drawn in - // pure black and white -- DrawEAN13 thresholds its own HRI on a scratch band -- - // so the value is insensitive, and 0x80 says so. - symbolThreshold = 0x80 - - // defaultTextThreshold is the 0x68 a template that says nothing gets. Text goes - // lower than the symbol to preserve thin stems at 7 pt. - // - // Zero is treated as "unset" rather than obeyed: no dot is below a threshold of - // zero, so a template that left the field empty would print a blank label. - defaultTextThreshold = 0x68 -) - // The technical anomalies a render can report. None of them stops a label: a // customer is standing at the scale, and a label that says a little less is worth // more than no label at all. But none of them is silent either. @@ -257,124 +245,6 @@ func (r *Rasterizer) anomaly(code, message, detail string) { r.log.Technical("warn", "printer", code, message, detail) } -// drawElement sets one field of the label inside its box. -func (r *Rasterizer) drawElement(dst *image.Gray, g *domain.Template, e domain.Element, label domain.Label, w words) error { - text, err := fieldText(e.Field, label, w) - if err != nil { - return err - } - if e.Framed { - drawFrame(dst, elementBox(g, e)) - } - if text == "" { - return nil - } - - box := textBox(g, e) - if box.Dx() <= 0 { - return fmt.Errorf("printing: le champ %q dispose de %d dots de large", e.Field, box.Dx()) - } - p, err := r.place(g, e, text, fixed.I(box.Dx())) - if err != nil { - return err - } - - pen := fixed.I(box.Min.X) - if e.Align == domain.AlignRight { - pen = fixed.I(box.Max.X) - p.width - } - drawRuns(dst, p.runs, pen, baselineDots(g, e)) - - if p.truncated { - r.anomaly(codeFieldTruncated, - fmt.Sprintf("le champ %q ne tient pas dans sa boîte, il a été tronqué", e.Field), - fmt.Sprintf("« %s » réduit de %d à %d µm puis coupé à « %s » pour %d dots", - text, e.FontSizeUM, p.sizeUM, p.text, box.Dx())) - } - if len(p.missing) > 0 { - r.anomaly(codeGlyphMissing, - fmt.Sprintf("des caractères du champ %q ne sont dans aucune police embarquée", e.Field), - fmt.Sprintf("« %s » : %s", text, describeRunes(p.missing))) - } - return nil -} - -// place runs the automatic reduction of §7.3 on one field. -// -// It descends by 0.1 mm from the nominal body to the floor of the element, and only -// when the floor itself does not fit does it truncate with an ellipsis. It never -// returns "it does not fit": something is always drawn, and the caller always hears -// about it. -func (r *Rasterizer) place(g *domain.Template, e domain.Element, text string, maxWidth fixed.Int26_6) (placement, error) { - floor := reductionFloor(e) - for size := e.FontSizeUM; ; size -= reductionStepUM { - if size < floor { - size = floor - } - p, err := r.compose(g, e, text, size) - if err != nil { - return placement{}, err - } - if p.width <= maxWidth { - return p, nil - } - if size == floor { - return r.truncate(g, e, text, size, maxWidth) - } - } -} - -// compose measures one field at one body, in the weight that body implies. -func (r *Rasterizer) compose(g *domain.Template, e domain.Element, text string, sizeUM domain.Micrometers) (placement, error) { - bold := isBold(g.Media, e, sizeUM) - primary, err := r.fonts.Face(labelFont, int(sizeUM), g.Media.DotsPerMM, bold) - if err != nil { - return placement{}, err - } - fallback, err := r.fonts.Face(fallbackFont, int(sizeUM), g.Media.DotsPerMM, bold) - if err != nil { - return placement{}, err - } - runs, missing := splitRuns(text, primary, fallback) - return placement{ - runs: runs, - width: runsWidth(runs), - sizeUM: sizeUM, - bold: bold, - text: text, - missing: missing, - }, nil -} - -// truncate cuts a field with an ellipsis at the smallest body its element allows. -// -// LAST RESORT, and never silent: the caller journals a technical anomaly naming the -// field, the bodies tried and what was kept. Truncating without a word is how a -// product name starts printing half-eaten and nobody finds out until a customer -// complains at the till. -func (r *Rasterizer) truncate(g *domain.Template, e domain.Element, text string, sizeUM domain.Micrometers, maxWidth fixed.Int26_6) (placement, error) { - runes := []rune(text) - for n := len(runes); n >= 0; n-- { - kept := strings.TrimRight(string(runes[:n]), " ") + ellipsis - p, err := r.compose(g, e, kept, sizeUM) - if err != nil { - return placement{}, err - } - if p.width <= maxWidth { - p.truncated = true - return p, nil - } - } - // Not even the ellipsis fits. A box that narrow is a template fault, not a data - // one, but the rest of the label still prints. - p, err := r.compose(g, e, "", sizeUM) - if err != nil { - return placement{}, err - } - p.truncated = true - return p, nil -} - // drawSymbol lays the EAN-13 block down and reports the geometry it used, which is // what the differentiated threshold needs afterwards. // @@ -398,55 +268,6 @@ func (r *Rasterizer) drawSymbol(dst *image.Gray, g *domain.Template, label domai return o, nil } -// applyThreshold burns every dot of r to pure black or pure white. -// -// Strictly below the threshold is ink. A dot exactly at the threshold stays white, -// which is what makes 0x80 a no-op on a block already drawn in 0x00 and 0xFF. -func applyThreshold(img *image.Gray, r image.Rectangle, threshold uint8) { - r = r.Intersect(img.Bounds()) - for y := r.Min.Y; y < r.Max.Y; y++ { - for x := r.Min.X; x < r.Max.X; x++ { - burnt := color.Gray{Y: 0xFF} - if img.GrayAt(x, y).Y < threshold { - burnt = color.Gray{Y: 0x00} - } - img.SetGray(x, y, burnt) - } - } -} - -// textThreshold is the binarisation threshold of everything that is not the symbol. -func textThreshold(g *domain.Template) uint8 { - if g.TextThreshold == 0 { - return defaultTextThreshold - } - return g.TextThreshold -} - -// surrounding returns the rectangles that cover outer minus inner -- "the rest of -// the label" of §7.3, expressed as rectangles because that is what applyThreshold -// takes. -func surrounding(outer, inner image.Rectangle) []image.Rectangle { - inner = inner.Intersect(outer) - if inner.Empty() { - return []image.Rectangle{outer} - } - var out []image.Rectangle - if inner.Min.Y > outer.Min.Y { - out = append(out, image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y)) - } - if inner.Max.Y < outer.Max.Y { - out = append(out, image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y)) - } - if inner.Min.X > outer.Min.X { - out = append(out, image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y)) - } - if inner.Max.X < outer.Max.X { - out = append(out, image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y)) - } - return out -} - // drawFrame outlines a box with the one dot rule §7.2 gives primary_unit_price, and // which annotate reuses for its own boxes. func drawFrame(dst *image.Gray, box image.Rectangle) { @@ -463,70 +284,3 @@ func drawFrame(dst *image.Gray, box image.Rectangle) { func fill(dst *image.Gray, r image.Rectangle) { draw.Draw(dst, r, image.NewUniform(color.Gray{Y: 0x00}), image.Point{}, draw.Src) } - -// annotate draws the bench overlay: the printable area, the two quiet zones of the -// symbol and a millimetre ruler. -// -// IT IS DRAWN AFTER THE THRESHOLDING, and that is the only order that works: an -// overlay laid down before would be dissolved by the very threshold it has to -// survive -- a grey rule above 0x68 comes out white. Drawn in pure black afterwards -// it is binary by construction, so the "nothing but 0x00 and 0xFF" invariant holds -// either way. -// -// It overlaps the label on purpose. An overlay is read OVER a rendering, and a -// ruler pushed into the margin would measure the margin. -func annotate(dst *image.Gray, g *domain.Template, o SymbolOptions) { - drawFrame(dst, image.Rect(0, 0, - roundDots(g.Media, g.PrintableWidthUM), roundDots(g.Media, g.PrintableHeightUM))) - - block := o.Bounds() - barsLeft := o.barsLeft() - drawFrame(dst, image.Rect(block.Min.X, block.Min.Y, barsLeft, block.Max.Y)) - drawFrame(dst, image.Rect(barsLeft+o.BarsWidthDots(), block.Min.Y, block.Max.X, block.Max.Y)) - - drawRuler(dst, g.Media.DotsPerMM) -} - -// drawRuler lays a millimetre scale along the top and left edges, ticks growing at -// every fifth and every tenth millimetre. -// -// It is what turns "the label looks slightly short" into a number, and it is the -// same scale the `ruler` self-test prints on a real roll (§8.6). -func drawRuler(dst *image.Gray, dotsPerMM float64) { - b := dst.Bounds() - for mm := 0; ; mm++ { - at := int(float64(mm)*dotsPerMM + 0.5) - if at >= b.Dx() && at >= b.Dy() { - return - } - length := 2 - switch { - case mm%10 == 0: - length = 6 - case mm%5 == 0: - length = 4 - } - if at < b.Dx() { - fill(dst, image.Rect(at, 0, at+1, length)) - } - if at < b.Dy() { - fill(dst, image.Rect(0, at, length, at+1)) - } - } -} - -// describeRunes names the characters no embedded font carries, by code point as well -// as by shape: a message a volunteer forwards to the producer has to survive being -// pasted into a mail client that cannot display them either. -func describeRunes(runes []rune) string { - seen := make(map[rune]bool, len(runes)) - var out []string - for _, r := range runes { - if seen[r] { - continue - } - seen[r] = true - out = append(out, fmt.Sprintf("U+%04X %q", r, string(r))) - } - return strings.Join(out, ", ") -} diff --git a/internal/printing/render_test.go b/internal/printing/render_test.go index 5634acc..7e64671 100644 --- a/internal/printing/render_test.go +++ b/internal/printing/render_test.go @@ -2,7 +2,6 @@ package printing import ( "bytes" - "fmt" "image" "image/png" "log/slog" @@ -11,13 +10,17 @@ import ( "strings" "testing" - "golang.org/x/image/math/fixed" - "openscale/internal/domain" ) // The tests of the rendering engine of §7.3. // +// This file keeps what judges the WHOLE RENDER: the goldens, the symbol held inside the +// complete label, the media that comes from the template, the volunteer's offset, and what +// Rasterize refuses. The fields, the layout, the thresholding, the annotation and the +// fallback font each have their own file, next to their production file; the shared +// fixtures are in harness_test.go. +// // # REGENERATING THE GOLDENS // // The goldens of TestTheLabelMatchesItsGoldens are PNGs under testdata/golden/. @@ -40,114 +43,6 @@ import ( // 0493021012365 is the one symbol_test.go freezes its 95 modules for; and the price // grid is domain.LaCagetteRules, established from the evidence (A7). -// The three real catalog rows the tests draw with, transcribed from -// testdata/catalog/flv.csv. -var ( - // celeryRow is row id 1153. Its reference carries the 021 the reference barcode - // of §18 is built on, so the label of the goldens shows the very symbol - // symbol_test.go decodes. - celeryRow = domain.Product{ - ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, - CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, - } - // lentilRow is row id 20. Its name carries U+2665, which Carlito has no glyph - // for: it is what puts the documented fallback in a golden instead of in a - // comment. - lentilRow = domain.Product{ - ID: "20", Name: "LENTILLES VERTES ♥ *", Reference: "0493171000007", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 789, - CategoryCode: "V", Qualification: domain.Weighable, CSVLine: 20, - } - // tommeRow is row id 3511, the LONGEST name of the authentic file at 69 - // characters. It is what the automatic reduction cannot save. - tommeRow = domain.Product{ - ID: "3511", Name: "♥AA-LA TOMME DES CROQUANTS AFFINE A LA LIQUEUR DE NOIX DU PERIGORD-MV", - Reference: "0493773000009", Mode: domain.ByWeight, PriceSuffix: " €/kg", - UnitPrice: 3269, CategoryCode: "A", Qualification: domain.Weighable, CSVLine: 3511, - } - // riceRow is row id 3526. Measured at 298 dots for a 280 dot box, it overflows by - // enough to need the reduction and by little enough for the reduction to save it. - riceRow = domain.Product{ - ID: "3526", Name: "Riz long complet BIO - Agidra", Reference: "0493777000005", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 467, - CategoryCode: "V", Qualification: domain.Weighable, CSVLine: 3526, - } -) - -// referenceMass is the 1,236 kg of test vector T1. -const referenceMass = domain.Grams(1236) - -// --- Fixtures -------------------------------------------------------------- - -// logEntry is one line a render wrote to its journal. -type logEntry struct{ level, source, code, message, detail string } - -// recordingLog is the journal a test hands a Rasterizer, so that "it journals" is an -// assertion and not a hope. -type recordingLog struct{ entries []logEntry } - -func (l *recordingLog) Technical(level, source, code, message, detail string) { - l.entries = append(l.entries, logEntry{level, source, code, message, detail}) -} - -// find returns the first entry carrying a code, or nil. -func (l *recordingLog) find(code string) *logEntry { - for i := range l.entries { - if l.entries[i].code == code { - return &l.entries[i] - } - } - return nil -} - -// codes lists what was journalled, for a failure message that says what happened -// instead of what did not. -func (l *recordingLog) codes() []string { - out := make([]string, 0, len(l.entries)) - for _, e := range l.entries { - out = append(out, e.code) - } - return out -} - -// newTestRasterizer builds a renderer whose journal a test can read back. -func newTestRasterizer(t *testing.T) (*Rasterizer, *recordingLog) { - t.Helper() - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - t.Cleanup(func() { library.Close() }) - log := &recordingLog{} - r, err := NewRasterizer(library, log) - if err != nil { - t.Fatalf("rastériseur : %v", err) - } - return r, log -} - -// weighing builds the Label one weighing produces, through the single calculation -// path of the application. -func weighing(t *testing.T, product domain.Product, mass domain.Grams, rules domain.PricingRules) domain.Label { - t.Helper() - label, err := domain.Price(product, domain.Measurement{Gross: mass}, rules) - if err != nil { - t.Fatalf("Price : %v", err) - } - plan, err := domain.PlanFor(product.Reference) - if err != nil { - t.Fatalf("plan du code %s : %v", product.Reference, err) - } - code, err := domain.Generate(product.Reference, int64(mass), plan.PayloadWidth) - if err != nil { - t.Fatalf("Generate : %v", err) - } - label.Barcode = code - label.JobID = "test" - return label -} - // --- The goldens ----------------------------------------------------------- // TestTheLabelMatchesItsGoldens is the pixel-level record of what §7.3 draws. @@ -321,558 +216,6 @@ func TestTheSymbolKeepsItsGridInTheCompleteRender(t *testing.T) { o.XDots, o.YDots, img.Bounds().Max, worst, worstAt) } -// --- The weight of the 7 pt field, both ways ------------------------------- - -// TestTheSevenPointFieldKeepsTheWeightItsTemplateAsksFor tests the automatic switch -// to bold in BOTH directions, because a rule with a named exception needs both. -// -// weighing_identical carries auto_bold:false on secondary_total_price: the source -// (reports/EtataImprimer.report, label LabelAPayer) carries no FontWeight, so the -// solidarity price prints in REGULAR, and bolding it would be the one visible -// departure from the original — which A1 forbids (§7.2). A template that does not -// opt out gets the rule. -func TestTheSevenPointFieldKeepsTheWeightItsTemplateAsksFor(t *testing.T) { - label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) - - for _, c := range []struct { - name string - autoBold bool - wantBold bool - }{ - {"auto_bold false, le gabarit qui s'en dispense", false, false}, - {"auto_bold true, un gabarit qui ne se prononce pas", true, true}, - } { - t.Run(c.name, func(t *testing.T) { - r, _ := newTestRasterizer(t) - // Le gabarit NEUTRE depuis le 29/07/2026 : le prix solidaire de - // weighing_identical est passe au corps du prix adherent a la demande du - // commanditaire, il n'est donc plus sous les 20 dots et n'exerce plus la - // regle. Le neutre garde un champ de 7 pt, et la regle porte sur le moteur, - // pas sur un gabarit en particulier. - template := domain.NeutralSingleTemplate() - index := elementIndex(t, &template, domain.FieldSecondaryTotalPrice) - element := &template.Elements[index] - element.AutoBold = c.autoBold - - // The premise of the whole rule: this field IS under the 20 dot mark. - if em := template.Media.MilliDots(element.FontSizeUM); em >= autoBoldBelowDots*1000 { - t.Fatalf("l'em du champ vaut %d milli-dots : il n'est plus sous les %d dots "+ - "et ce test ne démontre plus rien", em, autoBoldBelowDots) - } - - img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - box := elementBox(&template, *element) - regular := fieldOnItsOwn(t, r, &template, *element, label, false) - bold := fieldOnItsOwn(t, r, &template, *element, label, true) - - if sameInk(regular, bold, box) { - t.Fatal("le gras et le maigre sont indiscernables sur ce champ : ce test ne " + - "pourrait pas rougir") - } - want, other, wanted := regular, bold, "maigre" - if c.wantBold { - want, other, wanted = bold, regular, "gras" - } - if !sameInk(img, want, box) { - t.Errorf("le champ n'est pas rendu en %s", wanted) - } - if sameInk(img, other, box) { - t.Errorf("le champ est rendu dans l'autre graisse que %s", wanted) - } - }) - } -} - -// fieldOnItsOwn draws one element, in a forced weight, on an otherwise blank label of -// the same geometry. Comparing a render against a DRAWING rather than against a pixel -// count is what makes "it is not bold" mean something. -func fieldOnItsOwn(t *testing.T, r *Rasterizer, g *domain.Template, e domain.Element, label domain.Label, bold bool) *image.Gray { - t.Helper() - forced := e - forced.Bold = bold - forced.AutoBold = false - - w, _ := wordsFor(domain.LocaleFrench) - text, err := fieldText(e.Field, label, w) - if err != nil { - t.Fatalf("contenu du champ %q : %v", e.Field, err) - } - box := textBox(g, e) - p, err := r.place(g, forced, text, fixed.I(box.Dx())) - if err != nil { - t.Fatalf("placement : %v", err) - } - img := image.NewGray(image.Rect(0, 0, - roundDots(g.Media, g.Media.WidthUM), roundDots(g.Media, g.Media.HeightUM))) - for i := range img.Pix { - img.Pix[i] = 0xFF - } - pen := fixed.I(box.Min.X) - if e.Align == domain.AlignRight { - pen = fixed.I(box.Max.X) - p.width - } - drawRuns(img, p.runs, pen, baselineDots(g, e)) - applyThreshold(img, img.Bounds(), textThreshold(g)) - return img -} - -// sameInk reports whether two renders carry the same dots inside a box. -func sameInk(a, b *image.Gray, box image.Rectangle) bool { - box = box.Intersect(a.Bounds()).Intersect(b.Bounds()) - for y := box.Min.Y; y < box.Max.Y; y++ { - for x := box.Min.X; x < box.Max.X; x++ { - if isInk(a, x, y) != isInk(b, x, y) { - return false - } - } - } - return true -} - -// elementIndex finds a field in a template, and fails rather than return -1. -func elementIndex(t *testing.T, g *domain.Template, field string) int { - t.Helper() - for i, e := range g.Elements { - if e.Field == field { - return i - } - } - t.Fatalf("le gabarit %s ne place pas le champ %q", g.Name, field) - return -1 -} - -// --- Mono-tarif ------------------------------------------------------------ - -// TestAMonoTierLabelDropsTheSecondaryPrice: the field DISAPPEARS, and no `if` in the -// rendering code says so — Element.Active does (§7.2). -func TestAMonoTierLabelDropsTheSecondaryPrice(t *testing.T) { - r, _ := newTestRasterizer(t) - template := domain.IdenticalTemplate() - secondary := template.Elements[elementIndex(t, &template, domain.FieldSecondaryTotalPrice)] - box := elementBox(&template, secondary) - - // The template stays valid for a station that runs one tier: rules 3, 5 and 8 are - // about what is actually inked, so a mono-tarif poste must not be refused a - // template because of a field it will never draw. - if faults := template.Validate(1); len(faults) != 0 { - t.Errorf("le gabarit est refusé en mono-tarif : %v", faults) - } - - mono, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.SingleTierRules()), - domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize mono-tarif : %v", err) - } - if _, inked := inkBounds(mono, box); inked { - t.Errorf("la boîte du prix solidaire %v est encrée sur une étiquette mono-tarif", box) - } - - // And the same box IS inked when the grid has two tiers — without which the test - // above would pass on a renderer that draws nothing at all. - dual, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), - domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize double tarif : %v", err) - } - if _, inked := inkBounds(dual, box); !inked { - t.Errorf("la boîte du prix solidaire %v est vide en double tarif", box) - } - - // The rest of the label is untouched: the barcode block is identical either way. - o := NewSymbolOptions(template) - if !sameInk(mono, dual, o.Bounds()) { - t.Error("le symbole diffère entre mono-tarif et double tarif") - } -} - -// --- The final thresholding ------------------------------------------------ - -// TestTheRenderCarriesNothingButPureBlackAndWhite: the head is binary, and a render -// that kept greys would let the driver dither it into irregular bars (§7.3). -func TestTheRenderCarriesNothingButPureBlackAndWhite(t *testing.T) { - r, _ := newTestRasterizer(t) - for name, template := range domain.ShippedTemplates() { - for _, annotate := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/annotate=%v", name, annotate), func(t *testing.T) { - g := template - img, err := r.Rasterize(&g, weighing(t, lentilRow, referenceMass, domain.LaCagetteRules()), - domain.LocaleFrench, RenderOptions{Annotate: annotate}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - grey, black, white := 0, 0, 0 - var firstGrey image.Point - var firstValue uint8 - for i, v := range img.Pix { - switch v { - case 0x00: - black++ - case 0xFF: - white++ - default: - if grey == 0 { - firstGrey = image.Pt(i%img.Stride, i/img.Stride) - firstValue = v - } - grey++ - } - } - if grey > 0 { - t.Errorf("%d dots ne sont ni 0x00 ni 0xFF, le premier en %v vaut 0x%02X — "+ - "le pilote tramerait ces gris et produirait des barres irrégulières", - grey, firstGrey, firstValue) - } - if black == 0 { - t.Error("aucun dot noir : le seuillage a effacé l'étiquette") - } - t.Logf("%d dots noirs, %d blancs", black, white) - }) - } - } -} - -// TestTheThresholdIsDifferentiated: 0x80 on the symbol, TextThreshold on the rest. -// -// It is checked where it is observable: a grey laid inside the symbol block and a -// grey laid outside it, both between the two thresholds, must come out differently. -func TestTheThresholdIsDifferentiated(t *testing.T) { - template := domain.IdenticalTemplate() - if template.TextThreshold != defaultTextThreshold { - t.Fatalf("le gabarit porte un seuil texte de 0x%02X : ce test suppose 0x%02X", - template.TextThreshold, defaultTextThreshold) - } - o := NewSymbolOptions(template) - img := image.NewGray(image.Rect(0, 0, 320, 203)) - for i := range img.Pix { - img.Pix[i] = 0x70 // between 0x68 and 0x80 - } - - applyThreshold(img, o.Bounds(), symbolThreshold) - for _, rest := range surrounding(img.Bounds(), o.Bounds()) { - applyThreshold(img, rest, textThreshold(&template)) - } - - inside := image.Pt(o.XDots+1, o.YDots+1) - outside := image.Pt(o.XDots+1, o.YDots-1) - if !isInk(img, inside.X, inside.Y) { - t.Errorf("un gris 0x70 dans le symbole %v est resté blanc : le seuil 0x%02X n'y est pas appliqué", - o.Bounds(), symbolThreshold) - } - if isInk(img, outside.X, outside.Y) { - t.Errorf("un gris 0x70 hors du symbole est devenu noir : le seuil texte 0x%02X n'y est pas appliqué", - textThreshold(&template)) - } -} - -// TestAZeroTextThresholdFallsBackRatherThanBlankingTheLabel: with a threshold of -// zero no dot is ever below it, so obeying the field literally would print a blank. -func TestAZeroTextThresholdFallsBackRatherThanBlankingTheLabel(t *testing.T) { - template := domain.IdenticalTemplate() - template.TextThreshold = 0 - if got := textThreshold(&template); got != defaultTextThreshold { - t.Errorf("seuil 0x%02X pour un gabarit muet, attendu 0x%02X", got, defaultTextThreshold) - } - - r, _ := newTestRasterizer(t) - img, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), - domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - nameBox := elementBox(&template, template.Elements[elementIndex(t, &template, domain.FieldProductName)]) - if _, inked := inkBounds(img, nameBox); !inked { - t.Error("le nom du produit est vide : un seuil de texte à zéro a effacé l'étiquette") - } -} - -// --- The automatic reduction, both outcomes -------------------------------- - -// TestALongNameIsReducedThenTruncated covers the two outcomes of §7.3, on the two -// real names of the authentic catalog that produce them. -func TestALongNameIsReducedThenTruncated(t *testing.T) { - template := domain.IdenticalTemplate() - nameElement := template.Elements[elementIndex(t, &template, domain.FieldProductName)] - box := textBox(&template, nameElement) - - t.Run("réduit et tient", func(t *testing.T) { - r, log := newTestRasterizer(t) - p := placeName(t, r, &template, nameElement, riceRow.Name) - - if p.sizeUM >= nameElement.FontSizeUM { - t.Fatalf("« %s » tient au corps nominal de %d µm : ce nom ne fait plus déborder "+ - "la boîte et ce cas ne teste plus la réduction", - riceRow.Name, nameElement.FontSizeUM) - } - if p.sizeUM < reductionFloor(nameElement) { - t.Errorf("corps %d µm sous le plancher %d µm", p.sizeUM, reductionFloor(nameElement)) - } - if p.truncated { - t.Errorf("« %s » a été tronqué alors que la réduction suffisait", riceRow.Name) - } - if p.text != riceRow.Name { - t.Errorf("le texte rendu est %q, attendu le nom entier", p.text) - } - if p.width > fixed.I(box.Dx()) { - t.Errorf("%.2f dots après réduction pour une boîte de %d", float64(p.width)/64, box.Dx()) - } - if len(log.entries) != 0 { - t.Errorf("une réduction qui aboutit ne journalise rien, or : %v", log.codes()) - } - - // A REDUCED FIELD STAYS ON ITS LINE. The baseline comes from the NOMINAL body, - // so shrinking a name must not drop it a dot below the line it shares with the - // rest of the label. The reference is drawn at the same reduced body but on the - // baseline of the ELEMENT, which is exactly the property under test. - label := weighing(t, riceRow, referenceMass, domain.LaCagetteRules()) - img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - onItsLine := fieldOnItsOwn(t, r, &template, nameElement, label, false) - if !sameInk(img, onItsLine, elementBox(&template, nameElement)) { - t.Error("le nom réduit n'est pas tracé sur la ligne de base de son élément : " + - "réduire un champ le décale de la ligne qu'il partage avec les autres") - } - - t.Logf("« %s » : %d µm → %d µm, %.2f dots pour %d", - riceRow.Name, nameElement.FontSizeUM, p.sizeUM, float64(p.width)/64, box.Dx()) - }) - - t.Run("réduit, ne tient pas, tronqué et journalisé", func(t *testing.T) { - r, log := newTestRasterizer(t) - label := weighing(t, tommeRow, referenceMass, domain.LaCagetteRules()) - img, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - - entry := log.find(codeFieldTruncated) - if entry == nil { - t.Fatalf("aucune anomalie %s journalisée : §7.3 interdit de sortir en silence "+ - "(journalisé : %v)", codeFieldTruncated, log.codes()) - } - if entry.source != "printer" { - t.Errorf("source %q, attendu « printer »", entry.source) - } - if !strings.Contains(entry.message, domain.FieldProductName) { - t.Errorf("le message %q ne nomme pas le champ fautif", entry.message) - } - if !strings.Contains(entry.detail, tommeRow.Name) { - t.Errorf("le détail %q ne cite pas le nom d'origine", entry.detail) - } - - p := placeName(t, r, &template, nameElement, tommeRow.Name) - if !p.truncated { - t.Fatal("le plus long nom du fichier authentique n'a pas été tronqué") - } - if p.sizeUM != reductionFloor(nameElement) { - t.Errorf("tronqué au corps %d µm et non au plancher %d µm : §7.3 tronque au "+ - "DERNIER corps valide", p.sizeUM, reductionFloor(nameElement)) - } - if !strings.HasSuffix(p.text, ellipsis) { - t.Errorf("le texte tronqué %q ne porte pas d'ellipse", p.text) - } - if p.width > fixed.I(box.Dx()) { - t.Errorf("%.2f dots après troncature pour une boîte de %d", float64(p.width)/64, box.Dx()) - } - // The ink really stops inside the box: a truncation that only shortened the - // string would still overflow if the drawing ignored it. - if ink, ok := inkBounds(img, image.Rect(0, 0, img.Bounds().Dx(), box.Max.Y)); ok && ink.Max.X > box.Max.X { - t.Errorf("l'encre du nom s'étend jusqu'en x=%d, au-delà de la boîte %v", ink.Max.X, box) - } - t.Logf("« %s » → « %s » au corps %d µm", tommeRow.Name, p.text, p.sizeUM) - }) -} - -// placeName runs the reduction on one product name, which is what the two cases -// above differ by. -func placeName(t *testing.T, r *Rasterizer, g *domain.Template, e domain.Element, name string) placement { - t.Helper() - p, err := r.place(g, e, name, fixed.I(textBox(g, e).Dx())) - if err != nil { - t.Fatalf("placement de « %s » : %v", name, err) - } - return p -} - -// TestTheReductionNeverGoesBelowTheHardFloor: hard rule 9 sets 1800 µm for every -// field, and an element that declares no floor of its own does not escape it. -func TestTheReductionNeverGoesBelowTheHardFloor(t *testing.T) { - if got := reductionFloor(domain.Element{FontSizeUM: 3175}); got != domain.MinFontSizeUM { - t.Errorf("plancher %d µm pour un élément muet, attendu %d", got, domain.MinFontSizeUM) - } - if got := reductionFloor(domain.Element{FontSizeUM: 3175, MinFontSizeUM: 2200}); got != 2200 { - t.Errorf("plancher %d µm, attendu le 2200 déclaré par l'élément", got) - } - // A floor below the hard one is not honoured; a floor above the nominal body - // cannot be, since the reduction only ever goes down. - if got := reductionFloor(domain.Element{FontSizeUM: 3175, MinFontSizeUM: 500}); got != domain.MinFontSizeUM { - t.Errorf("plancher %d µm pour un élément qui déclare 500 µm, attendu %d", - got, domain.MinFontSizeUM) - } - if got := reductionFloor(domain.Element{FontSizeUM: 2000, MinFontSizeUM: 3000}); got != 2000 { - t.Errorf("plancher %d µm au-dessus du corps nominal", got) - } -} - -// TestABoxTooNarrowEvenForAnEllipsisStillPrintsTheRestOfTheLabel: a box that narrow -// is a template fault, not a data one, and the customer still gets a barcode. -func TestABoxTooNarrowEvenForAnEllipsisStillPrintsTheRestOfTheLabel(t *testing.T) { - r, log := newTestRasterizer(t) - template := domain.IdenticalTemplate() - index := elementIndex(t, &template, domain.FieldProductName) - template.Elements[index].WidthUM = 400 // 3.2 dots: not even "…" fits - - img, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), - domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - if log.find(codeFieldTruncated) == nil { - t.Errorf("aucune anomalie %s journalisée (journalisé : %v)", codeFieldTruncated, log.codes()) - } - box := elementBox(&template, template.Elements[index]) - if _, inked := inkBounds(img, box); inked { - t.Errorf("la boîte %v, trop étroite pour une ellipse, a quand même reçu de l'encre", box) - } - // The label is still a label: the symbol is there, whole. - o := NewSymbolOptions(template) - if first, last, ok := inkColumnRange(img, image.Rect(o.XDots, o.YDots, - o.XDots+o.TotalWidthDots(), o.YDots+o.BarHeightDots)); !ok || last-first+1 != 223 { - t.Error("le symbole a souffert d'un champ texte impossible à placer") - } -} - -// TestTheEngineRefusesToInventContent: three questions a field cannot answer, and -// none of them is answered by a plausible-looking string. -func TestTheEngineRefusesToInventContent(t *testing.T) { - w, _ := wordsFor(domain.LocaleFrench) - - if _, err := fieldText("prix_au_metre", domain.Label{}, w); err == nil { - t.Error("un champ inconnu a produit un contenu : la liste des FieldID est fermée (règle 7)") - } - mono := weighing(t, celeryRow, referenceMass, domain.SingleTierRules()) - if _, err := fieldText(domain.FieldSecondaryTotalPrice, mono, w); err == nil { - t.Error("le prix secondaire a été produit sur une grille mono-tarif : il n'existe pas") - } - if _, err := fieldText(domain.FieldQuantity, domain.Label{Mode: domain.SaleMode(9)}, w); err == nil { - t.Error("un mode de vente inconnu a produit une quantité") - } - if _, err := fieldText(domain.FieldPrimaryUnitPrice, domain.Label{}, w); err == nil { - t.Error("un prix principal a été produit sans tarif") - } -} - -// --- What the fields carry (A7) -------------------------------------------- - -// TestTheFieldsCarryWhatArbitrationSevenSays reproduces the three strings §7.2 spells -// out, from the example the legacy help screen states: garlic at 5,32 €/kg, 1,236 kg. -func TestTheFieldsCarryWhatArbitrationSevenSays(t *testing.T) { - garlic := domain.Product{ - ID: "1", Name: "AIL", Reference: "0493021000003", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 532, - } - label := weighing(t, garlic, referenceMass, domain.LaCagetteRules()) - w, _ := wordsFor(domain.LocaleFrench) - - for _, c := range []struct{ field, want string }{ - {domain.FieldProductName, "AIL"}, - {domain.FieldQuantity, "1,236 kg"}, - {domain.FieldPrimaryUnitPrice, "A: 4,79 €/kg"}, - {domain.FieldSecondaryTotalPrice, "S: 6,58 €"}, - {domain.FieldPrimaryTotalPrice, "A: 5,92 €"}, - } { - got, err := fieldText(c.field, label, w) - if err != nil { - t.Fatalf("%s : %v", c.field, err) - } - if got != c.want { - t.Errorf("%s = %q, attendu %q", c.field, got, c.want) - } - } -} - -// TestThePriceSuffixComesFromTheProduct: « €/kg » is not a constant of the template -// (§7.2). Two products, two suffixes, the same template. -func TestThePriceSuffixComesFromTheProduct(t *testing.T) { - w, _ := wordsFor(domain.LocaleFrench) - for _, suffix := range []string{" €/kg", " € le litre", " € l'unité"} { - product := domain.Product{ - Name: "PRODUIT", Reference: "0493021000003", Mode: domain.ByWeight, - PriceSuffix: suffix, UnitPrice: 532, - } - label := weighing(t, product, referenceMass, domain.LaCagetteRules()) - got, err := fieldText(domain.FieldPrimaryUnitPrice, label, w) - if err != nil { - t.Fatalf("%v", err) - } - if want := "A: 4,79" + suffix; got != want { - t.Errorf("prix unitaire = %q, attendu %q", got, want) - } - } -} - -// TestAMonoTierGridPrintsNoPrefix: with one tier the Abbrev is empty, and a bare -// « : » in front of a price would introduce nothing. -func TestAMonoTierGridPrintsNoPrefix(t *testing.T) { - label := weighing(t, celeryRow, referenceMass, domain.SingleTierRules()) - w, _ := wordsFor(domain.LocaleFrench) - got, err := fieldText(domain.FieldPrimaryTotalPrice, label, w) - if err != nil { - t.Fatalf("%v", err) - } - if strings.Contains(got, ":") { - t.Errorf("prix mono-tarif = %q : il porte un préfixe alors que l'Abbrev est vide", got) - } - if want := "4,14 €"; got != want { - t.Errorf("prix mono-tarif = %q, attendu %q", got, want) - } -} - -// TestAProductSoldByUnitCountsItsUnits: "1 unité", "3 unités" — the legacy wording, -// kept. -func TestAProductSoldByUnitCountsItsUnits(t *testing.T) { - w, _ := wordsFor(domain.LocaleFrench) - for _, c := range []struct { - quantity int - want string - }{{1, "1 unité"}, {3, "3 unités"}} { - label := domain.Label{Mode: domain.ByUnit, Quantity: c.quantity} - got, err := fieldText(domain.FieldQuantity, label, w) - if err != nil { - t.Fatalf("%v", err) - } - if got != c.want { - t.Errorf("quantité pour %d = %q, attendu %q", c.quantity, got, c.want) - } - } -} - -// TestAnUnknownLocaleFallsBackToFrenchAndSaysSo: a customer is waiting; a label in -// French beats no label, and silence beats neither. -func TestAnUnknownLocaleFallsBackToFrenchAndSaysSo(t *testing.T) { - r, log := newTestRasterizer(t) - template := domain.IdenticalTemplate() - if _, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), - domain.Locale("nl-BE"), RenderOptions{}); err != nil { - t.Fatalf("Rasterize : %v", err) - } - if log.find(codeUnknownLocale) == nil { - t.Errorf("aucune anomalie %s journalisée (journalisé : %v)", codeUnknownLocale, log.codes()) - } - - // And the empty locale IS French: a PrintJob whose field was never filled must - // print a label, not a fault. - empty, _ := newTestRasterizer(t) - if _, err := empty.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), - "", RenderOptions{}); err != nil { - t.Fatalf("Rasterize avec une langue vide : %v", err) - } -} - // --- The media comes from the template ------------------------------------- // TestTheMediaAndTheResolutionComeFromTheTemplate: no constant of the engine decides @@ -915,126 +258,6 @@ func TestTheMediaAndTheResolutionComeFromTheTemplate(t *testing.T) { widths[8], widths[12], ratio) } -// --- The frame ------------------------------------------------------------- - -// TestTheFramedFieldCarriesItsOneDotRule: a framed field draws a rule of ONE dot. -// -// Le gabarit de production ne s'en sert plus — le commanditaire a fait retirer la -// bordure du prix au kilo le 29/07/2026 —, mais `Framed` reste une fonction du moteur -// qu'un gabarit peut demander. Le test la pose donc lui-meme au lieu de compter sur un -// livrable pour l'exercer : c'est ce qui l'empeche de disparaitre avec une decision de -// mise en page. -func TestTheFramedFieldCarriesItsOneDotRule(t *testing.T) { - r, _ := newTestRasterizer(t) - label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) - - framed := domain.IdenticalTemplate() - index := elementIndex(t, &framed, domain.FieldPrimaryUnitPrice) - framed.Elements[index].Framed = true - box := elementBox(&framed, framed.Elements[index]) - - bare := domain.IdenticalTemplate() - bare.Elements[index].Framed = false - - with, err := r.Rasterize(&framed, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - without, err := r.Rasterize(&bare, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize sans cadre : %v", err) - } - - // The four sides are inked over their whole length -- a rule with a gap is not a - // rule. - for _, side := range []struct { - name string - r image.Rectangle - }{ - {"haut", image.Rect(box.Min.X, box.Min.Y, box.Max.X, box.Min.Y+1)}, - {"bas", image.Rect(box.Min.X, box.Max.Y-1, box.Max.X, box.Max.Y)}, - {"gauche", image.Rect(box.Min.X, box.Min.Y, box.Min.X+1, box.Max.Y)}, - {"droite", image.Rect(box.Max.X-1, box.Min.Y, box.Max.X, box.Max.Y)}, - } { - for y := side.r.Min.Y; y < side.r.Max.Y; y++ { - for x := side.r.Min.X; x < side.r.Max.X; x++ { - if !isInk(with, x, y) { - t.Fatalf("le côté %s du cadre n'est pas encré en (%d ; %d)", side.name, x, y) - } - } - } - } - - // It really is the FRAME that draws them, and not the text: the left column and - // the four corners are places a right-aligned price never reaches, and they are - // blank as soon as framed is false. - left := image.Rect(box.Min.X, box.Min.Y, box.Min.X+1, box.Max.Y) - if _, inked := inkBounds(without, left); inked { - t.Errorf("la colonne gauche %v est encrée alors que framed est faux : ce test "+ - "confondrait le cadre avec le texte", left) - } - for _, corner := range []image.Point{ - {X: box.Min.X, Y: box.Min.Y}, {X: box.Max.X - 1, Y: box.Min.Y}, - {X: box.Min.X, Y: box.Max.Y - 1}, {X: box.Max.X - 1, Y: box.Max.Y - 1}, - } { - if isInk(without, corner.X, corner.Y) { - t.Errorf("le coin %v est encré sans cadre", corner) - } - } - - // One dot thick, and no more: the column just inside the left rule carries - // nothing between the two horizontal rules. The text of a right-aligned price - // starts far to the right of it, so anything found there is a second stroke. - inner := image.Rect(box.Min.X+1, box.Min.Y+1, box.Min.X+2, box.Max.Y-1) - if _, inked := inkBounds(with, inner); inked { - t.Errorf("la colonne %v contre le trait du cadre est encrée : le cadre fait plus "+ - "d'un dot", inner) - } -} - -// --- The shared baseline --------------------------------------------------- - -// TestTheTwoPricesShareABaseline is the guard on emAscentPerMille. -// -// A template that aligns two DIFFERENT bodies does it by subtracting 750/1000 of each -// em from a common baseline. If this package ever placed baselines with another ascent -// — face.Metrics().Ascent, say, which is 0.952 em for Carlito — the two fields would -// print on two different lines. -// -// The alignment is BUILT HERE and no longer read off weighing_identical: since the -// 29/07/2026 its two prices share a body, on the commissioning party's request, so -// they share a baseline whatever this package believes about ascents. The guard has to -// outlive that layout decision, so it carries its own two bodies. -func TestTheTwoPricesShareABaseline(t *testing.T) { - template := domain.IdenticalTemplate() - primaryIndex := elementIndex(t, &template, domain.FieldPrimaryTotalPrice) - secondaryIndex := elementIndex(t, &template, domain.FieldSecondaryTotalPrice) - - // Le petit corps de l'ancienne mise en page, replace comme le gabarit le faisait : - // meme ligne de base, ascendante soustraite de chaque em. - const smallBody = domain.Micrometers(2_473) // 7 pt - const ascentPerMille = 750 - big := template.Elements[primaryIndex] - baseline := big.YUM + big.FontSizeUM*ascentPerMille/1000 - - template.Elements[secondaryIndex].FontSizeUM = smallBody - template.Elements[secondaryIndex].HeightUM = smallBody - template.Elements[secondaryIndex].YUM = baseline - smallBody*ascentPerMille/1000 - - primary := template.Elements[primaryIndex] - secondary := template.Elements[secondaryIndex] - if primary.FontSizeUM == secondary.FontSizeUM { - t.Fatal("les deux prix sont au même corps : ce test ne démontre plus rien") - } - a, b := baselineDots(&template, primary), baselineDots(&template, secondary) - if a != b { - t.Errorf("le prix adhérent est sur la ligne de base %d et le solidaire sur %d : "+ - "le moteur ne place pas ses lignes de base avec l'ascendante que le gabarit "+ - "a utilisée pour les aligner", a, b) - } - t.Logf("les deux prix partagent la ligne de base %d dots", a) -} - // --- The volunteer's ±1 dot adjustment ------------------------------------- // TestTheOffsetMovesTheWHOLELabel: the ±1 dot arrows of the admin screen move the @@ -1180,124 +403,3 @@ func TestTheFreeRasterizeRendersAndStaysAudible(t *testing.T) { "§7.3 sort en silence.\nJournal : %s", codeFieldTruncated, captured.String()) } } - -// --- The annotation -------------------------------------------------------- - -// TestTheAnnotationIsAnOverlayAndNothingMore: it adds dots, it never removes any, and -// it survives the thresholding it is drawn after. -func TestTheAnnotationIsAnOverlayAndNothingMore(t *testing.T) { - r, _ := newTestRasterizer(t) - template := domain.IdenticalTemplate() - label := weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()) - - plain, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - marked, err := r.Rasterize(&template, label, domain.LocaleFrench, RenderOptions{Annotate: true}) - if err != nil { - t.Fatalf("Rasterize annoté : %v", err) - } - - added := 0 - for y := plain.Bounds().Min.Y; y < plain.Bounds().Max.Y; y++ { - for x := plain.Bounds().Min.X; x < plain.Bounds().Max.X; x++ { - switch { - case isInk(plain, x, y) && !isInk(marked, x, y): - t.Fatalf("l'annotation a effacé le dot (%d ; %d) de l'étiquette", x, y) - case !isInk(plain, x, y) && isInk(marked, x, y): - added++ - } - } - } - if added == 0 { - t.Fatal("l'annotation n'a rien tracé") - } - - // The ruler starts at the origin of the label, which is what makes it useful for - // checking an offset: a tick sits on every millimetre of the top edge. - for mm := 1; mm <= 3; mm++ { - x := int(float64(mm)*template.Media.DotsPerMM + 0.5) - if !isInk(marked, x, 0) { - t.Errorf("aucune graduation en x=%d (%d mm) sur le bord haut", x, mm) - } - } - t.Logf("%d dots ajoutés par l'annotation", added) -} - -// --- The fallback font ----------------------------------------------------- - -// TestTheFallbackDrawsWhatCarlitoCannot is not a theoretical case: 127 of the 355 -// names of testdata/catalog/flv.csv carry U+2665, and Carlito has no glyph for it. -func TestTheFallbackDrawsWhatCarlitoCannot(t *testing.T) { - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - defer library.Close() - - carlito, err := library.Face(labelFont, 3175, 8, false) - if err != nil { - t.Fatalf("fonte : %v", err) - } - dejavu, err := library.Face(fallbackFont, 3175, 8, false) - if err != nil { - t.Fatalf("fonte de repli : %v", err) - } - const heart = '♥' - if hasGlyph(carlito, heart) { - t.Fatalf("Carlito dessine désormais U+%04X : ce repli n'a plus d'objet et le "+ - "commentaire de fallbackFont est faux", heart) - } - if !hasGlyph(dejavu, heart) { - t.Fatalf("DejaVu Sans Condensed ne dessine pas U+%04X non plus : un tiers du "+ - "catalogue s'imprimerait avec un trou dans son nom", heart) - } - - runs, missing := splitRuns(lentilRow.Name, carlito, dejavu) - if len(missing) != 0 { - t.Errorf("caractères perdus dans « %s » : %s", lentilRow.Name, describeRunes(missing)) - } - if len(runs) < 3 { - t.Errorf("« %s » découpé en %d plages : le cœur doit en ouvrir une à lui seul", - lentilRow.Name, len(runs)) - } - used := 0 - for _, run := range runs { - if run.face == dejavu { - used++ - } - } - if used != 1 { - t.Errorf("%d plages tracées en repli, attendu 1", used) - } - - // A string Carlito covers entirely stays ONE run, and that is what keeps the - // measurement kerned — the acceptance criterion of ADR-020 depends on it. - whole, _ := splitRuns("A: 4,32 €/kg", carlito, dejavu) - if len(whole) != 1 { - t.Errorf("« A: 4,32 €/kg » découpé en %d plages : mesurée par morceaux, la chaîne "+ - "perdrait son crénage et le critère d'ADR-020 se mettrait à échouer", len(whole)) - } -} - -// TestACharacterNoEmbeddedFontCarriesIsJournalled: dropped rather than drawn as a -// box, but never dropped quietly. -func TestACharacterNoEmbeddedFontCarriesIsJournalled(t *testing.T) { - r, log := newTestRasterizer(t) - template := domain.IdenticalTemplate() - product := celeryRow - product.Name = "CELERI 天 SAF" // a Han character neither embedded font carries - - if _, err := r.Rasterize(&template, weighing(t, product, referenceMass, domain.LaCagetteRules()), - domain.LocaleFrench, RenderOptions{}); err != nil { - t.Fatalf("Rasterize : %v", err) - } - entry := log.find(codeGlyphMissing) - if entry == nil { - t.Fatalf("aucune anomalie %s journalisée (journalisé : %v)", codeGlyphMissing, log.codes()) - } - if !strings.Contains(entry.detail, "U+5929") { - t.Errorf("le détail %q ne nomme pas le point de code fautif", entry.detail) - } -} diff --git a/internal/printing/routing.go b/internal/printing/routing.go new file mode 100644 index 0000000..f922935 --- /dev/null +++ b/internal/printing/routing.go @@ -0,0 +1,149 @@ +package printing + +// This file is §8.4: which printer the labels are coming out of, and what the screen +// says about it. Both switches are ASKED FOR — UseFallback and UseMain say at length why +// neither may ever be automatic — and each one forgets what the station knew about the +// printer it just left. + +import ( + "context" + "errors" + "fmt" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// Routing is which printer the labels are coming out of, and what the screen says about +// it. +type Routing struct { + // Fallback reports that the station is on the neighbour's printer. + Fallback bool + // Name is the FRENCH name of the printer in use. + Name string + // Banner is the PERMANENT banner of §8.4, in French. Empty on the main printer: + // there is nothing to warn about when everything is where it belongs. + Banner string + // Available reports that a fallback is configured at all, which is what decides + // whether the button « Imprimer sur l'imprimante du poste N » is offered (§14.4). + Available bool +} + +// Routing reports which printer is in use. +func (s *Service) Routing() Routing { + s.stateMu.Lock() + defer s.stateMu.Unlock() + r := Routing{Fallback: s.onFallback, Name: s.mainName, Available: s.fallback != nil} + if s.onFallback { + r.Name = s.fallbackName + r.Banner = fmt.Sprintf("Les étiquettes sortent sur l'imprimante de secours (%s).", s.fallbackName) + } + return r +} + +// UseFallback routes printing to the fallback printer FOR THE CURRENT SESSION (§8.4, +// bloquant-8). +// +// # Asked for, never automatic — and §8.4 is the one that decides +// +// The document describes an explicit button on the troubleshooting screen, « Imprimer +// sur l'imprimante du poste N », and a permanent banner. It is worth saying why that is +// the right call rather than a timid one, because « switch automatically when the main +// printer fails » sounds like a service. +// +// Nothing observable would trigger it honestly. What the station can see is a write +// that failed, and a write fails on a cable knocked loose for two seconds as readily as +// on a dead printer (important-7 is the same lesson from the other end: we do not +// confirm a physical event with a probe that does not observe it). An automatic switch +// would therefore move a customer's label two metres away, silently, on a transient — +// and the customer is standing at THIS station, watching a slot that stays empty. +// +// And it does not scale down the way it must. The four printers of the parc are two +// metres apart and each is the fallback of its neighbour; a network hiccup that touches +// all four would pile all four stations onto one printer, which is how a bad afternoon +// becomes a closed shop. +// +// So the switch is a human decision, taken by someone who has looked at the printer, +// and the banner is permanent because the same human has to remember to come back. +func (s *Service) UseFallback(ctx context.Context) error { + s.stateMu.Lock() + if s.fallback == nil { + s.stateMu.Unlock() + return errors.New("aucune imprimante de secours n'est configurée sur ce poste : " + + "renseignez printer.options.fallback (transport et file de l'imprimante voisine)") + } + if s.onFallback { + s.stateMu.Unlock() + return nil + } + s.onFallback = true + s.forget() + s.stateMu.Unlock() + + s.log.Technical(domain.LevelWarn, "printer", "", + fmt.Sprintf("Les étiquettes sont basculées sur l'imprimante de secours (%s).", s.fallbackName), + "bascule demandée depuis l'écran de dépannage ; elle dure jusqu'au retour explicite ou "+ + "jusqu'au redémarrage du service") + s.observeQueueAfterSwitch(ctx) + return nil +} + +// UseMain routes printing back to the main printer. +// +// Also asked for, and for the mirror reason: NOTHING tells this station that the main +// printer has been fixed. Level N1 cannot — it has not written to it since the switch — +// and the person who changed the roll or plugged the cable back in is the only one who +// knows. An automatic return would put the banner out while the labels were still +// coming out of the neighbour's printer, which is the one sentence a volunteer relies +// on to know where to walk. +func (s *Service) UseMain(ctx context.Context) error { + s.stateMu.Lock() + if !s.onFallback { + s.stateMu.Unlock() + return nil + } + s.onFallback = false + s.forget() + s.stateMu.Unlock() + + s.log.Technical(domain.LevelInfo, "printer", "", + fmt.Sprintf("Les étiquettes repassent sur l'imprimante du poste (%s).", s.mainName), + "retour demandé depuis l'écran de dépannage") + s.observeQueueAfterSwitch(ctx) + return nil +} + +// target is the printer the labels are going to right now. +func (s *Service) target() ports.Printer { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if s.onFallback { + return s.fallback + } + return s.main +} + +// routedName is the French name of that printer. +func (s *Service) routedName() string { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if s.onFallback { + return s.fallbackName + } + return s.mainName +} + +// observeQueueAfterSwitch re-reads the levels that can answer immediately, so that the +// screen does not keep showing LevelNone until the next label. +func (s *Service) observeQueueAfterSwitch(ctx context.Context) { s.Observe(ctx) } + +// forget drops every observation. It is called on both switches, and it is the honest +// half of the routing: what this station knew about one printer says NOTHING about +// another one, and carrying a green light across the switch would be inventing a +// measurement. The report goes back to LevelNone until something is observed. +// +// The caller holds stateMu. +func (s *Service) forget() { + s.seen = Observations{} + s.conclude() +} diff --git a/internal/printing/routing_test.go b/internal/printing/routing_test.go new file mode 100644 index 0000000..4937f7f --- /dev/null +++ b/internal/printing/routing_test.go @@ -0,0 +1,212 @@ +package printing + +import ( + "context" + "strings" + "testing" + + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// The tests of routing.go: the fallback printer, both ways. Switching FORGETS what was +// known of the other printer — a green light does not follow the switch — and a station +// with no fallback says so in French instead of refusing with no reason given. + +// --- The fallback printer, both ways --------------------------------------- + +// TestTheFallbackIsAskedForAndComesBackTheSameWay covers the switch and the return. +// +// Both directions are a HUMAN decision (§8.4): the station cannot honestly observe +// either event. What it sees when the main printer dies is a write that failed, and a +// write fails on a cable knocked loose for two seconds as readily as on a dead printer; +// an automatic switch would send a customer's label two metres away while they watch an +// empty slot. And nothing at all tells the station that the printer has been FIXED — +// the volunteer who changed the roll is the only one who knows. +func TestTheFallbackIsAskedForAndComesBackTheSameWay(t *testing.T) { + ctx := context.Background() + s := newService(t, true) + + if r := s.Routing(); r.Fallback || r.Banner != "" || !r.Available { + t.Fatalf("routage initial : %+v — le poste démarre sur son imprimante, et le bouton "+ + "« Imprimer sur l'imprimante du poste N » est offert puisqu'un secours est configuré", r) + } + if _, err := s.Print(ctx, aJob()); err != nil { + t.Fatalf("Print : %v", err) + } + + // --- towards the neighbour + if err := s.UseFallback(ctx); err != nil { + t.Fatalf("UseFallback : %v", err) + } + routing := s.Routing() + if !routing.Fallback || !strings.Contains(routing.Banner, "SATO WS408_3") { + t.Fatalf("routage après bascule : %+v — le bandeau est PERMANENT et il nomme "+ + "l'imprimante (§8.4)", routing) + } + if s.Descriptor().ID != "fallback" { + t.Errorf("le descripteur montre %q : l'écran doit montrer la machine qui imprime", + s.Descriptor().ID) + } + if _, err := s.Print(ctx, aJob()); err != nil { + t.Fatalf("Print sur le secours : %v", err) + } + if s.main.printed() != 1 || s.fallback.printed() != 1 { + t.Errorf("étiquettes : principale %d, secours %d — attendu 1 et 1", + s.main.printed(), s.fallback.printed()) + } + + // --- and back + if err := s.UseMain(ctx); err != nil { + t.Fatalf("UseMain : %v", err) + } + routing = s.Routing() + if routing.Fallback || routing.Banner != "" { + t.Fatalf("routage après retour : %+v — le bandeau disparaît quand il n'y a plus rien "+ + "à signaler", routing) + } + if _, err := s.Print(ctx, aJob()); err != nil { + t.Fatalf("Print après retour : %v", err) + } + if s.main.printed() != 2 || s.fallback.printed() != 1 { + t.Errorf("étiquettes : principale %d, secours %d — attendu 2 et 1", + s.main.printed(), s.fallback.printed()) + } + + // Both switches are journalled: somebody has to be able to answer « depuis quand + // est-ce qu'on imprime chez le voisin ? ». + var switched, returned bool + for _, line := range s.log.all() { + switched = switched || strings.Contains(line, "basculées sur l'imprimante de secours") + returned = returned || strings.Contains(line, "repassent sur l'imprimante du poste") + } + if !switched || !returned { + t.Errorf("journal : bascule=%v retour=%v — %v", switched, returned, s.log.all()) + } +} + +// TestSwitchingPrinterForgetsWhatWasKnownAboutTheOtherOne. +// +// What this station knew about one printer says NOTHING about another one. Carrying a +// green light across the switch would be inventing a measurement, which is the same +// mistake as announcing « prête » at level N1. +// The observation that has to be dropped is the LEVEL N1 one — what the last write did +// — because nothing else overwrites it: the next probe of N2 and N3 speaks to the new +// printer, but a write outcome just sits there and would go on describing a machine +// this station is no longer printing on. +func TestSwitchingPrinterForgetsWhatWasKnownAboutTheOtherOne(t *testing.T) { + ctx := context.Background() + s := newService(t, true) + // Both printers stay mute at N3, so that what the report shows can only come from + // the write that just happened — which is exactly the observation at stake. + printAndCheck := func(step string) { + t.Helper() + if _, err := s.Print(ctx, aJob()); err != nil { + t.Fatalf("%s : %v", step, err) + } + if got := s.Report().Level; got != LevelN1 { + t.Fatalf("%s : niveau = %s après une écriture réussie, attendu N1", step, got) + } + } + forgotten := func(step string) { + t.Helper() + report := s.Report() + if report.Level != LevelNone || report.Ready() { + t.Fatalf("%s : rapport = %+v, attendu « rien n'a été observé ». Ce que le poste "+ + "savait d'une imprimante ne dit RIEN d'une autre, et le résultat de la dernière "+ + "écriture est ce qu'aucune sonde ne vient remplacer", step, report) + } + } + + printAndCheck("impression sur la principale") + if err := s.UseFallback(ctx); err != nil { + t.Fatalf("UseFallback : %v", err) + } + forgotten("après la bascule vers le secours") + + printAndCheck("impression sur le secours") + if err := s.UseMain(ctx); err != nil { + t.Fatalf("UseMain : %v", err) + } + forgotten("après le retour à la principale") +} + +// TestAGreenLightDoesNotFollowTheSwitch: the neighbour's printer has not been looked at +// yet, and carrying « prête » across would be inventing a measurement — the same +// mistake as announcing « prête » at level N1. +func TestAGreenLightDoesNotFollowTheSwitch(t *testing.T) { + ctx := context.Background() + s := newService(t, true) + s.main.setStatus(ports.PrinterStatus{Health: ports.PrinterReady, Detail: "file vide"}) + + if report := s.Observe(ctx); !report.Ready() || report.Level != LevelN3 { + t.Fatalf("l'imprimante principale devait être connue prête : %+v", report) + } + if err := s.UseFallback(ctx); err != nil { + t.Fatalf("UseFallback : %v", err) + } + if report := s.Report(); report.Ready() { + t.Fatalf("le feu vert de la principale a suivi la bascule : %+v", report) + } +} + +// TestAStationWithNoFallbackSaysSoInFrench. +func TestAStationWithNoFallbackSaysSoInFrench(t *testing.T) { + s := newService(t, false) + + if r := s.Routing(); r.Available { + t.Error("un secours est annoncé disponible alors qu'aucun n'est configuré : " + + "le bouton de §14.4 ne doit pas apparaître") + } + err := s.UseFallback(context.Background()) + if err == nil { + t.Fatal("la bascule a réussi sans imprimante de secours") + } + if !strings.Contains(err.Error(), "printer.options.fallback") { + t.Errorf("message « %s » : il doit nommer la clé de configuration à renseigner", err) + } +} + +// TestSwitchingTwiceInTheSameDirectionIsANoOp: a volunteer pressing a button twice must +// not produce two journal lines and two forgotten states. +func TestSwitchingTwiceInTheSameDirectionIsANoOp(t *testing.T) { + ctx := context.Background() + s := newService(t, true) + + if err := s.UseMain(ctx); err != nil { // already on main + t.Fatalf("UseMain sur la principale : %v", err) + } + if len(s.log.all()) != 0 { + t.Errorf("journal non vide alors que rien n'a changé : %v", s.log.all()) + } + for press := 1; press <= 2; press++ { + if err := s.UseFallback(ctx); err != nil { + t.Fatalf("UseFallback, appui %d : %v", press, err) + } + } + lines := 0 + for _, line := range s.log.all() { + if strings.Contains(line, "basculées") { + lines++ + } + } + if lines != 1 { + t.Errorf("%d ligne(s) de bascule, attendu 1", lines) + } +} + +// TestAFallbackWithNoNameIsRefusedAtConstruction: a permanent banner that cannot say +// where the labels are coming out sends a volunteer looking at four printers. +func TestAFallbackWithNoNameIsRefusedAtConstruction(t *testing.T) { + _, err := NewService(ServiceOptions{ + Main: newStub("main"), + Fallback: newStub("fallback"), + Clock: fake.NewClock(testEpoch), + }) + if err == nil { + t.Fatal("une imprimante de secours sans nom a été acceptée") + } + if !strings.Contains(err.Error(), "bandeau") { + t.Errorf("message « %s » : il doit dire à quoi sert le nom", err) + } +} diff --git a/internal/printing/sbpl/command.go b/internal/printing/sbpl/command.go index fe76ab3..09673ce 100644 --- a/internal/printing/sbpl/command.go +++ b/internal/printing/sbpl/command.go @@ -1,5 +1,10 @@ package sbpl +// This file is the TYPED VOCABULARY of §8.3: one value type per SBPL field, each +// refusing at construction what the manual does not accept, plus the bounds and the +// operation names those refusals are spelled from. is the one field held apart, in +// offset.go, because its real bound is measured on the ink of the bitmap. + import ( "fmt" "image" @@ -287,138 +292,6 @@ func (g Graphic) validate() error { return nil } -// Offset is the payload of : how far the whole label travels on the media. -// -// It is the third of the three adjustments of §8.2 — the ±1 dot arrows a volunteer -// nudges a label back into place with, one dot at a time, because that is the size of -// the correction a misplaced roll needs. -// -// # WHAT BOUNDS IT, AND WHY IT IS NOT TEMPLATE RULE 6 -// -// §8.3 says this offset is « borné §7.5-6 ». Rule 6 bounds the offset OF A TEMPLATE, -// which moves ink INSIDE a fixed geometry, and it measures what is left against the -// 280 × 202 dots of ink of the production label. Applied literally to it gives, -// on the shipped weighing_identical, an admissible horizontal offset of ZERO dots — -// its ink already reaches 279.824 of the 280 — while the media it prints on is 320 -// dots wide and the head has 40 dots of bare label to the right of the last drop of -// ink. The arrows of the administration screen would be dead on arrival. -// -// The two quantities are not the same question. Rule 6 answers « does this template -// still reproduce the label of A1 »; this command answers « does the ink still land on -// the paper ». So the bound checked here is the second one, MEASURED ON THE VERY -// BITMAP ABOUT TO BE SENT: no offset may push a single INKED dot off the media. On the -// shipped template that gives x ∈ [-1 ; +40] and y ∈ [-3 ; +3]. -// -// It REFUSES rather than clamps, and it names the range it would have accepted. A -// volunteer nudging a label learns where the wall is, instead of watching the arrows -// silently stop working. -type Offset struct { - xDots int - yDots int -} - -// NewOffset declares how far the label travels, in dots of the media. -// -// It takes the graphic and the media because that is what the bound is measured -// against — the ink of this bitmap, on this stock — and it validates both rather than -// trusting them, so that it cannot be handed a forged Graphic and read a nil bitmap. -// -// The zero value is the neutral offset, and it is always admissible on any job whose -// graphic already fits its media. That is what makes Offset the one quantity of this -// package whose zero value is a legitimate configuration. -func NewOffset(xDots, yDots int, g Graphic, m MediaSize) (Offset, error) { - o := Offset{xDots: xDots, yDots: yDots} - if err := o.validate(); err != nil { - return o, err - } - return o, o.validateOn(g, m) -} - -// validate keeps the offset inside what the two ±dddd fields of can express. -// It is all Setup can check on its own, since the geometric bound needs the bitmap. -func (o Offset) validate() error { - if outside(o.xDots, -maxOffsetDots, maxOffsetDots) || outside(o.yDots, -maxOffsetDots, maxOffsetDots) { - return fault(ports.KindConfig, opOffset, - "décalage (%+d;%+d) hors bornes du champ , qui porte quatre chiffres (%+d à %+d dots)", - o.xDots, o.yDots, -maxOffsetDots, maxOffsetDots) - } - return nil -} - -// validateOn is the geometric bound: no inked dot of g may leave m. -func (o Offset) validateOn(g Graphic, m MediaSize) error { - if err := g.validate(); err != nil { - return err - } - if err := m.validate(); err != nil { - return err - } - x, y := admissibleOffsets(g, m) - if outside(o.xDots, x.low, x.high) { - return fault(ports.KindConfig, opOffset, - "décalage horizontal de %+d dots : l'encre de cette étiquette sortirait du média ; "+ - "ce rendu admet de %+d à %+d dots", o.xDots, x.low, x.high) - } - if outside(o.yDots, y.low, y.high) { - return fault(ports.KindConfig, opOffset, - "décalage vertical de %+d dots : l'encre de cette étiquette sortirait du média ; "+ - "ce rendu admet de %+d à %+d dots", o.yDots, y.low, y.high) - } - return nil -} - -// offsetRange is how far the label may travel along one axis, in dots. -type offsetRange struct{ low, high int } - -// admissibleOffsets reports that range on both axes, for the ink of g on the stock m. -// -// The ink is measured IN THE COORDINATES OF THE BITMAP, and where / then place -// the block is deliberately not part of it. The two are separate questions with -// separate answers: a block placed off the paper is a placement fault, bounded by the -// four digits of its own field, and reporting it as « ce décalage sortirait du média » -// would blame a setting a volunteer did not touch — the offset here may well be zero. -// -// A bitmap with NO ink at all — a template with nothing active on this station, a -// pattern that drew nothing — has nothing to push off the paper, so only the width of -// the field bounds it, which is what Offset.validate already enforces. -// -// The geometric range never needs clamping to that field, and it is worth saying why -// rather than adding a step nothing can exercise. A validated MediaSize is at most -// 9999 dots, and past the branch above the ink has a last column of at least 1, so the -// high bound is at most 9998. The low bound is minus the FIRST inked column, and a -// validated block is at most 999 bytes wide, so it is at worst -7991. Both ends are -// inside ±9999 by construction. -func admissibleOffsets(g Graphic, m MediaSize) (x, y offsetRange) { - ink := inkBounds(g.image) - if ink.Empty() { - whole := offsetRange{low: -maxOffsetDots, high: maxOffsetDots} - return whole, whole - } - return offsetRange{low: -ink.Min.X, high: m.widthDots - ink.Max.X}, - offsetRange{low: -ink.Min.Y, high: m.heightDots - ink.Max.Y} -} - -// inkBounds reports the smallest rectangle holding every burnt dot of img, in the -// coordinates of the image. An image with no ink returns an empty rectangle. -func inkBounds(img *image.Gray) image.Rectangle { - bounds := img.Bounds() - box := image.Rectangle{} - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - if img.GrayAt(x, y).Y >= inkThreshold { - continue - } - dot := image.Rect(x-bounds.Min.X, y-bounds.Min.Y, x-bounds.Min.X+1, y-bounds.Min.Y+1) - if box.Empty() { - box = dot - continue - } - box = box.Union(dot) - } - } - return box -} - // Setup is what wipes, and therefore what every job has to state again. // // The four of them travel together for that single reason: the manual says resets diff --git a/internal/printing/sbpl/command_test.go b/internal/printing/sbpl/command_test.go new file mode 100644 index 0000000..ac95113 --- /dev/null +++ b/internal/printing/sbpl/command_test.go @@ -0,0 +1,107 @@ +package sbpl_test + +import ( + "image" + "testing" + + "openscale/internal/printing/sbpl" + "openscale/internal/station/ports" +) + +// The tests of command.go: one bound per field. Every value SBPL can carry has a range, +// and what does not fit is REFUSED at construction rather than truncated on the wire — a +// label printed askew costs more than a refusal somebody can read. + +// --- 6. One bound check per field ------------------------------------------- + +// TestEveryFieldRefusesWhatSBPLCannotCarry is the table §8.3 asks for: one bounds +// test per field, on both sides of every bound. +// +// The zero value is in every table on purpose. It is the ONE malformed value an +// external caller can still forge — the fields are unexported, so a composite +// literal can write nothing else — and every bound of this package excludes it, +// which is what makes "a job Encode accepts is a job every field of which came out +// of a validating constructor" true. +func TestEveryFieldRefusesWhatSBPLCannotCarry(t *testing.T) { + for _, c := range []struct { + name string + build func() error + refused bool + op string + kind ports.Kind + }{ + {"média 0×0", func() error { _, err := sbpl.NewMediaSize(0, 0); return err }, true, "sbpl.media", ports.KindConfig}, + {"média 1×1", func() error { _, err := sbpl.NewMediaSize(1, 1); return err }, false, "", 0}, + {"média 9999×9999", func() error { _, err := sbpl.NewMediaSize(9999, 9999); return err }, false, "", 0}, + {"média 10000 de haut", func() error { _, err := sbpl.NewMediaSize(10000, 320); return err }, true, "sbpl.media", ports.KindConfig}, + {"média 10000 de large", func() error { _, err := sbpl.NewMediaSize(203, 10000); return err }, true, "sbpl.media", ports.KindConfig}, + {"média négatif", func() error { _, err := sbpl.NewMediaSize(-1, 320); return err }, true, "sbpl.media", ports.KindConfig}, + + {"noircissement 0", func() error { _, err := sbpl.NewDarkness(0); return err }, true, "sbpl.darkness", ports.KindConfig}, + {"noircissement 1", func() error { _, err := sbpl.NewDarkness(1); return err }, false, "", 0}, + {"noircissement 5", func() error { _, err := sbpl.NewDarkness(5); return err }, false, "", 0}, + {"noircissement 6", func() error { _, err := sbpl.NewDarkness(6); return err }, true, "sbpl.darkness", ports.KindConfig}, + + {"vitesse 1", func() error { _, err := sbpl.NewSpeed(1); return err }, true, "sbpl.speed", ports.KindConfig}, + {"vitesse 2", func() error { _, err := sbpl.NewSpeed(2); return err }, false, "", 0}, + {"vitesse 6", func() error { _, err := sbpl.NewSpeed(6); return err }, false, "", 0}, + {"vitesse 7", func() error { _, err := sbpl.NewSpeed(7); return err }, true, "sbpl.speed", ports.KindConfig}, + + {"0 exemplaire", func() error { _, err := sbpl.NewCopies(0); return err }, true, "sbpl.copies", ports.KindConfig}, + {"1 exemplaire", func() error { _, err := sbpl.NewCopies(1); return err }, false, "", 0}, + {"999999 exemplaires", func() error { _, err := sbpl.NewCopies(999_999); return err }, false, "", 0}, + {"1000000 exemplaires", func() error { _, err := sbpl.NewCopies(1_000_000); return err }, true, "sbpl.copies", ports.KindConfig}, + + {"modèle à 0 octet", func() error { _, err := sbpl.NewModel(0); return err }, true, "sbpl.model", ports.KindConfig}, + {"modèle à 1 octet", func() error { _, err := sbpl.NewModel(1); return err }, false, "", 0}, + {"modèle à 999 octets", func() error { _, err := sbpl.NewModel(999); return err }, false, "", 0}, + {"modèle à 1000 octets", func() error { _, err := sbpl.NewModel(1000); return err }, true, "sbpl.model", ports.KindConfig}, + + {"modèle forgé", func() error { + _, err := sbpl.NewGraphic(sbpl.Model{}, 0, 0, smallBitmap(), sbpl.InkIsOne) + return err + }, true, "sbpl.model", ports.KindConfig}, + {"aucun bitmap", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, nil, sbpl.InkIsOne) + return err + }, true, "sbpl.graphic", ports.KindInternal}, + {"bitmap sans surface", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, image.NewGray(image.Rect(0, 0, 0, 0)), sbpl.InkIsOne) + return err + }, true, "sbpl.graphic", ports.KindTemplate}, + {"bloc de 104 octets", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(104*8, 1), sbpl.InkIsOne) + return err + }, false, "", 0}, + {"bloc de 105 octets", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(104*8+1, 1), sbpl.InkIsOne) + return err + }, true, "sbpl.graphic", ports.KindTemplate}, + {"bloc de 600 dots de haut", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(8, 600), sbpl.InkIsOne) + return err + }, false, "", 0}, + {"bloc de 601 dots de haut", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(8, 601), sbpl.InkIsOne) + return err + }, true, "sbpl.graphic", ports.KindTemplate}, + {"polarité inconnue", func() error { + _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, smallBitmap(), sbpl.InkPolarity(7)) + return err + }, true, "sbpl.graphic", ports.KindConfig}, + } { + t.Run(c.name, func(t *testing.T) { + err := c.build() + if !c.refused { + if err != nil { + t.Fatalf("valeur refusée à tort : %v", err) + } + return + } + if err == nil { + t.Fatal("valeur hors bornes acceptée") + } + assertPrintError(t, err, c.kind, c.op) + }) + } +} diff --git a/internal/printing/sbpl/harness_test.go b/internal/printing/sbpl/harness_test.go new file mode 100644 index 0000000..2a498e9 --- /dev/null +++ b/internal/printing/sbpl/harness_test.go @@ -0,0 +1,239 @@ +package sbpl_test + +import ( + "bytes" + "errors" + "image" + "image/color" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/printing" + "openscale/internal/printing/sbpl" + "openscale/internal/station/ports" +) + +// What the tests of this package build their frames with: the bench bitmaps, the shipped +// values of §11.2, and constructors that FAIL THE TEST instead of returning an error. +// +// Those constructors exist so that the assertion stays readable: a test about a frame must +// not spend ten lines assembling a valid job before it reaches what it measures. + +// The shipped values of §11.2, and the reason the goldens carry these numbers +// rather than round ones: config-lacagette.json states darkness 3, speed 4, one +// copy. Nothing in this file invents a printer setting. +const ( + shippedDarkness = 3 + shippedSpeed = 4 + shippedCopies = 1 +) + +// The media of weighing_identical, in dots: 35 × 25 mm at 8 dots/mm, as the L0 bench +// measured it — the printer's own configuration first, a caliper on the stock second. +// It is stated here as the two numbers carries, and asserted against the template. +const ( + productionHeightDots = 200 + productionWidthDots = 280 +) + +// --- Fixtures --------------------------------------------------------------- + +// smallBitmap is sixteen dots by three, chosen so that its packing is legible in a +// golden a human reviews: a black row, a white row, and a row half of each. +// +// It reads FFFF 0000 FF00 under the shipped polarity — which is the whole point. +// A pattern picked for coverage would produce sixteen hexadecimal characters nobody +// can check by eye, and a golden nobody can check by eye records a bug just as +// faithfully as it records a frame. +func smallBitmap() *image.Gray { + img := image.NewGray(image.Rect(0, 0, 16, 3)) + ink, bare := color.Gray{Y: 0x00}, color.Gray{Y: 0xFF} + for x := 0; x < 16; x++ { + img.SetGray(x, 0, ink) + img.SetGray(x, 1, bare) + if x < 8 { + img.SetGray(x, 2, ink) + } else { + img.SetGray(x, 2, bare) + } + } + return img +} + +// smallBitmapHex is what smallBitmap must come out as, under the shipped polarity. +const smallBitmapHex = "FFFF" + "0000" + "FF00" + +// checkerboard is a bitmap that is not mostly white, so that the packing is +// exercised on both values and on every bit position of a byte. +// +// Its width is thirteen: NOT a multiple of eight, so every row ends on three padding +// bits. Those bits are the one part of the packing no dot of the label ever covers, +// and the polarity flip is where they get forgotten. +func checkerboard(width, height int) *image.Gray { + img := image.NewGray(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + shade := uint8(0xFF) + if (x+3*y)%7 < 3 || x == 0 || y == height-1 { + shade = 0x00 + } + img.SetGray(x, y, color.Gray{Y: shade}) + } + } + return img +} + +// celeryRow is row id 1153 of testdata/catalog/flv.csv, the authentic export. Its +// reference carries the 021 the reference barcode of §18 is built on, so the golden +// frame carries the very symbol internal/printing freezes its 95 modules for. +var celeryRow = domain.Product{ + ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, + CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, +} + +// referenceMass is the 1,236 kg of test vector T1. +const referenceMass = domain.Grams(1236) + +// productionBitmap renders the label of the parc, through the engine the raster +// driver uses, at the pitch of the head. +func productionBitmap(t *testing.T) *image.Gray { + t.Helper() + template := domain.IdenticalTemplate() + label, err := domain.Price(celeryRow, domain.Measurement{Gross: referenceMass}, domain.LaCagetteRules()) + if err != nil { + t.Fatalf("Price : %v", err) + } + plan, err := domain.PlanFor(celeryRow.Reference) + if err != nil { + t.Fatalf("plan du code %s : %v", celeryRow.Reference, err) + } + code, err := domain.Generate(celeryRow.Reference, int64(referenceMass), plan.PayloadWidth) + if err != nil { + t.Fatalf("Generate : %v", err) + } + label.Barcode = code + label.JobID = "test" + + img, err := printing.Rasterize(&template, label, domain.LocaleFrench, printing.RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + if got := img.Bounds(); got.Dx() != productionWidthDots || got.Dy() != productionHeightDots { + t.Fatalf("le rendu mesure %d × %d dots, le média de §7.2 en annonce %d × %d", + got.Dx(), got.Dy(), productionWidthDots, productionHeightDots) + } + return img +} + +// --- Builders that fail the test instead of returning an error --------------- + +func mustMedia(t *testing.T, heightDots, widthDots int) sbpl.MediaSize { + t.Helper() + media, err := sbpl.NewMediaSize(heightDots, widthDots) + if err != nil { + t.Fatalf("NewMediaSize(%d, %d) : %v", heightDots, widthDots, err) + } + return media +} + +// mustSetup gathers the shipped settings on a NEUTRAL offset — the zero Offset, which +// is what a station that has never been nudged sends. +func mustSetup(t *testing.T, heightDots, widthDots int) sbpl.Setup { + t.Helper() + return mustShiftedSetup(t, mustMedia(t, heightDots, widthDots), sbpl.Offset{}) +} + +func mustShiftedSetup(t *testing.T, media sbpl.MediaSize, offset sbpl.Offset) sbpl.Setup { + t.Helper() + darkness, err := sbpl.NewDarkness(shippedDarkness) + if err != nil { + t.Fatalf("NewDarkness(%d) : %v", shippedDarkness, err) + } + speed, err := sbpl.NewSpeed(shippedSpeed) + if err != nil { + t.Fatalf("NewSpeed(%d) : %v", shippedSpeed, err) + } + setup, err := sbpl.NewSetup(media, offset, darkness, speed) + if err != nil { + t.Fatalf("NewSetup : %v", err) + } + return setup +} + +func mustOffset(t *testing.T, xDots, yDots int, g sbpl.Graphic, m sbpl.MediaSize) sbpl.Offset { + t.Helper() + offset, err := sbpl.NewOffset(xDots, yDots, g, m) + if err != nil { + t.Fatalf("NewOffset(%+d, %+d) : %v", xDots, yDots, err) + } + return offset +} + +func mustGraphic(t *testing.T, x, y int, img *image.Gray, ink sbpl.InkPolarity) sbpl.Graphic { + t.Helper() + g, err := sbpl.NewGraphic(sbpl.WS408(), x, y, img, ink) + if err != nil { + t.Fatalf("NewGraphic(%d, %d) : %v", x, y, err) + } + return g +} + +func mustJob(t *testing.T, setup sbpl.Setup, graphic sbpl.Graphic, copies int) sbpl.Job { + t.Helper() + count, err := sbpl.NewCopies(copies) + if err != nil { + t.Fatalf("NewCopies(%d) : %v", copies, err) + } + job, err := sbpl.NewJob(setup, graphic, count) + if err != nil { + t.Fatalf("NewJob : %v", err) + } + return job +} + +// smallJob is the readable job: a legible bitmap on a small media. +func smallJob(t *testing.T) sbpl.Job { + t.Helper() + return mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), shippedCopies) +} + +// productionJob is the label of the parc, whole: the frame a station really sends. +func productionJob(t *testing.T) sbpl.Job { + t.Helper() + setup := mustSetup(t, productionHeightDots, productionWidthDots) + return mustJob(t, setup, mustGraphic(t, 0, 0, productionBitmap(t), sbpl.InkIsOne), shippedCopies) +} + +func encode(t *testing.T, job sbpl.Job) []byte { + t.Helper() + var frame bytes.Buffer + if err := sbpl.Encode(&frame, job); err != nil { + t.Fatalf("Encode : %v", err) + } + return frame.Bytes() +} + +// readable makes a frame quotable in a failure message: the escapes become , +// which is how §8.3 spells them, and everything else is already printable. +func readable(p []byte) string { + return strings.ReplaceAll(string(p), "\x1b", "") +} + +func assertPrintError(t *testing.T, err error, kind ports.Kind, op string) { + t.Helper() + var refusal *ports.PrintError + if !errors.As(err, &refusal) { + t.Fatalf("erreur de type %T, attendu *ports.PrintError : %v", err, err) + } + if refusal.Kind != kind { + t.Errorf("genre %s, attendu %s (message : %s)", refusal.Kind, kind, refusal.Message) + } + if refusal.Op != op { + t.Errorf("opération %q, attendue %q", refusal.Op, op) + } + if refusal.Message == "" { + t.Error("message vide : un bénévole doit lire ce qui ne va pas") + } +} diff --git a/internal/printing/sbpl/offset.go b/internal/printing/sbpl/offset.go new file mode 100644 index 0000000..b81a9e2 --- /dev/null +++ b/internal/printing/sbpl/offset.go @@ -0,0 +1,143 @@ +package sbpl + +// This file is the payload of alone: how far the whole label travels on the media, +// and the bound that refusal is measured against — the ink of the very bitmap about to +// be sent, never a rule copied from the template. + +import ( + "image" + + "openscale/internal/station/ports" +) + +// Offset is the payload of : how far the whole label travels on the media. +// +// It is the third of the three adjustments of §8.2 — the ±1 dot arrows a volunteer +// nudges a label back into place with, one dot at a time, because that is the size of +// the correction a misplaced roll needs. +// +// # WHAT BOUNDS IT, AND WHY IT IS NOT TEMPLATE RULE 6 +// +// §8.3 says this offset is « borné §7.5-6 ». Rule 6 bounds the offset OF A TEMPLATE, +// which moves ink INSIDE a fixed geometry, and it measures what is left against the +// 280 × 202 dots of ink of the production label. Applied literally to it gives, +// on the shipped weighing_identical, an admissible horizontal offset of ZERO dots — +// its ink already reaches 279.824 of the 280 — while the media it prints on is 320 +// dots wide and the head has 40 dots of bare label to the right of the last drop of +// ink. The arrows of the administration screen would be dead on arrival. +// +// The two quantities are not the same question. Rule 6 answers « does this template +// still reproduce the label of A1 »; this command answers « does the ink still land on +// the paper ». So the bound checked here is the second one, MEASURED ON THE VERY +// BITMAP ABOUT TO BE SENT: no offset may push a single INKED dot off the media. On the +// shipped template that gives x ∈ [-1 ; +40] and y ∈ [-3 ; +3]. +// +// It REFUSES rather than clamps, and it names the range it would have accepted. A +// volunteer nudging a label learns where the wall is, instead of watching the arrows +// silently stop working. +type Offset struct { + xDots int + yDots int +} + +// NewOffset declares how far the label travels, in dots of the media. +// +// It takes the graphic and the media because that is what the bound is measured +// against — the ink of this bitmap, on this stock — and it validates both rather than +// trusting them, so that it cannot be handed a forged Graphic and read a nil bitmap. +// +// The zero value is the neutral offset, and it is always admissible on any job whose +// graphic already fits its media. That is what makes Offset the one quantity of this +// package whose zero value is a legitimate configuration. +func NewOffset(xDots, yDots int, g Graphic, m MediaSize) (Offset, error) { + o := Offset{xDots: xDots, yDots: yDots} + if err := o.validate(); err != nil { + return o, err + } + return o, o.validateOn(g, m) +} + +// validate keeps the offset inside what the two ±dddd fields of can express. +// It is all Setup can check on its own, since the geometric bound needs the bitmap. +func (o Offset) validate() error { + if outside(o.xDots, -maxOffsetDots, maxOffsetDots) || outside(o.yDots, -maxOffsetDots, maxOffsetDots) { + return fault(ports.KindConfig, opOffset, + "décalage (%+d;%+d) hors bornes du champ , qui porte quatre chiffres (%+d à %+d dots)", + o.xDots, o.yDots, -maxOffsetDots, maxOffsetDots) + } + return nil +} + +// validateOn is the geometric bound: no inked dot of g may leave m. +func (o Offset) validateOn(g Graphic, m MediaSize) error { + if err := g.validate(); err != nil { + return err + } + if err := m.validate(); err != nil { + return err + } + x, y := admissibleOffsets(g, m) + if outside(o.xDots, x.low, x.high) { + return fault(ports.KindConfig, opOffset, + "décalage horizontal de %+d dots : l'encre de cette étiquette sortirait du média ; "+ + "ce rendu admet de %+d à %+d dots", o.xDots, x.low, x.high) + } + if outside(o.yDots, y.low, y.high) { + return fault(ports.KindConfig, opOffset, + "décalage vertical de %+d dots : l'encre de cette étiquette sortirait du média ; "+ + "ce rendu admet de %+d à %+d dots", o.yDots, y.low, y.high) + } + return nil +} + +// offsetRange is how far the label may travel along one axis, in dots. +type offsetRange struct{ low, high int } + +// admissibleOffsets reports that range on both axes, for the ink of g on the stock m. +// +// The ink is measured IN THE COORDINATES OF THE BITMAP, and where / then place +// the block is deliberately not part of it. The two are separate questions with +// separate answers: a block placed off the paper is a placement fault, bounded by the +// four digits of its own field, and reporting it as « ce décalage sortirait du média » +// would blame a setting a volunteer did not touch — the offset here may well be zero. +// +// A bitmap with NO ink at all — a template with nothing active on this station, a +// pattern that drew nothing — has nothing to push off the paper, so only the width of +// the field bounds it, which is what Offset.validate already enforces. +// +// The geometric range never needs clamping to that field, and it is worth saying why +// rather than adding a step nothing can exercise. A validated MediaSize is at most +// 9999 dots, and past the branch above the ink has a last column of at least 1, so the +// high bound is at most 9998. The low bound is minus the FIRST inked column, and a +// validated block is at most 999 bytes wide, so it is at worst -7991. Both ends are +// inside ±9999 by construction. +func admissibleOffsets(g Graphic, m MediaSize) (x, y offsetRange) { + ink := inkBounds(g.image) + if ink.Empty() { + whole := offsetRange{low: -maxOffsetDots, high: maxOffsetDots} + return whole, whole + } + return offsetRange{low: -ink.Min.X, high: m.widthDots - ink.Max.X}, + offsetRange{low: -ink.Min.Y, high: m.heightDots - ink.Max.Y} +} + +// inkBounds reports the smallest rectangle holding every burnt dot of img, in the +// coordinates of the image. An image with no ink returns an empty rectangle. +func inkBounds(img *image.Gray) image.Rectangle { + bounds := img.Bounds() + box := image.Rectangle{} + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if img.GrayAt(x, y).Y >= inkThreshold { + continue + } + dot := image.Rect(x-bounds.Min.X, y-bounds.Min.Y, x-bounds.Min.X+1, y-bounds.Min.Y+1) + if box.Empty() { + box = dot + continue + } + box = box.Union(dot) + } + } + return box +} diff --git a/internal/printing/sbpl/offset_test.go b/internal/printing/sbpl/offset_test.go new file mode 100644 index 0000000..38b1e32 --- /dev/null +++ b/internal/printing/sbpl/offset_test.go @@ -0,0 +1,261 @@ +package sbpl_test + +import ( + "bytes" + "errors" + "image" + "image/color" + "strings" + "testing" + + "openscale/internal/printing/sbpl" + "openscale/internal/station/ports" +) + +// The tests of offset.go: the shift of . It is bounded by the INK and not by the +// template, it is revalidated on every piece the assembly puts together, and it is never +// measured on a forged graphic — three refusals that keep a one-dot setting from pushing +// the label off the media. + +// --- 11. The offset of ------------------------------------------------- + +// bitmapWithOneInkedDot is a bare bitmap carrying a single burnt dot. +// +// One dot and not a shape: the admissible offset is read off the EDGES of the ink, so +// a fixture whose four edges are one known coordinate is a fixture whose expected +// range can be written down rather than computed by the code under test. +func bitmapWithOneInkedDot(width, height, atX, atY int) *image.Gray { + img := image.NewGray(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + shade := uint8(0xFF) + if x == atX && y == atY { + shade = 0x00 + } + img.SetGray(x, y, color.Gray{Y: shade}) + } + } + return img +} + +// TestTheOffsetIsBoundedByTheInkAndNotByTheTemplate is the rule of Offset, on a +// fixture whose ink is placed by hand so that the range can be stated instead of +// derived. +// +// smallBitmap inks the full sixteen dots of its width and its first and third rows, on +// a 16 × 24 media: there is nothing to spare horizontally, so the only admissible +// horizontal offset is zero, and vertically the label may drop by the 21 dots of bare +// stock below it. +func TestTheOffsetIsBoundedByTheInkAndNotByTheTemplate(t *testing.T) { + media := mustMedia(t, 24, 16) + graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) + + for _, c := range []struct { + name string + x, y int + refused bool + }{ + {"décalage nul", 0, 0, false}, + {"un dot à droite", 1, 0, true}, + {"un dot à gauche", -1, 0, true}, + {"dernier dot admis vers le bas", 0, 21, false}, + {"un dot de trop vers le bas", 0, 22, true}, + {"un dot vers le haut", 0, -1, true}, + } { + t.Run(c.name, func(t *testing.T) { + _, err := sbpl.NewOffset(c.x, c.y, graphic, media) + if !c.refused { + if err != nil { + t.Fatalf("décalage refusé alors que l'encre tient sur le média : %v", err) + } + return + } + if err == nil { + t.Fatal("décalage accepté : l'encre sortirait du média") + } + assertPrintError(t, err, ports.KindConfig, "sbpl.offset") + // It NAMES the range instead of saying no: a volunteer nudging a label has + // to learn where the wall is, or they keep pressing an arrow that does + // nothing. + var refusal *ports.PrintError + errors.As(err, &refusal) + if !strings.Contains(refusal.Message, "admet de") { + t.Errorf("le message ne nomme pas la plage admissible : %s", refusal.Message) + } + }) + } +} + +// TestABitmapWithNoInkIsBoundedOnlyByTheField covers the case a template with nothing +// active on this station produces: there is no ink to push off the paper, so only the +// four digits of bound the offset. +func TestABitmapWithNoInkIsBoundedOnlyByTheField(t *testing.T) { + media := mustMedia(t, 24, 16) + bare := image.NewGray(image.Rect(0, 0, 16, 3)) + for y := 0; y < 3; y++ { + for x := 0; x < 16; x++ { + bare.SetGray(x, y, color.Gray{Y: 0xFF}) + } + } + graphic := mustGraphic(t, 0, 0, bare, sbpl.InkIsOne) + + for _, extreme := range [][2]int{{9999, 9999}, {-9999, -9999}} { + mustOffset(t, extreme[0], extreme[1], graphic, media) + } + for _, past := range [][2]int{{10_000, 0}, {0, -10_000}} { + _, err := sbpl.NewOffset(past[0], past[1], graphic, media) + if err == nil { + t.Fatalf("décalage (%+d;%+d) accepté : ne porte que quatre chiffres", past[0], past[1]) + } + assertPrintError(t, err, ports.KindConfig, "sbpl.offset") + } +} + +// TestTheGeometricRangeAlwaysFitsTheField is the claim admissibleOffsets makes in +// prose, held on the two extremes the typed constructors can actually reach: the +// widest stock, and a block whose ink sits as far into it as a validated Graphic +// allows. Both ends stay inside the four digits of . +func TestTheGeometricRangeAlwaysFitsTheField(t *testing.T) { + widest, err := sbpl.NewModel(999) + if err != nil { + t.Fatalf("NewModel(999) : %v", err) + } + // 7992 dots of block on 9999 dots of stock, ink on the very last column: the range + // runs from -7991 to +2007, and neither end needs the field to be looked at. + block, err := sbpl.NewGraphic(widest, 0, 0, bitmapWithOneInkedDot(999*8, 1, 999*8-1, 0), sbpl.InkIsOne) + if err != nil { + t.Fatalf("NewGraphic : %v", err) + } + media := mustMedia(t, 9999, 9999) + mustOffset(t, -7991, 0, block, media) + mustOffset(t, 2007, 0, block, media) + for _, past := range [][2]int{{-7992, 0}, {2008, 0}} { + if _, err := sbpl.NewOffset(past[0], past[1], block, media); err == nil { + t.Errorf("décalage (%+d;%+d) accepté hors de la plage géométrique", past[0], past[1]) + } + } +} + +// TestAnOffsetMeasuredOnAnotherBitmapIsRefusedAtAssembly is the cross-field check of +// NewJob, and the one hole a per-field validation leaves open. +// +// Each half is valid on its own: the offset was measured against a bitmap with room to +// spare, the graphic fits its media. Together they push ink off the paper, and the +// only place that can see it is the assembly. +func TestAnOffsetMeasuredOnAnotherBitmapIsRefusedAtAssembly(t *testing.T) { + media := mustMedia(t, 24, 16) + roomy := mustGraphic(t, 0, 0, bitmapWithOneInkedDot(16, 3, 0, 0), sbpl.InkIsOne) + offset := mustOffset(t, 8, 0, roomy, media) + + full := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) + copies, err := sbpl.NewCopies(1) + if err != nil { + t.Fatalf("NewCopies : %v", err) + } + job, err := sbpl.NewJob(mustShiftedSetup(t, media, offset), full, copies) + if err == nil { + t.Fatal("NewJob a accepté un décalage mesuré sur un autre bitmap") + } + assertPrintError(t, err, ports.KindConfig, "sbpl.offset") + + transport := &countingWriter{} + if err := sbpl.Encode(transport, job); err == nil { + t.Fatal("Encode a accepté le même travail") + } + if transport.written != 0 { + t.Errorf("%d octets sont partis avant le refus", transport.written) + } +} + +// TestASetupRevalidatesEveryPartItGathers is the claim NewSetup makes: it validates +// its parts again rather than trusting them. +// +// Two of the four are forgeable as a zero value from outside — Darkness{} is a burn +// level of zero and Speed{} an inch per second of zero, neither of which any bound +// admits. The other two need the value a refusing constructor RETURNS: NewOffset and +// NewMediaSize hand back the thing they refused, which is the only way an external +// caller holds one, and it is exactly what this test needs. +func TestASetupRevalidatesEveryPartItGathers(t *testing.T) { + media := mustMedia(t, 24, 16) + graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) + darkness, err := sbpl.NewDarkness(shippedDarkness) + if err != nil { + t.Fatalf("NewDarkness : %v", err) + } + speed, err := sbpl.NewSpeed(shippedSpeed) + if err != nil { + t.Fatalf("NewSpeed : %v", err) + } + pastTheField, _ := sbpl.NewOffset(10_000, 0, graphic, media) + + for _, c := range []struct { + name string + media sbpl.MediaSize + offset sbpl.Offset + darkness sbpl.Darkness + speed sbpl.Speed + op string + }{ + {"média forgé", sbpl.MediaSize{}, sbpl.Offset{}, darkness, speed, "sbpl.media"}, + {"décalage hors champ", media, pastTheField, darkness, speed, "sbpl.offset"}, + {"noircissement forgé", media, sbpl.Offset{}, sbpl.Darkness{}, speed, "sbpl.darkness"}, + {"vitesse forgée", media, sbpl.Offset{}, darkness, sbpl.Speed{}, "sbpl.speed"}, + } { + t.Run(c.name, func(t *testing.T) { + _, err := sbpl.NewSetup(c.media, c.offset, c.darkness, c.speed) + if err == nil { + t.Fatal("NewSetup a accepté une partie invalide") + } + assertPrintError(t, err, ports.KindConfig, c.op) + }) + } +} + +// TestAnOffsetCannotBeMeasuredOnAForgedGraphic: NewOffset validates what it measures +// against, so that a zero-value Graphic cannot make it read a nil bitmap. +func TestAnOffsetCannotBeMeasuredOnAForgedGraphic(t *testing.T) { + media := mustMedia(t, 24, 16) + graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) + + _, err := sbpl.NewOffset(0, 0, sbpl.Graphic{}, media) + if err == nil { + t.Fatal("NewOffset a accepté un graphique forgé") + } + assertPrintError(t, err, ports.KindConfig, "sbpl.model") + + _, err = sbpl.NewOffset(0, 0, graphic, sbpl.MediaSize{}) + if err == nil { + t.Fatal("NewOffset a accepté un média forgé") + } + assertPrintError(t, err, ports.KindConfig, "sbpl.media") +} + +// TestTheOffsetReachesTheFrameSignAndAxisIncluded is the last link of the third +// adjustment of §8.2: the number a volunteer typed comes out in . +// +// V carries the VERTICAL axis and H the horizontal one — the reverse of the (x;y) of +// every other coordinate of this application, which is exactly the kind of swap that +// survives a review and shifts every label of the parc. +func TestTheOffsetReachesTheFrameSignAndAxisIncluded(t *testing.T) { + media := mustMedia(t, 24, 32) + // One dot at (4;4) on a 32 × 24 stock: four dots of slack up and left, so the + // negative offsets this test needs are legitimate rather than tolerated. + graphic := mustGraphic(t, 0, 0, bitmapWithOneInkedDot(16, 8, 4, 4), sbpl.InkIsOne) + + for _, c := range []struct { + x, y int + want string + }{ + {0, 0, "\x1bA3V+0000H+0000"}, + {2, -3, "\x1bA3V-0003H+0002"}, + {-2, 3, "\x1bA3V+0003H-0002"}, + {27, 19, "\x1bA3V+0019H+0027"}, + } { + offset := mustOffset(t, c.x, c.y, graphic, media) + frame := encode(t, mustJob(t, mustShiftedSetup(t, media, offset), graphic, 1)) + if !bytes.Contains(frame, []byte(c.want)) { + t.Errorf("décalage (%+d;%+d) : %s attendu dans %s", + c.x, c.y, readable([]byte(c.want)), readable(excerpt(frame, 15))) + } + } +} diff --git a/internal/printing/sbpl/refusal_test.go b/internal/printing/sbpl/refusal_test.go new file mode 100644 index 0000000..d48063c --- /dev/null +++ b/internal/printing/sbpl/refusal_test.go @@ -0,0 +1,141 @@ +package sbpl_test + +import ( + "errors" + "strings" + "testing" + + "openscale/internal/printing/sbpl" + "openscale/internal/station/ports" +) + +// What happens when it does not go through: a refused job writes NOTHING AT ALL to the +// transport — not one byte, not the beginning of a frame the printer would then sit +// waiting to see finished — and a transport that refuses is reported as TRANSIENT, +// because that is a cable or a queue, never a wrong label. + +// --- 5. A refused job leaves the transport untouched ------------------------ + +// countingWriter accepts everything and remembers how much. +type countingWriter struct{ written int } + +func (w *countingWriter) Write(p []byte) (int, error) { + w.written += len(p) + return len(p), nil +} + +// TestARefusedJobWritesNothingAtAll is property 3 of the package documentation, and +// it is the one departure from the sketch of §8.3 that has teeth. +// +// That sketch validates each command as it writes it, so a job whose is too wide +// puts , , , <#E>, and <%> on the wire and then stops: the printer is +// left mid-job, with every parameter reset and nothing to print, and the next job +// starts on top of it. Validating the whole job first costs one traversal and removes +// the state entirely. +// +// The invalid jobs come out of NewJob itself, which returns the job it refused — +// that is the only way an external caller can hold one, and it is exactly the value +// this test needs. +func TestARefusedJobWritesNothingAtAll(t *testing.T) { + valid := smallJob(t) + setup := mustSetup(t, 24, 16) + graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) + copies, err := sbpl.NewCopies(shippedCopies) + if err != nil { + t.Fatalf("NewCopies : %v", err) + } + + tooWide, _ := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(105*8, 1), sbpl.InkIsOne) + forgedSetup, _ := sbpl.NewJob(sbpl.Setup{}, graphic, copies) + forgedGraphic, _ := sbpl.NewJob(setup, sbpl.Graphic{}, copies) + forgedCopies, _ := sbpl.NewJob(setup, graphic, sbpl.Copies{}) + oversized, _ := sbpl.NewJob(setup, tooWide, copies) + + for _, c := range []struct { + name string + job sbpl.Job + op string + kind ports.Kind + }{ + {"travail vide", sbpl.Job{}, "sbpl.media", ports.KindConfig}, + {"réglages forgés", forgedSetup, "sbpl.media", ports.KindConfig}, + {"graphique forgé", forgedGraphic, "sbpl.model", ports.KindConfig}, + {"exemplaires forgés", forgedCopies, "sbpl.copies", ports.KindConfig}, + {"bloc trop large", oversized, "sbpl.graphic", ports.KindTemplate}, + } { + t.Run(c.name, func(t *testing.T) { + transport := &countingWriter{} + err := sbpl.Encode(transport, c.job) + if err == nil { + t.Fatal("Encode a accepté un travail invalide") + } + if transport.written != 0 { + t.Errorf("%d octets sont partis sur le transport avant le refus : "+ + "l'imprimante reste en plein travail", transport.written) + } + assertPrintError(t, err, c.kind, c.op) + }) + } + + // And the valid job of the same shape does reach the transport, so the test + // above is not passing because nothing ever gets written. + transport := &countingWriter{} + if err := sbpl.Encode(transport, valid); err != nil { + t.Fatalf("Encode d'un travail valide : %v", err) + } + if transport.written == 0 { + t.Error("un travail valide n'a rien écrit : le test des refus ne prouve rien") + } +} + +// --- 9. What the transport says, and what the driver announces -------------- + +// errRefused is what a device that stops taking bytes looks like from here. +var errRefused = errors.New("le périphérique a refusé l'écriture") + +// failingWriter accepts a fixed number of bytes and then refuses everything. +type failingWriter struct { + accept int + written int +} + +func (w *failingWriter) Write(p []byte) (int, error) { + if w.written+len(p) > w.accept { + return 0, errRefused + } + w.written += len(p) + return len(p), nil +} + +// TestATransportThatRefusesIsTransient checks the one failure this package can meet +// at write time, and the policy it carries. +// +// A device that stops taking bytes is exactly what the two retries of §8.2 exist +// for. Reporting it as anything but KindTransient would make the print service give +// up on a printer that was merely busy. +// +// 60 is in the table by measurement, not by taste: the ten commands around the bitmap +// weigh exactly that on this job, so a device that accepts 60 bytes and no more is one +// that dies on the FIRST BYTE OF THE PAYLOAD — the one write of the encoder that is +// not a formatted command, and the one carrying 16 kB behind it. +func TestATransportThatRefusesIsTransient(t *testing.T) { + for _, accept := range []int{0, 4, 30, 60} { + transport := &failingWriter{accept: accept} + err := sbpl.Encode(transport, smallJob(t)) + if err == nil { + t.Fatalf("un transport qui refuse après %d octets n'a produit aucune erreur", accept) + } + assertPrintError(t, err, ports.KindTransient, "sbpl.encode") + var refusal *ports.PrintError + errors.As(err, &refusal) + if !refusal.Retryable() { + t.Error("une panne de transport doit être réessayable (§8.5)") + } + if !errors.Is(err, errRefused) { + t.Errorf("l'erreur du transport n'est pas enveloppée : %v", err) + } + if !strings.Contains(err.Error(), "sbpl.encode") { + t.Errorf("le message ne nomme pas l'opération : %v", err) + } + } +} diff --git a/internal/printing/sbpl/sbpl_test.go b/internal/printing/sbpl/sbpl_test.go index 3366c8c..06de1a4 100644 --- a/internal/printing/sbpl/sbpl_test.go +++ b/internal/printing/sbpl/sbpl_test.go @@ -27,232 +27,28 @@ import ( "bytes" "crypto/sha256" "encoding/hex" - "errors" "flag" "fmt" - "go/ast" - "go/parser" - "go/token" - "go/types" "image" - "image/color" "os" "path/filepath" - "sort" "strconv" "strings" "testing" - "openscale/internal/domain" - "openscale/internal/printing" "openscale/internal/printing/sbpl" "openscale/internal/station/ports" ) +// This file holds the FRAME itself: the eleven commands in their order, the goldens, the +// determinism, the origin, the bitmap that comes back out of the hexadecimal, the declared +// volume and the descriptor. The bounds per field, the offset, the exported surface +// and what a refused job leaves on the transport each have their own file; the fixtures +// are in harness_test.go. + var update = flag.Bool("update", false, "réécrit les golden .sbpl de testdata/golden/ au lieu de les comparer") -// The shipped values of §11.2, and the reason the goldens carry these numbers -// rather than round ones: config-lacagette.json states darkness 3, speed 4, one -// copy. Nothing in this file invents a printer setting. -const ( - shippedDarkness = 3 - shippedSpeed = 4 - shippedCopies = 1 -) - -// The media of weighing_identical, in dots: 35 × 25 mm at 8 dots/mm, as the L0 bench -// measured it — the printer's own configuration first, a caliper on the stock second. -// It is stated here as the two numbers carries, and asserted against the template. -const ( - productionHeightDots = 200 - productionWidthDots = 280 -) - -// --- Fixtures --------------------------------------------------------------- - -// smallBitmap is sixteen dots by three, chosen so that its packing is legible in a -// golden a human reviews: a black row, a white row, and a row half of each. -// -// It reads FFFF 0000 FF00 under the shipped polarity — which is the whole point. -// A pattern picked for coverage would produce sixteen hexadecimal characters nobody -// can check by eye, and a golden nobody can check by eye records a bug just as -// faithfully as it records a frame. -func smallBitmap() *image.Gray { - img := image.NewGray(image.Rect(0, 0, 16, 3)) - ink, bare := color.Gray{Y: 0x00}, color.Gray{Y: 0xFF} - for x := 0; x < 16; x++ { - img.SetGray(x, 0, ink) - img.SetGray(x, 1, bare) - if x < 8 { - img.SetGray(x, 2, ink) - } else { - img.SetGray(x, 2, bare) - } - } - return img -} - -// smallBitmapHex is what smallBitmap must come out as, under the shipped polarity. -const smallBitmapHex = "FFFF" + "0000" + "FF00" - -// checkerboard is a bitmap that is not mostly white, so that the packing is -// exercised on both values and on every bit position of a byte. -// -// Its width is thirteen: NOT a multiple of eight, so every row ends on three padding -// bits. Those bits are the one part of the packing no dot of the label ever covers, -// and the polarity flip is where they get forgotten. -func checkerboard(width, height int) *image.Gray { - img := image.NewGray(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - shade := uint8(0xFF) - if (x+3*y)%7 < 3 || x == 0 || y == height-1 { - shade = 0x00 - } - img.SetGray(x, y, color.Gray{Y: shade}) - } - } - return img -} - -// celeryRow is row id 1153 of testdata/catalog/flv.csv, the authentic export. Its -// reference carries the 021 the reference barcode of §18 is built on, so the golden -// frame carries the very symbol internal/printing freezes its 95 modules for. -var celeryRow = domain.Product{ - ID: "1153", Name: "CELERI BRANCHE SAF", Reference: "0493021000003", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 335, - CategoryCode: "L", Qualification: domain.Weighable, CSVLine: 1153, -} - -// referenceMass is the 1,236 kg of test vector T1. -const referenceMass = domain.Grams(1236) - -// productionBitmap renders the label of the parc, through the engine the raster -// driver uses, at the pitch of the head. -func productionBitmap(t *testing.T) *image.Gray { - t.Helper() - template := domain.IdenticalTemplate() - label, err := domain.Price(celeryRow, domain.Measurement{Gross: referenceMass}, domain.LaCagetteRules()) - if err != nil { - t.Fatalf("Price : %v", err) - } - plan, err := domain.PlanFor(celeryRow.Reference) - if err != nil { - t.Fatalf("plan du code %s : %v", celeryRow.Reference, err) - } - code, err := domain.Generate(celeryRow.Reference, int64(referenceMass), plan.PayloadWidth) - if err != nil { - t.Fatalf("Generate : %v", err) - } - label.Barcode = code - label.JobID = "test" - - img, err := printing.Rasterize(&template, label, domain.LocaleFrench, printing.RenderOptions{}) - if err != nil { - t.Fatalf("Rasterize : %v", err) - } - if got := img.Bounds(); got.Dx() != productionWidthDots || got.Dy() != productionHeightDots { - t.Fatalf("le rendu mesure %d × %d dots, le média de §7.2 en annonce %d × %d", - got.Dx(), got.Dy(), productionWidthDots, productionHeightDots) - } - return img -} - -// --- Builders that fail the test instead of returning an error --------------- - -func mustMedia(t *testing.T, heightDots, widthDots int) sbpl.MediaSize { - t.Helper() - media, err := sbpl.NewMediaSize(heightDots, widthDots) - if err != nil { - t.Fatalf("NewMediaSize(%d, %d) : %v", heightDots, widthDots, err) - } - return media -} - -// mustSetup gathers the shipped settings on a NEUTRAL offset — the zero Offset, which -// is what a station that has never been nudged sends. -func mustSetup(t *testing.T, heightDots, widthDots int) sbpl.Setup { - t.Helper() - return mustShiftedSetup(t, mustMedia(t, heightDots, widthDots), sbpl.Offset{}) -} - -func mustShiftedSetup(t *testing.T, media sbpl.MediaSize, offset sbpl.Offset) sbpl.Setup { - t.Helper() - darkness, err := sbpl.NewDarkness(shippedDarkness) - if err != nil { - t.Fatalf("NewDarkness(%d) : %v", shippedDarkness, err) - } - speed, err := sbpl.NewSpeed(shippedSpeed) - if err != nil { - t.Fatalf("NewSpeed(%d) : %v", shippedSpeed, err) - } - setup, err := sbpl.NewSetup(media, offset, darkness, speed) - if err != nil { - t.Fatalf("NewSetup : %v", err) - } - return setup -} - -func mustOffset(t *testing.T, xDots, yDots int, g sbpl.Graphic, m sbpl.MediaSize) sbpl.Offset { - t.Helper() - offset, err := sbpl.NewOffset(xDots, yDots, g, m) - if err != nil { - t.Fatalf("NewOffset(%+d, %+d) : %v", xDots, yDots, err) - } - return offset -} - -func mustGraphic(t *testing.T, x, y int, img *image.Gray, ink sbpl.InkPolarity) sbpl.Graphic { - t.Helper() - g, err := sbpl.NewGraphic(sbpl.WS408(), x, y, img, ink) - if err != nil { - t.Fatalf("NewGraphic(%d, %d) : %v", x, y, err) - } - return g -} - -func mustJob(t *testing.T, setup sbpl.Setup, graphic sbpl.Graphic, copies int) sbpl.Job { - t.Helper() - count, err := sbpl.NewCopies(copies) - if err != nil { - t.Fatalf("NewCopies(%d) : %v", copies, err) - } - job, err := sbpl.NewJob(setup, graphic, count) - if err != nil { - t.Fatalf("NewJob : %v", err) - } - return job -} - -// smallJob is the readable job: a legible bitmap on a small media. -func smallJob(t *testing.T) sbpl.Job { - t.Helper() - return mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), shippedCopies) -} - -// productionJob is the label of the parc, whole: the frame a station really sends. -func productionJob(t *testing.T) sbpl.Job { - t.Helper() - setup := mustSetup(t, productionHeightDots, productionWidthDots) - return mustJob(t, setup, mustGraphic(t, 0, 0, productionBitmap(t), sbpl.InkIsOne), shippedCopies) -} - -func encode(t *testing.T, job sbpl.Job) []byte { - t.Helper() - var frame bytes.Buffer - if err := sbpl.Encode(&frame, job); err != nil { - t.Fatalf("Encode : %v", err) - } - return frame.Bytes() -} - -// readable makes a frame quotable in a failure message: the escapes become , -// which is how §8.3 spells them, and everything else is already printable. -func readable(p []byte) string { - return strings.ReplaceAll(string(p), "\x1b", "") -} - // --- 1. The frame is the eleven commands of §8.3, in that order -------------- // TestTheFrameIsTheElevenCommandsOfTheDocument spells the whole frame out, byte for @@ -483,418 +279,6 @@ func TestNothingInThePackageIteratesAMap(t *testing.T) { } } -// --- 4. A frame without its framing cannot be expressed --------------------- - -// TestEveryFrameOpensWithAAndClosesWithZ walks the whole boundary of what the API -// can express and finds the framing on every single output. -// -// is what triggers the print: a job that lost it leaves a printer holding a -// label it will never release, and a job that lost runs on whatever the previous -// one left behind — which §8.3 says is everything. -func TestEveryFrameOpensWithAAndClosesWithZ(t *testing.T) { - wide, err := sbpl.NewModel(999) - if err != nil { - t.Fatalf("NewModel(999) : %v", err) - } - widest, err := sbpl.NewGraphic(wide, 0, 0, checkerboard(999*8, 1), sbpl.InkIsOne) - if err != nil { - t.Fatalf("NewGraphic sur le bloc le plus large : %v", err) - } - - for _, c := range []struct { - name string - job sbpl.Job - }{ - {"média minimal", mustJob(t, mustSetup(t, 1, 1), mustGraphic(t, 0, 0, checkerboard(1, 1), sbpl.InkIsOne), 1)}, - {"média maximal", mustJob(t, mustSetup(t, 9999, 9999), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), 1)}, - {"position maximale", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 9998, 9998, smallBitmap(), sbpl.InkIsOne), 1)}, - {"bloc le plus large", mustJob(t, mustSetup(t, 9999, 9999), widest, 1)}, - {"bloc le plus haut", mustJob(t, mustSetup(t, 600, 16), mustGraphic(t, 0, 0, checkerboard(13, 600), sbpl.InkIsOne), 1)}, - {"polarité inversée", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsZero), 1)}, - {"exemplaires au maximum", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), 999_999)}, - {"étiquette de production", productionJob(t)}, - } { - t.Run(c.name, func(t *testing.T) { - frame := encode(t, c.job) - if !bytes.HasPrefix(frame, []byte("\x02\x1bA\x1bA1")) { - t.Errorf("la trame ne commence pas par STX : %s", readable(excerpt(frame, 0))) - } - if !bytes.HasSuffix(frame, []byte("\x1bZ\x03")) { - t.Errorf("la trame ne se termine pas par ETX : %s", readable(excerpt(frame, len(frame)))) - } - if n := bytes.Count(frame, []byte("\x1bZ")); n != 1 { - t.Errorf(" apparaît %d fois, il en faut exactement une", n) - } - }) - } -} - -// TestNoExportedIdentifierCanEmitACommandOnItsOwn is the demonstration the sequence -// is unforgeable, and it is a demonstration about the TYPES, not about the bytes. -// -// The claim of the package documentation is that no expression outside this package -// denotes a frame lacking or . That holds for exactly two structural reasons, -// and both are checked here rather than asserted in a comment: -// -// 1. the exported surface contains no type whose values are a command or a sequence -// of commands — the frozen list below is the whole of it, and every name in it -// is either a quantity, an error or the single entry point; -// 2. Encode is the only exported function that receives an io.Writer, so it is the -// only expression that can put a byte anywhere. -// -// Go cannot make the ZERO value of an exported struct inexpressible, so sbpl.Job{} -// remains writable — and it is refused, which the refusal tests below show. What is -// inexpressible is a NON-EMPTY frame that lost its framing, and that is the property -// that protects a printer. -// -// The frozen list is the point of this test: it fails the day someone exports a -// Begin, an End, a Command or an Encoder, which is the day the property dies. -func TestNoExportedIdentifierCanEmitACommandOnItsOwn(t *testing.T) { - // Every exported name of the package, and what each one is FOR. - frozen := []string{ - // The identity of the driver (§8.1). - "ID", "Descriptor", - // The typed quantities: one per field of one command, no more. The taxonomy of - // §8.5 is NOT among them: it is the contract between a driver and the station, - // so it lives in internal/station/ports and every driver raises the same one. - "Model", "WS408", "NewModel", - "MediaSize", "NewMediaSize", - "Offset", "NewOffset", - "Darkness", "NewDarkness", - "Speed", "NewSpeed", - "InkPolarity", "InkIsOne", "InkIsZero", - "Graphic", "NewGraphic", - "Copies", "NewCopies", - "Setup", "NewSetup", - // The job, and the ONE function that writes. - "Job", "NewJob", "Encode", - // The OTHER direction of the wire: what a station sends to ask this printer how - // it is, and the reading of what comes back (§8.5, level N3). None of the three - // is a command or a piece of the sequence, and none of them writes: they - // live here because the status frame is SBPL, so both drivers of §8.1 read it - // with these and neither keeps a copy of a table measured on a bench. - "Enquiry", "StatusFault", "FaultOfStatusFrame", - } - - exported, writers := exportedSurface(t) - sort.Strings(frozen) - // missing(want, got) reports what is in want and absent from got, so each diff is - // read in the direction of its first argument. Spelled the other way round, the two - // messages below told whoever added an export that a frozen name had disappeared. - if diff := missing(exported, frozen); len(diff) > 0 { - t.Errorf("identifiant(s) exporté(s) que ce test ne connaît pas : %s — si c'est une "+ - "commande ou un morceau de séquence, la propriété « une trame sans ni est "+ - "inexprimable » vient de mourir ; sinon, ajoutez-les à la liste gelée", strings.Join(diff, ", ")) - } - if diff := missing(frozen, exported); len(diff) > 0 { - t.Errorf("identifiant(s) gelé(s) qui n'existent plus : %s", strings.Join(diff, ", ")) - } - if len(writers) != 1 || writers[0] != "Encode" { - t.Errorf("les fonctions exportées qui reçoivent un io.Writer sont %v : il ne doit y "+ - "en avoir qu'une, Encode, sinon un appelant peut écrire des octets sans passer "+ - "par l'encadrement ", writers) - } -} - -// exportedSurface reports every exported top-level identifier of the package, plus -// the exported functions that receive an io.Writer. -// -// It parses the production sources of the package itself. Reflection would not do: -// it only sees the types a test names, so a newly exported one would be invisible -// to exactly the check meant to catch it. -func exportedSurface(t *testing.T) (names, writers []string) { - t.Helper() - fset := token.NewFileSet() - for _, path := range productionSources(t) { - file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) - if err != nil { - t.Fatalf("analyse de %s : %v", path, err) - } - for _, declaration := range file.Decls { - switch d := declaration.(type) { - case *ast.FuncDecl: - name := functionName(d) - if name == "" { - continue - } - names = append(names, name) - if takesAWriter(d.Type) { - writers = append(writers, name) - } - case *ast.GenDecl: - names = append(names, exportedSpecs(d)...) - } - } - } - sort.Strings(names) - return names, writers -} - -// functionName reports "Name" for a function and "Type.Name" for a method, or the -// empty string when it is unexported or hangs off an unexported type. -func functionName(d *ast.FuncDecl) string { - if !d.Name.IsExported() { - return "" - } - if d.Recv == nil || len(d.Recv.List) == 0 { - return d.Name.Name - } - receiver := strings.TrimPrefix(types.ExprString(d.Recv.List[0].Type), "*") - if !ast.IsExported(receiver) { - return "" - } - return receiver + "." + d.Name.Name -} - -// exportedSpecs reports the exported names a type, const or var block declares. -func exportedSpecs(d *ast.GenDecl) []string { - var names []string - for _, spec := range d.Specs { - switch s := spec.(type) { - case *ast.TypeSpec: - if s.Name.IsExported() { - names = append(names, s.Name.Name) - } - case *ast.ValueSpec: - for _, name := range s.Names { - if name.IsExported() { - names = append(names, name.Name) - } - } - } - } - return names -} - -func takesAWriter(signature *ast.FuncType) bool { - if signature.Params == nil { - return false - } - for _, parameter := range signature.Params.List { - if types.ExprString(parameter.Type) == "io.Writer" { - return true - } - } - return false -} - -// productionSources lists the .go files of the package, tests excluded. -func productionSources(t *testing.T) []string { - t.Helper() - all, err := filepath.Glob("*.go") - if err != nil { - t.Fatalf("énumération des sources : %v", err) - } - var sources []string - for _, path := range all { - if !strings.HasSuffix(path, "_test.go") { - sources = append(sources, path) - } - } - if len(sources) == 0 { - t.Fatal("aucune source de production trouvée : le test ne vérifie rien") - } - return sources -} - -// missing reports the elements of want that are absent from got, which must be -// sorted. -func missing(want, got []string) []string { - var absent []string - for _, name := range want { - at := sort.SearchStrings(got, name) - if at == len(got) || got[at] != name { - absent = append(absent, name) - } - } - return absent -} - -// --- 5. A refused job leaves the transport untouched ------------------------ - -// countingWriter accepts everything and remembers how much. -type countingWriter struct{ written int } - -func (w *countingWriter) Write(p []byte) (int, error) { - w.written += len(p) - return len(p), nil -} - -// TestARefusedJobWritesNothingAtAll is property 3 of the package documentation, and -// it is the one departure from the sketch of §8.3 that has teeth. -// -// That sketch validates each command as it writes it, so a job whose is too wide -// puts , , , <#E>, and <%> on the wire and then stops: the printer is -// left mid-job, with every parameter reset and nothing to print, and the next job -// starts on top of it. Validating the whole job first costs one traversal and removes -// the state entirely. -// -// The invalid jobs come out of NewJob itself, which returns the job it refused — -// that is the only way an external caller can hold one, and it is exactly the value -// this test needs. -func TestARefusedJobWritesNothingAtAll(t *testing.T) { - valid := smallJob(t) - setup := mustSetup(t, 24, 16) - graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) - copies, err := sbpl.NewCopies(shippedCopies) - if err != nil { - t.Fatalf("NewCopies : %v", err) - } - - tooWide, _ := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(105*8, 1), sbpl.InkIsOne) - forgedSetup, _ := sbpl.NewJob(sbpl.Setup{}, graphic, copies) - forgedGraphic, _ := sbpl.NewJob(setup, sbpl.Graphic{}, copies) - forgedCopies, _ := sbpl.NewJob(setup, graphic, sbpl.Copies{}) - oversized, _ := sbpl.NewJob(setup, tooWide, copies) - - for _, c := range []struct { - name string - job sbpl.Job - op string - kind ports.Kind - }{ - {"travail vide", sbpl.Job{}, "sbpl.media", ports.KindConfig}, - {"réglages forgés", forgedSetup, "sbpl.media", ports.KindConfig}, - {"graphique forgé", forgedGraphic, "sbpl.model", ports.KindConfig}, - {"exemplaires forgés", forgedCopies, "sbpl.copies", ports.KindConfig}, - {"bloc trop large", oversized, "sbpl.graphic", ports.KindTemplate}, - } { - t.Run(c.name, func(t *testing.T) { - transport := &countingWriter{} - err := sbpl.Encode(transport, c.job) - if err == nil { - t.Fatal("Encode a accepté un travail invalide") - } - if transport.written != 0 { - t.Errorf("%d octets sont partis sur le transport avant le refus : "+ - "l'imprimante reste en plein travail", transport.written) - } - assertPrintError(t, err, c.kind, c.op) - }) - } - - // And the valid job of the same shape does reach the transport, so the test - // above is not passing because nothing ever gets written. - transport := &countingWriter{} - if err := sbpl.Encode(transport, valid); err != nil { - t.Fatalf("Encode d'un travail valide : %v", err) - } - if transport.written == 0 { - t.Error("un travail valide n'a rien écrit : le test des refus ne prouve rien") - } -} - -func assertPrintError(t *testing.T, err error, kind ports.Kind, op string) { - t.Helper() - var refusal *ports.PrintError - if !errors.As(err, &refusal) { - t.Fatalf("erreur de type %T, attendu *ports.PrintError : %v", err, err) - } - if refusal.Kind != kind { - t.Errorf("genre %s, attendu %s (message : %s)", refusal.Kind, kind, refusal.Message) - } - if refusal.Op != op { - t.Errorf("opération %q, attendue %q", refusal.Op, op) - } - if refusal.Message == "" { - t.Error("message vide : un bénévole doit lire ce qui ne va pas") - } -} - -// --- 6. One bound check per field ------------------------------------------- - -// TestEveryFieldRefusesWhatSBPLCannotCarry is the table §8.3 asks for: one bounds -// test per field, on both sides of every bound. -// -// The zero value is in every table on purpose. It is the ONE malformed value an -// external caller can still forge — the fields are unexported, so a composite -// literal can write nothing else — and every bound of this package excludes it, -// which is what makes "a job Encode accepts is a job every field of which came out -// of a validating constructor" true. -func TestEveryFieldRefusesWhatSBPLCannotCarry(t *testing.T) { - for _, c := range []struct { - name string - build func() error - refused bool - op string - kind ports.Kind - }{ - {"média 0×0", func() error { _, err := sbpl.NewMediaSize(0, 0); return err }, true, "sbpl.media", ports.KindConfig}, - {"média 1×1", func() error { _, err := sbpl.NewMediaSize(1, 1); return err }, false, "", 0}, - {"média 9999×9999", func() error { _, err := sbpl.NewMediaSize(9999, 9999); return err }, false, "", 0}, - {"média 10000 de haut", func() error { _, err := sbpl.NewMediaSize(10000, 320); return err }, true, "sbpl.media", ports.KindConfig}, - {"média 10000 de large", func() error { _, err := sbpl.NewMediaSize(203, 10000); return err }, true, "sbpl.media", ports.KindConfig}, - {"média négatif", func() error { _, err := sbpl.NewMediaSize(-1, 320); return err }, true, "sbpl.media", ports.KindConfig}, - - {"noircissement 0", func() error { _, err := sbpl.NewDarkness(0); return err }, true, "sbpl.darkness", ports.KindConfig}, - {"noircissement 1", func() error { _, err := sbpl.NewDarkness(1); return err }, false, "", 0}, - {"noircissement 5", func() error { _, err := sbpl.NewDarkness(5); return err }, false, "", 0}, - {"noircissement 6", func() error { _, err := sbpl.NewDarkness(6); return err }, true, "sbpl.darkness", ports.KindConfig}, - - {"vitesse 1", func() error { _, err := sbpl.NewSpeed(1); return err }, true, "sbpl.speed", ports.KindConfig}, - {"vitesse 2", func() error { _, err := sbpl.NewSpeed(2); return err }, false, "", 0}, - {"vitesse 6", func() error { _, err := sbpl.NewSpeed(6); return err }, false, "", 0}, - {"vitesse 7", func() error { _, err := sbpl.NewSpeed(7); return err }, true, "sbpl.speed", ports.KindConfig}, - - {"0 exemplaire", func() error { _, err := sbpl.NewCopies(0); return err }, true, "sbpl.copies", ports.KindConfig}, - {"1 exemplaire", func() error { _, err := sbpl.NewCopies(1); return err }, false, "", 0}, - {"999999 exemplaires", func() error { _, err := sbpl.NewCopies(999_999); return err }, false, "", 0}, - {"1000000 exemplaires", func() error { _, err := sbpl.NewCopies(1_000_000); return err }, true, "sbpl.copies", ports.KindConfig}, - - {"modèle à 0 octet", func() error { _, err := sbpl.NewModel(0); return err }, true, "sbpl.model", ports.KindConfig}, - {"modèle à 1 octet", func() error { _, err := sbpl.NewModel(1); return err }, false, "", 0}, - {"modèle à 999 octets", func() error { _, err := sbpl.NewModel(999); return err }, false, "", 0}, - {"modèle à 1000 octets", func() error { _, err := sbpl.NewModel(1000); return err }, true, "sbpl.model", ports.KindConfig}, - - {"modèle forgé", func() error { - _, err := sbpl.NewGraphic(sbpl.Model{}, 0, 0, smallBitmap(), sbpl.InkIsOne) - return err - }, true, "sbpl.model", ports.KindConfig}, - {"aucun bitmap", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, nil, sbpl.InkIsOne) - return err - }, true, "sbpl.graphic", ports.KindInternal}, - {"bitmap sans surface", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, image.NewGray(image.Rect(0, 0, 0, 0)), sbpl.InkIsOne) - return err - }, true, "sbpl.graphic", ports.KindTemplate}, - {"bloc de 104 octets", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(104*8, 1), sbpl.InkIsOne) - return err - }, false, "", 0}, - {"bloc de 105 octets", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(104*8+1, 1), sbpl.InkIsOne) - return err - }, true, "sbpl.graphic", ports.KindTemplate}, - {"bloc de 600 dots de haut", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(8, 600), sbpl.InkIsOne) - return err - }, false, "", 0}, - {"bloc de 601 dots de haut", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, checkerboard(8, 601), sbpl.InkIsOne) - return err - }, true, "sbpl.graphic", ports.KindTemplate}, - {"polarité inconnue", func() error { - _, err := sbpl.NewGraphic(sbpl.WS408(), 0, 0, smallBitmap(), sbpl.InkPolarity(7)) - return err - }, true, "sbpl.graphic", ports.KindConfig}, - } { - t.Run(c.name, func(t *testing.T) { - err := c.build() - if !c.refused { - if err != nil { - t.Fatalf("valeur refusée à tort : %v", err) - } - return - } - if err == nil { - t.Fatal("valeur hors bornes acceptée") - } - assertPrintError(t, err, c.kind, c.op) - }) - } -} - // --- 7. The origin, dot number one ------------------------------------------ // TestTheGraphicBlockIsNumberedFromOne is the assertion §8.3 spells out by hand: the @@ -1043,58 +427,6 @@ func firstLowercase(s string) string { return "" } -// --- 9. What the transport says, and what the driver announces -------------- - -// errRefused is what a device that stops taking bytes looks like from here. -var errRefused = errors.New("le périphérique a refusé l'écriture") - -// failingWriter accepts a fixed number of bytes and then refuses everything. -type failingWriter struct { - accept int - written int -} - -func (w *failingWriter) Write(p []byte) (int, error) { - if w.written+len(p) > w.accept { - return 0, errRefused - } - w.written += len(p) - return len(p), nil -} - -// TestATransportThatRefusesIsTransient checks the one failure this package can meet -// at write time, and the policy it carries. -// -// A device that stops taking bytes is exactly what the two retries of §8.2 exist -// for. Reporting it as anything but KindTransient would make the print service give -// up on a printer that was merely busy. -// -// 60 is in the table by measurement, not by taste: the ten commands around the bitmap -// weigh exactly that on this job, so a device that accepts 60 bytes and no more is one -// that dies on the FIRST BYTE OF THE PAYLOAD — the one write of the encoder that is -// not a formatted command, and the one carrying 16 kB behind it. -func TestATransportThatRefusesIsTransient(t *testing.T) { - for _, accept := range []int{0, 4, 30, 60} { - transport := &failingWriter{accept: accept} - err := sbpl.Encode(transport, smallJob(t)) - if err == nil { - t.Fatalf("un transport qui refuse après %d octets n'a produit aucune erreur", accept) - } - assertPrintError(t, err, ports.KindTransient, "sbpl.encode") - var refusal *ports.PrintError - errors.As(err, &refusal) - if !refusal.Retryable() { - t.Error("une panne de transport doit être réessayable (§8.5)") - } - if !errors.Is(err, errRefused) { - t.Errorf("l'erreur du transport n'est pas enveloppée : %v", err) - } - if !strings.Contains(err.Error(), "sbpl.encode") { - t.Errorf("le message ne nomme pas l'opération : %v", err) - } - } -} - // The vocabulary of the six kinds, the spelling of each and the retry policy that // follows from them are tested where the taxonomy now lives: they are the contract // between a driver and the station, not a property of this encapsulation. See @@ -1169,249 +501,6 @@ func TestTheProductionFrameWeighsWhatTheDocumentSays(t *testing.T) { } } -// --- 11. The offset of ------------------------------------------------- - -// bitmapWithOneInkedDot is a bare bitmap carrying a single burnt dot. -// -// One dot and not a shape: the admissible offset is read off the EDGES of the ink, so -// a fixture whose four edges are one known coordinate is a fixture whose expected -// range can be written down rather than computed by the code under test. -func bitmapWithOneInkedDot(width, height, atX, atY int) *image.Gray { - img := image.NewGray(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { - shade := uint8(0xFF) - if x == atX && y == atY { - shade = 0x00 - } - img.SetGray(x, y, color.Gray{Y: shade}) - } - } - return img -} - -// TestTheOffsetIsBoundedByTheInkAndNotByTheTemplate is the rule of Offset, on a -// fixture whose ink is placed by hand so that the range can be stated instead of -// derived. -// -// smallBitmap inks the full sixteen dots of its width and its first and third rows, on -// a 16 × 24 media: there is nothing to spare horizontally, so the only admissible -// horizontal offset is zero, and vertically the label may drop by the 21 dots of bare -// stock below it. -func TestTheOffsetIsBoundedByTheInkAndNotByTheTemplate(t *testing.T) { - media := mustMedia(t, 24, 16) - graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) - - for _, c := range []struct { - name string - x, y int - refused bool - }{ - {"décalage nul", 0, 0, false}, - {"un dot à droite", 1, 0, true}, - {"un dot à gauche", -1, 0, true}, - {"dernier dot admis vers le bas", 0, 21, false}, - {"un dot de trop vers le bas", 0, 22, true}, - {"un dot vers le haut", 0, -1, true}, - } { - t.Run(c.name, func(t *testing.T) { - _, err := sbpl.NewOffset(c.x, c.y, graphic, media) - if !c.refused { - if err != nil { - t.Fatalf("décalage refusé alors que l'encre tient sur le média : %v", err) - } - return - } - if err == nil { - t.Fatal("décalage accepté : l'encre sortirait du média") - } - assertPrintError(t, err, ports.KindConfig, "sbpl.offset") - // It NAMES the range instead of saying no: a volunteer nudging a label has - // to learn where the wall is, or they keep pressing an arrow that does - // nothing. - var refusal *ports.PrintError - errors.As(err, &refusal) - if !strings.Contains(refusal.Message, "admet de") { - t.Errorf("le message ne nomme pas la plage admissible : %s", refusal.Message) - } - }) - } -} - -// TestABitmapWithNoInkIsBoundedOnlyByTheField covers the case a template with nothing -// active on this station produces: there is no ink to push off the paper, so only the -// four digits of bound the offset. -func TestABitmapWithNoInkIsBoundedOnlyByTheField(t *testing.T) { - media := mustMedia(t, 24, 16) - bare := image.NewGray(image.Rect(0, 0, 16, 3)) - for y := 0; y < 3; y++ { - for x := 0; x < 16; x++ { - bare.SetGray(x, y, color.Gray{Y: 0xFF}) - } - } - graphic := mustGraphic(t, 0, 0, bare, sbpl.InkIsOne) - - for _, extreme := range [][2]int{{9999, 9999}, {-9999, -9999}} { - mustOffset(t, extreme[0], extreme[1], graphic, media) - } - for _, past := range [][2]int{{10_000, 0}, {0, -10_000}} { - _, err := sbpl.NewOffset(past[0], past[1], graphic, media) - if err == nil { - t.Fatalf("décalage (%+d;%+d) accepté : ne porte que quatre chiffres", past[0], past[1]) - } - assertPrintError(t, err, ports.KindConfig, "sbpl.offset") - } -} - -// TestTheGeometricRangeAlwaysFitsTheField is the claim admissibleOffsets makes in -// prose, held on the two extremes the typed constructors can actually reach: the -// widest stock, and a block whose ink sits as far into it as a validated Graphic -// allows. Both ends stay inside the four digits of . -func TestTheGeometricRangeAlwaysFitsTheField(t *testing.T) { - widest, err := sbpl.NewModel(999) - if err != nil { - t.Fatalf("NewModel(999) : %v", err) - } - // 7992 dots of block on 9999 dots of stock, ink on the very last column: the range - // runs from -7991 to +2007, and neither end needs the field to be looked at. - block, err := sbpl.NewGraphic(widest, 0, 0, bitmapWithOneInkedDot(999*8, 1, 999*8-1, 0), sbpl.InkIsOne) - if err != nil { - t.Fatalf("NewGraphic : %v", err) - } - media := mustMedia(t, 9999, 9999) - mustOffset(t, -7991, 0, block, media) - mustOffset(t, 2007, 0, block, media) - for _, past := range [][2]int{{-7992, 0}, {2008, 0}} { - if _, err := sbpl.NewOffset(past[0], past[1], block, media); err == nil { - t.Errorf("décalage (%+d;%+d) accepté hors de la plage géométrique", past[0], past[1]) - } - } -} - -// TestAnOffsetMeasuredOnAnotherBitmapIsRefusedAtAssembly is the cross-field check of -// NewJob, and the one hole a per-field validation leaves open. -// -// Each half is valid on its own: the offset was measured against a bitmap with room to -// spare, the graphic fits its media. Together they push ink off the paper, and the -// only place that can see it is the assembly. -func TestAnOffsetMeasuredOnAnotherBitmapIsRefusedAtAssembly(t *testing.T) { - media := mustMedia(t, 24, 16) - roomy := mustGraphic(t, 0, 0, bitmapWithOneInkedDot(16, 3, 0, 0), sbpl.InkIsOne) - offset := mustOffset(t, 8, 0, roomy, media) - - full := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) - copies, err := sbpl.NewCopies(1) - if err != nil { - t.Fatalf("NewCopies : %v", err) - } - job, err := sbpl.NewJob(mustShiftedSetup(t, media, offset), full, copies) - if err == nil { - t.Fatal("NewJob a accepté un décalage mesuré sur un autre bitmap") - } - assertPrintError(t, err, ports.KindConfig, "sbpl.offset") - - transport := &countingWriter{} - if err := sbpl.Encode(transport, job); err == nil { - t.Fatal("Encode a accepté le même travail") - } - if transport.written != 0 { - t.Errorf("%d octets sont partis avant le refus", transport.written) - } -} - -// TestASetupRevalidatesEveryPartItGathers is the claim NewSetup makes: it validates -// its parts again rather than trusting them. -// -// Two of the four are forgeable as a zero value from outside — Darkness{} is a burn -// level of zero and Speed{} an inch per second of zero, neither of which any bound -// admits. The other two need the value a refusing constructor RETURNS: NewOffset and -// NewMediaSize hand back the thing they refused, which is the only way an external -// caller holds one, and it is exactly what this test needs. -func TestASetupRevalidatesEveryPartItGathers(t *testing.T) { - media := mustMedia(t, 24, 16) - graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) - darkness, err := sbpl.NewDarkness(shippedDarkness) - if err != nil { - t.Fatalf("NewDarkness : %v", err) - } - speed, err := sbpl.NewSpeed(shippedSpeed) - if err != nil { - t.Fatalf("NewSpeed : %v", err) - } - pastTheField, _ := sbpl.NewOffset(10_000, 0, graphic, media) - - for _, c := range []struct { - name string - media sbpl.MediaSize - offset sbpl.Offset - darkness sbpl.Darkness - speed sbpl.Speed - op string - }{ - {"média forgé", sbpl.MediaSize{}, sbpl.Offset{}, darkness, speed, "sbpl.media"}, - {"décalage hors champ", media, pastTheField, darkness, speed, "sbpl.offset"}, - {"noircissement forgé", media, sbpl.Offset{}, sbpl.Darkness{}, speed, "sbpl.darkness"}, - {"vitesse forgée", media, sbpl.Offset{}, darkness, sbpl.Speed{}, "sbpl.speed"}, - } { - t.Run(c.name, func(t *testing.T) { - _, err := sbpl.NewSetup(c.media, c.offset, c.darkness, c.speed) - if err == nil { - t.Fatal("NewSetup a accepté une partie invalide") - } - assertPrintError(t, err, ports.KindConfig, c.op) - }) - } -} - -// TestAnOffsetCannotBeMeasuredOnAForgedGraphic: NewOffset validates what it measures -// against, so that a zero-value Graphic cannot make it read a nil bitmap. -func TestAnOffsetCannotBeMeasuredOnAForgedGraphic(t *testing.T) { - media := mustMedia(t, 24, 16) - graphic := mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne) - - _, err := sbpl.NewOffset(0, 0, sbpl.Graphic{}, media) - if err == nil { - t.Fatal("NewOffset a accepté un graphique forgé") - } - assertPrintError(t, err, ports.KindConfig, "sbpl.model") - - _, err = sbpl.NewOffset(0, 0, graphic, sbpl.MediaSize{}) - if err == nil { - t.Fatal("NewOffset a accepté un média forgé") - } - assertPrintError(t, err, ports.KindConfig, "sbpl.media") -} - -// TestTheOffsetReachesTheFrameSignAndAxisIncluded is the last link of the third -// adjustment of §8.2: the number a volunteer typed comes out in . -// -// V carries the VERTICAL axis and H the horizontal one — the reverse of the (x;y) of -// every other coordinate of this application, which is exactly the kind of swap that -// survives a review and shifts every label of the parc. -func TestTheOffsetReachesTheFrameSignAndAxisIncluded(t *testing.T) { - media := mustMedia(t, 24, 32) - // One dot at (4;4) on a 32 × 24 stock: four dots of slack up and left, so the - // negative offsets this test needs are legitimate rather than tolerated. - graphic := mustGraphic(t, 0, 0, bitmapWithOneInkedDot(16, 8, 4, 4), sbpl.InkIsOne) - - for _, c := range []struct { - x, y int - want string - }{ - {0, 0, "\x1bA3V+0000H+0000"}, - {2, -3, "\x1bA3V-0003H+0002"}, - {-2, 3, "\x1bA3V+0003H-0002"}, - {27, 19, "\x1bA3V+0019H+0027"}, - } { - offset := mustOffset(t, c.x, c.y, graphic, media) - frame := encode(t, mustJob(t, mustShiftedSetup(t, media, offset), graphic, 1)) - if !bytes.Contains(frame, []byte(c.want)) { - t.Errorf("décalage (%+d;%+d) : %s attendu dans %s", - c.x, c.y, readable([]byte(c.want)), readable(excerpt(frame, 15))) - } - } -} - // --- 12. The merge changed nothing on the wire ------------------------------ // The frame of the reference weighing, byte for byte. diff --git a/internal/printing/sbpl/surface_test.go b/internal/printing/sbpl/surface_test.go new file mode 100644 index 0000000..53eb6ff --- /dev/null +++ b/internal/printing/sbpl/surface_test.go @@ -0,0 +1,247 @@ +package sbpl_test + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "go/types" + "path/filepath" + "sort" + "strings" + "testing" + + "openscale/internal/printing/sbpl" +) + +// The property this package claims first: a caller CANNOT express a frame without and +// . It is demonstrated by walking the exported surface of the production package — +// every public function, every method — and checking that not one of them can emit a +// command on its own. + +// --- 4. A frame without its framing cannot be expressed --------------------- + +// TestEveryFrameOpensWithAAndClosesWithZ walks the whole boundary of what the API +// can express and finds the framing on every single output. +// +// is what triggers the print: a job that lost it leaves a printer holding a +// label it will never release, and a job that lost runs on whatever the previous +// one left behind — which §8.3 says is everything. +func TestEveryFrameOpensWithAAndClosesWithZ(t *testing.T) { + wide, err := sbpl.NewModel(999) + if err != nil { + t.Fatalf("NewModel(999) : %v", err) + } + widest, err := sbpl.NewGraphic(wide, 0, 0, checkerboard(999*8, 1), sbpl.InkIsOne) + if err != nil { + t.Fatalf("NewGraphic sur le bloc le plus large : %v", err) + } + + for _, c := range []struct { + name string + job sbpl.Job + }{ + {"média minimal", mustJob(t, mustSetup(t, 1, 1), mustGraphic(t, 0, 0, checkerboard(1, 1), sbpl.InkIsOne), 1)}, + {"média maximal", mustJob(t, mustSetup(t, 9999, 9999), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), 1)}, + {"position maximale", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 9998, 9998, smallBitmap(), sbpl.InkIsOne), 1)}, + {"bloc le plus large", mustJob(t, mustSetup(t, 9999, 9999), widest, 1)}, + {"bloc le plus haut", mustJob(t, mustSetup(t, 600, 16), mustGraphic(t, 0, 0, checkerboard(13, 600), sbpl.InkIsOne), 1)}, + {"polarité inversée", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsZero), 1)}, + {"exemplaires au maximum", mustJob(t, mustSetup(t, 24, 16), mustGraphic(t, 0, 0, smallBitmap(), sbpl.InkIsOne), 999_999)}, + {"étiquette de production", productionJob(t)}, + } { + t.Run(c.name, func(t *testing.T) { + frame := encode(t, c.job) + if !bytes.HasPrefix(frame, []byte("\x02\x1bA\x1bA1")) { + t.Errorf("la trame ne commence pas par STX : %s", readable(excerpt(frame, 0))) + } + if !bytes.HasSuffix(frame, []byte("\x1bZ\x03")) { + t.Errorf("la trame ne se termine pas par ETX : %s", readable(excerpt(frame, len(frame)))) + } + if n := bytes.Count(frame, []byte("\x1bZ")); n != 1 { + t.Errorf(" apparaît %d fois, il en faut exactement une", n) + } + }) + } +} + +// TestNoExportedIdentifierCanEmitACommandOnItsOwn is the demonstration the sequence +// is unforgeable, and it is a demonstration about the TYPES, not about the bytes. +// +// The claim of the package documentation is that no expression outside this package +// denotes a frame lacking or . That holds for exactly two structural reasons, +// and both are checked here rather than asserted in a comment: +// +// 1. the exported surface contains no type whose values are a command or a sequence +// of commands — the frozen list below is the whole of it, and every name in it +// is either a quantity, an error or the single entry point; +// 2. Encode is the only exported function that receives an io.Writer, so it is the +// only expression that can put a byte anywhere. +// +// Go cannot make the ZERO value of an exported struct inexpressible, so sbpl.Job{} +// remains writable — and it is refused, which the refusal tests below show. What is +// inexpressible is a NON-EMPTY frame that lost its framing, and that is the property +// that protects a printer. +// +// The frozen list is the point of this test: it fails the day someone exports a +// Begin, an End, a Command or an Encoder, which is the day the property dies. +func TestNoExportedIdentifierCanEmitACommandOnItsOwn(t *testing.T) { + // Every exported name of the package, and what each one is FOR. + frozen := []string{ + // The identity of the driver (§8.1). + "ID", "Descriptor", + // The typed quantities: one per field of one command, no more. The taxonomy of + // §8.5 is NOT among them: it is the contract between a driver and the station, + // so it lives in internal/station/ports and every driver raises the same one. + "Model", "WS408", "NewModel", + "MediaSize", "NewMediaSize", + "Offset", "NewOffset", + "Darkness", "NewDarkness", + "Speed", "NewSpeed", + "InkPolarity", "InkIsOne", "InkIsZero", + "Graphic", "NewGraphic", + "Copies", "NewCopies", + "Setup", "NewSetup", + // The job, and the ONE function that writes. + "Job", "NewJob", "Encode", + // The OTHER direction of the wire: what a station sends to ask this printer how + // it is, and the reading of what comes back (§8.5, level N3). None of the three + // is a command or a piece of the sequence, and none of them writes: they + // live here because the status frame is SBPL, so both drivers of §8.1 read it + // with these and neither keeps a copy of a table measured on a bench. + "Enquiry", "StatusFault", "FaultOfStatusFrame", + } + + exported, writers := exportedSurface(t) + sort.Strings(frozen) + // missing(want, got) reports what is in want and absent from got, so each diff is + // read in the direction of its first argument. Spelled the other way round, the two + // messages below told whoever added an export that a frozen name had disappeared. + if diff := missing(exported, frozen); len(diff) > 0 { + t.Errorf("identifiant(s) exporté(s) que ce test ne connaît pas : %s — si c'est une "+ + "commande ou un morceau de séquence, la propriété « une trame sans ni est "+ + "inexprimable » vient de mourir ; sinon, ajoutez-les à la liste gelée", strings.Join(diff, ", ")) + } + if diff := missing(frozen, exported); len(diff) > 0 { + t.Errorf("identifiant(s) gelé(s) qui n'existent plus : %s", strings.Join(diff, ", ")) + } + if len(writers) != 1 || writers[0] != "Encode" { + t.Errorf("les fonctions exportées qui reçoivent un io.Writer sont %v : il ne doit y "+ + "en avoir qu'une, Encode, sinon un appelant peut écrire des octets sans passer "+ + "par l'encadrement ", writers) + } +} + +// exportedSurface reports every exported top-level identifier of the package, plus +// the exported functions that receive an io.Writer. +// +// It parses the production sources of the package itself. Reflection would not do: +// it only sees the types a test names, so a newly exported one would be invisible +// to exactly the check meant to catch it. +func exportedSurface(t *testing.T) (names, writers []string) { + t.Helper() + fset := token.NewFileSet() + for _, path := range productionSources(t) { + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("analyse de %s : %v", path, err) + } + for _, declaration := range file.Decls { + switch d := declaration.(type) { + case *ast.FuncDecl: + name := functionName(d) + if name == "" { + continue + } + names = append(names, name) + if takesAWriter(d.Type) { + writers = append(writers, name) + } + case *ast.GenDecl: + names = append(names, exportedSpecs(d)...) + } + } + } + sort.Strings(names) + return names, writers +} + +// functionName reports "Name" for a function and "Type.Name" for a method, or the +// empty string when it is unexported or hangs off an unexported type. +func functionName(d *ast.FuncDecl) string { + if !d.Name.IsExported() { + return "" + } + if d.Recv == nil || len(d.Recv.List) == 0 { + return d.Name.Name + } + receiver := strings.TrimPrefix(types.ExprString(d.Recv.List[0].Type), "*") + if !ast.IsExported(receiver) { + return "" + } + return receiver + "." + d.Name.Name +} + +// exportedSpecs reports the exported names a type, const or var block declares. +func exportedSpecs(d *ast.GenDecl) []string { + var names []string + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if s.Name.IsExported() { + names = append(names, s.Name.Name) + } + case *ast.ValueSpec: + for _, name := range s.Names { + if name.IsExported() { + names = append(names, name.Name) + } + } + } + } + return names +} + +func takesAWriter(signature *ast.FuncType) bool { + if signature.Params == nil { + return false + } + for _, parameter := range signature.Params.List { + if types.ExprString(parameter.Type) == "io.Writer" { + return true + } + } + return false +} + +// productionSources lists the .go files of the package, tests excluded. +func productionSources(t *testing.T) []string { + t.Helper() + all, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("énumération des sources : %v", err) + } + var sources []string + for _, path := range all { + if !strings.HasSuffix(path, "_test.go") { + sources = append(sources, path) + } + } + if len(sources) == 0 { + t.Fatal("aucune source de production trouvée : le test ne vérifie rien") + } + return sources +} + +// missing reports the elements of want that are absent from got, which must be +// sorted. +func missing(want, got []string) []string { + var absent []string + for _, name := range want { + at := sort.SearchStrings(got, name) + if at == len(got) || got[at] != name { + absent = append(absent, name) + } + } + return absent +} diff --git a/internal/printing/service.go b/internal/printing/service.go index ec27029..58bfb27 100644 --- a/internal/printing/service.go +++ b/internal/printing/service.go @@ -1,5 +1,9 @@ package printing +// This file is the printer of this station as the station OPERATES it: one job at a +// time, two retries, the three status levels of §8.5 and the roll behind them. WHICH +// printer a label comes out of is the other half, and it is in routing.go. + import ( "context" "errors" @@ -309,105 +313,6 @@ func (s *Service) Report() StatusReport { // changé le rouleau » and the recalibration behind it. func (s *Service) Roll() *RollCounter { return s.roll } -// Routing is which printer the labels are coming out of, and what the screen says about -// it. -type Routing struct { - // Fallback reports that the station is on the neighbour's printer. - Fallback bool - // Name is the FRENCH name of the printer in use. - Name string - // Banner is the PERMANENT banner of §8.4, in French. Empty on the main printer: - // there is nothing to warn about when everything is where it belongs. - Banner string - // Available reports that a fallback is configured at all, which is what decides - // whether the button « Imprimer sur l'imprimante du poste N » is offered (§14.4). - Available bool -} - -// Routing reports which printer is in use. -func (s *Service) Routing() Routing { - s.stateMu.Lock() - defer s.stateMu.Unlock() - r := Routing{Fallback: s.onFallback, Name: s.mainName, Available: s.fallback != nil} - if s.onFallback { - r.Name = s.fallbackName - r.Banner = fmt.Sprintf("Les étiquettes sortent sur l'imprimante de secours (%s).", s.fallbackName) - } - return r -} - -// UseFallback routes printing to the fallback printer FOR THE CURRENT SESSION (§8.4, -// bloquant-8). -// -// # Asked for, never automatic — and §8.4 is the one that decides -// -// The document describes an explicit button on the troubleshooting screen, « Imprimer -// sur l'imprimante du poste N », and a permanent banner. It is worth saying why that is -// the right call rather than a timid one, because « switch automatically when the main -// printer fails » sounds like a service. -// -// Nothing observable would trigger it honestly. What the station can see is a write -// that failed, and a write fails on a cable knocked loose for two seconds as readily as -// on a dead printer (important-7 is the same lesson from the other end: we do not -// confirm a physical event with a probe that does not observe it). An automatic switch -// would therefore move a customer's label two metres away, silently, on a transient — -// and the customer is standing at THIS station, watching a slot that stays empty. -// -// And it does not scale down the way it must. The four printers of the parc are two -// metres apart and each is the fallback of its neighbour; a network hiccup that touches -// all four would pile all four stations onto one printer, which is how a bad afternoon -// becomes a closed shop. -// -// So the switch is a human decision, taken by someone who has looked at the printer, -// and the banner is permanent because the same human has to remember to come back. -func (s *Service) UseFallback(ctx context.Context) error { - s.stateMu.Lock() - if s.fallback == nil { - s.stateMu.Unlock() - return errors.New("aucune imprimante de secours n'est configurée sur ce poste : " + - "renseignez printer.options.fallback (transport et file de l'imprimante voisine)") - } - if s.onFallback { - s.stateMu.Unlock() - return nil - } - s.onFallback = true - s.forget() - s.stateMu.Unlock() - - s.log.Technical(domain.LevelWarn, "printer", "", - fmt.Sprintf("Les étiquettes sont basculées sur l'imprimante de secours (%s).", s.fallbackName), - "bascule demandée depuis l'écran de dépannage ; elle dure jusqu'au retour explicite ou "+ - "jusqu'au redémarrage du service") - s.observeQueueAfterSwitch(ctx) - return nil -} - -// UseMain routes printing back to the main printer. -// -// Also asked for, and for the mirror reason: NOTHING tells this station that the main -// printer has been fixed. Level N1 cannot — it has not written to it since the switch — -// and the person who changed the roll or plugged the cable back in is the only one who -// knows. An automatic return would put the banner out while the labels were still -// coming out of the neighbour's printer, which is the one sentence a volunteer relies -// on to know where to walk. -func (s *Service) UseMain(ctx context.Context) error { - s.stateMu.Lock() - if !s.onFallback { - s.stateMu.Unlock() - return nil - } - s.onFallback = false - s.forget() - s.stateMu.Unlock() - - s.log.Technical(domain.LevelInfo, "printer", "", - fmt.Sprintf("Les étiquettes repassent sur l'imprimante du poste (%s).", s.mainName), - "retour demandé depuis l'écran de dépannage") - s.observeQueueAfterSwitch(ctx) - return nil -} - // Close releases both printers. It is idempotent: the Hub closes on a configuration // reload and again on shutdown (§11.4, §13.4), and a handle already released is not // news. @@ -429,26 +334,6 @@ func (s *Service) Close() error { return err } -// target is the printer the labels are going to right now. -func (s *Service) target() ports.Printer { - s.stateMu.Lock() - defer s.stateMu.Unlock() - if s.onFallback { - return s.fallback - } - return s.main -} - -// routedName is the French name of that printer. -func (s *Service) routedName() string { - s.stateMu.Lock() - defer s.stateMu.Unlock() - if s.onFallback { - return s.fallbackName - } - return s.mainName -} - // isClosed reports whether Close has run. func (s *Service) isClosed() bool { s.stateMu.Lock() @@ -464,21 +349,6 @@ func (s *Service) observeWrite(w *WriteOutcome) { s.conclude() } -// observeQueueAfterSwitch re-reads the levels that can answer immediately, so that the -// screen does not keep showing LevelNone until the next label. -func (s *Service) observeQueueAfterSwitch(ctx context.Context) { s.Observe(ctx) } - -// forget drops every observation. It is called on both switches, and it is the honest -// half of the routing: what this station knew about one printer says NOTHING about -// another one, and carrying a green light across the switch would be inventing a -// measurement. The report goes back to LevelNone until something is observed. -// -// The caller holds stateMu. -func (s *Service) forget() { - s.seen = Observations{} - s.conclude() -} - // conclude re-runs the assessment and journals a CHANGE of health. The caller holds // stateMu. func (s *Service) conclude() StatusReport { diff --git a/internal/printing/service_test.go b/internal/printing/service_test.go index 90d7a09..68b2b08 100644 --- a/internal/printing/service_test.go +++ b/internal/printing/service_test.go @@ -3,9 +3,7 @@ package printing import ( "context" "errors" - "runtime" "strings" - "sync" "testing" "time" @@ -14,187 +12,12 @@ import ( "openscale/internal/station/ports" ) -// testEpoch is where every clock in this file starts. Any instant does; a fixed one -// keeps a failure message reproducible. -var testEpoch = time.Date(2026, 7, 25, 14, 32, 5, 0, time.UTC) - -// transientError and permanentError are the two answers of the §8.5 taxonomy this -// service actually branches on, built as the ONE type a driver raises since the two -// copies were merged into ports.PrintError. +// The tests of the print service: the roll counter that NEVER blocks a print, the label in +// flight when the roll runs out, the retries on the injected clock, and the self-tests with +// their lifecycle. // -// They are named for the POLICY rather than for the kind, because the policy is what -// these tests are about: only a transient failure is tried again, and the choice of -// KindTemplate for the permanent one is arbitrary — any kind but transient would do, -// which is exactly the property under test. -func transientError(message string) error { - return &ports.PrintError{Kind: ports.KindTransient, Op: "stub.Print", Message: message} -} - -func permanentError(message string) error { - return &ports.PrintError{Kind: ports.KindTemplate, Op: "stub.Print", Message: message} -} - -// stubPrinter is a ports.Printer that records what it was asked and answers what a test -// told it to answer. -type stubPrinter struct { - id string - - mu sync.Mutex - jobs []ports.PrintJob - selfTests []string - // failures is consumed one per Print, front first. An exhausted list means success. - failures []error - status ports.PrinterStatus - statusCalls int - closes int - // hangs makes Print block until the context is done, which is failure test 6. - hangs bool - // attempts receives one token per Print call, so a test can step through retries - // without polling anything. - attempts chan struct{} -} - -func newStub(id string) *stubPrinter { - return &stubPrinter{id: id, attempts: make(chan struct{}, 8), - status: ports.PrinterStatus{Health: ports.PrinterUnknown}} -} - -func (p *stubPrinter) Descriptor() domain.PrinterDescriptor { - return domain.PrinterDescriptor{ID: p.id, Label: "stub " + p.id} -} - -func (p *stubPrinter) Print(ctx context.Context, job ports.PrintJob) (ports.PrintReceipt, error) { - p.mu.Lock() - hangs := p.hangs - var err error - if len(p.failures) > 0 { - err, p.failures = p.failures[0], p.failures[1:] - } - if err == nil && !hangs { - p.jobs = append(p.jobs, job) - } - p.mu.Unlock() - - select { - case p.attempts <- struct{}{}: - default: - } - if hangs { - <-ctx.Done() - return ports.PrintReceipt{}, ctx.Err() - } - if err != nil { - return ports.PrintReceipt{}, err - } - return ports.PrintReceipt{JobID: job.Label.JobID, Bytes: 16310}, nil -} - -func (p *stubPrinter) Status(context.Context) ports.PrinterStatus { - p.mu.Lock() - defer p.mu.Unlock() - p.statusCalls++ - return p.status -} - -func (p *stubPrinter) SelfTest(_ context.Context, what string) error { - p.mu.Lock() - defer p.mu.Unlock() - p.selfTests = append(p.selfTests, what) - if len(p.failures) > 0 { - var err error - err, p.failures = p.failures[0], p.failures[1:] - return err - } - return nil -} - -func (p *stubPrinter) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - p.closes++ - return nil -} - -func (p *stubPrinter) printed() int { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.jobs) -} - -func (p *stubPrinter) statusAsked() int { - p.mu.Lock() - defer p.mu.Unlock() - return p.statusCalls -} - -func (p *stubPrinter) setStatus(s ports.PrinterStatus) { - p.mu.Lock() - defer p.mu.Unlock() - p.status = s -} - -// serviceUnderTest wires a service over one main printer, with the clock, the counter -// and the journal a test can look into. -type serviceUnderTest struct { - *Service - main *stubPrinter - fallback *stubPrinter - clock *fake.Clock - log *recordedLog - roll *memoryRoll -} - -func newService(t *testing.T, withFallback bool) *serviceUnderTest { - t.Helper() - s := &serviceUnderTest{ - main: newStub("main"), - clock: fake.NewClock(testEpoch), - log: &recordedLog{}, - roll: &memoryRoll{}, - } - options := ServiceOptions{ - Main: s.main, - MainName: "file « SATO WS408_2 »", - Clock: s.clock, - Roll: NewRollCounter(s.roll, 1000, s.log), - Log: s.log, - } - if withFallback { - s.fallback = newStub("fallback") - options.Fallback = s.fallback - options.FallbackName = "file « SATO WS408_3 »" - } - service, err := NewService(options) - if err != nil { - t.Fatalf("NewService : %v", err) - } - t.Cleanup(func() { _ = service.Close() }) - s.Service = service - return s -} - -// aJob is one label to print. Nothing in this package looks inside it. -func aJob() ports.PrintJob { - return ports.PrintJob{Label: domain.Label{JobID: "01J9F2ABC"}} -} - -// waitForClockWaiters blocks until at least n waits are registered on the injected -// clock. Advancing before the code under test has asked the clock for anything delivers -// the tick to nobody. -func waitForClockWaiters(t *testing.T, clk *fake.Clock, n int) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for { - if waiters, _ := clk.Pending(); waiters >= n { - return - } - if time.Now().After(deadline) { - t.Fatalf("%d attente(s) sur l'horloge injectée, attendu %d : le délai est mesuré ailleurs", - func() int { w, _ := clk.Pending(); return w }(), n) - } - runtime.Gosched() - } -} +// The printer fallback has its own file, routing_test.go, as routing.go has its own. The +// printer stub and the service factory are in harness_test.go. // --- The roll counter never blocks a print --------------------------------- @@ -398,204 +221,6 @@ func TestAnUnreachablePrinterIsJournalledWithTheCodeOf154(t *testing.T) { } } -// --- The fallback printer, both ways --------------------------------------- - -// TestTheFallbackIsAskedForAndComesBackTheSameWay covers the switch and the return. -// -// Both directions are a HUMAN decision (§8.4): the station cannot honestly observe -// either event. What it sees when the main printer dies is a write that failed, and a -// write fails on a cable knocked loose for two seconds as readily as on a dead printer; -// an automatic switch would send a customer's label two metres away while they watch an -// empty slot. And nothing at all tells the station that the printer has been FIXED — -// the volunteer who changed the roll is the only one who knows. -func TestTheFallbackIsAskedForAndComesBackTheSameWay(t *testing.T) { - ctx := context.Background() - s := newService(t, true) - - if r := s.Routing(); r.Fallback || r.Banner != "" || !r.Available { - t.Fatalf("routage initial : %+v — le poste démarre sur son imprimante, et le bouton "+ - "« Imprimer sur l'imprimante du poste N » est offert puisqu'un secours est configuré", r) - } - if _, err := s.Print(ctx, aJob()); err != nil { - t.Fatalf("Print : %v", err) - } - - // --- towards the neighbour - if err := s.UseFallback(ctx); err != nil { - t.Fatalf("UseFallback : %v", err) - } - routing := s.Routing() - if !routing.Fallback || !strings.Contains(routing.Banner, "SATO WS408_3") { - t.Fatalf("routage après bascule : %+v — le bandeau est PERMANENT et il nomme "+ - "l'imprimante (§8.4)", routing) - } - if s.Descriptor().ID != "fallback" { - t.Errorf("le descripteur montre %q : l'écran doit montrer la machine qui imprime", - s.Descriptor().ID) - } - if _, err := s.Print(ctx, aJob()); err != nil { - t.Fatalf("Print sur le secours : %v", err) - } - if s.main.printed() != 1 || s.fallback.printed() != 1 { - t.Errorf("étiquettes : principale %d, secours %d — attendu 1 et 1", - s.main.printed(), s.fallback.printed()) - } - - // --- and back - if err := s.UseMain(ctx); err != nil { - t.Fatalf("UseMain : %v", err) - } - routing = s.Routing() - if routing.Fallback || routing.Banner != "" { - t.Fatalf("routage après retour : %+v — le bandeau disparaît quand il n'y a plus rien "+ - "à signaler", routing) - } - if _, err := s.Print(ctx, aJob()); err != nil { - t.Fatalf("Print après retour : %v", err) - } - if s.main.printed() != 2 || s.fallback.printed() != 1 { - t.Errorf("étiquettes : principale %d, secours %d — attendu 2 et 1", - s.main.printed(), s.fallback.printed()) - } - - // Both switches are journalled: somebody has to be able to answer « depuis quand - // est-ce qu'on imprime chez le voisin ? ». - var switched, returned bool - for _, line := range s.log.all() { - switched = switched || strings.Contains(line, "basculées sur l'imprimante de secours") - returned = returned || strings.Contains(line, "repassent sur l'imprimante du poste") - } - if !switched || !returned { - t.Errorf("journal : bascule=%v retour=%v — %v", switched, returned, s.log.all()) - } -} - -// TestSwitchingPrinterForgetsWhatWasKnownAboutTheOtherOne. -// -// What this station knew about one printer says NOTHING about another one. Carrying a -// green light across the switch would be inventing a measurement, which is the same -// mistake as announcing « prête » at level N1. -// The observation that has to be dropped is the LEVEL N1 one — what the last write did -// — because nothing else overwrites it: the next probe of N2 and N3 speaks to the new -// printer, but a write outcome just sits there and would go on describing a machine -// this station is no longer printing on. -func TestSwitchingPrinterForgetsWhatWasKnownAboutTheOtherOne(t *testing.T) { - ctx := context.Background() - s := newService(t, true) - // Both printers stay mute at N3, so that what the report shows can only come from - // the write that just happened — which is exactly the observation at stake. - printAndCheck := func(step string) { - t.Helper() - if _, err := s.Print(ctx, aJob()); err != nil { - t.Fatalf("%s : %v", step, err) - } - if got := s.Report().Level; got != LevelN1 { - t.Fatalf("%s : niveau = %s après une écriture réussie, attendu N1", step, got) - } - } - forgotten := func(step string) { - t.Helper() - report := s.Report() - if report.Level != LevelNone || report.Ready() { - t.Fatalf("%s : rapport = %+v, attendu « rien n'a été observé ». Ce que le poste "+ - "savait d'une imprimante ne dit RIEN d'une autre, et le résultat de la dernière "+ - "écriture est ce qu'aucune sonde ne vient remplacer", step, report) - } - } - - printAndCheck("impression sur la principale") - if err := s.UseFallback(ctx); err != nil { - t.Fatalf("UseFallback : %v", err) - } - forgotten("après la bascule vers le secours") - - printAndCheck("impression sur le secours") - if err := s.UseMain(ctx); err != nil { - t.Fatalf("UseMain : %v", err) - } - forgotten("après le retour à la principale") -} - -// TestAGreenLightDoesNotFollowTheSwitch: the neighbour's printer has not been looked at -// yet, and carrying « prête » across would be inventing a measurement — the same -// mistake as announcing « prête » at level N1. -func TestAGreenLightDoesNotFollowTheSwitch(t *testing.T) { - ctx := context.Background() - s := newService(t, true) - s.main.setStatus(ports.PrinterStatus{Health: ports.PrinterReady, Detail: "file vide"}) - - if report := s.Observe(ctx); !report.Ready() || report.Level != LevelN3 { - t.Fatalf("l'imprimante principale devait être connue prête : %+v", report) - } - if err := s.UseFallback(ctx); err != nil { - t.Fatalf("UseFallback : %v", err) - } - if report := s.Report(); report.Ready() { - t.Fatalf("le feu vert de la principale a suivi la bascule : %+v", report) - } -} - -// TestAStationWithNoFallbackSaysSoInFrench. -func TestAStationWithNoFallbackSaysSoInFrench(t *testing.T) { - s := newService(t, false) - - if r := s.Routing(); r.Available { - t.Error("un secours est annoncé disponible alors qu'aucun n'est configuré : " + - "le bouton de §14.4 ne doit pas apparaître") - } - err := s.UseFallback(context.Background()) - if err == nil { - t.Fatal("la bascule a réussi sans imprimante de secours") - } - if !strings.Contains(err.Error(), "printer.options.fallback") { - t.Errorf("message « %s » : il doit nommer la clé de configuration à renseigner", err) - } -} - -// TestSwitchingTwiceInTheSameDirectionIsANoOp: a volunteer pressing a button twice must -// not produce two journal lines and two forgotten states. -func TestSwitchingTwiceInTheSameDirectionIsANoOp(t *testing.T) { - ctx := context.Background() - s := newService(t, true) - - if err := s.UseMain(ctx); err != nil { // already on main - t.Fatalf("UseMain sur la principale : %v", err) - } - if len(s.log.all()) != 0 { - t.Errorf("journal non vide alors que rien n'a changé : %v", s.log.all()) - } - for press := 1; press <= 2; press++ { - if err := s.UseFallback(ctx); err != nil { - t.Fatalf("UseFallback, appui %d : %v", press, err) - } - } - lines := 0 - for _, line := range s.log.all() { - if strings.Contains(line, "basculées") { - lines++ - } - } - if lines != 1 { - t.Errorf("%d ligne(s) de bascule, attendu 1", lines) - } -} - -// TestAFallbackWithNoNameIsRefusedAtConstruction: a permanent banner that cannot say -// where the labels are coming out sends a volunteer looking at four printers. -func TestAFallbackWithNoNameIsRefusedAtConstruction(t *testing.T) { - _, err := NewService(ServiceOptions{ - Main: newStub("main"), - Fallback: newStub("fallback"), - Clock: fake.NewClock(testEpoch), - }) - if err == nil { - t.Fatal("une imprimante de secours sans nom a été acceptée") - } - if !strings.Contains(err.Error(), "bandeau") { - t.Errorf("message « %s » : il doit dire à quoi sert le nom", err) - } -} - // TestAServiceWithoutItsCollaboratorsIsRefused. func TestAServiceWithoutItsCollaboratorsIsRefused(t *testing.T) { for _, c := range []struct { @@ -745,6 +370,7 @@ func fmtWrap(err error) error { return wrapped{err} } type wrapped struct{ err error } func (w wrapped) Error() string { return "enveloppée : " + w.err.Error() } + func (w wrapped) Unwrap() error { return w.err } // TestAPrinterHangingIsBoundedByTheInjectedClock — failure test 6. The budget is spent diff --git a/internal/printing/symbol_decoder_test.go b/internal/printing/symbol_decoder_test.go new file mode 100644 index 0000000..48642d8 --- /dev/null +++ b/internal/printing/symbol_decoder_test.go @@ -0,0 +1,261 @@ +package printing + +import ( + "fmt" + "image" + "testing" + + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + "openscale/internal/domain" +) + +// An INDEPENDENT decoder, written from the specification with its own tables, and the pixel +// readers that feed it. +// +// This is what separates a golden from a proof: a golden that is only a recorded output +// agrees with a wrong encoder, and goes on agreeing for ever. Here the symbol is read back, +// and what comes back has to be the code it started from. + +// --- An independent decoder ------------------------------------------------ + +// The three code sets and the parity table, transcribed from the specification and +// NOT derived from the tables of internal/domain: a decoder built out of the +// encoder's own tables proves nothing. +var ( + setA = map[string]byte{ + "0001101": '0', "0011001": '1', "0010011": '2', "0111101": '3', "0100011": '4', + "0110001": '5', "0101111": '6', "0111011": '7', "0110111": '8', "0001011": '9', + } + setB = map[string]byte{ + "0100111": '0', "0110011": '1', "0011011": '2', "0100001": '3', "0011101": '4', + "0111001": '5', "0000101": '6', "0010001": '7', "0001001": '8', "0010111": '9', + } + setC = map[string]byte{ + "1110010": '0', "1100110": '1', "1101100": '2', "1000010": '3', "1011100": '4', + "1001110": '5', "1010000": '6', "1000100": '7', "1001000": '8', "1110100": '9', + } + leadingByParity = map[string]byte{ + "AAAAAA": '0', "AABABB": '1', "AABBAB": '2', "AABBBA": '3', "ABAABB": '4', + "ABBAAB": '5', "ABBBAA": '6', "ABABAB": '7', "ABABBA": '8', "ABBABA": '9', + } +) + +// decodeSymbol reads a 95 character bit string back into the 13 digits it carries. +func decodeSymbol(bits string) (string, error) { + if len(bits) != barModules { + return "", fmt.Errorf("%d modules, il en faut %d", len(bits), barModules) + } + for _, guard := range []struct { + name, want string + at int + }{ + {"gauche", "101", 0}, + {"centrale", "01010", centreGuardFirst}, + {"droite", "101", rightGuardFirst}, + } { + if got := bits[guard.at : guard.at+len(guard.want)]; got != guard.want { + return "", fmt.Errorf("garde %s = %s, attendu %s", guard.name, got, guard.want) + } + } + + var parity, digits string + for i := 0; i < 6; i++ { + chunk := bits[leftGroupFirst+7*i : leftGroupFirst+7*(i+1)] + switch { + case setA[chunk] != 0: + parity, digits = parity+"A", digits+string(setA[chunk]) + case setB[chunk] != 0: + parity, digits = parity+"B", digits+string(setB[chunk]) + default: + return "", fmt.Errorf("groupe gauche %d : %s n'est ni dans le jeu A ni dans le jeu B", i, chunk) + } + } + leading, ok := leadingByParity[parity] + if !ok { + return "", fmt.Errorf("le motif de parité %s ne correspond à aucun premier chiffre", parity) + } + for i := 0; i < 6; i++ { + chunk := bits[rightGroupFirst+7*i : rightGroupFirst+7*(i+1)] + digit, ok := setC[chunk] + if !ok { + return "", fmt.Errorf("groupe droit %d : %s n'est pas dans le jeu C", i, chunk) + } + digits += string(digit) + } + + decoded := string(leading) + digits + sum := 0 + for i := 0; i < 12; i++ { + weight := 1 + if (i+1)%2 == 0 { + weight = 3 + } + sum += weight * int(decoded[i]-'0') + } + if want := byte('0' + (10-sum%10)%10); decoded[12] != want { + return "", fmt.Errorf("les chiffres relus %s ne satisfont pas la clé de contrôle", decoded) + } + return decoded, nil +} + +// --- Fixtures -------------------------------------------------------------- + +// drawReferenceSymbol renders the reference code with the geometry of a template, +// at the origin, on a white image the exact size of the block. +func drawReferenceSymbol(t *testing.T, template domain.Template) (*Library, SymbolOptions, *image.Gray) { + t.Helper() + + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + o := NewSymbolOptions(template) + o.XDots, o.YDots = 0, 0 + + // Carlito rather than the "Code EAN13" font of the legacy report: ADR-019 draws + // the symbol geometrically and does not embed that font, and Carlito is the font + // of every other field of the label (ADR-020). + face, sizeUM, err := FitHRIFace(library, Carlito, o, template.Media.DotsPerMM) + if err != nil { + library.Close() + t.Fatalf("fonte de la HRI : %v", err) + } + o.HRIFace = face + t.Logf("%s : module %d milli-dots · barres %d dots · gardes +%d · bande HRI %d dots · "+ + "HRI en Carlito à %d µm · bloc %d × %d dots", + template.Name, o.ModuleMilliDots, o.BarHeightDots, o.GuardDescentDots, + o.HRIHeightDots, sizeUM, o.TotalWidthDots(), o.HeightDots()) + + code, err := domain.ParseEAN13(referenceCode) + if err != nil { + library.Close() + t.Fatalf("code de référence : %v", err) + } + modules, err := domain.Modules(code) + if err != nil { + library.Close() + t.Fatalf("Modules : %v", err) + } + + img := image.NewGray(image.Rect(0, 0, o.TotalWidthDots(), o.HeightDots())) + for i := range img.Pix { + img.Pix[i] = 0xFF + } + if err := DrawEAN13(img, code, modules, o); err != nil { + library.Close() + t.Fatalf("DrawEAN13 : %v", err) + } + return library, o, img +} + +// --- Reading pixels back --------------------------------------------------- + +// cluster is a run of consecutive columns carrying ink. +type cluster struct{ from, to int } + +// columnClusters splits r into the groups of adjacent inked columns it contains. +func columnClusters(img *image.Gray, r image.Rectangle) []cluster { + var out []cluster + open := false + for x := r.Min.X; x < r.Max.X; x++ { + inked := false + for y := r.Min.Y; y < r.Max.Y && !inked; y++ { + inked = isInk(img, x, y) + } + switch { + case inked && !open: + out = append(out, cluster{from: x, to: x}) + open = true + case inked: + out[len(out)-1].to = x + default: + open = false + } + } + return out +} + +// digitTemplate is one digit rendered alone, cropped to its ink. +type digitTemplate struct { + digit byte + ink []bool // row-major, w x h + w, h int +} + +// renderDigitTemplates draws the ten digits with the very face the HRI was drawn +// with, and crops each to its ink. Matching against these is what turns "there are +// pixels down there" into "these are the thirteen digits of the code". +func renderDigitTemplates(t *testing.T, face font.Face) []digitTemplate { + t.Helper() + out := make([]digitTemplate, 0, 10) + for digit := byte('0'); digit <= '9'; digit++ { + const pad = 8 + canvas := image.NewGray(image.Rect(0, 0, 4*pad, 4*pad)) + for i := range canvas.Pix { + canvas.Pix[i] = 0xFF + } + if _, ok := digitInk(face, digit); !ok { + t.Fatalf("la fonte n'a pas de glyphe pour %q", string(digit)) + } + drawDigit(canvas, face, digit, fixed.I(pad), 3*pad) + box, found := inkBounds(canvas, canvas.Bounds()) + if !found { + t.Fatalf("le gabarit du chiffre %q est vide", string(digit)) + } + tmpl := digitTemplate{digit: digit, w: box.Dx(), h: box.Dy()} + tmpl.ink = make([]bool, tmpl.w*tmpl.h) + for y := 0; y < tmpl.h; y++ { + for x := 0; x < tmpl.w; x++ { + tmpl.ink[y*tmpl.w+x] = isInk(canvas, box.Min.X+x, box.Min.Y+y) + } + } + out = append(out, tmpl) + } + return out +} + +// bestDigit reports which of the ten digits the ink inside window looks most like, +// and the number of pixels that still differ once it is best placed. +func bestDigit(img *image.Gray, window image.Rectangle, templates []digitTemplate) (byte, int) { + best, bestScore := byte(0), -1 + for _, tmpl := range templates { + score := matchTemplate(img, window, tmpl) + if score < 0 { + continue + } + if bestScore < 0 || score < bestScore { + best, bestScore = tmpl.digit, score + } + } + return best, bestScore +} + +// matchTemplate slides one digit over the window and reports the smallest number of +// differing pixels, or -1 when the digit does not fit at all. +func matchTemplate(img *image.Gray, window image.Rectangle, tmpl digitTemplate) int { + if tmpl.w > window.Dx() || tmpl.h > window.Dy() { + return -1 + } + best := -1 + for dy := 0; dy+tmpl.h <= window.Dy(); dy++ { + for dx := 0; dx+tmpl.w <= window.Dx(); dx++ { + differing := 0 + for y := 0; y < window.Dy(); y++ { + for x := 0; x < window.Dx(); x++ { + want := false + if y >= dy && y < dy+tmpl.h && x >= dx && x < dx+tmpl.w { + want = tmpl.ink[(y-dy)*tmpl.w+(x-dx)] + } + if isInk(img, window.Min.X+x, window.Min.Y+y) != want { + differing++ + } + } + } + if best < 0 || differing < best { + best = differing + } + } + } + return best +} diff --git a/internal/printing/symbol_refusals_test.go b/internal/printing/symbol_refusals_test.go new file mode 100644 index 0000000..c9b2694 --- /dev/null +++ b/internal/printing/symbol_refusals_test.go @@ -0,0 +1,237 @@ +package printing + +import ( + "image" + "testing" + + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + "openscale/internal/domain" +) + +// What the symbol REFUSES rather than draw askew: guards that descend lower than the other +// bars, a geometry with no room for the HRI line, and a font that cannot draw digits. A +// symbol drawn askew compiles perfectly well and fails at the till. + +// --- The guards ------------------------------------------------------------ + +// TestTheGuardsRunLowerThanTheOtherBars: modules 0-2, 45-49 and 92-94 descend by +// GuardDescentDots, and nothing else does. +// +// The descenders are what a scanner uses to find the edges of the symbol, and they +// are also what keeps the HRI digits in a band of their own instead of hanging under +// a flat row of bars. +func TestTheGuardsRunLowerThanTheOtherBars(t *testing.T) { + library, o, img := drawReferenceSymbol(t, domain.IdenticalTemplate()) + defer library.Close() + + if o.GuardDescentDots <= 0 { + t.Fatalf("descente des gardes %d dots : le gabarit de production en déclare 1 465 µm", + o.GuardDescentDots) + } + // One row inside the descent, below every ordinary bar and above the HRI digits. + row := o.YDots + o.BarHeightDots + left := o.barsLeft() + modules, err := domain.Modules(domain.EAN13(referenceCode)) + if err != nil { + t.Fatalf("Modules : %v", err) + } + for i, bar := range modules { + x := left + o.edge(i) + inked := isInk(img, x, row) + switch { + case isGuard(i) && bar && !inked: + t.Errorf("module %d est une barre de garde et ne descend pas sous les barres", i) + case !isGuard(i) && inked: + t.Errorf("module %d n'est pas une garde et descend quand même : la ligne %d "+ + "appartient à la bande de la HRI", i, row) + } + } + + // And the descent stops where it is declared to stop. + if bottom := o.YDots + o.BarHeightDots + o.GuardDescentDots; isInk(img, left, bottom) { + t.Errorf("la garde gauche est encore encrée en y=%d, au-delà des %d dots de descente", + bottom, o.GuardDescentDots) + } +} + +// --- What DrawEAN13 refuses ------------------------------------------------ + +// TestDrawEAN13RefusesWhatItCannotDrawCorrectly: every one of these would print +// something, and something wrong. +func TestDrawEAN13RefusesWhatItCannotDrawCorrectly(t *testing.T) { + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + defer library.Close() + + template := domain.IdenticalTemplate() + sound := NewSymbolOptions(template) + face, _, err := FitHRIFace(library, Carlito, sound, template.Media.DotsPerMM) + if err != nil { + t.Fatalf("fonte de la HRI : %v", err) + } + sound.HRIFace = face + sound.XDots, sound.YDots = 0, 0 + + code, err := domain.ParseEAN13(referenceCode) + if err != nil { + t.Fatalf("code de référence : %v", err) + } + modules, err := domain.Modules(code) + if err != nil { + t.Fatalf("Modules : %v", err) + } + full := image.NewGray(image.Rect(0, 0, sound.TotalWidthDots(), sound.HeightDots())) + + other, err := domain.ParseEAN13("0493021000003") + if err != nil { + t.Fatalf("second code : %v", err) + } + otherModules, err := domain.Modules(other) + if err != nil { + t.Fatalf("Modules du second code : %v", err) + } + + for _, c := range []struct { + name string + dst *image.Gray + code domain.EAN13 + modules [95]bool + mutate func(*SymbolOptions) + }{ + {name: "sans image", dst: nil, code: code, modules: modules}, + {name: "sans HRI", dst: full, code: code, modules: modules, + mutate: func(o *SymbolOptions) { o.HRIHeightDots = 0 }}, + {name: "gardes qui montent au lieu de descendre", dst: full, code: code, modules: modules, + mutate: func(o *SymbolOptions) { o.GuardDescentDots = -4 }}, + {name: "sans fonte de HRI", dst: full, code: code, modules: modules, + mutate: func(o *SymbolOptions) { o.HRIFace = nil }}, + {name: "module nul", dst: full, code: code, modules: modules, + mutate: func(o *SymbolOptions) { o.ModuleMilliDots = 0 }}, + {name: "barres sans hauteur", dst: full, code: code, modules: modules, + mutate: func(o *SymbolOptions) { o.BarHeightDots = 0 }}, + {name: "bloc plus large que l'image", dst: image.NewGray(image.Rect(0, 0, 100, 200)), + code: code, modules: modules}, + {name: "les barres codent un autre produit", dst: full, code: code, modules: otherModules}, + {name: "code trop court", dst: full, code: domain.EAN13("049302101236"), modules: modules}, + } { + t.Run(c.name, func(t *testing.T) { + o := sound + if c.mutate != nil { + c.mutate(&o) + } + if err := DrawEAN13(c.dst, c.code, c.modules, o); err == nil { + t.Errorf("accepté sans broncher : ce symbole partirait à l'impression") + } + }) + } +} + +// --- The HRI face ---------------------------------------------------------- + +// TestFitHRIFaceStaysAboveTheLegibilityFloor: the line the cashier falls back on is +// held to the same floor hard rule 9 imposes on every other field. +func TestFitHRIFaceStaysAboveTheLegibilityFloor(t *testing.T) { + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + defer library.Close() + + for _, template := range []domain.Template{ + domain.IdenticalTemplate(), domain.NeutralSingleTemplate(), + } { + t.Run(template.Name, func(t *testing.T) { + o := NewSymbolOptions(template) + face, sizeUM, err := FitHRIFace(library, Carlito, o, template.Media.DotsPerMM) + if err != nil { + t.Fatalf("fonte de la HRI : %v", err) + } + if sizeUM < domain.MinFontSizeUM { + t.Errorf("HRI au corps %d µm, sous le plancher de %d µm de la règle dure 9", + sizeUM, domain.MinFontSizeUM) + } + // It must fit the cell, or two neighbouring digits run into each other. + cell := o.edge(digitModules) + for digit := byte('0'); digit <= '9'; digit++ { + bounds, ok := digitInk(face, digit) + if !ok { + t.Fatalf("pas de glyphe pour %q", string(digit)) + } + if w := ceilDots(bounds.Max.X - bounds.Min.X); w > cell-hriCellClearanceDots { + t.Errorf("le chiffre %q fait %d dots de large pour une cellule de %d dots", + string(digit), w, cell) + } + if h := ceilDots(bounds.Max.Y - bounds.Min.Y); h > o.HRIHeightDots { + t.Errorf("le chiffre %q fait %d dots de haut pour une bande de %d dots", + string(digit), h, o.HRIHeightDots) + } + } + }) + } +} + +// TestFitHRIFaceRefusesAGeometryWithNoRoomForTheLine: a template that leaves no room +// for a legible HRI is refused at load time, not silently given digits nobody reads. +func TestFitHRIFaceRefusesAGeometryWithNoRoomForTheLine(t *testing.T) { + library, err := NewLibrary() + if err != nil { + t.Fatalf("bibliothèque de polices : %v", err) + } + defer library.Close() + + for _, c := range []struct { + name string + o SymbolOptions + }{ + {"module nul", SymbolOptions{ModuleMilliDots: 0, HRIHeightDots: 23}}, + {"bande HRI nulle", SymbolOptions{ModuleMilliDots: 2344, HRIHeightDots: 0}}, + {"cellule plus étroite qu'un chiffre", SymbolOptions{ModuleMilliDots: 200, HRIHeightDots: 23}}, + {"bande trop basse au plancher de lisibilité", + SymbolOptions{ModuleMilliDots: 2112, HRIHeightDots: 5}}, // 264 µm à 8 dots/mm, plancher GS1 + } { + t.Run(c.name, func(t *testing.T) { + if _, sizeUM, err := FitHRIFace(library, Carlito, c.o, 8); err == nil { + t.Errorf("accepté au corps %d µm : la HRI serait illisible", sizeUM) + } + }) + } + + // A font the binary does not carry must fail here too, rather than be substituted + // on the one line a cashier reads when the scanner refuses. + sound := NewSymbolOptions(domain.IdenticalTemplate()) + if _, _, err := FitHRIFace(library, "calibri", sound, 8); err == nil { + t.Error("« calibri » acceptée pour la HRI : c'est la police qu'on ne peut pas redistribuer") + } +} + +// faceWithoutDigits is a face that carries no digit glyph, which is what a subset +// font supplied by an operator could turn out to be. +type faceWithoutDigits struct{ font.Face } + +func (faceWithoutDigits) GlyphBounds(rune) (fixed.Rectangle26_6, fixed.Int26_6, bool) { + return fixed.Rectangle26_6{}, 0, false +} + +// TestDrawEAN13RefusesAFaceThatCannotDrawDigits: better no label than a symbol whose +// human-readable line is silently missing. +func TestDrawEAN13RefusesAFaceThatCannotDrawDigits(t *testing.T) { + library, o, img := drawReferenceSymbol(t, domain.IdenticalTemplate()) + defer library.Close() + + o.HRIFace = faceWithoutDigits{o.HRIFace} + code, err := domain.ParseEAN13(referenceCode) + if err != nil { + t.Fatalf("code de référence : %v", err) + } + modules, err := domain.Modules(code) + if err != nil { + t.Fatalf("Modules : %v", err) + } + if err := DrawEAN13(img, code, modules, o); err == nil { + t.Error("accepté : le symbole partirait sans sa ligne lisible, et la caissière " + + "perdrait son filet de secours (§7.4, important-5)") + } +} diff --git a/internal/printing/symbol_test.go b/internal/printing/symbol_test.go index 546dac0..9cad740 100644 --- a/internal/printing/symbol_test.go +++ b/internal/printing/symbol_test.go @@ -2,7 +2,6 @@ package printing import ( "flag" - "fmt" "image" "image/color" "image/png" @@ -10,13 +9,15 @@ import ( "path/filepath" "testing" - "golang.org/x/image/font" - "golang.org/x/image/math/fixed" - "openscale/internal/domain" ) -// The six non-regression tests of §7.4, plus what they need to be worth anything. +// The six non-regression tests of §7.4, plus what they need to be worth anything: the +// frozen modules, the absence of cumulative drift, the width of the bars, the whole module, +// the golden, and the block with its HRI band still there. +// +// What DrawEAN13 refuses is in symbol_refusals_test.go; the independent decoder that reads +// a symbol back out of its pixels is in symbol_decoder_test.go. // // # REGENERATING THE GOLDEN // @@ -34,9 +35,6 @@ import ( var update = flag.Bool("update", false, "réécrire les golden de internal/printing/testdata/golden") -// referenceCode is the vector T1 of §18: garlic, reference 021, 1.236 kg. -const referenceCode = "0493021012365" - // referenceModules are the 95 modules of referenceCode, FROZEN. // // They were obtained once and are checked here by a decoder written from the @@ -44,88 +42,6 @@ const referenceCode = "0493021012365" // output agrees with a wrong encoder, and would keep agreeing with it forever. const referenceModules = "10101000110001011011110100011010010011001100101010111001011001101101100100001010100001001110101" -// --- An independent decoder ------------------------------------------------ - -// The three code sets and the parity table, transcribed from the specification and -// NOT derived from the tables of internal/domain: a decoder built out of the -// encoder's own tables proves nothing. -var ( - setA = map[string]byte{ - "0001101": '0', "0011001": '1', "0010011": '2', "0111101": '3', "0100011": '4', - "0110001": '5', "0101111": '6', "0111011": '7', "0110111": '8', "0001011": '9', - } - setB = map[string]byte{ - "0100111": '0', "0110011": '1', "0011011": '2', "0100001": '3', "0011101": '4', - "0111001": '5', "0000101": '6', "0010001": '7', "0001001": '8', "0010111": '9', - } - setC = map[string]byte{ - "1110010": '0', "1100110": '1', "1101100": '2', "1000010": '3', "1011100": '4', - "1001110": '5', "1010000": '6', "1000100": '7', "1001000": '8', "1110100": '9', - } - leadingByParity = map[string]byte{ - "AAAAAA": '0', "AABABB": '1', "AABBAB": '2', "AABBBA": '3', "ABAABB": '4', - "ABBAAB": '5', "ABBBAA": '6', "ABABAB": '7', "ABABBA": '8', "ABBABA": '9', - } -) - -// decodeSymbol reads a 95 character bit string back into the 13 digits it carries. -func decodeSymbol(bits string) (string, error) { - if len(bits) != barModules { - return "", fmt.Errorf("%d modules, il en faut %d", len(bits), barModules) - } - for _, guard := range []struct { - name, want string - at int - }{ - {"gauche", "101", 0}, - {"centrale", "01010", centreGuardFirst}, - {"droite", "101", rightGuardFirst}, - } { - if got := bits[guard.at : guard.at+len(guard.want)]; got != guard.want { - return "", fmt.Errorf("garde %s = %s, attendu %s", guard.name, got, guard.want) - } - } - - var parity, digits string - for i := 0; i < 6; i++ { - chunk := bits[leftGroupFirst+7*i : leftGroupFirst+7*(i+1)] - switch { - case setA[chunk] != 0: - parity, digits = parity+"A", digits+string(setA[chunk]) - case setB[chunk] != 0: - parity, digits = parity+"B", digits+string(setB[chunk]) - default: - return "", fmt.Errorf("groupe gauche %d : %s n'est ni dans le jeu A ni dans le jeu B", i, chunk) - } - } - leading, ok := leadingByParity[parity] - if !ok { - return "", fmt.Errorf("le motif de parité %s ne correspond à aucun premier chiffre", parity) - } - for i := 0; i < 6; i++ { - chunk := bits[rightGroupFirst+7*i : rightGroupFirst+7*(i+1)] - digit, ok := setC[chunk] - if !ok { - return "", fmt.Errorf("groupe droit %d : %s n'est pas dans le jeu C", i, chunk) - } - digits += string(digit) - } - - decoded := string(leading) + digits - sum := 0 - for i := 0; i < 12; i++ { - weight := 1 - if (i+1)%2 == 0 { - weight = 3 - } - sum += weight * int(decoded[i]-'0') - } - if want := byte('0' + (10-sum%10)%10); decoded[12] != want { - return "", fmt.Errorf("les chiffres relus %s ne satisfont pas la clé de contrôle", decoded) - } - return decoded, nil -} - // --- 1. The frozen modules ------------------------------------------------- // TestTheFrozenModulesDecodeBackToTheReferenceCode is test 1 of §7.4. @@ -228,13 +144,6 @@ func TestEveryEdgeIsTheRoundedIdealPosition(t *testing.T) { float64(alternating)-ideal(barModules), ideal(barModules)) } -func abs(v float64) float64 { - if v < 0 { - return -v - } - return v -} - // --- 3. The width of the bars ---------------------------------------------- // TestTheBarsAreExactlyTwoHundredAndTwentyThreeDots is test 3 of §7.4, checked on @@ -314,13 +223,6 @@ type colourRun struct { black bool } -func colourName(black bool) string { - if black { - return "barre" - } - return "espace" -} - // colourRuns splits one row into its runs of constant colour, between x0 and x1. func colourRuns(img *image.Gray, y, x0, x1 int) []colourRun { var runs []colourRun @@ -560,421 +462,3 @@ func TestABlankHRIBandFailsTheReadBack(t *testing.T) { "que la HRI, et le test précédent passerait sans HRI", len(marks)) } } - -// --- The guards ------------------------------------------------------------ - -// TestTheGuardsRunLowerThanTheOtherBars: modules 0-2, 45-49 and 92-94 descend by -// GuardDescentDots, and nothing else does. -// -// The descenders are what a scanner uses to find the edges of the symbol, and they -// are also what keeps the HRI digits in a band of their own instead of hanging under -// a flat row of bars. -func TestTheGuardsRunLowerThanTheOtherBars(t *testing.T) { - library, o, img := drawReferenceSymbol(t, domain.IdenticalTemplate()) - defer library.Close() - - if o.GuardDescentDots <= 0 { - t.Fatalf("descente des gardes %d dots : le gabarit de production en déclare 1 465 µm", - o.GuardDescentDots) - } - // One row inside the descent, below every ordinary bar and above the HRI digits. - row := o.YDots + o.BarHeightDots - left := o.barsLeft() - modules, err := domain.Modules(domain.EAN13(referenceCode)) - if err != nil { - t.Fatalf("Modules : %v", err) - } - for i, bar := range modules { - x := left + o.edge(i) - inked := isInk(img, x, row) - switch { - case isGuard(i) && bar && !inked: - t.Errorf("module %d est une barre de garde et ne descend pas sous les barres", i) - case !isGuard(i) && inked: - t.Errorf("module %d n'est pas une garde et descend quand même : la ligne %d "+ - "appartient à la bande de la HRI", i, row) - } - } - - // And the descent stops where it is declared to stop. - if bottom := o.YDots + o.BarHeightDots + o.GuardDescentDots; isInk(img, left, bottom) { - t.Errorf("la garde gauche est encore encrée en y=%d, au-delà des %d dots de descente", - bottom, o.GuardDescentDots) - } -} - -// --- What DrawEAN13 refuses ------------------------------------------------ - -// TestDrawEAN13RefusesWhatItCannotDrawCorrectly: every one of these would print -// something, and something wrong. -func TestDrawEAN13RefusesWhatItCannotDrawCorrectly(t *testing.T) { - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - defer library.Close() - - template := domain.IdenticalTemplate() - sound := NewSymbolOptions(template) - face, _, err := FitHRIFace(library, Carlito, sound, template.Media.DotsPerMM) - if err != nil { - t.Fatalf("fonte de la HRI : %v", err) - } - sound.HRIFace = face - sound.XDots, sound.YDots = 0, 0 - - code, err := domain.ParseEAN13(referenceCode) - if err != nil { - t.Fatalf("code de référence : %v", err) - } - modules, err := domain.Modules(code) - if err != nil { - t.Fatalf("Modules : %v", err) - } - full := image.NewGray(image.Rect(0, 0, sound.TotalWidthDots(), sound.HeightDots())) - - other, err := domain.ParseEAN13("0493021000003") - if err != nil { - t.Fatalf("second code : %v", err) - } - otherModules, err := domain.Modules(other) - if err != nil { - t.Fatalf("Modules du second code : %v", err) - } - - for _, c := range []struct { - name string - dst *image.Gray - code domain.EAN13 - modules [95]bool - mutate func(*SymbolOptions) - }{ - {name: "sans image", dst: nil, code: code, modules: modules}, - {name: "sans HRI", dst: full, code: code, modules: modules, - mutate: func(o *SymbolOptions) { o.HRIHeightDots = 0 }}, - {name: "gardes qui montent au lieu de descendre", dst: full, code: code, modules: modules, - mutate: func(o *SymbolOptions) { o.GuardDescentDots = -4 }}, - {name: "sans fonte de HRI", dst: full, code: code, modules: modules, - mutate: func(o *SymbolOptions) { o.HRIFace = nil }}, - {name: "module nul", dst: full, code: code, modules: modules, - mutate: func(o *SymbolOptions) { o.ModuleMilliDots = 0 }}, - {name: "barres sans hauteur", dst: full, code: code, modules: modules, - mutate: func(o *SymbolOptions) { o.BarHeightDots = 0 }}, - {name: "bloc plus large que l'image", dst: image.NewGray(image.Rect(0, 0, 100, 200)), - code: code, modules: modules}, - {name: "les barres codent un autre produit", dst: full, code: code, modules: otherModules}, - {name: "code trop court", dst: full, code: domain.EAN13("049302101236"), modules: modules}, - } { - t.Run(c.name, func(t *testing.T) { - o := sound - if c.mutate != nil { - c.mutate(&o) - } - if err := DrawEAN13(c.dst, c.code, c.modules, o); err == nil { - t.Errorf("accepté sans broncher : ce symbole partirait à l'impression") - } - }) - } -} - -// --- The HRI face ---------------------------------------------------------- - -// TestFitHRIFaceStaysAboveTheLegibilityFloor: the line the cashier falls back on is -// held to the same floor hard rule 9 imposes on every other field. -func TestFitHRIFaceStaysAboveTheLegibilityFloor(t *testing.T) { - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - defer library.Close() - - for _, template := range []domain.Template{ - domain.IdenticalTemplate(), domain.NeutralSingleTemplate(), - } { - t.Run(template.Name, func(t *testing.T) { - o := NewSymbolOptions(template) - face, sizeUM, err := FitHRIFace(library, Carlito, o, template.Media.DotsPerMM) - if err != nil { - t.Fatalf("fonte de la HRI : %v", err) - } - if sizeUM < domain.MinFontSizeUM { - t.Errorf("HRI au corps %d µm, sous le plancher de %d µm de la règle dure 9", - sizeUM, domain.MinFontSizeUM) - } - // It must fit the cell, or two neighbouring digits run into each other. - cell := o.edge(digitModules) - for digit := byte('0'); digit <= '9'; digit++ { - bounds, ok := digitInk(face, digit) - if !ok { - t.Fatalf("pas de glyphe pour %q", string(digit)) - } - if w := ceilDots(bounds.Max.X - bounds.Min.X); w > cell-hriCellClearanceDots { - t.Errorf("le chiffre %q fait %d dots de large pour une cellule de %d dots", - string(digit), w, cell) - } - if h := ceilDots(bounds.Max.Y - bounds.Min.Y); h > o.HRIHeightDots { - t.Errorf("le chiffre %q fait %d dots de haut pour une bande de %d dots", - string(digit), h, o.HRIHeightDots) - } - } - }) - } -} - -// TestFitHRIFaceRefusesAGeometryWithNoRoomForTheLine: a template that leaves no room -// for a legible HRI is refused at load time, not silently given digits nobody reads. -func TestFitHRIFaceRefusesAGeometryWithNoRoomForTheLine(t *testing.T) { - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - defer library.Close() - - for _, c := range []struct { - name string - o SymbolOptions - }{ - {"module nul", SymbolOptions{ModuleMilliDots: 0, HRIHeightDots: 23}}, - {"bande HRI nulle", SymbolOptions{ModuleMilliDots: 2344, HRIHeightDots: 0}}, - {"cellule plus étroite qu'un chiffre", SymbolOptions{ModuleMilliDots: 200, HRIHeightDots: 23}}, - {"bande trop basse au plancher de lisibilité", - SymbolOptions{ModuleMilliDots: 2112, HRIHeightDots: 5}}, // 264 µm à 8 dots/mm, plancher GS1 - } { - t.Run(c.name, func(t *testing.T) { - if _, sizeUM, err := FitHRIFace(library, Carlito, c.o, 8); err == nil { - t.Errorf("accepté au corps %d µm : la HRI serait illisible", sizeUM) - } - }) - } - - // A font the binary does not carry must fail here too, rather than be substituted - // on the one line a cashier reads when the scanner refuses. - sound := NewSymbolOptions(domain.IdenticalTemplate()) - if _, _, err := FitHRIFace(library, "calibri", sound, 8); err == nil { - t.Error("« calibri » acceptée pour la HRI : c'est la police qu'on ne peut pas redistribuer") - } -} - -// faceWithoutDigits is a face that carries no digit glyph, which is what a subset -// font supplied by an operator could turn out to be. -type faceWithoutDigits struct{ font.Face } - -func (faceWithoutDigits) GlyphBounds(rune) (fixed.Rectangle26_6, fixed.Int26_6, bool) { - return fixed.Rectangle26_6{}, 0, false -} - -// TestDrawEAN13RefusesAFaceThatCannotDrawDigits: better no label than a symbol whose -// human-readable line is silently missing. -func TestDrawEAN13RefusesAFaceThatCannotDrawDigits(t *testing.T) { - library, o, img := drawReferenceSymbol(t, domain.IdenticalTemplate()) - defer library.Close() - - o.HRIFace = faceWithoutDigits{o.HRIFace} - code, err := domain.ParseEAN13(referenceCode) - if err != nil { - t.Fatalf("code de référence : %v", err) - } - modules, err := domain.Modules(code) - if err != nil { - t.Fatalf("Modules : %v", err) - } - if err := DrawEAN13(img, code, modules, o); err == nil { - t.Error("accepté : le symbole partirait sans sa ligne lisible, et la caissière " + - "perdrait son filet de secours (§7.4, important-5)") - } -} - -// --- Fixtures -------------------------------------------------------------- - -// drawReferenceSymbol renders the reference code with the geometry of a template, -// at the origin, on a white image the exact size of the block. -func drawReferenceSymbol(t *testing.T, template domain.Template) (*Library, SymbolOptions, *image.Gray) { - t.Helper() - - library, err := NewLibrary() - if err != nil { - t.Fatalf("bibliothèque de polices : %v", err) - } - o := NewSymbolOptions(template) - o.XDots, o.YDots = 0, 0 - - // Carlito rather than the "Code EAN13" font of the legacy report: ADR-019 draws - // the symbol geometrically and does not embed that font, and Carlito is the font - // of every other field of the label (ADR-020). - face, sizeUM, err := FitHRIFace(library, Carlito, o, template.Media.DotsPerMM) - if err != nil { - library.Close() - t.Fatalf("fonte de la HRI : %v", err) - } - o.HRIFace = face - t.Logf("%s : module %d milli-dots · barres %d dots · gardes +%d · bande HRI %d dots · "+ - "HRI en Carlito à %d µm · bloc %d × %d dots", - template.Name, o.ModuleMilliDots, o.BarHeightDots, o.GuardDescentDots, - o.HRIHeightDots, sizeUM, o.TotalWidthDots(), o.HeightDots()) - - code, err := domain.ParseEAN13(referenceCode) - if err != nil { - library.Close() - t.Fatalf("code de référence : %v", err) - } - modules, err := domain.Modules(code) - if err != nil { - library.Close() - t.Fatalf("Modules : %v", err) - } - - img := image.NewGray(image.Rect(0, 0, o.TotalWidthDots(), o.HeightDots())) - for i := range img.Pix { - img.Pix[i] = 0xFF - } - if err := DrawEAN13(img, code, modules, o); err != nil { - library.Close() - t.Fatalf("DrawEAN13 : %v", err) - } - return library, o, img -} - -// --- Reading pixels back --------------------------------------------------- - -// isInk reports whether a dot is burnt. The head is binary and DrawEAN13 thresholds -// its own HRI, so there is nothing in between to arbitrate. -func isInk(img *image.Gray, x, y int) bool { - return img.GrayAt(x, y).Y < 0x80 -} - -// inkBounds reports the tight box around the ink inside r. -func inkBounds(img *image.Gray, r image.Rectangle) (image.Rectangle, bool) { - box := image.Rectangle{Min: image.Pt(r.Max.X, r.Max.Y), Max: image.Pt(r.Min.X, r.Min.Y)} - found := false - for y := r.Min.Y; y < r.Max.Y; y++ { - for x := r.Min.X; x < r.Max.X; x++ { - if !isInk(img, x, y) { - continue - } - found = true - box.Min.X = min(box.Min.X, x) - box.Min.Y = min(box.Min.Y, y) - box.Max.X = max(box.Max.X, x+1) - box.Max.Y = max(box.Max.Y, y+1) - } - } - return box, found -} - -// inkColumnRange reports the first and last inked column of r. -func inkColumnRange(img *image.Gray, r image.Rectangle) (first, last int, ok bool) { - box, found := inkBounds(img, r) - if !found { - return 0, 0, false - } - return box.Min.X, box.Max.X - 1, true -} - -// cluster is a run of consecutive columns carrying ink. -type cluster struct{ from, to int } - -// columnClusters splits r into the groups of adjacent inked columns it contains. -func columnClusters(img *image.Gray, r image.Rectangle) []cluster { - var out []cluster - open := false - for x := r.Min.X; x < r.Max.X; x++ { - inked := false - for y := r.Min.Y; y < r.Max.Y && !inked; y++ { - inked = isInk(img, x, y) - } - switch { - case inked && !open: - out = append(out, cluster{from: x, to: x}) - open = true - case inked: - out[len(out)-1].to = x - default: - open = false - } - } - return out -} - -// digitTemplate is one digit rendered alone, cropped to its ink. -type digitTemplate struct { - digit byte - ink []bool // row-major, w x h - w, h int -} - -// renderDigitTemplates draws the ten digits with the very face the HRI was drawn -// with, and crops each to its ink. Matching against these is what turns "there are -// pixels down there" into "these are the thirteen digits of the code". -func renderDigitTemplates(t *testing.T, face font.Face) []digitTemplate { - t.Helper() - out := make([]digitTemplate, 0, 10) - for digit := byte('0'); digit <= '9'; digit++ { - const pad = 8 - canvas := image.NewGray(image.Rect(0, 0, 4*pad, 4*pad)) - for i := range canvas.Pix { - canvas.Pix[i] = 0xFF - } - if _, ok := digitInk(face, digit); !ok { - t.Fatalf("la fonte n'a pas de glyphe pour %q", string(digit)) - } - drawDigit(canvas, face, digit, fixed.I(pad), 3*pad) - box, found := inkBounds(canvas, canvas.Bounds()) - if !found { - t.Fatalf("le gabarit du chiffre %q est vide", string(digit)) - } - tmpl := digitTemplate{digit: digit, w: box.Dx(), h: box.Dy()} - tmpl.ink = make([]bool, tmpl.w*tmpl.h) - for y := 0; y < tmpl.h; y++ { - for x := 0; x < tmpl.w; x++ { - tmpl.ink[y*tmpl.w+x] = isInk(canvas, box.Min.X+x, box.Min.Y+y) - } - } - out = append(out, tmpl) - } - return out -} - -// bestDigit reports which of the ten digits the ink inside window looks most like, -// and the number of pixels that still differ once it is best placed. -func bestDigit(img *image.Gray, window image.Rectangle, templates []digitTemplate) (byte, int) { - best, bestScore := byte(0), -1 - for _, tmpl := range templates { - score := matchTemplate(img, window, tmpl) - if score < 0 { - continue - } - if bestScore < 0 || score < bestScore { - best, bestScore = tmpl.digit, score - } - } - return best, bestScore -} - -// matchTemplate slides one digit over the window and reports the smallest number of -// differing pixels, or -1 when the digit does not fit at all. -func matchTemplate(img *image.Gray, window image.Rectangle, tmpl digitTemplate) int { - if tmpl.w > window.Dx() || tmpl.h > window.Dy() { - return -1 - } - best := -1 - for dy := 0; dy+tmpl.h <= window.Dy(); dy++ { - for dx := 0; dx+tmpl.w <= window.Dx(); dx++ { - differing := 0 - for y := 0; y < window.Dy(); y++ { - for x := 0; x < window.Dx(); x++ { - want := false - if y >= dy && y < dy+tmpl.h && x >= dx && x < dx+tmpl.w { - want = tmpl.ink[(y-dy)*tmpl.w+(x-dx)] - } - if isInk(img, window.Min.X+x, window.Min.Y+y) != want { - differing++ - } - } - } - if best < 0 || differing < best { - best = differing - } - } - } - return best -} diff --git a/internal/printing/threshold.go b/internal/printing/threshold.go new file mode 100644 index 0000000..aeadd20 --- /dev/null +++ b/internal/printing/threshold.go @@ -0,0 +1,77 @@ +package printing + +// This file is the FINAL binarisation of §7.3: the two thresholds a render burns its +// grays with, and the rectangles each one is applied to. It is the last thing Rasterize +// does, because the head is binary and keeping grays would let a driver dither the render +// into irregular bars. + +import ( + "image" + "image/color" + + "openscale/internal/domain" +) + +// The differentiated thresholds of §7.3, and the reason there are two of them. +const ( + // symbolThreshold is applied to the symbol block. The symbol is already drawn in + // pure black and white -- DrawEAN13 thresholds its own HRI on a scratch band -- + // so the value is insensitive, and 0x80 says so. + symbolThreshold = 0x80 + + // defaultTextThreshold is the 0x68 a template that says nothing gets. Text goes + // lower than the symbol to preserve thin stems at 7 pt. + // + // Zero is treated as "unset" rather than obeyed: no dot is below a threshold of + // zero, so a template that left the field empty would print a blank label. + defaultTextThreshold = 0x68 +) + +// applyThreshold burns every dot of r to pure black or pure white. +// +// Strictly below the threshold is ink. A dot exactly at the threshold stays white, +// which is what makes 0x80 a no-op on a block already drawn in 0x00 and 0xFF. +func applyThreshold(img *image.Gray, r image.Rectangle, threshold uint8) { + r = r.Intersect(img.Bounds()) + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + burnt := color.Gray{Y: 0xFF} + if img.GrayAt(x, y).Y < threshold { + burnt = color.Gray{Y: 0x00} + } + img.SetGray(x, y, burnt) + } + } +} + +// textThreshold is the binarisation threshold of everything that is not the symbol. +func textThreshold(g *domain.Template) uint8 { + if g.TextThreshold == 0 { + return defaultTextThreshold + } + return g.TextThreshold +} + +// surrounding returns the rectangles that cover outer minus inner -- "the rest of +// the label" of §7.3, expressed as rectangles because that is what applyThreshold +// takes. +func surrounding(outer, inner image.Rectangle) []image.Rectangle { + inner = inner.Intersect(outer) + if inner.Empty() { + return []image.Rectangle{outer} + } + var out []image.Rectangle + if inner.Min.Y > outer.Min.Y { + out = append(out, image.Rect(outer.Min.X, outer.Min.Y, outer.Max.X, inner.Min.Y)) + } + if inner.Max.Y < outer.Max.Y { + out = append(out, image.Rect(outer.Min.X, inner.Max.Y, outer.Max.X, outer.Max.Y)) + } + if inner.Min.X > outer.Min.X { + out = append(out, image.Rect(outer.Min.X, inner.Min.Y, inner.Min.X, inner.Max.Y)) + } + if inner.Max.X < outer.Max.X { + out = append(out, image.Rect(inner.Max.X, inner.Min.Y, outer.Max.X, inner.Max.Y)) + } + return out +} diff --git a/internal/printing/threshold_test.go b/internal/printing/threshold_test.go new file mode 100644 index 0000000..8fe97e9 --- /dev/null +++ b/internal/printing/threshold_test.go @@ -0,0 +1,113 @@ +package printing + +import ( + "fmt" + "image" + "testing" + + "openscale/internal/domain" +) + +// The tests of threshold.go: a thermal head prints black or white and nothing else, so the +// final render must carry no grey at all. The threshold is differentiated — text and symbol +// do not share one — and a text threshold of zero falls back instead of emptying the label. + +// --- The final thresholding ------------------------------------------------ + +// TestTheRenderCarriesNothingButPureBlackAndWhite: the head is binary, and a render +// that kept greys would let the driver dither it into irregular bars (§7.3). +func TestTheRenderCarriesNothingButPureBlackAndWhite(t *testing.T) { + r, _ := newTestRasterizer(t) + for name, template := range domain.ShippedTemplates() { + for _, annotate := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/annotate=%v", name, annotate), func(t *testing.T) { + g := template + img, err := r.Rasterize(&g, weighing(t, lentilRow, referenceMass, domain.LaCagetteRules()), + domain.LocaleFrench, RenderOptions{Annotate: annotate}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + grey, black, white := 0, 0, 0 + var firstGrey image.Point + var firstValue uint8 + for i, v := range img.Pix { + switch v { + case 0x00: + black++ + case 0xFF: + white++ + default: + if grey == 0 { + firstGrey = image.Pt(i%img.Stride, i/img.Stride) + firstValue = v + } + grey++ + } + } + if grey > 0 { + t.Errorf("%d dots ne sont ni 0x00 ni 0xFF, le premier en %v vaut 0x%02X — "+ + "le pilote tramerait ces gris et produirait des barres irrégulières", + grey, firstGrey, firstValue) + } + if black == 0 { + t.Error("aucun dot noir : le seuillage a effacé l'étiquette") + } + t.Logf("%d dots noirs, %d blancs", black, white) + }) + } + } +} + +// TestTheThresholdIsDifferentiated: 0x80 on the symbol, TextThreshold on the rest. +// +// It is checked where it is observable: a grey laid inside the symbol block and a +// grey laid outside it, both between the two thresholds, must come out differently. +func TestTheThresholdIsDifferentiated(t *testing.T) { + template := domain.IdenticalTemplate() + if template.TextThreshold != defaultTextThreshold { + t.Fatalf("le gabarit porte un seuil texte de 0x%02X : ce test suppose 0x%02X", + template.TextThreshold, defaultTextThreshold) + } + o := NewSymbolOptions(template) + img := image.NewGray(image.Rect(0, 0, 320, 203)) + for i := range img.Pix { + img.Pix[i] = 0x70 // between 0x68 and 0x80 + } + + applyThreshold(img, o.Bounds(), symbolThreshold) + for _, rest := range surrounding(img.Bounds(), o.Bounds()) { + applyThreshold(img, rest, textThreshold(&template)) + } + + inside := image.Pt(o.XDots+1, o.YDots+1) + outside := image.Pt(o.XDots+1, o.YDots-1) + if !isInk(img, inside.X, inside.Y) { + t.Errorf("un gris 0x70 dans le symbole %v est resté blanc : le seuil 0x%02X n'y est pas appliqué", + o.Bounds(), symbolThreshold) + } + if isInk(img, outside.X, outside.Y) { + t.Errorf("un gris 0x70 hors du symbole est devenu noir : le seuil texte 0x%02X n'y est pas appliqué", + textThreshold(&template)) + } +} + +// TestAZeroTextThresholdFallsBackRatherThanBlankingTheLabel: with a threshold of +// zero no dot is ever below it, so obeying the field literally would print a blank. +func TestAZeroTextThresholdFallsBackRatherThanBlankingTheLabel(t *testing.T) { + template := domain.IdenticalTemplate() + template.TextThreshold = 0 + if got := textThreshold(&template); got != defaultTextThreshold { + t.Errorf("seuil 0x%02X pour un gabarit muet, attendu 0x%02X", got, defaultTextThreshold) + } + + r, _ := newTestRasterizer(t) + img, err := r.Rasterize(&template, weighing(t, celeryRow, referenceMass, domain.LaCagetteRules()), + domain.LocaleFrench, RenderOptions{}) + if err != nil { + t.Fatalf("Rasterize : %v", err) + } + nameBox := elementBox(&template, template.Elements[elementIndex(t, &template, domain.FieldProductName)]) + if _, inked := inkBounds(img, nameBox); !inked { + t.Error("le nom du produit est vide : un seuil de texte à zéro a effacé l'étiquette") + } +} diff --git a/internal/printing/transport/conformance/check_frame.go b/internal/printing/transport/conformance/check_frame.go new file mode 100644 index 0000000..41012b2 --- /dev/null +++ b/internal/printing/transport/conformance/check_frame.go @@ -0,0 +1,138 @@ +package conformance + +// This file holds clauses 1 to 6: the identity a configuration file names, the line a +// volunteer reads when nothing comes out, and what ONE frame is owed on its way to the +// device — every byte delivered, a short count reported, an empty payload and an +// unreachable destination both refused. + +import ( + "bytes" + "context" + "strings" + "testing" + "unicode" + + "openscale/internal/fake" +) + +// checkName verifies the identity the registry and config.json both read. +func checkName(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(tr) + + name := tr.Name() + switch { + case name == "": + r.Errorf("Name() is empty. It is the key of the transport registry and the value of printer.options.transport: an anonymous transport cannot be named by a configuration file, and control 8 of Config.Validate has nothing to check against") + case name != subject.Name: + r.Errorf("Name() = %q while the subject is submitted as %q. Those two are the same string in config.json, and a transport that answers to a name nobody registered is unreachable", name, subject.Name) + } + if i := strings.IndexFunc(name, unicode.IsSpace); i >= 0 { + r.Errorf("Name() = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", name, i) + } + if i := strings.IndexFunc(name, unicode.IsUpper); i >= 0 { + r.Errorf("Name() = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different transport", name, i, strings.ToLower(name)) + } + if again := tr.Name(); again != name { + r.Errorf("Name() answered %q then %q. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", name, again) + } +} + +// checkDescribeNamesTheDestination is the line a volunteer reads when nothing comes out. +func checkDescribeNamesTheDestination(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(tr) + + described := tr.Describe() + if described == "" { + r.Fatalf("Describe() is empty. It is the wording of the administration screen and of the technical journal: « impression indisponible » with nothing after it sends a volunteer looking at the wrong printer") + } + if !strings.Contains(described, subject.Destination) { + r.Errorf("Describe() = %q and does not name %q, the destination this transport was built for. A description that omits the queue, the address or the path is the same sentence for the four stations of the shop, and it is on that sentence that somebody decides which cable to check", described, subject.Destination) + } + if again := tr.Describe(); again != described { + r.Errorf("Describe() answered %q then %q. It is read by the admin screen and by the journal separately, and a wording that moves between two calls makes two log lines look like two printers", described, again) + } +} + +// checkWriteDeliversEveryByte is the contract in one line: what went in comes out, and +// the count is the truth. +func checkWriteDeliversEveryByte(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(tr) + + n, err := tr.Write(context.Background(), payload) + if err != nil { + r.Fatalf("Write returned %v on a destination the subject declares healthy. Subject.New must build a transport that can be written to in the test environment — a temporary directory, a pipe, a loopback listener — and a device that is deliberately absent belongs in Subject.Unreachable", err) + } + if n != len(payload) { + r.Errorf("Write reported %d bytes accepted out of %d with a nil error. The count is what the print receipt carries (ports.PrintReceipt.Bytes); a count that does not match the frame makes the journal useless for the one question it is kept for", n, len(payload)) + } + if subject.Delivered == nil { + r.Skipf("Subject.Delivered is nil: the suite cannot read the destination back, so it took Write at its word. Supply it as soon as the destination is readable — the round trip is the only check that catches a transport that re-encodes, trims or line-ends what it carries") + } + if got := subject.Delivered(t, tr); !bytes.Equal(got, payload) { + r.Errorf("what arrived is not what was sent.\n sent (%d bytes) %x\n arrived (%d bytes) %x\nAn SBPL frame is binary: an ESC byte dropped, a \\r\\n translated or a trailing zero trimmed and the printer sees a command it does not know (§8.3)", len(payload), payload, len(got), got) + } +} + +// checkEmptyPayloadIsRefused is the same lie as a short write, one layer down. +func checkEmptyPayloadIsRefused(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(tr) + + n, err := tr.Write(context.Background(), nil) + if err == nil { + r.Errorf("Write(ctx, nil) reported %d bytes and NO error. Nothing legitimate hands a printer zero bytes: answering « c'est fait » turns an encoder that produced nothing into a successful weighing, and the customer walks off without a label while the screen says one was sent (§8.5)", n) + } + if n != 0 { + r.Errorf("Write(ctx, nil) reported %d bytes accepted, out of none given", n) + } + if subject.Delivered != nil { + if got := subject.Delivered(t, tr); len(got) > 0 { + r.Errorf("Write(ctx, nil) was refused and %d bytes still reached the destination: %x", len(got), got) + } + } +} + +// checkPartialWriteIsAnError is clause 4, and it is the one a transport breaks by +// writing `return w.Write(p)` and thinking the job is done. +func checkPartialWriteIsAnError(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.Short == nil { + r.Skipf("Subject.Short is nil: this subject offers no way to make its destination accept fewer bytes than it is given. Supply it for anything that reaches a device — WritePrinter really does report a short count with a nil error, and a transport that passes that on turns a lost label into a confirmed one") + } + tr := build(t, r, subject.Short, fake.NewClock(t0)) + defer closeAndForget(tr) + + n, err := tr.Write(context.Background(), payload) + if err == nil { + r.Fatalf("the destination accepted %d bytes out of %d and Write reported SUCCESS. A truncated frame prints blank, and the station would journal result='sent' for a label nobody ever held (§8.3, §8.5)", n, len(payload)) + } + if n == len(payload) { + r.Errorf("Write returned an error and still claimed the whole frame (%d bytes) was accepted. The count travels into the print receipt; it has to say what really went through", n) + } +} + +// checkUnreachableDeviceIsAnError is the KindTransient of §8.5: the queue that is not +// there, the printer that is off, the node that came back as lp1. +func checkUnreachableDeviceIsAnError(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.Unreachable == nil { + r.Skipf("Subject.Unreachable is nil: this subject declares no destination that cannot be opened. Supply it for anything that opens something — an unknown queue name, a device that is not there — because it is the failure a station actually meets, and it must arrive as an error the print service can retry") + } + tr := build(t, r, subject.Unreachable, fake.NewClock(t0)) + defer closeAndForget(tr) + + n, err := tr.Write(context.Background(), payload) + if err == nil { + r.Errorf("Write reported %d bytes and no error on a destination that cannot be opened. The print service reads the error to decide between « 2 réessais, 300 ms puis 1 s » and « pas de réessai » (§8.5); with none, it retries nothing and confirms everything", n) + } + if n != 0 { + r.Errorf("Write reported %d bytes accepted by a destination that was never opened", n) + } +} diff --git a/internal/printing/transport/conformance/check_lifecycle.go b/internal/printing/transport/conformance/check_lifecycle.go new file mode 100644 index 0000000..e807f91 --- /dev/null +++ b/internal/printing/transport/conformance/check_lifecycle.go @@ -0,0 +1,195 @@ +package conformance + +// This file holds clauses 7 to 11: what a cancelled context leaves behind, what Query +// may claim, and the exits — a Close the print service calls twice, a write that arrives +// after it, and the goroutine count that has to come back to where it started. + +import ( + "context" + "errors" + "testing" + + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// checkCancelledContextWritesNothing is the cheap half of failure test 6: a job that +// arrives after the budget has already burnt must not reach the head. +func checkCancelledContextWritesNothing(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(tr) + + dead, cancel := context.WithCancel(context.Background()) + cancel() + + n, err := tr.Write(dead, payload) + if !errors.Is(err, context.Canceled) { + r.Errorf("Write on an ALREADY cancelled context returned (%d, %v), want a context error. The 8 s budget of the print service arrives as this context (§8.2); a transport that writes anyway is a second label going out after the Hub gave up on the first", n, err) + } + if n != 0 { + r.Errorf("Write reported %d bytes on a cancelled context. A job the printer received part of is a job that failed, and a non-zero count invites the caller to read it as progress", n) + } + if subject.Delivered != nil { + if got := subject.Delivered(t, tr); len(got) > 0 { + r.Errorf("the context was cancelled before Write and %d bytes reached the destination anyway: %x", len(got), got) + } + } +} + +// checkCancelDuringWriteLeavesNothing is failure test 6 itself, « imprimante qui pend +// 60 s » (§16.2): the caller gets the floor back, and nothing is left running. +// +// The two halves matter equally. Returning is what keeps the Hub alive; leaving nothing +// behind is what keeps the NEXT weighing alive, because the goroutine this would leak +// holds the mutex the print service serializes on (§8.2). +func checkCancelDuringWriteLeavesNothing(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.Blocking == nil { + r.Skipf("Subject.Blocking is nil: this subject offers no destination that parks a write. Supply it for anything that talks to a device — it is failure test 6, and it is the clause whose breach blocks the whole station and not just one label") + } + before := settledGoroutines(subject.patience()) + + tr := build(t, r, subject.Blocking, fake.NewClock(t0)) + defer closeAndForget(tr) + + ctx, cancel := context.WithCancel(context.Background()) + type outcome struct { + n int + err error + } + returned := make(chan outcome, 1) + go func() { + n, err := tr.Write(ctx, payload) + returned <- outcome{n, err} + }() + + // Let the write reach the destination and PARK there before pulling the rug out. + // Cancelling a Write that has not started yet would silently re-run the previous + // check and credit this one with it. + // + // Two goroutines, not one: the one just launched above, and the one the transport + // spawns to hold the write that no context can interrupt. Seeing the second is what + // says the write is past its entry guard and inside the destination. + if !waitUntil(func() bool { return goroutines() >= before+2 }, subject.patience()) { + r.Logf("the blocking write never showed up as a goroutine of its own; cancelling anyway") + } + select { + case got := <-returned: + r.Fatalf("Write came back on its own with (%d, %v), before anything was cancelled. Subject.Blocking has to build a transport whose write PARKS until the handle is closed — the printer of failure test 6, which hangs for sixty seconds — or this check verifies nothing", got.n, got.err) + default: + } + cancel() + + select { + case got := <-returned: + if !errors.Is(got.err, context.Canceled) { + r.Errorf("Write returned (%d, %v) after its context was cancelled, want a context error", got.n, got.err) + } + if got.n != 0 { + r.Errorf("Write reported %d bytes after being cancelled mid-flight", got.n) + } + case <-realClock.After(subject.patience()): + r.Fatalf("Write was STILL RUNNING %s after its context was cancelled. Nothing this application writes to honours a context on its own — not os.File, not net.Conn, not WritePrinter — so the transport has to close the handle to unblock it. Without that, failure test 6 freezes the print service and the Hub behind it (§16.2)\n%s", subject.patience(), goroutineDump()) + } + + closeAndForget(tr) + if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { + r.Errorf("goroutines went from %d to %d and stayed there for %s after a cancelled write. Giving the caller the floor back is only half of it: the write goroutine has to be GONE, because §13.1 claims the inventory of goroutines is exhaustive and because the handle it holds is the one the next label needs\n%s", + before, goroutines(), subject.patience(), goroutineDump()) + } +} + +// checkQueryAnswersOrDeclares holds a transport to the honesty of §8.5: an unknown +// status is a legitimate answer, a pretended one is not. +func checkQueryAnswersOrDeclares(t *testing.T, r reporter, subject Subject) { + r.Helper() + clk := fake.NewClock(t0) + tr := build(t, r, subject.New, clk) + defer closeAndForget(tr) + + raw, err, returned := probe(tr, clk, subject.patience()) + if !returned { + r.Fatalf("Query was still running %s after the injected clock passed its %s budget. The budget of the native probe is measured on the clock the transport was GIVEN (§5.3): a transport that timed itself on the wall clock would hang the troubleshooting screen here and burn half a second per call in production\n%s", subject.patience(), probeBudget, goroutineDump()) + } + + if !subject.Bidirectional { + if !errors.Is(err, ports.ErrUnsupported) { + r.Errorf("Query returned (%x, %v) on a transport submitted as ONE-WAY, want an error wrapping ports.ErrUnsupported. That sentinel is what lets the printer driver fall back to level N1 instead of showing a volunteer the result of a probe that never happened (§8.5)", raw, err) + } + return + } + if errors.Is(err, ports.ErrUnsupported) { + r.Errorf("Query declined with ports.ErrUnsupported on a transport submitted as BIDIRECTIONAL. Set Subject.Bidirectional to false, or carry the probe: a subject that does not match its transport makes every other check ambiguous") + } + if err == nil && len(raw) == 0 { + r.Logf("Query answered nothing within the budget, which §8.5 reads as « on ne sait pas » and not as a failure") + } +} + +// checkCloseIsIdempotent covers both calls the print service really makes: the one on a +// configuration reload and the one on shutdown. +func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + + for call := 1; call <= 3; call++ { + err, panicked := closeQuietly(tr) + if panicked != nil { + r.Fatalf("Close PANICKED on call %d: %v. The print service closes on a reload and again on shutdown (§11.4, §13.4), and a panic there takes the whole station down", call, panicked) + } + if err != nil { + // Allowed and logged rather than judged: a handle already released is not news. + r.Logf("Close returned %v on call %d", err, call) + } + } +} + +// checkWriteAfterCloseIsRefused keeps a station that has given up from being brought back +// by a job that arrived late. +func checkWriteAfterCloseIsRefused(t *testing.T, r reporter, subject Subject) { + r.Helper() + tr := build(t, r, subject.New, fake.NewClock(t0)) + if _, panicked := closeQuietly(tr); panicked != nil { + r.Fatalf("Close PANICKED: %v", panicked) + } + + n, err := tr.Write(context.Background(), payload) + if err == nil { + r.Errorf("Write reported %d bytes and no error AFTER Close. A reload is a close followed by a new transport (§11.4): a job that reopens the device behind the closed one prints on hardware the station believes it has released, and two transports then race for the same handle", n) + } + if n != 0 { + r.Errorf("Write reported %d bytes accepted after Close", n) + } + if subject.Delivered != nil { + if got := subject.Delivered(t, tr); len(got) > 0 { + r.Errorf("%d bytes reached the destination after Close: %x", len(got), got) + } + } +} + +// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count: the test binary +// runs goroutines of its own, and the runtime may still be retiring those of the previous +// check. +func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { + r.Helper() + before := settledGoroutines(subject.patience()) + + clk := fake.NewClock(t0) + tr := build(t, r, subject.New, clk) + if _, err := tr.Write(context.Background(), payload); err != nil { + r.Fatalf("Write returned %v on a destination the subject declares healthy", err) + } + if _, _, returned := probe(tr, clk, subject.patience()); !returned { + r.Fatalf("Query never came back; the leak this check looks for is hidden behind it\n%s", goroutineDump()) + } + closeAndForget(tr) + + if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { + r.Errorf("goroutines went from %d to %d and stayed there for %s after a whole job and a Close. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every transport takes its own away:\n%s", + before, goroutines(), subject.patience(), goroutineDump()) + } + if _, tickers := clk.Pending(); tickers > 0 { + r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) + } +} diff --git a/internal/printing/transport/conformance/conformance.go b/internal/printing/transport/conformance/conformance.go index c8296ea..1a76fb5 100644 --- a/internal/printing/transport/conformance/conformance.go +++ b/internal/printing/transport/conformance/conformance.go @@ -48,16 +48,14 @@ // count, and a second transport running beside it would make that number mean nothing. package conformance +// This file is the entry point and the LIST: what the suite sends, the Subject a +// transport is submitted as, Suite, and the order the clauses are read in. The clauses +// themselves live in check_frame.go and check_lifecycle.go. + import ( - "bytes" - "context" - "errors" - "strings" "testing" "time" - "unicode" - "openscale/internal/fake" "openscale/internal/station/ports" ) @@ -241,309 +239,6 @@ func validate(r reporter, subject Subject) { } } -// checkName verifies the identity the registry and config.json both read. -func checkName(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(tr) - - name := tr.Name() - switch { - case name == "": - r.Errorf("Name() is empty. It is the key of the transport registry and the value of printer.options.transport: an anonymous transport cannot be named by a configuration file, and control 8 of Config.Validate has nothing to check against") - case name != subject.Name: - r.Errorf("Name() = %q while the subject is submitted as %q. Those two are the same string in config.json, and a transport that answers to a name nobody registered is unreachable", name, subject.Name) - } - if i := strings.IndexFunc(name, unicode.IsSpace); i >= 0 { - r.Errorf("Name() = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", name, i) - } - if i := strings.IndexFunc(name, unicode.IsUpper); i >= 0 { - r.Errorf("Name() = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different transport", name, i, strings.ToLower(name)) - } - if again := tr.Name(); again != name { - r.Errorf("Name() answered %q then %q. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", name, again) - } -} - -// checkDescribeNamesTheDestination is the line a volunteer reads when nothing comes out. -func checkDescribeNamesTheDestination(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(tr) - - described := tr.Describe() - if described == "" { - r.Fatalf("Describe() is empty. It is the wording of the administration screen and of the technical journal: « impression indisponible » with nothing after it sends a volunteer looking at the wrong printer") - } - if !strings.Contains(described, subject.Destination) { - r.Errorf("Describe() = %q and does not name %q, the destination this transport was built for. A description that omits the queue, the address or the path is the same sentence for the four stations of the shop, and it is on that sentence that somebody decides which cable to check", described, subject.Destination) - } - if again := tr.Describe(); again != described { - r.Errorf("Describe() answered %q then %q. It is read by the admin screen and by the journal separately, and a wording that moves between two calls makes two log lines look like two printers", described, again) - } -} - -// checkWriteDeliversEveryByte is the contract in one line: what went in comes out, and -// the count is the truth. -func checkWriteDeliversEveryByte(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(tr) - - n, err := tr.Write(context.Background(), payload) - if err != nil { - r.Fatalf("Write returned %v on a destination the subject declares healthy. Subject.New must build a transport that can be written to in the test environment — a temporary directory, a pipe, a loopback listener — and a device that is deliberately absent belongs in Subject.Unreachable", err) - } - if n != len(payload) { - r.Errorf("Write reported %d bytes accepted out of %d with a nil error. The count is what the print receipt carries (ports.PrintReceipt.Bytes); a count that does not match the frame makes the journal useless for the one question it is kept for", n, len(payload)) - } - if subject.Delivered == nil { - r.Skipf("Subject.Delivered is nil: the suite cannot read the destination back, so it took Write at its word. Supply it as soon as the destination is readable — the round trip is the only check that catches a transport that re-encodes, trims or line-ends what it carries") - } - if got := subject.Delivered(t, tr); !bytes.Equal(got, payload) { - r.Errorf("what arrived is not what was sent.\n sent (%d bytes) %x\n arrived (%d bytes) %x\nAn SBPL frame is binary: an ESC byte dropped, a \\r\\n translated or a trailing zero trimmed and the printer sees a command it does not know (§8.3)", len(payload), payload, len(got), got) - } -} - -// checkEmptyPayloadIsRefused is the same lie as a short write, one layer down. -func checkEmptyPayloadIsRefused(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(tr) - - n, err := tr.Write(context.Background(), nil) - if err == nil { - r.Errorf("Write(ctx, nil) reported %d bytes and NO error. Nothing legitimate hands a printer zero bytes: answering « c'est fait » turns an encoder that produced nothing into a successful weighing, and the customer walks off without a label while the screen says one was sent (§8.5)", n) - } - if n != 0 { - r.Errorf("Write(ctx, nil) reported %d bytes accepted, out of none given", n) - } - if subject.Delivered != nil { - if got := subject.Delivered(t, tr); len(got) > 0 { - r.Errorf("Write(ctx, nil) was refused and %d bytes still reached the destination: %x", len(got), got) - } - } -} - -// checkPartialWriteIsAnError is clause 4, and it is the one a transport breaks by -// writing `return w.Write(p)` and thinking the job is done. -func checkPartialWriteIsAnError(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.Short == nil { - r.Skipf("Subject.Short is nil: this subject offers no way to make its destination accept fewer bytes than it is given. Supply it for anything that reaches a device — WritePrinter really does report a short count with a nil error, and a transport that passes that on turns a lost label into a confirmed one") - } - tr := build(t, r, subject.Short, fake.NewClock(t0)) - defer closeAndForget(tr) - - n, err := tr.Write(context.Background(), payload) - if err == nil { - r.Fatalf("the destination accepted %d bytes out of %d and Write reported SUCCESS. A truncated frame prints blank, and the station would journal result='sent' for a label nobody ever held (§8.3, §8.5)", n, len(payload)) - } - if n == len(payload) { - r.Errorf("Write returned an error and still claimed the whole frame (%d bytes) was accepted. The count travels into the print receipt; it has to say what really went through", n) - } -} - -// checkUnreachableDeviceIsAnError is the KindTransient of §8.5: the queue that is not -// there, the printer that is off, the node that came back as lp1. -func checkUnreachableDeviceIsAnError(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.Unreachable == nil { - r.Skipf("Subject.Unreachable is nil: this subject declares no destination that cannot be opened. Supply it for anything that opens something — an unknown queue name, a device that is not there — because it is the failure a station actually meets, and it must arrive as an error the print service can retry") - } - tr := build(t, r, subject.Unreachable, fake.NewClock(t0)) - defer closeAndForget(tr) - - n, err := tr.Write(context.Background(), payload) - if err == nil { - r.Errorf("Write reported %d bytes and no error on a destination that cannot be opened. The print service reads the error to decide between « 2 réessais, 300 ms puis 1 s » and « pas de réessai » (§8.5); with none, it retries nothing and confirms everything", n) - } - if n != 0 { - r.Errorf("Write reported %d bytes accepted by a destination that was never opened", n) - } -} - -// checkCancelledContextWritesNothing is the cheap half of failure test 6: a job that -// arrives after the budget has already burnt must not reach the head. -func checkCancelledContextWritesNothing(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(tr) - - dead, cancel := context.WithCancel(context.Background()) - cancel() - - n, err := tr.Write(dead, payload) - if !errors.Is(err, context.Canceled) { - r.Errorf("Write on an ALREADY cancelled context returned (%d, %v), want a context error. The 8 s budget of the print service arrives as this context (§8.2); a transport that writes anyway is a second label going out after the Hub gave up on the first", n, err) - } - if n != 0 { - r.Errorf("Write reported %d bytes on a cancelled context. A job the printer received part of is a job that failed, and a non-zero count invites the caller to read it as progress", n) - } - if subject.Delivered != nil { - if got := subject.Delivered(t, tr); len(got) > 0 { - r.Errorf("the context was cancelled before Write and %d bytes reached the destination anyway: %x", len(got), got) - } - } -} - -// checkCancelDuringWriteLeavesNothing is failure test 6 itself, « imprimante qui pend -// 60 s » (§16.2): the caller gets the floor back, and nothing is left running. -// -// The two halves matter equally. Returning is what keeps the Hub alive; leaving nothing -// behind is what keeps the NEXT weighing alive, because the goroutine this would leak -// holds the mutex the print service serializes on (§8.2). -func checkCancelDuringWriteLeavesNothing(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.Blocking == nil { - r.Skipf("Subject.Blocking is nil: this subject offers no destination that parks a write. Supply it for anything that talks to a device — it is failure test 6, and it is the clause whose breach blocks the whole station and not just one label") - } - before := settledGoroutines(subject.patience()) - - tr := build(t, r, subject.Blocking, fake.NewClock(t0)) - defer closeAndForget(tr) - - ctx, cancel := context.WithCancel(context.Background()) - type outcome struct { - n int - err error - } - returned := make(chan outcome, 1) - go func() { - n, err := tr.Write(ctx, payload) - returned <- outcome{n, err} - }() - - // Let the write reach the destination and PARK there before pulling the rug out. - // Cancelling a Write that has not started yet would silently re-run the previous - // check and credit this one with it. - // - // Two goroutines, not one: the one just launched above, and the one the transport - // spawns to hold the write that no context can interrupt. Seeing the second is what - // says the write is past its entry guard and inside the destination. - if !waitUntil(func() bool { return goroutines() >= before+2 }, subject.patience()) { - r.Logf("the blocking write never showed up as a goroutine of its own; cancelling anyway") - } - select { - case got := <-returned: - r.Fatalf("Write came back on its own with (%d, %v), before anything was cancelled. Subject.Blocking has to build a transport whose write PARKS until the handle is closed — the printer of failure test 6, which hangs for sixty seconds — or this check verifies nothing", got.n, got.err) - default: - } - cancel() - - select { - case got := <-returned: - if !errors.Is(got.err, context.Canceled) { - r.Errorf("Write returned (%d, %v) after its context was cancelled, want a context error", got.n, got.err) - } - if got.n != 0 { - r.Errorf("Write reported %d bytes after being cancelled mid-flight", got.n) - } - case <-realClock.After(subject.patience()): - r.Fatalf("Write was STILL RUNNING %s after its context was cancelled. Nothing this application writes to honours a context on its own — not os.File, not net.Conn, not WritePrinter — so the transport has to close the handle to unblock it. Without that, failure test 6 freezes the print service and the Hub behind it (§16.2)\n%s", subject.patience(), goroutineDump()) - } - - closeAndForget(tr) - if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { - r.Errorf("goroutines went from %d to %d and stayed there for %s after a cancelled write. Giving the caller the floor back is only half of it: the write goroutine has to be GONE, because §13.1 claims the inventory of goroutines is exhaustive and because the handle it holds is the one the next label needs\n%s", - before, goroutines(), subject.patience(), goroutineDump()) - } -} - -// checkQueryAnswersOrDeclares holds a transport to the honesty of §8.5: an unknown -// status is a legitimate answer, a pretended one is not. -func checkQueryAnswersOrDeclares(t *testing.T, r reporter, subject Subject) { - r.Helper() - clk := fake.NewClock(t0) - tr := build(t, r, subject.New, clk) - defer closeAndForget(tr) - - raw, err, returned := probe(tr, clk, subject.patience()) - if !returned { - r.Fatalf("Query was still running %s after the injected clock passed its %s budget. The budget of the native probe is measured on the clock the transport was GIVEN (§5.3): a transport that timed itself on the wall clock would hang the troubleshooting screen here and burn half a second per call in production\n%s", subject.patience(), probeBudget, goroutineDump()) - } - - if !subject.Bidirectional { - if !errors.Is(err, ports.ErrUnsupported) { - r.Errorf("Query returned (%x, %v) on a transport submitted as ONE-WAY, want an error wrapping ports.ErrUnsupported. That sentinel is what lets the printer driver fall back to level N1 instead of showing a volunteer the result of a probe that never happened (§8.5)", raw, err) - } - return - } - if errors.Is(err, ports.ErrUnsupported) { - r.Errorf("Query declined with ports.ErrUnsupported on a transport submitted as BIDIRECTIONAL. Set Subject.Bidirectional to false, or carry the probe: a subject that does not match its transport makes every other check ambiguous") - } - if err == nil && len(raw) == 0 { - r.Logf("Query answered nothing within the budget, which §8.5 reads as « on ne sait pas » and not as a failure") - } -} - -// checkCloseIsIdempotent covers both calls the print service really makes: the one on a -// configuration reload and the one on shutdown. -func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - - for call := 1; call <= 3; call++ { - err, panicked := closeQuietly(tr) - if panicked != nil { - r.Fatalf("Close PANICKED on call %d: %v. The print service closes on a reload and again on shutdown (§11.4, §13.4), and a panic there takes the whole station down", call, panicked) - } - if err != nil { - // Allowed and logged rather than judged: a handle already released is not news. - r.Logf("Close returned %v on call %d", err, call) - } - } -} - -// checkWriteAfterCloseIsRefused keeps a station that has given up from being brought back -// by a job that arrived late. -func checkWriteAfterCloseIsRefused(t *testing.T, r reporter, subject Subject) { - r.Helper() - tr := build(t, r, subject.New, fake.NewClock(t0)) - if _, panicked := closeQuietly(tr); panicked != nil { - r.Fatalf("Close PANICKED: %v", panicked) - } - - n, err := tr.Write(context.Background(), payload) - if err == nil { - r.Errorf("Write reported %d bytes and no error AFTER Close. A reload is a close followed by a new transport (§11.4): a job that reopens the device behind the closed one prints on hardware the station believes it has released, and two transports then race for the same handle", n) - } - if n != 0 { - r.Errorf("Write reported %d bytes accepted after Close", n) - } - if subject.Delivered != nil { - if got := subject.Delivered(t, tr); len(got) > 0 { - r.Errorf("%d bytes reached the destination after Close: %x", len(got), got) - } - } -} - -// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count: the test binary -// runs goroutines of its own, and the runtime may still be retiring those of the previous -// check. -func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { - r.Helper() - before := settledGoroutines(subject.patience()) - - clk := fake.NewClock(t0) - tr := build(t, r, subject.New, clk) - if _, err := tr.Write(context.Background(), payload); err != nil { - r.Fatalf("Write returned %v on a destination the subject declares healthy", err) - } - if _, _, returned := probe(tr, clk, subject.patience()); !returned { - r.Fatalf("Query never came back; the leak this check looks for is hidden behind it\n%s", goroutineDump()) - } - closeAndForget(tr) - - if !waitUntil(func() bool { return goroutines() <= before }, subject.patience()) { - r.Errorf("goroutines went from %d to %d and stayed there for %s after a whole job and a Close. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every transport takes its own away:\n%s", - before, goroutines(), subject.patience(), goroutineDump()) - } - if _, tickers := clk.Pending(); tickers > 0 { - r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) - } -} - // build calls one of the subject's constructors and refuses a nil transport, which would // otherwise surface as a nil dereference three frames deeper. func build(t *testing.T, r reporter, constructor func(*testing.T, ports.Clock) ports.Transport, diff --git a/internal/printing/transport/file_test.go b/internal/printing/transport/file_test.go new file mode 100644 index 0000000..35ba942 --- /dev/null +++ b/internal/printing/transport/file_test.go @@ -0,0 +1,151 @@ +package transport_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/fake" + "openscale/internal/printing/transport" +) + +// The tests of file.go, read back OFF THE DISK: what was written is exactly what was +// handed over, two labels never share a file, and a file left by an earlier run is NEVER +// overwritten — this is the transport of development and of support, the one whose +// captures are read again afterwards. + +// --- the file transport, read back off the disk ---------------------------- + +// TestTheFileTransportWritesExactlyWhatItWasGiven is the round trip the whole diagnostic +// use rests on: an SBPL frame is binary, and a byte translated on the way is a frame the +// printer no longer understands. +func TestTheFileTransportWritesExactlyWhatItWasGiven(t *testing.T) { + dir := t.TempDir() + spool := spoolIn(t, dir, fake.NewClock(t0)) + defer spool.Close() + + n, err := spool.Write(context.Background(), frame) + if err != nil { + t.Fatalf("Write : %v", err) + } + if n != len(frame) { + t.Fatalf("Write = %d octets, attendu %d", n, len(frame)) + } + + written, err := os.ReadFile(spool.LastPath()) + if err != nil { + t.Fatalf("relecture : %v", err) + } + if string(written) != string(frame) { + t.Fatalf("le fichier porte %x, attendu %x", written, frame) + } + if got, want := filepath.Base(spool.LastPath()), "2026-07-24T14-32-05_001.sbpl"; got != want { + t.Fatalf("le fichier s'appelle %q, attendu %q — c'est le nom de §8.4, les deux-points en moins", got, want) + } +} + +// TestTwoLabelsNeverShareAFile is why the creation is exclusive: a diagnostic file that +// replaced the one before it would lose the very frame somebody asked for. +func TestTwoLabelsNeverShareAFile(t *testing.T) { + dir := t.TempDir() + spool := spoolIn(t, dir, fake.NewClock(t0)) + defer spool.Close() + + seen := make(map[string]bool) + for range 3 { + if _, err := spool.Write(context.Background(), frame); err != nil { + t.Fatalf("Write : %v", err) + } + seen[spool.LastPath()] = true + } + if len(seen) != 3 { + t.Fatalf("trois étiquettes ont produit %d fichiers : %v", len(seen), seen) + } +} + +// TestAFileLeftByAPreviousRunIsNeverOverwritten is the collision the sequence number +// alone cannot avoid: the counter restarts with the process, the clock does not. +func TestAFileLeftByAPreviousRunIsNeverOverwritten(t *testing.T) { + dir := t.TempDir() + occupied := filepath.Join(dir, "2026-07-24T14-32-05_001.sbpl") + if err := os.WriteFile(occupied, []byte("la trame d'hier"), 0o644); err != nil { + t.Fatalf("préparation : %v", err) + } + + spool := spoolIn(t, dir, fake.NewClock(t0)) + defer spool.Close() + if _, err := spool.Write(context.Background(), frame); err != nil { + t.Fatalf("Write : %v", err) + } + + if spool.LastPath() == occupied { + t.Fatalf("le transport a écrasé %s", occupied) + } + kept, err := os.ReadFile(occupied) + if err != nil || string(kept) != "la trame d'hier" { + t.Fatalf("le fichier précédent vaut %q (%v)", kept, err) + } +} + +// TestADirectoryThatDoesNotExistYetIsCreated keeps a support directory nobody created +// from costing a label. +func TestADirectoryThatDoesNotExistYetIsCreated(t *testing.T) { + dir := filepath.Join(t.TempDir(), "etiquettes", "poste-2") + spool := spoolIn(t, dir, fake.NewClock(t0)) + defer spool.Close() + + if _, err := spool.Write(context.Background(), frame); err != nil { + t.Fatalf("Write : %v", err) + } + if _, err := os.Stat(spool.LastPath()); err != nil { + t.Fatalf("le fichier n'a pas été créé : %v", err) + } +} + +// TestADirectoryThatCannotBeCreatedIsAnError is failure test 11 in miniature: the path +// is a FILE, so no directory can go there, and the transport says so instead of losing +// the frame quietly. +func TestADirectoryThatCannotBeCreatedIsAnError(t *testing.T) { + blocked := filepath.Join(t.TempDir(), "obstacle") + if err := os.WriteFile(blocked, nil, 0o644); err != nil { + t.Fatalf("préparation : %v", err) + } + + spool := spoolIn(t, filepath.Join(blocked, "etiquettes"), fake.NewClock(t0)) + defer spool.Close() + if _, err := spool.Write(context.Background(), frame); err == nil { + t.Fatalf("un répertoire impossible a été accepté") + } +} + +// TestTheSearchForAFreeNameGivesUp bounds a loop that would otherwise be infinite the day +// something else is writing into the same directory. +func TestTheSearchForAFreeNameGivesUp(t *testing.T) { + spool, err := transport.NewFile(transport.FileOptions{ + Dir: t.TempDir(), + Clock: fake.NewClock(t0), + Create: func(string) (transport.Sink, error) { return nil, os.ErrExist }, + }) + if err != nil { + t.Fatalf("NewFile : %v", err) + } + defer spool.Close() + + if _, err := spool.Write(context.Background(), frame); err == nil { + t.Fatalf("la recherche d'un nom libre n'a jamais rendu la main") + } else if !strings.Contains(err.Error(), "aucun nom libre") { + t.Fatalf("message inattendu : %v", err) + } +} + +// TestLastPathIsEmptyBeforeTheFirstLabel keeps the troubleshooting screen from offering a +// file that does not exist. +func TestLastPathIsEmptyBeforeTheFirstLabel(t *testing.T) { + spool := spoolIn(t, t.TempDir(), fake.NewClock(t0)) + defer spool.Close() + if path := spool.LastPath(); path != "" { + t.Fatalf("LastPath() = %q avant toute impression", path) + } +} diff --git a/internal/printing/transport/probe.go b/internal/printing/transport/probe.go index fae5ec7..88fe43d 100644 --- a/internal/printing/transport/probe.go +++ b/internal/printing/transport/probe.go @@ -44,7 +44,6 @@ const probeBufferSize = 512 // in microseconds instead of half a second per case (§5.3, §16.4). func interrogate(ctx context.Context, clk ports.Clock, target string, open func() (Duplex, error), request []byte, budget time.Duration) ([]byte, error) { - switch { case len(request) == 0: return nil, fmt.Errorf("%s : aucune requête de statut à envoyer ; l'interrogation native "+ diff --git a/internal/printing/transport/transport_test.go b/internal/printing/transport/transport_test.go index 122a99a..8c914bf 100644 --- a/internal/printing/transport/transport_test.go +++ b/internal/printing/transport/transport_test.go @@ -21,6 +21,15 @@ import ( "openscale/internal/station/ports" ) +// The CONTRACT all four transports of §8.2 honour: the name they announce, the device key +// they really read, what they refuse at construction, what the probe reports, and what +// they do with a cancelled context or a close. +// +// The last two sections are what no double would prove: the production seams, exercised on +// a real file and a real socket, with no printer. +// +// The `file` transport, which is read back off the disk, has its own file. + // t0 is where the injected clock starts. It is the instant §8.4 shows in the name of a // job file, so the names this package produces can be read against the document. var t0 = time.Date(2026, 7, 24, 14, 32, 5, 0, time.UTC) @@ -468,140 +477,6 @@ func TestAWriteThatFailsMidwayReportsWhatWentThrough(t *testing.T) { } } -// --- the file transport, read back off the disk ---------------------------- - -// TestTheFileTransportWritesExactlyWhatItWasGiven is the round trip the whole diagnostic -// use rests on: an SBPL frame is binary, and a byte translated on the way is a frame the -// printer no longer understands. -func TestTheFileTransportWritesExactlyWhatItWasGiven(t *testing.T) { - dir := t.TempDir() - spool := spoolIn(t, dir, fake.NewClock(t0)) - defer spool.Close() - - n, err := spool.Write(context.Background(), frame) - if err != nil { - t.Fatalf("Write : %v", err) - } - if n != len(frame) { - t.Fatalf("Write = %d octets, attendu %d", n, len(frame)) - } - - written, err := os.ReadFile(spool.LastPath()) - if err != nil { - t.Fatalf("relecture : %v", err) - } - if string(written) != string(frame) { - t.Fatalf("le fichier porte %x, attendu %x", written, frame) - } - if got, want := filepath.Base(spool.LastPath()), "2026-07-24T14-32-05_001.sbpl"; got != want { - t.Fatalf("le fichier s'appelle %q, attendu %q — c'est le nom de §8.4, les deux-points en moins", got, want) - } -} - -// TestTwoLabelsNeverShareAFile is why the creation is exclusive: a diagnostic file that -// replaced the one before it would lose the very frame somebody asked for. -func TestTwoLabelsNeverShareAFile(t *testing.T) { - dir := t.TempDir() - spool := spoolIn(t, dir, fake.NewClock(t0)) - defer spool.Close() - - seen := make(map[string]bool) - for range 3 { - if _, err := spool.Write(context.Background(), frame); err != nil { - t.Fatalf("Write : %v", err) - } - seen[spool.LastPath()] = true - } - if len(seen) != 3 { - t.Fatalf("trois étiquettes ont produit %d fichiers : %v", len(seen), seen) - } -} - -// TestAFileLeftByAPreviousRunIsNeverOverwritten is the collision the sequence number -// alone cannot avoid: the counter restarts with the process, the clock does not. -func TestAFileLeftByAPreviousRunIsNeverOverwritten(t *testing.T) { - dir := t.TempDir() - occupied := filepath.Join(dir, "2026-07-24T14-32-05_001.sbpl") - if err := os.WriteFile(occupied, []byte("la trame d'hier"), 0o644); err != nil { - t.Fatalf("préparation : %v", err) - } - - spool := spoolIn(t, dir, fake.NewClock(t0)) - defer spool.Close() - if _, err := spool.Write(context.Background(), frame); err != nil { - t.Fatalf("Write : %v", err) - } - - if spool.LastPath() == occupied { - t.Fatalf("le transport a écrasé %s", occupied) - } - kept, err := os.ReadFile(occupied) - if err != nil || string(kept) != "la trame d'hier" { - t.Fatalf("le fichier précédent vaut %q (%v)", kept, err) - } -} - -// TestADirectoryThatDoesNotExistYetIsCreated keeps a support directory nobody created -// from costing a label. -func TestADirectoryThatDoesNotExistYetIsCreated(t *testing.T) { - dir := filepath.Join(t.TempDir(), "etiquettes", "poste-2") - spool := spoolIn(t, dir, fake.NewClock(t0)) - defer spool.Close() - - if _, err := spool.Write(context.Background(), frame); err != nil { - t.Fatalf("Write : %v", err) - } - if _, err := os.Stat(spool.LastPath()); err != nil { - t.Fatalf("le fichier n'a pas été créé : %v", err) - } -} - -// TestADirectoryThatCannotBeCreatedIsAnError is failure test 11 in miniature: the path -// is a FILE, so no directory can go there, and the transport says so instead of losing -// the frame quietly. -func TestADirectoryThatCannotBeCreatedIsAnError(t *testing.T) { - blocked := filepath.Join(t.TempDir(), "obstacle") - if err := os.WriteFile(blocked, nil, 0o644); err != nil { - t.Fatalf("préparation : %v", err) - } - - spool := spoolIn(t, filepath.Join(blocked, "etiquettes"), fake.NewClock(t0)) - defer spool.Close() - if _, err := spool.Write(context.Background(), frame); err == nil { - t.Fatalf("un répertoire impossible a été accepté") - } -} - -// TestTheSearchForAFreeNameGivesUp bounds a loop that would otherwise be infinite the day -// something else is writing into the same directory. -func TestTheSearchForAFreeNameGivesUp(t *testing.T) { - spool, err := transport.NewFile(transport.FileOptions{ - Dir: t.TempDir(), - Clock: fake.NewClock(t0), - Create: func(string) (transport.Sink, error) { return nil, os.ErrExist }, - }) - if err != nil { - t.Fatalf("NewFile : %v", err) - } - defer spool.Close() - - if _, err := spool.Write(context.Background(), frame); err == nil { - t.Fatalf("la recherche d'un nom libre n'a jamais rendu la main") - } else if !strings.Contains(err.Error(), "aucun nom libre") { - t.Fatalf("message inattendu : %v", err) - } -} - -// TestLastPathIsEmptyBeforeTheFirstLabel keeps the troubleshooting screen from offering a -// file that does not exist. -func TestLastPathIsEmptyBeforeTheFirstLabel(t *testing.T) { - spool := spoolIn(t, t.TempDir(), fake.NewClock(t0)) - defer spool.Close() - if path := spool.LastPath(); path != "" { - t.Fatalf("LastPath() = %q avant toute impression", path) - } -} - // --- the production seams, exercised without any printer ------------------- // TestOpenSystemNodeWritesToARealFile exercises the REAL opener of the Linux default, diff --git a/internal/scale/conformance/check_channels.go b/internal/scale/conformance/check_channels.go new file mode 100644 index 0000000..8ce815b --- /dev/null +++ b/internal/scale/conformance/check_channels.go @@ -0,0 +1,80 @@ +package conformance + +import ( + "context" + "testing" + + "openscale/internal/domain" + "openscale/internal/fake" +) + +// THE TWO CHANNELS — clauses 2 and 3, and they are the two that have already frozen a +// station. +// +// out belongs to the Hub for the whole lifetime of the process: a driver that closes it +// breaks the serial -> manual -> serial round trip and makes the degraded state +// IRREVERSIBLE (bloquant-2). done is closed on EVERY exit path, a Start that returned an +// error included — otherwise the wait in restartScale never unblocks, and the volunteer +// who changed a setting watches a screen that never answers (§11.4). + +// checkOutIsNeverClosed is bloquant-2, and it is the clause with the worst +// consequence: a driver that closes the Hub's channel makes the manual fallback +// permanent, because the channel it should come back on no longer exists. +func checkOutIsNeverClosed(t *testing.T, r reporter, subject Subject) { + r.Helper() + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + session.stop(r) + + if session.sawOutClosed() { + r.Fatalf("the driver CLOSED out. That channel belongs to the Hub for the lifetime of the process; closing it breaks the serial -> manual -> serial round trip and makes the degraded state irreversible (bloquant-2). Signal your own termination by closing done, which is yours") + } + if !session.outStillAcceptsASend() { + r.Errorf("out no longer accepts a send once the driver is gone: it was closed. The Hub hands THIS SAME channel to the next driver, which is what makes the return to serial possible (bloquant-2)") + } +} + +// checkDoneClosesWhenTheContextEnds verifies the exit the Hub uses on every reload. +func checkDoneClosesWhenTheContextEnds(t *testing.T, r reporter, subject Subject) { + r.Helper() + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + + session.cancel() + if !waitClosed(session.done, session.patience) { + r.Fatalf("done was still open %s after the context was cancelled. Start publishes until ctx is done and THEN closes done (§5.3); the wait in restartScale is what would never unblock, and PUT /admin/api/config would never answer (§11.4)", session.patience) + } +} + +// checkDoneClosesWhenStartFails is the mandatory corollary, and it is test de panne +// 1 ter (b): a driver that returns an error before it ever launched its goroutine +// still owes the Hub a closed done. +func checkDoneClosesWhenStartFails(t *testing.T, r reporter, subject Subject) { + r.Helper() + if subject.Unstartable == nil { + r.Skipf("Subject.Unstartable is nil: this driver declares no way for Start to fail. Supply it as soon as the driver opens a device — a port name that does not exist is enough — because this is the clause whose breach makes the configuration screen hang (§11.4)") + } + scale := build(t, r, subject.Unstartable, fake.NewClock(t0)) + defer closeAndForget(scale) + + out := make(chan domain.ScaleEvent, outBuffer) + done := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err, panicked := startQuietly(scale, ctx, out, done) + if panicked != nil { + r.Fatalf("Start PANICKED instead of returning an error: %v. A port that is not there is an ordinary Tuesday, not a programming error", panicked) + } + if err == nil { + r.Errorf("Start SUCCEEDED on the driver Subject.Unstartable built, so the subject is not what it declares; cancelling and checking done anyway") + cancel() + } + if !waitClosed(done, subject.patience()) { + r.Fatalf("Start returned %v and left done OPEN. done is closed on EVERY exit path, this one included: the wait in restartScale would never unblock, the configuration would never be written, and a volunteer would be left with a screen that does not answer (§11.4, test de panne 1 ter b)", err) + } +} diff --git a/internal/scale/conformance/check_events.go b/internal/scale/conformance/check_events.go new file mode 100644 index 0000000..4e0ae62 --- /dev/null +++ b/internal/scale/conformance/check_events.go @@ -0,0 +1,94 @@ +package conformance + +import ( + "context" + "testing" + "time" + + "openscale/internal/domain" +) + +// WHAT TRAVELS ON THE CHANNELS — clauses 4 and 6. +// +// The last event carries StatusDisconnected, and that field ALONE is what loses the +// scale on the Hub side (défaut 40): a driver whose exit is silent leaves the screen +// showing the weight of a bag that is no longer there. And every Measurement is +// coherent, with a Timestamp from the clock the driver was GIVEN — the age of a +// measurement is Now - Timestamp, so a driver on the wall clock defeats that +// computation (bloquant-1). + +// checkLastEventIsDisconnected is défaut 40. +// +// It also proves the other half of clause 2: these events were read from the channel +// the SUITE created and lent out, so a driver publishing on some channel of its own +// would arrive here with nothing to show. +func checkLastEventIsDisconnected(t *testing.T, r reporter, subject Subject) { + r.Helper() + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + session.stop(r) + + events := session.collected() + if len(events) == 0 { + r.Fatalf("the driver published NOTHING on out over its whole life. A driver that exits emits one last ScaleEvent{StatusDisconnected} (§9.1): without it the Hub never learns the scale is gone and the screen keeps showing the weight of a bag that has left") + } + last := events[len(events)-1] + if last.Status != domain.StatusDisconnected { + r.Errorf("the last of %d events has Status = %s, want %s. That field ALONE loses the scale on the Hub side, which is why it may never be left to the caller to deduce from Err (défaut 40)", len(events), last.Status, domain.StatusDisconnected) + } + if subject.RequireDisconnectCause && last.Err == nil { + r.Errorf("the last event has Err = nil while this subject asks to be held to the tightened contract of §9.1: the device error when there is one, ctx.Err() on cancellation, ErrLoopStopped otherwise. Nothing CONDITIONS on Err — it has to stay loggable") + } +} + +// checkMeasurementsAreCoherent reads every measurement the driver published and holds +// it to what the rest of the application assumes without ever checking again. +func checkMeasurementsAreCoherent(t *testing.T, r reporter, subject Subject) { + r.Helper() + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + + if subject.Feed != nil && !session.awaitMeasurement() { + r.Fatalf("%d bytes went in through Subject.Feed and not one Measurement came out within %s. Feed the accumulator of internal/domain/frame rather than a fixed window: a decoder that silently drops what it does not recognise is exactly the 18-byte read that lost one frame in two (§9.1)", + len(subject.Frames), session.patience) + } + session.stop(r) + + // The window the INJECTED clock covered. It closes here and not at t0 because + // Subject.Feed is allowed to advance that clock for a driver that paces itself on it. + window := session.clock.Now() + + var previous time.Time + for i, event := range session.collected() { + measurement := event.Measurement + if measurement == nil { + continue + } + switch { + case measurement.Timestamp.IsZero(): + r.Errorf("event %d: Measurement.Timestamp is the zero instant. The age of a reading is Now - Timestamp (§6.5): a zero instant makes every measurement look expired and no weighing possible at all (bloquant-1)", i) + case measurement.Timestamp.Before(t0), measurement.Timestamp.After(window): + r.Errorf("event %d: Measurement.Timestamp = %s falls outside [%s, %s], the window the clock the suite HANDED YOU ever covered: this driver reads a clock of its own. `go run ./tools/boundary` walks our files and cannot see inside yours, so this check stands in for it (§5.3)", + i, measurement.Timestamp.UTC(), t0.UTC(), window.UTC()) + case measurement.Timestamp.Before(previous): + r.Errorf("event %d: Measurement.Timestamp = %s goes backwards from %s on the previous measurement. One clock, one direction: an age computed against a wandering instant can come out negative", i, measurement.Timestamp.UTC(), previous.UTC()) + default: + previous = measurement.Timestamp + } + if measurement.Gross > MaxExpressibleGrams || measurement.Gross < -MaxExpressibleGrams { + r.Errorf("event %d: Measurement.Gross = %d g is outside ±%d g, which is everything the frame grammar of §9.2 can express. A mass no frame could have carried means the decoder invented digits, and the barcode carries five of them", i, measurement.Gross, MaxExpressibleGrams) + } + switch measurement.Stability { + case domain.Stable, domain.Unstable, domain.StabilityUnknown, domain.StabilityNotApplicable: + default: + r.Errorf("event %d: Measurement.Stability = %d is outside the vocabulary. A model that does not report the ST/US flag says StabilityUnknown and lets the variation criterion take over; it does not invent a value (§6.5)", i, measurement.Stability) + } + if event.Status == domain.StatusDisconnected { + r.Errorf("event %d carries a Measurement AND Status = %s. Those two contradict each other: this single event both delivers a weight and takes the scale away, and Status is what the Hub acts on (défaut 40)", i, domain.StatusDisconnected) + } + } +} diff --git a/internal/scale/conformance/check_exit.go b/internal/scale/conformance/check_exit.go new file mode 100644 index 0000000..b71e619 --- /dev/null +++ b/internal/scale/conformance/check_exit.go @@ -0,0 +1,98 @@ +package conformance + +import ( + "context" + "testing" + + "openscale/internal/domain" + "openscale/internal/fake" +) + +// THE EXITS — clauses 5, 7 and 8: Close is idempotent because the Hub closes on a reload +// and again on shutdown; a context already cancelled BEFORE Start is a real start-up +// race, a reload overlapping a shutdown, and not a theoretical one; and no goroutine +// survives the driver. + +// checkCloseIsIdempotent covers both calls the Hub really makes: the one after a +// failed Start, and the one on shutdown that follows a reload. +func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { + r.Helper() + + // A driver that was never started. The Hub reaches this every time Start fails: it + // builds, it fails, it closes. + unstarted := build(t, r, subject.New, fake.NewClock(t0)) + for call := 1; call <= 3; call++ { + if _, panicked := closeQuietly(unstarted); panicked != nil { + r.Fatalf("Close PANICKED on call %d of a driver that was never started: %v. Close releases what was taken and says nothing about what was not", call, panicked) + } + } + + // And a driver that ran. Returning an error on the second call is allowed — a + // handle already released is not news, and the Hub logs it as ERR-SCL-08 — but a + // panic takes the whole station down with it. + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + session.quiesce(r) + for call := 1; call <= 3; call++ { + if _, panicked := closeQuietly(session.scale); panicked != nil { + r.Fatalf("Close PANICKED on call %d after a normal exit: %v. The Hub closes on a reload and again on shutdown (§11.4, §13.4)", call, panicked) + } + } +} + +// checkStartSurvivesACancelledContext is the start-up race of a reload that overlaps +// the shutdown of the driver it replaces. +// +// Start is free to return an error or nil here — the context is already dead, both +// are honest. What it may not do is panic, leave done open, or close out. +func checkStartSurvivesACancelledContext(t *testing.T, r reporter, subject Subject) { + r.Helper() + dead, cancel := context.WithCancel(context.Background()) + cancel() + + session := newSession(t, r, subject, dead) + defer session.release() + if !waitClosed(session.done, session.patience) { + r.Fatalf("done was still open %s after Start was handed a context that was ALREADY cancelled. Start returned %v; whichever way it leaves, it closes done (§5.3)", session.patience, session.startErr) + } + session.stopCollecting() + + if session.sawOutClosed() { + r.Errorf("the driver closed out on the cancelled-context path. out belongs to the Hub on every path, this one included (bloquant-2)") + } + if events := session.collected(); len(events) > 0 { + if last := events[len(events)-1]; last.Status != domain.StatusDisconnected { + r.Errorf("the last event has Status = %s, want %s: whatever path a driver leaves by, it leaves Disconnected (défaut 40)", last.Status, domain.StatusDisconnected) + } + } + if _, panicked := closeQuietly(session.scale); panicked != nil { + r.Fatalf("Close PANICKED after a start on a cancelled context: %v", panicked) + } +} + +// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count. +// +// An absolute number would be worthless: the test binary runs goroutines of its own, +// and the runtime may still be retiring those of the previous check. What is asserted +// is that the count comes back to where it was, once done has been closed and Close +// has returned — which is why the suite waits for those two before looking. +func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { + r.Helper() + before := settledGoroutines(subject.patience()) + + session := newSession(t, r, subject, context.Background()) + defer session.release() + requireStarted(r, session) + session.feed(t) + session.stop(r) + + if !waitUntil(func() bool { return goroutines() <= before }, session.patience) { + r.Errorf("goroutines went from %d to %d and stayed there for %s after done was closed and Close returned. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every driver takes its own away:\n%s", + before, goroutines(), session.patience, goroutineDump()) + } + if _, tickers := session.clock.Pending(); tickers > 0 { + r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) + } +} diff --git a/internal/scale/conformance/check_identity.go b/internal/scale/conformance/check_identity.go new file mode 100644 index 0000000..3437ff8 --- /dev/null +++ b/internal/scale/conformance/check_identity.go @@ -0,0 +1,42 @@ +package conformance + +import ( + "strings" + "testing" + "unicode" + + "openscale/internal/fake" +) + +// IDENTITY — clause 1. Descriptor is a REGISTRY KEY: an empty ID, a blank or an +// upper-case letter in it, and scale.type can no longer name the driver in config.json. + +// checkDescriptor verifies the identity the registry and the admin form both read. +// +// Descriptor is called before anything is started, because that is when the Hub calls +// it: the drop-down list of scale.type is built from drivers nobody has opened yet. +func checkDescriptor(t *testing.T, r reporter, subject Subject) { + r.Helper() + scale := build(t, r, subject.New, fake.NewClock(t0)) + defer closeAndForget(scale) + + descriptor := scale.Descriptor() + if descriptor.ID == "" { + r.Errorf("Descriptor().ID is empty. It is the key of the driver registry and the value of scale.type in config.json: an anonymous driver cannot be named by a configuration file, and the admin screen has nothing to generate its form from") + } + if descriptor.Label == "" { + r.Errorf("Descriptor().Label is empty. It is what a volunteer replacing the hardware looks for in the menu, and it has to be the name printed on the device: « GRAM XFOC RS »") + } + if descriptor.NominalRate <= 0 { + r.Errorf("Descriptor().NominalRate = %s, want > 0. The rate meter starts from the declared cadence and only leaves it once it holds eight intervals of its own; a zero cadence makes the derived expiry meaningless (§6.5)", descriptor.NominalRate) + } + if i := strings.IndexFunc(descriptor.ID, unicode.IsSpace); i >= 0 { + r.Errorf("Descriptor().ID = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", descriptor.ID, i) + } + if i := strings.IndexFunc(descriptor.ID, unicode.IsUpper); i >= 0 { + r.Errorf("Descriptor().ID = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different driver — the same trap the legacy application fell into with the case of its frame suffix", descriptor.ID, i, strings.ToLower(descriptor.ID)) + } + if again := scale.Descriptor(); again != descriptor { + r.Errorf("Descriptor() answered %+v then %+v. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", descriptor, again) + } +} diff --git a/internal/scale/conformance/conformance.go b/internal/scale/conformance/conformance.go index 3289051..96c78c6 100644 --- a/internal/scale/conformance/conformance.go +++ b/internal/scale/conformance/conformance.go @@ -45,99 +45,18 @@ package conformance import ( - "context" - "strings" "testing" - "time" - "unicode" - "openscale/internal/domain" - "openscale/internal/fake" "openscale/internal/station/ports" ) -// MaxExpressibleGrams is the heaviest mass the frame grammar of §9.2 can express: six -// integer digits of kilograms and the three decimals that survive the padding. +// This file is the HARNESS and the LIST: what a subject must declare, how a verdict is +// handed down, and the nine cases in the order a contributor wants to read them. The +// cases themselves are in the four check_*.go files, one per family, and what a subject +// IS is in subject.go. // -// The coherence check bounds Gross by the GRAMMAR and not by any model capacity, on -// purpose. A scale over capacity reports whatever it likes — the corpus holds an -// "OL,GS,+ 99.999KG" — and the suite must not call that a driver bug: the field that -// carries the truth there is Overload, and safeguard rule 1 reads it (§6.4). -const MaxExpressibleGrams domain.Grams = 999_999_999 - -// Subject is the driver submitted to the suite. -// -// Two fields are mandatory, Name and New; every other one widens what the suite can -// reach. That asymmetry is the point: submitting a driver must cost one function -// literal, and a contributor who supplies nothing else still gets the checks that -// need no device. -type Subject struct { - // Name is the driver under test, spelled as its registry key: "gram-xfoc-rs". It - // names the sub-test group, so it appears in every failure line. - Name string - - // New returns ONE fresh driver, built but not started. - // - // It is called once per check, because a driver that has been started, cancelled - // and closed is not a fair subject for the next one. clk is the clock the driver - // MUST take its instants from: the suite hands over a fake one and then checks - // the timestamps that come back. - New func(t *testing.T, clk ports.Clock) ports.Scale - - // Unstartable returns a driver whose Start is expected to FAIL before it ever - // launches its goroutine: a port that does not exist, a handle already held. - // - // Leave it nil when the driver has no such failure mode — the empty weight source - // of internal/scale/absent opens nothing — and the check that needs it reports - // itself SKIPPED rather than passed. Any driver that opens a device should supply - // it: "done is closed even when Start returns an error" is the clause a driver - // breaks first, and the only one whose consequence is a screen that never answers - // (§11.4). - Unstartable func(t *testing.T, clk ports.Clock) ports.Scale - - // Feed hands raw device bytes to a driver the suite has already started, the way - // the wire would. - // - // It is what turns the measurement checks from "whatever happened to arrive" into - // an assertion. The closure usually writes into the pipe that the New closure kept - // a side reference to, and it may also advance the clock it was given, for a driver - // that paces itself on it. - // - // Setting Feed REQUIRES Frames, and Frames without Feed is refused: test data that - // silently reaches nothing is worse than no test data. - Feed func(t *testing.T, s ports.Scale, raw []byte) - - // Frames is what Feed injects: a capture of this very model, ideally one straight - // out of `openscale capture --port COM3 --duration 60s`. - Frames []byte - - // RequireDisconnectCause also demands a non-nil Err on the last event. - // - // Off by default, and deliberately so: ports.Scale does NOT require it. Making the - // loss of the scale depend on an optional field is exactly the defect that let the - // signal fall into a default branch and never reach the state machine (défaut 40). - // internal/scale/serial tightens its OWN contract so that the cause always remains - // loggable (§9.1) and turns this on to be held to it. - RequireDisconnectCause bool - - // Patience is how long the suite waits, ON THE WALL CLOCK, for a driver to do what - // it said it would: close done, publish a measurement, let its goroutines go. Zero - // means defaultPatience. - // - // Wall clock, in a repository where everything else runs on an injected fake, - // because what is bounded here is a goroutine leaving blocking OS I/O. Raise it for - // a device that is genuinely slow to release a handle; do not raise it to make a - // flaky driver pass. - Patience time.Duration -} - -// patience reports the wall-clock budget of one wait. -func (s Subject) patience() time.Duration { - if s.Patience > 0 { - return s.Patience - } - return defaultPatience -} +// Adding a case means adding it to checks() below AND to a family file. A case that +// exists but is not listed here is a case the suite never runs. // Suite runs every conformance check against subject, each one as a sub-test of t. // @@ -227,258 +146,6 @@ func validate(r reporter, subject Subject) { } } -// checkDescriptor verifies the identity the registry and the admin form both read. -// -// Descriptor is called before anything is started, because that is when the Hub calls -// it: the drop-down list of scale.type is built from drivers nobody has opened yet. -func checkDescriptor(t *testing.T, r reporter, subject Subject) { - r.Helper() - scale := build(t, r, subject.New, fake.NewClock(t0)) - defer closeAndForget(scale) - - descriptor := scale.Descriptor() - if descriptor.ID == "" { - r.Errorf("Descriptor().ID is empty. It is the key of the driver registry and the value of scale.type in config.json: an anonymous driver cannot be named by a configuration file, and the admin screen has nothing to generate its form from") - } - if descriptor.Label == "" { - r.Errorf("Descriptor().Label is empty. It is what a volunteer replacing the hardware looks for in the menu, and it has to be the name printed on the device: « GRAM XFOC RS »") - } - if descriptor.NominalRate <= 0 { - r.Errorf("Descriptor().NominalRate = %s, want > 0. The rate meter starts from the declared cadence and only leaves it once it holds eight intervals of its own; a zero cadence makes the derived expiry meaningless (§6.5)", descriptor.NominalRate) - } - if i := strings.IndexFunc(descriptor.ID, unicode.IsSpace); i >= 0 { - r.Errorf("Descriptor().ID = %q holds a blank at byte %d. It is a configuration value a human types: nobody types a trailing space twice the same way, and the registry lookup is an exact string comparison", descriptor.ID, i) - } - if i := strings.IndexFunc(descriptor.ID, unicode.IsUpper); i >= 0 { - r.Errorf("Descriptor().ID = %q holds an upper-case letter at byte %d. The registry is keyed on the exact string, so %q would be a different driver — the same trap the legacy application fell into with the case of its frame suffix", descriptor.ID, i, strings.ToLower(descriptor.ID)) - } - if again := scale.Descriptor(); again != descriptor { - r.Errorf("Descriptor() answered %+v then %+v. The registry, the admin form and the journal each read it separately; an identity that moves between two calls is not an identity", descriptor, again) - } -} - -// checkOutIsNeverClosed is bloquant-2, and it is the clause with the worst -// consequence: a driver that closes the Hub's channel makes the manual fallback -// permanent, because the channel it should come back on no longer exists. -func checkOutIsNeverClosed(t *testing.T, r reporter, subject Subject) { - r.Helper() - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - session.stop(r) - - if session.sawOutClosed() { - r.Fatalf("the driver CLOSED out. That channel belongs to the Hub for the lifetime of the process; closing it breaks the serial -> manual -> serial round trip and makes the degraded state irreversible (bloquant-2). Signal your own termination by closing done, which is yours") - } - if !session.outStillAcceptsASend() { - r.Errorf("out no longer accepts a send once the driver is gone: it was closed. The Hub hands THIS SAME channel to the next driver, which is what makes the return to serial possible (bloquant-2)") - } -} - -// checkDoneClosesWhenTheContextEnds verifies the exit the Hub uses on every reload. -func checkDoneClosesWhenTheContextEnds(t *testing.T, r reporter, subject Subject) { - r.Helper() - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - - session.cancel() - if !waitClosed(session.done, session.patience) { - r.Fatalf("done was still open %s after the context was cancelled. Start publishes until ctx is done and THEN closes done (§5.3); the wait in restartScale is what would never unblock, and PUT /admin/api/config would never answer (§11.4)", session.patience) - } -} - -// checkDoneClosesWhenStartFails is the mandatory corollary, and it is test de panne -// 1 ter (b): a driver that returns an error before it ever launched its goroutine -// still owes the Hub a closed done. -func checkDoneClosesWhenStartFails(t *testing.T, r reporter, subject Subject) { - r.Helper() - if subject.Unstartable == nil { - r.Skipf("Subject.Unstartable is nil: this driver declares no way for Start to fail. Supply it as soon as the driver opens a device — a port name that does not exist is enough — because this is the clause whose breach makes the configuration screen hang (§11.4)") - } - scale := build(t, r, subject.Unstartable, fake.NewClock(t0)) - defer closeAndForget(scale) - - out := make(chan domain.ScaleEvent, outBuffer) - done := make(chan struct{}) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err, panicked := startQuietly(scale, ctx, out, done) - if panicked != nil { - r.Fatalf("Start PANICKED instead of returning an error: %v. A port that is not there is an ordinary Tuesday, not a programming error", panicked) - } - if err == nil { - r.Errorf("Start SUCCEEDED on the driver Subject.Unstartable built, so the subject is not what it declares; cancelling and checking done anyway") - cancel() - } - if !waitClosed(done, subject.patience()) { - r.Fatalf("Start returned %v and left done OPEN. done is closed on EVERY exit path, this one included: the wait in restartScale would never unblock, the configuration would never be written, and a volunteer would be left with a screen that does not answer (§11.4, test de panne 1 ter b)", err) - } -} - -// checkLastEventIsDisconnected is défaut 40. -// -// It also proves the other half of clause 2: these events were read from the channel -// the SUITE created and lent out, so a driver publishing on some channel of its own -// would arrive here with nothing to show. -func checkLastEventIsDisconnected(t *testing.T, r reporter, subject Subject) { - r.Helper() - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - session.stop(r) - - events := session.collected() - if len(events) == 0 { - r.Fatalf("the driver published NOTHING on out over its whole life. A driver that exits emits one last ScaleEvent{StatusDisconnected} (§9.1): without it the Hub never learns the scale is gone and the screen keeps showing the weight of a bag that has left") - } - last := events[len(events)-1] - if last.Status != domain.StatusDisconnected { - r.Errorf("the last of %d events has Status = %s, want %s. That field ALONE loses the scale on the Hub side, which is why it may never be left to the caller to deduce from Err (défaut 40)", len(events), last.Status, domain.StatusDisconnected) - } - if subject.RequireDisconnectCause && last.Err == nil { - r.Errorf("the last event has Err = nil while this subject asks to be held to the tightened contract of §9.1: the device error when there is one, ctx.Err() on cancellation, ErrLoopStopped otherwise. Nothing CONDITIONS on Err — it has to stay loggable") - } -} - -// checkCloseIsIdempotent covers both calls the Hub really makes: the one after a -// failed Start, and the one on shutdown that follows a reload. -func checkCloseIsIdempotent(t *testing.T, r reporter, subject Subject) { - r.Helper() - - // A driver that was never started. The Hub reaches this every time Start fails: it - // builds, it fails, it closes. - unstarted := build(t, r, subject.New, fake.NewClock(t0)) - for call := 1; call <= 3; call++ { - if _, panicked := closeQuietly(unstarted); panicked != nil { - r.Fatalf("Close PANICKED on call %d of a driver that was never started: %v. Close releases what was taken and says nothing about what was not", call, panicked) - } - } - - // And a driver that ran. Returning an error on the second call is allowed — a - // handle already released is not news, and the Hub logs it as ERR-SCL-08 — but a - // panic takes the whole station down with it. - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - session.quiesce(r) - for call := 1; call <= 3; call++ { - if _, panicked := closeQuietly(session.scale); panicked != nil { - r.Fatalf("Close PANICKED on call %d after a normal exit: %v. The Hub closes on a reload and again on shutdown (§11.4, §13.4)", call, panicked) - } - } -} - -// checkMeasurementsAreCoherent reads every measurement the driver published and holds -// it to what the rest of the application assumes without ever checking again. -func checkMeasurementsAreCoherent(t *testing.T, r reporter, subject Subject) { - r.Helper() - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - - if subject.Feed != nil && !session.awaitMeasurement() { - r.Fatalf("%d bytes went in through Subject.Feed and not one Measurement came out within %s. Feed the accumulator of internal/domain/frame rather than a fixed window: a decoder that silently drops what it does not recognise is exactly the 18-byte read that lost one frame in two (§9.1)", - len(subject.Frames), session.patience) - } - session.stop(r) - - // The window the INJECTED clock covered. It closes here and not at t0 because - // Subject.Feed is allowed to advance that clock for a driver that paces itself on it. - window := session.clock.Now() - - var previous time.Time - for i, event := range session.collected() { - measurement := event.Measurement - if measurement == nil { - continue - } - switch { - case measurement.Timestamp.IsZero(): - r.Errorf("event %d: Measurement.Timestamp is the zero instant. The age of a reading is Now - Timestamp (§6.5): a zero instant makes every measurement look expired and no weighing possible at all (bloquant-1)", i) - case measurement.Timestamp.Before(t0), measurement.Timestamp.After(window): - r.Errorf("event %d: Measurement.Timestamp = %s falls outside [%s, %s], the window the clock the suite HANDED YOU ever covered: this driver reads a clock of its own. `go run ./tools/boundary` walks our files and cannot see inside yours, so this check stands in for it (§5.3)", - i, measurement.Timestamp.UTC(), t0.UTC(), window.UTC()) - case measurement.Timestamp.Before(previous): - r.Errorf("event %d: Measurement.Timestamp = %s goes backwards from %s on the previous measurement. One clock, one direction: an age computed against a wandering instant can come out negative", i, measurement.Timestamp.UTC(), previous.UTC()) - default: - previous = measurement.Timestamp - } - if measurement.Gross > MaxExpressibleGrams || measurement.Gross < -MaxExpressibleGrams { - r.Errorf("event %d: Measurement.Gross = %d g is outside ±%d g, which is everything the frame grammar of §9.2 can express. A mass no frame could have carried means the decoder invented digits, and the barcode carries five of them", i, measurement.Gross, MaxExpressibleGrams) - } - switch measurement.Stability { - case domain.Stable, domain.Unstable, domain.StabilityUnknown, domain.StabilityNotApplicable: - default: - r.Errorf("event %d: Measurement.Stability = %d is outside the vocabulary. A model that does not report the ST/US flag says StabilityUnknown and lets the variation criterion take over; it does not invent a value (§6.5)", i, measurement.Stability) - } - if event.Status == domain.StatusDisconnected { - r.Errorf("event %d carries a Measurement AND Status = %s. Those two contradict each other: this single event both delivers a weight and takes the scale away, and Status is what the Hub acts on (défaut 40)", i, domain.StatusDisconnected) - } - } -} - -// checkStartSurvivesACancelledContext is the start-up race of a reload that overlaps -// the shutdown of the driver it replaces. -// -// Start is free to return an error or nil here — the context is already dead, both -// are honest. What it may not do is panic, leave done open, or close out. -func checkStartSurvivesACancelledContext(t *testing.T, r reporter, subject Subject) { - r.Helper() - dead, cancel := context.WithCancel(context.Background()) - cancel() - - session := newSession(t, r, subject, dead) - defer session.release() - if !waitClosed(session.done, session.patience) { - r.Fatalf("done was still open %s after Start was handed a context that was ALREADY cancelled. Start returned %v; whichever way it leaves, it closes done (§5.3)", session.patience, session.startErr) - } - session.stopCollecting() - - if session.sawOutClosed() { - r.Errorf("the driver closed out on the cancelled-context path. out belongs to the Hub on every path, this one included (bloquant-2)") - } - if events := session.collected(); len(events) > 0 { - if last := events[len(events)-1]; last.Status != domain.StatusDisconnected { - r.Errorf("the last event has Status = %s, want %s: whatever path a driver leaves by, it leaves Disconnected (défaut 40)", last.Status, domain.StatusDisconnected) - } - } - if _, panicked := closeQuietly(session.scale); panicked != nil { - r.Fatalf("Close PANICKED after a start on a cancelled context: %v", panicked) - } -} - -// checkNoGoroutineLeaks compares a DIFFERENCE and not an absolute count. -// -// An absolute number would be worthless: the test binary runs goroutines of its own, -// and the runtime may still be retiring those of the previous check. What is asserted -// is that the count comes back to where it was, once done has been closed and Close -// has returned — which is why the suite waits for those two before looking. -func checkNoGoroutineLeaks(t *testing.T, r reporter, subject Subject) { - r.Helper() - before := settledGoroutines(subject.patience()) - - session := newSession(t, r, subject, context.Background()) - defer session.release() - requireStarted(r, session) - session.feed(t) - session.stop(r) - - if !waitUntil(func() bool { return goroutines() <= before }, session.patience) { - r.Errorf("goroutines went from %d to %d and stayed there for %s after done was closed and Close returned. §13.1 claims the inventory of goroutines is exhaustive, and it is only true if every driver takes its own away:\n%s", - before, goroutines(), session.patience, goroutineDump()) - } - if _, tickers := session.clock.Pending(); tickers > 0 { - r.Errorf("%d ticker(s) of the injected clock are still running after Close. The stop function that Clock.Ticker returns is not optional: a ticker nobody stops is a leak the goroutine count cannot always see (§13.1)", tickers) - } -} - // requireStarted refuses to judge the rest of the contract on a driver that never // started: every clause that follows would report the environment's fault as the // driver's. diff --git a/internal/scale/conformance/subject.go b/internal/scale/conformance/subject.go new file mode 100644 index 0000000..4eacf86 --- /dev/null +++ b/internal/scale/conformance/subject.go @@ -0,0 +1,98 @@ +package conformance + +import ( + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// What a subject IS: the two mandatory fields, every optional one that widens what the +// suite can reach, and the bound the coherence check reads masses against. +// +// The asymmetry is the point — submitting a driver must cost one function literal, and +// a contributor who supplies nothing else still gets the checks that need no device. + +// MaxExpressibleGrams is the heaviest mass the frame grammar of §9.2 can express: six +// integer digits of kilograms and the three decimals that survive the padding. +// +// The coherence check bounds Gross by the GRAMMAR and not by any model capacity, on +// purpose. A scale over capacity reports whatever it likes — the corpus holds an +// "OL,GS,+ 99.999KG" — and the suite must not call that a driver bug: the field that +// carries the truth there is Overload, and safeguard rule 1 reads it (§6.4). +const MaxExpressibleGrams domain.Grams = 999_999_999 + +// Subject is the driver submitted to the suite. +// +// Two fields are mandatory, Name and New; every other one widens what the suite can +// reach. That asymmetry is the point: submitting a driver must cost one function +// literal, and a contributor who supplies nothing else still gets the checks that +// need no device. +type Subject struct { + // Name is the driver under test, spelled as its registry key: "gram-xfoc-rs". It + // names the sub-test group, so it appears in every failure line. + Name string + + // New returns ONE fresh driver, built but not started. + // + // It is called once per check, because a driver that has been started, cancelled + // and closed is not a fair subject for the next one. clk is the clock the driver + // MUST take its instants from: the suite hands over a fake one and then checks + // the timestamps that come back. + New func(t *testing.T, clk ports.Clock) ports.Scale + + // Unstartable returns a driver whose Start is expected to FAIL before it ever + // launches its goroutine: a port that does not exist, a handle already held. + // + // Leave it nil when the driver has no such failure mode — the empty weight source + // of internal/scale/absent opens nothing — and the check that needs it reports + // itself SKIPPED rather than passed. Any driver that opens a device should supply + // it: "done is closed even when Start returns an error" is the clause a driver + // breaks first, and the only one whose consequence is a screen that never answers + // (§11.4). + Unstartable func(t *testing.T, clk ports.Clock) ports.Scale + + // Feed hands raw device bytes to a driver the suite has already started, the way + // the wire would. + // + // It is what turns the measurement checks from "whatever happened to arrive" into + // an assertion. The closure usually writes into the pipe that the New closure kept + // a side reference to, and it may also advance the clock it was given, for a driver + // that paces itself on it. + // + // Setting Feed REQUIRES Frames, and Frames without Feed is refused: test data that + // silently reaches nothing is worse than no test data. + Feed func(t *testing.T, s ports.Scale, raw []byte) + + // Frames is what Feed injects: a capture of this very model, ideally one straight + // out of `openscale capture --port COM3 --duration 60s`. + Frames []byte + + // RequireDisconnectCause also demands a non-nil Err on the last event. + // + // Off by default, and deliberately so: ports.Scale does NOT require it. Making the + // loss of the scale depend on an optional field is exactly the defect that let the + // signal fall into a default branch and never reach the state machine (défaut 40). + // internal/scale/serial tightens its OWN contract so that the cause always remains + // loggable (§9.1) and turns this on to be held to it. + RequireDisconnectCause bool + + // Patience is how long the suite waits, ON THE WALL CLOCK, for a driver to do what + // it said it would: close done, publish a measurement, let its goroutines go. Zero + // means defaultPatience. + // + // Wall clock, in a repository where everything else runs on an injected fake, + // because what is bounded here is a goroutine leaving blocking OS I/O. Raise it for + // a device that is genuinely slow to release a handle; do not raise it to make a + // flaky driver pass. + Patience time.Duration +} + +// patience reports the wall-clock budget of one wait. +func (s Subject) patience() time.Duration { + if s.Patience > 0 { + return s.Patience + } + return defaultPatience +} diff --git a/internal/scale/replay/exit_test.go b/internal/scale/replay/exit_test.go new file mode 100644 index 0000000..b6ac199 --- /dev/null +++ b/internal/scale/replay/exit_test.go @@ -0,0 +1,225 @@ +package replay + +import ( + "context" + "errors" + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/domain/frame" + "openscale/internal/fake" +) + +// The exit contract of ports.Scale, which this driver owes exactly like a serial one: +// done is closed on EVERY path — an exhausted capture, a cancellation, a Start that +// refused, a second Start — the LAST event carries StatusDisconnected, and Close is +// idempotent and safe on a driver that was never started. +// +// These are the clauses internal/scale/conformance checks for every driver; what is +// asserted here is this driver's own way of honouring them. + +// --- the end of the capture ----------------------------------------------------------- + +func TestAnExhaustedCaptureEndsLikeAnUnpluggedScale(t *testing.T) { + // The honest ending: the file ran out, so the weight source is gone. The state machine + // acts on Status alone (défaut 40), and the cause says which of the two happened — + // the file ended, or the cable came out. + s := start(t, Source{Name: "frames.txt", Frames: []byte(nominalCapture)}, nil) + + events := s.drainUntilDone(t) + s.awaitDone(t) + + if len(events) == 0 { + t.Fatal("aucun événement publié") + } + last := events[len(events)-1] + if last.Status != domain.StatusDisconnected { + t.Errorf("dernier statut %s, attendu %s", last.Status, domain.StatusDisconnected) + } + if !errors.Is(last.Err, ErrScriptExhausted) { + t.Errorf("cause %v, attendu ErrScriptExhausted", last.Err) + } + if measurements := countMeasurements(events); measurements != 3 { + t.Errorf("%d mesures, attendu 3", measurements) + } + if events[0].Status != domain.StatusConnected || events[0].Measurement != nil { + t.Errorf("premier événement %+v, attendu un StatusConnected sec : « ouvert ET qui "+ + "répond » se prouve aux premiers octets", events[0]) + } +} + +func TestACancelledReplayCarriesItsCause(t *testing.T) { + s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, + Clock: fake.NewClock(t0)}, nil) + + s.nextMeasurement(t) // the first record is played at once + s.cancel() + s.awaitDone(t) + + last := lastEvent(t, s.out) + if last.Status != domain.StatusDisconnected { + t.Errorf("dernier statut %s, attendu %s", last.Status, domain.StatusDisconnected) + } + if !errors.Is(last.Err, context.Canceled) { + t.Errorf("cause %v, attendu context.Canceled", last.Err) + } +} + +func TestACancelledReplayGivesTheFloorBackWhileNobodyIsReading(t *testing.T) { + // The shutdown that matters: the Hub has stopped reading and the driver is in the + // middle of publishing. Every send of this package is bounded by the context, so the + // replay leaves at once instead of holding the shutdown against a channel nobody + // drains any more (§13.4). The capture declares three instants that are all zero, so + // the driver never waits on the clock and is always inside a send. + instant := "@0 ST,GS,+ 1.236KG\n@0 ST,GS,+ 0.850KG\n@0 US,GS,+ 1.240KG\n" + scale := New(Source{Name: "instantané", Frames: []byte(instant), + Decoder: &frame.Accumulator{}, Clock: fake.NewClock(t0)}, nil) + + out := make(chan domain.ScaleEvent, 1) // one slot, and nobody empties it + done := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := scale.Start(ctx, out, done); err != nil { + t.Fatalf("démarrage : %v", err) + } + + <-out // the driver has published, and is now blocked on the next send + cancel() + + select { + case <-done: + case <-time.After(watchdog): + t.Fatal("le rejeu n'a pas rendu la main sur un canal saturé (§13.4)") + } + if err := scale.Close(); err != nil { + t.Errorf("fermeture : %v", err) + } +} + +// --- what no retry can fix ------------------------------------------------------------- + +func TestStartRefusesWhatItCannotReplayAndStillClosesDone(t *testing.T) { + // The clause whose breach hangs the configuration screen: done is closed on EVERY exit + // path, this one included (§11.4, test de panne 1 ter b). + cases := []struct { + name string + source Source + wants error + }{ + {"capture vide", Source{Decoder: &frame.Accumulator{}, Clock: newRunningClock()}, + ErrEmptyCapture}, + {"capture illisible", Source{Frames: []byte("@ nope\n"), + Decoder: &frame.Accumulator{}, Clock: newRunningClock()}, nil}, + {"aucune horloge", Source{Frames: []byte(nominalCapture), + Decoder: &frame.Accumulator{}}, ErrNoClock}, + // A capture with no decoder is refused for the same reason as one with no clock, + // and it is the reason this package no longer falls back on the grammar of §9.2: + // handed the capture of another protocol, that grammar answers zero measurements + // and NO error, which is the answer of an unplugged scale. + {"aucun décodeur", Source{Frames: []byte(nominalCapture), Clock: newRunningClock()}, + ErrNoDecoder}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + done := make(chan struct{}) + err := New(c.source, nil).Start(context.Background(), + make(chan domain.ScaleEvent, 1), done) + if err == nil { + t.Fatal("démarrage accepté") + } + if c.wants != nil && !errors.Is(err, c.wants) { + t.Errorf("erreur %v, attendu %v", err, c.wants) + } + select { + case <-done: + case <-time.After(watchdog): + t.Fatal("done laissé ouvert par un Start refusé (§11.4)") + } + }) + } +} + +// --- the lifecycle ---------------------------------------------------------------------- + +func TestASecondStartIsRefusedAndStillClosesDone(t *testing.T) { + s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, + Clock: fake.NewClock(t0)}, nil) + + done := make(chan struct{}) + if err := s.scale.Start(context.Background(), s.out, done); !errors.Is(err, ErrAlreadyStarted) { + t.Errorf("erreur %v, attendu ErrAlreadyStarted", err) + } + select { + case <-done: + case <-time.After(watchdog): + t.Fatal("done laissé ouvert par un Start refusé (§11.4)") + } +} + +func TestCloseIsIdempotentAndSafeWithoutStart(t *testing.T) { + unstarted := New(Source{Frames: []byte(nominalCapture)}, nil) + for call := 1; call <= 3; call++ { + if err := unstarted.Close(); err != nil { + t.Errorf("appel %d sur un rejeu jamais démarré : %v", call, err) + } + } + + s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, + Clock: fake.NewClock(t0)}, nil) + for call := 1; call <= 3; call++ { + if err := s.scale.Close(); err != nil { + t.Errorf("appel %d : %v", call, err) + } + } + s.awaitDone(t) +} + +func TestTheLastEventIsNeverLostToACoinToss(t *testing.T) { + // The shutdown of §13.4, reproduced exactly: the Hub loop has RETURNED, nobody reads + // out any more, and the driver still owes a last Disconnected. A send that waited + // unconditionally would deadlock the shutdown against a channel with no reader; a + // plain select between the send and a context that is already cancelled would toss a + // coin over the one event the state machine acts on. So it tries first, and only then + // gives up — and this test is the case where it has to give up. + full := make(chan domain.ScaleEvent, 1) + full <- domain.ScaleEvent{Status: domain.StatusConnected} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + returned := make(chan struct{}) + go func() { + defer close(returned) + sendFinal(ctx, full, domain.ScaleEvent{Status: domain.StatusDisconnected, + Err: ErrScriptExhausted}) + }() + select { + case <-returned: + case <-time.After(watchdog): + t.Fatal("le dernier événement bloque sur un canal que plus personne ne lit : " + + "l'arrêt du poste ne rendrait jamais la main (§13.4)") + } +} + +// lastEvent returns the last event still in the buffer, and fails the test when there is +// none. +func lastEvent(t *testing.T, out chan domain.ScaleEvent) domain.ScaleEvent { + t.Helper() + var last domain.ScaleEvent + found := false + for { + select { + case event, open := <-out: + if !open { + t.Fatal("le canal du Hub a été FERMÉ par le driver (bloquant-2)") + } + last, found = event, true + default: + if !found { + t.Fatal("aucun événement en attente") + } + return last + } + } +} diff --git a/internal/scale/replay/scale_test.go b/internal/scale/replay/scale_test.go index d7a43c9..f455b30 100644 --- a/internal/scale/replay/scale_test.go +++ b/internal/scale/replay/scale_test.go @@ -2,7 +2,6 @@ package replay import ( "context" - "errors" "runtime" "strings" "sync" @@ -15,6 +14,14 @@ import ( "openscale/internal/station/ports" ) +// The bench every test of this package replays through, and what a replay is FOR: the +// instants come from the file and from nowhere else, so a thousand frames are replayed +// in microseconds, --x10 divides every delay, and the descriptor announces the cadence +// the capture itself declares. +// +// The exit contract — done closed on every path, the last event, Close — is in +// exit_test.go. + // t0 is where the injected clock starts. A fixed instant: nothing here reads the real // one, and every timestamp a test asserts is derived from this one by the capture itself. var t0 = time.Date(2026, 7, 25, 9, 30, 0, 0, time.UTC) @@ -309,84 +316,6 @@ func TestTheIdentityIsStableAndNamesADiagnosticTool(t *testing.T) { } } -// --- the end of the capture ----------------------------------------------------------- - -func TestAnExhaustedCaptureEndsLikeAnUnpluggedScale(t *testing.T) { - // The honest ending: the file ran out, so the weight source is gone. The state machine - // acts on Status alone (défaut 40), and the cause says which of the two happened — - // the file ended, or the cable came out. - s := start(t, Source{Name: "frames.txt", Frames: []byte(nominalCapture)}, nil) - - events := s.drainUntilDone(t) - s.awaitDone(t) - - if len(events) == 0 { - t.Fatal("aucun événement publié") - } - last := events[len(events)-1] - if last.Status != domain.StatusDisconnected { - t.Errorf("dernier statut %s, attendu %s", last.Status, domain.StatusDisconnected) - } - if !errors.Is(last.Err, ErrScriptExhausted) { - t.Errorf("cause %v, attendu ErrScriptExhausted", last.Err) - } - if measurements := countMeasurements(events); measurements != 3 { - t.Errorf("%d mesures, attendu 3", measurements) - } - if events[0].Status != domain.StatusConnected || events[0].Measurement != nil { - t.Errorf("premier événement %+v, attendu un StatusConnected sec : « ouvert ET qui "+ - "répond » se prouve aux premiers octets", events[0]) - } -} - -func TestACancelledReplayCarriesItsCause(t *testing.T) { - s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, - Clock: fake.NewClock(t0)}, nil) - - s.nextMeasurement(t) // the first record is played at once - s.cancel() - s.awaitDone(t) - - last := lastEvent(t, s.out) - if last.Status != domain.StatusDisconnected { - t.Errorf("dernier statut %s, attendu %s", last.Status, domain.StatusDisconnected) - } - if !errors.Is(last.Err, context.Canceled) { - t.Errorf("cause %v, attendu context.Canceled", last.Err) - } -} - -func TestACancelledReplayGivesTheFloorBackWhileNobodyIsReading(t *testing.T) { - // The shutdown that matters: the Hub has stopped reading and the driver is in the - // middle of publishing. Every send of this package is bounded by the context, so the - // replay leaves at once instead of holding the shutdown against a channel nobody - // drains any more (§13.4). The capture declares three instants that are all zero, so - // the driver never waits on the clock and is always inside a send. - instant := "@0 ST,GS,+ 1.236KG\n@0 ST,GS,+ 0.850KG\n@0 US,GS,+ 1.240KG\n" - scale := New(Source{Name: "instantané", Frames: []byte(instant), - Decoder: &frame.Accumulator{}, Clock: fake.NewClock(t0)}, nil) - - out := make(chan domain.ScaleEvent, 1) // one slot, and nobody empties it - done := make(chan struct{}) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := scale.Start(ctx, out, done); err != nil { - t.Fatalf("démarrage : %v", err) - } - - <-out // the driver has published, and is now blocked on the next send - cancel() - - select { - case <-done: - case <-time.After(watchdog): - t.Fatal("le rejeu n'a pas rendu la main sur un canal saturé (§13.4)") - } - if err := scale.Close(); err != nil { - t.Errorf("fermeture : %v", err) - } -} - func TestRepeatStartsTheCaptureAgain(t *testing.T) { // What a front-end test driving a real binary with --scale replay needs: a station // whose weight source died after three frames would prove nothing about the fourth @@ -440,49 +369,6 @@ func TestARepeatedCaptureNeverSpinsTheProcessor(t *testing.T) { } } -// --- what no retry can fix ------------------------------------------------------------- - -func TestStartRefusesWhatItCannotReplayAndStillClosesDone(t *testing.T) { - // The clause whose breach hangs the configuration screen: done is closed on EVERY exit - // path, this one included (§11.4, test de panne 1 ter b). - cases := []struct { - name string - source Source - wants error - }{ - {"capture vide", Source{Decoder: &frame.Accumulator{}, Clock: newRunningClock()}, - ErrEmptyCapture}, - {"capture illisible", Source{Frames: []byte("@ nope\n"), - Decoder: &frame.Accumulator{}, Clock: newRunningClock()}, nil}, - {"aucune horloge", Source{Frames: []byte(nominalCapture), - Decoder: &frame.Accumulator{}}, ErrNoClock}, - // A capture with no decoder is refused for the same reason as one with no clock, - // and it is the reason this package no longer falls back on the grammar of §9.2: - // handed the capture of another protocol, that grammar answers zero measurements - // and NO error, which is the answer of an unplugged scale. - {"aucun décodeur", Source{Frames: []byte(nominalCapture), Clock: newRunningClock()}, - ErrNoDecoder}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - done := make(chan struct{}) - err := New(c.source, nil).Start(context.Background(), - make(chan domain.ScaleEvent, 1), done) - if err == nil { - t.Fatal("démarrage accepté") - } - if c.wants != nil && !errors.Is(err, c.wants) { - t.Errorf("erreur %v, attendu %v", err, c.wants) - } - select { - case <-done: - case <-time.After(watchdog): - t.Fatal("done laissé ouvert par un Start refusé (§11.4)") - } - }) - } -} - func TestARefusalIsWrittenToTheJournalInFrench(t *testing.T) { log := &recordingLog{} done := make(chan struct{}) @@ -520,41 +406,6 @@ func TestTheJournalNamesWhatIsBeingReplayed(t *testing.T) { } } -// --- the lifecycle ---------------------------------------------------------------------- - -func TestASecondStartIsRefusedAndStillClosesDone(t *testing.T) { - s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, - Clock: fake.NewClock(t0)}, nil) - - done := make(chan struct{}) - if err := s.scale.Start(context.Background(), s.out, done); !errors.Is(err, ErrAlreadyStarted) { - t.Errorf("erreur %v, attendu ErrAlreadyStarted", err) - } - select { - case <-done: - case <-time.After(watchdog): - t.Fatal("done laissé ouvert par un Start refusé (§11.4)") - } -} - -func TestCloseIsIdempotentAndSafeWithoutStart(t *testing.T) { - unstarted := New(Source{Frames: []byte(nominalCapture)}, nil) - for call := 1; call <= 3; call++ { - if err := unstarted.Close(); err != nil { - t.Errorf("appel %d sur un rejeu jamais démarré : %v", call, err) - } - } - - s := start(t, Source{Frames: []byte(nominalCapture), Cadence: time.Hour, - Clock: fake.NewClock(t0)}, nil) - for call := 1; call <= 3; call++ { - if err := s.scale.Close(); err != nil { - t.Errorf("appel %d : %v", call, err) - } - } - s.awaitDone(t) -} - func TestTheParsedCaptureIsReadableBeforeAnythingIsPlayed(t *testing.T) { // What `openscale replay frames.txt` announces before it starts. script := New(Source{Frames: []byte(nominalCapture)}, nil).Script() @@ -563,33 +414,6 @@ func TestTheParsedCaptureIsReadableBeforeAnythingIsPlayed(t *testing.T) { } } -func TestTheLastEventIsNeverLostToACoinToss(t *testing.T) { - // The shutdown of §13.4, reproduced exactly: the Hub loop has RETURNED, nobody reads - // out any more, and the driver still owes a last Disconnected. A send that waited - // unconditionally would deadlock the shutdown against a channel with no reader; a - // plain select between the send and a context that is already cancelled would toss a - // coin over the one event the state machine acts on. So it tries first, and only then - // gives up — and this test is the case where it has to give up. - full := make(chan domain.ScaleEvent, 1) - full <- domain.ScaleEvent{Status: domain.StatusConnected} - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - returned := make(chan struct{}) - go func() { - defer close(returned) - sendFinal(ctx, full, domain.ScaleEvent{Status: domain.StatusDisconnected, - Err: ErrScriptExhausted}) - }() - select { - case <-returned: - case <-time.After(watchdog): - t.Fatal("le dernier événement bloque sur un canal que plus personne ne lit : " + - "l'arrêt du poste ne rendrait jamais la main (§13.4)") - } -} - // --- helpers ------------------------------------------------------------------------------ func countMeasurements(events []domain.ScaleEvent) int { @@ -601,25 +425,3 @@ func countMeasurements(events []domain.ScaleEvent) int { } return total } - -// lastEvent returns the last event still in the buffer, and fails the test when there is -// none. -func lastEvent(t *testing.T, out chan domain.ScaleEvent) domain.ScaleEvent { - t.Helper() - var last domain.ScaleEvent - found := false - for { - select { - case event, open := <-out: - if !open { - t.Fatal("le canal du Hub a été FERMÉ par le driver (bloquant-2)") - } - last, found = event, true - default: - if !found { - t.Fatal("aucun événement en attente") - } - return last - } - } -} diff --git a/internal/scale/serial/backoff_test.go b/internal/scale/serial/backoff_test.go new file mode 100644 index 0000000..a4d0930 --- /dev/null +++ b/internal/scale/serial/backoff_test.go @@ -0,0 +1,145 @@ +package serial + +import ( + "testing" + "time" + + "openscale/internal/domain" +) + +// Losing the port and getting it back: the backoff grows from the FIRST error and is +// capped, one outage is ONE journal line and not one per attempt, the backoff resets +// only once the port has really answered, and a measurement passes again afterwards. + +// --- reconnection --------------------------------------------------------------- + +func TestTheBackoffGrowsFromTheFirstErrorAndIsCapped(t *testing.T) { + // The correction of §9.1: the legacy application waited for ONE THOUSAND consecutive + // errors, about seven minutes of frozen screen. Here the first retry is 200 ms away + // and the delays double up to the 5 s ceiling. Measured on the INJECTED clock, so + // eleven seconds of declared delay cost microseconds of wall time. + b := newBench() // every open fails: the cable is out + clk := newRecordingClock() + out := make(chan domain.ScaleEvent, 64) + started := time.Now() + done, cancel := startLoop(t, loopOptions(clk, b), out, nil) + + want := []time.Duration{ + 200 * time.Millisecond, 400 * time.Millisecond, 800 * time.Millisecond, + 1600 * time.Millisecond, 3200 * time.Millisecond, 5 * time.Second, 5 * time.Second, + } + for i, expected := range want { + if got := clk.nextDelay(t); got != expected { + t.Fatalf("échec n° %d : attente de %v, attendu %v", i+1, got, expected) + } + } + cancel() + waitClosed(t, done, "done") + + if elapsed := time.Since(started); elapsed > watchdog { + t.Errorf("%v de temps mural pour 11 s de délais : l'horloge n'est pas injectée", elapsed) + } + if b.opens() < len(want) { + t.Errorf("%d ouvertures pour %d échecs attendus", b.opens(), len(want)) + } + // The status is reported IMMEDIATELY and at EVERY attempt: the Hub folds the + // repetitions into one transition (§13.2), and a status sent once can be lost. + disconnected := 0 + for range len(out) { + if (<-out).Status == domain.StatusDisconnected { + disconnected++ + } + } + if disconnected < len(want) { + t.Errorf("%d événements Disconnected pour %d échecs", disconnected, len(want)) + } +} + +func TestOneOutageIsOneJournalLine(t *testing.T) { + // ADR-013: the journal degrades, the service never does. At BackoffMax an unplugged + // cable would otherwise write a line every five seconds for as long as the shop is + // open, and drown the one line that explained the outage. + clk := newRecordingClock() + log := &recordingLog{} + out := make(chan domain.ScaleEvent, 64) + done, cancel := startLoop(t, loopOptions(clk, newBench()), out, log) + + for range 6 { + clk.nextDelay(t) + } + cancel() + waitClosed(t, done, "done") + + if got := log.count(codePortUnavailable); got != 1 { + t.Errorf("%d lignes %s pour une seule panne, attendu 1 (%v)", + got, codePortUnavailable, log.codes()) + } +} + +func TestTheBackoffResetsOnlyOnceThePortHasAnswered(t *testing.T) { + // A failing USB adapter opens and drops at once. Resetting the delay on a successful + // OPEN would hammer it every 200 ms all morning; only bytes prove a link. + silent1 := newScriptedPort(readResult{err: errLinkLost}) + silent2 := newScriptedPort(readResult{err: errLinkLost}) + answering := newScriptedPort( + readResult{data: nominalFrame}, + readResult{err: errLinkLost}, + ) + clk := newRecordingClock() + out := make(chan domain.ScaleEvent, 64) + startLoop(t, loopOptions(clk, newBench(silent1, silent2, answering)), out, nil) + + want := []time.Duration{ + 200 * time.Millisecond, // the first port dropped without a byte + 400 * time.Millisecond, // so did the second: the delay keeps growing + 200 * time.Millisecond, // the third ANSWERED: a fresh outage starts over + } + for i, expected := range want { + if got := clk.nextDelay(t); got != expected { + t.Fatalf("attente n° %d de %v, attendu %v", i+1, got, expected) + } + } +} + +func TestTheScaleComesBackAndAMeasurementPassesAgain(t *testing.T) { + // Failure test 1 bis (§16.2), at driver level. The second half matters as much: half + // a frame from BEFORE the outage must not be completed by bytes from after it, or + // the loop would report a mass nobody ever put on the plate. + before := newScriptedPort( + readResult{data: "ST,GS,+ 1.236K"}, // a frame cut short of its last byte + readResult{err: errLinkLost}, + ) + after := newScriptedPort( + readResult{data: "G\r\n"}, // the missing byte, from the far side of the outage + readResult{data: "ST,GS,+ 0.850KG\r\n"}, + ) + clk := newRecordingClock() + out := make(chan domain.ScaleEvent, 32) + startLoop(t, loopOptions(clk, newBench(before, after)), out, nil, after) + + requireStatus(t, nextEvent(t, out), domain.StatusConnected) + requireStatus(t, nextEvent(t, out), domain.StatusDisconnected) + clk.nextDelay(t) // the backoff between the two ports + requireStatus(t, nextEvent(t, out), domain.StatusConnected) + requireMass(t, nextEvent(t, out), 850) +} + +// --- the backoff, as a function ------------------------------------------------- + +func TestBackoffDelay(t *testing.T) { + options := Options{BackoffMin: 200 * time.Millisecond, BackoffMax: 5 * time.Second} + for _, tc := range []struct { + failures int + want time.Duration + }{ + {0, 200 * time.Millisecond}, + {1, 400 * time.Millisecond}, + {4, 3200 * time.Millisecond}, + {5, 5 * time.Second}, + {64, 5 * time.Second}, // no overflow, whatever the length of the outage + } { + if got := backoffDelay(options, tc.failures); got != tc.want { + t.Errorf("backoffDelay(%d) = %v, attendu %v", tc.failures, got, tc.want) + } + } +} diff --git a/internal/scale/serial/emitter_test.go b/internal/scale/serial/emitter_test.go new file mode 100644 index 0000000..6c3e815 --- /dev/null +++ b/internal/scale/serial/emitter_test.go @@ -0,0 +1,109 @@ +package serial + +import ( + "testing" + + "openscale/internal/domain" +) + +// What the emitter drops, and what it waits for. A slow consumer costs MEASUREMENTS and +// never the read — the port is drained whatever the Hub is doing — a status is never +// dropped before a measurement, and the FINAL event is worth waiting for, though not +// for ever. + +// --- the slow consumer ---------------------------------------------------------- + +func TestASlowConsumerCostsMeasurementsAndNeverTheRead(t *testing.T) { + // out with a capacity of one and nobody reading it: the port keeps being read, and + // of the readings that did not fit the LAST one wins. A stale weight is refused by + // the expiry anyway (§6.5), so keeping the freshest is the only useful policy. + port := newScriptedPort( + readResult{data: "ST,GS,+ 1.236KG\r\n"}, + readResult{data: "ST,GS,+ 0.850KG\r\n"}, + readResult{data: "ST,GS,- 0.282KG\r\n"}, + ) + out := make(chan domain.ScaleEvent, 1) + startLoop(t, loopOptions(newRecordingClock(), newBench(port)), out, nil, port) + + // Four reads STARTED means the three frames were read while out was full. + port.waitReads(t, 4) + requireStatus(t, nextEvent(t, out), domain.StatusConnected) + + port.idle() // the fourth read comes back, and the loop retries what it held + requireMass(t, nextEvent(t, out), -282) +} + +func TestTheEmitterDropsAMeasurementBeforeAStatus(t *testing.T) { + // Défaut 40 seen from the sending side: a dropped measurement costs one cadence, a + // dropped status costs a state machine that never learns the scale is gone. + out := make(chan domain.ScaleEvent, 1) + hub := &emitter{out: out} + + first := domain.Measurement{Gross: 1236} + hub.push(domain.ScaleEvent{Measurement: &first}) // fits + hub.push(disconnected(errLinkLost)) // does not: held back + second := domain.Measurement{Gross: 850} + hub.push(domain.ScaleEvent{Measurement: &second}) // gives way to the held status + + if hub.pending == nil || hub.pending.Measurement != nil { + t.Fatalf("événement retenu %v, attendu le changement de statut", hub.pending) + } + if hub.dropped != 1 { + t.Errorf("%d mesure(s) abandonnée(s), attendu 1", hub.dropped) + } + requireMass(t, <-out, 1236) + hub.flush() + requireStatus(t, <-out, domain.StatusDisconnected) + if hub.pending != nil { + t.Error("l'événement retenu n'a pas été relâché") + } +} + +func TestTheFinalEventIsWorthWaitingForButNotForever(t *testing.T) { + held := domain.Measurement{Gross: 1236} + fullChannel := func() chan domain.ScaleEvent { + out := make(chan domain.ScaleEvent, 1) + out <- domain.ScaleEvent{Measurement: &held} + return out + } + + t.Run("delivered as soon as the Hub catches up", func(t *testing.T) { + out := fullChannel() + hub := &emitter{out: out, clock: newRecordingClock(), budget: defaultBackoffMin} + delivered := make(chan struct{}) + go func() { + defer close(delivered) + hub.pushFinal(disconnected(errLinkLost)) + }() + + requireMass(t, nextEvent(t, out), 1236) + requireStatus(t, nextEvent(t, out), domain.StatusDisconnected) + waitClosed(t, delivered, "pushFinal") + }) + + t.Run("given up on a channel nobody reads any more", func(t *testing.T) { + // On shutdown the Hub loop has RETURNED before Close is called (§13.4): waiting + // without a bound would deadlock the stop against a channel nobody reads. + out := fullChannel() + clk := newRecordingClock() + hub := &emitter{out: out, clock: clk, budget: defaultBackoffMin} + gaveUp := make(chan struct{}) + go func() { + defer close(gaveUp) + hub.pushFinal(disconnected(errLinkLost)) + }() + + if got := clk.nextDelay(t); got != defaultBackoffMin { + t.Errorf("budget de %v, attendu %v", got, defaultBackoffMin) + } + waitClosed(t, gaveUp, "pushFinal") + if hub.dropped != 1 { + t.Errorf("%d événement(s) abandonné(s), attendu 1", hub.dropped) + } + }) + + t.Run("without a clock there is nothing to wait on", func(t *testing.T) { + hub := &emitter{out: fullChannel()} + hub.pushFinal(disconnected(errLinkLost)) // must return rather than hang + }) +} diff --git a/internal/scale/serial/exit_test.go b/internal/scale/serial/exit_test.go new file mode 100644 index 0000000..82a0e1e --- /dev/null +++ b/internal/scale/serial/exit_test.go @@ -0,0 +1,133 @@ +package serial + +import ( + "context" + "errors" + "testing" + + "openscale/internal/domain" +) + +// The exit contract, in the one order it may happen: ONE StatusDisconnected, THEN done +// closed, and out left OPEN for the driver that will replace this one. Plus the two +// reasons an exit carries — the cancellation when the device gave none, and the device's +// own when it gave one — and a handle that refuses to close, which is journalled and +// never swallowed. + +// --- the exit contract ---------------------------------------------------------- + +func TestTheExitContractIsOneDisconnectedThenDoneAndOutLeftOpen(t *testing.T) { + port := newScriptedPort(readResult{data: nominalFrame}) + out := make(chan domain.ScaleEvent, 8) + done, cancel := startLoop(t, loopOptions(newRecordingClock(), newBench(port)), out, nil, port) + + requireStatus(t, nextEvent(t, out), domain.StatusConnected) + requireMass(t, nextEvent(t, out), 1236) + + cancel() + port.idle() // the read in flight comes back empty-handed and the loop sees ctx + waitClosed(t, done, "done") + + last := drainLast(t, out) + if last.Status != domain.StatusDisconnected { + t.Errorf("dernier événement %v, attendu %v", last.Status, domain.StatusDisconnected) + } + if last.Err == nil { + t.Error("Err nil sur le dernier événement : la cause d'une perte de balance doit " + + "toujours rester journalisable (§9.1)") + } + if !errors.Is(last.Err, context.Canceled) { + t.Errorf("Err = %v, attendu l'annulation du contexte", last.Err) + } + requireOutStillOpen(t, out) + + if closes := port.closeCount(); closes != 1 { + t.Errorf("port refermé %d fois, attendu 1 — le port série de Windows est exclusif", closes) + } +} + +func TestDoneIsClosedWhenTheOptionsAreUnusable(t *testing.T) { + // The MANDATORY COROLLARY of §5.3, at loop level: done is closed on EVERY exit path, + // including the one that never opens a port at all, or the bounded wait of + // restartScale would be waiting on a channel nobody will ever close. + b := newBench(newScriptedPort()) + log := &recordingLog{} + out := make(chan domain.ScaleEvent, 4) + done := make(chan struct{}) + + options := loopOptions(newRecordingClock(), b) + options.Decoder = nil + + Loop(context.Background(), options, out, done, log) // returns at once, no goroutine + + waitClosed(t, done, "done") + last := drainLast(t, out) + if last.Status != domain.StatusDisconnected || last.Err == nil { + t.Errorf("dernier événement %v / %v, attendu Disconnected avec une cause", + last.Status, last.Err) + } + if b.opens() != 0 { + t.Errorf("%d ouverture(s) de port : des options inutilisables ne se réessaient pas", + b.opens()) + } + if log.count(codeUnusableOptions) != 1 { + t.Errorf("codes journalisés %v, attendu un %s", log.codes(), codeUnusableOptions) + } + requireOutStillOpen(t, out) +} + +func TestTheCancellationIsTheReasonWhenTheDeviceGaveNone(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + out := make(chan domain.ScaleEvent, 4) + done := make(chan struct{}) + + Loop(ctx, loopOptions(newRecordingClock(), newBench()), out, done, nil) + + waitClosed(t, done, "done") + last := drainLast(t, out) + if last.Err == nil { + t.Fatal("Err nil : ce champ n'est jamais nil sur le dernier événement") + } + if !errors.Is(last.Err, context.Canceled) { + t.Errorf("Err = %v, attendu l'annulation", last.Err) + } +} + +func TestTheDeviceReasonSurvivesTheCancellation(t *testing.T) { + // « Pourquoi ce poste est-il en saisie manuelle ce matin ? » — "context canceled" + // answers nothing, the device error answers everything. + clk := newRecordingClock() + out := make(chan domain.ScaleEvent, 32) + done, cancel := startLoop(t, loopOptions(clk, newBench()), out, nil) + + clk.nextDelay(t) // one failed open + cancel() + waitClosed(t, done, "done") + + last := drainLast(t, out) + if last.Err == nil || errors.Is(last.Err, context.Canceled) { + t.Errorf("Err = %v, attendu la raison du périphérique", last.Err) + } +} + +func TestAHandleThatRefusesToCloseIsJournalised(t *testing.T) { + // It is journalised and nothing more: there is nothing a driver could do about a + // handle the operating system will not take back, and §11.4 already treats an + // unconfirmed close as an amber light rather than a failed configuration write. + port := newScriptedPort(readResult{err: errLinkLost}) + port.refuseToClose(errors.New("handle invalide")) + clk := newRecordingClock() + log := &recordingLog{} + out := make(chan domain.ScaleEvent, 16) + done, cancel := startLoop(t, loopOptions(clk, newBench(port)), out, log) + + clk.nextDelay(t) // the session ended, the port was released, badly + cancel() + waitClosed(t, done, "done") + + if got := log.count(codeCloseRefused); got != 1 { + t.Errorf("%d lignes %s, attendu 1 : une fermeture refusée annonce la réouverture "+ + "qui échouera en « accès refusé » (%v)", got, codeCloseRefused, log.codes()) + } +} diff --git a/internal/scale/serial/loop_test.go b/internal/scale/serial/loop_test.go index 019dc3d..e160b0a 100644 --- a/internal/scale/serial/loop_test.go +++ b/internal/scale/serial/loop_test.go @@ -2,7 +2,6 @@ package serial import ( "context" - "errors" "os" "path/filepath" "testing" @@ -13,6 +12,14 @@ import ( "openscale/internal/station/ports" ) +// The bench of the reader loop, and what it is there to prove about READING: a frame +// traverses the loop, the buffer is four kibibytes and NOT the sixteen of the legacy +// SetupComm, a frame cut between two reads is glued back together, and the whole living +// corpus goes through sliced at eighteen bytes. +// +// The exit contract is in exit_test.go, the reconnection in backoff_test.go, and what +// the emitter drops in emitter_test.go. + // watchdog is how long a test waits for something that should already have happened. // // It is never reached when the code is right: every delay of this package is measured @@ -261,351 +268,3 @@ func TestTheLivingCorpusTraversesTheLoopSlicedAtEighteenBytes(t *testing.T) { t.Errorf("%d trames en surcharge, attendu 1", overloads) } } - -// --- the exit contract ---------------------------------------------------------- - -func TestTheExitContractIsOneDisconnectedThenDoneAndOutLeftOpen(t *testing.T) { - port := newScriptedPort(readResult{data: nominalFrame}) - out := make(chan domain.ScaleEvent, 8) - done, cancel := startLoop(t, loopOptions(newRecordingClock(), newBench(port)), out, nil, port) - - requireStatus(t, nextEvent(t, out), domain.StatusConnected) - requireMass(t, nextEvent(t, out), 1236) - - cancel() - port.idle() // the read in flight comes back empty-handed and the loop sees ctx - waitClosed(t, done, "done") - - last := drainLast(t, out) - if last.Status != domain.StatusDisconnected { - t.Errorf("dernier événement %v, attendu %v", last.Status, domain.StatusDisconnected) - } - if last.Err == nil { - t.Error("Err nil sur le dernier événement : la cause d'une perte de balance doit " + - "toujours rester journalisable (§9.1)") - } - if !errors.Is(last.Err, context.Canceled) { - t.Errorf("Err = %v, attendu l'annulation du contexte", last.Err) - } - requireOutStillOpen(t, out) - - if closes := port.closeCount(); closes != 1 { - t.Errorf("port refermé %d fois, attendu 1 — le port série de Windows est exclusif", closes) - } -} - -func TestDoneIsClosedWhenTheOptionsAreUnusable(t *testing.T) { - // The MANDATORY COROLLARY of §5.3, at loop level: done is closed on EVERY exit path, - // including the one that never opens a port at all, or the bounded wait of - // restartScale would be waiting on a channel nobody will ever close. - b := newBench(newScriptedPort()) - log := &recordingLog{} - out := make(chan domain.ScaleEvent, 4) - done := make(chan struct{}) - - options := loopOptions(newRecordingClock(), b) - options.Decoder = nil - - Loop(context.Background(), options, out, done, log) // returns at once, no goroutine - - waitClosed(t, done, "done") - last := drainLast(t, out) - if last.Status != domain.StatusDisconnected || last.Err == nil { - t.Errorf("dernier événement %v / %v, attendu Disconnected avec une cause", - last.Status, last.Err) - } - if b.opens() != 0 { - t.Errorf("%d ouverture(s) de port : des options inutilisables ne se réessaient pas", - b.opens()) - } - if log.count(codeUnusableOptions) != 1 { - t.Errorf("codes journalisés %v, attendu un %s", log.codes(), codeUnusableOptions) - } - requireOutStillOpen(t, out) -} - -func TestTheCancellationIsTheReasonWhenTheDeviceGaveNone(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - out := make(chan domain.ScaleEvent, 4) - done := make(chan struct{}) - - Loop(ctx, loopOptions(newRecordingClock(), newBench()), out, done, nil) - - waitClosed(t, done, "done") - last := drainLast(t, out) - if last.Err == nil { - t.Fatal("Err nil : ce champ n'est jamais nil sur le dernier événement") - } - if !errors.Is(last.Err, context.Canceled) { - t.Errorf("Err = %v, attendu l'annulation", last.Err) - } -} - -func TestTheDeviceReasonSurvivesTheCancellation(t *testing.T) { - // « Pourquoi ce poste est-il en saisie manuelle ce matin ? » — "context canceled" - // answers nothing, the device error answers everything. - clk := newRecordingClock() - out := make(chan domain.ScaleEvent, 32) - done, cancel := startLoop(t, loopOptions(clk, newBench()), out, nil) - - clk.nextDelay(t) // one failed open - cancel() - waitClosed(t, done, "done") - - last := drainLast(t, out) - if last.Err == nil || errors.Is(last.Err, context.Canceled) { - t.Errorf("Err = %v, attendu la raison du périphérique", last.Err) - } -} - -// --- reconnection --------------------------------------------------------------- - -func TestTheBackoffGrowsFromTheFirstErrorAndIsCapped(t *testing.T) { - // The correction of §9.1: the legacy application waited for ONE THOUSAND consecutive - // errors, about seven minutes of frozen screen. Here the first retry is 200 ms away - // and the delays double up to the 5 s ceiling. Measured on the INJECTED clock, so - // eleven seconds of declared delay cost microseconds of wall time. - b := newBench() // every open fails: the cable is out - clk := newRecordingClock() - out := make(chan domain.ScaleEvent, 64) - started := time.Now() - done, cancel := startLoop(t, loopOptions(clk, b), out, nil) - - want := []time.Duration{ - 200 * time.Millisecond, 400 * time.Millisecond, 800 * time.Millisecond, - 1600 * time.Millisecond, 3200 * time.Millisecond, 5 * time.Second, 5 * time.Second, - } - for i, expected := range want { - if got := clk.nextDelay(t); got != expected { - t.Fatalf("échec n° %d : attente de %v, attendu %v", i+1, got, expected) - } - } - cancel() - waitClosed(t, done, "done") - - if elapsed := time.Since(started); elapsed > watchdog { - t.Errorf("%v de temps mural pour 11 s de délais : l'horloge n'est pas injectée", elapsed) - } - if b.opens() < len(want) { - t.Errorf("%d ouvertures pour %d échecs attendus", b.opens(), len(want)) - } - // The status is reported IMMEDIATELY and at EVERY attempt: the Hub folds the - // repetitions into one transition (§13.2), and a status sent once can be lost. - disconnected := 0 - for range len(out) { - if (<-out).Status == domain.StatusDisconnected { - disconnected++ - } - } - if disconnected < len(want) { - t.Errorf("%d événements Disconnected pour %d échecs", disconnected, len(want)) - } -} - -func TestOneOutageIsOneJournalLine(t *testing.T) { - // ADR-013: the journal degrades, the service never does. At BackoffMax an unplugged - // cable would otherwise write a line every five seconds for as long as the shop is - // open, and drown the one line that explained the outage. - clk := newRecordingClock() - log := &recordingLog{} - out := make(chan domain.ScaleEvent, 64) - done, cancel := startLoop(t, loopOptions(clk, newBench()), out, log) - - for range 6 { - clk.nextDelay(t) - } - cancel() - waitClosed(t, done, "done") - - if got := log.count(codePortUnavailable); got != 1 { - t.Errorf("%d lignes %s pour une seule panne, attendu 1 (%v)", - got, codePortUnavailable, log.codes()) - } -} - -func TestTheBackoffResetsOnlyOnceThePortHasAnswered(t *testing.T) { - // A failing USB adapter opens and drops at once. Resetting the delay on a successful - // OPEN would hammer it every 200 ms all morning; only bytes prove a link. - silent1 := newScriptedPort(readResult{err: errLinkLost}) - silent2 := newScriptedPort(readResult{err: errLinkLost}) - answering := newScriptedPort( - readResult{data: nominalFrame}, - readResult{err: errLinkLost}, - ) - clk := newRecordingClock() - out := make(chan domain.ScaleEvent, 64) - startLoop(t, loopOptions(clk, newBench(silent1, silent2, answering)), out, nil) - - want := []time.Duration{ - 200 * time.Millisecond, // the first port dropped without a byte - 400 * time.Millisecond, // so did the second: the delay keeps growing - 200 * time.Millisecond, // the third ANSWERED: a fresh outage starts over - } - for i, expected := range want { - if got := clk.nextDelay(t); got != expected { - t.Fatalf("attente n° %d de %v, attendu %v", i+1, got, expected) - } - } -} - -func TestTheScaleComesBackAndAMeasurementPassesAgain(t *testing.T) { - // Failure test 1 bis (§16.2), at driver level. The second half matters as much: half - // a frame from BEFORE the outage must not be completed by bytes from after it, or - // the loop would report a mass nobody ever put on the plate. - before := newScriptedPort( - readResult{data: "ST,GS,+ 1.236K"}, // a frame cut short of its last byte - readResult{err: errLinkLost}, - ) - after := newScriptedPort( - readResult{data: "G\r\n"}, // the missing byte, from the far side of the outage - readResult{data: "ST,GS,+ 0.850KG\r\n"}, - ) - clk := newRecordingClock() - out := make(chan domain.ScaleEvent, 32) - startLoop(t, loopOptions(clk, newBench(before, after)), out, nil, after) - - requireStatus(t, nextEvent(t, out), domain.StatusConnected) - requireStatus(t, nextEvent(t, out), domain.StatusDisconnected) - clk.nextDelay(t) // the backoff between the two ports - requireStatus(t, nextEvent(t, out), domain.StatusConnected) - requireMass(t, nextEvent(t, out), 850) -} - -// --- the slow consumer ---------------------------------------------------------- - -func TestASlowConsumerCostsMeasurementsAndNeverTheRead(t *testing.T) { - // out with a capacity of one and nobody reading it: the port keeps being read, and - // of the readings that did not fit the LAST one wins. A stale weight is refused by - // the expiry anyway (§6.5), so keeping the freshest is the only useful policy. - port := newScriptedPort( - readResult{data: "ST,GS,+ 1.236KG\r\n"}, - readResult{data: "ST,GS,+ 0.850KG\r\n"}, - readResult{data: "ST,GS,- 0.282KG\r\n"}, - ) - out := make(chan domain.ScaleEvent, 1) - startLoop(t, loopOptions(newRecordingClock(), newBench(port)), out, nil, port) - - // Four reads STARTED means the three frames were read while out was full. - port.waitReads(t, 4) - requireStatus(t, nextEvent(t, out), domain.StatusConnected) - - port.idle() // the fourth read comes back, and the loop retries what it held - requireMass(t, nextEvent(t, out), -282) -} - -func TestAHandleThatRefusesToCloseIsJournalised(t *testing.T) { - // It is journalised and nothing more: there is nothing a driver could do about a - // handle the operating system will not take back, and §11.4 already treats an - // unconfirmed close as an amber light rather than a failed configuration write. - port := newScriptedPort(readResult{err: errLinkLost}) - port.refuseToClose(errors.New("handle invalide")) - clk := newRecordingClock() - log := &recordingLog{} - out := make(chan domain.ScaleEvent, 16) - done, cancel := startLoop(t, loopOptions(clk, newBench(port)), out, log) - - clk.nextDelay(t) // the session ended, the port was released, badly - cancel() - waitClosed(t, done, "done") - - if got := log.count(codeCloseRefused); got != 1 { - t.Errorf("%d lignes %s, attendu 1 : une fermeture refusée annonce la réouverture "+ - "qui échouera en « accès refusé » (%v)", got, codeCloseRefused, log.codes()) - } -} - -func TestTheEmitterDropsAMeasurementBeforeAStatus(t *testing.T) { - // Défaut 40 seen from the sending side: a dropped measurement costs one cadence, a - // dropped status costs a state machine that never learns the scale is gone. - out := make(chan domain.ScaleEvent, 1) - hub := &emitter{out: out} - - first := domain.Measurement{Gross: 1236} - hub.push(domain.ScaleEvent{Measurement: &first}) // fits - hub.push(disconnected(errLinkLost)) // does not: held back - second := domain.Measurement{Gross: 850} - hub.push(domain.ScaleEvent{Measurement: &second}) // gives way to the held status - - if hub.pending == nil || hub.pending.Measurement != nil { - t.Fatalf("événement retenu %v, attendu le changement de statut", hub.pending) - } - if hub.dropped != 1 { - t.Errorf("%d mesure(s) abandonnée(s), attendu 1", hub.dropped) - } - requireMass(t, <-out, 1236) - hub.flush() - requireStatus(t, <-out, domain.StatusDisconnected) - if hub.pending != nil { - t.Error("l'événement retenu n'a pas été relâché") - } -} - -func TestTheFinalEventIsWorthWaitingForButNotForever(t *testing.T) { - held := domain.Measurement{Gross: 1236} - fullChannel := func() chan domain.ScaleEvent { - out := make(chan domain.ScaleEvent, 1) - out <- domain.ScaleEvent{Measurement: &held} - return out - } - - t.Run("delivered as soon as the Hub catches up", func(t *testing.T) { - out := fullChannel() - hub := &emitter{out: out, clock: newRecordingClock(), budget: defaultBackoffMin} - delivered := make(chan struct{}) - go func() { - defer close(delivered) - hub.pushFinal(disconnected(errLinkLost)) - }() - - requireMass(t, nextEvent(t, out), 1236) - requireStatus(t, nextEvent(t, out), domain.StatusDisconnected) - waitClosed(t, delivered, "pushFinal") - }) - - t.Run("given up on a channel nobody reads any more", func(t *testing.T) { - // On shutdown the Hub loop has RETURNED before Close is called (§13.4): waiting - // without a bound would deadlock the stop against a channel nobody reads. - out := fullChannel() - clk := newRecordingClock() - hub := &emitter{out: out, clock: clk, budget: defaultBackoffMin} - gaveUp := make(chan struct{}) - go func() { - defer close(gaveUp) - hub.pushFinal(disconnected(errLinkLost)) - }() - - if got := clk.nextDelay(t); got != defaultBackoffMin { - t.Errorf("budget de %v, attendu %v", got, defaultBackoffMin) - } - waitClosed(t, gaveUp, "pushFinal") - if hub.dropped != 1 { - t.Errorf("%d événement(s) abandonné(s), attendu 1", hub.dropped) - } - }) - - t.Run("without a clock there is nothing to wait on", func(t *testing.T) { - hub := &emitter{out: fullChannel()} - hub.pushFinal(disconnected(errLinkLost)) // must return rather than hang - }) -} - -// --- the backoff, as a function ------------------------------------------------- - -func TestBackoffDelay(t *testing.T) { - options := Options{BackoffMin: 200 * time.Millisecond, BackoffMax: 5 * time.Second} - for _, tc := range []struct { - failures int - want time.Duration - }{ - {0, 200 * time.Millisecond}, - {1, 400 * time.Millisecond}, - {4, 3200 * time.Millisecond}, - {5, 5 * time.Second}, - {64, 5 * time.Second}, // no overflow, whatever the length of the outage - } { - if got := backoffDelay(options, tc.failures); got != tc.want { - t.Errorf("backoffDelay(%d) = %v, attendu %v", tc.failures, got, tc.want) - } - } -} diff --git a/internal/station/catalogwatch.go b/internal/station/catalogwatch.go new file mode 100644 index 0000000..2ddbcec --- /dev/null +++ b/internal/station/catalogwatch.go @@ -0,0 +1,154 @@ +package station + +import ( + "context" + "errors" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is goroutine n° 5 of §13.1: the watch that reads whole catalogs from the +// source in service, offers them to the loop and acknowledges the file. The swap +// itself is the Hub's business and is DEFERRED (§10.8). + +// currentCatalogSource reports the source in service. +// +// It exists because a reload replaces that source while watchCatalog is reading from +// it: -race caught the write of restartCatalog against the read of the watch loop. +func (s *Station) currentCatalogSource() ports.CatalogSource { + s.catalogMu.Lock() + defer s.catalogMu.Unlock() + return s.catalogSource +} + +// swapCatalogSource puts next in service and returns the one it replaced, so the +// caller closes the old source OUTSIDE the lock — Close talks to a file system or to +// a WebDAV server and has no business being held against the watch loop. +// +// It also ENDS the read in flight. Without that, the swap changes what a getter +// answers and nothing else: the watch stays parked in the source it just replaced, +// for as long as the process lives, and a station pointed at a share goes on watching +// an empty drop folder until somebody restarts the service. +func (s *Station) swapCatalogSource(next ports.CatalogSource) ports.CatalogSource { + s.catalogMu.Lock() + defer s.catalogMu.Unlock() + previous := s.catalogSource + s.catalogSource = next + if s.cancelCatalogRead != nil { + s.cancelCatalogRead() + s.cancelCatalogRead = nil + } + return previous +} + +// beginCatalogRead hands back the source in service and the context to read it with. +// +// The context ends with the parent or with the next swap, whichever comes first, and +// the returned func ends it once the read is over. It is handed out EVEN WHEN THERE IS +// NO SOURCE: a station whose share was unreachable at boot starts without one, and +// waiting on that context is how it notices the one a reload puts in service. +func (s *Station) beginCatalogRead(parent context.Context) (ports.CatalogSource, context.Context, context.CancelFunc) { + s.catalogMu.Lock() + defer s.catalogMu.Unlock() + ctx, cancel := context.WithCancel(parent) + s.cancelCatalogRead = cancel + return s.catalogSource, ctx, cancel +} + +// watchCatalog reads whole catalogs from the source and hands them to the loop. +// +// The swap itself is the Hub's business and it is DEFERRED: this goroutine never +// changes what is on screen, it only offers. +func (s *Station) watchCatalog(ctx context.Context) { + defer close(s.catalogDone) + for { + source, readCtx, endRead := s.beginCatalogRead(ctx) + if source == nil { + // Wait for one to arrive rather than for the end of the process: the + // station was started with an unbuildable source, and the volunteer is + // about to repair it on the screen. + <-readCtx.Done() + endRead() + if ctx.Err() != nil { + return + } + continue + } + batch, err := source.Next(readCtx) + // READ BEFORE ENDING IT: endRead cancels this very context, so asking + // afterwards answers « replaced » for every batch the source ever yields. + replaced := readCtx.Err() != nil + endRead() + if ctx.Err() != nil { + return + } + // A read ended by the swap and not by the source: the station has a new source + // and this loop reads it now. It is not a failure and it says nothing in the + // journal — the reload already wrote what changed. + if replaced { + continue + } + if err != nil { + s.hub.logTechnical(domain.LevelWarn, "catalog", "ERR-CAT-03", + "Lecture du catalogue impossible.", err.Error()) + continue + } + if batch == nil { + continue + } + s.offer(ctx, source, batch) + } +} + +// offer qualifies one batch, hands it to the loop and acknowledges the file. +// +// Acknowledgement is EXPLICIT and comes LAST: deleting at read time would let a +// crash between reading and applying lose an update for good, and without a trace. +func (s *Station) offer(ctx context.Context, source ports.CatalogSource, batch *ports.Batch) { + cfg := *s.hub.cfg.Load() + catalog, result, err := s.applyCatalog(ctx, cfg, batch) + if err != nil { + s.hub.logTechnical(domain.LevelError, "catalog", "ERR-CAT-03", + "Catalogue refusé.", err.Error()) + } else if catalog != nil { + s.logIfCatalogErr(s.hub.PushCatalog(ctx, &CatalogBatch{ + Catalog: catalog, Source: batch.Source, + FileName: batch.FileName, ImportedAt: importedAt(result, s.clock), + })) + } + if err := source.Acknowledge(ctx, batch, result); err != nil { + s.hub.logTechnical(domain.LevelWarn, "catalog", "ERR-CAT-05", + "Fichier de catalogue non supprimé.", err.Error()) + } +} + +// importedAt is the instant the applier recorded, or the clock when it recorded none. +// +// The fallback is for the DEFAULT applier — plainCatalog, which writes no history row +// because it has no store to write it to — and for any plug-in one somebody adds later. +// A station whose applier keeps no history still has to answer « ces prix datent de +// quand ? », and the moment its catalog was offered is the truest thing left to say. +func importedAt(result ports.BatchResult, clock ports.Clock) time.Time { + if result.AppliedAt.IsZero() { + return clock.Now() + } + return result.AppliedAt +} + +// logIfCatalogErr reports a catalog that never reached the loop. +func (s *Station) logIfCatalogErr(err error) { + if err == nil || errors.Is(err, ErrStopped) || errors.Is(err, context.Canceled) { + return + } + s.hub.logTechnical(domain.LevelWarn, "catalog", "", + "Catalogue non remis au Hub.", err.Error()) +} + +// plainCatalog is the default applier: it freezes the rows the source produced +// with the categories this station is configured for, and acknowledges 'applied'. +func plainCatalog(_ context.Context, cfg domain.Config, b *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { + return domain.NewCatalog(b.Products, cfg.Catalog.Categories), + ports.BatchResult{Result: domain.ImportApplied}, nil +} diff --git a/internal/station/catalogwatch_test.go b/internal/station/catalogwatch_test.go new file mode 100644 index 0000000..8c455c7 --- /dev/null +++ b/internal/station/catalogwatch_test.go @@ -0,0 +1,189 @@ +package station + +import ( + "context" + "errors" + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// The catalog watch across a reload: a source that is replaced while the watch is +// parked inside it, one that could not be built, and one that arrives on a station +// that started without any. The watch must never stay reading the source it no longer +// has. + +// TestTheCatalogBlockFollowsTheStationNumber is the fourth line of the table: +// station.number is reloaded WITH the catalog, because the name of the watched +// file — flv_.csv — is its only real consumer. +func TestTheCatalogBlockFollowsTheStationNumber(t *testing.T) { + first := newDropSource(nil) + second := newDropSource(nil) + b := newBench(t) + b.station.swapCatalogSource(first) + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } + + next := b.hub.Config() + next.Station.Number = 3 + + outcome, err := b.station.Reload(ReloadRequest{Next: next}) + if err != nil { + t.Fatalf("Reload : %v", err) + } + if len(outcome.Changed) != 1 || outcome.Changed[0] != blockCatalog { + t.Fatalf("blocs redémarrés %v, attendu [%s]", outcome.Changed, blockCatalog) + } + if outcome.ConfirmBefore.IsZero() != true { + t.Fatal("un changement de catalogue arme un compte à rebours : il ne coupe rien") + } + if b.station.currentCatalogSource() != second { + t.Fatal("la veille n'a pas été relancée sur la nouvelle source") + } +} + +// parkingSource blocks in Next until its context is cancelled, and announces every +// entry. +// +// Announcing the ENTRY is the whole point: a test that only knows a source yielded +// once cannot tell whether the watch has gone back inside it, and the property below +// is about a watch that is provably parked in the source a reload replaces. +type parkingSource struct{ entries chan struct{} } + +func newParkingSource() *parkingSource { + return &parkingSource{entries: make(chan struct{}, 4)} +} + +func (s *parkingSource) Name() string { return domain.CatalogSourceLocalDrop } + +func (s *parkingSource) Next(ctx context.Context) (*ports.Batch, error) { + s.entries <- struct{}{} + <-ctx.Done() + return nil, ctx.Err() +} + +func (s *parkingSource) Acknowledge(context.Context, *ports.Batch, ports.BatchResult) error { + return nil +} + +func (s *parkingSource) Close() error { return nil } + +var _ ports.CatalogSource = (*parkingSource)(nil) + +// awaitEntry waits for the watch to be inside the source. +func awaitEntry(t *testing.T, source *parkingSource, message string) { + t.Helper() + select { + case <-source.entries: + case <-time.After(hang): + t.Fatal(message) + } +} + +// TestTheWatchLeavesTheSourceAReloadReplaced is what the pointer swap of +// TestTheCatalogBlockFollowsTheStationNumber does NOT prove. +// +// The watch reads the source into a local variable and then blocks inside its Next, +// which returns on a batch, an error or a cancellation and on nothing else. Swapping +// the pointer under a goroutine parked in the old source changes what a getter +// answers and leaves the watch exactly where it was: the station went on watching an +// empty drop folder after being pointed at a share, and only a restart of the service +// ever moved it. « Recharger le catalogue » made it worse rather than better — it +// wakes the source in service, which is the one nobody is reading. +func TestTheWatchLeavesTheSourceAReloadReplaced(t *testing.T) { + first, second := newParkingSource(), newParkingSource() + b := newBench(t, func(o *benchOptions) { o.source = first }) + awaitEntry(t, first, "la veille n'est jamais entrée dans la source de départ") + + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } + next := b.hub.Config() + next.Station.Number = 3 + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + + awaitEntry(t, second, "la veille est restée dans la source remplacée : "+ + "la nouvelle n'a jamais été lue, et un poste dans cet état n'importe plus rien") +} + +// TestReplacingTheSourceIsNotAReadFailure keeps ERR-CAT-03 worth reading. +// +// The cancellation that ends the read is the station's own doing, and a journal that +// reported it as « Lecture du catalogue impossible » would put a red line under every +// ordinary change of source — which is how a code stops being read. +func TestReplacingTheSourceIsNotAReadFailure(t *testing.T) { + first, second := newParkingSource(), newParkingSource() + b := newBench(t, func(o *benchOptions) { o.source = first }) + awaitEntry(t, first, "la veille n'est jamais entrée dans la source de départ") + + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } + next := b.hub.Config() + next.Station.Number = 3 + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + awaitEntry(t, second, "la veille est restée dans la source remplacée") + + // A BARRIER, and it is what makes the assertion below mean something: a technical + // line is enqueued on a channel the journal drains on its own goroutine, so asking + // « is ERR-CAT-03 there ? » right away asks before the writer had to answer. This + // second reload cannot rebuild anything and says so — after the watch enqueued + // whatever it was going to enqueue, one FIFO, one consumer. ERR-CAT-05 in sight + // therefore means ERR-CAT-03 would be in sight too. + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { + return nil, errors.New("partage inaccessible") + } + next.Station.Number = 4 + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + awaitCondition(t, func() bool { return b.technical.has("ERR-CAT-05") }, + "la barrière n'est jamais arrivée dans le journal") + + if b.technical.has("ERR-CAT-03") { + t.Fatal("le remplacement d'une source a été journalisé comme une lecture impossible") + } +} + +// TestTheWatchPicksUpASourceItStartedWithout is the other half of the same +// property, and the one an installation meets first. +// +// A source that cannot be built is an amber light and never a station that refuses to +// start (serve.go), so a station whose share was unreachable at boot runs with no +// source at all. The watch used to wait on the process context in that case — which +// is to say for good: the volunteer repairs the address on the screen, the station +// answers « configuration enregistrée », and nothing is ever watched again. +func TestTheWatchPicksUpASourceItStartedWithout(t *testing.T) { + arriving := newParkingSource() + b := newBench(t) // no source: this is a station whose share was unreachable at boot + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return arriving, nil } + + next := b.hub.Config() + next.Station.Number = 3 + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + + awaitEntry(t, arriving, "la veille n'a jamais pris la source que le rechargement a mise "+ + "en service : ce poste ne peut plus importer sans redémarrage") +} + +// TestACatalogSourceThatCannotBeRebuiltIsJournalled keeps the memory catalog in +// service: there is no gap, and the failure is named. +func TestACatalogSourceThatCannotBeRebuiltIsJournalled(t *testing.T) { + b := newBench(t) + b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { + return nil, errors.New("partage inaccessible") + } + next := b.hub.Config() + next.Station.Number = 4 + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + if b.hub.Catalog() == nil { + t.Fatal("le catalogue en mémoire a été perdu : le rechargement d'une source ne coupe rien") + } + awaitCondition(t, func() bool { return b.technical.has("ERR-CAT-05") }, + "l'échec de reconstruction de la source n'a pas été journalisé") +} diff --git a/internal/station/devices.go b/internal/station/devices.go new file mode 100644 index 0000000..18e7f2c --- /dev/null +++ b/internal/station/devices.go @@ -0,0 +1,192 @@ +package station + +import ( + "context" + "time" + + "openscale/internal/domain" +) + +// This file is the WIRING of the devices: opening the scale a configuration names, +// closing the one in service, rebuilding the printer and the catalog source, and +// falling back to manual entry when nothing else worked. What DECIDES that a block has +// moved is in reload.go. + +// codeScaleUnavailable is ERR-SCL-03 — « Le port de la balance ne peut pas être +// ouvert. » It is the code the fallback to manual entry carries, because that is +// the fact a volunteer has to act on. +const codeScaleUnavailable = "ERR-SCL-03" + +// scaleCloseBudget bounds the release of the serial port during a reload (§11.4). +// +// Both waits it covers are bounded, and both had to be: the contract does require +// closing done on every exit path, but a contract is not an execution guarantee; +// and Close, declared BLOCKING, may never return on a failed Windows serial port. +// The caller is the handler that writes the configuration, and writing a +// configuration must NEVER be able to hang. +const scaleCloseBudget = 3 * time.Second + +// restartScale cancels the sub-context, THEN WAITS for the device to be +// effectively closed before re-instantiating. +// +// On Windows the serial port is exclusive: without that wait, reopening fails +// intermittently with « Access denied ». That is why Scale.Close is BLOCKING. +// +// BOTH WAITS ARE BOUNDED, by the injected clock, and the caller is the handler +// that writes the configuration: +// +// a) a bare <-scaleDone — the contract does require closing done on EVERY exit +// path, including a Start that failed before launching its goroutine; but a +// contract is not an execution guarantee, and a faulty third-party driver +// would freeze the administration screen; +// b) Close, declared BLOCKING, may never return on a failed Windows serial port, +// and a bounded wait placed AFTER it would never have been reached. +func (s *Station) restartScale(next domain.Config) { + s.stopScale(next) + if s.newScale == nil || !next.Scale.Present { + return + } + driver, err := s.newScale(next) + if err != nil { + s.degradeToManual(codeScaleUnavailable, err.Error()) + return + } + s.scale = driver + if err := s.startScale(next); err != nil { + s.degradeToManual(codeScaleUnavailable, err.Error()) + return + } + s.hub.degraded.Store(nil) +} + +// stopScale cancels the driver in service and waits, BOUNDED, for it to let go. +func (s *Station) stopScale(next domain.Config) { + if s.cancelScale != nil { + s.cancelScale() + s.cancelScale = nil + } + + // Close runs in a DISPOSABLE goroutine: transient just like the one of + // ports.WithBudget, at most one per reload, released when the driver releases + // the port. + closed := make(chan struct{}) + previous, running := s.scale, s.scaleRunning + go func() { + defer close(closed) + if previous != nil { + s.logIfErr(previous.Close()) + } + }() + + waits := []<-chan struct{}{closed} + if running { + // Only a driver that was actually STARTED closes its done channel. Waiting + // on the channel of a driver that never ran would burn the whole budget on + // a station that has no scale at all. + waits = append(waits, s.scaleDone) + } + if !waitAll(s.clock, scaleCloseBudget, waits...) { + // We RE-INSTANTIATE ANYWAY. Reopening may fail with « Access denied »: + // that is an amber light and a fallback to manual entry, never a stalled + // configuration write. + s.hub.logTechnical(domain.LevelError, "scale", "ERR-SCL-08", + "Fermeture du port non confirmée en 3 s, réinstanciation forcée.", + next.Scale.Type) + s.counters.UnconfirmedScaleCloses.Add(1) + } + + // The old done channel is ABANDONED, never reused: a late goroutine that + // closed it afterwards would close nothing observable. + s.scaleDone = make(chan struct{}) + s.scale, s.scaleRunning = nil, false +} + +// startScale starts the driver in place, if there is one and the station declares +// it has a scale. +// +// A station that declares it has no scale has nothing to open, and that is an +// EXPLICIT declaration and not an inference: scale.present false turns the light +// off instead of leaving it red. +// +// scaleRunning is set BEFORE the call and stays set even when Start returns an +// error, because the contract of §5.3 has done closed on EVERY exit path — a +// driver that failed to open still signals its own end, and the next restart has +// to wait for that signal. +func (s *Station) startScale(cfg domain.Config) error { + if s.scale == nil || !cfg.Scale.Present { + return nil + } + ctx, cancel := context.WithCancel(s.rootCtx) + s.cancelScale = cancel + s.hub.nominalRate.Store(int64(s.scale.Descriptor().NominalRate)) + s.scaleRunning = true + return s.scale.Start(ctx, s.hub.Measurements(), s.scaleDone) +} + +// restartPrinter rebuilds the printer, and KEEPS THE ONE THAT WORKS if the new one +// cannot be built. +// +// Losing a working printer over a bad setting would take the station out of +// service for a change that was refused anyway; the amber light and the technical +// line say what happened. +func (s *Station) restartPrinter(next domain.Config) { + if s.newPrinter == nil { + return + } + built, err := s.newPrinter(next) + if err != nil { + s.hub.logTechnical(domain.LevelError, "printer", "ERR-PRN-01", + "Imprimante non reconstruite : la précédente reste en service.", err.Error()) + return + } + previous := s.printer + s.printer = built + s.print.printer = built + if previous != nil { + s.logIfErr(previous.Close()) + } +} + +// restartCatalog stops the watch and starts it again on the new source, and on the +// new file name. The catalog IN MEMORY is untouched: there is no gap in service. +func (s *Station) restartCatalog(next domain.Config) { + if s.newCatalogSource == nil { + return + } + built, err := s.newCatalogSource(next) + if err != nil { + s.hub.logTechnical(domain.LevelError, "catalog", "ERR-CAT-05", + "Source de catalogue non reconstruite.", err.Error()) + return + } + previous := s.swapCatalogSource(built) + if previous != nil { + s.logIfErr(previous.Close()) + } +} + +// degradeToManual is the fallback of §11.4 when nothing else worked. +// +// The station enters manual entry — a STATE, entered automatically, and not a +// driver somebody wrote into a file: the configuration on disk keeps saying what +// the operator asked for, and the in-memory one says what the station can actually +// do. The instant is what makes « pourquoi ce poste est-il en saisie manuelle ce +// matin ? » a decidable question. +func (s *Station) degradeToManual(code, reason string) { + live := *s.hub.cfg.Load() + live.Scale.Present = false + live.Scale.ManualEntryAllowed = true + s.hub.cfg.Store(&live) + s.hub.degraded.Store(&Degradation{Since: s.clock.Now(), Code: code, Reason: reason}) + s.hub.logTechnical(domain.LevelError, "scale", code, + "Matériel indisponible : le poste passe en saisie manuelle.", reason) +} + +// logIfErr sends a driver error to the technical journal and swallows it. +func (s *Station) logIfErr(err error) { + if err == nil { + return + } + s.hub.logTechnical(domain.LevelWarn, "scale", "ERR-SCL-05", + "Fermeture du périphérique en erreur.", err.Error()) +} diff --git a/internal/station/devices_test.go b/internal/station/devices_test.go new file mode 100644 index 0000000..02b7563 --- /dev/null +++ b/internal/station/devices_test.go @@ -0,0 +1,290 @@ +package station + +import ( + "errors" + "sync" + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// The drivers a configuration change rebuilds, and the promise that none of them can +// stall the screen that asked: serial to manual and back, a Start that failed before +// its goroutine, a Close that never returns, and a printer that stays in service when +// its replacement cannot be built. + +// scaleForge hands out one fake scale per instantiation, and remembers them all. +type scaleForge struct { + clock ports.Clock + mu sync.Mutex + built []*fake.Scale + // prepare is run on each new driver, so a test can decide how the NEXT one + // misbehaves. + prepare func(*fake.Scale) + err error +} + +func (f *scaleForge) New(domain.Config) (ports.Scale, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, f.err + } + s := fake.NewScale(f.clock) + if f.prepare != nil { + f.prepare(s) + } + f.built = append(f.built, s) + return s, nil +} + +func (f *scaleForge) last() *fake.Scale { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.built) == 0 { + return nil + } + return f.built[len(f.built)-1] +} + +func (f *scaleForge) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.built) +} + +// TestSerialToManualAndBack is failure test 1 ter (a): two successive reloads, +// both directions work, and no channel is lost. +// +// The measurement channel belongs to the Hub FOR THE LIFETIME OF THE PROCESS: the +// re-instantiated driver writes into the SAME one. That is what makes the degraded +// mode reversible (bloquant-2). +func TestSerialToManualAndBack(t *testing.T) { + forge := &scaleForge{} + b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) + forge.clock = b.clock + + // serial -> manual + manual := b.hub.Config() + manual.Scale.Present = false + manual.Scale.Type = "" + if _, err := b.station.Reload(ReloadRequest{Next: manual}); err != nil { + t.Fatalf("Reload vers manuel : %v", err) + } + if !b.scale.Closed() { + t.Fatal("la balance de départ n'a pas été fermée") + } + b.tick() + if got := b.hub.State().State; got != domain.ManualMode { + t.Fatalf("état %s, attendu manual_mode", got) + } + + // manual -> serial + serial := b.hub.Config() + serial.Scale.Present = true + serial.Scale.Type = "gram-xfoc-plus" + if _, err := b.station.Reload(ReloadRequest{Next: serial}); err != nil { + t.Fatalf("Reload vers série : %v", err) + } + if forge.count() != 1 { + t.Fatalf("%d balances instanciées, attendu 1", forge.count()) + } + + // The SAME channel still carries measurements, from the NEW driver. + forge.last().Push(1236, domain.Stable) + b.awaitIntake() + b.tick() + if got := b.hub.State().Weight.Gross; got != 1236 { + t.Fatalf("poids %d g après l'aller-retour, attendu 1236 g : le canal a été perdu", got) + } +} + +// TestAStartThatFailsBeforeItsGoroutineStillAnswers is failure test 1 ter (b). +// +// The driver fails before it ever launched anything, and it still closes done — +// the mandatory corollary of §5.3. The configuration write answers, and the +// station falls back to manual entry with an amber light. +func TestAStartThatFailsBeforeItsGoroutineStillAnswers(t *testing.T) { + forge := &scaleForge{prepare: func(s *fake.Scale) { + s.FailToStart(errors.New("accès refusé au port COM8")) + }} + b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) + forge.clock = b.clock + + next := b.hub.Config() + next.Scale.Options = mustOptions(t, `{"port":"COM9"}`) + + started := time.Now() + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + if elapsed := time.Since(started); elapsed > 20*time.Millisecond { + t.Fatalf("le rechargement a pris %s de temps mural", elapsed) + } + + assertFallbackToManual(t, b, codeScaleUnavailable) + if got := b.hub.Config().Scale.Options; !hasOption(got, "port", `"COM9"`) { + t.Fatal("la configuration demandée n'est pas en service : seul le repli doit différer") + } +} + +// TestACloseThatNeverReturnsIsBounded is failure test 1 ter (c), and it is the +// hard point of the reload. +// +// The wait is bounded at 3 s of FAKE clock — under twenty milliseconds of wall +// time — the configuration is applied anyway, ERR-SCL-08 is journalled and the +// fallback is manual entry with an amber light. +func TestACloseThatNeverReturnsIsBounded(t *testing.T) { + forge := &scaleForge{err: errors.New("le port ne se rouvre pas")} + b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) + forge.clock = b.clock + + b.scale.HangOnClose() + defer b.scale.Release() + + next := b.hub.Config() + next.Scale.Options = mustOptions(t, `{"port":"COM9"}`) + + started := time.Now() + done := make(chan error, 1) + go func() { _, err := b.station.Reload(ReloadRequest{Next: next}); done <- err }() + + // Nothing moves until the INJECTED clock does. + select { + case <-done: + t.Fatal("le rechargement n'a pas attendu la fermeture du port") + case <-time.After(20 * time.Millisecond): + } + b.clock.Advance(scaleCloseBudget) + + select { + case err := <-done: + if err != nil { + t.Fatalf("Reload : %v", err) + } + case <-time.After(hang): + t.Fatal("le rechargement n'est pas borné : la configuration ne peut pas s'écrire") + } + if elapsed := time.Since(started); elapsed > 200*time.Millisecond { + t.Fatalf("le rechargement a pris %s de temps mural : le budget n'est pas sur l'horloge injectée", elapsed) + } + + // The technical line travels through the journal WORKER, on its own goroutine, so + // it is not there the instant Reload returns. A single tick was enough on a quiet + // machine and not on a loaded CI runner, which is the definition of a flaky test. + b.tick() + awaitCondition(t, func() bool { return b.technical.has("ERR-SCL-08") }, + "ERR-SCL-08 n'a pas été journalisé alors que la fermeture n'a pas été confirmée") + if got := b.station.Counters().UnconfirmedScaleCloses.Load(); got != 1 { + t.Fatalf("fermetures non confirmées = %d, attendu 1", got) + } + assertFallbackToManual(t, b, codeScaleUnavailable) +} + +// assertFallbackToManual checks the fallback of §11.4: a STATE, entered +// automatically, with its cause and its instant. +func assertFallbackToManual(t *testing.T, b *bench, code string) { + t.Helper() + cfg := b.hub.Config() + if cfg.Scale.Present { + t.Fatal("le poste se croit encore équipé d'une balance") + } + if !cfg.Scale.ManualEntryAllowed { + t.Fatal("le repli n'autorise pas la saisie manuelle : le poste ne peut plus peser") + } + b.tick() + s := b.hub.State() + if s.Degraded == nil { + t.Fatal("aucune dégradation publiée : le bandeau ne peut pas dire pourquoi") + } + if s.Degraded.Code != code { + t.Fatalf("code de dégradation %q, attendu %q", s.Degraded.Code, code) + } + if s.Degraded.Since.IsZero() { + t.Fatal("la dégradation n'a pas d'horodate : « pourquoi ce poste est-il en saisie " + + "manuelle ce matin ? » redevient indécidable") + } + if s.State != domain.ManualMode { + t.Fatalf("état %s, attendu manual_mode", s.State) + } +} + +// printerForge hands out one fake printer per instantiation. +type printerForge struct { + mu sync.Mutex + built []*fake.Printer + err error +} + +func (f *printerForge) New(domain.Config) (ports.Printer, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, f.err + } + p := fake.NewPrinter() + f.built = append(f.built, p) + return p, nil +} + +func (f *printerForge) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.built) +} + +// TestThePrinterBlockRebuildsAndReleasesTheOldOne is the second line of the +// hardware table of §11.4: close, rebuild, self-test, about 200 ms. +func TestThePrinterBlockRebuildsAndReleasesTheOldOne(t *testing.T) { + forge := &printerForge{} + b := newBench(t) + b.station.newPrinter = forge.New + + next := b.hub.Config() + next.Printer.Options = mustOptions(t, `{"transport":"winspool","queue":"SATO WS408_3"}`) + + outcome, err := b.station.Reload(ReloadRequest{Next: next}) + if err != nil { + t.Fatalf("Reload : %v", err) + } + if len(outcome.Changed) != 1 || outcome.Changed[0] != blockPrinter { + t.Fatalf("blocs redémarrés %v, attendu [%s]", outcome.Changed, blockPrinter) + } + if outcome.ConfirmBefore.IsZero() { + t.Fatal("un changement d'imprimante n'a pas armé le compte à rebours") + } + if forge.count() != 1 { + t.Fatalf("%d imprimantes instanciées, attendu 1", forge.count()) + } + if !b.printer.Closed() { + t.Fatal("l'imprimante précédente n'a pas été relâchée") + } +} + +// TestAPrinterThatCannotBeRebuiltKeepsTheOneThatWorks: losing a working printer +// over a setting that was refused anyway would take the station out of service for +// nothing. +func TestAPrinterThatCannotBeRebuiltKeepsTheOneThatWorks(t *testing.T) { + forge := &printerForge{err: errors.New("file d'impression introuvable")} + b := newBench(t) + b.station.newPrinter = forge.New + + next := b.hub.Config() + next.Printer.Options = mustOptions(t, `{"transport":"winspool","queue":"inconnue"}`) + if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { + t.Fatalf("Reload : %v", err) + } + if b.printer.Closed() { + t.Fatal("l'imprimante qui marche a été fermée pour une configuration refusée") + } + b.feed(1236, 2) + if ack := b.tap("still-printing", 1236); !ack.Accepted { + t.Fatalf("le poste n'imprime plus après un rechargement refusé : %s", ack.Message) + } + b.awaitPrint() + awaitCondition(t, func() bool { return b.technical.has("ERR-PRN-01") }, + "le refus de reconstruction n'a pas été journalisé") +} diff --git a/internal/station/doubles_test.go b/internal/station/doubles_test.go new file mode 100644 index 0000000..a3e70eb --- /dev/null +++ b/internal/station/doubles_test.go @@ -0,0 +1,213 @@ +package station + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "openscale/internal/domain" + "openscale/internal/fake" + "openscale/internal/station/ports" +) + +// The doubles every test of this package is given: the configuration a station starts +// on, the two catalogs it shows, and the journal and the technical sink that record +// what it did. The bench that assembles them is in harness_test.go. + +// loadConfig reads the configuration actually shipped with the binary. +// +// The real file and not a literal: a test that invents its own thresholds proves +// nothing about the station anybody will run. +func loadConfig(t *testing.T) domain.Config { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "config-lacagette.json")) + if err != nil { + t.Fatalf("lecture de la configuration livrée : %v", err) + } + var cfg domain.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("configuration livrée illisible : %v", err) + } + return cfg +} + +// garlicCatalog is one product, the one every vector of the document is written +// against. +func garlicCatalog() *domain.Catalog { + return domain.NewCatalog( + []domain.Product{{ + ID: garlicID, Name: "AIL", Reference: "0493021000003", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 532, + CategoryCode: "vegetables", Qualification: domain.Weighable, + }}, + []domain.Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, + ) +} + +// leekID is the second product, and it exists for one assertion: failure test 17 (b) +// requires the label to carry the product touched LAST, which a one-product catalog +// cannot tell apart from the product touched first. +const leekID = "7001" + +// twoProductCatalog is the garlic plus one leek, both weighable. +func twoProductCatalog() *domain.Catalog { + return domain.NewCatalog( + append(garlicCatalog().Products(), domain.Product{ + ID: leekID, Name: "POIREAU", Reference: "0493022000002", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, + CategoryCode: "vegetables", Qualification: domain.Weighable, + }), + []domain.Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, + ) +} + +// recordingJournal is a Journal that keeps what it was given and says so. +type recordingJournal struct { + mu sync.Mutex + weighings []domain.Weighing + purges int + err error + // written is signalled once per row, so a test can wait for the end of a cycle + // without a sleep. + written chan struct{} +} + +func newRecordingJournal() *recordingJournal { + return &recordingJournal{written: make(chan struct{}, 1<<16)} +} + +func (j *recordingJournal) RecordWeighing(_ context.Context, w *domain.Weighing) error { + j.mu.Lock() + if j.err != nil { + err := j.err + j.mu.Unlock() + return err + } + j.weighings = append(j.weighings, *w) + j.mu.Unlock() + j.written <- struct{}{} + return nil +} + +func (j *recordingJournal) PurgeWeighings(context.Context) (int64, error) { + j.mu.Lock() + defer j.mu.Unlock() + j.purges++ + return 0, nil +} + +func (j *recordingJournal) rows() []domain.Weighing { + j.mu.Lock() + defer j.mu.Unlock() + out := make([]domain.Weighing, len(j.weighings)) + copy(out, j.weighings) + return out +} + +// last returns the most recent row WITHOUT copying the whole journal. +// +// It is not a micro-optimisation: the volume test writes ten thousand rows, and +// copying the lot on every one of them is quadratic — four seconds of the +// ten-second budget of §16.4, spent by the test harness on itself. +func (j *recordingJournal) last() domain.Weighing { + j.mu.Lock() + defer j.mu.Unlock() + return j.weighings[len(j.weighings)-1] +} + +// count reports how many rows were written. +func (j *recordingJournal) count() int { + j.mu.Lock() + defer j.mu.Unlock() + return len(j.weighings) +} + +func (j *recordingJournal) purgeCount() int { + j.mu.Lock() + defer j.mu.Unlock() + return j.purges +} + +// recordingTechnical is a TechnicalSink that keeps every line. +type recordingTechnical struct { + mu sync.Mutex + entries []TechnicalEntry +} + +func (r *recordingTechnical) RecordTechnical(_ context.Context, e TechnicalEntry) error { + r.mu.Lock() + defer r.mu.Unlock() + r.entries = append(r.entries, e) + return nil +} + +// has reports whether a line carrying that code was written. +func (r *recordingTechnical) has(code string) bool { return r.count(code) > 0 } + +// count reports how many lines carry that code. +// +// The COUNT and not the presence is what failure test 1 needs: twenty +// StatusDisconnected in a row must produce one line, and a test that only asked +// whether there was one would pass on twenty. +func (r *recordingTechnical) count(code string) int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, e := range r.entries { + if e.Code == code { + n++ + } + } + return n +} + +// countSource reports how many lines came from that source. +// +// A source and not a code, because the lines this answers for carry no ERR code: +// « the release server could not be reached » is not a fault of the station, and +// giving it a code would put it in the same list as a printer that has stopped. +func (r *recordingTechnical) countSource(source string) int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, e := range r.entries { + if e.Source == source { + n++ + } + } + return n +} + +// lastLevel reports the level of the most recent line of that source. +func (r *recordingTechnical) lastLevel(source string) string { + r.mu.Lock() + defer r.mu.Unlock() + for i := len(r.entries) - 1; i >= 0; i-- { + if r.entries[i].Source == source { + return r.entries[i].Level + } + } + return "" +} + +// nopScale satisfies ports.Scale and does nothing but honour the contract. +type nopScale struct{ descriptor domain.ScaleDescriptor } + +func (s nopScale) Descriptor() domain.ScaleDescriptor { return s.descriptor } + +func (s nopScale) Start(ctx context.Context, _ chan<- domain.ScaleEvent, done chan<- struct{}) error { + go func() { <-ctx.Done(); close(done) }() + return nil +} + +func (s nopScale) Close() error { return nil } + +var _ ports.Scale = nopScale{} + +// fakeClockAt is a clock frozen at one instant, for the tests that drive a bare +// Hub instead of a whole station. +func fakeClockAt(at time.Time) *fake.Clock { return fake.NewClock(at) } diff --git a/internal/station/downtime.go b/internal/station/downtime.go new file mode 100644 index 0000000..bd4d59d --- /dev/null +++ b/internal/station/downtime.go @@ -0,0 +1,63 @@ +package station + +import "openscale/internal/domain" + +// This file answers the question the three acts that stop a station ask — installing a +// version, restarting the service, restarting the machine: may the station be taken +// down right now, and if not, what does the screen say? + +// DowntimeRefused carries the guard's OWN French sentence up to the screen. +// +// A type and not a formatted string, for the reason update.BusyError already gives: the +// layer above renders that sentence verbatim, because the guard knows whether a weighing +// or a catalogue is in the way and an HTTP handler does not. Recovering it by cutting a +// prefix off an error message would break the first time either side is reworded. +type DowntimeRefused struct{ Reason string } + +// Error renders the refusal for a log. +func (e *DowntimeRefused) Error() string { + return "station: the station must not be taken down: " + e.Reason +} + +// DowntimeGuard reports whether the station may be taken down, and says IN FRENCH +// why not when it may not. +// +// It answers for the THREE acts that stop the station: installing a new version, +// restarting the service, restarting the machine. The name says « taken down » and +// not « updated » because the rule never depended on what came after the stop -- +// what it protects is the weighing in progress and the catalogue not yet in service. +// +// The rule lives here and not in the HTTP layer, for one reason: the HTTP layer +// would have to read a state in order to deduce a rule, and the rule would then +// exist in two places. It asks a question and renders the answer. +func (h *Hub) DowntimeGuard() (bool, string) { + return downtimeGuardFor(h.State().State, h.catalogWaiting.Load()) +} + +// downtimeGuardFor is the rule itself, without a Hub, so that every state of the +// machine can be put to it in one table. +// +// OutOfService and Faulted PASS, deliberately. A station that cannot serve is +// exactly the one that may need a newer binary, and refusing there would close +// the only door -- which is why NeutralProfile names a repository. +func downtimeGuardFor(state domain.State, catalogWaiting bool) (bool, string) { + if catalogWaiting { + // The CSV has already been read and deleted -- the deletion IS the + // acknowledgement -- and the products live only in memory until a quiet + // moment lets them enter service. Stopping the station here loses them, + // and nothing will ever offer them again. + return false, "Un catalogue vient d'arriver et n'est pas encore en service. Réessayez dans un instant." + } + switch state { + case domain.Initializing, domain.Idle, domain.ManualMode, domain.ScaleLost, + domain.Faulted, domain.OutOfService: + return true, "" + default: + // ProductArmed, WeightPresent, WeightStable, AwaitingStability, + // EnteringTare, EnteringWeight, Validating, Printing, Succeeded and + // Rejected all mean somebody is mid-cycle or reading a result. Each of + // them clears in seconds; the button says to try again rather than + // cutting a label in half. + return false, "Une pesée est en cours. Réessayez dans un instant." + } +} diff --git a/internal/station/effects.go b/internal/station/effects.go new file mode 100644 index 0000000..f77ebca --- /dev/null +++ b/internal/station/effects.go @@ -0,0 +1,140 @@ +package station + +import ( + "time" + + "openscale/internal/domain" +) + +// This file performs the effects the machine emitted. Every one of them NEVER blocks +// and NEVER calls Transition: an effect with something to say to the machine RETURNS +// an event, which the loop drains at the top of its next turn. + +// execute performs one effect. It NEVER blocks and NEVER calls Transition. +// +// When an effect has to make something happen inside the machine, it returns the +// event instead of injecting it, and the loop drains it on the next turn. +func (h *Hub) execute(ef domain.Effect, now time.Time) domain.Event { + switch e := ef.(type) { + case domain.PrintEffect: + return h.print(e) + + case domain.RecordEffect: + select { + case h.journalEntries <- e.Weighing: + default: + // Slow or full disk: the weighing is LOST FOR THE JOURNAL, but the + // label came out and the customer is served. + // WE DEGRADE THE JOURNAL, NEVER THE SERVICE. + h.counters.UnloggedWeighings.Add(1) + h.ring.Add(e.Weighing) + } + + case domain.AckEffect: + h.idempotency.Store(e.Key, e.Ack) + reply(h.pendingReply, e.Ack) + h.pendingReply = nil + + case domain.MessageEffect: + message := Message{Level: e.Level, Code: e.Code, Text: e.Text} + if e.Duration > 0 { + message.ExpiresAt = now.Add(e.Duration) + } + h.message = &message + + case domain.SoundEffect: + // The BROWSER plays the sound; the backend does no audio I/O. + h.sound = e.Name + + case domain.TechnicalLogEffect: + h.logTechnical(e.Level, e.Source, e.Code, e.Message, e.Detail) + + case domain.ArmTimerEffect: + h.armExpiresAt = now.Add(e.Duration) + + case domain.ApplyCatalogEffect: + h.storeCatalog(e.Catalog, e.ImportedAt) + } + return nil +} + +// print hands one label to the worker, and turns a saturated worker into the +// failure the machine already knows how to answer. +func (h *Hub) print(e domain.PrintEffect) domain.Event { + cfg := h.cfg.Load() + j := job{ + Label: e.Label, + Template: h.template(cfg.Printer.Template), + Locale: cfg.UI.Language, + Copies: copies(cfg), + Reprint: e.Reprint, + } + select { + case h.printJobs <- j: + return nil + default: + h.logTechnical(domain.LevelError, "printer", "ERR-PRN-09", + "Worker d'impression saturé.", e.Label.JobID) + return domain.PrintFinished{JobID: e.Label.JobID, Err: ErrPrintWorkerBusy} + } +} + +// template resolves printer.template against the templates the station was given. +// +// A name that resolves to nothing yields the zero template rather than a panic: a +// configuration control refuses an unknown template long before a customer stands +// at the scale (§11.3), and a station that has got past that control must keep +// serving. +func (h *Hub) template(name string) domain.Template { + if t, ok := h.templates[name]; ok { + return t + } + return domain.Template{} +} + +// copies is printer.options.copies, which is 1 on the shipped file. +// +// A count the operator left at zero or below is ONE, not none: a station that +// prints nothing because a field is empty is a station nobody can debug. +func copies(cfg *domain.Config) int { + n, ok := cfg.Printer.Options.Int("copies") + if !ok || n < 1 { + return 1 + } + return int(n) +} + +// reply NEVER BLOCKS. +// +// The ack channel has a capacity of one and is written once, but the default +// covers the caller that gave up before the answer — browser closed, request +// context cancelled. A nil channel is tolerated too: a command can be injected +// without a caller. +func reply(ch chan<- domain.Ack, a domain.Ack) { + if ch == nil { + return + } + select { + case ch <- a: + default: // caller gone — we do not hold the Hub goroutine back for it + } +} + +// defaultAck derives, from the resulting model, the answer that no effect +// produced: the state reached, the refusal and its code, never a JobID. +// +// It is distinct from an acceptance ack — Accepted stays false — and the +// administration screen renders it as such. +func defaultAck(m domain.Model, ev domain.Event) domain.Ack { + ack := domain.Ack{State: m.State} + if blocking := domain.FirstBlocking(m.Diagnostics); blocking != nil { + ack.Code, ack.Message = blocking.Code, blocking.Message + return ack + } + if _, isReprint := ev.(domain.ReprintRequested); isReprint { + ack.Message = "Cette étiquette ne peut plus être réimprimée." + return ack + } + ack.Message = "Cette action n'est pas possible pour l'instant." + return ack +} diff --git a/internal/station/effects_test.go b/internal/station/effects_test.go new file mode 100644 index 0000000..d9ac570 --- /dev/null +++ b/internal/station/effects_test.go @@ -0,0 +1,264 @@ +package station + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "openscale/internal/domain" +) + +// What the effects of effects.go do, and what they refuse to do to the service: a +// command always answers, a saturated print worker becomes a failure the machine knows, +// a hanging printer never holds the loop, and a journal that cannot write degrades the +// JOURNAL and never the service. + +// TestNoLeakOnCommandWithoutAck is the test §13.2 names. +// +// Five hundred refused commands alternated with five hundred nominal ones, then +// the goroutine count compared with the baseline AT REST, WITH NO CLIENT +// CONNECTED. Without the end-of-cycle safety net, every refusal leaks the +// goroutine of its caller. +func TestNoLeakOnCommandWithoutAck(t *testing.T) { + b := newBench(t) + b.push(1236, domain.Stable) + b.tick() + + baseline := stableCount() + + ctx := context.Background() + for i := 0; i < 500; i++ { + // Refused: a product the catalog does not offer, then an event the current + // state has nothing to say about. + if _, err := b.hub.Submit(ctx, domain.ProductTapped{ProductID: "inconnu"}, ""); err != nil { + t.Fatalf("Submit(produit inconnu) : %v", err) + } + if _, err := b.hub.Submit(ctx, domain.Dismiss{}, ""); err != nil { + t.Fatalf("Submit(Dismiss hors Faulted) : %v", err) + } + if _, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: "jamais imprimé"}, ""); err != nil { + t.Fatalf("Submit(réimpression impossible) : %v", err) + } + } + + if got := stableCount(); got > baseline { + t.Fatalf("%d goroutines après 1 500 commandes refusées, ligne de base %d : une commande "+ + "refusée laisse son appelant en attente", got, baseline) + } +} + +// TestARefusedCommandStillAnswers checks the CONTENT of the safety net, not only +// that it fires: the answer names the state reached and refuses in French. +func TestARefusedCommandStillAnswers(t *testing.T) { + b := newBench(t) + ctx := context.Background() + + ack, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: "jamais imprimé"}, "") + if err != nil { + t.Fatalf("Submit : %v", err) + } + if ack.Accepted { + t.Fatal("une réimpression impossible a été acceptée") + } + if ack.Message == "" { + t.Fatal("un refus sans message : l'écran n'a rien à afficher") + } + if !strings.Contains(ack.Message, "réimprim") { + t.Fatalf("message %q : il doit parler de la réimpression", ack.Message) + } +} + +// TestSubmitAnswersWhenTheHubIsGone proves the symmetric half of the contract: a +// caller never waits on the channel alone. +func TestSubmitAnswersWhenTheHubIsGone(t *testing.T) { + b := newBench(t) + b.station.Stop() + <-b.station.Stopped() + + if _, err := b.hub.Submit(context.Background(), domain.Cancel{}, ""); !errors.Is(err, ErrStopped) { + t.Fatalf("erreur %v, attendu ErrStopped", err) + } +} + +// TestASaturatedPrintWorkerBecomesAPrintFailure covers the branch of execute that +// only an ABNORMAL station reaches: the machine forbids two prints inside one +// cycle, so a full channel means the worker is stuck on a device. +// +// It is driven directly rather than through the loop, and that is the honest way +// to test it: getting there through the machine would require a third cycle to +// start while the second is still printing, which the machine refuses — the test +// would prove the setup, not the branch. +func TestASaturatedPrintWorkerBecomesAPrintFailure(t *testing.T) { + h := newHub(Options{ + Clock: fakeClockAt(epoch), Config: loadConfig(t), Catalog: garlicCatalog(), + Counters: &Counters{}, + }) + h.printJobs <- job{} // the worker is stuck: the one slot is taken + + ev := h.execute(domain.PrintEffect{Label: domain.Label{JobID: "j-42"}}, epoch) + finished, ok := ev.(domain.PrintFinished) + if !ok { + t.Fatalf("execute rend %T, attendu un domain.PrintFinished réinjecté", ev) + } + if finished.JobID != "j-42" { + t.Fatalf("job %q, attendu j-42", finished.JobID) + } + if !errors.Is(finished.Err, ErrPrintWorkerBusy) { + t.Fatalf("erreur %v, attendu ErrPrintWorkerBusy", finished.Err) + } + select { + case entry := <-h.technical: + if entry.Code != "ERR-PRN-09" { + t.Fatalf("code technique %q, attendu ERR-PRN-09", entry.Code) + } + default: + t.Fatal("aucune ligne technique : la saturation du worker n'est pas journalisée") + } +} + +// TestTheHubKeepsAnsweringWhileThePrinterHangs is failure test 6 seen from the +// Hub: the device is stuck, and neither the loop nor a caller waits on it. +func TestTheHubKeepsAnsweringWhileThePrinterHangs(t *testing.T) { + b := newBench(t) + b.printer.Hang() + defer b.printer.Release() + + b.feed(1236, 2) + if ack := b.tap("hung", 1236); !ack.Accepted { + t.Fatalf("pesée refusée : %s", ack.Message) + } + // The loop keeps turning and keeps answering while the device holds the worker. + for i := 0; i < 10; i++ { + b.tick() + } + if got := b.hub.State().State; got != domain.Printing { + t.Fatalf("état %s : le cycle ne devrait pas se terminer sans réponse de l'imprimante", got) + } +} + +// TestTheJournalDegradesAndTheServiceDoesNot is ADR-013 from the failing side: the +// store refuses, the label still came out, the weighing lands in the RAM ring and +// the counter goes up. +func TestTheJournalDegradesAndTheServiceDoesNot(t *testing.T) { + b := newBench(t) + b.journal.mu.Lock() + b.journal.err = errors.New("disque plein") + b.journal.mu.Unlock() + + b.feed(1236, 2) + if ack := b.tap("disk-full", 1236); !ack.Accepted { + t.Fatalf("pesée refusée alors que seul le journal est en panne : %s", ack.Message) + } + b.awaitPrint() + if n := len(b.printer.Jobs()); n != 1 { + t.Fatalf("%d étiquettes : la pesée doit sortir même quand le journal ne suit pas", n) + } + // The store refuses, so the counter is what says so. It is written by the + // journal worker, which is why the assertion converges instead of snapshotting. + counters := b.station.Counters() + for i := 0; i < 20000 && counters.UnloggedWeighings.Load() == 0; i++ { + b.tick() + if counters.UnloggedWeighings.Load() != 0 { + break + } + } + if got := counters.UnloggedWeighings.Load(); got != 1 { + t.Fatalf("compteur de pesées non journalisées = %d, attendu 1", got) + } + // The row goes to the RAM ring, exactly as it does when the channel is + // saturated: a disk that is full and a channel nobody drains lose the same row + // for the same customer, and failure test 7 asks for the ring on the first of + // the two (§16.2, ADR-013). The counter says HOW MANY were lost; only the ring + // says WHICH ONES. + if entries := b.hub.Entries(); len(entries) != 1 { + t.Fatalf("%d pesée(s) dans l'anneau RAM, attendu 1", len(entries)) + } +} + +// TestARefusedWeighingAnswersWithItsSafeguardCode covers the end-of-cycle safety +// net where it matters most: a blocking safeguard produces no AckEffect of its own +// in some paths, and the answer must still name the code the screen shows. +func TestARefusedWeighingAnswersWithItsSafeguardCode(t *testing.T) { + b := newBench(t) + // Eight grams: under min_weight_g, which is 10 on the shipped file. + b.feed(8, 2) + ack := b.tap("too-light", 8) + if ack.Accepted { + t.Fatal("une pesée de 8 g a été acceptée alors que le plancher est à 10 g") + } + if ack.Code != domain.CodeWeightTooLow { + t.Fatalf("code %q, attendu %q", ack.Code, domain.CodeWeightTooLow) + } + if ack.Message == "" { + t.Fatal("un refus sans message : le client ne sait pas quoi corriger") + } + if n := len(b.printer.Jobs()); n != 0 { + t.Fatalf("%d étiquettes pour une pesée refusée", n) + } + + // And the refusal IS journalled, with what it would have cost. + row := b.awaitJournal() + if row.Result != domain.ResultRejected { + t.Fatalf("résultat %q, attendu %q", row.Result, domain.ResultRejected) + } + if row.Detail == "" { + t.Fatal("le journal ne dit pas pourquoi la pesée a été refusée") + } +} + +// TestDefaultAckNamesTheStateAndNeverAJobID is the unit of the safety net. +func TestDefaultAckNamesTheStateAndNeverAJobID(t *testing.T) { + rejected := domain.Model{ + State: domain.Rejected, + Diagnostics: []domain.Diagnostic{ + {Code: domain.CodeWeightTooLow, Severity: domain.Blocking, + Message: domain.DefaultMessage(domain.CodeWeightTooLow)}, + }, + } + ack := defaultAck(rejected, domain.ProductTapped{}) + if ack.Accepted { + t.Fatal("le filet de fin de cycle prétend accepter : il n'est pas un accusé d'acceptation") + } + if ack.State != domain.Rejected || ack.Code != domain.CodeWeightTooLow { + t.Fatalf("accusé %+v : il doit porter l'état atteint et le code bloquant", ack) + } + if ack.JobID != "" { + t.Fatal("le filet de fin de cycle a inventé un JobID : aucune étiquette n'est partie") + } + + silent := defaultAck(domain.Model{State: domain.Idle}, domain.Dismiss{}) + if silent.Message == "" { + t.Fatal("un événement ignoré rend un accusé muet : l'appelant n'a rien à afficher") + } +} + +// TestTheNumberOfCopiesComesFromTheConfiguration, and a count left at zero is ONE: +// a station that prints nothing because a field is empty is a station nobody can +// debug. +func TestTheNumberOfCopiesComesFromTheConfiguration(t *testing.T) { + b := newBench(t, func(o *benchOptions) { + o.config = func(c *domain.Config) { + c.Printer.Options["copies"] = json.RawMessage(`3`) + } + }) + b.feed(1236, 2) + b.tap("three-copies", 1236) + b.awaitPrint() + if got := b.printer.Jobs()[0].Copies; got != 3 { + t.Fatalf("%d exemplaires, attendu 3", got) + } + + zero := newBench(t, func(o *benchOptions) { + o.config = func(c *domain.Config) { + c.Printer.Options["copies"] = json.RawMessage(`0`) + } + }) + zero.feed(1236, 2) + zero.tap("zero-copies", 1236) + zero.awaitPrint() + if got := zero.printer.Jobs()[0].Copies; got != 1 { + t.Fatalf("%d exemplaires pour un réglage à zéro, attendu 1", got) + } +} diff --git a/internal/station/failures_catalog_bench_test.go b/internal/station/failures_catalog_bench_test.go new file mode 100644 index 0000000..e754594 --- /dev/null +++ b/internal/station/failures_catalog_bench_test.go @@ -0,0 +1,480 @@ +package station + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "openscale/internal/catalog" + "openscale/internal/catalog/importer" + "openscale/internal/catalog/localdrop" + "openscale/internal/domain" + "openscale/internal/fake" + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// The bench of the REAL chain: the local drop of §10.1 watching a real directory, the +// Odoo parser of §10.2 reading the two authentic files, the qualification of §10.3, the +// guards of §10.4, the quarantine of §10.5 in SQLite, the image store of §10.7 on disk +// and the transaction of §10.9 — assembled once, driven by the tests of +// failures_catalog_real_test.go. + +// --- The same seven, against the real chain --------------------------------- + +// fixtures is where the two authentic exchange files live (CLAUDE.md). +const fixtures = "../../testdata/catalog/" + +// dropInterval is catalog.options.poll_interval_s of the shipped configuration, and +// advancing the injected clock by that much is one scan of the drop directory. +const dropInterval = 5 * time.Second + +// The two inventories, MEASURED on the files of the repository and not copied from the +// document. §16.2 line 12 bis states « 331 tuiles dont 174 sans photo » and §18 « 181 +// avec photo et 174 sans » — but 181 + 174 = 355, which is the number of ROWS. The tile +// split is 177 with a photo and 154 without, and both are asserted below so that +// nobody has to take that on trust. +const ( + realRows, realTiles = 355, 331 + realNotWeighable, realIssues = 8, 16 + realUnitMismatch = 1 + realPhotoRows, realPhotoFiles = 181, 165 + realTilesWithPhoto = 177 + realTilesWithoutPhoto = 154 + realOtherTiles = 126 + + firstRows, firstTiles = 153, 107 +) + +// technicalBridge lets a driver of internal/catalog write into the very sink the +// station writes to, so that "no red light" is one assertion and not two. +type technicalBridge struct{ sink *recordingTechnical } + +// Technical records one line. +func (b technicalBridge) Technical(level, source, code, message, detail string) { + _ = b.sink.RecordTechnical(context.Background(), TechnicalEntry{ + Level: level, Source: source, Code: code, Message: message, Detail: detail, + }) +} + +// realOptions is what a test wants to change about the standard real bench. +type realOptions struct { + // catalog is what is in service before anything is dropped. Nil is a virgin + // station, whose grid says "Catalogue vide". + catalog *domain.Catalog + // wrap decorates the real source. It exists for failure test 11 and for nothing + // else: it is where the one unreproducible syscall is injected. + wrap func(ports.CatalogSource) ports.CatalogSource +} + +// realBench is a whole station wired to the whole of L7: a real drop directory, the +// real parser, the real qualification, the real guards, a real SQLite database and a +// real image store. Only the clock is a double. +type realBench struct { + t *testing.T + hub *Hub + clock *fake.Clock + db *store.DB + images *catalog.ImageStore + path string + archives string + technical *recordingTechnical + returned chan struct{} +} + +// newRealBench starts one. +func newRealBench(t *testing.T, tweak ...func(*realOptions)) *realBench { + t.Helper() + + o := realOptions{} + for _, f := range tweak { + f(&o) + } + cfg := loadConfig(t) + // The shipped file declares webdav, because the production share is one. What is + // exercised here is the drop directory, which is the source a volunteer uses and + // the one the drag and drop of the administration screen writes into (A4). + cfg.Catalog.Type = domain.CatalogSourceLocalDrop + + clock := fake.NewClock(epoch) + dataDir := t.TempDir() + db := store.OpenTest(t) + sink := &recordingTechnical{} + + images, err := catalog.NewImageStore(dataDir) + if err != nil { + t.Fatalf("puits d'images : %v", err) + } + drop, err := localdrop.New(catalog.SourceConfig{ + Catalog: cfg.Catalog, StationNumber: cfg.Station.Number, DataDir: dataDir, + Clock: clock, Log: technicalBridge{sink}, Images: images, Quarantine: db, + }) + if err != nil { + t.Fatalf("source de catalogue : %v", err) + } + applier, err := importer.New(importer.Options{ + Records: db, Clock: clock, Log: technicalBridge{sink}, + }) + if err != nil { + t.Fatalf("applicateur : %v", err) + } + + var source ports.CatalogSource = drop + if o.wrap != nil { + source = o.wrap(drop) + } + st, err := New(Options{ + Clock: clock, Config: cfg, Catalog: o.catalog, + Scale: fake.NewScale(clock), Printer: fake.NewPrinter(), + Journal: newRecordingJournal(), TechnicalSink: sink, + CatalogSource: source, ApplyCatalog: applier.Apply, + }) + if err != nil { + t.Fatalf("station.New : %v", err) + } + + b := &realBench{ + t: t, hub: st.Hub(), clock: clock, db: db, images: images, + path: drop.Path(), + archives: filepath.Join(dataDir, "catalog", "archives"), + technical: sink, + returned: make(chan struct{}), + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer close(b.returned) + _ = st.Start(ctx) + }() + select { + case <-st.Ready(): + case <-time.After(hang): + t.Fatal("le poste n'a jamais fini de démarrer") + } + t.Cleanup(func() { + st.Stop() + cancel() + <-b.returned + }) + return b +} + +// drop writes one of the authentic files into the watched directory. +func (b *realBench) drop(name string) { + b.t.Helper() + b.dropContent(fixtureBytes(b.t, name)) +} + +// dropContent writes arbitrary bytes into the watched directory, which is what a +// producer, a synchronisation tool and the administration drag and drop all do (A4). +func (b *realBench) dropContent(content []byte) { + b.t.Helper() + if err := os.WriteFile(b.path, content, 0o644); err != nil { + b.t.Fatalf("dépôt du fichier : %v", err) + } +} + +// scan advances the injected clock by one polling interval and lets the Hub turn. +func (b *realBench) scan() { + b.t.Helper() + b.clock.Advance(dropInterval) + b.flush() + b.flush() +} + +// flush runs one full turn of the loop and waits for its answer. +func (b *realBench) flush() { + b.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), hang) + defer cancel() + if _, err := b.hub.Submit(ctx, domain.Tick{}, ""); err != nil { + b.t.Fatalf("la boucle ne répond plus : %v", err) + } +} + +// awaitTiles scans until the grid in service holds exactly that many tiles. +func (b *realBench) awaitTiles(want int) *domain.Catalog { + b.t.Helper() + awaitCondition(b.t, func() bool { + b.scan() + return b.hub.Catalog() != nil && b.hub.Catalog().WeighableCount() == want + }, fmt.Sprintf("la grille n'a jamais compté %d tuiles", want)) + return b.hub.Catalog() +} + +// awaitFileGone scans until the dropped file has been acknowledged, which is to say +// ARCHIVED AND REMOVED (ADR-004). +func (b *realBench) awaitFileGone() { + b.t.Helper() + awaitCondition(b.t, func() bool { + b.scan() + _, err := os.Stat(b.path) + return errors.Is(err, os.ErrNotExist) + }, "le fichier déposé n'a jamais disparu : l'acquittement EST la suppression") +} + +// awaitImports scans until the history holds that many rows, most recent first. +func (b *realBench) awaitImports(want int) []domain.Import { + b.t.Helper() + awaitCondition(b.t, func() bool { + b.scan() + return len(b.imports()) >= want + }, fmt.Sprintf("l'historique n'a jamais compté %d import(s)", want)) + return b.imports() +} + +// imports reads the import history. +func (b *realBench) imports() []domain.Import { + b.t.Helper() + rows, err := b.db.Imports(context.Background(), 20, 0) + if err != nil { + b.t.Fatalf("Imports : %v", err) + } + return rows +} + +// archived lists what the archive directory holds, sorted. +func (b *realBench) archived() []string { + b.t.Helper() + entries, err := os.ReadDir(b.archives) + if err != nil { + b.t.Fatalf("lecture des archives : %v", err) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + // A « .part » is a copy IN FLIGHT, not an archive: Archive.Begin opens it before + // the parse and Commit is what turns it into one. Counting it as an archive made + // this bench read « a file was archived » where the truth was « a reading has + // started », and internal/catalog/archive.go already skips the same suffix when + // it prunes. + if strings.HasSuffix(entry.Name(), ".part") { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + return names +} + +// photoFiles counts the photos really written under /images. +func (b *realBench) photoFiles() int { + b.t.Helper() + n := 0 + err := filepath.WalkDir(b.images.Directory(), func(_ string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() { + n++ + } + return nil + }) + if err != nil { + b.t.Fatalf("parcours du puits d'images : %v", err) + } + return n +} + +// fixtureBytes reads one of the two authentic files. +func fixtureBytes(t *testing.T, name string) []byte { + t.Helper() + content, err := os.ReadFile(fixtures + name) + if err != nil { + t.Fatalf("lecture de la fixture %s : %v", name, err) + } + return content +} + +// digest is the sha256 of what was dropped, which is the key of the quarantine (§10.5). +func digest(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +// rows splits an exchange file into its lines, header included. +// +// Splitting on the separator is safe on these files and only on them: no name carries a +// quote or a semicolon, and the base64 alphabet has neither (§10.2). +func rows(t *testing.T, content []byte) []string { + t.Helper() + out := make([]string, 0, 400) + for _, line := range strings.Split(string(content), "\r\n") { + if line != "" { + out = append(out, line) + } + } + if len(out) < 2 { + t.Fatalf("%d ligne(s) dans le fichier : ce n'est pas un catalogue", len(out)) + } + return out +} + +// join puts the lines back together in the form the format declares: CRLF, and a final +// one, exactly as the producer writes it. +func join(lines []string) []byte { + return []byte(strings.Join(lines, "\r\n") + "\r\n") +} + +// identifier reports the Odoo id one line of the exchange file carries. +func identifier(line string) string { + fields := strings.Split(line, ";") + if len(fields) == 0 { + return "" + } + return strings.Trim(fields[0], `"`) +} + +// withoutProducts removes the lines of the products named, and only those. +func withoutProducts(t *testing.T, lines []string, ids map[string]bool) []string { + t.Helper() + out := make([]string, 0, len(lines)) + removed := 0 + for i, line := range lines { + if i > 0 && ids[identifier(line)] { + removed++ + continue + } + out = append(out, line) + } + if removed != len(ids) { + t.Fatalf("%d ligne(s) retirée(s) pour %d produits nommés", removed, len(ids)) + } + return out +} + +// touchLastName changes one product NAME and nothing else. +// +// It is what makes a second import a real one: the sha of the file changes, the +// qualification of every row does not, so whatever the grid then loses it lost for a +// reason that is not the file. +func touchLastName(t *testing.T, lines []string) []string { + t.Helper() + out := append([]string(nil), lines...) + last := len(out) - 1 + fields := strings.Split(out[last], ";") + if len(fields) != 7 { + t.Fatalf("la dernière ligne porte %d colonnes", len(fields)) + } + fields[1] = strings.TrimSuffix(fields[1], `"`) + ` BIS"` + out[last] = strings.Join(fields, ";") + return out +} + +// shiftColumns swaps `code-barre` and `prix` on every line, header included. +// +// That is what a column shift at the producer looks like, and it is the failure the +// relative guard exists for (§10.4b): every row is still perfectly readable, the file +// is still whole, the absolute guard sees nothing at all — and not one product is +// weighable any more. +func shiftColumns(t *testing.T, lines []string) []string { + t.Helper() + out := make([]string, 0, len(lines)) + for _, line := range lines { + fields := strings.Split(line, ";") + if len(fields) != 7 { + t.Fatalf("ligne à %d colonnes : %.40s", len(fields), line) + } + fields[2], fields[3] = fields[3], fields[2] + out = append(out, strings.Join(fields, ";")) + } + return out +} + +// linesWithLevel counts the technical lines carrying one code at one level. +func (b *realBench) linesWithLevel(code, level string) int { + b.technical.mu.Lock() + defer b.technical.mu.Unlock() + n := 0 + for _, e := range b.technical.entries { + if e.Code == code && e.Level == level { + n++ + } + } + return n +} + +// quarantine reads what stands against a content, or reports that nothing does. +func (b *realBench) quarantine(sha string) (domain.QuarantineEntry, bool) { + b.t.Helper() + entry, err := b.db.Quarantine(context.Background(), sha) + return entry, err == nil +} + +// pollByPoll is a REAL catalog source the test polls ONE scrutation at a time. +// +// # WHY THE TEST BELOW CANNOT USE THE CLOCK +// +// Failure test 8 writes a file that grows and asserts nothing was read. That assertion +// is only sound if EXACTLY ONE poll observes each size, and advancing the injected +// clock does not give that: it merely drops a tick into a channel of capacity one. The +// watch runs on its own goroutine (§13.1 n° 5), so nothing says the poll that tick +// triggers has happened before the test writes the NEXT size. +// +// When that poll lags one turn, two consecutive polls see the same bytes, the file is +// declared immobile, and what the test calls a violation is a perfectly correct read of +// a file that really had stopped moving. It turned CI red on 29/07/2026, with an +// archive AND its .reason.txt — the copy was being parsed while os.WriteFile truncated +// it underneath. No local stress reproduces it: it takes a loaded two-core runner. +// +// The rendezvous closes the window instead of widening it. `ask` is taken by the watch +// when it is about to poll and `done` is given back when that poll is over, so the test +// writes the next size while the watch is PARKED — which no timeout can promise. +type pollByPoll struct { + ports.CatalogSource + ask chan struct{} + done chan struct{} +} + +// Next performs exactly one poll per request from the test. +// +// The context handed down is already spent, which is what makes the real Next poll once +// and come back rather than wait for its own ticker. It costs the refusal path its +// quarantine write — and that is acceptable HERE and only here, because a refusal is +// precisely what this scenario asserts never happens. +func (p *pollByPoll) Next(ctx context.Context) (*ports.Batch, error) { + select { + case <-p.ask: + case <-ctx.Done(): + return nil, ctx.Err() + } + batch, err := p.CatalogSource.Next(spent(ctx)) + if errors.Is(err, context.Canceled) && ctx.Err() == nil { + // The cancellation is OURS, and it means « the poll is over, nothing found ». + err = nil + } + select { + case p.done <- struct{}{}: + case <-ctx.Done(): + } + return batch, err +} + +// once asks for one poll and does not return until that poll has finished. +func (p *pollByPoll) once(t *testing.T) { + t.Helper() + select { + case p.ask <- struct{}{}: + case <-time.After(hang): + t.Fatal("la veille du catalogue n'a jamais redemandé de scrutation") + } + select { + case <-p.done: + case <-time.After(hang): + t.Fatal("la scrutation demandée ne s'est jamais terminée") + } +} + +// spent returns a child context that is already done. +func spent(parent context.Context) context.Context { + ctx, cancel := context.WithCancel(parent) + cancel() + return ctx +} diff --git a/internal/station/failures_catalog_content_test.go b/internal/station/failures_catalog_content_test.go new file mode 100644 index 0000000..1aae87b --- /dev/null +++ b/internal/station/failures_catalog_content_test.go @@ -0,0 +1,380 @@ +package station + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// The catalog half of the recette of §16.2, lines 10, 12, 12 bis and 12 ter: what goes +// wrong in what a catalog CONTAINS, driven against the doubles — the same file twice, +// an amputated catalog, an ordinary one that must light nothing, and a product that +// leaves the file. The lines about READING a file are in failures_catalog_test.go. + +// --- 10: the same file twice ------------------------------------------------ + +// TestTheSameCatalogTwiceIsAppliedThenUnchanged is failure test 10, and it is +// important-2 written as an assertion. +// +// A producer may drop a byte-identical export every night. That is a NOMINAL +// outcome: two rows in `imports`, `applied` then `unchanged`, no red light and no +// quarantine. An earlier design turned it into a constraint violation, an aborted +// transaction, an unacknowledged file, a retry, and finally a permanent ban +// (ADR-015). +// +// TO REPLAY IN L7: the sha is computed as the file is read, and the comparison is +// against the last import whose result is `applied`. Both live in internal/catalog. +func TestTheSameCatalogTwiceIsAppliedThenUnchanged(t *testing.T) { + const sha = "sha-identique" + ctx := context.Background() + db := store.OpenTest(t) + + source := newDropFolder() + products := leeks(3) + b := newBench(t, func(o *benchOptions) { + o.source = source + o.applyCatalog = sameFileApplier(db, products) + }) + + for i := 0; i < 2; i++ { + source.drop(&ports.Batch{ + ID: sha, Source: domain.CatalogSourceLocalDrop, FileName: "flv_2.csv", + Products: products, RowsRead: len(products), + }) + } + acknowledged := source.awaitAcknowledgements(t, 2) + + if got := acknowledged[0].Result; got != domain.ImportApplied { + t.Fatalf("premier acquittement %q, attendu %q", got, domain.ImportApplied) + } + if got := acknowledged[1].Result; got != domain.ImportUnchanged { + t.Fatalf("second acquittement %q, attendu %q : un fichier déjà vu est un cas NOMINAL", + got, domain.ImportUnchanged) + } + + imports, err := db.Imports(ctx, 10, 0) + if err != nil { + t.Fatalf("Imports : %v", err) + } + if len(imports) != 2 { + t.Fatalf("%d ligne(s) dans imports, attendu 2 : l'historique est append-only", len(imports)) + } + // Most recent first. + if imports[0].Result != domain.ImportUnchanged || imports[1].Result != domain.ImportApplied { + t.Fatalf("résultats journalisés %q puis %q, attendu %q puis %q", + imports[1].Result, imports[0].Result, domain.ImportApplied, domain.ImportUnchanged) + } + if imports[0].SHA256 != sha || imports[1].SHA256 != sha { + t.Fatal("les deux lignes ne portent pas le sha du fichier déposé") + } + + if _, err := db.Quarantine(ctx, sha); err == nil { + t.Fatal("le fichier a été mis en quarantaine alors qu'il est valide et inchangé") + } + if b.technical.has("ERR-CAT-03") { + t.Fatal("ERR-CAT-03 journalisé pour un fichier valide déposé deux fois") + } + if level := worstTechnicalLevel(b.technical); level == domain.LevelError { + t.Fatal("un feu rouge s'est allumé sur un dépôt parfaitement nominal") + } +} + +// --- 12: an amputated catalog ------------------------------------------------ + +// TestAnAmputatedCatalogIsRefusedAndNamesItsReasons is failure test 12, and it is +// important-13. +// +// Forty per cent of the WEIGHABLE products gone from one import to the next is what a +// column shift at the producer looks like: the rows are still readable, the file is +// still whole, and the grid would empty. The batch is not applied, the catalog N−1 +// keeps serving, and the refusal names the three majority reasons WITH a line number +// — a report that says "40 % missing" is a filter, one that says what to fix is a +// work plan (§10.3 bis). +// +// TO REPLAY IN L7: the arithmetic itself — `max_weighable_drop` against the previous +// import — and the counting of the majority reasons. What is asserted here is that +// the station never puts a refused batch on the screen and never loses the reason. +func TestAnAmputatedCatalogIsRefusedAndNamesItsReasons(t *testing.T) { + initial := domain.NewCatalog(leeks(331), nil) + source := newDropFolder() + + drop, ok := loadConfig(t).Catalog.Options.Ratio("max_weighable_drop") + if !ok { + t.Fatal("max_weighable_drop absent de la configuration livrée") + } + + b := newBench(t, func(o *benchOptions) { + o.catalog = initial + o.source = source + o.applyCatalog = func(_ context.Context, cfg domain.Config, batch *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { + weighable := weighableCount(batch.Products) + previous := initial.WeighableCount() + if float64(weighable) < float64(previous)*(1-drop) { + return nil, ports.BatchResult{ + Result: domain.ImportRejected, Code: "ERR-CAT-03", + Reason: fmt.Sprintf( + "%d pesables reçus contre %d au dernier import ; motifs majoritaires : "+ + "INVALID_BARCODE (ligne 12), PREPACKAGED_PRODUCT (ligne 41), "+ + "NO_BARCODE (ligne 88)", weighable, previous), + }, errors.New("catalogue amputé") + } + return domain.NewCatalog(batch.Products, cfg.Catalog.Categories), + ports.BatchResult{Result: domain.ImportApplied}, nil + } + }) + + amputated := leeks(198) // 60 % of 331: the guard fires at 90 % + source.drop(&ports.Batch{ + ID: "sha-ampute", Source: domain.CatalogSourceLocalDrop, FileName: "flv_2.csv", + Products: amputated, RowsRead: len(amputated), + }) + acknowledged := source.awaitAcknowledgements(t, 1) + + if got := acknowledged[0].Result; got != domain.ImportRejected { + t.Fatalf("acquittement %q, attendu %q", got, domain.ImportRejected) + } + if got := acknowledged[0].Code; got != "ERR-CAT-03" { + t.Fatalf("code %q, attendu ERR-CAT-03", got) + } + for _, reason := range []string{"INVALID_BARCODE", "PREPACKAGED_PRODUCT", "NO_BARCODE", "ligne 12"} { + if !strings.Contains(acknowledged[0].Reason, reason) { + t.Fatalf("le motif du refus (%q) ne nomme pas %q", acknowledged[0].Reason, reason) + } + } + + b.advance(domain.MaxSwitchIdle) + if b.hub.Catalog() != initial { + t.Fatal("un catalogue amputé a pris service") + } + if got := b.hub.Catalog().WeighableCount(); got != 331 { + t.Fatalf("%d tuiles en service, attendu les 331 du catalogue N−1", got) + } + awaitTechnical(t, b.technical, "ERR-CAT-03", "aucune ligne technique : le feu rouge n'a rien à afficher") +} + +// --- 12 bis: an ordinary catalog lights nothing ------------------------------ + +// TestAnOrdinaryCatalogLightsNothingRed is failure test 12 bis, and it is the test +// that forbids the return of the calque. +// +// The two authentic inventories, dropped on a virgin station and then a second time. +// Nothing on the CLIENT screen — a prepackaged product is not an incident, and a +// banner that would be shown every day, all day, on the only screen anybody looks at +// is worse than no banner at all (§10.4). The tile count is the number of WEIGHABLE +// products and never the number of rows read. And the `A` filter yields 126 tiles, +// six more than the 120 slots of the legacy form: that assertion is what forbids a +// per-category ceiling from ever coming back through the window. +// +// TO REPLAY IN L7: the figures below are the document's, and the batches are built +// from them. Nothing in this repository parses flv.csv yet, and a test that invented +// a parser would be inventing its own answer. What L7 must show is that +// csvodoo.Read(flv.csv) produces EXACTLY these counts. +func TestAnOrdinaryCatalogLightsNothingRed(t *testing.T) { + cases := []struct { + file string + rows, weighable, notWeighable, anomalies, units int + otherRows, otherWeighable int + }{ + // 355 rows: 331 weighable (of which 1 carries a divergent unit), 8 not + // weighable, 16 anomalies. Category A: 140 rows, 126 of them weighable. + {"flv.csv", 355, 331, 8, 16, 1, 140, 126}, + // 153 rows: 107 weighable, 39 not weighable, 7 anomalies, 5 divergent units. + {"flv_1.csv", 153, 107, 39, 7, 5, 0, 0}, + } + + for _, c := range cases { + t.Run(c.file, func(t *testing.T) { + // The document's own arithmetic, checked against itself before anything + // else: a figure that does not add up would make every assertion below + // meaningless. + if c.weighable+c.notWeighable+c.anomalies != c.rows { + t.Fatalf("%d + %d + %d ≠ %d : l'inventaire du document ne se recompose pas", + c.weighable, c.notWeighable, c.anomalies, c.rows) + } + + products := inventory(c.rows, c.weighable, c.notWeighable, c.otherRows, c.otherWeighable) + source := newDropFolder() + b := newBench(t, func(o *benchOptions) { + o.catalog = nil // a virgin station: the grid says « Catalogue vide » + o.source = source + }) + + batch := &ports.Batch{ + ID: "sha-" + c.file, Source: domain.CatalogSourceLocalDrop, FileName: c.file, + Products: products, RowsRead: c.rows, + Findings: findings(c.anomalies, c.units), + } + source.drop(batch) + source.awaitAcknowledgements(t, 1) + + awaitCondition(t, func() bool { + b.advance(tickInterval) + return b.hub.Catalog() != nil && b.hub.Catalog().Len() == c.rows + }, "le premier catalogue n'a jamais pris service") + + catalog := b.hub.Catalog() + if got := catalog.WeighableCount(); got != c.weighable { + t.Fatalf("%d tuile(s), attendu %d : la grille compte les PESABLES, "+ + "jamais les lignes reçues", got, c.weighable) + } + if got := weighableIn(catalog, "other"); got != c.otherWeighable { + t.Fatalf("filtre A : %d tuile(s), attendu %d", got, c.otherWeighable) + } + + // Nothing on the client screen, and no red light. + s := b.hub.State() + if s.Message != nil { + t.Fatalf("bandeau client « %s » pour un catalogue ordinaire", s.Message.Text) + } + if s.State != domain.Idle { + t.Fatalf("état %s après un import nominal, attendu idle", s.State) + } + if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { + t.Fatal("un feu rouge s'est allumé sur un catalogue ordinaire") + } + + // The same file again. That it comes back `unchanged` is the sha + // comparison of §10.5, which belongs to the applier — failure test 10 + // carries one and asserts exactly that. What is asserted HERE is the + // other half of the sentence, and it is the one about the customer: a + // second drop changes nothing on the screen. + source.drop(batch) + source.awaitAcknowledgements(t, 2) + if b.hub.State().Message != nil { + t.Fatal("le second dépôt a affiché un bandeau client") + } + }) + } +} + +// --- 12 ter: a product leaves the file ---------------------------------------- + +// TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll is failure test 12 ter. +// +// A product absent from the new file is MARKED WITHDRAWN at a date, never deleted. It +// leaves the grid, and it keeps its weighing history, its local decision and its +// image. « Ce produit a disparu du CSV » becomes a fact the dashboard can show — +// « 4 produits retirés » — instead of a silence (§10.9). +func TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll(t *testing.T) { + ctx := context.Background() + db := store.OpenTest(t) + categories := loadConfig(t).Catalog.Categories + + full := leeks(8) + first, err := db.ReplaceCatalog(ctx, store.Batch{ + Import: domain.Import{ + OccurredAt: store.TestEpoch, Source: domain.CatalogSourceLocalDrop, + FileName: "flv_2.csv", SHA256: "sha-n", RowsRead: len(full), + Weighable: len(full), Result: domain.ImportApplied, + }, + Categories: categories, Products: full, + }) + if err != nil { + t.Fatalf("premier import : %v", err) + } + if first.Inserted != 8 { + t.Fatalf("%d insertion(s), attendu 8", first.Inserted) + } + + // One of the four about to disappear carries a weighing and a human decision. + doomed := full[4:] + weighing := domain.Weighing{ + OccurredAt: store.TestEpoch, Station: 2, JobID: "01J9F2ABC", + IdempotencyKey: "01J9F2ABC", ProductID: doomed[0].ID, ProductName: doomed[0].Name, + Reference: doomed[0].Reference, Mode: domain.ByWeight, + GrossWeight: 1236, NetWeight: 1236, Quantity: 1, Barcode: "0493021012365", + Source: domain.SourceScale, Stability: domain.Stable, Result: domain.ResultSent, + } + if err := db.RecordWeighing(ctx, &weighing); err != nil { + t.Fatalf("RecordWeighing : %v", err) + } + if err := db.SaveDecision(ctx, domain.LocalDecision{ + ProductID: doomed[0].ID, Offered: false, Reason: "prix faux chez Odoo", + DecidedAt: store.TestEpoch, DecidedBy: "bénévole", + }); err != nil { + t.Fatalf("SaveDecision : %v", err) + } + + // The next file is short of four rows. + second, err := db.ReplaceCatalog(ctx, store.Batch{ + Import: domain.Import{ + OccurredAt: store.TestEpoch, Source: domain.CatalogSourceLocalDrop, + FileName: "flv_2.csv", SHA256: "sha-n-plus-1", RowsRead: 4, + Weighable: 4, Result: domain.ImportApplied, + }, + Categories: categories, Products: full[:4], + }) + if err != nil { + t.Fatalf("second import : %v", err) + } + if second.Withdrawn != 4 { + t.Fatalf("%d produit(s) retirés, attendu 4 : « 4 produits retirés » est la phrase "+ + "que le tableau de bord doit pouvoir écrire", second.Withdrawn) + } + if second.Inserted != 0 || second.Updated != 4 { + t.Fatalf("outcome = %+v, attendu 0 insertion et 4 mises à jour", second) + } + + // The count reaches the import row, which is what the dashboard reads. + imports, err := db.Imports(ctx, 1, 0) + if err != nil || len(imports) == 0 { + t.Fatalf("Imports : %v", err) + } + if imports[0].ProductsWithdrawn != 4 { + t.Fatalf("products_withdrawn = %d dans la ligne d'import, attendu 4", + imports[0].ProductsWithdrawn) + } + + // The four are out of the grid, and none of them was destroyed. + catalog, err := db.LoadCatalog(ctx) + if err != nil { + t.Fatalf("LoadCatalog : %v", err) + } + // The four SURVIVORS are still there, and they are asserted first: a grid that + // withdrew everything would satisfy « the four that left are gone » without + // serving anybody. + if catalog.Len() != 4 { + t.Fatalf("%d produit(s) en grille, attendu les 4 que le fichier porte encore", + catalog.Len()) + } + for _, p := range full[:4] { + if _, offered := catalog.ByID(p.ID); !offered { + t.Fatalf("le produit %s a quitté la grille alors qu'il est toujours dans le fichier", p.ID) + } + } + for _, p := range doomed { + if _, offered := catalog.ByID(p.ID); offered { + t.Fatalf("le produit %s est encore dans la grille alors qu'il a disparu du fichier", p.ID) + } + row, err := db.Product(ctx, p.ID) + if err != nil { + t.Fatalf("le produit %s a été EFFACÉ : %v", p.ID, err) + } + if row.WithdrawnAt.IsZero() { + t.Fatalf("le produit %s n'a pas de date de retrait", p.ID) + } + } + + // Its weighing is still readable, and its decision still stands. + rows, err := db.Weighings(ctx, store.JournalFilter{Limit: 10}) + if err != nil { + t.Fatalf("Weighings : %v", err) + } + if len(rows) != 1 || rows[0].ProductID != doomed[0].ID { + t.Fatalf("%d pesée(s) lisibles : l'historique d'un produit retiré a disparu avec lui", + len(rows)) + } + decision, err := db.Decision(ctx, doomed[0].ID) + if err != nil { + t.Fatalf("la décision locale n'a pas survécu au retrait : %v", err) + } + if decision.Offered { + t.Fatal("la décision locale a été réécrite par l'import") + } +} diff --git a/internal/station/failures_catalog_doubles_test.go b/internal/station/failures_catalog_doubles_test.go new file mode 100644 index 0000000..055491e --- /dev/null +++ b/internal/station/failures_catalog_doubles_test.go @@ -0,0 +1,253 @@ +package station + +import ( + "context" + "fmt" + "sync" + "testing" + + "openscale/internal/domain" + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// What the seven catalog lines are played WITH: the drop folder that honours the +// contract of ports.CatalogSource without imitating internal/catalog, the applier that +// stands for the qualification, and the fixtures every one of them builds products out +// of. A double used by ONE line stays beside that line. + +// --- The doubles ----------------------------------------------------------- + +// dropFolder is the source a test drops files into, by hand. +// +// It stands for internal/catalog/localdrop without imitating it: what it honours is +// the contract of ports.CatalogSource, and in particular the one property the station +// depends on — acknowledgement is EXPLICIT, SEPARATE from reading, and comes last. +type dropFolder struct { + files chan *ports.Batch + + mu sync.Mutex + acked []ports.BatchResult + ackErr error +} + +func newDropFolder() *dropFolder { + return &dropFolder{files: make(chan *ports.Batch, 4)} +} + +// Name reports the registry key of the source. +func (s *dropFolder) Name() string { return domain.CatalogSourceLocalDrop } + +// Next blocks until a file is dropped or the context is done. +func (s *dropFolder) Next(ctx context.Context) (*ports.Batch, error) { + select { + case batch := <-s.files: + return batch, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Acknowledge records what the station did with a batch, and fails when the test +// asked it to — which is the read-only directory of failure test 11. +func (s *dropFolder) Acknowledge(_ context.Context, _ *ports.Batch, r ports.BatchResult) error { + s.mu.Lock() + defer s.mu.Unlock() + s.acked = append(s.acked, r) + return s.ackErr +} + +// Close stops watching the source. +func (s *dropFolder) Close() error { return nil } + +// drop puts one file in the folder. +func (s *dropFolder) drop(b *ports.Batch) { s.files <- b } + +// refuseToDelete makes every acknowledgement fail, as a read-only directory does. +func (s *dropFolder) refuseToDelete(err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.ackErr = err +} + +// acknowledgements returns a copy of what was acknowledged, oldest first. +func (s *dropFolder) acknowledgements() []ports.BatchResult { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ports.BatchResult, len(s.acked)) + copy(out, s.acked) + return out +} + +// awaitAcknowledgements waits for n files to have been acknowledged. +// awaitTechnical waits for a technical line to REACH THE SINK, and never merely for the +// act that produces it to have run. +// +// The two are not the same instant, and the whole reason this helper exists is that the +// gap between them is invisible on a fast machine. `Hub.logTechnical` (hub.go) hands the +// entry to a CHANNEL — a non-blocking send, so that journalling can never hold up the one +// goroutine that decides — and `journalWorker.run` (workers.go) drains it on ANOTHER +// goroutine. An acknowledgement therefore proves that `logTechnical` was CALLED; it +// proves nothing about the entry having been written where a test can read it. +// +// Read straight after the acknowledgement, `technical.has` was a race that this repository +// won six hundred times in a row locally and lost on a loaded CI runner: +// TestAnAmputatedCatalogIsRefusedAndNamesItsReasons, « aucune ligne technique : le feu +// rouge n'a rien à afficher ». Delaying the drain by 50 ms reproduces it every time, which +// is how it was found. +// +// The NEGATIVE readings around this file need no such wait: they assert that a line will +// never come, and waiting for it would only make them slower at being right. +// It takes the SINK and not the bench: the two harnesses of this package hold the same +// `*recordingTechnical`, and what is being waited on belongs to it. +func awaitTechnical(t *testing.T, technical *recordingTechnical, code, message string) { + t.Helper() + awaitCondition(t, func() bool { return technical.has(code) }, message) +} + +func (s *dropFolder) awaitAcknowledgements(t *testing.T, n int) []ports.BatchResult { + t.Helper() + awaitCondition(t, func() bool { return len(s.acknowledgements()) >= n }, + fmt.Sprintf("%d lot(s) acquitté(s), attendu %d", len(s.acknowledgements()), n)) + return s.acknowledgements() +} + +var _ ports.CatalogSource = (*dropFolder)(nil) + +// sameFileApplier is §10.5 as an applier: a sha already applied is not re-imported, +// it is recorded `unchanged` and acknowledged. +func sameFileApplier(db *store.DB, products []domain.Product) CatalogApplier { + return func(ctx context.Context, cfg domain.Config, batch *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { + last, err := db.LastAppliedImport(ctx) + if err == nil && last.SHA256 == batch.ID { + unchanged := domain.Import{ + OccurredAt: store.TestEpoch, Source: batch.Source, FileName: batch.FileName, + SHA256: batch.ID, RowsRead: batch.RowsRead, Result: domain.ImportUnchanged, + } + if _, err := db.RecordImport(ctx, unchanged, nil); err != nil { + return nil, ports.BatchResult{}, err + } + return nil, ports.BatchResult{Result: domain.ImportUnchanged}, nil + } + applied := domain.Import{ + OccurredAt: store.TestEpoch, Source: batch.Source, FileName: batch.FileName, + SHA256: batch.ID, RowsRead: batch.RowsRead, Weighable: len(products), + Result: domain.ImportApplied, + } + if _, err := db.ReplaceCatalog(ctx, store.Batch{ + Import: applied, Categories: cfg.Catalog.Categories, Products: products, + }); err != nil { + return nil, ports.BatchResult{}, err + } + return domain.NewCatalog(products, cfg.Catalog.Categories), + ports.BatchResult{Result: domain.ImportApplied}, nil + } +} + +// --- Fixtures --------------------------------------------------------------- + +// leeks builds n weighable products, all alike. +// +// The reference is thirteen characters because the schema demands zero or thirteen, +// and the ids start at 7001 so that no fixture of this package can collide with the +// garlic of §16.3. +func leeks(n int) []domain.Product { + out := make([]domain.Product, 0, n) + for i := 0; i < n; i++ { + out = append(out, domain.Product{ + ID: itoa(7001 + i), Name: "POIREAU " + itoa(i), Reference: "0493022000002", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, + CategoryCode: "vegetables", Qualification: domain.Weighable, CSVLine: i + 2, + }) + } + return out +} + +// inventory builds one file's worth of rows with the qualification mix of §10.4, and +// puts otherWeighable of the weighable ones in category A — « Autres », the one the +// legacy form could only show 120 of. +func inventory(rows, weighable, notWeighable, otherRows, otherWeighable int) []domain.Product { + out := make([]domain.Product, 0, rows) + for i := 0; i < rows; i++ { + p := domain.Product{ + ID: itoa(20 + i), Name: "PRODUIT " + itoa(i), Reference: "0493022000002", + Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, + CategoryCode: "vegetables", CSVLine: i + 2, + } + switch { + case i < weighable: + p.Qualification = domain.Weighable + case i < weighable+notWeighable: + p.Qualification, p.Reason = domain.NotWeighable, domain.FindingPrepackagedProduct + default: + p.Qualification, p.Reason = domain.Anomaly, domain.FindingReservedZoneNotEmpty + } + // Category A carries otherRows rows, otherWeighable of them weighable: the + // two figures differ, and that difference is the fourteen masked codes of + // §14.3. + if i < otherWeighable || (i >= weighable && i < weighable+(otherRows-otherWeighable)) { + p.CategoryCode = "other" + } + out = append(out, p) + } + return out +} + +// findings builds what an import has to say about the rows it read: anomalies to fix +// in Odoo, and divergent units that change nothing but a printed suffix. +func findings(anomalies, units int) []domain.Finding { + out := make([]domain.Finding, 0, anomalies+units) + for i := 0; i < anomalies; i++ { + out = append(out, domain.Finding{ + Code: domain.FindingReservedZoneNotEmpty, Issue: domain.IssueAnomaly, + CSVLine: i + 2, ProductID: itoa(20 + i), + Message: "Le code déborde sur la zone réservée au poids. À corriger dans Odoo.", + }) + } + for i := 0; i < units; i++ { + out = append(out, domain.Finding{ + Code: domain.FindingUnitMismatch, Issue: domain.IssueInfo, + CSVLine: 100 + i, ProductID: itoa(120 + i), + Message: "L'unité déclarée contredit le préfixe du code-barres.", + }) + } + return out +} + +// weighableCount reports how many of a batch's rows get a tile. +func weighableCount(products []domain.Product) int { + n := 0 + for _, p := range products { + if p.Qualification == domain.Weighable { + n++ + } + } + return n +} + +// weighableIn reports how many tiles one category holds. +func weighableIn(catalog *domain.Catalog, code string) int { + n := 0 + for _, p := range catalog.Products() { + if p.CategoryCode == code && p.Qualification == domain.Weighable { + n++ + } + } + return n +} + +// worstTechnicalLevel reports the most severe level journalled so far. +func worstTechnicalLevel(r *recordingTechnical) string { + r.mu.Lock() + defer r.mu.Unlock() + worst := domain.LevelInfo + for _, e := range r.entries { + if e.Level == domain.LevelError { + return domain.LevelError + } + if e.Level == domain.LevelWarn { + worst = domain.LevelWarn + } + } + return worst +} diff --git a/internal/station/failures_catalog_real_test.go b/internal/station/failures_catalog_real_test.go new file mode 100644 index 0000000..90ef64b --- /dev/null +++ b/internal/station/failures_catalog_real_test.go @@ -0,0 +1,600 @@ +package station + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/catalog" + "openscale/internal/domain" + "openscale/internal/station/ports" + "openscale/internal/store" +) + +// The same seven lines, replayed against internal/catalog AS IT IS SHIPPED. A failure +// test written against a double proves the double; these are the ones that prove the +// station. The bench they run on is in failures_catalog_bench_test.go. + +// TestACatalogFileStillGrowingIsNotReadAgainstTheRealDrop is failure test 8, replayed +// against the poll loop of internal/catalog/localdrop. +// +// The assertion that carries it is the ARCHIVE: the copy is written WHILE the file is +// read, so an empty archive directory is proof that nothing was read at all — and that +// is stronger than counting reads, because it is the artefact production leaves behind. +func TestACatalogFileStillGrowingIsNotReadAgainstTheRealDrop(t *testing.T) { + initial := garlicCatalog() + watch := &pollByPoll{ask: make(chan struct{}), done: make(chan struct{})} + b := newRealBench(t, func(o *realOptions) { + o.catalog = initial + o.wrap = func(s ports.CatalogSource) ports.CatalogSource { + watch.CatalogSource = s + return watch + } + }) + + lines := rows(t, fixtureBytes(t, "flv_1.csv")) + for _, upto := range []int{20, 60, 100, 140} { + b.dropContent(join(lines[:upto])) + // ONE poll, and it has finished when this returns: whatever it saw, it saw this + // size and no other. + watch.once(t) + b.flush() + // A COMMITTED archive is the proof that a file was read to the end and + // acknowledged. The « .part » of a copy in flight is not one, and counting it as + // such is what turned this red on a loaded runner the first time. + if names := b.archived(); len(names) != 0 { + t.Fatalf("archives %v : la copie est écrite PENDANT la lecture, donc un fichier "+ + "dont la taille bouge encore a été lu", names) + } + if _, err := os.Stat(b.path); err != nil { + t.Fatalf("le fichier a disparu sans avoir été acquitté : %v", err) + } + if b.hub.Catalog() != initial { + t.Fatal("le catalogue a été remplacé par un fichier en cours d'écriture") + } + } + + // Immobile at last. What takes service is the WHOLE file — 107 tiles — and never + // one of the four truncations above. Two identical polls are what the rule asks for, + // and the loop below is what grants them. + b.dropContent(join(lines)) + awaitCondition(t, func() bool { + watch.once(t) + b.flush() + return b.hub.Catalog() != nil && b.hub.Catalog().WeighableCount() == firstTiles + }, fmt.Sprintf("la grille n'a jamais compté %d tuiles", firstTiles)) + if grid := b.hub.Catalog(); grid.Len() != firstRows { + t.Errorf("%d produits en base, attendu les %d lignes du fichier", grid.Len(), firstRows) + } + awaitCondition(t, func() bool { + watch.once(t) + _, err := os.Stat(b.path) + return errors.Is(err, os.ErrNotExist) + }, "le fichier déposé n'a jamais disparu : l'acquittement EST la suppression") + if names := b.archived(); len(names) != 1 { + t.Errorf("archives %v, attendu une seule copie : celle du fichier complet", names) + } +} + +// TestACorruptedCatalogIsQuarantinedAgainstTheRealChain is failure test 9, replayed +// against the real parser, the real archive and the real quarantine table. +// +// Three drops of the same unusable content. Each one is set aside with its reason and +// REMOVED — leaving it would re-read the same broken file every five seconds for ever — +// the catalog in service is never touched, and the third failure is the one that turns +// the light red. +func TestACorruptedCatalogIsQuarantinedAgainstTheRealChain(t *testing.T) { + initial := garlicCatalog() + b := newRealBench(t, func(o *realOptions) { o.catalog = initial }) + + broken := []byte("\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") + sha := digest(broken) + + for attempt := 1; attempt <= 3; attempt++ { + b.dropContent(broken) + b.awaitFileGone() + entry, banned := b.quarantine(sha) + if !banned { + t.Fatalf("essai %d : rien en quarantaine sous le sha du fichier refusé", attempt) + } + if entry.FailureCount != attempt { + t.Fatalf("essai %d : %d échec(s) comptés", attempt, entry.FailureCount) + } + } + + entry, _ := b.quarantine(sha) + if entry.Code != "ERR-CAT-03" { + t.Errorf("code %q en quarantaine, attendu ERR-CAT-03", entry.Code) + } + threshold, ok := b.hub.Config().Catalog.Options.Int("failures_before_reject") + if !ok || int64(entry.FailureCount) < threshold { + t.Errorf("%d échecs pour un seuil de %d", entry.FailureCount, threshold) + } + // The catalog N−1 served throughout. + b.scan() + if b.hub.Catalog() != initial { + t.Fatal("un contenu refusé a remplacé le catalogue N−1") + } + // Three copies and three reasons: somebody has to be able to see it happened three + // times, without a database. + names := b.archived() + if len(names) != 6 { + t.Fatalf("archives %v, attendu trois copies et trois motifs", names) + } + reason, err := os.ReadFile(filepath.Join(b.archives, names[len(names)-1])) + if err != nil || !strings.Contains(string(reason), "ERR-CAT-03") { + t.Errorf("le motif archivé ne nomme pas le code : %s / %v", reason, err) + } + // The light only goes RED on the third refusal: a producer who corrects the file + // after one bad export must not find a station that has already given up. + if got := b.linesWithLevel("ERR-CAT-03", domain.LevelError); got != 1 { + t.Errorf("%d ligne(s) ERR-CAT-03 en niveau erreur, attendu 1 — celle du "+ + "troisième refus", got) + } +} + +// TestTheSameCatalogTwiceAgainstTheRealFile is failure test 10 on the authentic file. +// +// A producer may drop a byte-identical export every night: two rows in `imports`, +// `applied` then `unchanged`, no red light, no quarantine — and not one photo rewritten, +// because the sha IS the address (§10.7). +func TestTheSameCatalogTwiceAgainstTheRealFile(t *testing.T) { + b := newRealBench(t) + + b.drop("flv.csv") + b.awaitTiles(realTiles) + b.awaitFileGone() + written := b.photoFiles() + + b.drop("flv.csv") + b.awaitFileGone() + history := b.awaitImports(2) + + if len(history) != 2 { + t.Fatalf("%d ligne(s) dans imports, attendu 2 : l'historique est append-only", len(history)) + } + if history[1].Result != domain.ImportApplied || history[0].Result != domain.ImportUnchanged { + t.Fatalf("résultats %q puis %q, attendu %q puis %q", + history[1].Result, history[0].Result, domain.ImportApplied, domain.ImportUnchanged) + } + if history[0].SHA256 != history[1].SHA256 { + t.Error("les deux lignes ne portent pas le même sha") + } + if _, banned := b.quarantine(history[0].SHA256); banned { + t.Fatal("un catalogue valide déposé deux fois a été mis en quarantaine") + } + if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { + t.Fatal("déposer deux fois le même fichier a allumé un feu") + } + if b.hub.State().Message != nil { + t.Fatalf("bandeau client « %s » pour un second dépôt", b.hub.State().Message.Text) + } + if got := b.photoFiles(); got != written { + t.Errorf("%d photos sur le disque après le second dépôt, %d après le premier : "+ + "réimporter le même catalogue ne doit écrire aucun fichier", got, written) + } + if b.hub.Catalog().WeighableCount() != realTiles { + t.Errorf("%d tuiles après le second dépôt", b.hub.Catalog().WeighableCount()) + } +} + +// undeletable is a REAL local drop whose acknowledgement fails the way a read-only +// directory makes it fail. +// +// It is the one injection of this file, and it is at the syscall: no portable file +// system produces a file that can be read and not deleted — Windows clears a read-only +// attribute by itself, Unix decides by the directory. Everything else here is the real +// thing, and the source-side half of the line is exercised in +// internal/catalog/localdrop, where the seam lives. +type undeletable struct { + ports.CatalogSource + directory string +} + +// Acknowledge refuses, and never touches the file — which is exactly the state §16.2 +// line 11 describes: read, applied, still there. +func (u undeletable) Acknowledge(context.Context, *ports.Batch, ports.BatchResult) error { + return fmt.Errorf("%w : droits en écriture manquants sur %s pour le compte balance", + catalog.ErrNotAcknowledged, u.directory) +} + +// TestACatalogFileThatCannotBeDeletedAgainstTheRealApplier is failure test 11, and the +// trap of §16.2 line 11. +// +// The file was read and APPLIED; only its removal failed. That is ERR-CAT-05, an amber +// light, and it must NEVER count against the quarantine: the file is not corrupted, the +// directory is. A red light that fires wrongly is the worst enemy of operations, +// because after three false alarms the team stops looking at the lights. +func TestACatalogFileThatCannotBeDeletedAgainstTheRealApplier(t *testing.T) { + initial := garlicCatalog() + b := newRealBench(t, func(o *realOptions) { + o.catalog = initial + o.wrap = func(s ports.CatalogSource) ports.CatalogSource { + return undeletable{CatalogSource: s, directory: `\\serveur\balance\`} + } + }) + + content := fixtureBytes(t, "flv_1.csv") + b.dropContent(content) + + // The catalog it carried takes service all the same. + b.awaitTiles(firstTiles) + awaitCondition(t, func() bool { + b.scan() + return b.technical.has("ERR-CAT-05") + }, "ERR-CAT-05 n'a pas été journalisé alors que le fichier n'a pas pu être supprimé") + + if b.technical.has("ERR-CAT-03") { + t.Fatal("ERR-CAT-03 journalisé : une suppression impossible a été prise pour un " + + "échec de contenu") + } + if _, banned := b.quarantine(digest(content)); banned { + t.Fatal("le contenu a été mis en quarantaine alors que seule sa suppression a échoué") + } + if _, err := os.Stat(b.path); err != nil { + t.Fatalf("le fichier a disparu : %v", err) + } + // It is read again and again, and every re-reading is `unchanged`: a station whose + // share is read-only keeps serving, and nothing accumulates but history. + // + // The applied one is named BY ITS IDENTITY and not by its rank, because the number of + // re-readings is bounded by nothing at all: `Next` polls the moment it is entered, so + // a file nobody can delete is read again as fast as the loop turns. Twenty rows — + // the window `imports()` reads, which is the window the screen shows — scroll past in + // well under a second on a slow machine, and « le premier import » silently became + // « le vingtième ». Measured here: two rows on this machine, more than twenty as soon + // as anything delays the test. + applied, err := b.db.LastAppliedImport(context.Background()) + if err != nil { + t.Fatalf("aucun import appliqué : la première lecture n'a rien mis en service : %v", err) + } + for _, row := range b.awaitImports(2) { + if row.ID == applied.ID { + continue + } + if row.Result != domain.ImportUnchanged { + t.Fatalf("import %d journalisé %q, attendu %q : une seule lecture applique, "+ + "toutes les autres sont des relectures", row.ID, row.Result, domain.ImportUnchanged) + } + } +} + +// TestAnAmputatedCatalogIsRefusedAgainstTheRealGuard is failure test 12, replayed on a +// column shift of the AUTHENTIC file. +// +// Every one of the 355 rows is still perfectly readable, so the absolute guard of +// §10.4a sees nothing at all; not one product is weighable any more, which is exactly +// the grandeur the relative guard watches. +func TestAnAmputatedCatalogIsRefusedAgainstTheRealGuard(t *testing.T) { + b := newRealBench(t) + b.drop("flv.csv") + b.awaitTiles(realTiles) + b.awaitFileGone() + + shifted := join(shiftColumns(t, rows(t, fixtureBytes(t, "flv.csv")))) + b.dropContent(shifted) + b.awaitFileGone() + history := b.awaitImports(2) + + refused := history[0] + if refused.Result != domain.ImportRejected || refused.Code != "ERR-CAT-03" { + t.Fatalf("ligne d'import %+v, attendu un refus ERR-CAT-03", refused) + } + if refused.RowsRead != realRows || refused.UnreadableRows != 0 { + t.Errorf("%d lignes lues dont %d illisibles : le garde ABSOLU ne voit rien, "+ + "c'est le garde RELATIF qui doit refuser", refused.RowsRead, refused.UnreadableRows) + } + if refused.Weighable != 0 { + t.Errorf("%d pesables après un décalage de colonne", refused.Weighable) + } + for _, expected := range []string{ + "0 produit pesable reçu contre 331", "90 %", + domain.FindingPriceUnreadable, "par exemple ligne", "reste en service", + } { + if !strings.Contains(refused.Reason, expected) { + t.Errorf("le motif du refus ne contient pas %q :\n%s", expected, refused.Reason) + } + } + // The catalog N−1 kept serving, whole. + b.scan() + if got := b.hub.Catalog().WeighableCount(); got != realTiles { + t.Fatalf("%d tuiles en service, attendu les %d du catalogue N−1", got, realTiles) + } + // A content failure, so it counts — and the reason is next to the archived copy. + entry, banned := b.quarantine(digest(shifted)) + if !banned || entry.FailureCount != 1 { + t.Errorf("quarantaine %+v", entry) + } + if !hasReason(b.archived()) { + t.Errorf("aucun .reason.txt à côté de la copie refusée : %v", b.archived()) + } + awaitTechnical(t, b.technical, "ERR-CAT-03", "aucune ligne technique : le feu rouge n'a rien à afficher") +} + +// hasReason reports whether a refusal left its explanation next to a copy. +func hasReason(names []string) bool { + for _, name := range names { + if strings.HasSuffix(name, ".reason.txt") { + return true + } + } + return false +} + +// TestAnOrdinaryCatalogLightsNothingRedAgainstTheRealFiles is failure test 12 bis and +// the acceptance criterion of §18, on the two AUTHENTIC files and nothing else. +// +// Every figure below was MEASURED on the files of this repository. Two of them correct +// the document, and the correction is arithmetic rather than opinion: §18 says « 331 +// tuiles dont 181 avec photo et 174 sans » and §16.2 says « 331 tuiles dont 174 sans +// photo », but 181 + 174 = 355, which is the number of ROWS. Of the 331 TILES, 177 +// carry a photo and 154 do not. +func TestAnOrdinaryCatalogLightsNothingRedAgainstTheRealFiles(t *testing.T) { + for _, c := range []struct { + file string + rowsRead, tiles, notWeighable, issues, units int + photoRows, photoFiles int + tilesWithPhoto, tilesWithoutPhoto, otherTiles int + }{ + {"flv.csv", realRows, realTiles, realNotWeighable, realIssues, realUnitMismatch, + realPhotoRows, realPhotoFiles, realTilesWithPhoto, realTilesWithoutPhoto, realOtherTiles}, + {"flv_1.csv", firstRows, firstTiles, 39, 7, 5, 0, 0, 0, firstTiles, 1}, + } { + t.Run(c.file, func(t *testing.T) { + // The inventory has to recompose, or every assertion below means nothing. + if c.tiles+c.notWeighable+c.issues != c.rowsRead { + t.Fatalf("%d + %d + %d ≠ %d", c.tiles, c.notWeighable, c.issues, c.rowsRead) + } + b := newRealBench(t) // a virgin station: the grid says « Catalogue vide » + b.drop(c.file) + grid := b.awaitTiles(c.tiles) + b.awaitFileGone() + + if grid.Len() != c.rowsRead { + t.Errorf("%d produits en base, attendu les %d lignes reçues : un préemballé "+ + "est une ligne, il n'a simplement pas de tuile", grid.Len(), c.rowsRead) + } + if got := weighableIn(grid, "other"); got != c.otherTiles { + t.Errorf("filtre « Autres » : %d tuiles, attendu %d", got, c.otherTiles) + } + + // A tile without a photo is NORMAL and makes no hole: the two counts add up + // to the number of tiles, so no product was dropped for want of a picture. + with, without := 0, 0 + for _, p := range grid.Products() { + if p.Qualification != domain.Weighable { + continue + } + if p.ImageSHA != "" { + with++ + } else { + without++ + } + } + if with != c.tilesWithPhoto || without != c.tilesWithoutPhoto { + t.Errorf("%d tuiles avec photo et %d sans, attendu %d et %d", + with, without, c.tilesWithPhoto, c.tilesWithoutPhoto) + } + if with+without != c.tiles { + t.Fatalf("%d + %d ≠ %d : une tuile a été perdue faute de photo", + with, without, c.tiles) + } + if got := b.photoFiles(); got != c.photoFiles { + t.Errorf("%d photos écrites sur le disque, attendu %d", got, c.photoFiles) + } + + // The inventory the administration screen shows: 355 · 331 · 8 · 16. + history := b.imports() + if len(history) != 1 { + t.Fatalf("%d ligne(s) d'import", len(history)) + } + row := history[0] + if row.RowsRead != c.rowsRead || row.Weighable != c.tiles || + row.NotWeighable != c.notWeighable || row.Anomalies != c.issues { + t.Errorf("inventaire %d · %d · %d · %d, attendu %d · %d · %d · %d", + row.RowsRead, row.Weighable, row.NotWeighable, row.Anomalies, + c.rowsRead, c.tiles, c.notWeighable, c.issues) + } + if row.UnitMismatches != c.units || row.ImagesDecoded != c.photoRows { + t.Errorf("%d unités divergentes et %d images décodées, attendu %d et %d", + row.UnitMismatches, row.ImagesDecoded, c.units, c.photoRows) + } + if row.ImagesRejected != 0 { + t.Errorf("%d photo(s) refusée(s) sur un fichier authentique", row.ImagesRejected) + } + // The anomalies are NAMED, one line each: a report that says « 16 anomalies » + // is a filter, one that says which row to fix is a work plan (§10.3 bis). + findings, err := b.db.Findings(context.Background(), row.ID) + if err != nil { + t.Fatalf("Findings : %v", err) + } + anomalies := 0 + for _, f := range findings { + if f.Issue != domain.IssueAnomaly { + continue + } + anomalies++ + if f.CSVLine == 0 || f.ProductID == "" || f.Message == "" { + t.Fatalf("signalement sans où/quoi/pourquoi : %+v", f) + } + } + if anomalies != c.issues { + t.Errorf("%d signalements d'anomalie conservés, attendu %d", anomalies, c.issues) + } + + // Nothing on the CLIENT screen, and no red light. + if s := b.hub.State(); s.Message != nil { + t.Fatalf("bandeau client « %s » pour un catalogue ordinaire", s.Message.Text) + } + if s := b.hub.State(); s.State != domain.Idle { + t.Fatalf("état %s après un import nominal", s.State) + } + if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { + t.Fatal("un feu rouge s'est allumé sur un catalogue ordinaire") + } + + // The same file again changes nothing at all. + b.drop(c.file) + b.awaitFileGone() + b.awaitImports(2) + if b.hub.State().Message != nil { + t.Fatal("le second dépôt a affiché un bandeau client") + } + if got := b.hub.Catalog().WeighableCount(); got != c.tiles { + t.Errorf("%d tuiles après le second dépôt", got) + } + }) + } +} + +// TestAProductThatLeavesTheFileAgainstTheRealChain is failure test 12 ter on the +// authentic file. +// +// A product absent from the new file is MARKED WITHDRAWN at a date, never deleted. It +// leaves the grid and keeps its weighing history, its local decision and its image — +// « 4 produits retirés » becomes a fact the dashboard can show instead of a silence. +func TestAProductThatLeavesTheFileAgainstTheRealChain(t *testing.T) { + ctx := context.Background() + b := newRealBench(t) + b.drop("flv.csv") + grid := b.awaitTiles(realTiles) + b.awaitFileGone() + + // Four tiles about to disappear, and one of them carries a weighing and a decision. + doomed := make([]domain.Product, 0, 4) + for _, p := range grid.Products() { + if p.Qualification == domain.Weighable && len(doomed) < 4 { + doomed = append(doomed, p) + } + } + ids := map[string]bool{} + for _, p := range doomed { + ids[p.ID] = true + } + weighing := domain.Weighing{ + OccurredAt: store.TestEpoch, Station: 2, JobID: "01J9F2ABC", + IdempotencyKey: "01J9F2ABC", ProductID: doomed[0].ID, ProductName: doomed[0].Name, + Reference: doomed[0].Reference, Mode: doomed[0].Mode, + GrossWeight: 1236, NetWeight: 1236, Quantity: 1, Barcode: "0493021012365", + Source: domain.SourceScale, Stability: domain.Stable, Result: domain.ResultSent, + } + if err := b.db.RecordWeighing(ctx, &weighing); err != nil { + t.Fatalf("RecordWeighing : %v", err) + } + waiver := domain.Grams(8) + decision := domain.LocalDecision{ + ProductID: doomed[0].ID, Offered: false, MinWeightG: &waiver, + Reason: "prix faux chez Odoo", DecidedAt: store.TestEpoch, DecidedBy: "bénévole", + } + if err := b.db.SaveDecision(ctx, decision); err != nil { + t.Fatalf("SaveDecision : %v", err) + } + + // The next file is short of those four rows. + b.dropContent(join(withoutProducts(t, rows(t, fixtureBytes(t, "flv.csv")), ids))) + b.awaitFileGone() + history := b.awaitImports(2) + + if history[0].ProductsWithdrawn != 4 { + t.Fatalf("%d produit(s) retirés dans la ligne d'import, attendu 4 : « 4 produits "+ + "retirés » est la phrase que le tableau de bord doit pouvoir écrire", + history[0].ProductsWithdrawn) + } + // The survivors first: a grid that withdrew everything would satisfy « the four + // that left are gone » without serving anybody. + if got := b.hub.Catalog().Len(); got != realRows-4 { + t.Fatalf("%d produits en grille, attendu %d", got, realRows-4) + } + for _, p := range doomed { + if _, offered := b.hub.Catalog().ByID(p.ID); offered { + t.Fatalf("le produit %s est encore en grille alors qu'il a quitté le fichier", p.ID) + } + row, err := b.db.Product(ctx, p.ID) + if err != nil { + t.Fatalf("le produit %s a été EFFACÉ : %v", p.ID, err) + } + if row.WithdrawnAt.IsZero() { + t.Fatalf("le produit %s n'a pas de date de retrait", p.ID) + } + } + // Its weighing is still readable, and its decision still stands — both columns. + journal, err := b.db.Weighings(ctx, store.JournalFilter{Limit: 10}) + if err != nil || len(journal) != 1 || journal[0].ProductID != doomed[0].ID { + t.Fatalf("%d pesée(s) lisibles : l'historique d'un produit retiré a disparu avec lui : %v", + len(journal), err) + } + kept, err := b.db.Decision(ctx, doomed[0].ID) + if err != nil { + t.Fatalf("la décision locale n'a pas survécu au retrait : %v", err) + } + if kept.Offered || kept.MinWeightG == nil || *kept.MinWeightG != waiver || + kept.Reason != decision.Reason { + t.Errorf("décision relue %+v", kept) + } +} + +// TestALocalDecisionSurvivesTheNextImport is §10.6, and the sentence of §18 it settles: +// « sans que la table ait à survivre à quoi que ce soit ». +// +// « Ne plus proposer ce produit » and the light-product waiver are two COLUMNS of one +// decision, not two mechanisms, and an import is an upsert that does not touch them. +func TestALocalDecisionSurvivesTheNextImport(t *testing.T) { + ctx := context.Background() + b := newRealBench(t) + b.drop("flv.csv") + grid := b.awaitTiles(realTiles) + b.awaitFileGone() + + var chosen domain.Product + for _, p := range grid.Products() { + if p.Qualification == domain.Weighable { + chosen = p + break + } + } + waiver := domain.Grams(8) + decision := domain.LocalDecision{ + ProductID: chosen.ID, Offered: false, MinWeightG: &waiver, + Reason: "code appartenant à un autre article", DecidedAt: store.TestEpoch, + DecidedBy: "bénévole", + } + if err := b.db.SaveDecision(ctx, decision); err != nil { + t.Fatalf("SaveDecision : %v", err) + } + + // The next export carries that product again, unchanged. The decision is a + // JUDGEMENT, and no import overwrites one. + b.dropContent(join(touchLastName(t, rows(t, fixtureBytes(t, "flv.csv"))))) + b.awaitFileGone() + b.awaitImports(2) + + grid = b.awaitTiles(realTiles - 1) + if _, offered := grid.ByID(chosen.ID); offered { + t.Fatalf("le produit %s (%s) est revenu en grille avec l'import suivant", + chosen.ID, chosen.Name) + } + kept, err := b.db.Decision(ctx, chosen.ID) + if err != nil { + t.Fatalf("la décision a disparu avec l'import : %v", err) + } + if kept.Offered { + t.Fatal("l'import a remis le produit en vente") + } + if kept.MinWeightG == nil || *kept.MinWeightG != waiver { + t.Errorf("la dérogation « produit léger » n'a pas survécu : %+v", kept.MinWeightG) + } + if kept.Reason != decision.Reason || !kept.DecidedAt.Equal(decision.DecidedAt) || + kept.DecidedBy != decision.DecidedBy { + t.Errorf("décision relue %+v", kept) + } + // The product itself is still in the catalog, not withdrawn: it is in the file, a + // human simply stopped offering it. + row, err := b.db.Product(ctx, chosen.ID) + if err != nil || !row.WithdrawnAt.IsZero() { + t.Errorf("produit %s marqué retiré alors qu'il est dans le fichier : %v", chosen.ID, err) + } +} diff --git a/internal/station/failures_catalog_test.go b/internal/station/failures_catalog_test.go index 77d3e8f..d215b75 100644 --- a/internal/station/failures_catalog_test.go +++ b/internal/station/failures_catalog_test.go @@ -2,24 +2,11 @@ package station import ( "context" - "crypto/sha256" - "encoding/hex" "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" "sync" "testing" - "time" - "openscale/internal/catalog" - "openscale/internal/catalog/importer" - "openscale/internal/catalog/localdrop" "openscale/internal/domain" - "openscale/internal/fake" "openscale/internal/station/ports" "openscale/internal/store" ) @@ -49,104 +36,13 @@ import ( // produces: Windows clears a read-only attribute by itself and Unix decides by the // directory. The half of that line that lives in this package is real all the same, and // the other half is exercised in internal/catalog/localdrop, where the seam is. - -// --- The doubles ----------------------------------------------------------- - -// dropFolder is the source a test drops files into, by hand. -// -// It stands for internal/catalog/localdrop without imitating it: what it honours is -// the contract of ports.CatalogSource, and in particular the one property the station -// depends on — acknowledgement is EXPLICIT, SEPARATE from reading, and comes last. -type dropFolder struct { - files chan *ports.Batch - - mu sync.Mutex - acked []ports.BatchResult - ackErr error -} - -func newDropFolder() *dropFolder { - return &dropFolder{files: make(chan *ports.Batch, 4)} -} - -// Name reports the registry key of the source. -func (s *dropFolder) Name() string { return domain.CatalogSourceLocalDrop } - -// Next blocks until a file is dropped or the context is done. -func (s *dropFolder) Next(ctx context.Context) (*ports.Batch, error) { - select { - case batch := <-s.files: - return batch, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// Acknowledge records what the station did with a batch, and fails when the test -// asked it to — which is the read-only directory of failure test 11. -func (s *dropFolder) Acknowledge(_ context.Context, _ *ports.Batch, r ports.BatchResult) error { - s.mu.Lock() - defer s.mu.Unlock() - s.acked = append(s.acked, r) - return s.ackErr -} - -// Close stops watching the source. -func (s *dropFolder) Close() error { return nil } - -// drop puts one file in the folder. -func (s *dropFolder) drop(b *ports.Batch) { s.files <- b } - -// refuseToDelete makes every acknowledgement fail, as a read-only directory does. -func (s *dropFolder) refuseToDelete(err error) { - s.mu.Lock() - defer s.mu.Unlock() - s.ackErr = err -} - -// acknowledgements returns a copy of what was acknowledged, oldest first. -func (s *dropFolder) acknowledgements() []ports.BatchResult { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]ports.BatchResult, len(s.acked)) - copy(out, s.acked) - return out -} - -// awaitAcknowledgements waits for n files to have been acknowledged. -// awaitTechnical waits for a technical line to REACH THE SINK, and never merely for the -// act that produces it to have run. -// -// The two are not the same instant, and the whole reason this helper exists is that the -// gap between them is invisible on a fast machine. `Hub.logTechnical` (hub.go) hands the -// entry to a CHANNEL — a non-blocking send, so that journalling can never hold up the one -// goroutine that decides — and `journalWorker.run` (workers.go) drains it on ANOTHER -// goroutine. An acknowledgement therefore proves that `logTechnical` was CALLED; it -// proves nothing about the entry having been written where a test can read it. -// -// Read straight after the acknowledgement, `technical.has` was a race that this repository -// won six hundred times in a row locally and lost on a loaded CI runner: -// TestAnAmputatedCatalogIsRefusedAndNamesItsReasons, « aucune ligne technique : le feu -// rouge n'a rien à afficher ». Delaying the drain by 50 ms reproduces it every time, which -// is how it was found. // -// The NEGATIVE readings around this file need no such wait: they assert that a line will -// never come, and waiting for it would only make them slower at being right. -// It takes the SINK and not the bench: the two harnesses of this package hold the same -// `*recordingTechnical`, and what is being waited on belongs to it. -func awaitTechnical(t *testing.T, technical *recordingTechnical, code, message string) { - t.Helper() - awaitCondition(t, func() bool { return technical.has(code) }, message) -} - -func (s *dropFolder) awaitAcknowledgements(t *testing.T, n int) []ports.BatchResult { - t.Helper() - awaitCondition(t, func() bool { return len(s.acknowledgements()) >= n }, - fmt.Sprintf("%d lot(s) acquitté(s), attendu %d", len(s.acknowledgements()), n)) - return s.acknowledgements() -} - -var _ ports.CatalogSource = (*dropFolder)(nil) +// THE FILE HAS BEEN SPLIT ALONG ITS OWN JOINTS. What is left here is the READING half +// against the doubles — lines 8, 9 and 11, where a file is being written, is corrupted, +// or cannot be deleted. The half about what a catalog CONTAINS is in +// failures_catalog_content_test.go, the doubles and fixtures both share in +// failures_catalog_doubles_test.go, and the second half — the same seven against the +// real chain — in failures_catalog_bench_test.go and failures_catalog_real_test.go. // --- 8: a file the producer is still writing ------------------------------- @@ -359,104 +255,6 @@ func TestACorruptedCatalogIsQuarantinedAndNMinusOneServesOn(t *testing.T) { } } -// --- 10: the same file twice ------------------------------------------------ - -// TestTheSameCatalogTwiceIsAppliedThenUnchanged is failure test 10, and it is -// important-2 written as an assertion. -// -// A producer may drop a byte-identical export every night. That is a NOMINAL -// outcome: two rows in `imports`, `applied` then `unchanged`, no red light and no -// quarantine. An earlier design turned it into a constraint violation, an aborted -// transaction, an unacknowledged file, a retry, and finally a permanent ban -// (ADR-015). -// -// TO REPLAY IN L7: the sha is computed as the file is read, and the comparison is -// against the last import whose result is `applied`. Both live in internal/catalog. -func TestTheSameCatalogTwiceIsAppliedThenUnchanged(t *testing.T) { - const sha = "sha-identique" - ctx := context.Background() - db := store.OpenTest(t) - - source := newDropFolder() - products := leeks(3) - b := newBench(t, func(o *benchOptions) { - o.source = source - o.applyCatalog = sameFileApplier(db, products) - }) - - for i := 0; i < 2; i++ { - source.drop(&ports.Batch{ - ID: sha, Source: domain.CatalogSourceLocalDrop, FileName: "flv_2.csv", - Products: products, RowsRead: len(products), - }) - } - acknowledged := source.awaitAcknowledgements(t, 2) - - if got := acknowledged[0].Result; got != domain.ImportApplied { - t.Fatalf("premier acquittement %q, attendu %q", got, domain.ImportApplied) - } - if got := acknowledged[1].Result; got != domain.ImportUnchanged { - t.Fatalf("second acquittement %q, attendu %q : un fichier déjà vu est un cas NOMINAL", - got, domain.ImportUnchanged) - } - - imports, err := db.Imports(ctx, 10, 0) - if err != nil { - t.Fatalf("Imports : %v", err) - } - if len(imports) != 2 { - t.Fatalf("%d ligne(s) dans imports, attendu 2 : l'historique est append-only", len(imports)) - } - // Most recent first. - if imports[0].Result != domain.ImportUnchanged || imports[1].Result != domain.ImportApplied { - t.Fatalf("résultats journalisés %q puis %q, attendu %q puis %q", - imports[1].Result, imports[0].Result, domain.ImportApplied, domain.ImportUnchanged) - } - if imports[0].SHA256 != sha || imports[1].SHA256 != sha { - t.Fatal("les deux lignes ne portent pas le sha du fichier déposé") - } - - if _, err := db.Quarantine(ctx, sha); err == nil { - t.Fatal("le fichier a été mis en quarantaine alors qu'il est valide et inchangé") - } - if b.technical.has("ERR-CAT-03") { - t.Fatal("ERR-CAT-03 journalisé pour un fichier valide déposé deux fois") - } - if level := worstTechnicalLevel(b.technical); level == domain.LevelError { - t.Fatal("un feu rouge s'est allumé sur un dépôt parfaitement nominal") - } -} - -// sameFileApplier is §10.5 as an applier: a sha already applied is not re-imported, -// it is recorded `unchanged` and acknowledged. -func sameFileApplier(db *store.DB, products []domain.Product) CatalogApplier { - return func(ctx context.Context, cfg domain.Config, batch *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { - last, err := db.LastAppliedImport(ctx) - if err == nil && last.SHA256 == batch.ID { - unchanged := domain.Import{ - OccurredAt: store.TestEpoch, Source: batch.Source, FileName: batch.FileName, - SHA256: batch.ID, RowsRead: batch.RowsRead, Result: domain.ImportUnchanged, - } - if _, err := db.RecordImport(ctx, unchanged, nil); err != nil { - return nil, ports.BatchResult{}, err - } - return nil, ports.BatchResult{Result: domain.ImportUnchanged}, nil - } - applied := domain.Import{ - OccurredAt: store.TestEpoch, Source: batch.Source, FileName: batch.FileName, - SHA256: batch.ID, RowsRead: batch.RowsRead, Weighable: len(products), - Result: domain.ImportApplied, - } - if _, err := db.ReplaceCatalog(ctx, store.Batch{ - Import: applied, Categories: cfg.Catalog.Categories, Products: products, - }); err != nil { - return nil, ports.BatchResult{}, err - } - return domain.NewCatalog(products, cfg.Catalog.Categories), - ports.BatchResult{Result: domain.ImportApplied}, nil - } -} - // --- 11: a file that cannot be deleted -------------------------------------- // TestACatalogFileThatCannotBeDeletedIsAmberAndNotBanned is failure test 11, and it @@ -510,1435 +308,3 @@ func TestACatalogFileThatCannotBeDeletedIsAmberAndNotBanned(t *testing.T) { return b.hub.Catalog() != initial }, "le catalogue lu n'a pas pris service parce que son fichier n'a pas pu être supprimé") } - -// --- 12: an amputated catalog ------------------------------------------------ - -// TestAnAmputatedCatalogIsRefusedAndNamesItsReasons is failure test 12, and it is -// important-13. -// -// Forty per cent of the WEIGHABLE products gone from one import to the next is what a -// column shift at the producer looks like: the rows are still readable, the file is -// still whole, and the grid would empty. The batch is not applied, the catalog N−1 -// keeps serving, and the refusal names the three majority reasons WITH a line number -// — a report that says "40 % missing" is a filter, one that says what to fix is a -// work plan (§10.3 bis). -// -// TO REPLAY IN L7: the arithmetic itself — `max_weighable_drop` against the previous -// import — and the counting of the majority reasons. What is asserted here is that -// the station never puts a refused batch on the screen and never loses the reason. -func TestAnAmputatedCatalogIsRefusedAndNamesItsReasons(t *testing.T) { - initial := domain.NewCatalog(leeks(331), nil) - source := newDropFolder() - - drop, ok := loadConfig(t).Catalog.Options.Ratio("max_weighable_drop") - if !ok { - t.Fatal("max_weighable_drop absent de la configuration livrée") - } - - b := newBench(t, func(o *benchOptions) { - o.catalog = initial - o.source = source - o.applyCatalog = func(_ context.Context, cfg domain.Config, batch *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { - weighable := weighableCount(batch.Products) - previous := initial.WeighableCount() - if float64(weighable) < float64(previous)*(1-drop) { - return nil, ports.BatchResult{ - Result: domain.ImportRejected, Code: "ERR-CAT-03", - Reason: fmt.Sprintf( - "%d pesables reçus contre %d au dernier import ; motifs majoritaires : "+ - "INVALID_BARCODE (ligne 12), PREPACKAGED_PRODUCT (ligne 41), "+ - "NO_BARCODE (ligne 88)", weighable, previous), - }, errors.New("catalogue amputé") - } - return domain.NewCatalog(batch.Products, cfg.Catalog.Categories), - ports.BatchResult{Result: domain.ImportApplied}, nil - } - }) - - amputated := leeks(198) // 60 % of 331: the guard fires at 90 % - source.drop(&ports.Batch{ - ID: "sha-ampute", Source: domain.CatalogSourceLocalDrop, FileName: "flv_2.csv", - Products: amputated, RowsRead: len(amputated), - }) - acknowledged := source.awaitAcknowledgements(t, 1) - - if got := acknowledged[0].Result; got != domain.ImportRejected { - t.Fatalf("acquittement %q, attendu %q", got, domain.ImportRejected) - } - if got := acknowledged[0].Code; got != "ERR-CAT-03" { - t.Fatalf("code %q, attendu ERR-CAT-03", got) - } - for _, reason := range []string{"INVALID_BARCODE", "PREPACKAGED_PRODUCT", "NO_BARCODE", "ligne 12"} { - if !strings.Contains(acknowledged[0].Reason, reason) { - t.Fatalf("le motif du refus (%q) ne nomme pas %q", acknowledged[0].Reason, reason) - } - } - - b.advance(domain.MaxSwitchIdle) - if b.hub.Catalog() != initial { - t.Fatal("un catalogue amputé a pris service") - } - if got := b.hub.Catalog().WeighableCount(); got != 331 { - t.Fatalf("%d tuiles en service, attendu les 331 du catalogue N−1", got) - } - awaitTechnical(t, b.technical, "ERR-CAT-03", "aucune ligne technique : le feu rouge n'a rien à afficher") -} - -// --- 12 bis: an ordinary catalog lights nothing ------------------------------ - -// TestAnOrdinaryCatalogLightsNothingRed is failure test 12 bis, and it is the test -// that forbids the return of the calque. -// -// The two authentic inventories, dropped on a virgin station and then a second time. -// Nothing on the CLIENT screen — a prepackaged product is not an incident, and a -// banner that would be shown every day, all day, on the only screen anybody looks at -// is worse than no banner at all (§10.4). The tile count is the number of WEIGHABLE -// products and never the number of rows read. And the `A` filter yields 126 tiles, -// six more than the 120 slots of the legacy form: that assertion is what forbids a -// per-category ceiling from ever coming back through the window. -// -// TO REPLAY IN L7: the figures below are the document's, and the batches are built -// from them. Nothing in this repository parses flv.csv yet, and a test that invented -// a parser would be inventing its own answer. What L7 must show is that -// csvodoo.Read(flv.csv) produces EXACTLY these counts. -func TestAnOrdinaryCatalogLightsNothingRed(t *testing.T) { - cases := []struct { - file string - rows, weighable, notWeighable, anomalies, units int - otherRows, otherWeighable int - }{ - // 355 rows: 331 weighable (of which 1 carries a divergent unit), 8 not - // weighable, 16 anomalies. Category A: 140 rows, 126 of them weighable. - {"flv.csv", 355, 331, 8, 16, 1, 140, 126}, - // 153 rows: 107 weighable, 39 not weighable, 7 anomalies, 5 divergent units. - {"flv_1.csv", 153, 107, 39, 7, 5, 0, 0}, - } - - for _, c := range cases { - t.Run(c.file, func(t *testing.T) { - // The document's own arithmetic, checked against itself before anything - // else: a figure that does not add up would make every assertion below - // meaningless. - if c.weighable+c.notWeighable+c.anomalies != c.rows { - t.Fatalf("%d + %d + %d ≠ %d : l'inventaire du document ne se recompose pas", - c.weighable, c.notWeighable, c.anomalies, c.rows) - } - - products := inventory(c.rows, c.weighable, c.notWeighable, c.otherRows, c.otherWeighable) - source := newDropFolder() - b := newBench(t, func(o *benchOptions) { - o.catalog = nil // a virgin station: the grid says « Catalogue vide » - o.source = source - }) - - batch := &ports.Batch{ - ID: "sha-" + c.file, Source: domain.CatalogSourceLocalDrop, FileName: c.file, - Products: products, RowsRead: c.rows, - Findings: findings(c.anomalies, c.units), - } - source.drop(batch) - source.awaitAcknowledgements(t, 1) - - awaitCondition(t, func() bool { - b.advance(tickInterval) - return b.hub.Catalog() != nil && b.hub.Catalog().Len() == c.rows - }, "le premier catalogue n'a jamais pris service") - - catalog := b.hub.Catalog() - if got := catalog.WeighableCount(); got != c.weighable { - t.Fatalf("%d tuile(s), attendu %d : la grille compte les PESABLES, "+ - "jamais les lignes reçues", got, c.weighable) - } - if got := weighableIn(catalog, "other"); got != c.otherWeighable { - t.Fatalf("filtre A : %d tuile(s), attendu %d", got, c.otherWeighable) - } - - // Nothing on the client screen, and no red light. - s := b.hub.State() - if s.Message != nil { - t.Fatalf("bandeau client « %s » pour un catalogue ordinaire", s.Message.Text) - } - if s.State != domain.Idle { - t.Fatalf("état %s après un import nominal, attendu idle", s.State) - } - if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { - t.Fatal("un feu rouge s'est allumé sur un catalogue ordinaire") - } - - // The same file again. That it comes back `unchanged` is the sha - // comparison of §10.5, which belongs to the applier — failure test 10 - // carries one and asserts exactly that. What is asserted HERE is the - // other half of the sentence, and it is the one about the customer: a - // second drop changes nothing on the screen. - source.drop(batch) - source.awaitAcknowledgements(t, 2) - if b.hub.State().Message != nil { - t.Fatal("le second dépôt a affiché un bandeau client") - } - }) - } -} - -// --- 12 ter: a product leaves the file ---------------------------------------- - -// TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll is failure test 12 ter. -// -// A product absent from the new file is MARKED WITHDRAWN at a date, never deleted. It -// leaves the grid, and it keeps its weighing history, its local decision and its -// image. « Ce produit a disparu du CSV » becomes a fact the dashboard can show — -// « 4 produits retirés » — instead of a silence (§10.9). -func TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll(t *testing.T) { - ctx := context.Background() - db := store.OpenTest(t) - categories := loadConfig(t).Catalog.Categories - - full := leeks(8) - first, err := db.ReplaceCatalog(ctx, store.Batch{ - Import: domain.Import{ - OccurredAt: store.TestEpoch, Source: domain.CatalogSourceLocalDrop, - FileName: "flv_2.csv", SHA256: "sha-n", RowsRead: len(full), - Weighable: len(full), Result: domain.ImportApplied, - }, - Categories: categories, Products: full, - }) - if err != nil { - t.Fatalf("premier import : %v", err) - } - if first.Inserted != 8 { - t.Fatalf("%d insertion(s), attendu 8", first.Inserted) - } - - // One of the four about to disappear carries a weighing and a human decision. - doomed := full[4:] - weighing := domain.Weighing{ - OccurredAt: store.TestEpoch, Station: 2, JobID: "01J9F2ABC", - IdempotencyKey: "01J9F2ABC", ProductID: doomed[0].ID, ProductName: doomed[0].Name, - Reference: doomed[0].Reference, Mode: domain.ByWeight, - GrossWeight: 1236, NetWeight: 1236, Quantity: 1, Barcode: "0493021012365", - Source: domain.SourceScale, Stability: domain.Stable, Result: domain.ResultSent, - } - if err := db.RecordWeighing(ctx, &weighing); err != nil { - t.Fatalf("RecordWeighing : %v", err) - } - if err := db.SaveDecision(ctx, domain.LocalDecision{ - ProductID: doomed[0].ID, Offered: false, Reason: "prix faux chez Odoo", - DecidedAt: store.TestEpoch, DecidedBy: "bénévole", - }); err != nil { - t.Fatalf("SaveDecision : %v", err) - } - - // The next file is short of four rows. - second, err := db.ReplaceCatalog(ctx, store.Batch{ - Import: domain.Import{ - OccurredAt: store.TestEpoch, Source: domain.CatalogSourceLocalDrop, - FileName: "flv_2.csv", SHA256: "sha-n-plus-1", RowsRead: 4, - Weighable: 4, Result: domain.ImportApplied, - }, - Categories: categories, Products: full[:4], - }) - if err != nil { - t.Fatalf("second import : %v", err) - } - if second.Withdrawn != 4 { - t.Fatalf("%d produit(s) retirés, attendu 4 : « 4 produits retirés » est la phrase "+ - "que le tableau de bord doit pouvoir écrire", second.Withdrawn) - } - if second.Inserted != 0 || second.Updated != 4 { - t.Fatalf("outcome = %+v, attendu 0 insertion et 4 mises à jour", second) - } - - // The count reaches the import row, which is what the dashboard reads. - imports, err := db.Imports(ctx, 1, 0) - if err != nil || len(imports) == 0 { - t.Fatalf("Imports : %v", err) - } - if imports[0].ProductsWithdrawn != 4 { - t.Fatalf("products_withdrawn = %d dans la ligne d'import, attendu 4", - imports[0].ProductsWithdrawn) - } - - // The four are out of the grid, and none of them was destroyed. - catalog, err := db.LoadCatalog(ctx) - if err != nil { - t.Fatalf("LoadCatalog : %v", err) - } - // The four SURVIVORS are still there, and they are asserted first: a grid that - // withdrew everything would satisfy « the four that left are gone » without - // serving anybody. - if catalog.Len() != 4 { - t.Fatalf("%d produit(s) en grille, attendu les 4 que le fichier porte encore", - catalog.Len()) - } - for _, p := range full[:4] { - if _, offered := catalog.ByID(p.ID); !offered { - t.Fatalf("le produit %s a quitté la grille alors qu'il est toujours dans le fichier", p.ID) - } - } - for _, p := range doomed { - if _, offered := catalog.ByID(p.ID); offered { - t.Fatalf("le produit %s est encore dans la grille alors qu'il a disparu du fichier", p.ID) - } - row, err := db.Product(ctx, p.ID) - if err != nil { - t.Fatalf("le produit %s a été EFFACÉ : %v", p.ID, err) - } - if row.WithdrawnAt.IsZero() { - t.Fatalf("le produit %s n'a pas de date de retrait", p.ID) - } - } - - // Its weighing is still readable, and its decision still stands. - rows, err := db.Weighings(ctx, store.JournalFilter{Limit: 10}) - if err != nil { - t.Fatalf("Weighings : %v", err) - } - if len(rows) != 1 || rows[0].ProductID != doomed[0].ID { - t.Fatalf("%d pesée(s) lisibles : l'historique d'un produit retiré a disparu avec lui", - len(rows)) - } - decision, err := db.Decision(ctx, doomed[0].ID) - if err != nil { - t.Fatalf("la décision locale n'a pas survécu au retrait : %v", err) - } - if decision.Offered { - t.Fatal("la décision locale a été réécrite par l'import") - } -} - -// --- Fixtures --------------------------------------------------------------- - -// leeks builds n weighable products, all alike. -// -// The reference is thirteen characters because the schema demands zero or thirteen, -// and the ids start at 7001 so that no fixture of this package can collide with the -// garlic of §16.3. -func leeks(n int) []domain.Product { - out := make([]domain.Product, 0, n) - for i := 0; i < n; i++ { - out = append(out, domain.Product{ - ID: itoa(7001 + i), Name: "POIREAU " + itoa(i), Reference: "0493022000002", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, - CategoryCode: "vegetables", Qualification: domain.Weighable, CSVLine: i + 2, - }) - } - return out -} - -// inventory builds one file's worth of rows with the qualification mix of §10.4, and -// puts otherWeighable of the weighable ones in category A — « Autres », the one the -// legacy form could only show 120 of. -func inventory(rows, weighable, notWeighable, otherRows, otherWeighable int) []domain.Product { - out := make([]domain.Product, 0, rows) - for i := 0; i < rows; i++ { - p := domain.Product{ - ID: itoa(20 + i), Name: "PRODUIT " + itoa(i), Reference: "0493022000002", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, - CategoryCode: "vegetables", CSVLine: i + 2, - } - switch { - case i < weighable: - p.Qualification = domain.Weighable - case i < weighable+notWeighable: - p.Qualification, p.Reason = domain.NotWeighable, domain.FindingPrepackagedProduct - default: - p.Qualification, p.Reason = domain.Anomaly, domain.FindingReservedZoneNotEmpty - } - // Category A carries otherRows rows, otherWeighable of them weighable: the - // two figures differ, and that difference is the fourteen masked codes of - // §14.3. - if i < otherWeighable || (i >= weighable && i < weighable+(otherRows-otherWeighable)) { - p.CategoryCode = "other" - } - out = append(out, p) - } - return out -} - -// findings builds what an import has to say about the rows it read: anomalies to fix -// in Odoo, and divergent units that change nothing but a printed suffix. -func findings(anomalies, units int) []domain.Finding { - out := make([]domain.Finding, 0, anomalies+units) - for i := 0; i < anomalies; i++ { - out = append(out, domain.Finding{ - Code: domain.FindingReservedZoneNotEmpty, Issue: domain.IssueAnomaly, - CSVLine: i + 2, ProductID: itoa(20 + i), - Message: "Le code déborde sur la zone réservée au poids. À corriger dans Odoo.", - }) - } - for i := 0; i < units; i++ { - out = append(out, domain.Finding{ - Code: domain.FindingUnitMismatch, Issue: domain.IssueInfo, - CSVLine: 100 + i, ProductID: itoa(120 + i), - Message: "L'unité déclarée contredit le préfixe du code-barres.", - }) - } - return out -} - -// weighableCount reports how many of a batch's rows get a tile. -func weighableCount(products []domain.Product) int { - n := 0 - for _, p := range products { - if p.Qualification == domain.Weighable { - n++ - } - } - return n -} - -// weighableIn reports how many tiles one category holds. -func weighableIn(catalog *domain.Catalog, code string) int { - n := 0 - for _, p := range catalog.Products() { - if p.CategoryCode == code && p.Qualification == domain.Weighable { - n++ - } - } - return n -} - -// worstTechnicalLevel reports the most severe level journalled so far. -func worstTechnicalLevel(r *recordingTechnical) string { - r.mu.Lock() - defer r.mu.Unlock() - worst := domain.LevelInfo - for _, e := range r.entries { - if e.Level == domain.LevelError { - return domain.LevelError - } - if e.Level == domain.LevelWarn { - worst = domain.LevelWarn - } - } - return worst -} - -// --- The same seven, against the real chain --------------------------------- - -// fixtures is where the two authentic exchange files live (CLAUDE.md). -const fixtures = "../../testdata/catalog/" - -// dropInterval is catalog.options.poll_interval_s of the shipped configuration, and -// advancing the injected clock by that much is one scan of the drop directory. -const dropInterval = 5 * time.Second - -// The two inventories, MEASURED on the files of the repository and not copied from the -// document. §16.2 line 12 bis states « 331 tuiles dont 174 sans photo » and §18 « 181 -// avec photo et 174 sans » — but 181 + 174 = 355, which is the number of ROWS. The tile -// split is 177 with a photo and 154 without, and both are asserted below so that -// nobody has to take that on trust. -const ( - realRows, realTiles = 355, 331 - realNotWeighable, realIssues = 8, 16 - realUnitMismatch = 1 - realPhotoRows, realPhotoFiles = 181, 165 - realTilesWithPhoto = 177 - realTilesWithoutPhoto = 154 - realOtherTiles = 126 - - firstRows, firstTiles = 153, 107 -) - -// technicalBridge lets a driver of internal/catalog write into the very sink the -// station writes to, so that "no red light" is one assertion and not two. -type technicalBridge struct{ sink *recordingTechnical } - -// Technical records one line. -func (b technicalBridge) Technical(level, source, code, message, detail string) { - _ = b.sink.RecordTechnical(context.Background(), TechnicalEntry{ - Level: level, Source: source, Code: code, Message: message, Detail: detail, - }) -} - -// realOptions is what a test wants to change about the standard real bench. -type realOptions struct { - // catalog is what is in service before anything is dropped. Nil is a virgin - // station, whose grid says "Catalogue vide". - catalog *domain.Catalog - // wrap decorates the real source. It exists for failure test 11 and for nothing - // else: it is where the one unreproducible syscall is injected. - wrap func(ports.CatalogSource) ports.CatalogSource -} - -// realBench is a whole station wired to the whole of L7: a real drop directory, the -// real parser, the real qualification, the real guards, a real SQLite database and a -// real image store. Only the clock is a double. -type realBench struct { - t *testing.T - hub *Hub - clock *fake.Clock - db *store.DB - images *catalog.ImageStore - path string - archives string - technical *recordingTechnical - returned chan struct{} -} - -// newRealBench starts one. -func newRealBench(t *testing.T, tweak ...func(*realOptions)) *realBench { - t.Helper() - - o := realOptions{} - for _, f := range tweak { - f(&o) - } - cfg := loadConfig(t) - // The shipped file declares webdav, because the production share is one. What is - // exercised here is the drop directory, which is the source a volunteer uses and - // the one the drag and drop of the administration screen writes into (A4). - cfg.Catalog.Type = domain.CatalogSourceLocalDrop - - clock := fake.NewClock(epoch) - dataDir := t.TempDir() - db := store.OpenTest(t) - sink := &recordingTechnical{} - - images, err := catalog.NewImageStore(dataDir) - if err != nil { - t.Fatalf("puits d'images : %v", err) - } - drop, err := localdrop.New(catalog.SourceConfig{ - Catalog: cfg.Catalog, StationNumber: cfg.Station.Number, DataDir: dataDir, - Clock: clock, Log: technicalBridge{sink}, Images: images, Quarantine: db, - }) - if err != nil { - t.Fatalf("source de catalogue : %v", err) - } - applier, err := importer.New(importer.Options{ - Records: db, Clock: clock, Log: technicalBridge{sink}, - }) - if err != nil { - t.Fatalf("applicateur : %v", err) - } - - var source ports.CatalogSource = drop - if o.wrap != nil { - source = o.wrap(drop) - } - st, err := New(Options{ - Clock: clock, Config: cfg, Catalog: o.catalog, - Scale: fake.NewScale(clock), Printer: fake.NewPrinter(), - Journal: newRecordingJournal(), TechnicalSink: sink, - CatalogSource: source, ApplyCatalog: applier.Apply, - }) - if err != nil { - t.Fatalf("station.New : %v", err) - } - - b := &realBench{ - t: t, hub: st.Hub(), clock: clock, db: db, images: images, - path: drop.Path(), - archives: filepath.Join(dataDir, "catalog", "archives"), - technical: sink, - returned: make(chan struct{}), - } - ctx, cancel := context.WithCancel(context.Background()) - go func() { - defer close(b.returned) - _ = st.Start(ctx) - }() - select { - case <-st.Ready(): - case <-time.After(hang): - t.Fatal("le poste n'a jamais fini de démarrer") - } - t.Cleanup(func() { - st.Stop() - cancel() - <-b.returned - }) - return b -} - -// drop writes one of the authentic files into the watched directory. -func (b *realBench) drop(name string) { - b.t.Helper() - b.dropContent(fixtureBytes(b.t, name)) -} - -// dropContent writes arbitrary bytes into the watched directory, which is what a -// producer, a synchronisation tool and the administration drag and drop all do (A4). -func (b *realBench) dropContent(content []byte) { - b.t.Helper() - if err := os.WriteFile(b.path, content, 0o644); err != nil { - b.t.Fatalf("dépôt du fichier : %v", err) - } -} - -// scan advances the injected clock by one polling interval and lets the Hub turn. -func (b *realBench) scan() { - b.t.Helper() - b.clock.Advance(dropInterval) - b.flush() - b.flush() -} - -// flush runs one full turn of the loop and waits for its answer. -func (b *realBench) flush() { - b.t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), hang) - defer cancel() - if _, err := b.hub.Submit(ctx, domain.Tick{}, ""); err != nil { - b.t.Fatalf("la boucle ne répond plus : %v", err) - } -} - -// awaitTiles scans until the grid in service holds exactly that many tiles. -func (b *realBench) awaitTiles(want int) *domain.Catalog { - b.t.Helper() - awaitCondition(b.t, func() bool { - b.scan() - return b.hub.Catalog() != nil && b.hub.Catalog().WeighableCount() == want - }, fmt.Sprintf("la grille n'a jamais compté %d tuiles", want)) - return b.hub.Catalog() -} - -// awaitFileGone scans until the dropped file has been acknowledged, which is to say -// ARCHIVED AND REMOVED (ADR-004). -func (b *realBench) awaitFileGone() { - b.t.Helper() - awaitCondition(b.t, func() bool { - b.scan() - _, err := os.Stat(b.path) - return errors.Is(err, os.ErrNotExist) - }, "le fichier déposé n'a jamais disparu : l'acquittement EST la suppression") -} - -// awaitImports scans until the history holds that many rows, most recent first. -func (b *realBench) awaitImports(want int) []domain.Import { - b.t.Helper() - awaitCondition(b.t, func() bool { - b.scan() - return len(b.imports()) >= want - }, fmt.Sprintf("l'historique n'a jamais compté %d import(s)", want)) - return b.imports() -} - -// imports reads the import history. -func (b *realBench) imports() []domain.Import { - b.t.Helper() - rows, err := b.db.Imports(context.Background(), 20, 0) - if err != nil { - b.t.Fatalf("Imports : %v", err) - } - return rows -} - -// archived lists what the archive directory holds, sorted. -func (b *realBench) archived() []string { - b.t.Helper() - entries, err := os.ReadDir(b.archives) - if err != nil { - b.t.Fatalf("lecture des archives : %v", err) - } - names := make([]string, 0, len(entries)) - for _, entry := range entries { - // A « .part » is a copy IN FLIGHT, not an archive: Archive.Begin opens it before - // the parse and Commit is what turns it into one. Counting it as an archive made - // this bench read « a file was archived » where the truth was « a reading has - // started », and internal/catalog/archive.go already skips the same suffix when - // it prunes. - if strings.HasSuffix(entry.Name(), ".part") { - continue - } - names = append(names, entry.Name()) - } - sort.Strings(names) - return names -} - -// photoFiles counts the photos really written under /images. -func (b *realBench) photoFiles() int { - b.t.Helper() - n := 0 - err := filepath.WalkDir(b.images.Directory(), func(_ string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() { - n++ - } - return nil - }) - if err != nil { - b.t.Fatalf("parcours du puits d'images : %v", err) - } - return n -} - -// fixtureBytes reads one of the two authentic files. -func fixtureBytes(t *testing.T, name string) []byte { - t.Helper() - content, err := os.ReadFile(fixtures + name) - if err != nil { - t.Fatalf("lecture de la fixture %s : %v", name, err) - } - return content -} - -// digest is the sha256 of what was dropped, which is the key of the quarantine (§10.5). -func digest(content []byte) string { - sum := sha256.Sum256(content) - return hex.EncodeToString(sum[:]) -} - -// rows splits an exchange file into its lines, header included. -// -// Splitting on the separator is safe on these files and only on them: no name carries a -// quote or a semicolon, and the base64 alphabet has neither (§10.2). -func rows(t *testing.T, content []byte) []string { - t.Helper() - out := make([]string, 0, 400) - for _, line := range strings.Split(string(content), "\r\n") { - if line != "" { - out = append(out, line) - } - } - if len(out) < 2 { - t.Fatalf("%d ligne(s) dans le fichier : ce n'est pas un catalogue", len(out)) - } - return out -} - -// join puts the lines back together in the form the format declares: CRLF, and a final -// one, exactly as the producer writes it. -func join(lines []string) []byte { - return []byte(strings.Join(lines, "\r\n") + "\r\n") -} - -// identifier reports the Odoo id one line of the exchange file carries. -func identifier(line string) string { - fields := strings.Split(line, ";") - if len(fields) == 0 { - return "" - } - return strings.Trim(fields[0], `"`) -} - -// withoutProducts removes the lines of the products named, and only those. -func withoutProducts(t *testing.T, lines []string, ids map[string]bool) []string { - t.Helper() - out := make([]string, 0, len(lines)) - removed := 0 - for i, line := range lines { - if i > 0 && ids[identifier(line)] { - removed++ - continue - } - out = append(out, line) - } - if removed != len(ids) { - t.Fatalf("%d ligne(s) retirée(s) pour %d produits nommés", removed, len(ids)) - } - return out -} - -// touchLastName changes one product NAME and nothing else. -// -// It is what makes a second import a real one: the sha of the file changes, the -// qualification of every row does not, so whatever the grid then loses it lost for a -// reason that is not the file. -func touchLastName(t *testing.T, lines []string) []string { - t.Helper() - out := append([]string(nil), lines...) - last := len(out) - 1 - fields := strings.Split(out[last], ";") - if len(fields) != 7 { - t.Fatalf("la dernière ligne porte %d colonnes", len(fields)) - } - fields[1] = strings.TrimSuffix(fields[1], `"`) + ` BIS"` - out[last] = strings.Join(fields, ";") - return out -} - -// shiftColumns swaps `code-barre` and `prix` on every line, header included. -// -// That is what a column shift at the producer looks like, and it is the failure the -// relative guard exists for (§10.4b): every row is still perfectly readable, the file -// is still whole, the absolute guard sees nothing at all — and not one product is -// weighable any more. -func shiftColumns(t *testing.T, lines []string) []string { - t.Helper() - out := make([]string, 0, len(lines)) - for _, line := range lines { - fields := strings.Split(line, ";") - if len(fields) != 7 { - t.Fatalf("ligne à %d colonnes : %.40s", len(fields), line) - } - fields[2], fields[3] = fields[3], fields[2] - out = append(out, strings.Join(fields, ";")) - } - return out -} - -// linesWithLevel counts the technical lines carrying one code at one level. -func (b *realBench) linesWithLevel(code, level string) int { - b.technical.mu.Lock() - defer b.technical.mu.Unlock() - n := 0 - for _, e := range b.technical.entries { - if e.Code == code && e.Level == level { - n++ - } - } - return n -} - -// quarantine reads what stands against a content, or reports that nothing does. -func (b *realBench) quarantine(sha string) (domain.QuarantineEntry, bool) { - b.t.Helper() - entry, err := b.db.Quarantine(context.Background(), sha) - return entry, err == nil -} - -// pollByPoll is a REAL catalog source the test polls ONE scrutation at a time. -// -// # WHY THE TEST BELOW CANNOT USE THE CLOCK -// -// Failure test 8 writes a file that grows and asserts nothing was read. That assertion -// is only sound if EXACTLY ONE poll observes each size, and advancing the injected -// clock does not give that: it merely drops a tick into a channel of capacity one. The -// watch runs on its own goroutine (§13.1 n° 5), so nothing says the poll that tick -// triggers has happened before the test writes the NEXT size. -// -// When that poll lags one turn, two consecutive polls see the same bytes, the file is -// declared immobile, and what the test calls a violation is a perfectly correct read of -// a file that really had stopped moving. It turned CI red on 29/07/2026, with an -// archive AND its .reason.txt — the copy was being parsed while os.WriteFile truncated -// it underneath. No local stress reproduces it: it takes a loaded two-core runner. -// -// The rendezvous closes the window instead of widening it. `ask` is taken by the watch -// when it is about to poll and `done` is given back when that poll is over, so the test -// writes the next size while the watch is PARKED — which no timeout can promise. -type pollByPoll struct { - ports.CatalogSource - ask chan struct{} - done chan struct{} -} - -// Next performs exactly one poll per request from the test. -// -// The context handed down is already spent, which is what makes the real Next poll once -// and come back rather than wait for its own ticker. It costs the refusal path its -// quarantine write — and that is acceptable HERE and only here, because a refusal is -// precisely what this scenario asserts never happens. -func (p *pollByPoll) Next(ctx context.Context) (*ports.Batch, error) { - select { - case <-p.ask: - case <-ctx.Done(): - return nil, ctx.Err() - } - batch, err := p.CatalogSource.Next(spent(ctx)) - if errors.Is(err, context.Canceled) && ctx.Err() == nil { - // The cancellation is OURS, and it means « the poll is over, nothing found ». - err = nil - } - select { - case p.done <- struct{}{}: - case <-ctx.Done(): - } - return batch, err -} - -// once asks for one poll and does not return until that poll has finished. -func (p *pollByPoll) once(t *testing.T) { - t.Helper() - select { - case p.ask <- struct{}{}: - case <-time.After(hang): - t.Fatal("la veille du catalogue n'a jamais redemandé de scrutation") - } - select { - case <-p.done: - case <-time.After(hang): - t.Fatal("la scrutation demandée ne s'est jamais terminée") - } -} - -// spent returns a child context that is already done. -func spent(parent context.Context) context.Context { - ctx, cancel := context.WithCancel(parent) - cancel() - return ctx -} - -// TestACatalogFileStillGrowingIsNotReadAgainstTheRealDrop is failure test 8, replayed -// against the poll loop of internal/catalog/localdrop. -// -// The assertion that carries it is the ARCHIVE: the copy is written WHILE the file is -// read, so an empty archive directory is proof that nothing was read at all — and that -// is stronger than counting reads, because it is the artefact production leaves behind. -func TestACatalogFileStillGrowingIsNotReadAgainstTheRealDrop(t *testing.T) { - initial := garlicCatalog() - watch := &pollByPoll{ask: make(chan struct{}), done: make(chan struct{})} - b := newRealBench(t, func(o *realOptions) { - o.catalog = initial - o.wrap = func(s ports.CatalogSource) ports.CatalogSource { - watch.CatalogSource = s - return watch - } - }) - - lines := rows(t, fixtureBytes(t, "flv_1.csv")) - for _, upto := range []int{20, 60, 100, 140} { - b.dropContent(join(lines[:upto])) - // ONE poll, and it has finished when this returns: whatever it saw, it saw this - // size and no other. - watch.once(t) - b.flush() - // A COMMITTED archive is the proof that a file was read to the end and - // acknowledged. The « .part » of a copy in flight is not one, and counting it as - // such is what turned this red on a loaded runner the first time. - if names := b.archived(); len(names) != 0 { - t.Fatalf("archives %v : la copie est écrite PENDANT la lecture, donc un fichier "+ - "dont la taille bouge encore a été lu", names) - } - if _, err := os.Stat(b.path); err != nil { - t.Fatalf("le fichier a disparu sans avoir été acquitté : %v", err) - } - if b.hub.Catalog() != initial { - t.Fatal("le catalogue a été remplacé par un fichier en cours d'écriture") - } - } - - // Immobile at last. What takes service is the WHOLE file — 107 tiles — and never - // one of the four truncations above. Two identical polls are what the rule asks for, - // and the loop below is what grants them. - b.dropContent(join(lines)) - awaitCondition(t, func() bool { - watch.once(t) - b.flush() - return b.hub.Catalog() != nil && b.hub.Catalog().WeighableCount() == firstTiles - }, fmt.Sprintf("la grille n'a jamais compté %d tuiles", firstTiles)) - if grid := b.hub.Catalog(); grid.Len() != firstRows { - t.Errorf("%d produits en base, attendu les %d lignes du fichier", grid.Len(), firstRows) - } - awaitCondition(t, func() bool { - watch.once(t) - _, err := os.Stat(b.path) - return errors.Is(err, os.ErrNotExist) - }, "le fichier déposé n'a jamais disparu : l'acquittement EST la suppression") - if names := b.archived(); len(names) != 1 { - t.Errorf("archives %v, attendu une seule copie : celle du fichier complet", names) - } -} - -// TestACorruptedCatalogIsQuarantinedAgainstTheRealChain is failure test 9, replayed -// against the real parser, the real archive and the real quarantine table. -// -// Three drops of the same unusable content. Each one is set aside with its reason and -// REMOVED — leaving it would re-read the same broken file every five seconds for ever — -// the catalog in service is never touched, and the third failure is the one that turns -// the light red. -func TestACorruptedCatalogIsQuarantinedAgainstTheRealChain(t *testing.T) { - initial := garlicCatalog() - b := newRealBench(t, func(o *realOptions) { o.catalog = initial }) - - broken := []byte("\"id\";\"nom\"\r\n\"20\";\"UNE SEULE COLONNE UTILE\"\r\n") - sha := digest(broken) - - for attempt := 1; attempt <= 3; attempt++ { - b.dropContent(broken) - b.awaitFileGone() - entry, banned := b.quarantine(sha) - if !banned { - t.Fatalf("essai %d : rien en quarantaine sous le sha du fichier refusé", attempt) - } - if entry.FailureCount != attempt { - t.Fatalf("essai %d : %d échec(s) comptés", attempt, entry.FailureCount) - } - } - - entry, _ := b.quarantine(sha) - if entry.Code != "ERR-CAT-03" { - t.Errorf("code %q en quarantaine, attendu ERR-CAT-03", entry.Code) - } - threshold, ok := b.hub.Config().Catalog.Options.Int("failures_before_reject") - if !ok || int64(entry.FailureCount) < threshold { - t.Errorf("%d échecs pour un seuil de %d", entry.FailureCount, threshold) - } - // The catalog N−1 served throughout. - b.scan() - if b.hub.Catalog() != initial { - t.Fatal("un contenu refusé a remplacé le catalogue N−1") - } - // Three copies and three reasons: somebody has to be able to see it happened three - // times, without a database. - names := b.archived() - if len(names) != 6 { - t.Fatalf("archives %v, attendu trois copies et trois motifs", names) - } - reason, err := os.ReadFile(filepath.Join(b.archives, names[len(names)-1])) - if err != nil || !strings.Contains(string(reason), "ERR-CAT-03") { - t.Errorf("le motif archivé ne nomme pas le code : %s / %v", reason, err) - } - // The light only goes RED on the third refusal: a producer who corrects the file - // after one bad export must not find a station that has already given up. - if got := b.linesWithLevel("ERR-CAT-03", domain.LevelError); got != 1 { - t.Errorf("%d ligne(s) ERR-CAT-03 en niveau erreur, attendu 1 — celle du "+ - "troisième refus", got) - } -} - -// TestTheSameCatalogTwiceAgainstTheRealFile is failure test 10 on the authentic file. -// -// A producer may drop a byte-identical export every night: two rows in `imports`, -// `applied` then `unchanged`, no red light, no quarantine — and not one photo rewritten, -// because the sha IS the address (§10.7). -func TestTheSameCatalogTwiceAgainstTheRealFile(t *testing.T) { - b := newRealBench(t) - - b.drop("flv.csv") - b.awaitTiles(realTiles) - b.awaitFileGone() - written := b.photoFiles() - - b.drop("flv.csv") - b.awaitFileGone() - history := b.awaitImports(2) - - if len(history) != 2 { - t.Fatalf("%d ligne(s) dans imports, attendu 2 : l'historique est append-only", len(history)) - } - if history[1].Result != domain.ImportApplied || history[0].Result != domain.ImportUnchanged { - t.Fatalf("résultats %q puis %q, attendu %q puis %q", - history[1].Result, history[0].Result, domain.ImportApplied, domain.ImportUnchanged) - } - if history[0].SHA256 != history[1].SHA256 { - t.Error("les deux lignes ne portent pas le même sha") - } - if _, banned := b.quarantine(history[0].SHA256); banned { - t.Fatal("un catalogue valide déposé deux fois a été mis en quarantaine") - } - if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { - t.Fatal("déposer deux fois le même fichier a allumé un feu") - } - if b.hub.State().Message != nil { - t.Fatalf("bandeau client « %s » pour un second dépôt", b.hub.State().Message.Text) - } - if got := b.photoFiles(); got != written { - t.Errorf("%d photos sur le disque après le second dépôt, %d après le premier : "+ - "réimporter le même catalogue ne doit écrire aucun fichier", got, written) - } - if b.hub.Catalog().WeighableCount() != realTiles { - t.Errorf("%d tuiles après le second dépôt", b.hub.Catalog().WeighableCount()) - } -} - -// undeletable is a REAL local drop whose acknowledgement fails the way a read-only -// directory makes it fail. -// -// It is the one injection of this file, and it is at the syscall: no portable file -// system produces a file that can be read and not deleted — Windows clears a read-only -// attribute by itself, Unix decides by the directory. Everything else here is the real -// thing, and the source-side half of the line is exercised in -// internal/catalog/localdrop, where the seam lives. -type undeletable struct { - ports.CatalogSource - directory string -} - -// Acknowledge refuses, and never touches the file — which is exactly the state §16.2 -// line 11 describes: read, applied, still there. -func (u undeletable) Acknowledge(context.Context, *ports.Batch, ports.BatchResult) error { - return fmt.Errorf("%w : droits en écriture manquants sur %s pour le compte balance", - catalog.ErrNotAcknowledged, u.directory) -} - -// TestACatalogFileThatCannotBeDeletedAgainstTheRealApplier is failure test 11, and the -// trap of §16.2 line 11. -// -// The file was read and APPLIED; only its removal failed. That is ERR-CAT-05, an amber -// light, and it must NEVER count against the quarantine: the file is not corrupted, the -// directory is. A red light that fires wrongly is the worst enemy of operations, -// because after three false alarms the team stops looking at the lights. -func TestACatalogFileThatCannotBeDeletedAgainstTheRealApplier(t *testing.T) { - initial := garlicCatalog() - b := newRealBench(t, func(o *realOptions) { - o.catalog = initial - o.wrap = func(s ports.CatalogSource) ports.CatalogSource { - return undeletable{CatalogSource: s, directory: `\\serveur\balance\`} - } - }) - - content := fixtureBytes(t, "flv_1.csv") - b.dropContent(content) - - // The catalog it carried takes service all the same. - b.awaitTiles(firstTiles) - awaitCondition(t, func() bool { - b.scan() - return b.technical.has("ERR-CAT-05") - }, "ERR-CAT-05 n'a pas été journalisé alors que le fichier n'a pas pu être supprimé") - - if b.technical.has("ERR-CAT-03") { - t.Fatal("ERR-CAT-03 journalisé : une suppression impossible a été prise pour un " + - "échec de contenu") - } - if _, banned := b.quarantine(digest(content)); banned { - t.Fatal("le contenu a été mis en quarantaine alors que seule sa suppression a échoué") - } - if _, err := os.Stat(b.path); err != nil { - t.Fatalf("le fichier a disparu : %v", err) - } - // It is read again and again, and every re-reading is `unchanged`: a station whose - // share is read-only keeps serving, and nothing accumulates but history. - // - // The applied one is named BY ITS IDENTITY and not by its rank, because the number of - // re-readings is bounded by nothing at all: `Next` polls the moment it is entered, so - // a file nobody can delete is read again as fast as the loop turns. Twenty rows — - // the window `imports()` reads, which is the window the screen shows — scroll past in - // well under a second on a slow machine, and « le premier import » silently became - // « le vingtième ». Measured here: two rows on this machine, more than twenty as soon - // as anything delays the test. - applied, err := b.db.LastAppliedImport(context.Background()) - if err != nil { - t.Fatalf("aucun import appliqué : la première lecture n'a rien mis en service : %v", err) - } - for _, row := range b.awaitImports(2) { - if row.ID == applied.ID { - continue - } - if row.Result != domain.ImportUnchanged { - t.Fatalf("import %d journalisé %q, attendu %q : une seule lecture applique, "+ - "toutes les autres sont des relectures", row.ID, row.Result, domain.ImportUnchanged) - } - } -} - -// TestAnAmputatedCatalogIsRefusedAgainstTheRealGuard is failure test 12, replayed on a -// column shift of the AUTHENTIC file. -// -// Every one of the 355 rows is still perfectly readable, so the absolute guard of -// §10.4a sees nothing at all; not one product is weighable any more, which is exactly -// the grandeur the relative guard watches. -func TestAnAmputatedCatalogIsRefusedAgainstTheRealGuard(t *testing.T) { - b := newRealBench(t) - b.drop("flv.csv") - b.awaitTiles(realTiles) - b.awaitFileGone() - - shifted := join(shiftColumns(t, rows(t, fixtureBytes(t, "flv.csv")))) - b.dropContent(shifted) - b.awaitFileGone() - history := b.awaitImports(2) - - refused := history[0] - if refused.Result != domain.ImportRejected || refused.Code != "ERR-CAT-03" { - t.Fatalf("ligne d'import %+v, attendu un refus ERR-CAT-03", refused) - } - if refused.RowsRead != realRows || refused.UnreadableRows != 0 { - t.Errorf("%d lignes lues dont %d illisibles : le garde ABSOLU ne voit rien, "+ - "c'est le garde RELATIF qui doit refuser", refused.RowsRead, refused.UnreadableRows) - } - if refused.Weighable != 0 { - t.Errorf("%d pesables après un décalage de colonne", refused.Weighable) - } - for _, expected := range []string{ - "0 produit pesable reçu contre 331", "90 %", - domain.FindingPriceUnreadable, "par exemple ligne", "reste en service", - } { - if !strings.Contains(refused.Reason, expected) { - t.Errorf("le motif du refus ne contient pas %q :\n%s", expected, refused.Reason) - } - } - // The catalog N−1 kept serving, whole. - b.scan() - if got := b.hub.Catalog().WeighableCount(); got != realTiles { - t.Fatalf("%d tuiles en service, attendu les %d du catalogue N−1", got, realTiles) - } - // A content failure, so it counts — and the reason is next to the archived copy. - entry, banned := b.quarantine(digest(shifted)) - if !banned || entry.FailureCount != 1 { - t.Errorf("quarantaine %+v", entry) - } - if !hasReason(b.archived()) { - t.Errorf("aucun .reason.txt à côté de la copie refusée : %v", b.archived()) - } - awaitTechnical(t, b.technical, "ERR-CAT-03", "aucune ligne technique : le feu rouge n'a rien à afficher") -} - -// hasReason reports whether a refusal left its explanation next to a copy. -func hasReason(names []string) bool { - for _, name := range names { - if strings.HasSuffix(name, ".reason.txt") { - return true - } - } - return false -} - -// TestAnOrdinaryCatalogLightsNothingRedAgainstTheRealFiles is failure test 12 bis and -// the acceptance criterion of §18, on the two AUTHENTIC files and nothing else. -// -// Every figure below was MEASURED on the files of this repository. Two of them correct -// the document, and the correction is arithmetic rather than opinion: §18 says « 331 -// tuiles dont 181 avec photo et 174 sans » and §16.2 says « 331 tuiles dont 174 sans -// photo », but 181 + 174 = 355, which is the number of ROWS. Of the 331 TILES, 177 -// carry a photo and 154 do not. -func TestAnOrdinaryCatalogLightsNothingRedAgainstTheRealFiles(t *testing.T) { - for _, c := range []struct { - file string - rowsRead, tiles, notWeighable, issues, units int - photoRows, photoFiles int - tilesWithPhoto, tilesWithoutPhoto, otherTiles int - }{ - {"flv.csv", realRows, realTiles, realNotWeighable, realIssues, realUnitMismatch, - realPhotoRows, realPhotoFiles, realTilesWithPhoto, realTilesWithoutPhoto, realOtherTiles}, - {"flv_1.csv", firstRows, firstTiles, 39, 7, 5, 0, 0, 0, firstTiles, 1}, - } { - t.Run(c.file, func(t *testing.T) { - // The inventory has to recompose, or every assertion below means nothing. - if c.tiles+c.notWeighable+c.issues != c.rowsRead { - t.Fatalf("%d + %d + %d ≠ %d", c.tiles, c.notWeighable, c.issues, c.rowsRead) - } - b := newRealBench(t) // a virgin station: the grid says « Catalogue vide » - b.drop(c.file) - grid := b.awaitTiles(c.tiles) - b.awaitFileGone() - - if grid.Len() != c.rowsRead { - t.Errorf("%d produits en base, attendu les %d lignes reçues : un préemballé "+ - "est une ligne, il n'a simplement pas de tuile", grid.Len(), c.rowsRead) - } - if got := weighableIn(grid, "other"); got != c.otherTiles { - t.Errorf("filtre « Autres » : %d tuiles, attendu %d", got, c.otherTiles) - } - - // A tile without a photo is NORMAL and makes no hole: the two counts add up - // to the number of tiles, so no product was dropped for want of a picture. - with, without := 0, 0 - for _, p := range grid.Products() { - if p.Qualification != domain.Weighable { - continue - } - if p.ImageSHA != "" { - with++ - } else { - without++ - } - } - if with != c.tilesWithPhoto || without != c.tilesWithoutPhoto { - t.Errorf("%d tuiles avec photo et %d sans, attendu %d et %d", - with, without, c.tilesWithPhoto, c.tilesWithoutPhoto) - } - if with+without != c.tiles { - t.Fatalf("%d + %d ≠ %d : une tuile a été perdue faute de photo", - with, without, c.tiles) - } - if got := b.photoFiles(); got != c.photoFiles { - t.Errorf("%d photos écrites sur le disque, attendu %d", got, c.photoFiles) - } - - // The inventory the administration screen shows: 355 · 331 · 8 · 16. - history := b.imports() - if len(history) != 1 { - t.Fatalf("%d ligne(s) d'import", len(history)) - } - row := history[0] - if row.RowsRead != c.rowsRead || row.Weighable != c.tiles || - row.NotWeighable != c.notWeighable || row.Anomalies != c.issues { - t.Errorf("inventaire %d · %d · %d · %d, attendu %d · %d · %d · %d", - row.RowsRead, row.Weighable, row.NotWeighable, row.Anomalies, - c.rowsRead, c.tiles, c.notWeighable, c.issues) - } - if row.UnitMismatches != c.units || row.ImagesDecoded != c.photoRows { - t.Errorf("%d unités divergentes et %d images décodées, attendu %d et %d", - row.UnitMismatches, row.ImagesDecoded, c.units, c.photoRows) - } - if row.ImagesRejected != 0 { - t.Errorf("%d photo(s) refusée(s) sur un fichier authentique", row.ImagesRejected) - } - // The anomalies are NAMED, one line each: a report that says « 16 anomalies » - // is a filter, one that says which row to fix is a work plan (§10.3 bis). - findings, err := b.db.Findings(context.Background(), row.ID) - if err != nil { - t.Fatalf("Findings : %v", err) - } - anomalies := 0 - for _, f := range findings { - if f.Issue != domain.IssueAnomaly { - continue - } - anomalies++ - if f.CSVLine == 0 || f.ProductID == "" || f.Message == "" { - t.Fatalf("signalement sans où/quoi/pourquoi : %+v", f) - } - } - if anomalies != c.issues { - t.Errorf("%d signalements d'anomalie conservés, attendu %d", anomalies, c.issues) - } - - // Nothing on the CLIENT screen, and no red light. - if s := b.hub.State(); s.Message != nil { - t.Fatalf("bandeau client « %s » pour un catalogue ordinaire", s.Message.Text) - } - if s := b.hub.State(); s.State != domain.Idle { - t.Fatalf("état %s après un import nominal", s.State) - } - if b.technical.has("ERR-CAT-03") || b.technical.has("ERR-CAT-05") { - t.Fatal("un feu rouge s'est allumé sur un catalogue ordinaire") - } - - // The same file again changes nothing at all. - b.drop(c.file) - b.awaitFileGone() - b.awaitImports(2) - if b.hub.State().Message != nil { - t.Fatal("le second dépôt a affiché un bandeau client") - } - if got := b.hub.Catalog().WeighableCount(); got != c.tiles { - t.Errorf("%d tuiles après le second dépôt", got) - } - }) - } -} - -// TestAProductThatLeavesTheFileAgainstTheRealChain is failure test 12 ter on the -// authentic file. -// -// A product absent from the new file is MARKED WITHDRAWN at a date, never deleted. It -// leaves the grid and keeps its weighing history, its local decision and its image — -// « 4 produits retirés » becomes a fact the dashboard can show instead of a silence. -func TestAProductThatLeavesTheFileAgainstTheRealChain(t *testing.T) { - ctx := context.Background() - b := newRealBench(t) - b.drop("flv.csv") - grid := b.awaitTiles(realTiles) - b.awaitFileGone() - - // Four tiles about to disappear, and one of them carries a weighing and a decision. - doomed := make([]domain.Product, 0, 4) - for _, p := range grid.Products() { - if p.Qualification == domain.Weighable && len(doomed) < 4 { - doomed = append(doomed, p) - } - } - ids := map[string]bool{} - for _, p := range doomed { - ids[p.ID] = true - } - weighing := domain.Weighing{ - OccurredAt: store.TestEpoch, Station: 2, JobID: "01J9F2ABC", - IdempotencyKey: "01J9F2ABC", ProductID: doomed[0].ID, ProductName: doomed[0].Name, - Reference: doomed[0].Reference, Mode: doomed[0].Mode, - GrossWeight: 1236, NetWeight: 1236, Quantity: 1, Barcode: "0493021012365", - Source: domain.SourceScale, Stability: domain.Stable, Result: domain.ResultSent, - } - if err := b.db.RecordWeighing(ctx, &weighing); err != nil { - t.Fatalf("RecordWeighing : %v", err) - } - waiver := domain.Grams(8) - decision := domain.LocalDecision{ - ProductID: doomed[0].ID, Offered: false, MinWeightG: &waiver, - Reason: "prix faux chez Odoo", DecidedAt: store.TestEpoch, DecidedBy: "bénévole", - } - if err := b.db.SaveDecision(ctx, decision); err != nil { - t.Fatalf("SaveDecision : %v", err) - } - - // The next file is short of those four rows. - b.dropContent(join(withoutProducts(t, rows(t, fixtureBytes(t, "flv.csv")), ids))) - b.awaitFileGone() - history := b.awaitImports(2) - - if history[0].ProductsWithdrawn != 4 { - t.Fatalf("%d produit(s) retirés dans la ligne d'import, attendu 4 : « 4 produits "+ - "retirés » est la phrase que le tableau de bord doit pouvoir écrire", - history[0].ProductsWithdrawn) - } - // The survivors first: a grid that withdrew everything would satisfy « the four - // that left are gone » without serving anybody. - if got := b.hub.Catalog().Len(); got != realRows-4 { - t.Fatalf("%d produits en grille, attendu %d", got, realRows-4) - } - for _, p := range doomed { - if _, offered := b.hub.Catalog().ByID(p.ID); offered { - t.Fatalf("le produit %s est encore en grille alors qu'il a quitté le fichier", p.ID) - } - row, err := b.db.Product(ctx, p.ID) - if err != nil { - t.Fatalf("le produit %s a été EFFACÉ : %v", p.ID, err) - } - if row.WithdrawnAt.IsZero() { - t.Fatalf("le produit %s n'a pas de date de retrait", p.ID) - } - } - // Its weighing is still readable, and its decision still stands — both columns. - journal, err := b.db.Weighings(ctx, store.JournalFilter{Limit: 10}) - if err != nil || len(journal) != 1 || journal[0].ProductID != doomed[0].ID { - t.Fatalf("%d pesée(s) lisibles : l'historique d'un produit retiré a disparu avec lui : %v", - len(journal), err) - } - kept, err := b.db.Decision(ctx, doomed[0].ID) - if err != nil { - t.Fatalf("la décision locale n'a pas survécu au retrait : %v", err) - } - if kept.Offered || kept.MinWeightG == nil || *kept.MinWeightG != waiver || - kept.Reason != decision.Reason { - t.Errorf("décision relue %+v", kept) - } -} - -// TestALocalDecisionSurvivesTheNextImport is §10.6, and the sentence of §18 it settles: -// « sans que la table ait à survivre à quoi que ce soit ». -// -// « Ne plus proposer ce produit » and the light-product waiver are two COLUMNS of one -// decision, not two mechanisms, and an import is an upsert that does not touch them. -func TestALocalDecisionSurvivesTheNextImport(t *testing.T) { - ctx := context.Background() - b := newRealBench(t) - b.drop("flv.csv") - grid := b.awaitTiles(realTiles) - b.awaitFileGone() - - var chosen domain.Product - for _, p := range grid.Products() { - if p.Qualification == domain.Weighable { - chosen = p - break - } - } - waiver := domain.Grams(8) - decision := domain.LocalDecision{ - ProductID: chosen.ID, Offered: false, MinWeightG: &waiver, - Reason: "code appartenant à un autre article", DecidedAt: store.TestEpoch, - DecidedBy: "bénévole", - } - if err := b.db.SaveDecision(ctx, decision); err != nil { - t.Fatalf("SaveDecision : %v", err) - } - - // The next export carries that product again, unchanged. The decision is a - // JUDGEMENT, and no import overwrites one. - b.dropContent(join(touchLastName(t, rows(t, fixtureBytes(t, "flv.csv"))))) - b.awaitFileGone() - b.awaitImports(2) - - grid = b.awaitTiles(realTiles - 1) - if _, offered := grid.ByID(chosen.ID); offered { - t.Fatalf("le produit %s (%s) est revenu en grille avec l'import suivant", - chosen.ID, chosen.Name) - } - kept, err := b.db.Decision(ctx, chosen.ID) - if err != nil { - t.Fatalf("la décision a disparu avec l'import : %v", err) - } - if kept.Offered { - t.Fatal("l'import a remis le produit en vente") - } - if kept.MinWeightG == nil || *kept.MinWeightG != waiver { - t.Errorf("la dérogation « produit léger » n'a pas survécu : %+v", kept.MinWeightG) - } - if kept.Reason != decision.Reason || !kept.DecidedAt.Equal(decision.DecidedAt) || - kept.DecidedBy != decision.DecidedBy { - t.Errorf("décision relue %+v", kept) - } - // The product itself is still in the catalog, not withdrawn: it is in the file, a - // human simply stopped offering it. - row, err := b.db.Product(ctx, chosen.ID) - if err != nil || !row.WithdrawnAt.IsZero() { - t.Errorf("produit %s marqué retiré alors qu'il est dans le fichier : %v", chosen.ID, err) - } -} diff --git a/internal/station/failures_test.go b/internal/station/failures_test.go index de1d104..28871ff 100644 --- a/internal/station/failures_test.go +++ b/internal/station/failures_test.go @@ -28,9 +28,9 @@ import ( // 1 | TestScaleLossTriggeredByStatusAlone | hub_test.go // 1 bis | TestTheScaleComesBackInsideTwoHundredMilliseconds | HERE // | TestTheScaleComesBack | hub_test.go -// 1 ter | TestSerialToManualAndBack (a)| reload_test.go -// | TestAStartThatFailsBeforeItsGoroutineStillAnswers (b)| reload_test.go -// | TestACloseThatNeverReturnsIsBounded (c)| reload_test.go +// 1 ter | TestSerialToManualAndBack (a)| devices_test.go +// | TestAStartThatFailsBeforeItsGoroutineStillAnswers (b)| devices_test.go +// | TestACloseThatNeverReturnsIsBounded (c)| devices_test.go // 2 | TestABabblingScaleYieldsNoMeasurement | HERE // 3 | TestAScaleThatNeverSaysStableStillPrintsInAdvisory | HERE // 3 bis | TestASlowScaleIsCappedAtTheCeilingAndKeepsWeighing | HERE @@ -43,11 +43,11 @@ import ( // | TestAFullDiskLightsTheDashboardRedAndRefusesNobody | web // 8 | TestACatalogFileStillGrowingIsNotRead | failures_catalog_test.go // 9 | TestACorruptedCatalogIsQuarantinedAndNMinusOneServesOn | failures_catalog_test.go -// 10 | TestTheSameCatalogTwiceIsAppliedThenUnchanged | failures_catalog_test.go +// 10 | TestTheSameCatalogTwiceIsAppliedThenUnchanged | failures_catalog_content_test.go // 11 | TestACatalogFileThatCannotBeDeletedIsAmberAndNotBanned | failures_catalog_test.go -// 12 | TestAnAmputatedCatalogIsRefusedAndNamesItsReasons | failures_catalog_test.go -// 12 bis| TestAnOrdinaryCatalogLightsNothingRed | failures_catalog_test.go -// 12 ter| TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll | failures_catalog_test.go +// 12 | TestAnAmputatedCatalogIsRefusedAndNamesItsReasons | failures_catalog_content_test.go +// 12 bis| TestAnOrdinaryCatalogLightsNothingRed | failures_catalog_content_test.go +// 12 ter| TestAProductThatLeavesTheFileIsWithdrawnAndKeepsAll | failures_catalog_content_test.go // 13 | TestTheCatalogNeverSwapsUnderAFinger | hub_test.go // 14 | TestALockedDatabaseNeverReachesTheCustomer | HERE // 15 | TestDoubleTapPrintsOneLabel | hub_test.go diff --git a/internal/station/fingerprint.go b/internal/station/fingerprint.go new file mode 100644 index 0000000..8fa55b1 --- /dev/null +++ b/internal/station/fingerprint.go @@ -0,0 +1,52 @@ +package station + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" +) + +// This file answers the one question a reload asks of every configuration block: did +// this block MOVE, or was it only serialized differently? + +// BlockFingerprint is the SHA-256 of the CANONICAL JSON of one configuration +// block, in eight hexadecimal characters. +// +// Canonical means: keys sorted, no spaces, numbers re-read literally. That is what +// makes the comparison semantic — two files that differ only by their key order +// must not cut a serial port in the middle of a service — and it is also what the +// administration screen shows to answer « quels blocs ont bougé ? ». +func BlockFingerprint(block any) string { + raw, err := json.Marshal(block) + if err != nil { + // A block that cannot be serialized cannot be compared either. Returning a + // value that is never equal to anything makes the change VISIBLE, which is + // the safe direction: a restart too many beats a port left on a stale + // setting. + return fmt.Sprintf("unmarshalable-%p", block) + } + canonical, err := canonicalJSON(raw) + if err != nil { + return fmt.Sprintf("unmarshalable-%p", block) + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:4]) +} + +// canonicalJSON re-reads and re-writes a JSON document so that two semantically +// identical documents produce the same bytes. +// +// Numbers go through json.Number, so 1605 stays 1605 and does not become 1.605e3 +// through a float64 — a fingerprint that changes with a serialization detail is a +// fingerprint that restarts hardware for nothing. +func canonicalJSON(raw []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return json.Marshal(value) +} diff --git a/internal/station/fingerprint_test.go b/internal/station/fingerprint_test.go new file mode 100644 index 0000000..254324d --- /dev/null +++ b/internal/station/fingerprint_test.go @@ -0,0 +1,80 @@ +package station + +import ( + "encoding/json" + "testing" + + "openscale/internal/domain" +) + +// The one question a reload asks of every block — did it MOVE, or was it only +// serialized differently? A fingerprint that answered on the text would cut a serial +// port in the middle of a service. + +// TestBlockFingerprintIsSemanticAndNotTextual is what keeps a reload from cutting +// a serial port because somebody reordered two JSON keys. +func TestBlockFingerprintIsSemanticAndNotTextual(t *testing.T) { + first := domain.ScaleConfig{ + Type: "gram-xfoc-plus", Present: true, ManualEntryAllowed: true, + Options: mustOptions(t, `{"port":"COM8","baud":9600}`), + } + second := first + second.Options = mustOptions(t, `{"baud":9600,"port":"COM8"}`) + + if BlockFingerprint(first) != BlockFingerprint(second) { + t.Fatal("deux configurations sémantiquement identiques ont des empreintes différentes : " + + "un réordonnancement de clés couperait le port série en plein service") + } + + // The case that really needs canonicalising: a NESTED object. Driver options + // hold raw JSON, so the bytes of printer.options.fallback travel exactly as + // they were typed — key order included. + nested := domain.PrinterConfig{ + Type: "raster", Template: "weighing_identical", + Options: mustOptions(t, `{"fallback":{"enabled":false,"queue":"SATO WS408_3"}}`), + } + reordered := nested + reordered.Options = mustOptions(t, `{"fallback":{"queue":"SATO WS408_3","enabled":false}}`) + if BlockFingerprint(nested) != BlockFingerprint(reordered) { + t.Fatal("un objet imbriqué réordonné change l'empreinte : la file d'impression " + + "serait reconstruite pour un espace de plus dans le fichier") + } + + third := first + third.Options = mustOptions(t, `{"port":"COM9","baud":9600}`) + if BlockFingerprint(first) == BlockFingerprint(third) { + t.Fatal("deux configurations différentes ont la même empreinte : le changement passerait inaperçu") + } + + if got := len(BlockFingerprint(first)); got != 8 { + t.Fatalf("empreinte de %d caractères, attendu 8 : c'est ce que lit l'écran d'administration", got) + } +} + +// TestANumberKeepsItsLiteralInTheFingerprint guards the canonicalisation: a figure +// re-read as a float and printed back in exponent form would change an +// fingerprint, and restart hardware, for nothing. +func TestANumberKeepsItsLiteralInTheFingerprint(t *testing.T) { + options := mustOptions(t, `{"max_amount":99999999999999999999}`) + first := BlockFingerprint(domain.ScaleConfig{Options: options}) + second := BlockFingerprint(domain.ScaleConfig{Options: mustOptions(t, `{"max_amount":99999999999999999999}`)}) + if first != second { + t.Fatal("un grand entier ne survit pas à la canonicalisation") + } +} + +// mustOptions parses driver options from the JSON an operator would have typed. +func mustOptions(t *testing.T, raw string) domain.DriverOptions { + t.Helper() + var options domain.DriverOptions + if err := json.Unmarshal([]byte(raw), &options); err != nil { + t.Fatalf("options illisibles : %v", err) + } + return options +} + +// hasOption reports whether an option carries a given raw JSON value. +func hasOption(options domain.DriverOptions, key, want string) bool { + raw, ok := options[key] + return ok && string(raw) == want +} diff --git a/internal/station/harness_test.go b/internal/station/harness_test.go index c3c244f..606df60 100644 --- a/internal/station/harness_test.go +++ b/internal/station/harness_test.go @@ -2,11 +2,7 @@ package station import ( "context" - "encoding/json" - "os" - "path/filepath" "runtime" - "sync" "testing" "time" @@ -15,6 +11,10 @@ import ( "openscale/internal/station/ports" ) +// The bench: a whole station, on a FAKE CLOCK, with no scale, no printer and no +// browser. Every test of this package weighs through it. What it is built out of — +// the configuration, the catalogs, the recorders — is in doubles_test.go. + // epoch is the instant every test starts at. // // A fixed instant and not « now »: a snapshot, a journal row and a countdown are @@ -58,53 +58,6 @@ const ( // That is the trade the sentence above claims to make, and five did not honour it. const hang = 30 * time.Second -// loadConfig reads the configuration actually shipped with the binary. -// -// The real file and not a literal: a test that invents its own thresholds proves -// nothing about the station anybody will run. -func loadConfig(t *testing.T) domain.Config { - t.Helper() - raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "config-lacagette.json")) - if err != nil { - t.Fatalf("lecture de la configuration livrée : %v", err) - } - var cfg domain.Config - if err := json.Unmarshal(raw, &cfg); err != nil { - t.Fatalf("configuration livrée illisible : %v", err) - } - return cfg -} - -// garlicCatalog is one product, the one every vector of the document is written -// against. -func garlicCatalog() *domain.Catalog { - return domain.NewCatalog( - []domain.Product{{ - ID: garlicID, Name: "AIL", Reference: "0493021000003", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 532, - CategoryCode: "vegetables", Qualification: domain.Weighable, - }}, - []domain.Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, - ) -} - -// leekID is the second product, and it exists for one assertion: failure test 17 (b) -// requires the label to carry the product touched LAST, which a one-product catalog -// cannot tell apart from the product touched first. -const leekID = "7001" - -// twoProductCatalog is the garlic plus one leek, both weighable. -func twoProductCatalog() *domain.Catalog { - return domain.NewCatalog( - append(garlicCatalog().Products(), domain.Product{ - ID: leekID, Name: "POIREAU", Reference: "0493022000002", - Mode: domain.ByWeight, PriceSuffix: " €/kg", UnitPrice: 300, - CategoryCode: "vegetables", Qualification: domain.Weighable, - }), - []domain.Category{{Code: "vegetables", Label: "Légumes", Rank: 1, Visible: true}}, - ) -} - // tapProduct taps one tile by its id, for the tests that need a catalog of more than // one product. func (b *bench) tapProduct(id, key string, seen domain.Grams) domain.Ack { @@ -120,135 +73,6 @@ func (b *bench) tapProduct(id, key string, seen domain.Grams) domain.Ack { return ack } -// recordingJournal is a Journal that keeps what it was given and says so. -type recordingJournal struct { - mu sync.Mutex - weighings []domain.Weighing - purges int - err error - // written is signalled once per row, so a test can wait for the end of a cycle - // without a sleep. - written chan struct{} -} - -func newRecordingJournal() *recordingJournal { - return &recordingJournal{written: make(chan struct{}, 1<<16)} -} - -func (j *recordingJournal) RecordWeighing(_ context.Context, w *domain.Weighing) error { - j.mu.Lock() - if j.err != nil { - err := j.err - j.mu.Unlock() - return err - } - j.weighings = append(j.weighings, *w) - j.mu.Unlock() - j.written <- struct{}{} - return nil -} - -func (j *recordingJournal) PurgeWeighings(context.Context) (int64, error) { - j.mu.Lock() - defer j.mu.Unlock() - j.purges++ - return 0, nil -} - -func (j *recordingJournal) rows() []domain.Weighing { - j.mu.Lock() - defer j.mu.Unlock() - out := make([]domain.Weighing, len(j.weighings)) - copy(out, j.weighings) - return out -} - -// last returns the most recent row WITHOUT copying the whole journal. -// -// It is not a micro-optimisation: the volume test writes ten thousand rows, and -// copying the lot on every one of them is quadratic — four seconds of the -// ten-second budget of §16.4, spent by the test harness on itself. -func (j *recordingJournal) last() domain.Weighing { - j.mu.Lock() - defer j.mu.Unlock() - return j.weighings[len(j.weighings)-1] -} - -// count reports how many rows were written. -func (j *recordingJournal) count() int { - j.mu.Lock() - defer j.mu.Unlock() - return len(j.weighings) -} - -func (j *recordingJournal) purgeCount() int { - j.mu.Lock() - defer j.mu.Unlock() - return j.purges -} - -// recordingTechnical is a TechnicalSink that keeps every line. -type recordingTechnical struct { - mu sync.Mutex - entries []TechnicalEntry -} - -func (r *recordingTechnical) RecordTechnical(_ context.Context, e TechnicalEntry) error { - r.mu.Lock() - defer r.mu.Unlock() - r.entries = append(r.entries, e) - return nil -} - -// has reports whether a line carrying that code was written. -func (r *recordingTechnical) has(code string) bool { return r.count(code) > 0 } - -// count reports how many lines carry that code. -// -// The COUNT and not the presence is what failure test 1 needs: twenty -// StatusDisconnected in a row must produce one line, and a test that only asked -// whether there was one would pass on twenty. -func (r *recordingTechnical) count(code string) int { - r.mu.Lock() - defer r.mu.Unlock() - n := 0 - for _, e := range r.entries { - if e.Code == code { - n++ - } - } - return n -} - -// countSource reports how many lines came from that source. -// -// A source and not a code, because the lines this answers for carry no ERR code: -// « the release server could not be reached » is not a fault of the station, and -// giving it a code would put it in the same list as a printer that has stopped. -func (r *recordingTechnical) countSource(source string) int { - r.mu.Lock() - defer r.mu.Unlock() - n := 0 - for _, e := range r.entries { - if e.Source == source { - n++ - } - } - return n -} - -// lastLevel reports the level of the most recent line of that source. -func (r *recordingTechnical) lastLevel(source string) string { - r.mu.Lock() - defer r.mu.Unlock() - for i := len(r.entries) - 1; i >= 0; i-- { - if r.entries[i].Source == source { - return r.entries[i].Level - } - } - return "" -} - // bench is a whole station running on a fake clock, with no hardware at all. type bench struct { t *testing.T @@ -596,21 +420,3 @@ func stableCount() int { } return previous } - -// nopScale satisfies ports.Scale and does nothing but honour the contract. -type nopScale struct{ descriptor domain.ScaleDescriptor } - -func (s nopScale) Descriptor() domain.ScaleDescriptor { return s.descriptor } - -func (s nopScale) Start(ctx context.Context, _ chan<- domain.ScaleEvent, done chan<- struct{}) error { - go func() { <-ctx.Done(); close(done) }() - return nil -} - -func (s nopScale) Close() error { return nil } - -var _ ports.Scale = nopScale{} - -// fakeClockAt is a clock frozen at one instant, for the tests that drive a bare -// Hub instead of a whole station. -func fakeClockAt(at time.Time) *fake.Clock { return fake.NewClock(at) } diff --git a/internal/station/hub.go b/internal/station/hub.go index 6dab474..01fc473 100644 --- a/internal/station/hub.go +++ b/internal/station/hub.go @@ -11,6 +11,10 @@ import ( "openscale/internal/station/ports" ) +// This file is the Hub itself: its channels, its fields and the accessors that never +// block. What the loop DOES with them is in loop.go, effects.go, subscribers.go and +// publish.go. + // tickInterval is how often the loop wakes up. // // The Tick carries NO temporal semantics (bloquant-1): every duration is computed @@ -18,22 +22,6 @@ import ( // tick decides is only how soon a deadline that has already passed is NOTICED. const tickInterval = 100 * time.Millisecond -// publishThrottle and publishHeartbeat are the two halves of §13.3: at most ten -// snapshots a second when something changes, and one every half second even when -// nothing does, so that a browser that has just reconnected is never left staring -// at a stale banner. -const ( - publishThrottle = 100 * time.Millisecond - publishHeartbeat = 500 * time.Millisecond -) - -// subscriberDepth is the capacity of one subscriber channel. -// -// One. A snapshot 400 ms old has no value, so a slow subscriber gets the stale one -// dropped and the fresh one written; it can never hold the loop back, and the -// reading of the scale can never wait on a browser. -const subscriberDepth = 1 - // ErrStopped reports a Hub that has already returned. A caller gets it instead of // waiting for an answer that will never come. var ErrStopped = errors.New("station: le poste est arrêté") @@ -82,18 +70,6 @@ type job struct { Reprint bool } -// subscription is a request to add or to remove one subscriber. -// -// It exists so that the map of subscribers is touched by the loop goroutine and by -// nothing else. A mutex there would reopen exactly the race this design closes, -// and closing a subscriber channel from a third goroutine while publish is -// emitting on it is a « send on closed channel » (défaut 61). -type subscription struct { - add chan Snapshot - remove chan Snapshot - ack chan struct{} -} - // Hub is the single decision-making goroutine of the station. // // Read the field groups as they are laid out: what only the loop touches needs no @@ -237,8 +213,6 @@ func newHub(o Options) *Hub { return h } -// --- The public surface ---------------------------------------------------- - // Measurements is the channel a scale driver publishes on. // // It is handed to Scale.Start and never closed: the same channel serves the driver @@ -256,62 +230,6 @@ func (h *Hub) State() Snapshot { return *h.state.Load() } // Config returns the configuration in force. func (h *Hub) Config() domain.Config { return *h.cfg.Load() } -// DowntimeRefused carries the guard's OWN French sentence up to the screen. -// -// A type and not a formatted string, for the reason update.BusyError already gives: the -// layer above renders that sentence verbatim, because the guard knows whether a weighing -// or a catalogue is in the way and an HTTP handler does not. Recovering it by cutting a -// prefix off an error message would break the first time either side is reworded. -type DowntimeRefused struct{ Reason string } - -// Error renders the refusal for a log. -func (e *DowntimeRefused) Error() string { - return "station: the station must not be taken down: " + e.Reason -} - -// DowntimeGuard reports whether the station may be taken down, and says IN FRENCH -// why not when it may not. -// -// It answers for the THREE acts that stop the station: installing a new version, -// restarting the service, restarting the machine. The name says « taken down » and -// not « updated » because the rule never depended on what came after the stop -- -// what it protects is the weighing in progress and the catalogue not yet in service. -// -// The rule lives here and not in the HTTP layer, for one reason: the HTTP layer -// would have to read a state in order to deduce a rule, and the rule would then -// exist in two places. It asks a question and renders the answer. -func (h *Hub) DowntimeGuard() (bool, string) { - return downtimeGuardFor(h.State().State, h.catalogWaiting.Load()) -} - -// downtimeGuardFor is the rule itself, without a Hub, so that every state of the -// machine can be put to it in one table. -// -// OutOfService and Faulted PASS, deliberately. A station that cannot serve is -// exactly the one that may need a newer binary, and refusing there would close -// the only door -- which is why NeutralProfile names a repository. -func downtimeGuardFor(state domain.State, catalogWaiting bool) (bool, string) { - if catalogWaiting { - // The CSV has already been read and deleted -- the deletion IS the - // acknowledgement -- and the products live only in memory until a quiet - // moment lets them enter service. Stopping the station here loses them, - // and nothing will ever offer them again. - return false, "Un catalogue vient d'arriver et n'est pas encore en service. Réessayez dans un instant." - } - switch state { - case domain.Initializing, domain.Idle, domain.ManualMode, domain.ScaleLost, - domain.Faulted, domain.OutOfService: - return true, "" - default: - // ProductArmed, WeightPresent, WeightStable, AwaitingStability, - // EnteringTare, EnteringWeight, Validating, Printing, Succeeded and - // Rejected all mean somebody is mid-cycle or reading a result. Each of - // them clears in seconds; the button says to try again rather than - // cutting a label in half. - return false, "Une pesée est en cours. Réessayez dans un instant." - } -} - // Catalog returns the catalog in service, or nil before the first one. func (h *Hub) Catalog() *domain.Catalog { return h.catalog.Load() } @@ -390,75 +308,6 @@ func (h *Hub) PushCatalog(ctx context.Context, b *CatalogBatch) error { } } -// Subscribe returns the snapshot channel of a new subscriber and the function that -// unsubscribes it. -// -// h.subscribers is a field of the Hub JUST LIKE h.model: read and written in the -// loop goroutine only. This function does not touch it — it posts a request on -// h.subscriptions and waits for the ack, or gives up if the Hub has already -// stopped, in which case it closes the channel itself so that the caller's handler -// exits at once rather than waiting for a snapshot nobody will ever send. -func (h *Hub) Subscribe() (<-chan Snapshot, func()) { - ch := make(chan Snapshot, subscriberDepth) - if !h.request(subscription{add: ch, ack: make(chan struct{}, 1)}) { - close(ch) - return ch, func() {} - } - var once sync.Once - unsubscribe := func() { - once.Do(func() { - h.request(subscription{remove: ch, ack: make(chan struct{}, 1)}) - }) - } - return ch, unsubscribe -} - -// request posts one subscription change and reports whether the loop took it. -// -// The final non-blocking read of the ack is what makes the answer exact: the loop -// acks in the same turn it applies the change, so an ack that is not there when -// the Hub is done means the request was never applied — and then, and only then, -// the caller still owns the channel. -func (h *Hub) request(req subscription) bool { - select { - case h.subscriptions <- req: - case <-h.done: - return false - } - select { - case <-req.ack: - return true - case <-h.done: - select { - case <-req.ack: - return true - default: - return false - } - } -} - -// CloseSubscribers closes every subscriber channel and empties the map. -// -// 1. IDEMPOTENT — the body runs once. It has two legitimate call sites, Stop and -// the server's shutdown hook, and running both of them used to be a double -// close and a panic on every shutdown with a browser connected. -// 2. ORDERED — it is called only AFTER the loop has returned, so no publish can -// still be emitting on a channel it closes. gracefulStop, which runs IN the -// loop goroutine just before the loop returns, goes through the same guard: -// depending on the shutdown path either it or the external caller closes, -// never both. -func (h *Hub) CloseSubscribers() { - h.closeOnce.Do(func() { - h.subscribersMu.Lock() - defer h.subscribersMu.Unlock() - for ch := range h.subscribers { - close(ch) - delete(h.subscribers, ch) - } - }) -} - // TechnicalLog returns the ports.TechnicalLog every driver receives. // // It never blocks and never opens a file: the entry goes onto a bounded channel @@ -490,608 +339,3 @@ func (h *Hub) logTechnical(level, source, code, message, detail string) { h.counters.DroppedTechnicalEntries.Add(1) } } - -// --- The loop -------------------------------------------------------------- - -// run is the single goroutine that decides. It returns when ctx is done. -// -// ticks comes from the INJECTED CLOCK, not from time.NewTicker, and it is -// registered by the CALLER so that its first tick does not depend on when the -// scheduler got here. With the fake clock, Advance(2*time.Second) really produces -// the twenty ticks, so stability, expiry, interface timeouts and the reprint -// window are genuinely exercised in microseconds of wall time instead of being -// tested by a sleep that tests nothing. -func (h *Hub) run(ctx context.Context, ticks <-chan time.Time) { - defer close(h.done) - - var deferredEvents []domain.Event - - for { - var ev domain.Event - - if len(deferredEvents) > 0 { - ev, deferredEvents = deferredEvents[0], deferredEvents[1:] - } else { - select { - case <-ctx.Done(): - h.gracefulStop() - return - - case e := <-h.measurements: - next, ok := h.receive(e) - if !ok { - continue - } - ev = next - - case c := <-h.commands: - if ack, seen := h.idempotency.Lookup(c.Key); seen { - // A key already answered REPLAYS the answer and executes - // nothing: that is the whole of failure test 15. - reply(c.Reply, ack) - continue - } - h.pendingReply = c.Reply - ev = c.Ev - - case r := <-h.printResults: - ev = domain.PrintFinished{JobID: r.JobID, Err: r.Err, Duration: r.Duration} - - case batch := <-h.incomingCatalog: - if h.catalog.Load() != nil { - // DEFERRED swap (§10.8): a catalog never takes service under a - // customer's finger. The Tick drains it when the station has - // been idle and untouched for MaxSwitchIdle. - h.pendingBatch = batch - h.catalogWaiting.Store(true) - continue - } - // NOTHING is on screen yet. There is no finger to reorder tiles - // under, and a station showing « Catalogue vide » must serve the - // moment it can, so the first catalog goes THROUGH THE MACHINE — - // which is also what takes it out of Initializing. - ev = domain.CatalogReady{Catalog: batch.Catalog, ImportedAt: batch.ImportedAt} - - case req := <-h.subscriptions: - h.applySubscription(req) - continue - - case <-ticks: - ev = domain.Tick{} - } - } - - now := h.clock.Now() - cfg := *h.cfg.Load() - previous := h.model - - // ---- THE ONLY PLACE WHERE THE MODEL CHANGES ---------------------- - next, effects := domain.Transition(h.model, ev, domain.TransitionContext{ - Cfg: cfg, - Now: now, - LastMeasurement: h.lastMeasurement, - // The age is COMPUTED, never accumulated. A lost tick can no longer - // UNDER-COUNT it and let an expired weight through (bloquant-1). - MeasurementAge: h.measurementAge(now), - Expiry: h.expiry(cfg), - Catalog: h.catalog.Load(), - }) - h.model = next - - if next.State == domain.Idle && previous.State != domain.Idle { - // The bag left the plate, or the cycle was cancelled. That physical - // signal is what empties the banner — not a stopwatch (§14.3). - h.message = nil - } - - // « quiet for MaxSwitchIdle » is one clock: the last instant the station was - // doing anything at all. A Tick is deliberately not one — it is what MEASURES - // the wait, so counting it would reset it for ever. - // - // The states that keep the clock running are the same ones the swap refuses, - // and they have to be: reading `State != Idle` here meant that a station with - // no scale sat in ScaleLost refreshing this instant on every tick, so the wait - // never elapsed and the FIRST catalog never took service. Two conditions that - // must agree are written once. - if !swapIsSafeIn(h.model.State) || isInteraction(ev) { - h.lastInteraction = now - } - - h.applyPendingBatch(now) - - for _, ef := range effects { - // execute NEVER blocks and NEVER calls Transition: an effect that has - // to re-inject an event pushes it onto deferredEvents, drained at the - // top of the next turn. Calling back into the loop from here would be - // an immediate deadlock. - if e := h.execute(ef, now); e != nil { - deferredEvents = append(deferredEvents, e) - } - } - - // A COMMAND CYCLE ALWAYS REPLIES. The hard rule is that every terminal - // transition emits an AckEffect; this block makes it true by construction - // rather than by discipline. A rejection, a blocking safeguard, a hidden - // product, an event the current state ignores: without it, pendingReply - // stays set, the caller waits without a deadline, and the next command - // overwrites the channel having never answered on it — one leaked - // goroutine per refused command, in the very component whose goroutine - // inventory §13.1 claims to be exhaustive. - if h.pendingReply != nil { - reply(h.pendingReply, defaultAck(h.model, ev)) - h.pendingReply = nil - } - - h.publish(now) - } -} - -// receive turns one scale event into the event the machine understands, and -// reports whether there is one at all. -// -// The trigger of a scale loss is the Status field ALONE (défaut 40). The last -// event a driver emits on its way out does carry a non-nil Err, but the Err -// CONDITIONS nothing: it is a logged reason. Making the loss depend on an optional -// field is what let the signal fall into a default branch and never reach the -// machine. -func (h *Hub) receive(e domain.ScaleEvent) (domain.Event, bool) { - switch { - case e.Status == domain.StatusDisconnected: - return domain.ScaleDisconnected{Err: e.Err}, true - - case e.Status == domain.StatusConnected && h.model.State == domain.ScaleLost: - // Intervals measured across an outage describe the outage, not the - // cadence: a rate meter that kept them would derive an expiry from a hole. - h.rate.Reset() - return domain.ScaleReconnected{}, true - - case e.Measurement != nil: - h.seq++ - m := *e.Measurement - m.Seq = h.seq - h.lastMeasurement = m - h.rate.Observe(m) - return domain.MeasurementReceived{M: m}, true - } - return nil, false -} - -// measurementAge is how old the last reading is, on the injected clock. -// -// Before the first frame there is nothing to age, and reporting a duration counted -// from the zero instant would make every safeguard refuse on a station that has -// just started. -func (h *Hub) measurementAge(now time.Time) time.Duration { - if h.lastMeasurement.Timestamp.IsZero() { - return 0 - } - return now.Sub(h.lastMeasurement.Timestamp) -} - -// expiry is how old a reading may get before it is refused, DERIVED from the -// cadence actually observed (A3). -func (h *Hub) expiry(cfg domain.Config) time.Duration { - return h.rate.Expiry(cfg.Stability, time.Duration(h.nominalRate.Load())) -} - -// swapIsSafeIn reports the states a catalog may be swapped in. -// -// The rule the deferred swap enforces is « never reorder the tiles under a -// customer's finger » (failure test 13), and the states where there IS no finger are -// more than Idle alone. Requiring Idle strictly was found by running a real station: -// with no scale plugged in the machine sits in ScaleLost for ever, so the FIRST -// catalog of a fresh station never took service — the file was read, the 355 rows -// were written to the base, the archive was made, and the grid stayed empty. A -// station that cannot show its catalog until somebody plugs a scale in is not the -// station §15.4 describes, which says « Catalogue vide. En attente de flv_.csv » -// precisely because it expects to leave that state on a file and not on a cable. -// -// So: every state that carries a weighing in progress, or a label the customer is -// still looking at, refuses the swap. The rest accept it. -func swapIsSafeIn(state domain.State) bool { - switch state { - case domain.Idle, domain.Initializing, domain.ScaleLost, domain.ManualMode: - return true - default: - // ProductArmed, WeightPresent, WeightStable, AwaitingStability, EnteringTare, - // EnteringWeight, Validating, Printing, Succeeded, Rejected, Faulted and - // OutOfService all mean somebody is mid-cycle or reading a result. - return false - } -} - -// applyPendingBatch swaps the catalog in, but only when nobody is weighing. -// -// MaxSwitchIdle is a CODE CONSTANT of the domain and never a configuration key: -// setting it to zero would reopen the failure mode where an import reorders the -// tiles under a customer's finger (failure test 13). -func (h *Hub) applyPendingBatch(now time.Time) { - if h.pendingBatch == nil || !swapIsSafeIn(h.model.State) { - return - } - if now.Sub(h.lastInteraction) < domain.MaxSwitchIdle { - return - } - // The batch's own instant, and NOT `now`: the swap is deliberately later than the - // import — that is what MaxSwitchIdle buys — and the screen states the import. - h.storeCatalog(h.pendingBatch.Catalog, h.pendingBatch.ImportedAt) - h.pendingBatch = nil - h.catalogWaiting.Store(false) -} - -// applySubscription adds or removes one subscriber, in the loop goroutine. -// -// Subscribing, unsubscribing and closing subscriber channels are SERIALIZED here, -// in the only goroutine allowed to touch h.subscribers. That is what makes the -// single-writer invariant true of the map itself, and not of the easy fields -// alone. -func (h *Hub) applySubscription(req subscription) { - switch { - case req.add != nil: - h.subscribersMu.Lock() - h.subscribers[req.add] = struct{}{} - h.subscribersMu.Unlock() - // A new subscriber gets the current state at once rather than waiting for - // the next change: a browser that has just restarted must be correct - // immediately. - req.add <- h.lastPublished - case req.remove != nil: - h.subscribersMu.Lock() - _, live := h.subscribers[req.remove] - if live { - delete(h.subscribers, req.remove) - } - h.subscribersMu.Unlock() - if live { - close(req.remove) - } - } - select { - case req.ack <- struct{}{}: - default: - } -} - -// gracefulStop runs in the loop goroutine, just before the loop returns. -// -// It drains the subscription requests that arrived and were never served, then -// closes every subscriber channel through the SAME guard as CloseSubscribers. -// Draining matters: a request left in the buffer would be a Subscribe whose -// caller waits for a snapshot nobody will ever send. -func (h *Hub) gracefulStop() { - for { - select { - case req := <-h.subscriptions: - h.applySubscription(req) - default: - h.CloseSubscribers() - return - } - } -} - -// --- Effects --------------------------------------------------------------- - -// execute performs one effect. It NEVER blocks and NEVER calls Transition. -// -// When an effect has to make something happen inside the machine, it returns the -// event instead of injecting it, and the loop drains it on the next turn. -func (h *Hub) execute(ef domain.Effect, now time.Time) domain.Event { - switch e := ef.(type) { - case domain.PrintEffect: - return h.print(e) - - case domain.RecordEffect: - select { - case h.journalEntries <- e.Weighing: - default: - // Slow or full disk: the weighing is LOST FOR THE JOURNAL, but the - // label came out and the customer is served. - // WE DEGRADE THE JOURNAL, NEVER THE SERVICE. - h.counters.UnloggedWeighings.Add(1) - h.ring.Add(e.Weighing) - } - - case domain.AckEffect: - h.idempotency.Store(e.Key, e.Ack) - reply(h.pendingReply, e.Ack) - h.pendingReply = nil - - case domain.MessageEffect: - message := Message{Level: e.Level, Code: e.Code, Text: e.Text} - if e.Duration > 0 { - message.ExpiresAt = now.Add(e.Duration) - } - h.message = &message - - case domain.SoundEffect: - // The BROWSER plays the sound; the backend does no audio I/O. - h.sound = e.Name - - case domain.TechnicalLogEffect: - h.logTechnical(e.Level, e.Source, e.Code, e.Message, e.Detail) - - case domain.ArmTimerEffect: - h.armExpiresAt = now.Add(e.Duration) - - case domain.ApplyCatalogEffect: - h.storeCatalog(e.Catalog, e.ImportedAt) - } - return nil -} - -// print hands one label to the worker, and turns a saturated worker into the -// failure the machine already knows how to answer. -func (h *Hub) print(e domain.PrintEffect) domain.Event { - cfg := h.cfg.Load() - j := job{ - Label: e.Label, - Template: h.template(cfg.Printer.Template), - Locale: cfg.UI.Language, - Copies: copies(cfg), - Reprint: e.Reprint, - } - select { - case h.printJobs <- j: - return nil - default: - h.logTechnical(domain.LevelError, "printer", "ERR-PRN-09", - "Worker d'impression saturé.", e.Label.JobID) - return domain.PrintFinished{JobID: e.Label.JobID, Err: ErrPrintWorkerBusy} - } -} - -// template resolves printer.template against the templates the station was given. -// -// A name that resolves to nothing yields the zero template rather than a panic: a -// configuration control refuses an unknown template long before a customer stands -// at the scale (§11.3), and a station that has got past that control must keep -// serving. -func (h *Hub) template(name string) domain.Template { - if t, ok := h.templates[name]; ok { - return t - } - return domain.Template{} -} - -// copies is printer.options.copies, which is 1 on the shipped file. -// -// A count the operator left at zero or below is ONE, not none: a station that -// prints nothing because a field is empty is a station nobody can debug. -func copies(cfg *domain.Config) int { - n, ok := cfg.Printer.Options.Int("copies") - if !ok || n < 1 { - return 1 - } - return int(n) -} - -// reply NEVER BLOCKS. -// -// The ack channel has a capacity of one and is written once, but the default -// covers the caller that gave up before the answer — browser closed, request -// context cancelled. A nil channel is tolerated too: a command can be injected -// without a caller. -func reply(ch chan<- domain.Ack, a domain.Ack) { - if ch == nil { - return - } - select { - case ch <- a: - default: // caller gone — we do not hold the Hub goroutine back for it - } -} - -// defaultAck derives, from the resulting model, the answer that no effect -// produced: the state reached, the refusal and its code, never a JobID. -// -// It is distinct from an acceptance ack — Accepted stays false — and the -// administration screen renders it as such. -func defaultAck(m domain.Model, ev domain.Event) domain.Ack { - ack := domain.Ack{State: m.State} - if blocking := domain.FirstBlocking(m.Diagnostics); blocking != nil { - ack.Code, ack.Message = blocking.Code, blocking.Message - return ack - } - if _, isReprint := ev.(domain.ReprintRequested); isReprint { - ack.Message = "Cette étiquette ne peut plus être réimprimée." - return ack - } - ack.Message = "Cette action n'est pas possible pour l'instant." - return ack -} - -// --- Publication ----------------------------------------------------------- - -// publish emits the snapshot, throttled to 10 Hz with a forced heartbeat every -// 500 ms. -// -// now is a PARAMETER: the same clock as the ticker, read once per turn. And -// publishPending is CONSUMED — without that, on a fake clock the Hub published a -// single snapshot and then fell silent for good. -func (h *Hub) publish(now time.Time) { - s := h.buildSnapshot(now) - changed := s.Revision != h.lastPublished.Revision - since := now.Sub(h.lastPublishedAt) - - if !changed && !h.publishPending && since < publishHeartbeat { - return - } - if changed && since < publishThrottle { - h.publishPending = true // it goes out on the next tick - return - } - h.publishPending = false - h.lastPublished, h.lastPublishedAt = s, now - h.state.Store(&s) - h.beat.Store(now.UnixNano()) - // A sound is an EDGE, not a state: it is played once, so it leaves the model - // as soon as a snapshot has carried it. - h.sound = "" - - // The whole fan-out happens UNDER the lock, and that is deliberate: every send - // below is a select with a default, so nothing here can block, and holding the - // lock is what makes a close impossible between choosing a channel and sending on - // it. Copying the channels out first and sending outside would reintroduce exactly - // the send-on-closed-channel that CloseSubscribers is ordered to avoid. - h.subscribersMu.Lock() - defer h.subscribersMu.Unlock() - for ch := range h.subscribers { - select { - case ch <- s: - default: - // Capacity one: drop the stale snapshot and write the fresh one. A - // snapshot 400 ms old has no value, and a slow subscriber must never - // hold the reading of the scale back. - select { - case <-ch: - default: - } - select { - case ch <- s: - default: - } - } - } -} - -// buildSnapshot freezes what the screen has to know. -// -// Revision carries over from the last published snapshot and goes up only when the -// content actually changed, which is what makes the throttle of publish meaningful -// rather than a timer that fires for nothing. -func (h *Hub) buildSnapshot(now time.Time) Snapshot { - cfg := *h.cfg.Load() - expiry := h.expiry(cfg) - age := h.measurementAge(now) - hasWeight := !h.lastMeasurement.Timestamp.IsZero() - - s := Snapshot{ - At: now, - State: h.model.State, - Weight: Weight{ - Gross: h.lastMeasurement.Gross, - Tare: h.model.Tare, - Net: h.lastMeasurement.Gross - h.model.Tare, - Quantity: h.model.Units, - Stability: h.lastMeasurement.Stability, - Latched: h.model.LatchState.Latched, - Seq: h.lastMeasurement.Seq, - Age: age, - Expiry: expiry, - }, - HasWeight: hasWeight, - // The comparison is STRICT, exactly as safeguard rule 2 states it: at the - // expiry itself the weight is still good, one millisecond later it is not. - Expired: hasWeight && age > expiry, - Product: h.model.CurrentProduct, - Tare: h.model.Tare, - Units: h.model.Units, - Label: h.model.Label, - LastLabel: h.model.LastLabel, - LastPrintedAt: h.model.LastPrintedAt, - ReprintAvailable: h.reprintAvailable(cfg, now), - Message: h.liveMessage(now), - Sound: h.sound, - Diagnostics: copyDiagnostics(h.model.Diagnostics), - FaultCode: h.model.FaultCode, - ArmingExpiresAt: h.armingDeadline(), - Catalog: h.catalog.Load(), - Scale: h.scaleHealth(cfg), - Degraded: h.degraded.Load(), - Station: cfg.Station.Number, - UnloggedWeighings: h.counters.UnloggedWeighings.Load(), - } - if p := h.health.Load(); p != nil { - s.Printer = *p - } - - s.Revision = h.lastPublished.Revision - if !s.sameContentAs(h.lastPublished) { - s.Revision++ - } - return s -} - -// reprintAvailable reports whether the permanent bottom bar has anything to offer. -// -// A window of zero disables reprinting, which is the only sensible reading of « how -// long the bar stays active » = 0. -func (h *Hub) reprintAvailable(cfg domain.Config, now time.Time) bool { - if h.model.LastLabel == nil || h.model.Reprinted { - return false - } - window := time.Duration(cfg.UI.ReprintWindowSeconds) * time.Second - return window > 0 && now.Sub(h.model.LastPrintedAt) <= window -} - -// liveMessage drops a banner nobody came back for. -// -// A message with no expiry survives until the state changes, and that is on -// purpose: a station with no scale does not stop having no scale because five -// seconds went by. -func (h *Hub) liveMessage(now time.Time) *Message { - if h.message == nil { - return nil - } - if !h.message.ExpiresAt.IsZero() && now.After(h.message.ExpiresAt) { - return nil - } - return h.message -} - -// armingDeadline reports the end of the bounded wait the station is in, and zero -// when it is not waiting for anything. -// -// It is derived from the STATE rather than cleared by hand, so no path can leave a -// countdown running on a screen that has moved on. -func (h *Hub) armingDeadline() time.Time { - switch h.model.State { - case domain.ProductArmed, domain.AwaitingStability, - domain.EnteringTare, domain.EnteringWeight: - return h.armExpiresAt - } - return time.Time{} -} - -// scaleHealth is what the station can say about its scale without asking it. -func (h *Hub) scaleHealth(cfg domain.Config) ScaleHealth { - median, measured := h.rate.Median() - tooSlow, _ := h.rate.RateIsTooSlow(cfg.Stability) - return ScaleHealth{ - Connected: h.model.State != domain.ScaleLost, - Median: median, - Observations: h.rate.Observations(), - Provisional: !measured, - TooSlow: tooSlow, - } -} - -// copyDiagnostics freezes what the safeguards said. -// -// A copy, because a snapshot is published and a published value is never allowed -// to change behind a reader's back. -func copyDiagnostics(in []domain.Diagnostic) []domain.Diagnostic { - if len(in) == 0 { - return nil - } - out := make([]domain.Diagnostic, len(in)) - copy(out, in) - return out -} - -// isInteraction reports whether an event is somebody DOING something. -// -// It is the list of the events a human causes, written out rather than inferred: -// what it protects is the deferred catalog swap, and « the customer stopped -// touching the screen ten seconds ago » must not be answered by a timer the -// station generates for itself. -func isInteraction(ev domain.Event) bool { - switch ev.(type) { - case domain.ProductTapped, domain.TareTapped, domain.TareConfirmed, - domain.ManualWeightConfirmed, domain.ReprintRequested, - domain.Cancel, domain.Dismiss: - return true - } - return false -} diff --git a/internal/station/hub_test.go b/internal/station/hub_test.go index b8f6314..61c3ff3 100644 --- a/internal/station/hub_test.go +++ b/internal/station/hub_test.go @@ -2,15 +2,21 @@ package station import ( "context" - "encoding/json" "errors" - "strings" "testing" "time" "openscale/internal/domain" ) +// One customer, one cycle, through the loop of loop.go: a bag on the plate, a tile +// tapped, a label. And the four things that interrupt it — a second tap, a weight that +// has expired, a scale that goes and comes back, and a catalog that must not reorder +// the tiles under a finger. +// +// What the EFFECTS of a cycle do is in effects_test.go, what goes out on the wire in +// publish_test.go. + // TestWeighingEndToEnd proves that one tap yields one label, that what is printed // is what was displayed, and that a repeated idempotency key prints nothing more. // @@ -353,168 +359,6 @@ func TestArmingExpiresBeforeNextCustomerBag(t *testing.T) { } } -// TestNoLeakOnCommandWithoutAck is the test §13.2 names. -// -// Five hundred refused commands alternated with five hundred nominal ones, then -// the goroutine count compared with the baseline AT REST, WITH NO CLIENT -// CONNECTED. Without the end-of-cycle safety net, every refusal leaks the -// goroutine of its caller. -func TestNoLeakOnCommandWithoutAck(t *testing.T) { - b := newBench(t) - b.push(1236, domain.Stable) - b.tick() - - baseline := stableCount() - - ctx := context.Background() - for i := 0; i < 500; i++ { - // Refused: a product the catalog does not offer, then an event the current - // state has nothing to say about. - if _, err := b.hub.Submit(ctx, domain.ProductTapped{ProductID: "inconnu"}, ""); err != nil { - t.Fatalf("Submit(produit inconnu) : %v", err) - } - if _, err := b.hub.Submit(ctx, domain.Dismiss{}, ""); err != nil { - t.Fatalf("Submit(Dismiss hors Faulted) : %v", err) - } - if _, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: "jamais imprimé"}, ""); err != nil { - t.Fatalf("Submit(réimpression impossible) : %v", err) - } - } - - if got := stableCount(); got > baseline { - t.Fatalf("%d goroutines après 1 500 commandes refusées, ligne de base %d : une commande "+ - "refusée laisse son appelant en attente", got, baseline) - } -} - -// TestARefusedCommandStillAnswers checks the CONTENT of the safety net, not only -// that it fires: the answer names the state reached and refuses in French. -func TestARefusedCommandStillAnswers(t *testing.T) { - b := newBench(t) - ctx := context.Background() - - ack, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: "jamais imprimé"}, "") - if err != nil { - t.Fatalf("Submit : %v", err) - } - if ack.Accepted { - t.Fatal("une réimpression impossible a été acceptée") - } - if ack.Message == "" { - t.Fatal("un refus sans message : l'écran n'a rien à afficher") - } - if !strings.Contains(ack.Message, "réimprim") { - t.Fatalf("message %q : il doit parler de la réimpression", ack.Message) - } -} - -// TestSubmitAnswersWhenTheHubIsGone proves the symmetric half of the contract: a -// caller never waits on the channel alone. -func TestSubmitAnswersWhenTheHubIsGone(t *testing.T) { - b := newBench(t) - b.station.Stop() - <-b.station.Stopped() - - if _, err := b.hub.Submit(context.Background(), domain.Cancel{}, ""); !errors.Is(err, ErrStopped) { - t.Fatalf("erreur %v, attendu ErrStopped", err) - } -} - -// TestASaturatedPrintWorkerBecomesAPrintFailure covers the branch of execute that -// only an ABNORMAL station reaches: the machine forbids two prints inside one -// cycle, so a full channel means the worker is stuck on a device. -// -// It is driven directly rather than through the loop, and that is the honest way -// to test it: getting there through the machine would require a third cycle to -// start while the second is still printing, which the machine refuses — the test -// would prove the setup, not the branch. -func TestASaturatedPrintWorkerBecomesAPrintFailure(t *testing.T) { - h := newHub(Options{ - Clock: fakeClockAt(epoch), Config: loadConfig(t), Catalog: garlicCatalog(), - Counters: &Counters{}, - }) - h.printJobs <- job{} // the worker is stuck: the one slot is taken - - ev := h.execute(domain.PrintEffect{Label: domain.Label{JobID: "j-42"}}, epoch) - finished, ok := ev.(domain.PrintFinished) - if !ok { - t.Fatalf("execute rend %T, attendu un domain.PrintFinished réinjecté", ev) - } - if finished.JobID != "j-42" { - t.Fatalf("job %q, attendu j-42", finished.JobID) - } - if !errors.Is(finished.Err, ErrPrintWorkerBusy) { - t.Fatalf("erreur %v, attendu ErrPrintWorkerBusy", finished.Err) - } - select { - case entry := <-h.technical: - if entry.Code != "ERR-PRN-09" { - t.Fatalf("code technique %q, attendu ERR-PRN-09", entry.Code) - } - default: - t.Fatal("aucune ligne technique : la saturation du worker n'est pas journalisée") - } -} - -// TestTheHubKeepsAnsweringWhileThePrinterHangs is failure test 6 seen from the -// Hub: the device is stuck, and neither the loop nor a caller waits on it. -func TestTheHubKeepsAnsweringWhileThePrinterHangs(t *testing.T) { - b := newBench(t) - b.printer.Hang() - defer b.printer.Release() - - b.feed(1236, 2) - if ack := b.tap("hung", 1236); !ack.Accepted { - t.Fatalf("pesée refusée : %s", ack.Message) - } - // The loop keeps turning and keeps answering while the device holds the worker. - for i := 0; i < 10; i++ { - b.tick() - } - if got := b.hub.State().State; got != domain.Printing { - t.Fatalf("état %s : le cycle ne devrait pas se terminer sans réponse de l'imprimante", got) - } -} - -// TestTheJournalDegradesAndTheServiceDoesNot is ADR-013 from the failing side: the -// store refuses, the label still came out, the weighing lands in the RAM ring and -// the counter goes up. -func TestTheJournalDegradesAndTheServiceDoesNot(t *testing.T) { - b := newBench(t) - b.journal.mu.Lock() - b.journal.err = errors.New("disque plein") - b.journal.mu.Unlock() - - b.feed(1236, 2) - if ack := b.tap("disk-full", 1236); !ack.Accepted { - t.Fatalf("pesée refusée alors que seul le journal est en panne : %s", ack.Message) - } - b.awaitPrint() - if n := len(b.printer.Jobs()); n != 1 { - t.Fatalf("%d étiquettes : la pesée doit sortir même quand le journal ne suit pas", n) - } - // The store refuses, so the counter is what says so. It is written by the - // journal worker, which is why the assertion converges instead of snapshotting. - counters := b.station.Counters() - for i := 0; i < 20000 && counters.UnloggedWeighings.Load() == 0; i++ { - b.tick() - if counters.UnloggedWeighings.Load() != 0 { - break - } - } - if got := counters.UnloggedWeighings.Load(); got != 1 { - t.Fatalf("compteur de pesées non journalisées = %d, attendu 1", got) - } - // The row goes to the RAM ring, exactly as it does when the channel is - // saturated: a disk that is full and a channel nobody drains lose the same row - // for the same customer, and failure test 7 asks for the ring on the first of - // the two (§16.2, ADR-013). The counter says HOW MANY were lost; only the ring - // says WHICH ONES. - if entries := b.hub.Entries(); len(entries) != 1 { - t.Fatalf("%d pesée(s) dans l'anneau RAM, attendu 1", len(entries)) - } -} - // TestTheCatalogNeverSwapsUnderAFinger is failure test 13. func TestTheCatalogNeverSwapsUnderAFinger(t *testing.T) { initial := garlicCatalog() @@ -565,259 +409,3 @@ func TestTheCatalogNeverSwapsUnderAFinger(t *testing.T) { t.Fatal("le catalogue n'a jamais basculé alors que le poste est au repos depuis 10 s") } } - -// TestAChangeInsideTheThrottleGoesOutOnTheNextTick pins both halves of the -// publication rule at once: nothing is emitted twice inside 100 ms, and what was -// held back is emitted on the very next tick rather than waiting for the 500 ms -// heartbeat. -func TestAChangeInsideTheThrottleGoesOutOnTheNextTick(t *testing.T) { - b := newBench(t) - snapshots, unsubscribe := b.hub.Subscribe() - defer unsubscribe() - <-snapshots // the state a new subscriber gets at once - - b.tick() // a publication happens here, and fixes the throttle window - drain(snapshots) - - // Same instant as the last publication: the change is held back. - b.push(1236, domain.Stable) - b.flush() - if len(snapshots) != 0 { - t.Fatal("un changement a été publié dans les 100 ms de la publication précédente") - } - - b.tick() - if len(snapshots) != 1 { - t.Fatal("le snapshot retenu par le throttle n'est jamais parti") - } - if got := (<-snapshots).Weight.Gross; got != 1236 { - t.Fatalf("poids publié %d g, attendu 1236 g", got) - } -} - -// drain empties a subscriber channel without blocking. -func drain(snapshots <-chan Snapshot) { - for { - select { - case <-snapshots: - default: - return - } - } -} - -// TestPublicationIsThrottledAndStillBeats checks both halves of §13.3 at once: -// nothing is published twice in 100 ms, and something is published at least every -// 500 ms even when nothing changes. -func TestPublicationIsThrottledAndStillBeats(t *testing.T) { - b := newBench(t) - snapshots, unsubscribe := b.hub.Subscribe() - defer unsubscribe() - <-snapshots // the state a new subscriber gets at once - - // Nothing changes for two seconds: the forced heartbeat must still fire, and - // it must not fire ten times a second. - beats := 0 - for i := 0; i < 20; i++ { - b.tick() - select { - case <-snapshots: - beats++ - default: - } - } - if beats == 0 { - t.Fatal("aucun battement en 2 s : un navigateur reconnecté resterait sur un bandeau figé") - } - if beats > 8 { - t.Fatalf("%d battements en 2 s alors que rien ne change : le throttle ne tient pas", beats) - } -} - -// TestASlowSubscriberNeverHoldsTheHub proves the drop-old rule: a subscriber that -// stops reading gets the stale snapshot dropped, never the loop blocked. -func TestASlowSubscriberNeverHoldsTheHub(t *testing.T) { - b := newBench(t) - snapshots, unsubscribe := b.hub.Subscribe() - defer unsubscribe() - - // Never read from snapshots, and keep the station busy. - for i := 0; i < 50; i++ { - b.push(domain.Grams(1000+i), domain.Stable) - b.tick() - } - if got := b.hub.State().Weight.Gross; got != 1049 { - t.Fatalf("dernier poids %d g, attendu 1049 g : la boucle a été retenue par un abonné", got) - } - if n := len(snapshots); n > subscriberDepth { - t.Fatalf("%d snapshots en attente, capacité %d", n, subscriberDepth) - } -} - -// TestARefusedWeighingAnswersWithItsSafeguardCode covers the end-of-cycle safety -// net where it matters most: a blocking safeguard produces no AckEffect of its own -// in some paths, and the answer must still name the code the screen shows. -func TestARefusedWeighingAnswersWithItsSafeguardCode(t *testing.T) { - b := newBench(t) - // Eight grams: under min_weight_g, which is 10 on the shipped file. - b.feed(8, 2) - ack := b.tap("too-light", 8) - if ack.Accepted { - t.Fatal("une pesée de 8 g a été acceptée alors que le plancher est à 10 g") - } - if ack.Code != domain.CodeWeightTooLow { - t.Fatalf("code %q, attendu %q", ack.Code, domain.CodeWeightTooLow) - } - if ack.Message == "" { - t.Fatal("un refus sans message : le client ne sait pas quoi corriger") - } - if n := len(b.printer.Jobs()); n != 0 { - t.Fatalf("%d étiquettes pour une pesée refusée", n) - } - - // And the refusal IS journalled, with what it would have cost. - row := b.awaitJournal() - if row.Result != domain.ResultRejected { - t.Fatalf("résultat %q, attendu %q", row.Result, domain.ResultRejected) - } - if row.Detail == "" { - t.Fatal("le journal ne dit pas pourquoi la pesée a été refusée") - } -} - -// TestDefaultAckNamesTheStateAndNeverAJobID is the unit of the safety net. -func TestDefaultAckNamesTheStateAndNeverAJobID(t *testing.T) { - rejected := domain.Model{ - State: domain.Rejected, - Diagnostics: []domain.Diagnostic{ - {Code: domain.CodeWeightTooLow, Severity: domain.Blocking, - Message: domain.DefaultMessage(domain.CodeWeightTooLow)}, - }, - } - ack := defaultAck(rejected, domain.ProductTapped{}) - if ack.Accepted { - t.Fatal("le filet de fin de cycle prétend accepter : il n'est pas un accusé d'acceptation") - } - if ack.State != domain.Rejected || ack.Code != domain.CodeWeightTooLow { - t.Fatalf("accusé %+v : il doit porter l'état atteint et le code bloquant", ack) - } - if ack.JobID != "" { - t.Fatal("le filet de fin de cycle a inventé un JobID : aucune étiquette n'est partie") - } - - silent := defaultAck(domain.Model{State: domain.Idle}, domain.Dismiss{}) - if silent.Message == "" { - t.Fatal("un événement ignoré rend un accusé muet : l'appelant n'a rien à afficher") - } -} - -// TestABannerExpiresOnTheInjectedClock: what really ends a message is the physical -// signal, and these durations only bound a message nobody came back for. -func TestABannerExpiresOnTheInjectedClock(t *testing.T) { - b := newBench(t) - b.feed(8, 2) - b.tap("too-light", 8) - b.tick() - - message := b.hub.State().Message - if message == nil { - t.Fatal("aucun bandeau après un refus") - } - if message.Code != domain.CodeWeightTooLow { - t.Fatalf("code du bandeau %q, attendu %q", message.Code, domain.CodeWeightTooLow) - } - - b.advance(domain.RejectMessageDuration + time.Second) - if got := b.hub.State().Message; got != nil { - t.Fatalf("le bandeau %q survit à sa durée", got.Text) - } -} - -// TestTheScaleLossBannerHasNoExpiry: a station with no scale does not stop having -// no scale because five seconds went by. -func TestTheScaleLossBannerHasNoExpiry(t *testing.T) { - b := newBench(t) - b.disconnect(errors.New("câble débranché")) - b.tick() - - message := b.hub.State().Message - if message == nil { - t.Fatal("aucun bandeau après la perte de la balance") - } - if !message.ExpiresAt.IsZero() { - t.Fatalf("le bandeau de perte de balance expire à %s", message.ExpiresAt) - } - b.advance(time.Hour) - if b.hub.State().Message == nil { - t.Fatal("le bandeau de perte de balance a disparu tout seul") - } -} - -// TestTheNumberOfCopiesComesFromTheConfiguration, and a count left at zero is ONE: -// a station that prints nothing because a field is empty is a station nobody can -// debug. -func TestTheNumberOfCopiesComesFromTheConfiguration(t *testing.T) { - b := newBench(t, func(o *benchOptions) { - o.config = func(c *domain.Config) { - c.Printer.Options["copies"] = json.RawMessage(`3`) - } - }) - b.feed(1236, 2) - b.tap("three-copies", 1236) - b.awaitPrint() - if got := b.printer.Jobs()[0].Copies; got != 3 { - t.Fatalf("%d exemplaires, attendu 3", got) - } - - zero := newBench(t, func(o *benchOptions) { - o.config = func(c *domain.Config) { - c.Printer.Options["copies"] = json.RawMessage(`0`) - } - }) - zero.feed(1236, 2) - zero.tap("zero-copies", 1236) - zero.awaitPrint() - if got := zero.printer.Jobs()[0].Copies; got != 1 { - t.Fatalf("%d exemplaires pour un réglage à zéro, attendu 1", got) - } -} - -// TestTheReprintBarIsPermanentInsideItsWindow is §8.5: one reprint per label, -// marked, inside reprint_window_s. -func TestTheReprintBarIsPermanentInsideItsWindow(t *testing.T) { - b := newBench(t) - b.feed(1236, 2) - first := b.tap("original", 1236) - b.awaitJournal() - b.tick() - - s := b.hub.State() - if !s.ReprintAvailable { - t.Fatal("la barre de réimpression n'est pas active juste après une étiquette") - } - - ctx := context.Background() - ack, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: first.JobID, Key: "reprint-1"}, "reprint-1") - if err != nil { - t.Fatalf("Submit(ReprintRequested) : %v", err) - } - if !ack.Accepted { - t.Fatalf("réimpression refusée dans sa fenêtre : %s", ack.Message) - } - row := b.awaitJournal() - if row.Result != domain.ResultReprint { - t.Fatalf("résultat %q, attendu %q", row.Result, domain.ResultReprint) - } - - // One reprint only. - again, err := b.hub.Submit(ctx, domain.ReprintRequested{Key: "reprint-2"}, "reprint-2") - if err != nil { - t.Fatalf("Submit : %v", err) - } - if again.Accepted { - t.Fatal("une seconde réimpression a été acceptée") - } - if n := len(b.printer.Jobs()); n != 2 { - t.Fatalf("%d étiquettes, attendu 2 (l'originale et sa réimpression)", n) - } -} diff --git a/internal/station/loop.go b/internal/station/loop.go new file mode 100644 index 0000000..1c5bef0 --- /dev/null +++ b/internal/station/loop.go @@ -0,0 +1,272 @@ +package station + +import ( + "context" + "time" + + "openscale/internal/domain" +) + +// This file is the single decision-making goroutine: the select, the ONE call to +// domain.Transition, and the small questions that call needs answered — what a scale +// event means, how old the last reading is, and when a catalog may be swapped in. + +// run is the single goroutine that decides. It returns when ctx is done. +// +// ticks comes from the INJECTED CLOCK, not from time.NewTicker, and it is +// registered by the CALLER so that its first tick does not depend on when the +// scheduler got here. With the fake clock, Advance(2*time.Second) really produces +// the twenty ticks, so stability, expiry, interface timeouts and the reprint +// window are genuinely exercised in microseconds of wall time instead of being +// tested by a sleep that tests nothing. +func (h *Hub) run(ctx context.Context, ticks <-chan time.Time) { + defer close(h.done) + + var deferredEvents []domain.Event + + for { + var ev domain.Event + + if len(deferredEvents) > 0 { + ev, deferredEvents = deferredEvents[0], deferredEvents[1:] + } else { + select { + case <-ctx.Done(): + h.gracefulStop() + return + + case e := <-h.measurements: + next, ok := h.receive(e) + if !ok { + continue + } + ev = next + + case c := <-h.commands: + if ack, seen := h.idempotency.Lookup(c.Key); seen { + // A key already answered REPLAYS the answer and executes + // nothing: that is the whole of failure test 15. + reply(c.Reply, ack) + continue + } + h.pendingReply = c.Reply + ev = c.Ev + + case r := <-h.printResults: + ev = domain.PrintFinished{JobID: r.JobID, Err: r.Err, Duration: r.Duration} + + case batch := <-h.incomingCatalog: + if h.catalog.Load() != nil { + // DEFERRED swap (§10.8): a catalog never takes service under a + // customer's finger. The Tick drains it when the station has + // been idle and untouched for MaxSwitchIdle. + h.pendingBatch = batch + h.catalogWaiting.Store(true) + continue + } + // NOTHING is on screen yet. There is no finger to reorder tiles + // under, and a station showing « Catalogue vide » must serve the + // moment it can, so the first catalog goes THROUGH THE MACHINE — + // which is also what takes it out of Initializing. + ev = domain.CatalogReady{Catalog: batch.Catalog, ImportedAt: batch.ImportedAt} + + case req := <-h.subscriptions: + h.applySubscription(req) + continue + + case <-ticks: + ev = domain.Tick{} + } + } + + now := h.clock.Now() + cfg := *h.cfg.Load() + previous := h.model + + // ---- THE ONLY PLACE WHERE THE MODEL CHANGES ---------------------- + next, effects := domain.Transition(h.model, ev, domain.TransitionContext{ + Cfg: cfg, + Now: now, + LastMeasurement: h.lastMeasurement, + // The age is COMPUTED, never accumulated. A lost tick can no longer + // UNDER-COUNT it and let an expired weight through (bloquant-1). + MeasurementAge: h.measurementAge(now), + Expiry: h.expiry(cfg), + Catalog: h.catalog.Load(), + }) + h.model = next + + if next.State == domain.Idle && previous.State != domain.Idle { + // The bag left the plate, or the cycle was cancelled. That physical + // signal is what empties the banner — not a stopwatch (§14.3). + h.message = nil + } + + // « quiet for MaxSwitchIdle » is one clock: the last instant the station was + // doing anything at all. A Tick is deliberately not one — it is what MEASURES + // the wait, so counting it would reset it for ever. + // + // The states that keep the clock running are the same ones the swap refuses, + // and they have to be: reading `State != Idle` here meant that a station with + // no scale sat in ScaleLost refreshing this instant on every tick, so the wait + // never elapsed and the FIRST catalog never took service. Two conditions that + // must agree are written once. + if !swapIsSafeIn(h.model.State) || isInteraction(ev) { + h.lastInteraction = now + } + + h.applyPendingBatch(now) + + for _, ef := range effects { + // execute NEVER blocks and NEVER calls Transition: an effect that has + // to re-inject an event pushes it onto deferredEvents, drained at the + // top of the next turn. Calling back into the loop from here would be + // an immediate deadlock. + if e := h.execute(ef, now); e != nil { + deferredEvents = append(deferredEvents, e) + } + } + + // A COMMAND CYCLE ALWAYS REPLIES. The hard rule is that every terminal + // transition emits an AckEffect; this block makes it true by construction + // rather than by discipline. A rejection, a blocking safeguard, a hidden + // product, an event the current state ignores: without it, pendingReply + // stays set, the caller waits without a deadline, and the next command + // overwrites the channel having never answered on it — one leaked + // goroutine per refused command, in the very component whose goroutine + // inventory §13.1 claims to be exhaustive. + if h.pendingReply != nil { + reply(h.pendingReply, defaultAck(h.model, ev)) + h.pendingReply = nil + } + + h.publish(now) + } +} + +// receive turns one scale event into the event the machine understands, and +// reports whether there is one at all. +// +// The trigger of a scale loss is the Status field ALONE (défaut 40). The last +// event a driver emits on its way out does carry a non-nil Err, but the Err +// CONDITIONS nothing: it is a logged reason. Making the loss depend on an optional +// field is what let the signal fall into a default branch and never reach the +// machine. +func (h *Hub) receive(e domain.ScaleEvent) (domain.Event, bool) { + switch { + case e.Status == domain.StatusDisconnected: + return domain.ScaleDisconnected{Err: e.Err}, true + + case e.Status == domain.StatusConnected && h.model.State == domain.ScaleLost: + // Intervals measured across an outage describe the outage, not the + // cadence: a rate meter that kept them would derive an expiry from a hole. + h.rate.Reset() + return domain.ScaleReconnected{}, true + + case e.Measurement != nil: + h.seq++ + m := *e.Measurement + m.Seq = h.seq + h.lastMeasurement = m + h.rate.Observe(m) + return domain.MeasurementReceived{M: m}, true + } + return nil, false +} + +// measurementAge is how old the last reading is, on the injected clock. +// +// Before the first frame there is nothing to age, and reporting a duration counted +// from the zero instant would make every safeguard refuse on a station that has +// just started. +func (h *Hub) measurementAge(now time.Time) time.Duration { + if h.lastMeasurement.Timestamp.IsZero() { + return 0 + } + return now.Sub(h.lastMeasurement.Timestamp) +} + +// expiry is how old a reading may get before it is refused, DERIVED from the +// cadence actually observed (A3). +func (h *Hub) expiry(cfg domain.Config) time.Duration { + return h.rate.Expiry(cfg.Stability, time.Duration(h.nominalRate.Load())) +} + +// swapIsSafeIn reports the states a catalog may be swapped in. +// +// The rule the deferred swap enforces is « never reorder the tiles under a +// customer's finger » (failure test 13), and the states where there IS no finger are +// more than Idle alone. Requiring Idle strictly was found by running a real station: +// with no scale plugged in the machine sits in ScaleLost for ever, so the FIRST +// catalog of a fresh station never took service — the file was read, the 355 rows +// were written to the base, the archive was made, and the grid stayed empty. A +// station that cannot show its catalog until somebody plugs a scale in is not the +// station §15.4 describes, which says « Catalogue vide. En attente de flv_.csv » +// precisely because it expects to leave that state on a file and not on a cable. +// +// So: every state that carries a weighing in progress, or a label the customer is +// still looking at, refuses the swap. The rest accept it. +func swapIsSafeIn(state domain.State) bool { + switch state { + case domain.Idle, domain.Initializing, domain.ScaleLost, domain.ManualMode: + return true + default: + // ProductArmed, WeightPresent, WeightStable, AwaitingStability, EnteringTare, + // EnteringWeight, Validating, Printing, Succeeded, Rejected, Faulted and + // OutOfService all mean somebody is mid-cycle or reading a result. + return false + } +} + +// applyPendingBatch swaps the catalog in, but only when nobody is weighing. +// +// MaxSwitchIdle is a CODE CONSTANT of the domain and never a configuration key: +// setting it to zero would reopen the failure mode where an import reorders the +// tiles under a customer's finger (failure test 13). +func (h *Hub) applyPendingBatch(now time.Time) { + if h.pendingBatch == nil || !swapIsSafeIn(h.model.State) { + return + } + if now.Sub(h.lastInteraction) < domain.MaxSwitchIdle { + return + } + // The batch's own instant, and NOT `now`: the swap is deliberately later than the + // import — that is what MaxSwitchIdle buys — and the screen states the import. + h.storeCatalog(h.pendingBatch.Catalog, h.pendingBatch.ImportedAt) + h.pendingBatch = nil + h.catalogWaiting.Store(false) +} + +// gracefulStop runs in the loop goroutine, just before the loop returns. +// +// It drains the subscription requests that arrived and were never served, then +// closes every subscriber channel through the SAME guard as CloseSubscribers. +// Draining matters: a request left in the buffer would be a Subscribe whose +// caller waits for a snapshot nobody will ever send. +func (h *Hub) gracefulStop() { + for { + select { + case req := <-h.subscriptions: + h.applySubscription(req) + default: + h.CloseSubscribers() + return + } + } +} + +// isInteraction reports whether an event is somebody DOING something. +// +// It is the list of the events a human causes, written out rather than inferred: +// what it protects is the deferred catalog swap, and « the customer stopped +// touching the screen ten seconds ago » must not be answered by a timer the +// station generates for itself. +func isInteraction(ev domain.Event) bool { + switch ev.(type) { + case domain.ProductTapped, domain.TareTapped, domain.TareConfirmed, + domain.ManualWeightConfirmed, domain.ReprintRequested, + domain.Cancel, domain.Dismiss: + return true + } + return false +} diff --git a/internal/station/options.go b/internal/station/options.go new file mode 100644 index 0000000..aec1272 --- /dev/null +++ b/internal/station/options.go @@ -0,0 +1,146 @@ +package station + +import ( + "context" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is what a station is GIVEN: the factories that build a driver out of a +// configuration, the collaborators the shutdown releases, and the Options that carry +// them all. Nothing here decides anything — station.go turns it into a Station. + +// ScaleFactory builds the scale driver a configuration names. +// +// It is INJECTED because internal/station knows no concrete driver: adding a scale +// is one package and one line in cmd/openscale/drivers.go, with zero modification +// here (cut 2 of §5.2). +type ScaleFactory func(cfg domain.Config) (ports.Scale, error) + +// PrinterFactory builds the printer a configuration names. +type PrinterFactory func(cfg domain.Config) (ports.Printer, error) + +// CatalogSourceFactory builds the catalog source a configuration names. +type CatalogSourceFactory func(cfg domain.Config) (ports.CatalogSource, error) + +// CatalogApplier turns the batch a source produced into the snapshot that will +// take service, and says what to acknowledge. +// +// It is a hook and not a hard-coded step because the qualification of §10.3 and +// the guards of §10.4 — an amputated catalog must not replace a healthy one — +// belong to internal/catalog, which this package does not import. The default +// builds the snapshot and nothing else. +type CatalogApplier func(ctx context.Context, cfg domain.Config, b *ports.Batch) (*domain.Catalog, ports.BatchResult, error) + +// CatalogBatch is a whole catalog waiting to take service. +// +// It carries what produced it so that the dashboard can say « Catalogue du +// 24/07/2026 » without asking the store. +type CatalogBatch struct { + Catalog *domain.Catalog + Source string + FileName string + // ImportedAt is the instant of the import that PRODUCED this catalog — the + // occurred_at of its row in the imports table, and never the instant of the swap. + // + // The two differ by up to MaxSwitchIdle, because a catalog waits for a station + // nobody is touching (§10.8). Stamping the swap made the same catalog carry one + // date in service and another after the next restart, which reads it back from the + // base. One catalog, one instant. + ImportedAt time.Time +} + +// Server is the part of an HTTP server the shutdown needs. Declared here, on the +// consumer's side, so that internal/station imports no net/http. +type Server interface { + // Shutdown stops accepting and waits for the active requests, up to ctx. + Shutdown(ctx context.Context) error +} + +// Closer is the part of the store, and of anything else with a handle, that the +// shutdown needs. +type Closer interface { + Close() error +} + +// Waiter is something the shutdown waits for before closing what it writes to — +// an import transaction that has to roll back, typically. +type Waiter interface { + Wait() +} + +// Options is everything a station is given. Clock, Config, Printer and Journal +// are required; the rest has an honest default. +type Options struct { + Clock ports.Clock + Config domain.Config + // Catalog is the snapshot already in the store, or nil on a virgin station. Nil + // starts the machine in Initializing, which is what makes the grid say + // « Catalogue vide » instead of showing nothing. + Catalog *domain.Catalog + // CatalogAt is when that snapshot was IMPORTED — the instant of the last import + // the store applied, read back from the base by the composition root. + // + // It is handed in rather than taken from the clock, and that is the defect this + // field was added for: a station stamps the catalog it starts with, so reading the + // clock here dated every catalog from the last reboot. §14.3 shows this instant + // permanently to answer « ces prix datent de quand ? », and a date that a service + // restart moves answers a question nobody asked. + CatalogAt time.Time + // OutOfService starts the station in the one terminal state, which is what an + // unusable configuration does (§11.3, ERR-CFG-01). + OutOfService bool + // Registries carries the driver descriptors, and it is here for ONE question: is + // the configuration that just arrived still unusable? + // + // A station started out of service is repaired from the administration screen, block + // by block, and it comes back into service the moment the last fault goes — which is + // what §11.4 promises when it says no configuration block requires a restart. Left at + // its zero value, no driver is known, every configuration carries faults, and the + // station never returns: that is the safe default for a caller that never had a reason + // to be out of service in the first place. + Registries domain.Registries + // Poller is the daily check for a newer version of this binary. Nil starts no + // worker at all, which is what a binary that cannot update itself honestly is + // -- a development build, or a platform with no swap. + Poller Poller + // Templates resolves printer.template. It defaults to the shipped ones. + Templates map[string]domain.Template + // NominalRate is the cadence the scale driver DECLARES, used until the rate + // meter has eight intervals of its own. + NominalRate time.Duration + Counters *Counters + + Scale ports.Scale + Printer ports.Printer + CatalogSource ports.CatalogSource + Journal Journal + TechnicalSink TechnicalSink + + NewScale ScaleFactory + NewPrinter PrinterFactory + NewCatalogSource CatalogSourceFactory + ApplyCatalog CatalogApplier + + Server Server + Store Closer + // CatalogWait rolls an import transaction back before the database closes. + CatalogWait Waiter + // OnRevert is called when the 60 s window of §11.4 closed without a confirmation, and it + // receives THE FILE AS IT WAS BEFORE THE SAVE — never the configuration the station was + // running. + // + // It exists because the countdown protects the RUNNING station and the file is written + // before it starts: without this hook, a station that rolled back would come back, at + // the next restart, on the very configuration nobody confirmed — which is exactly the + // branch the countdown was cutting. What it does is the caller's business; internal/ + // station knows no file. + // + // The two documents are distinct on the one station this matters for. A station whose + // configuration is unusable RUNS the neutral profile (§11.3) while its file keeps the + // cooperative's tariffs, safeguards and categories: handing the running configuration + // over here wrote the factory profile onto that file, on the very save that repaired it. + OnRevert func(fileBefore domain.Config) +} diff --git a/internal/station/publish.go b/internal/station/publish.go new file mode 100644 index 0000000..8f08fd0 --- /dev/null +++ b/internal/station/publish.go @@ -0,0 +1,197 @@ +package station + +import ( + "time" + + "openscale/internal/domain" +) + +// This file is §13.3: the snapshot the loop freezes at the end of every turn, and the +// throttle it goes out under — at most ten a second when something changes, one every +// half second when nothing does. + +// publishThrottle and publishHeartbeat are the two halves of §13.3: at most ten +// snapshots a second when something changes, and one every half second even when +// nothing does, so that a browser that has just reconnected is never left staring +// at a stale banner. +const ( + publishThrottle = 100 * time.Millisecond + publishHeartbeat = 500 * time.Millisecond +) + +// publish emits the snapshot, throttled to 10 Hz with a forced heartbeat every +// 500 ms. +// +// now is a PARAMETER: the same clock as the ticker, read once per turn. And +// publishPending is CONSUMED — without that, on a fake clock the Hub published a +// single snapshot and then fell silent for good. +func (h *Hub) publish(now time.Time) { + s := h.buildSnapshot(now) + changed := s.Revision != h.lastPublished.Revision + since := now.Sub(h.lastPublishedAt) + + if !changed && !h.publishPending && since < publishHeartbeat { + return + } + if changed && since < publishThrottle { + h.publishPending = true // it goes out on the next tick + return + } + h.publishPending = false + h.lastPublished, h.lastPublishedAt = s, now + h.state.Store(&s) + h.beat.Store(now.UnixNano()) + // A sound is an EDGE, not a state: it is played once, so it leaves the model + // as soon as a snapshot has carried it. + h.sound = "" + + // The whole fan-out happens UNDER the lock, and that is deliberate: every send + // below is a select with a default, so nothing here can block, and holding the + // lock is what makes a close impossible between choosing a channel and sending on + // it. Copying the channels out first and sending outside would reintroduce exactly + // the send-on-closed-channel that CloseSubscribers is ordered to avoid. + h.subscribersMu.Lock() + defer h.subscribersMu.Unlock() + for ch := range h.subscribers { + select { + case ch <- s: + default: + // Capacity one: drop the stale snapshot and write the fresh one. A + // snapshot 400 ms old has no value, and a slow subscriber must never + // hold the reading of the scale back. + select { + case <-ch: + default: + } + select { + case ch <- s: + default: + } + } + } +} + +// buildSnapshot freezes what the screen has to know. +// +// Revision carries over from the last published snapshot and goes up only when the +// content actually changed, which is what makes the throttle of publish meaningful +// rather than a timer that fires for nothing. +func (h *Hub) buildSnapshot(now time.Time) Snapshot { + cfg := *h.cfg.Load() + expiry := h.expiry(cfg) + age := h.measurementAge(now) + hasWeight := !h.lastMeasurement.Timestamp.IsZero() + + s := Snapshot{ + At: now, + State: h.model.State, + Weight: Weight{ + Gross: h.lastMeasurement.Gross, + Tare: h.model.Tare, + Net: h.lastMeasurement.Gross - h.model.Tare, + Quantity: h.model.Units, + Stability: h.lastMeasurement.Stability, + Latched: h.model.LatchState.Latched, + Seq: h.lastMeasurement.Seq, + Age: age, + Expiry: expiry, + }, + HasWeight: hasWeight, + // The comparison is STRICT, exactly as safeguard rule 2 states it: at the + // expiry itself the weight is still good, one millisecond later it is not. + Expired: hasWeight && age > expiry, + Product: h.model.CurrentProduct, + Tare: h.model.Tare, + Units: h.model.Units, + Label: h.model.Label, + LastLabel: h.model.LastLabel, + LastPrintedAt: h.model.LastPrintedAt, + ReprintAvailable: h.reprintAvailable(cfg, now), + Message: h.liveMessage(now), + Sound: h.sound, + Diagnostics: copyDiagnostics(h.model.Diagnostics), + FaultCode: h.model.FaultCode, + ArmingExpiresAt: h.armingDeadline(), + Catalog: h.catalog.Load(), + Scale: h.scaleHealth(cfg), + Degraded: h.degraded.Load(), + Station: cfg.Station.Number, + UnloggedWeighings: h.counters.UnloggedWeighings.Load(), + } + if p := h.health.Load(); p != nil { + s.Printer = *p + } + + s.Revision = h.lastPublished.Revision + if !s.sameContentAs(h.lastPublished) { + s.Revision++ + } + return s +} + +// reprintAvailable reports whether the permanent bottom bar has anything to offer. +// +// A window of zero disables reprinting, which is the only sensible reading of « how +// long the bar stays active » = 0. +func (h *Hub) reprintAvailable(cfg domain.Config, now time.Time) bool { + if h.model.LastLabel == nil || h.model.Reprinted { + return false + } + window := time.Duration(cfg.UI.ReprintWindowSeconds) * time.Second + return window > 0 && now.Sub(h.model.LastPrintedAt) <= window +} + +// liveMessage drops a banner nobody came back for. +// +// A message with no expiry survives until the state changes, and that is on +// purpose: a station with no scale does not stop having no scale because five +// seconds went by. +func (h *Hub) liveMessage(now time.Time) *Message { + if h.message == nil { + return nil + } + if !h.message.ExpiresAt.IsZero() && now.After(h.message.ExpiresAt) { + return nil + } + return h.message +} + +// armingDeadline reports the end of the bounded wait the station is in, and zero +// when it is not waiting for anything. +// +// It is derived from the STATE rather than cleared by hand, so no path can leave a +// countdown running on a screen that has moved on. +func (h *Hub) armingDeadline() time.Time { + switch h.model.State { + case domain.ProductArmed, domain.AwaitingStability, + domain.EnteringTare, domain.EnteringWeight: + return h.armExpiresAt + } + return time.Time{} +} + +// scaleHealth is what the station can say about its scale without asking it. +func (h *Hub) scaleHealth(cfg domain.Config) ScaleHealth { + median, measured := h.rate.Median() + tooSlow, _ := h.rate.RateIsTooSlow(cfg.Stability) + return ScaleHealth{ + Connected: h.model.State != domain.ScaleLost, + Median: median, + Observations: h.rate.Observations(), + Provisional: !measured, + TooSlow: tooSlow, + } +} + +// copyDiagnostics freezes what the safeguards said. +// +// A copy, because a snapshot is published and a published value is never allowed +// to change behind a reader's back. +func copyDiagnostics(in []domain.Diagnostic) []domain.Diagnostic { + if len(in) == 0 { + return nil + } + out := make([]domain.Diagnostic, len(in)) + copy(out, in) + return out +} diff --git a/internal/station/publish_test.go b/internal/station/publish_test.go new file mode 100644 index 0000000..71dd412 --- /dev/null +++ b/internal/station/publish_test.go @@ -0,0 +1,184 @@ +package station + +import ( + "context" + "errors" + "testing" + "time" + + "openscale/internal/domain" +) + +// What reaches a screen, and when: the throttle and the forced heartbeat of §13.3, a +// slow subscriber that never holds the loop back, and the two things a snapshot carries +// that expire on their own — the banner and the reprint bar. + +// TestAChangeInsideTheThrottleGoesOutOnTheNextTick pins both halves of the +// publication rule at once: nothing is emitted twice inside 100 ms, and what was +// held back is emitted on the very next tick rather than waiting for the 500 ms +// heartbeat. +func TestAChangeInsideTheThrottleGoesOutOnTheNextTick(t *testing.T) { + b := newBench(t) + snapshots, unsubscribe := b.hub.Subscribe() + defer unsubscribe() + <-snapshots // the state a new subscriber gets at once + + b.tick() // a publication happens here, and fixes the throttle window + drain(snapshots) + + // Same instant as the last publication: the change is held back. + b.push(1236, domain.Stable) + b.flush() + if len(snapshots) != 0 { + t.Fatal("un changement a été publié dans les 100 ms de la publication précédente") + } + + b.tick() + if len(snapshots) != 1 { + t.Fatal("le snapshot retenu par le throttle n'est jamais parti") + } + if got := (<-snapshots).Weight.Gross; got != 1236 { + t.Fatalf("poids publié %d g, attendu 1236 g", got) + } +} + +// drain empties a subscriber channel without blocking. +func drain(snapshots <-chan Snapshot) { + for { + select { + case <-snapshots: + default: + return + } + } +} + +// TestPublicationIsThrottledAndStillBeats checks both halves of §13.3 at once: +// nothing is published twice in 100 ms, and something is published at least every +// 500 ms even when nothing changes. +func TestPublicationIsThrottledAndStillBeats(t *testing.T) { + b := newBench(t) + snapshots, unsubscribe := b.hub.Subscribe() + defer unsubscribe() + <-snapshots // the state a new subscriber gets at once + + // Nothing changes for two seconds: the forced heartbeat must still fire, and + // it must not fire ten times a second. + beats := 0 + for i := 0; i < 20; i++ { + b.tick() + select { + case <-snapshots: + beats++ + default: + } + } + if beats == 0 { + t.Fatal("aucun battement en 2 s : un navigateur reconnecté resterait sur un bandeau figé") + } + if beats > 8 { + t.Fatalf("%d battements en 2 s alors que rien ne change : le throttle ne tient pas", beats) + } +} + +// TestASlowSubscriberNeverHoldsTheHub proves the drop-old rule: a subscriber that +// stops reading gets the stale snapshot dropped, never the loop blocked. +func TestASlowSubscriberNeverHoldsTheHub(t *testing.T) { + b := newBench(t) + snapshots, unsubscribe := b.hub.Subscribe() + defer unsubscribe() + + // Never read from snapshots, and keep the station busy. + for i := 0; i < 50; i++ { + b.push(domain.Grams(1000+i), domain.Stable) + b.tick() + } + if got := b.hub.State().Weight.Gross; got != 1049 { + t.Fatalf("dernier poids %d g, attendu 1049 g : la boucle a été retenue par un abonné", got) + } + if n := len(snapshots); n > subscriberDepth { + t.Fatalf("%d snapshots en attente, capacité %d", n, subscriberDepth) + } +} + +// TestABannerExpiresOnTheInjectedClock: what really ends a message is the physical +// signal, and these durations only bound a message nobody came back for. +func TestABannerExpiresOnTheInjectedClock(t *testing.T) { + b := newBench(t) + b.feed(8, 2) + b.tap("too-light", 8) + b.tick() + + message := b.hub.State().Message + if message == nil { + t.Fatal("aucun bandeau après un refus") + } + if message.Code != domain.CodeWeightTooLow { + t.Fatalf("code du bandeau %q, attendu %q", message.Code, domain.CodeWeightTooLow) + } + + b.advance(domain.RejectMessageDuration + time.Second) + if got := b.hub.State().Message; got != nil { + t.Fatalf("le bandeau %q survit à sa durée", got.Text) + } +} + +// TestTheScaleLossBannerHasNoExpiry: a station with no scale does not stop having +// no scale because five seconds went by. +func TestTheScaleLossBannerHasNoExpiry(t *testing.T) { + b := newBench(t) + b.disconnect(errors.New("câble débranché")) + b.tick() + + message := b.hub.State().Message + if message == nil { + t.Fatal("aucun bandeau après la perte de la balance") + } + if !message.ExpiresAt.IsZero() { + t.Fatalf("le bandeau de perte de balance expire à %s", message.ExpiresAt) + } + b.advance(time.Hour) + if b.hub.State().Message == nil { + t.Fatal("le bandeau de perte de balance a disparu tout seul") + } +} + +// TestTheReprintBarIsPermanentInsideItsWindow is §8.5: one reprint per label, +// marked, inside reprint_window_s. +func TestTheReprintBarIsPermanentInsideItsWindow(t *testing.T) { + b := newBench(t) + b.feed(1236, 2) + first := b.tap("original", 1236) + b.awaitJournal() + b.tick() + + s := b.hub.State() + if !s.ReprintAvailable { + t.Fatal("la barre de réimpression n'est pas active juste après une étiquette") + } + + ctx := context.Background() + ack, err := b.hub.Submit(ctx, domain.ReprintRequested{JobID: first.JobID, Key: "reprint-1"}, "reprint-1") + if err != nil { + t.Fatalf("Submit(ReprintRequested) : %v", err) + } + if !ack.Accepted { + t.Fatalf("réimpression refusée dans sa fenêtre : %s", ack.Message) + } + row := b.awaitJournal() + if row.Result != domain.ResultReprint { + t.Fatalf("résultat %q, attendu %q", row.Result, domain.ResultReprint) + } + + // One reprint only. + again, err := b.hub.Submit(ctx, domain.ReprintRequested{Key: "reprint-2"}, "reprint-2") + if err != nil { + t.Fatalf("Submit : %v", err) + } + if again.Accepted { + t.Fatal("une seconde réimpression a été acceptée") + } + if n := len(b.printer.Jobs()); n != 2 { + t.Fatalf("%d étiquettes, attendu 2 (l'originale et sa réimpression)", n) + } +} diff --git a/internal/station/reload.go b/internal/station/reload.go new file mode 100644 index 0000000..8102007 --- /dev/null +++ b/internal/station/reload.go @@ -0,0 +1,230 @@ +package station + +import ( + "context" + "errors" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is the hot reload of §11.4: which blocks a new configuration moved, the +// 60 s countdown a hardware change has to be confirmed inside, and the rollback that +// puts BOTH the running station and its file back when nobody confirmed. + +// confirmationWindow is how long a hardware change has to be confirmed before the +// station goes back to the configuration it had (§11.4). +// +// It is `ip route` under SSH: impossible to cut the branch you are sitting on. +const confirmationWindow = 60 * time.Second + +// The configuration blocks a change can touch, spelled the way the admin screen +// spells them. +const ( + blockScale = "scale" + blockPrinter = "printer" + blockCatalog = "catalog" + blockNetwork = "network.listen" +) + +// ErrNoConfirmationPending reports a confirmation nobody asked for. +var ErrNoConfirmationPending = errors.New("station: aucune confirmation en attente") + +// pendingConfirmation is what to go back to if nobody confirms — and it is TWO documents, +// because a station and its file do not always carry the same one. +type pendingConfirmation struct { + // running is what the station was OPERATING ON, and it is what goes back into service. + running domain.Config + // file is what the configuration file CARRIED before the save, and it is what goes + // back on disk. On a station out of service the two differ completely (§11.3). + file domain.Config + deadline time.Time +} + +// ReloadOutcome is what a configuration change did, and what is still expected of +// whoever asked for it. +type ReloadOutcome struct { + // Changed names the blocks that actually moved. + Changed []string + // ConfirmBefore is the end of the 60 s countdown, and it is zero when nothing + // has to be confirmed. Past it, without a Confirm, the station goes back to the + // configuration it had. + ConfirmBefore time.Time +} + +// ReloadRequest is one configuration change, with the document the rollback would have to +// put back on disk. +// +// The two travel together because the countdown of §11.4 has two things to undo, and a +// caller that only handed over the new configuration left the station guessing at the other. +type ReloadRequest struct { + // Next is the configuration to put in service. + Next domain.Config + // FileBefore is what the configuration FILE carried before this change was written, + // and it is the document a rollback puts back on disk. + // + // A POINTER, and nil says « je n'ai pas pu lire le fichier » — the rollback then falls + // back on the configuration in service, which is all such a caller possesses. It is not + // a domain.Config with a zero value, because the zero value of a configuration LOOKS + // like a configuration: a caller that forgot this field would arm a rollback towards a + // document nobody ever validated, and nothing would say so. + FileBefore *domain.Config +} + +// Reload publishes a new configuration and restarts ONLY the subsystems whose +// block actually changed. +// +// limits, tiers, template, UI and journal apply instantly, with no gap in service: +// they are read from the atomic pointer on the next turn of the loop and by +// nothing else. +func (s *Station) Reload(req ReloadRequest) (ReloadOutcome, error) { + s.reloadMu.Lock() + defer s.reloadMu.Unlock() + + running := *s.hub.cfg.Load() + changed := s.apply(running, req.Next) + + outcome := ReloadOutcome{Changed: changed} + if needsConfirmation(changed) { + outcome.ConfirmBefore = s.clock.Now().Add(confirmationWindow) + file := running + if req.FileBefore != nil { + file = *req.FileBefore + } + s.confirmation = &pendingConfirmation{ + running: running, file: file, deadline: outcome.ConfirmBefore, + } + } + return outcome, nil +} + +// PendingConfirmation reports the end of the countdown still running, or the zero time when +// nothing is waiting to be confirmed. +// +// It exists so that the administration can REFUSE a second save inside the window, the way +// it refuses a confirmation outside it. Accepting one would replace the target of the +// rollback with a configuration nobody has confirmed either, and the version somebody really +// did validate would be the one lost. +func (s *Station) PendingConfirmation() time.Time { + s.reloadMu.Lock() + defer s.reloadMu.Unlock() + if s.confirmation == nil { + return time.Time{} + } + return s.confirmation.deadline +} + +// Confirm accepts the configuration in force and stops the countdown. +func (s *Station) Confirm() error { + s.reloadMu.Lock() + defer s.reloadMu.Unlock() + if s.confirmation == nil { + return ErrNoConfirmationPending + } + s.confirmation = nil + return nil +} + +// revertIfUnconfirmed puts the previous configuration back when the countdown ran +// out. It is called from the supervisor, which is the only goroutine that watches +// deadlines — no timer goroutine is added to the inventory of §13.1. +func (s *Station) revertIfUnconfirmed(now time.Time) { + s.reloadMu.Lock() + defer s.reloadMu.Unlock() + if s.confirmation == nil || now.Before(s.confirmation.deadline) { + return + } + running, file := s.confirmation.running, s.confirmation.file + s.confirmation = nil + s.hub.logTechnical(domain.LevelWarn, "config", "", + "Configuration non confirmée en 60 s : retour à la version précédente.", "") + // The station goes back to what it was OPERATING ON, and to nothing else. Applying the + // file here would be the tempting symmetry and the wrong one: on a station out of + // service that document is the very one §11.3 refuses to run, and nothing in this + // package can put a station BACK into the out-of-service state. + s.apply(*s.hub.cfg.Load(), running) + if s.onRevert != nil { + // The FILE goes back too, and it has to: the countdown protects the station that + // is running, and the write of §11.4 happened before the countdown started. A + // station that rolled back and then restarted on the unconfirmed configuration + // would have cut the branch sixty seconds later than announced. + s.onRevert(file) + } +} + +// apply stores the configuration and restarts what has to be restarted. +// +// The comparison is NORMALIZED and not a reflect.DeepEqual over raw JSON: two +// configurations that are semantically identical but serialized with a different +// key order must NOT cut the serial port in the middle of a service. +func (s *Station) apply(previous, next domain.Config) []string { + // limits, tiers, template, UI, journal: instant, no service gap. + s.hub.cfg.Store(&next) + + var changed []string + if BlockFingerprint(previous.Scale) != BlockFingerprint(next.Scale) { + changed = append(changed, blockScale) + s.restartScale(next) + } + if BlockFingerprint(previous.Printer) != BlockFingerprint(next.Printer) { + changed = append(changed, blockPrinter) + s.restartPrinter(next) + } + // station.number is reloaded WITH the catalog: its only real consumer is the + // name of the watched file, flv_.csv (§11.2). + if BlockFingerprint(previous.Catalog) != BlockFingerprint(next.Catalog) || + previous.Station.Number != next.Station.Number { + changed = append(changed, blockCatalog) + s.restartCatalog(next) + } + if previous.Network.Listen != next.Network.Listen { + changed = append(changed, blockNetwork) + } + // LAST, and after the drivers have been rebuilt: a station coming back into service + // must find its scale open and its printer in place, not be declared ready in front + // of devices that are still being instantiated. + s.returnToServiceIfRepaired(next) + return changed +} + +// returnToServiceIfRepaired takes a station out of the terminal state of §11.3 once the +// configuration it is given no longer carries a fault. +// +// The question is asked HERE and not in the machine because the machine has no registry: +// « unusable » means « names a driver this binary does not have, or forgets an option that +// driver requires », and only the composition root knows what this binary was built with. +// The machine is told the ANSWER, once, through the one event that leaves the state. +// +// It costs one turn of the loop and it is spent on the goroutine of an administration +// handler, which is already waiting for a reload that opens a serial port. Failure is +// silent on purpose: the station is out of service either way, and a save that reported +// « configuration écrite mais poste toujours hors service » with no gesture attached would +// only frighten whoever just repaired it. +func (s *Station) returnToServiceIfRepaired(next domain.Config) { + if s.hub.State().State != domain.OutOfService { + return + } + if len((&next).Validate(s.registries)) > 0 { + return + } + ctx, cancel := ports.WithBudget(context.Background(), s.clock, hubStopBudget) + defer cancel() + if _, err := s.hub.Submit(ctx, domain.ConfigurationRepaired{}, ""); err != nil { + return + } + s.hub.logTechnical(domain.LevelWarn, "config", "", + "Configuration réparée : le poste quitte l'état hors service.", next.Fingerprint()) +} + +// needsConfirmation reports the blocks that arm the 60 s countdown: the hardware +// ones and the listening address. +func needsConfirmation(changed []string) bool { + for _, block := range changed { + switch block { + case blockScale, blockPrinter, blockNetwork: + return true + } + } + return false +} diff --git a/internal/station/reload_test.go b/internal/station/reload_test.go index 3e3e934..9590c0e 100644 --- a/internal/station/reload_test.go +++ b/internal/station/reload_test.go @@ -1,58 +1,17 @@ package station import ( - "context" - "encoding/json" "errors" "runtime" - "sync" "testing" "time" - - "openscale/internal/domain" - "openscale/internal/fake" - "openscale/internal/station/ports" ) -// scaleForge hands out one fake scale per instantiation, and remembers them all. -type scaleForge struct { - clock ports.Clock - mu sync.Mutex - built []*fake.Scale - // prepare is run on each new driver, so a test can decide how the NEXT one - // misbehaves. - prepare func(*fake.Scale) - err error -} - -func (f *scaleForge) New(domain.Config) (ports.Scale, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.err != nil { - return nil, f.err - } - s := fake.NewScale(f.clock) - if f.prepare != nil { - f.prepare(s) - } - f.built = append(f.built, s) - return s, nil -} - -func (f *scaleForge) last() *fake.Scale { - f.mu.Lock() - defer f.mu.Unlock() - if len(f.built) == 0 { - return nil - } - return f.built[len(f.built)-1] -} - -func (f *scaleForge) count() int { - f.mu.Lock() - defer f.mu.Unlock() - return len(f.built) -} +// The hot reload of §11.4 seen from outside: a block that applies with no gap in +// service, the sixty-second countdown a hardware change arms, and what happens when +// nobody confirms it. The DEVICES a change rebuilds are in devices_test.go, the +// comparison that decides a block moved in fingerprint_test.go, and the catalog watch +// in catalogwatch_test.go. // TestAnInstantBlockNeverCutsAnything is the first line of the table of §11.4: // limits, tiers, template, UI and journal apply through an atomic store, with no @@ -84,162 +43,6 @@ func TestAnInstantBlockNeverCutsAnything(t *testing.T) { } } -// TestSerialToManualAndBack is failure test 1 ter (a): two successive reloads, -// both directions work, and no channel is lost. -// -// The measurement channel belongs to the Hub FOR THE LIFETIME OF THE PROCESS: the -// re-instantiated driver writes into the SAME one. That is what makes the degraded -// mode reversible (bloquant-2). -func TestSerialToManualAndBack(t *testing.T) { - forge := &scaleForge{} - b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) - forge.clock = b.clock - - // serial -> manual - manual := b.hub.Config() - manual.Scale.Present = false - manual.Scale.Type = "" - if _, err := b.station.Reload(ReloadRequest{Next: manual}); err != nil { - t.Fatalf("Reload vers manuel : %v", err) - } - if !b.scale.Closed() { - t.Fatal("la balance de départ n'a pas été fermée") - } - b.tick() - if got := b.hub.State().State; got != domain.ManualMode { - t.Fatalf("état %s, attendu manual_mode", got) - } - - // manual -> serial - serial := b.hub.Config() - serial.Scale.Present = true - serial.Scale.Type = "gram-xfoc-plus" - if _, err := b.station.Reload(ReloadRequest{Next: serial}); err != nil { - t.Fatalf("Reload vers série : %v", err) - } - if forge.count() != 1 { - t.Fatalf("%d balances instanciées, attendu 1", forge.count()) - } - - // The SAME channel still carries measurements, from the NEW driver. - forge.last().Push(1236, domain.Stable) - b.awaitIntake() - b.tick() - if got := b.hub.State().Weight.Gross; got != 1236 { - t.Fatalf("poids %d g après l'aller-retour, attendu 1236 g : le canal a été perdu", got) - } -} - -// TestAStartThatFailsBeforeItsGoroutineStillAnswers is failure test 1 ter (b). -// -// The driver fails before it ever launched anything, and it still closes done — -// the mandatory corollary of §5.3. The configuration write answers, and the -// station falls back to manual entry with an amber light. -func TestAStartThatFailsBeforeItsGoroutineStillAnswers(t *testing.T) { - forge := &scaleForge{prepare: func(s *fake.Scale) { - s.FailToStart(errors.New("accès refusé au port COM8")) - }} - b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) - forge.clock = b.clock - - next := b.hub.Config() - next.Scale.Options = mustOptions(t, `{"port":"COM9"}`) - - started := time.Now() - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - if elapsed := time.Since(started); elapsed > 20*time.Millisecond { - t.Fatalf("le rechargement a pris %s de temps mural", elapsed) - } - - assertFallbackToManual(t, b, codeScaleUnavailable) - if got := b.hub.Config().Scale.Options; !hasOption(got, "port", `"COM9"`) { - t.Fatal("la configuration demandée n'est pas en service : seul le repli doit différer") - } -} - -// TestACloseThatNeverReturnsIsBounded is failure test 1 ter (c), and it is the -// hard point of the reload. -// -// The wait is bounded at 3 s of FAKE clock — under twenty milliseconds of wall -// time — the configuration is applied anyway, ERR-SCL-08 is journalled and the -// fallback is manual entry with an amber light. -func TestACloseThatNeverReturnsIsBounded(t *testing.T) { - forge := &scaleForge{err: errors.New("le port ne se rouvre pas")} - b := newBench(t, func(o *benchOptions) { o.newScale = forge.New }) - forge.clock = b.clock - - b.scale.HangOnClose() - defer b.scale.Release() - - next := b.hub.Config() - next.Scale.Options = mustOptions(t, `{"port":"COM9"}`) - - started := time.Now() - done := make(chan error, 1) - go func() { _, err := b.station.Reload(ReloadRequest{Next: next}); done <- err }() - - // Nothing moves until the INJECTED clock does. - select { - case <-done: - t.Fatal("le rechargement n'a pas attendu la fermeture du port") - case <-time.After(20 * time.Millisecond): - } - b.clock.Advance(scaleCloseBudget) - - select { - case err := <-done: - if err != nil { - t.Fatalf("Reload : %v", err) - } - case <-time.After(hang): - t.Fatal("le rechargement n'est pas borné : la configuration ne peut pas s'écrire") - } - if elapsed := time.Since(started); elapsed > 200*time.Millisecond { - t.Fatalf("le rechargement a pris %s de temps mural : le budget n'est pas sur l'horloge injectée", elapsed) - } - - // The technical line travels through the journal WORKER, on its own goroutine, so - // it is not there the instant Reload returns. A single tick was enough on a quiet - // machine and not on a loaded CI runner, which is the definition of a flaky test. - b.tick() - awaitCondition(t, func() bool { return b.technical.has("ERR-SCL-08") }, - "ERR-SCL-08 n'a pas été journalisé alors que la fermeture n'a pas été confirmée") - if got := b.station.Counters().UnconfirmedScaleCloses.Load(); got != 1 { - t.Fatalf("fermetures non confirmées = %d, attendu 1", got) - } - assertFallbackToManual(t, b, codeScaleUnavailable) -} - -// assertFallbackToManual checks the fallback of §11.4: a STATE, entered -// automatically, with its cause and its instant. -func assertFallbackToManual(t *testing.T, b *bench, code string) { - t.Helper() - cfg := b.hub.Config() - if cfg.Scale.Present { - t.Fatal("le poste se croit encore équipé d'une balance") - } - if !cfg.Scale.ManualEntryAllowed { - t.Fatal("le repli n'autorise pas la saisie manuelle : le poste ne peut plus peser") - } - b.tick() - s := b.hub.State() - if s.Degraded == nil { - t.Fatal("aucune dégradation publiée : le bandeau ne peut pas dire pourquoi") - } - if s.Degraded.Code != code { - t.Fatalf("code de dégradation %q, attendu %q", s.Degraded.Code, code) - } - if s.Degraded.Since.IsZero() { - t.Fatal("la dégradation n'a pas d'horodate : « pourquoi ce poste est-il en saisie " + - "manuelle ce matin ? » redevient indécidable") - } - if s.State != domain.ManualMode { - t.Fatalf("état %s, attendu manual_mode", s.State) - } -} - // TestAHardwareChangeArmsTheCountdownAndCanBeConfirmed covers the ordinary path of // the three-stage guard. func TestAHardwareChangeArmsTheCountdownAndCanBeConfirmed(t *testing.T) { @@ -293,7 +96,6 @@ func TestAnUnconfirmedHardwareChangeGoesBack(t *testing.T) { awaitCondition(t, func() bool { return hasOption(b.hub.Config().Scale.Options, "port", `"COM8"`) }, "la configuration non confirmée n'est jamais revenue en arrière") - } // The two bounds of the wait below. They are POLLING intervals and not budgets: no @@ -368,324 +170,6 @@ func skipUnderShort(t *testing.T) { } } -// TestBlockFingerprintIsSemanticAndNotTextual is what keeps a reload from cutting -// a serial port because somebody reordered two JSON keys. -func TestBlockFingerprintIsSemanticAndNotTextual(t *testing.T) { - first := domain.ScaleConfig{ - Type: "gram-xfoc-plus", Present: true, ManualEntryAllowed: true, - Options: mustOptions(t, `{"port":"COM8","baud":9600}`), - } - second := first - second.Options = mustOptions(t, `{"baud":9600,"port":"COM8"}`) - - if BlockFingerprint(first) != BlockFingerprint(second) { - t.Fatal("deux configurations sémantiquement identiques ont des empreintes différentes : " + - "un réordonnancement de clés couperait le port série en plein service") - } - - // The case that really needs canonicalising: a NESTED object. Driver options - // hold raw JSON, so the bytes of printer.options.fallback travel exactly as - // they were typed — key order included. - nested := domain.PrinterConfig{ - Type: "raster", Template: "weighing_identical", - Options: mustOptions(t, `{"fallback":{"enabled":false,"queue":"SATO WS408_3"}}`), - } - reordered := nested - reordered.Options = mustOptions(t, `{"fallback":{"queue":"SATO WS408_3","enabled":false}}`) - if BlockFingerprint(nested) != BlockFingerprint(reordered) { - t.Fatal("un objet imbriqué réordonné change l'empreinte : la file d'impression " + - "serait reconstruite pour un espace de plus dans le fichier") - } - - third := first - third.Options = mustOptions(t, `{"port":"COM9","baud":9600}`) - if BlockFingerprint(first) == BlockFingerprint(third) { - t.Fatal("deux configurations différentes ont la même empreinte : le changement passerait inaperçu") - } - - if got := len(BlockFingerprint(first)); got != 8 { - t.Fatalf("empreinte de %d caractères, attendu 8 : c'est ce que lit l'écran d'administration", got) - } -} - -// TestANumberKeepsItsLiteralInTheFingerprint guards the canonicalisation: a figure -// re-read as a float and printed back in exponent form would change an -// fingerprint, and restart hardware, for nothing. -func TestANumberKeepsItsLiteralInTheFingerprint(t *testing.T) { - options := mustOptions(t, `{"max_amount":99999999999999999999}`) - first := BlockFingerprint(domain.ScaleConfig{Options: options}) - second := BlockFingerprint(domain.ScaleConfig{Options: mustOptions(t, `{"max_amount":99999999999999999999}`)}) - if first != second { - t.Fatal("un grand entier ne survit pas à la canonicalisation") - } -} - -// mustOptions parses driver options from the JSON an operator would have typed. -func mustOptions(t *testing.T, raw string) domain.DriverOptions { - t.Helper() - var options domain.DriverOptions - if err := json.Unmarshal([]byte(raw), &options); err != nil { - t.Fatalf("options illisibles : %v", err) - } - return options -} - -// hasOption reports whether an option carries a given raw JSON value. -func hasOption(options domain.DriverOptions, key, want string) bool { - raw, ok := options[key] - return ok && string(raw) == want -} - -// printerForge hands out one fake printer per instantiation. -type printerForge struct { - mu sync.Mutex - built []*fake.Printer - err error -} - -func (f *printerForge) New(domain.Config) (ports.Printer, error) { - f.mu.Lock() - defer f.mu.Unlock() - if f.err != nil { - return nil, f.err - } - p := fake.NewPrinter() - f.built = append(f.built, p) - return p, nil -} - -func (f *printerForge) count() int { - f.mu.Lock() - defer f.mu.Unlock() - return len(f.built) -} - -// TestThePrinterBlockRebuildsAndReleasesTheOldOne is the second line of the -// hardware table of §11.4: close, rebuild, self-test, about 200 ms. -func TestThePrinterBlockRebuildsAndReleasesTheOldOne(t *testing.T) { - forge := &printerForge{} - b := newBench(t) - b.station.newPrinter = forge.New - - next := b.hub.Config() - next.Printer.Options = mustOptions(t, `{"transport":"winspool","queue":"SATO WS408_3"}`) - - outcome, err := b.station.Reload(ReloadRequest{Next: next}) - if err != nil { - t.Fatalf("Reload : %v", err) - } - if len(outcome.Changed) != 1 || outcome.Changed[0] != blockPrinter { - t.Fatalf("blocs redémarrés %v, attendu [%s]", outcome.Changed, blockPrinter) - } - if outcome.ConfirmBefore.IsZero() { - t.Fatal("un changement d'imprimante n'a pas armé le compte à rebours") - } - if forge.count() != 1 { - t.Fatalf("%d imprimantes instanciées, attendu 1", forge.count()) - } - if !b.printer.Closed() { - t.Fatal("l'imprimante précédente n'a pas été relâchée") - } -} - -// TestAPrinterThatCannotBeRebuiltKeepsTheOneThatWorks: losing a working printer -// over a setting that was refused anyway would take the station out of service for -// nothing. -func TestAPrinterThatCannotBeRebuiltKeepsTheOneThatWorks(t *testing.T) { - forge := &printerForge{err: errors.New("file d'impression introuvable")} - b := newBench(t) - b.station.newPrinter = forge.New - - next := b.hub.Config() - next.Printer.Options = mustOptions(t, `{"transport":"winspool","queue":"inconnue"}`) - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - if b.printer.Closed() { - t.Fatal("l'imprimante qui marche a été fermée pour une configuration refusée") - } - b.feed(1236, 2) - if ack := b.tap("still-printing", 1236); !ack.Accepted { - t.Fatalf("le poste n'imprime plus après un rechargement refusé : %s", ack.Message) - } - b.awaitPrint() - awaitCondition(t, func() bool { return b.technical.has("ERR-PRN-01") }, - "le refus de reconstruction n'a pas été journalisé") -} - -// TestTheCatalogBlockFollowsTheStationNumber is the fourth line of the table: -// station.number is reloaded WITH the catalog, because the name of the watched -// file — flv_.csv — is its only real consumer. -func TestTheCatalogBlockFollowsTheStationNumber(t *testing.T) { - first := newDropSource(nil) - second := newDropSource(nil) - b := newBench(t) - b.station.swapCatalogSource(first) - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } - - next := b.hub.Config() - next.Station.Number = 3 - - outcome, err := b.station.Reload(ReloadRequest{Next: next}) - if err != nil { - t.Fatalf("Reload : %v", err) - } - if len(outcome.Changed) != 1 || outcome.Changed[0] != blockCatalog { - t.Fatalf("blocs redémarrés %v, attendu [%s]", outcome.Changed, blockCatalog) - } - if outcome.ConfirmBefore.IsZero() != true { - t.Fatal("un changement de catalogue arme un compte à rebours : il ne coupe rien") - } - if b.station.currentCatalogSource() != second { - t.Fatal("la veille n'a pas été relancée sur la nouvelle source") - } -} - -// parkingSource blocks in Next until its context is cancelled, and announces every -// entry. -// -// Announcing the ENTRY is the whole point: a test that only knows a source yielded -// once cannot tell whether the watch has gone back inside it, and the property below -// is about a watch that is provably parked in the source a reload replaces. -type parkingSource struct{ entries chan struct{} } - -func newParkingSource() *parkingSource { - return &parkingSource{entries: make(chan struct{}, 4)} -} - -func (s *parkingSource) Name() string { return domain.CatalogSourceLocalDrop } - -func (s *parkingSource) Next(ctx context.Context) (*ports.Batch, error) { - s.entries <- struct{}{} - <-ctx.Done() - return nil, ctx.Err() -} - -func (s *parkingSource) Acknowledge(context.Context, *ports.Batch, ports.BatchResult) error { - return nil -} - -func (s *parkingSource) Close() error { return nil } - -var _ ports.CatalogSource = (*parkingSource)(nil) - -// awaitEntry waits for the watch to be inside the source. -func awaitEntry(t *testing.T, source *parkingSource, message string) { - t.Helper() - select { - case <-source.entries: - case <-time.After(hang): - t.Fatal(message) - } -} - -// TestTheWatchLeavesTheSourceAReloadReplaced is what the pointer swap of -// TestTheCatalogBlockFollowsTheStationNumber does NOT prove. -// -// The watch reads the source into a local variable and then blocks inside its Next, -// which returns on a batch, an error or a cancellation and on nothing else. Swapping -// the pointer under a goroutine parked in the old source changes what a getter -// answers and leaves the watch exactly where it was: the station went on watching an -// empty drop folder after being pointed at a share, and only a restart of the service -// ever moved it. « Recharger le catalogue » made it worse rather than better — it -// wakes the source in service, which is the one nobody is reading. -func TestTheWatchLeavesTheSourceAReloadReplaced(t *testing.T) { - first, second := newParkingSource(), newParkingSource() - b := newBench(t, func(o *benchOptions) { o.source = first }) - awaitEntry(t, first, "la veille n'est jamais entrée dans la source de départ") - - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } - next := b.hub.Config() - next.Station.Number = 3 - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - - awaitEntry(t, second, "la veille est restée dans la source remplacée : "+ - "la nouvelle n'a jamais été lue, et un poste dans cet état n'importe plus rien") -} - -// TestReplacingTheSourceIsNotAReadFailure keeps ERR-CAT-03 worth reading. -// -// The cancellation that ends the read is the station's own doing, and a journal that -// reported it as « Lecture du catalogue impossible » would put a red line under every -// ordinary change of source — which is how a code stops being read. -func TestReplacingTheSourceIsNotAReadFailure(t *testing.T) { - first, second := newParkingSource(), newParkingSource() - b := newBench(t, func(o *benchOptions) { o.source = first }) - awaitEntry(t, first, "la veille n'est jamais entrée dans la source de départ") - - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return second, nil } - next := b.hub.Config() - next.Station.Number = 3 - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - awaitEntry(t, second, "la veille est restée dans la source remplacée") - - // A BARRIER, and it is what makes the assertion below mean something: a technical - // line is enqueued on a channel the journal drains on its own goroutine, so asking - // « is ERR-CAT-03 there ? » right away asks before the writer had to answer. This - // second reload cannot rebuild anything and says so — after the watch enqueued - // whatever it was going to enqueue, one FIFO, one consumer. ERR-CAT-05 in sight - // therefore means ERR-CAT-03 would be in sight too. - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { - return nil, errors.New("partage inaccessible") - } - next.Station.Number = 4 - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - awaitCondition(t, func() bool { return b.technical.has("ERR-CAT-05") }, - "la barrière n'est jamais arrivée dans le journal") - - if b.technical.has("ERR-CAT-03") { - t.Fatal("le remplacement d'une source a été journalisé comme une lecture impossible") - } -} - -// TestTheWatchPicksUpASourceItStartedWithout is the other half of the same -// property, and the one an installation meets first. -// -// A source that cannot be built is an amber light and never a station that refuses to -// start (serve.go), so a station whose share was unreachable at boot runs with no -// source at all. The watch used to wait on the process context in that case — which -// is to say for good: the volunteer repairs the address on the screen, the station -// answers « configuration enregistrée », and nothing is ever watched again. -func TestTheWatchPicksUpASourceItStartedWithout(t *testing.T) { - arriving := newParkingSource() - b := newBench(t) // no source: this is a station whose share was unreachable at boot - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { return arriving, nil } - - next := b.hub.Config() - next.Station.Number = 3 - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - - awaitEntry(t, arriving, "la veille n'a jamais pris la source que le rechargement a mise "+ - "en service : ce poste ne peut plus importer sans redémarrage") -} - -// TestACatalogSourceThatCannotBeRebuiltIsJournalled keeps the memory catalog in -// service: there is no gap, and the failure is named. -func TestACatalogSourceThatCannotBeRebuiltIsJournalled(t *testing.T) { - b := newBench(t) - b.station.newCatalogSource = func(domain.Config) (ports.CatalogSource, error) { - return nil, errors.New("partage inaccessible") - } - next := b.hub.Config() - next.Station.Number = 4 - if _, err := b.station.Reload(ReloadRequest{Next: next}); err != nil { - t.Fatalf("Reload : %v", err) - } - if b.hub.Catalog() == nil { - t.Fatal("le catalogue en mémoire a été perdu : le rechargement d'une source ne coupe rien") - } - awaitCondition(t, func() bool { return b.technical.has("ERR-CAT-05") }, - "l'échec de reconstruction de la source n'a pas été journalisé") -} - // TestTheListenAddressArmsTheCountdownWithoutRestartingAProcess is ADR-027: a // net.Listener closes and reopens in three lines, so no configuration block // demands a process restart. diff --git a/internal/station/shutdown.go b/internal/station/shutdown.go new file mode 100644 index 0000000..339b1df --- /dev/null +++ b/internal/station/shutdown.go @@ -0,0 +1,172 @@ +package station + +import ( + "context" + "time" + + "openscale/internal/domain" + "openscale/internal/station/ports" +) + +// This file is the ordered shutdown of §13.4, and THE ORDER IS THE FIX: cancel the +// root, wait for the loop to RETURN, close the subscribers, drain the workers, then +// release the devices. Every wait here is bounded on the injected clock. + +// The budgets of the shutdown sequence (§13.4). Every one of them is spent on the +// INJECTED clock, never on context.WithTimeout, which reads the real one. +const ( + // hubStopBudget bounds the wait for the loop to RETURN. It exits through + // ctx.Done within a few microseconds — it never blocks — but a shutdown does + // not hang on an invariant. + hubStopBudget = 1 * time.Second + // serverStopBudget is what Shutdown gets once no SSE stream is active any more. + serverStopBudget = 2 * time.Second + // printDrainBudget lets the label in flight finish. + printDrainBudget = 8 * time.Second + // journalDrainBudget lets the pending rows be written. + journalDrainBudget = 2 * time.Second + // deviceCloseBudget bounds a Close that never returns, which a faulty Windows + // serial port really does. §13.4 leaves this one unbounded; leaving it so is + // exactly the systemd SIGKILL that §13.4 exists to remove. + deviceCloseBudget = 3 * time.Second +) + +// Stop shuts the station down in the ONLY safe order. +// +// THE ORDER IS THE FIX. We first wait for the Hub loop to RETURN, and only then +// close the subscriber channels: closing them before means closing them while +// publish is emitting on them, which is a « send on closed channel ». The loop +// exits through ctx.Done within a few microseconds — it never blocks — but the +// wait stays bounded, because a shutdown does not hang on an invariant. +// +// It is idempotent, and it has to be: Stop is called by the service manager and +// again by whatever noticed first. +func (s *Station) Stop() { + s.stopOnce.Do(func() { + defer close(s.stopped) + t0 := s.clock.Now() + + if s.cancelRoot != nil { + // Cancels EVERY in-flight request context as well, because the server + // derives them from this one through BaseContext, AND the Hub loop. + s.cancelRoot() + } + + if s.started { + s.awaitHubStop() + } + + // IDEMPOTENT: the SSE handlers see their channel closed and exit + // IMMEDIATELY. A second call from the server's shutdown hook is a no-op. + s.hub.CloseSubscribers() + + if s.server != nil { + ctx, cancel := ports.WithBudget(context.Background(), s.clock, serverStopBudget) + _ = s.server.Shutdown(ctx) + cancel() // never dropped: go vet lostcancel + } + + if s.started { + // The workers die by the CLOSURE of their channel, and the Hub loop — + // their only writer — has already returned. + close(s.hub.printJobs) + if !waitAll(s.clock, printDrainBudget, s.print.finished) { + s.hub.logTechnical(domain.LevelWarn, "printer", "", + "Étiquette en cours non terminée dans le budget d'arrêt.", "") + } + close(s.hub.journalEntries) + if !waitAll(s.clock, journalDrainBudget, s.journal.finished) { + s.hub.logTechnical(domain.LevelWarn, "system", "", + "Journal non vidé dans le budget d'arrêt.", "") + } + } + + // Written SYNCHRONOUSLY and BEFORE the devices close, for two reasons that + // both come from the order of §13.4: the worker that carries technical + // lines has just been drained, so the channel would swallow this one; and + // the store closes at the very end, so a line written after it would find + // no database to go into. + if s.sink != nil { + _ = s.sink.RecordTechnical(context.Background(), TechnicalEntry{ + At: s.clock.Now(), Level: domain.LevelInfo, Source: "system", + Message: "Boucle et workers arrêtés, fermeture des périphériques.", + Detail: s.clock.Now().Sub(t0).String(), + }) + } + + s.closeDevices() + s.duration = s.clock.Now().Sub(t0) + }) +} + +// awaitHubStop waits, BOUNDED, for the loop to have returned, and reports whether +// it did. +// +// The loop exits through ctx.Done within a few microseconds — it never blocks +// (invariant 3 of §13.2) — so the bound is not there because it is expected to +// fire. It is there because a shutdown must not hang on an invariant: the day one +// of them is broken, the station still stops and says so. +func (s *Station) awaitHubStop() bool { + select { + case <-s.hub.Done(): + return true + case <-s.clock.After(hubStopBudget): + s.hub.logTechnical(domain.LevelError, "system", "ERR-SYS-04", + "Boucle du Hub non terminée en 1 s, arrêt poursuivi.", "") + return false + } +} + +// StopDuration is how long the shutdown took, MEASURED ON THE INJECTED CLOCK. +// +// It is the figure the endurance test asserts — « arrêt complet en moins de 3 s +// avec 4 abonnés » — and it is an assertion rather than an intention precisely +// because it is measured and not guessed. It is only meaningful once Stopped is +// closed. +func (s *Station) StopDuration() time.Duration { return s.duration } + +// Stopped is closed when Stop has finished. It is what a test and a service +// wrapper wait on. +func (s *Station) Stopped() <-chan struct{} { return s.stopped } + +// closeDevices releases the scale, the catalog source and the store, in that +// order, and lets none of them hang the shutdown. +// +// Scale.Close is declared BLOCKING and really does fail to return on a faulty +// Windows serial port. §13.4 leaves it unbounded; bounding it is what keeps the +// measured budget true, and the process is going away anyway. +func (s *Station) closeDevices() { + // The devices are the fields a RELOAD replaces — scale, printer, catalogSource — + // and a reload runs on the goroutine of an HTTP handler while this runs on the + // one calling Stop. Taking reloadMu is what stops the two from interleaving, and + // it is the right mutex here where it was the wrong one for catalogSource alone: + // a shutdown genuinely SHOULD wait for a reload in flight to finish rather than + // close a serial port somebody is in the middle of reopening. The wait is bounded + // anyway — every step a reload takes is (§11.4). + s.reloadMu.Lock() + defer s.reloadMu.Unlock() + + if s.scale != nil { + closed := make(chan struct{}) + driver := s.scale + go func() { + defer close(closed) + s.logIfErr(driver.Close()) + }() + if !waitAll(s.clock, deviceCloseBudget, closed) { + s.counters.UnconfirmedScaleCloses.Add(1) + } + } + if s.catalogWait != nil { + s.catalogWait.Wait() + } + if source := s.currentCatalogSource(); source != nil { + s.logIfErr(source.Close()) + } + if s.printer != nil { + s.logIfErr(s.printer.Close()) + } + if s.store != nil { + s.logIfErr(s.store.Close()) + } +} diff --git a/internal/station/station.go b/internal/station/station.go index 907e568..e401490 100644 --- a/internal/station/station.go +++ b/internal/station/station.go @@ -1,13 +1,8 @@ package station import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" - "fmt" "sync" "time" @@ -15,189 +10,11 @@ import ( "openscale/internal/station/ports" ) -// codeScaleUnavailable is ERR-SCL-03 — « Le port de la balance ne peut pas être -// ouvert. » It is the code the fallback to manual entry carries, because that is -// the fact a volunteer has to act on. -const codeScaleUnavailable = "ERR-SCL-03" - -// The budgets of the shutdown sequence (§13.4). Every one of them is spent on the -// INJECTED clock, never on context.WithTimeout, which reads the real one. -const ( - // hubStopBudget bounds the wait for the loop to RETURN. It exits through - // ctx.Done within a few microseconds — it never blocks — but a shutdown does - // not hang on an invariant. - hubStopBudget = 1 * time.Second - // serverStopBudget is what Shutdown gets once no SSE stream is active any more. - serverStopBudget = 2 * time.Second - // printDrainBudget lets the label in flight finish. - printDrainBudget = 8 * time.Second - // journalDrainBudget lets the pending rows be written. - journalDrainBudget = 2 * time.Second - // deviceCloseBudget bounds a Close that never returns, which a faulty Windows - // serial port really does. §13.4 leaves this one unbounded; leaving it so is - // exactly the systemd SIGKILL that §13.4 exists to remove. - deviceCloseBudget = 3 * time.Second -) - -// scaleCloseBudget bounds the release of the serial port during a reload (§11.4). -// -// Both waits it covers are bounded, and both had to be: the contract does require -// closing done on every exit path, but a contract is not an execution guarantee; -// and Close, declared BLOCKING, may never return on a failed Windows serial port. -// The caller is the handler that writes the configuration, and writing a -// configuration must NEVER be able to hang. -const scaleCloseBudget = 3 * time.Second - -// confirmationWindow is how long a hardware change has to be confirmed before the -// station goes back to the configuration it had (§11.4). -// -// It is `ip route` under SSH: impossible to cut the branch you are sitting on. -const confirmationWindow = 60 * time.Second - -// The configuration blocks a change can touch, spelled the way the admin screen -// spells them. -const ( - blockScale = "scale" - blockPrinter = "printer" - blockCatalog = "catalog" - blockNetwork = "network.listen" -) - -// ErrNoConfirmationPending reports a confirmation nobody asked for. -var ErrNoConfirmationPending = errors.New("station: aucune confirmation en attente") - -// ScaleFactory builds the scale driver a configuration names. -// -// It is INJECTED because internal/station knows no concrete driver: adding a scale -// is one package and one line in cmd/openscale/drivers.go, with zero modification -// here (cut 2 of §5.2). -type ScaleFactory func(cfg domain.Config) (ports.Scale, error) - -// PrinterFactory builds the printer a configuration names. -type PrinterFactory func(cfg domain.Config) (ports.Printer, error) - -// CatalogSourceFactory builds the catalog source a configuration names. -type CatalogSourceFactory func(cfg domain.Config) (ports.CatalogSource, error) - -// CatalogApplier turns the batch a source produced into the snapshot that will -// take service, and says what to acknowledge. -// -// It is a hook and not a hard-coded step because the qualification of §10.3 and -// the guards of §10.4 — an amputated catalog must not replace a healthy one — -// belong to internal/catalog, which this package does not import. The default -// builds the snapshot and nothing else. -type CatalogApplier func(ctx context.Context, cfg domain.Config, b *ports.Batch) (*domain.Catalog, ports.BatchResult, error) - -// CatalogBatch is a whole catalog waiting to take service. -// -// It carries what produced it so that the dashboard can say « Catalogue du -// 24/07/2026 » without asking the store. -type CatalogBatch struct { - Catalog *domain.Catalog - Source string - FileName string - // ImportedAt is the instant of the import that PRODUCED this catalog — the - // occurred_at of its row in the imports table, and never the instant of the swap. - // - // The two differ by up to MaxSwitchIdle, because a catalog waits for a station - // nobody is touching (§10.8). Stamping the swap made the same catalog carry one - // date in service and another after the next restart, which reads it back from the - // base. One catalog, one instant. - ImportedAt time.Time -} - -// Server is the part of an HTTP server the shutdown needs. Declared here, on the -// consumer's side, so that internal/station imports no net/http. -type Server interface { - // Shutdown stops accepting and waits for the active requests, up to ctx. - Shutdown(ctx context.Context) error -} - -// Closer is the part of the store, and of anything else with a handle, that the -// shutdown needs. -type Closer interface { - Close() error -} - -// Waiter is something the shutdown waits for before closing what it writes to — -// an import transaction that has to roll back, typically. -type Waiter interface { - Wait() -} - -// Options is everything a station is given. Clock, Config, Printer and Journal -// are required; the rest has an honest default. -type Options struct { - Clock ports.Clock - Config domain.Config - // Catalog is the snapshot already in the store, or nil on a virgin station. Nil - // starts the machine in Initializing, which is what makes the grid say - // « Catalogue vide » instead of showing nothing. - Catalog *domain.Catalog - // CatalogAt is when that snapshot was IMPORTED — the instant of the last import - // the store applied, read back from the base by the composition root. - // - // It is handed in rather than taken from the clock, and that is the defect this - // field was added for: a station stamps the catalog it starts with, so reading the - // clock here dated every catalog from the last reboot. §14.3 shows this instant - // permanently to answer « ces prix datent de quand ? », and a date that a service - // restart moves answers a question nobody asked. - CatalogAt time.Time - // OutOfService starts the station in the one terminal state, which is what an - // unusable configuration does (§11.3, ERR-CFG-01). - OutOfService bool - // Registries carries the driver descriptors, and it is here for ONE question: is - // the configuration that just arrived still unusable? - // - // A station started out of service is repaired from the administration screen, block - // by block, and it comes back into service the moment the last fault goes — which is - // what §11.4 promises when it says no configuration block requires a restart. Left at - // its zero value, no driver is known, every configuration carries faults, and the - // station never returns: that is the safe default for a caller that never had a reason - // to be out of service in the first place. - Registries domain.Registries - // Poller is the daily check for a newer version of this binary. Nil starts no - // worker at all, which is what a binary that cannot update itself honestly is - // -- a development build, or a platform with no swap. - Poller Poller - // Templates resolves printer.template. It defaults to the shipped ones. - Templates map[string]domain.Template - // NominalRate is the cadence the scale driver DECLARES, used until the rate - // meter has eight intervals of its own. - NominalRate time.Duration - Counters *Counters - - Scale ports.Scale - Printer ports.Printer - CatalogSource ports.CatalogSource - Journal Journal - TechnicalSink TechnicalSink - - NewScale ScaleFactory - NewPrinter PrinterFactory - NewCatalogSource CatalogSourceFactory - ApplyCatalog CatalogApplier - - Server Server - Store Closer - // CatalogWait rolls an import transaction back before the database closes. - CatalogWait Waiter - // OnRevert is called when the 60 s window of §11.4 closed without a confirmation, and it - // receives THE FILE AS IT WAS BEFORE THE SAVE — never the configuration the station was - // running. - // - // It exists because the countdown protects the RUNNING station and the file is written - // before it starts: without this hook, a station that rolled back would come back, at - // the next restart, on the very configuration nobody confirmed — which is exactly the - // branch the countdown was cutting. What it does is the caller's business; internal/ - // station knows no file. - // - // The two documents are distinct on the one station this matters for. A station whose - // configuration is unusable RUNS the neutral profile (§11.3) while its file keeps the - // cooperative's tariffs, safeguards and categories: handing the running configuration - // over here wrote the factory profile onto that file, on the very save that repaired it. - OnRevert func(fileBefore domain.Config) -} +// This file is the Station itself: what it holds, how it is wired, what Start launches +// and how a supervisor asks whether it is still alive. What it DOES with a +// configuration change, with a catalog or with a stop is in reload.go, devices.go, +// catalogwatch.go and shutdown.go — and the bounded wait all four of them spend on the +// injected clock is at the bottom of this one. // Station is the running weighing station: the Hub, its two workers, its // supervisor, the hot reload of §11.4 and the ordered shutdown of §13.4. @@ -281,17 +98,6 @@ type Station struct { duration time.Duration } -// pendingConfirmation is what to go back to if nobody confirms — and it is TWO documents, -// because a station and its file do not always carry the same one. -type pendingConfirmation struct { - // running is what the station was OPERATING ON, and it is what goes back into service. - running domain.Config - // file is what the configuration file CARRIED before the save, and it is what goes - // back on disk. On a station out of service the two differ completely (§11.3). - file domain.Config - deadline time.Time -} - // New wires a station. It starts nothing. func New(o Options) (*Station, error) { switch { @@ -433,360 +239,6 @@ func (s *Station) Alive() bool { // hubLivenessBudget is publishHeartbeat + tickInterval, rounded up. const hubLivenessBudget = publishHeartbeat + tickInterval + 400*time.Millisecond -// --- Hot reload (§11.4) ---------------------------------------------------- - -// ReloadOutcome is what a configuration change did, and what is still expected of -// whoever asked for it. -type ReloadOutcome struct { - // Changed names the blocks that actually moved. - Changed []string - // ConfirmBefore is the end of the 60 s countdown, and it is zero when nothing - // has to be confirmed. Past it, without a Confirm, the station goes back to the - // configuration it had. - ConfirmBefore time.Time -} - -// ReloadRequest is one configuration change, with the document the rollback would have to -// put back on disk. -// -// The two travel together because the countdown of §11.4 has two things to undo, and a -// caller that only handed over the new configuration left the station guessing at the other. -type ReloadRequest struct { - // Next is the configuration to put in service. - Next domain.Config - // FileBefore is what the configuration FILE carried before this change was written, - // and it is the document a rollback puts back on disk. - // - // A POINTER, and nil says « je n'ai pas pu lire le fichier » — the rollback then falls - // back on the configuration in service, which is all such a caller possesses. It is not - // a domain.Config with a zero value, because the zero value of a configuration LOOKS - // like a configuration: a caller that forgot this field would arm a rollback towards a - // document nobody ever validated, and nothing would say so. - FileBefore *domain.Config -} - -// Reload publishes a new configuration and restarts ONLY the subsystems whose -// block actually changed. -// -// limits, tiers, template, UI and journal apply instantly, with no gap in service: -// they are read from the atomic pointer on the next turn of the loop and by -// nothing else. -func (s *Station) Reload(req ReloadRequest) (ReloadOutcome, error) { - s.reloadMu.Lock() - defer s.reloadMu.Unlock() - - running := *s.hub.cfg.Load() - changed := s.apply(running, req.Next) - - outcome := ReloadOutcome{Changed: changed} - if needsConfirmation(changed) { - outcome.ConfirmBefore = s.clock.Now().Add(confirmationWindow) - file := running - if req.FileBefore != nil { - file = *req.FileBefore - } - s.confirmation = &pendingConfirmation{ - running: running, file: file, deadline: outcome.ConfirmBefore, - } - } - return outcome, nil -} - -// PendingConfirmation reports the end of the countdown still running, or the zero time when -// nothing is waiting to be confirmed. -// -// It exists so that the administration can REFUSE a second save inside the window, the way -// it refuses a confirmation outside it. Accepting one would replace the target of the -// rollback with a configuration nobody has confirmed either, and the version somebody really -// did validate would be the one lost. -func (s *Station) PendingConfirmation() time.Time { - s.reloadMu.Lock() - defer s.reloadMu.Unlock() - if s.confirmation == nil { - return time.Time{} - } - return s.confirmation.deadline -} - -// Confirm accepts the configuration in force and stops the countdown. -func (s *Station) Confirm() error { - s.reloadMu.Lock() - defer s.reloadMu.Unlock() - if s.confirmation == nil { - return ErrNoConfirmationPending - } - s.confirmation = nil - return nil -} - -// revertIfUnconfirmed puts the previous configuration back when the countdown ran -// out. It is called from the supervisor, which is the only goroutine that watches -// deadlines — no timer goroutine is added to the inventory of §13.1. -func (s *Station) revertIfUnconfirmed(now time.Time) { - s.reloadMu.Lock() - defer s.reloadMu.Unlock() - if s.confirmation == nil || now.Before(s.confirmation.deadline) { - return - } - running, file := s.confirmation.running, s.confirmation.file - s.confirmation = nil - s.hub.logTechnical(domain.LevelWarn, "config", "", - "Configuration non confirmée en 60 s : retour à la version précédente.", "") - // The station goes back to what it was OPERATING ON, and to nothing else. Applying the - // file here would be the tempting symmetry and the wrong one: on a station out of - // service that document is the very one §11.3 refuses to run, and nothing in this - // package can put a station BACK into the out-of-service state. - s.apply(*s.hub.cfg.Load(), running) - if s.onRevert != nil { - // The FILE goes back too, and it has to: the countdown protects the station that - // is running, and the write of §11.4 happened before the countdown started. A - // station that rolled back and then restarted on the unconfirmed configuration - // would have cut the branch sixty seconds later than announced. - s.onRevert(file) - } -} - -// apply stores the configuration and restarts what has to be restarted. -// -// The comparison is NORMALIZED and not a reflect.DeepEqual over raw JSON: two -// configurations that are semantically identical but serialized with a different -// key order must NOT cut the serial port in the middle of a service. -func (s *Station) apply(previous, next domain.Config) []string { - // limits, tiers, template, UI, journal: instant, no service gap. - s.hub.cfg.Store(&next) - - var changed []string - if BlockFingerprint(previous.Scale) != BlockFingerprint(next.Scale) { - changed = append(changed, blockScale) - s.restartScale(next) - } - if BlockFingerprint(previous.Printer) != BlockFingerprint(next.Printer) { - changed = append(changed, blockPrinter) - s.restartPrinter(next) - } - // station.number is reloaded WITH the catalog: its only real consumer is the - // name of the watched file, flv_.csv (§11.2). - if BlockFingerprint(previous.Catalog) != BlockFingerprint(next.Catalog) || - previous.Station.Number != next.Station.Number { - changed = append(changed, blockCatalog) - s.restartCatalog(next) - } - if previous.Network.Listen != next.Network.Listen { - changed = append(changed, blockNetwork) - } - // LAST, and after the drivers have been rebuilt: a station coming back into service - // must find its scale open and its printer in place, not be declared ready in front - // of devices that are still being instantiated. - s.returnToServiceIfRepaired(next) - return changed -} - -// returnToServiceIfRepaired takes a station out of the terminal state of §11.3 once the -// configuration it is given no longer carries a fault. -// -// The question is asked HERE and not in the machine because the machine has no registry: -// « unusable » means « names a driver this binary does not have, or forgets an option that -// driver requires », and only the composition root knows what this binary was built with. -// The machine is told the ANSWER, once, through the one event that leaves the state. -// -// It costs one turn of the loop and it is spent on the goroutine of an administration -// handler, which is already waiting for a reload that opens a serial port. Failure is -// silent on purpose: the station is out of service either way, and a save that reported -// « configuration écrite mais poste toujours hors service » with no gesture attached would -// only frighten whoever just repaired it. -func (s *Station) returnToServiceIfRepaired(next domain.Config) { - if s.hub.State().State != domain.OutOfService { - return - } - if len((&next).Validate(s.registries)) > 0 { - return - } - ctx, cancel := ports.WithBudget(context.Background(), s.clock, hubStopBudget) - defer cancel() - if _, err := s.hub.Submit(ctx, domain.ConfigurationRepaired{}, ""); err != nil { - return - } - s.hub.logTechnical(domain.LevelWarn, "config", "", - "Configuration réparée : le poste quitte l'état hors service.", next.Fingerprint()) -} - -// needsConfirmation reports the blocks that arm the 60 s countdown: the hardware -// ones and the listening address. -func needsConfirmation(changed []string) bool { - for _, block := range changed { - switch block { - case blockScale, blockPrinter, blockNetwork: - return true - } - } - return false -} - -// restartScale cancels the sub-context, THEN WAITS for the device to be -// effectively closed before re-instantiating. -// -// On Windows the serial port is exclusive: without that wait, reopening fails -// intermittently with « Access denied ». That is why Scale.Close is BLOCKING. -// -// BOTH WAITS ARE BOUNDED, by the injected clock, and the caller is the handler -// that writes the configuration: -// -// a) a bare <-scaleDone — the contract does require closing done on EVERY exit -// path, including a Start that failed before launching its goroutine; but a -// contract is not an execution guarantee, and a faulty third-party driver -// would freeze the administration screen; -// b) Close, declared BLOCKING, may never return on a failed Windows serial port, -// and a bounded wait placed AFTER it would never have been reached. -func (s *Station) restartScale(next domain.Config) { - s.stopScale(next) - if s.newScale == nil || !next.Scale.Present { - return - } - driver, err := s.newScale(next) - if err != nil { - s.degradeToManual(codeScaleUnavailable, err.Error()) - return - } - s.scale = driver - if err := s.startScale(next); err != nil { - s.degradeToManual(codeScaleUnavailable, err.Error()) - return - } - s.hub.degraded.Store(nil) -} - -// stopScale cancels the driver in service and waits, BOUNDED, for it to let go. -func (s *Station) stopScale(next domain.Config) { - if s.cancelScale != nil { - s.cancelScale() - s.cancelScale = nil - } - - // Close runs in a DISPOSABLE goroutine: transient just like the one of - // ports.WithBudget, at most one per reload, released when the driver releases - // the port. - closed := make(chan struct{}) - previous, running := s.scale, s.scaleRunning - go func() { - defer close(closed) - if previous != nil { - s.logIfErr(previous.Close()) - } - }() - - waits := []<-chan struct{}{closed} - if running { - // Only a driver that was actually STARTED closes its done channel. Waiting - // on the channel of a driver that never ran would burn the whole budget on - // a station that has no scale at all. - waits = append(waits, s.scaleDone) - } - if !waitAll(s.clock, scaleCloseBudget, waits...) { - // We RE-INSTANTIATE ANYWAY. Reopening may fail with « Access denied »: - // that is an amber light and a fallback to manual entry, never a stalled - // configuration write. - s.hub.logTechnical(domain.LevelError, "scale", "ERR-SCL-08", - "Fermeture du port non confirmée en 3 s, réinstanciation forcée.", - next.Scale.Type) - s.counters.UnconfirmedScaleCloses.Add(1) - } - - // The old done channel is ABANDONED, never reused: a late goroutine that - // closed it afterwards would close nothing observable. - s.scaleDone = make(chan struct{}) - s.scale, s.scaleRunning = nil, false -} - -// startScale starts the driver in place, if there is one and the station declares -// it has a scale. -// -// A station that declares it has no scale has nothing to open, and that is an -// EXPLICIT declaration and not an inference: scale.present false turns the light -// off instead of leaving it red. -// -// scaleRunning is set BEFORE the call and stays set even when Start returns an -// error, because the contract of §5.3 has done closed on EVERY exit path — a -// driver that failed to open still signals its own end, and the next restart has -// to wait for that signal. -func (s *Station) startScale(cfg domain.Config) error { - if s.scale == nil || !cfg.Scale.Present { - return nil - } - ctx, cancel := context.WithCancel(s.rootCtx) - s.cancelScale = cancel - s.hub.nominalRate.Store(int64(s.scale.Descriptor().NominalRate)) - s.scaleRunning = true - return s.scale.Start(ctx, s.hub.Measurements(), s.scaleDone) -} - -// restartPrinter rebuilds the printer, and KEEPS THE ONE THAT WORKS if the new one -// cannot be built. -// -// Losing a working printer over a bad setting would take the station out of -// service for a change that was refused anyway; the amber light and the technical -// line say what happened. -func (s *Station) restartPrinter(next domain.Config) { - if s.newPrinter == nil { - return - } - built, err := s.newPrinter(next) - if err != nil { - s.hub.logTechnical(domain.LevelError, "printer", "ERR-PRN-01", - "Imprimante non reconstruite : la précédente reste en service.", err.Error()) - return - } - previous := s.printer - s.printer = built - s.print.printer = built - if previous != nil { - s.logIfErr(previous.Close()) - } -} - -// restartCatalog stops the watch and starts it again on the new source, and on the -// new file name. The catalog IN MEMORY is untouched: there is no gap in service. -func (s *Station) restartCatalog(next domain.Config) { - if s.newCatalogSource == nil { - return - } - built, err := s.newCatalogSource(next) - if err != nil { - s.hub.logTechnical(domain.LevelError, "catalog", "ERR-CAT-05", - "Source de catalogue non reconstruite.", err.Error()) - return - } - previous := s.swapCatalogSource(built) - if previous != nil { - s.logIfErr(previous.Close()) - } -} - -// degradeToManual is the fallback of §11.4 when nothing else worked. -// -// The station enters manual entry — a STATE, entered automatically, and not a -// driver somebody wrote into a file: the configuration on disk keeps saying what -// the operator asked for, and the in-memory one says what the station can actually -// do. The instant is what makes « pourquoi ce poste est-il en saisie manuelle ce -// matin ? » a decidable question. -func (s *Station) degradeToManual(code, reason string) { - live := *s.hub.cfg.Load() - live.Scale.Present = false - live.Scale.ManualEntryAllowed = true - s.hub.cfg.Store(&live) - s.hub.degraded.Store(&Degradation{Since: s.clock.Now(), Code: code, Reason: reason}) - s.hub.logTechnical(domain.LevelError, "scale", code, - "Matériel indisponible : le poste passe en saisie manuelle.", reason) -} - -// logIfErr sends a driver error to the technical journal and swallows it. -func (s *Station) logIfErr(err error) { - if err == nil { - return - } - s.hub.logTechnical(domain.LevelWarn, "scale", "ERR-SCL-05", - "Fermeture du périphérique en erreur.", err.Error()) -} - // waitAll reports whether ALL the channels were closed before the deadline. // // It closes nothing and retains no goroutine: the deadline is one channel from the @@ -802,327 +254,3 @@ func waitAll(clk ports.Clock, d time.Duration, cs ...<-chan struct{}) bool { } return true } - -// BlockFingerprint is the SHA-256 of the CANONICAL JSON of one configuration -// block, in eight hexadecimal characters. -// -// Canonical means: keys sorted, no spaces, numbers re-read literally. That is what -// makes the comparison semantic — two files that differ only by their key order -// must not cut a serial port in the middle of a service — and it is also what the -// administration screen shows to answer « quels blocs ont bougé ? ». -func BlockFingerprint(block any) string { - raw, err := json.Marshal(block) - if err != nil { - // A block that cannot be serialized cannot be compared either. Returning a - // value that is never equal to anything makes the change VISIBLE, which is - // the safe direction: a restart too many beats a port left on a stale - // setting. - return fmt.Sprintf("unmarshalable-%p", block) - } - canonical, err := canonicalJSON(raw) - if err != nil { - return fmt.Sprintf("unmarshalable-%p", block) - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:4]) -} - -// canonicalJSON re-reads and re-writes a JSON document so that two semantically -// identical documents produce the same bytes. -// -// Numbers go through json.Number, so 1605 stays 1605 and does not become 1.605e3 -// through a float64 — a fingerprint that changes with a serialization detail is a -// fingerprint that restarts hardware for nothing. -func canonicalJSON(raw []byte) ([]byte, error) { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.UseNumber() - var value any - if err := decoder.Decode(&value); err != nil { - return nil, err - } - return json.Marshal(value) -} - -// --- The catalog watch (§13.1 n° 5) ---------------------------------------- - -// currentCatalogSource reports the source in service. -// -// It exists because a reload replaces that source while watchCatalog is reading from -// it: -race caught the write of restartCatalog against the read of the watch loop. -func (s *Station) currentCatalogSource() ports.CatalogSource { - s.catalogMu.Lock() - defer s.catalogMu.Unlock() - return s.catalogSource -} - -// swapCatalogSource puts next in service and returns the one it replaced, so the -// caller closes the old source OUTSIDE the lock — Close talks to a file system or to -// a WebDAV server and has no business being held against the watch loop. -// -// It also ENDS the read in flight. Without that, the swap changes what a getter -// answers and nothing else: the watch stays parked in the source it just replaced, -// for as long as the process lives, and a station pointed at a share goes on watching -// an empty drop folder until somebody restarts the service. -func (s *Station) swapCatalogSource(next ports.CatalogSource) ports.CatalogSource { - s.catalogMu.Lock() - defer s.catalogMu.Unlock() - previous := s.catalogSource - s.catalogSource = next - if s.cancelCatalogRead != nil { - s.cancelCatalogRead() - s.cancelCatalogRead = nil - } - return previous -} - -// beginCatalogRead hands back the source in service and the context to read it with. -// -// The context ends with the parent or with the next swap, whichever comes first, and -// the returned func ends it once the read is over. It is handed out EVEN WHEN THERE IS -// NO SOURCE: a station whose share was unreachable at boot starts without one, and -// waiting on that context is how it notices the one a reload puts in service. -func (s *Station) beginCatalogRead(parent context.Context) (ports.CatalogSource, context.Context, context.CancelFunc) { - s.catalogMu.Lock() - defer s.catalogMu.Unlock() - ctx, cancel := context.WithCancel(parent) - s.cancelCatalogRead = cancel - return s.catalogSource, ctx, cancel -} - -// watchCatalog reads whole catalogs from the source and hands them to the loop. -// -// The swap itself is the Hub's business and it is DEFERRED: this goroutine never -// changes what is on screen, it only offers. -func (s *Station) watchCatalog(ctx context.Context) { - defer close(s.catalogDone) - for { - source, readCtx, endRead := s.beginCatalogRead(ctx) - if source == nil { - // Wait for one to arrive rather than for the end of the process: the - // station was started with an unbuildable source, and the volunteer is - // about to repair it on the screen. - <-readCtx.Done() - endRead() - if ctx.Err() != nil { - return - } - continue - } - batch, err := source.Next(readCtx) - // READ BEFORE ENDING IT: endRead cancels this very context, so asking - // afterwards answers « replaced » for every batch the source ever yields. - replaced := readCtx.Err() != nil - endRead() - if ctx.Err() != nil { - return - } - // A read ended by the swap and not by the source: the station has a new source - // and this loop reads it now. It is not a failure and it says nothing in the - // journal — the reload already wrote what changed. - if replaced { - continue - } - if err != nil { - s.hub.logTechnical(domain.LevelWarn, "catalog", "ERR-CAT-03", - "Lecture du catalogue impossible.", err.Error()) - continue - } - if batch == nil { - continue - } - s.offer(ctx, source, batch) - } -} - -// offer qualifies one batch, hands it to the loop and acknowledges the file. -// -// Acknowledgement is EXPLICIT and comes LAST: deleting at read time would let a -// crash between reading and applying lose an update for good, and without a trace. -func (s *Station) offer(ctx context.Context, source ports.CatalogSource, batch *ports.Batch) { - cfg := *s.hub.cfg.Load() - catalog, result, err := s.applyCatalog(ctx, cfg, batch) - if err != nil { - s.hub.logTechnical(domain.LevelError, "catalog", "ERR-CAT-03", - "Catalogue refusé.", err.Error()) - } else if catalog != nil { - s.logIfCatalogErr(s.hub.PushCatalog(ctx, &CatalogBatch{ - Catalog: catalog, Source: batch.Source, - FileName: batch.FileName, ImportedAt: importedAt(result, s.clock), - })) - } - if err := source.Acknowledge(ctx, batch, result); err != nil { - s.hub.logTechnical(domain.LevelWarn, "catalog", "ERR-CAT-05", - "Fichier de catalogue non supprimé.", err.Error()) - } -} - -// importedAt is the instant the applier recorded, or the clock when it recorded none. -// -// The fallback is for the DEFAULT applier — plainCatalog, which writes no history row -// because it has no store to write it to — and for any plug-in one somebody adds later. -// A station whose applier keeps no history still has to answer « ces prix datent de -// quand ? », and the moment its catalog was offered is the truest thing left to say. -func importedAt(result ports.BatchResult, clock ports.Clock) time.Time { - if result.AppliedAt.IsZero() { - return clock.Now() - } - return result.AppliedAt -} - -// logIfCatalogErr reports a catalog that never reached the loop. -func (s *Station) logIfCatalogErr(err error) { - if err == nil || errors.Is(err, ErrStopped) || errors.Is(err, context.Canceled) { - return - } - s.hub.logTechnical(domain.LevelWarn, "catalog", "", - "Catalogue non remis au Hub.", err.Error()) -} - -// plainCatalog is the default applier: it freezes the rows the source produced -// with the categories this station is configured for, and acknowledges 'applied'. -func plainCatalog(_ context.Context, cfg domain.Config, b *ports.Batch) (*domain.Catalog, ports.BatchResult, error) { - return domain.NewCatalog(b.Products, cfg.Catalog.Categories), - ports.BatchResult{Result: domain.ImportApplied}, nil -} - -// --- Shutdown (§13.4) ------------------------------------------------------ - -// Stop shuts the station down in the ONLY safe order. -// -// THE ORDER IS THE FIX. We first wait for the Hub loop to RETURN, and only then -// close the subscriber channels: closing them before means closing them while -// publish is emitting on them, which is a « send on closed channel ». The loop -// exits through ctx.Done within a few microseconds — it never blocks — but the -// wait stays bounded, because a shutdown does not hang on an invariant. -// -// It is idempotent, and it has to be: Stop is called by the service manager and -// again by whatever noticed first. -func (s *Station) Stop() { - s.stopOnce.Do(func() { - defer close(s.stopped) - t0 := s.clock.Now() - - if s.cancelRoot != nil { - // Cancels EVERY in-flight request context as well, because the server - // derives them from this one through BaseContext, AND the Hub loop. - s.cancelRoot() - } - - if s.started { - s.awaitHubStop() - } - - // IDEMPOTENT: the SSE handlers see their channel closed and exit - // IMMEDIATELY. A second call from the server's shutdown hook is a no-op. - s.hub.CloseSubscribers() - - if s.server != nil { - ctx, cancel := ports.WithBudget(context.Background(), s.clock, serverStopBudget) - _ = s.server.Shutdown(ctx) - cancel() // never dropped: go vet lostcancel - } - - if s.started { - // The workers die by the CLOSURE of their channel, and the Hub loop — - // their only writer — has already returned. - close(s.hub.printJobs) - if !waitAll(s.clock, printDrainBudget, s.print.finished) { - s.hub.logTechnical(domain.LevelWarn, "printer", "", - "Étiquette en cours non terminée dans le budget d'arrêt.", "") - } - close(s.hub.journalEntries) - if !waitAll(s.clock, journalDrainBudget, s.journal.finished) { - s.hub.logTechnical(domain.LevelWarn, "system", "", - "Journal non vidé dans le budget d'arrêt.", "") - } - } - - // Written SYNCHRONOUSLY and BEFORE the devices close, for two reasons that - // both come from the order of §13.4: the worker that carries technical - // lines has just been drained, so the channel would swallow this one; and - // the store closes at the very end, so a line written after it would find - // no database to go into. - if s.sink != nil { - _ = s.sink.RecordTechnical(context.Background(), TechnicalEntry{ - At: s.clock.Now(), Level: domain.LevelInfo, Source: "system", - Message: "Boucle et workers arrêtés, fermeture des périphériques.", - Detail: s.clock.Now().Sub(t0).String(), - }) - } - - s.closeDevices() - s.duration = s.clock.Now().Sub(t0) - }) -} - -// awaitHubStop waits, BOUNDED, for the loop to have returned, and reports whether -// it did. -// -// The loop exits through ctx.Done within a few microseconds — it never blocks -// (invariant 3 of §13.2) — so the bound is not there because it is expected to -// fire. It is there because a shutdown must not hang on an invariant: the day one -// of them is broken, the station still stops and says so. -func (s *Station) awaitHubStop() bool { - select { - case <-s.hub.Done(): - return true - case <-s.clock.After(hubStopBudget): - s.hub.logTechnical(domain.LevelError, "system", "ERR-SYS-04", - "Boucle du Hub non terminée en 1 s, arrêt poursuivi.", "") - return false - } -} - -// StopDuration is how long the shutdown took, MEASURED ON THE INJECTED CLOCK. -// -// It is the figure the endurance test asserts — « arrêt complet en moins de 3 s -// avec 4 abonnés » — and it is an assertion rather than an intention precisely -// because it is measured and not guessed. It is only meaningful once Stopped is -// closed. -func (s *Station) StopDuration() time.Duration { return s.duration } - -// Stopped is closed when Stop has finished. It is what a test and a service -// wrapper wait on. -func (s *Station) Stopped() <-chan struct{} { return s.stopped } - -// closeDevices releases the scale, the catalog source and the store, in that -// order, and lets none of them hang the shutdown. -// -// Scale.Close is declared BLOCKING and really does fail to return on a faulty -// Windows serial port. §13.4 leaves it unbounded; bounding it is what keeps the -// measured budget true, and the process is going away anyway. -func (s *Station) closeDevices() { - // The devices are the fields a RELOAD replaces — scale, printer, catalogSource — - // and a reload runs on the goroutine of an HTTP handler while this runs on the - // one calling Stop. Taking reloadMu is what stops the two from interleaving, and - // it is the right mutex here where it was the wrong one for catalogSource alone: - // a shutdown genuinely SHOULD wait for a reload in flight to finish rather than - // close a serial port somebody is in the middle of reopening. The wait is bounded - // anyway — every step a reload takes is (§11.4). - s.reloadMu.Lock() - defer s.reloadMu.Unlock() - - if s.scale != nil { - closed := make(chan struct{}) - driver := s.scale - go func() { - defer close(closed) - s.logIfErr(driver.Close()) - }() - if !waitAll(s.clock, deviceCloseBudget, closed) { - s.counters.UnconfirmedScaleCloses.Add(1) - } - } - if s.catalogWait != nil { - s.catalogWait.Wait() - } - if source := s.currentCatalogSource(); source != nil { - s.logIfErr(source.Close()) - } - if s.printer != nil { - s.logIfErr(s.printer.Close()) - } - if s.store != nil { - s.logIfErr(s.store.Close()) - } -} diff --git a/internal/station/subscribers.go b/internal/station/subscribers.go new file mode 100644 index 0000000..2ae4e31 --- /dev/null +++ b/internal/station/subscribers.go @@ -0,0 +1,129 @@ +package station + +import "sync" + +// This file is everything that touches the subscriber map: subscribing, leaving, and +// closing every channel once the loop has returned. The map is written in ONE +// goroutine — the loop's — through h.subscriptions; what the mutex covers is the +// shutdown alone, and the Hub says why. + +// subscriberDepth is the capacity of one subscriber channel. +// +// One. A snapshot 400 ms old has no value, so a slow subscriber gets the stale one +// dropped and the fresh one written; it can never hold the loop back, and the +// reading of the scale can never wait on a browser. +const subscriberDepth = 1 + +// subscription is a request to add or to remove one subscriber. +// +// It exists so that the map of subscribers is touched by the loop goroutine and by +// nothing else. A mutex there would reopen exactly the race this design closes, +// and closing a subscriber channel from a third goroutine while publish is +// emitting on it is a « send on closed channel » (défaut 61). +type subscription struct { + add chan Snapshot + remove chan Snapshot + ack chan struct{} +} + +// Subscribe returns the snapshot channel of a new subscriber and the function that +// unsubscribes it. +// +// h.subscribers is a field of the Hub JUST LIKE h.model: read and written in the +// loop goroutine only. This function does not touch it — it posts a request on +// h.subscriptions and waits for the ack, or gives up if the Hub has already +// stopped, in which case it closes the channel itself so that the caller's handler +// exits at once rather than waiting for a snapshot nobody will ever send. +func (h *Hub) Subscribe() (<-chan Snapshot, func()) { + ch := make(chan Snapshot, subscriberDepth) + if !h.request(subscription{add: ch, ack: make(chan struct{}, 1)}) { + close(ch) + return ch, func() {} + } + var once sync.Once + unsubscribe := func() { + once.Do(func() { + h.request(subscription{remove: ch, ack: make(chan struct{}, 1)}) + }) + } + return ch, unsubscribe +} + +// request posts one subscription change and reports whether the loop took it. +// +// The final non-blocking read of the ack is what makes the answer exact: the loop +// acks in the same turn it applies the change, so an ack that is not there when +// the Hub is done means the request was never applied — and then, and only then, +// the caller still owns the channel. +func (h *Hub) request(req subscription) bool { + select { + case h.subscriptions <- req: + case <-h.done: + return false + } + select { + case <-req.ack: + return true + case <-h.done: + select { + case <-req.ack: + return true + default: + return false + } + } +} + +// CloseSubscribers closes every subscriber channel and empties the map. +// +// 1. IDEMPOTENT — the body runs once. It has two legitimate call sites, Stop and +// the server's shutdown hook, and running both of them used to be a double +// close and a panic on every shutdown with a browser connected. +// 2. ORDERED — it is called only AFTER the loop has returned, so no publish can +// still be emitting on a channel it closes. gracefulStop, which runs IN the +// loop goroutine just before the loop returns, goes through the same guard: +// depending on the shutdown path either it or the external caller closes, +// never both. +func (h *Hub) CloseSubscribers() { + h.closeOnce.Do(func() { + h.subscribersMu.Lock() + defer h.subscribersMu.Unlock() + for ch := range h.subscribers { + close(ch) + delete(h.subscribers, ch) + } + }) +} + +// applySubscription adds or removes one subscriber, in the loop goroutine. +// +// Subscribing, unsubscribing and closing subscriber channels are SERIALIZED here, +// in the only goroutine allowed to touch h.subscribers. That is what makes the +// single-writer invariant true of the map itself, and not of the easy fields +// alone. +func (h *Hub) applySubscription(req subscription) { + switch { + case req.add != nil: + h.subscribersMu.Lock() + h.subscribers[req.add] = struct{}{} + h.subscribersMu.Unlock() + // A new subscriber gets the current state at once rather than waiting for + // the next change: a browser that has just restarted must be correct + // immediately. + req.add <- h.lastPublished + case req.remove != nil: + h.subscribersMu.Lock() + _, live := h.subscribers[req.remove] + if live { + delete(h.subscribers, req.remove) + } + h.subscribersMu.Unlock() + if live { + close(req.remove) + } + } + select { + case req.ack <- struct{}{}: + default: + } +} diff --git a/internal/station/workers.go b/internal/station/workers.go index 59c8814..2167309 100644 --- a/internal/station/workers.go +++ b/internal/station/workers.go @@ -270,7 +270,6 @@ type Poller interface { // between a deterministic test and a flaky one, and it was measured, not guessed. func (s *Station) runUpdateWorker(ctx context.Context, grace <-chan time.Time, ticks <-chan time.Time, stop func(), poller Poller) { - defer stop() select { case <-ctx.Done(): diff --git a/internal/store/catalog.go b/internal/store/catalog.go index 0db8246..de0f0e8 100644 --- a/internal/store/catalog.go +++ b/internal/store/catalog.go @@ -10,6 +10,13 @@ import ( "openscale/internal/domain" ) +// This file is the ONE transaction of §10.9: a whole catalog replaces the one in the +// base, or none of it does. The import row, the categories, the images, the products +// and the withdrawal of what left the file are written together — so the history and +// the grid can never disagree about which import produced which prices. +// +// What READS a catalog back out is in products.go. + // Batch is a whole catalog, as one import produced it. // // It carries the import row on purpose: products.last_import_id is NOT NULL, so the @@ -257,225 +264,3 @@ func withdrawUnseen(ctx context.Context, tx *sql.Tx, importID int64, now time.Ti n, err := res.RowsAffected() return int(n), err } - -// LoadCatalog builds the immutable snapshot the Hub publishes. -// -// The grid predicate of §12.3 is evaluated HERE, in SQL, once: present in the catalog -// (withdrawn_at IS NULL) and not refused by a human (local_decisions.offered). There -// is no products.visible column to forget to filter on, and no consumer can forget a -// clause it never sees. Products that are not weighable are still returned -- they are -// part of the catalog, they simply get no tile, and WeighableCount is what the -// dashboard shows. -// -// Ordering is alphabetical by name, as §12.3 requires; the presentation order of the -// grid remains the front's business. -func (d *DB) LoadCatalog(ctx context.Context) (*domain.Catalog, error) { - categories, err := d.categories(ctx) - if err != nil { - return nil, err - } - rows, err := d.reader.QueryContext(ctx, ` - SELECT p.id, p.name, p.reference, p.mode, p.price_suffix, p.unit_price_cents, - p.category_code, p.qualification, p.reason, p.csv_line, p.image_sha256 - FROM products p - LEFT JOIN local_decisions d ON d.product_id = p.id - WHERE p.withdrawn_at IS NULL - AND COALESCE(d.offered, 1) = 1 - ORDER BY p.name`) - if err != nil { - return nil, fmt.Errorf("lecture du catalogue impossible : %w", err) - } - defer rows.Close() - - var products []domain.Product - for rows.Next() { - p, err := scanProduct(rows) - if err != nil { - return nil, err - } - products = append(products, p) - } - if err := rows.Err(); err != nil { - return nil, err - } - return domain.NewCatalog(products, categories), nil -} - -// ProductRow is a product plus the three storage facts the domain type does not carry. -// -// The domain has no place for them and must not grow one: "when was this row last -// seen" and "which import saw it" are observations about a row, not properties of a -// product (§10.9). Only the admin screens read them. -type ProductRow struct { - Product domain.Product - SeenAt time.Time - // WithdrawnAt is the zero instant while the product is in the catalog. - WithdrawnAt time.Time - LastImportID int64 -} - -// AllProducts returns every row, withdrawn ones included, for the admin catalog screen. -// -// One grid with filters derived from the data, not four screens (ADR-024): the caller -// gets everything and narrows it itself, which is also what lets it say "4 products -// withdrawn since the import of 12/03" without a second query. -func (d *DB) AllProducts(ctx context.Context) ([]ProductRow, error) { - rows, err := d.reader.QueryContext(ctx, ` - SELECT id, name, reference, mode, price_suffix, unit_price_cents, category_code, - qualification, reason, csv_line, image_sha256, seen_at, withdrawn_at, last_import_id - FROM products - ORDER BY name`) - if err != nil { - return nil, fmt.Errorf("lecture des produits impossible : %w", err) - } - defer rows.Close() - - var out []ProductRow - for rows.Next() { - var ( - r ProductRow - reference string - mode string - qual string - imageSHA sql.NullString - seenAt string - withdrawn sql.NullString - ) - if err := rows.Scan(&r.Product.ID, &r.Product.Name, &reference, &mode, &r.Product.PriceSuffix, - &r.Product.UnitPrice, &r.Product.CategoryCode, &qual, &r.Product.Reason, - &r.Product.CSVLine, &imageSHA, &seenAt, &withdrawn, &r.LastImportID); err != nil { - return nil, err - } - if err := fillProduct(&r.Product, reference, mode, qual, imageSHA); err != nil { - return nil, err - } - if r.SeenAt, err = parseTime(seenAt); err != nil { - return nil, err - } - if r.WithdrawnAt, err = timeFromNull(withdrawn); err != nil { - return nil, err - } - out = append(out, r) - } - return out, rows.Err() -} - -// Product returns one product by its Odoo id, withdrawn or not. -// -// Returns ErrNotFound when the id is unknown. It never returns "absent" for a -// withdrawn product: the row survives its disappearance from the CSV, and a journal -// entry that names it must still be readable (§10.9). -func (d *DB) Product(ctx context.Context, id string) (ProductRow, error) { - var ( - r ProductRow - reference string - mode string - qual string - imageSHA sql.NullString - seenAt string - withdrawn sql.NullString - ) - err := d.reader.QueryRowContext(ctx, ` - SELECT id, name, reference, mode, price_suffix, unit_price_cents, category_code, - qualification, reason, csv_line, image_sha256, seen_at, withdrawn_at, last_import_id - FROM products WHERE id = ?`, id). - Scan(&r.Product.ID, &r.Product.Name, &reference, &mode, &r.Product.PriceSuffix, - &r.Product.UnitPrice, &r.Product.CategoryCode, &qual, &r.Product.Reason, - &r.Product.CSVLine, &imageSHA, &seenAt, &withdrawn, &r.LastImportID) - if err != nil { - return ProductRow{}, notFound(err) - } - if err := fillProduct(&r.Product, reference, mode, qual, imageSHA); err != nil { - return ProductRow{}, err - } - if r.SeenAt, err = parseTime(seenAt); err != nil { - return ProductRow{}, err - } - if r.WithdrawnAt, err = timeFromNull(withdrawn); err != nil { - return ProductRow{}, err - } - return r, nil -} - -// Image returns the metadata of one photo, addressed by its content. -// -// It is what GET /images/{sha}.{ext} consults before serving a file: the extension and -// the Content-Type derive from the stored FORMAT, never from the requested extension, -// so a request for .jpg on a row that says png is a 404 and not a mislabelled body -// (§10.7). Returns ErrNotFound for an unknown sha. -func (d *DB) Image(ctx context.Context, sha string) (domain.Image, error) { - var ( - img domain.Image - seenAt string - ) - err := d.reader.QueryRowContext(ctx, - `SELECT sha256, byte_count, format, width, height, seen_at FROM images WHERE sha256 = ?`, sha). - Scan(&img.SHA256, &img.ByteCount, &img.Format, &img.Width, &img.Height, &seenAt) - if err != nil { - return domain.Image{}, notFound(err) - } - if img.SeenAt, err = parseTime(seenAt); err != nil { - return domain.Image{}, err - } - return img, nil -} - -// categories reads the shelves of the grid, in display order. -func (d *DB) categories(ctx context.Context) ([]domain.Category, error) { - rows, err := d.reader.QueryContext(ctx, - `SELECT code, label, rank, color, visible FROM categories ORDER BY rank, code`) - if err != nil { - return nil, fmt.Errorf("lecture des catégories impossible : %w", err) - } - defer rows.Close() - - var out []domain.Category - for rows.Next() { - var ( - c domain.Category - visible int - ) - if err := rows.Scan(&c.Code, &c.Label, &c.Rank, &c.Color, &visible); err != nil { - return nil, err - } - c.Visible = visible == 1 - out = append(out, c) - } - return out, rows.Err() -} - -// scanProduct reads the eleven catalog columns of a grid query. -func scanProduct(rows *sql.Rows) (domain.Product, error) { - var ( - p domain.Product - reference string - mode string - qual string - imageSHA sql.NullString - ) - if err := rows.Scan(&p.ID, &p.Name, &reference, &mode, &p.PriceSuffix, &p.UnitPrice, - &p.CategoryCode, &qual, &p.Reason, &p.CSVLine, &imageSHA); err != nil { - return domain.Product{}, err - } - err := fillProduct(&p, reference, mode, qual, imageSHA) - return p, err -} - -// fillProduct converts the four columns that are not plain scalars. -func fillProduct(p *domain.Product, reference, mode, qual string, imageSHA sql.NullString) error { - p.Reference = domain.EAN13(reference) - m, err := parseSaleMode(mode) - if err != nil { - return fmt.Errorf("produit %s : %w", p.ID, err) - } - p.Mode = m - q, err := parseQualification(qual) - if err != nil { - return fmt.Errorf("produit %s : %w", p.ID, err) - } - p.Qualification = q - if imageSHA.Valid { - p.ImageSHA = imageSHA.String - } - return nil -} diff --git a/internal/store/catalog_test.go b/internal/store/catalog_test.go index e9a46ad..1e9a550 100644 --- a/internal/store/catalog_test.go +++ b/internal/store/catalog_test.go @@ -8,6 +8,14 @@ import ( "openscale/internal/domain" ) +// The ONE transaction of §10.9: a whole catalog replaces the one in the base, or none +// of it does. What is asserted here is that the import row and the products land +// together, that a product keeps its identity across an upsert, and that a product +// which left the file is WITHDRAWN and not deleted — it left the file, it did not leave +// the history. +// +// What reads a catalog back out is in products_test.go. + // shippedCategories are the four of §11.2, in the order the grid shows them. func shippedCategories() []domain.Category { return []domain.Category{ @@ -368,142 +376,3 @@ func TestReplaceCatalogRefusesANonAppliedResult(t *testing.T) { t.Fatal("ReplaceCatalog a accepté un import 'unchanged' ; c'est le rôle de RecordImport") } } - -// TestLoadCatalogAppliesTheGridPredicate: the predicate of §12.3 lives in SQL, once, -// so that no consumer can forget a clause it never sees. -func TestLoadCatalogAppliesTheGridPredicate(t *testing.T) { - ctx := context.Background() - clk := newClock(TestEpoch) - db, _ := openAt(t, clk) - - prepackaged := product("40", "CONFITURE 250g", "3760123456789", 350) - prepackaged.Qualification = domain.NotWeighable - prepackaged.Reason = domain.FindingPrepackagedProduct - - if _, err := db.ReplaceCatalog(ctx, batch("flv_1.csv", "sha-1", TestEpoch, - product("20", "AIL", "0493021000003", 532), - product("32", "AMANDES", "0493117000009", 1605), - prepackaged, - )); err != nil { - t.Fatalf("ReplaceCatalog: %v", err) - } - - // A prepackaged product is a row of the catalog; it simply gets no tile. - catalog := mustLoadCatalog(t, db) - if catalog.Len() != 3 { - t.Fatalf("%d ligne(s) au catalogue, want 3", catalog.Len()) - } - if catalog.WeighableCount() != 2 { - t.Fatalf("%d tuile(s), want 2", catalog.WeighableCount()) - } - - // A human refusal removes the product from the snapshot entirely: the front must not - // be able to show a tile for something the shop decided to stop offering. - if err := db.SaveDecision(ctx, domain.LocalDecision{ - ProductID: "32", Offered: false, Reason: "code appartenant à un autre article", - DecidedAt: clk.Now(), DecidedBy: "bénévole", - }); err != nil { - t.Fatalf("SaveDecision: %v", err) - } - catalog = mustLoadCatalog(t, db) - if _, ok := catalog.ByID("32"); ok { - t.Fatal("un produit non proposé reste dans la grille") - } - if catalog.WeighableCount() != 1 { - t.Fatalf("%d tuile(s) après la décision locale, want 1", catalog.WeighableCount()) - } -} - -func TestLoadCatalogReturnsTheConfiguredCategoriesInOrder(t *testing.T) { - db := OpenTest(t) - seedCatalog(t, db, product("20", "AIL", "0493021000003", 532)) - - categories := mustLoadCatalog(t, db).Categories() - if len(categories) != 4 { - t.Fatalf("%d catégorie(s), want 4", len(categories)) - } - want := []string{"fruits", "vegetables", "bulk", "other"} - for i, code := range want { - if categories[i].Code != code { - t.Fatalf("catégorie %d = %q, want %q", i, categories[i].Code, code) - } - } -} - -// TestImagesAreAddressedByContent: re-importing the same catalog recomputes the -// fingerprints and writes no new row -- which is what makes an import idempotent (§10.7). -func TestImagesAreAddressedByContent(t *testing.T) { - ctx := context.Background() - clk := newClock(TestEpoch) - db, _ := openAt(t, clk) - - // 10 of the 181 images of flv.csv are PNGs the legacy application named .jpg. The - // stored format is the REAL one. - img := domain.Image{SHA256: "abc123", Format: domain.ImagePNG, ByteCount: 1400, Width: 120, Height: 120, SeenAt: TestEpoch} - withImage := product("20", "AIL", "0493021000003", 532) - withImage.ImageSHA = img.SHA256 - - b := batch("flv_1.csv", "sha-1", TestEpoch, withImage) - b.Images = []domain.Image{img} - b.Import.ImagesDecoded = 1 - if _, err := db.ReplaceCatalog(ctx, b); err != nil { - t.Fatalf("ReplaceCatalog: %v", err) - } - - got, err := db.Image(ctx, "abc123") - if err != nil { - t.Fatalf("Image: %v", err) - } - if got.Format != domain.ImagePNG { - t.Fatalf("format = %q, want png : l'extension ne fait pas foi", got.Format) - } - - clk.Advance(24 * time.Hour) - img.SeenAt = clk.Now() - b2 := batch("flv_1.csv", "sha-2", clk.Now(), withImage) - b2.Images = []domain.Image{img} - if _, err := db.ReplaceCatalog(ctx, b2); err != nil { - t.Fatalf("second ReplaceCatalog: %v", err) - } - var n int - if err := db.reader.QueryRow(`SELECT COUNT(*) FROM images`).Scan(&n); err != nil { - t.Fatalf("COUNT images: %v", err) - } - if n != 1 { - t.Fatalf("%d image(s) en base, want 1 : l'adressage par sha n'est pas idempotent", n) - } - if again, _ := db.Image(ctx, "abc123"); !again.SeenAt.Equal(clk.Now()) { - t.Errorf("seen_at = %s, want %s", again.SeenAt, clk.Now()) - } -} - -func TestImageAndProductReportNotFound(t *testing.T) { - ctx := context.Background() - db := OpenTest(t) - - if _, err := db.Image(ctx, "inconnu"); err != ErrNotFound { - t.Fatalf("Image(inconnu) = %v, want ErrNotFound", err) - } - if _, err := db.Product(ctx, "inconnu"); err != ErrNotFound { - t.Fatalf("Product(inconnu) = %v, want ErrNotFound", err) - } -} - -// TestCatalogWithoutImagesIsANormalCase: flv_1.csv is exactly that, and it must not -// raise anything. -func TestCatalogWithoutImagesIsANormalCase(t *testing.T) { - db := OpenTest(t) - out := seedCatalog(t, db, - product("20", "LENTILLES", "0493171000007", 789), - product("32", "AMANDES", "0493117000009", 1605), - ) - if out.Inserted != 2 { - t.Fatalf("outcome = %+v", out) - } - catalog := mustLoadCatalog(t, db) - for _, p := range catalog.Products() { - if p.ImageSHA != "" { - t.Errorf("produit %s : ImageSHA = %q, want vide", p.ID, p.ImageSHA) - } - } -} diff --git a/internal/store/products.go b/internal/store/products.go new file mode 100644 index 0000000..26a2a7a --- /dev/null +++ b/internal/store/products.go @@ -0,0 +1,238 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "openscale/internal/domain" +) + +// This file reads a catalog back out: the snapshot a station starts on, the rows the +// administration screens page through, and the photos addressed by their content. +// +// A withdrawn product is still HERE — it left the file, it did not leave the history — +// and it is the grid predicate, not a deletion, that keeps it off the customer screen. + +// LoadCatalog builds the immutable snapshot the Hub publishes. +// +// The grid predicate of §12.3 is evaluated HERE, in SQL, once: present in the catalog +// (withdrawn_at IS NULL) and not refused by a human (local_decisions.offered). There +// is no products.visible column to forget to filter on, and no consumer can forget a +// clause it never sees. Products that are not weighable are still returned -- they are +// part of the catalog, they simply get no tile, and WeighableCount is what the +// dashboard shows. +// +// Ordering is alphabetical by name, as §12.3 requires; the presentation order of the +// grid remains the front's business. +func (d *DB) LoadCatalog(ctx context.Context) (*domain.Catalog, error) { + categories, err := d.categories(ctx) + if err != nil { + return nil, err + } + rows, err := d.reader.QueryContext(ctx, ` + SELECT p.id, p.name, p.reference, p.mode, p.price_suffix, p.unit_price_cents, + p.category_code, p.qualification, p.reason, p.csv_line, p.image_sha256 + FROM products p + LEFT JOIN local_decisions d ON d.product_id = p.id + WHERE p.withdrawn_at IS NULL + AND COALESCE(d.offered, 1) = 1 + ORDER BY p.name`) + if err != nil { + return nil, fmt.Errorf("lecture du catalogue impossible : %w", err) + } + defer rows.Close() + + var products []domain.Product + for rows.Next() { + p, err := scanProduct(rows) + if err != nil { + return nil, err + } + products = append(products, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return domain.NewCatalog(products, categories), nil +} + +// ProductRow is a product plus the three storage facts the domain type does not carry. +// +// The domain has no place for them and must not grow one: "when was this row last +// seen" and "which import saw it" are observations about a row, not properties of a +// product (§10.9). Only the admin screens read them. +type ProductRow struct { + Product domain.Product + SeenAt time.Time + // WithdrawnAt is the zero instant while the product is in the catalog. + WithdrawnAt time.Time + LastImportID int64 +} + +// AllProducts returns every row, withdrawn ones included, for the admin catalog screen. +// +// One grid with filters derived from the data, not four screens (ADR-024): the caller +// gets everything and narrows it itself, which is also what lets it say "4 products +// withdrawn since the import of 12/03" without a second query. +func (d *DB) AllProducts(ctx context.Context) ([]ProductRow, error) { + rows, err := d.reader.QueryContext(ctx, ` + SELECT id, name, reference, mode, price_suffix, unit_price_cents, category_code, + qualification, reason, csv_line, image_sha256, seen_at, withdrawn_at, last_import_id + FROM products + ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("lecture des produits impossible : %w", err) + } + defer rows.Close() + + var out []ProductRow + for rows.Next() { + var ( + r ProductRow + reference string + mode string + qual string + imageSHA sql.NullString + seenAt string + withdrawn sql.NullString + ) + if err := rows.Scan(&r.Product.ID, &r.Product.Name, &reference, &mode, &r.Product.PriceSuffix, + &r.Product.UnitPrice, &r.Product.CategoryCode, &qual, &r.Product.Reason, + &r.Product.CSVLine, &imageSHA, &seenAt, &withdrawn, &r.LastImportID); err != nil { + return nil, err + } + if err := fillProduct(&r.Product, reference, mode, qual, imageSHA); err != nil { + return nil, err + } + if r.SeenAt, err = parseTime(seenAt); err != nil { + return nil, err + } + if r.WithdrawnAt, err = timeFromNull(withdrawn); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// Product returns one product by its Odoo id, withdrawn or not. +// +// Returns ErrNotFound when the id is unknown. It never returns "absent" for a +// withdrawn product: the row survives its disappearance from the CSV, and a journal +// entry that names it must still be readable (§10.9). +func (d *DB) Product(ctx context.Context, id string) (ProductRow, error) { + var ( + r ProductRow + reference string + mode string + qual string + imageSHA sql.NullString + seenAt string + withdrawn sql.NullString + ) + err := d.reader.QueryRowContext(ctx, ` + SELECT id, name, reference, mode, price_suffix, unit_price_cents, category_code, + qualification, reason, csv_line, image_sha256, seen_at, withdrawn_at, last_import_id + FROM products WHERE id = ?`, id). + Scan(&r.Product.ID, &r.Product.Name, &reference, &mode, &r.Product.PriceSuffix, + &r.Product.UnitPrice, &r.Product.CategoryCode, &qual, &r.Product.Reason, + &r.Product.CSVLine, &imageSHA, &seenAt, &withdrawn, &r.LastImportID) + if err != nil { + return ProductRow{}, notFound(err) + } + if err := fillProduct(&r.Product, reference, mode, qual, imageSHA); err != nil { + return ProductRow{}, err + } + if r.SeenAt, err = parseTime(seenAt); err != nil { + return ProductRow{}, err + } + if r.WithdrawnAt, err = timeFromNull(withdrawn); err != nil { + return ProductRow{}, err + } + return r, nil +} + +// Image returns the metadata of one photo, addressed by its content. +// +// It is what GET /images/{sha}.{ext} consults before serving a file: the extension and +// the Content-Type derive from the stored FORMAT, never from the requested extension, +// so a request for .jpg on a row that says png is a 404 and not a mislabelled body +// (§10.7). Returns ErrNotFound for an unknown sha. +func (d *DB) Image(ctx context.Context, sha string) (domain.Image, error) { + var ( + img domain.Image + seenAt string + ) + err := d.reader.QueryRowContext(ctx, + `SELECT sha256, byte_count, format, width, height, seen_at FROM images WHERE sha256 = ?`, sha). + Scan(&img.SHA256, &img.ByteCount, &img.Format, &img.Width, &img.Height, &seenAt) + if err != nil { + return domain.Image{}, notFound(err) + } + if img.SeenAt, err = parseTime(seenAt); err != nil { + return domain.Image{}, err + } + return img, nil +} + +// categories reads the shelves of the grid, in display order. +func (d *DB) categories(ctx context.Context) ([]domain.Category, error) { + rows, err := d.reader.QueryContext(ctx, + `SELECT code, label, rank, color, visible FROM categories ORDER BY rank, code`) + if err != nil { + return nil, fmt.Errorf("lecture des catégories impossible : %w", err) + } + defer rows.Close() + + var out []domain.Category + for rows.Next() { + var ( + c domain.Category + visible int + ) + if err := rows.Scan(&c.Code, &c.Label, &c.Rank, &c.Color, &visible); err != nil { + return nil, err + } + c.Visible = visible == 1 + out = append(out, c) + } + return out, rows.Err() +} + +// scanProduct reads the eleven catalog columns of a grid query. +func scanProduct(rows *sql.Rows) (domain.Product, error) { + var ( + p domain.Product + reference string + mode string + qual string + imageSHA sql.NullString + ) + if err := rows.Scan(&p.ID, &p.Name, &reference, &mode, &p.PriceSuffix, &p.UnitPrice, + &p.CategoryCode, &qual, &p.Reason, &p.CSVLine, &imageSHA); err != nil { + return domain.Product{}, err + } + err := fillProduct(&p, reference, mode, qual, imageSHA) + return p, err +} + +// fillProduct converts the four columns that are not plain scalars. +func fillProduct(p *domain.Product, reference, mode, qual string, imageSHA sql.NullString) error { + p.Reference = domain.EAN13(reference) + m, err := parseSaleMode(mode) + if err != nil { + return fmt.Errorf("produit %s : %w", p.ID, err) + } + p.Mode = m + q, err := parseQualification(qual) + if err != nil { + return fmt.Errorf("produit %s : %w", p.ID, err) + } + p.Qualification = q + if imageSHA.Valid { + p.ImageSHA = imageSHA.String + } + return nil +} diff --git a/internal/store/products_test.go b/internal/store/products_test.go new file mode 100644 index 0000000..b1a88eb --- /dev/null +++ b/internal/store/products_test.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + "testing" + "time" + + "openscale/internal/domain" +) + +// Reading a catalog back out: the snapshot a station starts on, which applies the grid +// predicate rather than deleting anything, the categories in the order the +// configuration declares them, and the photos addressed by their content — the same +// image stored once, whatever the number of products carrying it. + +// TestLoadCatalogAppliesTheGridPredicate: the predicate of §12.3 lives in SQL, once, +// so that no consumer can forget a clause it never sees. +func TestLoadCatalogAppliesTheGridPredicate(t *testing.T) { + ctx := context.Background() + clk := newClock(TestEpoch) + db, _ := openAt(t, clk) + + prepackaged := product("40", "CONFITURE 250g", "3760123456789", 350) + prepackaged.Qualification = domain.NotWeighable + prepackaged.Reason = domain.FindingPrepackagedProduct + + if _, err := db.ReplaceCatalog(ctx, batch("flv_1.csv", "sha-1", TestEpoch, + product("20", "AIL", "0493021000003", 532), + product("32", "AMANDES", "0493117000009", 1605), + prepackaged, + )); err != nil { + t.Fatalf("ReplaceCatalog: %v", err) + } + + // A prepackaged product is a row of the catalog; it simply gets no tile. + catalog := mustLoadCatalog(t, db) + if catalog.Len() != 3 { + t.Fatalf("%d ligne(s) au catalogue, want 3", catalog.Len()) + } + if catalog.WeighableCount() != 2 { + t.Fatalf("%d tuile(s), want 2", catalog.WeighableCount()) + } + + // A human refusal removes the product from the snapshot entirely: the front must not + // be able to show a tile for something the shop decided to stop offering. + if err := db.SaveDecision(ctx, domain.LocalDecision{ + ProductID: "32", Offered: false, Reason: "code appartenant à un autre article", + DecidedAt: clk.Now(), DecidedBy: "bénévole", + }); err != nil { + t.Fatalf("SaveDecision: %v", err) + } + catalog = mustLoadCatalog(t, db) + if _, ok := catalog.ByID("32"); ok { + t.Fatal("un produit non proposé reste dans la grille") + } + if catalog.WeighableCount() != 1 { + t.Fatalf("%d tuile(s) après la décision locale, want 1", catalog.WeighableCount()) + } +} + +func TestLoadCatalogReturnsTheConfiguredCategoriesInOrder(t *testing.T) { + db := OpenTest(t) + seedCatalog(t, db, product("20", "AIL", "0493021000003", 532)) + + categories := mustLoadCatalog(t, db).Categories() + if len(categories) != 4 { + t.Fatalf("%d catégorie(s), want 4", len(categories)) + } + want := []string{"fruits", "vegetables", "bulk", "other"} + for i, code := range want { + if categories[i].Code != code { + t.Fatalf("catégorie %d = %q, want %q", i, categories[i].Code, code) + } + } +} + +// TestImagesAreAddressedByContent: re-importing the same catalog recomputes the +// fingerprints and writes no new row -- which is what makes an import idempotent (§10.7). +func TestImagesAreAddressedByContent(t *testing.T) { + ctx := context.Background() + clk := newClock(TestEpoch) + db, _ := openAt(t, clk) + + // 10 of the 181 images of flv.csv are PNGs the legacy application named .jpg. The + // stored format is the REAL one. + img := domain.Image{SHA256: "abc123", Format: domain.ImagePNG, ByteCount: 1400, Width: 120, Height: 120, SeenAt: TestEpoch} + withImage := product("20", "AIL", "0493021000003", 532) + withImage.ImageSHA = img.SHA256 + + b := batch("flv_1.csv", "sha-1", TestEpoch, withImage) + b.Images = []domain.Image{img} + b.Import.ImagesDecoded = 1 + if _, err := db.ReplaceCatalog(ctx, b); err != nil { + t.Fatalf("ReplaceCatalog: %v", err) + } + + got, err := db.Image(ctx, "abc123") + if err != nil { + t.Fatalf("Image: %v", err) + } + if got.Format != domain.ImagePNG { + t.Fatalf("format = %q, want png : l'extension ne fait pas foi", got.Format) + } + + clk.Advance(24 * time.Hour) + img.SeenAt = clk.Now() + b2 := batch("flv_1.csv", "sha-2", clk.Now(), withImage) + b2.Images = []domain.Image{img} + if _, err := db.ReplaceCatalog(ctx, b2); err != nil { + t.Fatalf("second ReplaceCatalog: %v", err) + } + var n int + if err := db.reader.QueryRow(`SELECT COUNT(*) FROM images`).Scan(&n); err != nil { + t.Fatalf("COUNT images: %v", err) + } + if n != 1 { + t.Fatalf("%d image(s) en base, want 1 : l'adressage par sha n'est pas idempotent", n) + } + if again, _ := db.Image(ctx, "abc123"); !again.SeenAt.Equal(clk.Now()) { + t.Errorf("seen_at = %s, want %s", again.SeenAt, clk.Now()) + } +} + +func TestImageAndProductReportNotFound(t *testing.T) { + ctx := context.Background() + db := OpenTest(t) + + if _, err := db.Image(ctx, "inconnu"); err != ErrNotFound { + t.Fatalf("Image(inconnu) = %v, want ErrNotFound", err) + } + if _, err := db.Product(ctx, "inconnu"); err != ErrNotFound { + t.Fatalf("Product(inconnu) = %v, want ErrNotFound", err) + } +} + +// TestCatalogWithoutImagesIsANormalCase: flv_1.csv is exactly that, and it must not +// raise anything. +func TestCatalogWithoutImagesIsANormalCase(t *testing.T) { + db := OpenTest(t) + out := seedCatalog(t, db, + product("20", "LENTILLES", "0493171000007", 789), + product("32", "AMANDES", "0493117000009", 1605), + ) + if out.Inserted != 2 { + t.Fatalf("outcome = %+v", out) + } + catalog := mustLoadCatalog(t, db) + for _, p := range catalog.Products() { + if p.ImageSHA != "" { + t.Errorf("produit %s : ImageSHA = %q, want vide", p.ID, p.ImageSHA) + } + } +} diff --git a/internal/web/admin.go b/internal/web/admin.go deleted file mode 100644 index 4b687ce..0000000 --- a/internal/web/admin.go +++ /dev/null @@ -1,746 +0,0 @@ -package web - -import ( - "encoding/csv" - "net/http" - "strconv" - "time" - - "openscale/internal/domain" - "openscale/internal/station/ports" -) - -// This file serves the six expert pages of §14.4. Everything in it is READ except -// three routes, and those three are the ones a password protects. - -// JournalQuery narrows one page of the weighing journal. -// -// It is declared HERE and not imported from the store: internal/web knows no database -// package (§5.2), so cmd/openscale translates this into whatever the store speaks. -type JournalQuery struct { - Since time.Time - Until time.Time - Result string - Limit int - Offset int -} - -// TechnicalQuery narrows one page of the technical journal. -type TechnicalQuery struct { - Since time.Time - Until time.Time - // Level keeps one level only. It is NOT a threshold: the screen filters by what a - // line IS, and « everything at least as bad as a warning » is a question nobody - // asked at the counter. - Level string - Source string - Code string - Limit int - Offset int -} - -// TechnicalLine is one line of the technical journal as the screen reads it. -type TechnicalLine struct { - ID int64 - OccurredAt time.Time - Level string - Source string - Code string - Message string - Detail string -} - -// PortInfo is one serial port the platform enumerated, with the USB description that -// makes it recognisable — « COM8 » names nothing, « COM8 — FTDI FT232R » names a -// cable somebody can see (§14.4). -type PortInfo struct { - Name string - Description string - VID string - PID string -} - -// PrinterInfo is one print queue or one device the platform knows about. -type PrinterInfo struct { - Name string - // Key is the printer.options key this destination goes into: "queue", "path" or - // "address" (domain.DeviceKey*). The enumeration that found it is the only layer that - // knows, and the screen has no way of telling the three apart by looking at the name. - Key string - Detail string - Default bool -} - -// ScaleDetection is what one port answered when the parsers were applied to it. -// -// It is the detection that answers « is there a scale? », not the operator (§14.4). -type ScaleDetection struct { - Port string - // Driver is the registry key of the parser that recognised the frames, empty when - // none did. - Driver string - ValidCount int - // Frames is what was read, decoded, so that a support call can look at them. - Frames []string - Message string -} - -// PreviewQuery is what GET /admin/api/label/preview.png renders. -type PreviewQuery struct { - Template string - // Demo asks for the demonstration values rather than the weighing in flight, - // which is what the settings screen shows while nobody is weighing. - Demo bool - // Dual asks for the two-tier layout, so that an operator sees the crowded case - // without having to configure it first. - Dual bool -} - -// --- The journal ----------------------------------------------------------- - -// weighingDTO is one row of the journal. -type weighingDTO struct { - ID int64 `json:"id"` - OccurredAt string `json:"occurred_at"` - Station int `json:"station"` - JobID string `json:"job_id"` - ProductID string `json:"product_id"` - ProductName string `json:"product_name"` - Reference string `json:"reference"` - Mode string `json:"mode"` - GrossG int64 `json:"gross_g"` - TareG int64 `json:"tare_g"` - NetG int64 `json:"net_g"` - Quantity int `json:"quantity"` - Barcode string `json:"barcode"` - Source string `json:"source"` - Stability string `json:"stability"` - RateMS int `json:"rate_ms"` - // Frame is the RAW serial frame, kept as the living corpus of the replay driver: - // any frame that caused an unexplained refusal becomes a permanent test (§15.4). - Frame string `json:"frame"` - Result string `json:"result"` - Detail string `json:"detail"` - DurationMS int `json:"duration_ms"` - Lines []lineDTO `json:"lines"` -} - -// lineDTO is one price line of one journalled weighing. -type lineDTO struct { - TierCode string `json:"tier_code"` - UnitPriceCents int64 `json:"unit_price_cents"` - AmountCents int64 `json:"amount_cents"` -} - -// journal is GET /admin/api/journal: the 200 last weighings, filtered. -func (s *Server) journal(w http.ResponseWriter, r *http.Request) { - if s.store == nil { - unavailable(w, "ce poste n'a pas de journal") - return - } - rows, err := s.store.Weighings(r.Context(), journalQueryOf(r)) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - out := make([]weighingDTO, 0, len(rows)) - for _, row := range rows { - out = append(out, weighingOf(row)) - } - writeJSON(w, http.StatusOK, struct { - Weighings []weighingDTO `json:"weighings"` - }{out}) -} - -// journalCSV is GET /admin/api/journal/export.csv. -// -// A semicolon and a UTF-8 BOM: this file is opened in the spreadsheet of a French -// Windows, and a comma-separated file lands in one column there. It is the same -// trade-off the producer's own export makes (§10.2). -func (s *Server) journalCSV(w http.ResponseWriter, r *http.Request) { - if s.store == nil { - unavailable(w, "ce poste n'a pas de journal") - return - } - rows, err := s.store.Weighings(r.Context(), journalQueryOf(r)) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - - w.Header().Set("Content-Type", "text/csv; charset=utf-8") - w.Header().Set("Content-Disposition", `attachment; filename="journal.csv"`) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) - - out := csv.NewWriter(w) - out.Comma = ';' - defer out.Flush() - _ = out.Write([]string{"occurred_at", "station", "job_id", "product_id", "product_name", - "reference", "mode", "gross_g", "tare_g", "net_g", "quantity", "barcode", - "source", "stability", "result", "detail", "duration_ms"}) - for _, row := range rows { - _ = out.Write([]string{ - stamp(row.OccurredAt), strconv.Itoa(row.Station), row.JobID, - row.ProductID, row.ProductName, string(row.Reference), row.Mode.String(), - strconv.FormatInt(int64(row.GrossWeight), 10), - strconv.FormatInt(int64(row.Tare), 10), - strconv.FormatInt(int64(row.NetWeight), 10), - strconv.Itoa(row.Quantity), string(row.Barcode), - row.Source, row.Stability.String(), row.Result, row.Detail, - strconv.Itoa(row.DurationMS), - }) - } -} - -// journalQueryOf reads the filters off the query string. -func journalQueryOf(r *http.Request) JournalQuery { - q := JournalQuery{ - Result: r.URL.Query().Get("result"), - Limit: intParam(r, "limit", 200), - Offset: intParam(r, "offset", 0), - } - q.Since = instantParam(r, "since") - q.Until = instantParam(r, "until") - return q -} - -// weighingOf converts one journalled weighing. -func weighingOf(row domain.Weighing) weighingDTO { - out := weighingDTO{ - ID: row.ID, OccurredAt: stamp(row.OccurredAt), Station: row.Station, - JobID: row.JobID, ProductID: row.ProductID, ProductName: row.ProductName, - Reference: string(row.Reference), Mode: row.Mode.String(), - GrossG: int64(row.GrossWeight), TareG: int64(row.Tare), NetG: int64(row.NetWeight), - Quantity: row.Quantity, Barcode: string(row.Barcode), - Source: row.Source, Stability: row.Stability.String(), RateMS: row.RateMS, - Frame: row.Frame, Result: row.Result, Detail: row.Detail, - DurationMS: row.DurationMS, - Lines: make([]lineDTO, 0, len(row.Lines)), - } - for _, line := range row.Lines { - out.Lines = append(out.Lines, lineDTO{ - TierCode: line.TierCode, UnitPriceCents: int64(line.UnitPrice), - AmountCents: int64(line.Amount), - }) - } - return out -} - -// --- The technical journal -------------------------------------------------- - -// technicalLineDTO is one line of the technical journal. -type technicalLineDTO struct { - ID int64 `json:"id"` - OccurredAt string `json:"occurred_at"` - Level string `json:"level"` - Source string `json:"source"` - Code string `json:"code"` - Message string `json:"message"` - Detail string `json:"detail"` -} - -// technicalJournal is GET /admin/api/technical. -func (s *Server) technicalJournal(w http.ResponseWriter, r *http.Request) { - if s.store == nil { - unavailable(w, "ce poste n'a pas de journal technique") - return - } - query := TechnicalQuery{ - Level: r.URL.Query().Get("level"), Source: r.URL.Query().Get("source"), - Code: r.URL.Query().Get("code"), - Limit: intParam(r, "limit", 200), Offset: intParam(r, "offset", 0), - } - query.Since, query.Until = instantParam(r, "since"), instantParam(r, "until") - - lines, err := s.store.TechnicalEntries(r.Context(), query) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - writeJSON(w, http.StatusOK, struct { - Entries []technicalLineDTO `json:"entries"` - }{technicalLinesOf(lines)}) -} - -// technicalLinesOf converts a page of the technical journal. -func technicalLinesOf(lines []TechnicalLine) []technicalLineDTO { - out := make([]technicalLineDTO, 0, len(lines)) - for _, line := range lines { - out = append(out, technicalLineDTO{ - ID: line.ID, OccurredAt: stamp(line.OccurredAt), Level: line.Level, - Source: line.Source, Code: line.Code, Message: line.Message, Detail: line.Detail, - }) - } - return out -} - -// --- Imports ---------------------------------------------------------------- - -// importDTO is the inventory of one import, and it is written the way §14.4 reads it -// out loud: received, weighable, not weighable, anomalies. -// -// Never « 46 produits en erreur ». It is false — a prepackaged boulgour is not an -// error, it is not the scale's business — it alarms without giving anything to do, -// and it drowns the only figure that deserves the eye: the rows somebody can fix. -type importDTO struct { - ID int64 `json:"id"` - OccurredAt string `json:"occurred_at"` - Source string `json:"source"` - FileName string `json:"file_name"` - Result string `json:"result"` - Code string `json:"code"` - Reason string `json:"reason"` - - RowsRead int `json:"rows_read_count"` - UnreadableRows int `json:"unreadable_rows_count"` - Weighable int `json:"weighable_count"` - NotWeighable int `json:"not_weighable_count"` - Anomalies int `json:"anomalies_count"` - UnitMismatches int `json:"unit_mismatches_count"` - ImagesDecoded int `json:"images_decoded_count"` - ImagesRejected int `json:"images_rejected_count"` - ProductsWithdrawn int `json:"products_withdrawn_count"` - DurationMS int `json:"duration_ms"` -} - -// imports is GET /admin/api/imports: the twenty last imports, and the findings of the -// one named by ?id=. -func (s *Server) imports(w http.ResponseWriter, r *http.Request) { - if s.store == nil { - unavailable(w, "ce poste n'a pas d'historique d'imports") - return - } - list, err := s.store.Imports(r.Context(), intParam(r, "limit", 20), intParam(r, "offset", 0)) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - // Both lists are BUILT, including the one this call may never fill: a station with no - // catalog has no import to name, so `?id=` is absent, so the findings are never read — - // and a nil slice would go out as `null` against a contract that declares an array. - // That is what took the Catalogue page down on a station installed this morning. - body := struct { - Imports []importDTO `json:"imports"` - Findings []findingDTO `json:"findings"` - }{Imports: make([]importDTO, 0, len(list)), Findings: []findingDTO{}} - for _, record := range list { - body.Imports = append(body.Imports, importOf(record)) - } - - if id := intParam(r, "id", 0); id > 0 { - findings, err := s.store.Findings(r.Context(), int64(id)) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - body.Findings = findingsOf(findings) - } - writeJSON(w, http.StatusOK, body) -} - -// importOf converts one import record. -func importOf(record domain.Import) importDTO { - return importDTO{ - ID: record.ID, OccurredAt: stamp(record.OccurredAt), - Source: record.Source, FileName: record.FileName, - Result: record.Result, Code: record.Code, Reason: record.Reason, - RowsRead: record.RowsRead, UnreadableRows: record.UnreadableRows, - Weighable: record.Weighable, NotWeighable: record.NotWeighable, - Anomalies: record.Anomalies, UnitMismatches: record.UnitMismatches, - ImagesDecoded: record.ImagesDecoded, ImagesRejected: record.ImagesRejected, - ProductsWithdrawn: record.ProductsWithdrawn, DurationMS: record.DurationMS, - } -} - -// findingDTO is one row an import had something to say about. -// -// CSVLine is what makes the report usable: it names the row to fix IN ODOO, which is -// the only place anybody can fix it. ProductName is what makes it readable: it is the -// name the import itself read, and the screen shows it rather than send whoever corrects -// the file looking up an Odoo id first. -type findingDTO struct { - CSVLine int `json:"csv_line"` - ProductID string `json:"product_id"` - ProductName string `json:"product_name"` - Code string `json:"code"` - Issue string `json:"issue"` - Message string `json:"message"` - Value string `json:"value"` -} - -// findingsOf converts what one import reported. -func findingsOf(findings []domain.Finding) []findingDTO { - out := make([]findingDTO, 0, len(findings)) - for _, f := range findings { - out = append(out, findingDTO{ - CSVLine: f.CSVLine, ProductID: f.ProductID, ProductName: f.ProductName, - Code: f.Code, Issue: f.Issue, Message: f.Message, Value: f.Value, - }) - } - return out -} - -// forgetQuarantine is POST /admin/api/catalog/forget-quarantine. -func (s *Server) forgetQuarantine(w http.ResponseWriter, r *http.Request) { - if s.catalog == nil { - unavailable(w, "aucune source de catalogue n'est configurée") - return - } - if err := s.catalog.ForgetQuarantine(r.Context()); err != nil { - writeProblem(w, http.StatusInternalServerError, "", err.Error()) - return - } - writeJSON(w, http.StatusOK, actionDTO{ - Done: true, Message: "La quarantaine est oubliée : le prochain fichier sera relu."}) -} - -// --- The one table of human decisions --------------------------------------- - -// decisionDTO is one human judgement about one product (§10.6, ADR-017). -type decisionDTO struct { - ProductID string `json:"product_id"` - Offered bool `json:"offered"` - // MinWeightG is the per-product light-product waiver. Null means the general - // limit applies — the absence of a decision is not a refusal. - MinWeightG *int64 `json:"min_weight_g"` - Reason string `json:"reason"` - DecidedBy string `json:"decided_by"` - DecidedAt string `json:"decided_at"` -} - -// decisionRequest is the body of POST /admin/api/products/{id}/decision. -// -// ONE route for the ONE table of human decisions: « ne plus proposer ce produit » and -// « ce produit peut peser moins de 10 g » are two columns of local_decisions, not two -// mechanisms (§14.5). -type decisionRequest struct { - Offered *bool `json:"offered"` - MinWeightG *int64 `json:"min_weight_g"` - Reason string `json:"reason"` - DecidedBy *string `json:"decided_by"` -} - -// productDecision is POST /admin/api/products/{id}/decision. -func (s *Server) productDecision(w http.ResponseWriter, r *http.Request) { - var body decisionRequest - if !decodeJSON(w, r, &body) { - return - } - if s.store == nil { - unavailable(w, "ce poste n'enregistre pas de décision locale") - return - } - id := r.PathValue("id") - if id == "" { - writeProblem(w, http.StatusBadRequest, "", "Aucun produit n'est désigné.") - return - } - - offered := true - if body.Offered != nil { - offered = *body.Offered - } - // « Offered again, and no waiver » is the ABSENCE of a decision, not a row saying - // nothing: leaving one would make the screen list a product nobody decided - // anything about (§10.6). - if offered && body.MinWeightG == nil { - if err := s.store.ClearDecision(r.Context(), id); err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - writeJSON(w, http.StatusOK, actionDTO{ - Done: true, Message: "Ce produit est de nouveau proposé sans dérogation."}) - return - } - if body.Reason == "" { - // The reason is what makes the decision readable in six months, by somebody - // who was not there. A decision without one is a mystery with a date. - writeProblem(w, http.StatusUnprocessableEntity, "", - "Indiquez le motif de cette décision.") - return - } - - decision := domain.LocalDecision{ - ProductID: id, Offered: offered, MinWeightG: gramsOf(body.MinWeightG), - Reason: body.Reason, DecidedAt: s.clock.Now(), DecidedBy: decidedBy(body.DecidedBy), - } - if err := s.store.SaveDecision(r.Context(), decision); err != nil { - writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) - return - } - s.technical.Technical(domain.LevelInfo, "catalog", "", - "Décision locale enregistrée.", id+" : "+body.Reason) - writeJSON(w, http.StatusOK, actionDTO{Done: true, Message: "La décision est enregistrée."}) -} - -// decidedBy names who decided, with the honest default. -// -// « bénévole » and not an empty string: nobody signs in by name on this station, and -// leaving the field empty would suggest a name could have been known. -func decidedBy(who *string) string { - if who == nil || *who == "" { - return "bénévole" - } - return *who -} - -// gramsOf carries the optional waiver across the DTO boundary, absence included. -func gramsOf(value *int64) *domain.Grams { - if value == nil { - return nil - } - grams := domain.Grams(*value) - return &grams -} - -// gramsValue is the reverse: a waiver as the screen reads it, or null. -func gramsValue(value *domain.Grams) *int64 { - if value == nil { - return nil - } - plain := int64(*value) - return &plain -} - -// decisionsOf converts the human judgements in force. -func decisionsOf(decisions []domain.LocalDecision) []decisionDTO { - out := make([]decisionDTO, 0, len(decisions)) - for _, d := range decisions { - out = append(out, decisionDTO{ - ProductID: d.ProductID, Offered: d.Offered, MinWeightG: gramsValue(d.MinWeightG), - Reason: d.Reason, DecidedBy: d.DecidedBy, DecidedAt: stamp(d.DecidedAt), - }) - } - return out -} - -// --- What is plugged in ------------------------------------------------------ - -// listPorts is GET /admin/api/ports. -func (s *Server) listPorts(w http.ResponseWriter, r *http.Request) { - if s.hardware == nil { - unavailable(w, "l'énumération des ports n'est pas câblée") - return - } - ports, err := s.hardware.Ports(r.Context()) - if err != nil { - writeProblem(w, http.StatusBadGateway, "", err.Error()) - return - } - body := struct { - Ports []portDTO `json:"ports"` - }{make([]portDTO, 0, len(ports))} - for _, p := range ports { - body.Ports = append(body.Ports, portDTO{ - Name: p.Name, Description: p.Description, VID: p.VID, PID: p.PID, - }) - } - writeJSON(w, http.StatusOK, body) -} - -// portDTO is one serial port. -type portDTO struct { - Name string `json:"name"` - Description string `json:"description"` - VID string `json:"vid"` - PID string `json:"pid"` -} - -// printerDeviceDTO is one print destination this station can reach. -type printerDeviceDTO struct { - Name string `json:"name"` - // Key is the printer.options key this destination goes INTO, as the enumeration that - // found it declared: "queue", "path" or "address". - // - // The screen writes what a volunteer clicks into THAT key and no other. It wrote every - // one of them into `queue`, and the two routes served by this handler do not answer the - // same kind of thing: one lists the queues of the spooler, the other the hosts that - // replied on port 9100. - Key string `json:"key"` - Detail string `json:"detail"` - Default bool `json:"default"` -} - -// listPrinters is GET /admin/api/printers. -func (s *Server) listPrinters(w http.ResponseWriter, r *http.Request) { - s.answerPrinters(w, r, false) -} - -// discoverPrinters is POST /admin/api/printers/discover: the deeper search, which may -// take seconds and is therefore a POST and not a GET. -func (s *Server) discoverPrinters(w http.ResponseWriter, r *http.Request) { - s.answerPrinters(w, r, true) -} - -// answerPrinters serves both printer routes. -func (s *Server) answerPrinters(w http.ResponseWriter, r *http.Request, discover bool) { - if s.hardware == nil { - unavailable(w, "l'énumération des imprimantes n'est pas câblée") - return - } - // Enumerating a Windows spooler can take seconds, and discovering can take more. - // A handler never waits on the platform without a deadline. - ctx, cancel := ports.WithBudget(r.Context(), s.clock, deviceBudget) - defer cancel() - - list, err := s.hardware.Printers(ctx) - if discover { - list, err = s.hardware.DiscoverPrinters(ctx) - } - if err != nil { - writeProblem(w, http.StatusBadGateway, "", err.Error()) - return - } - body := struct { - Printers []printerDeviceDTO `json:"printers"` - }{make([]printerDeviceDTO, 0, len(list))} - for _, p := range list { - body.Printers = append(body.Printers, printerDeviceDTO{ - Name: p.Name, Key: p.Key, Detail: p.Detail, Default: p.Default, - }) - } - writeJSON(w, http.StatusOK, body) -} - -// detectRequest is the body of POST /admin/api/scale/detect and /scale/capture. -type detectRequest struct { - Port string `json:"port"` - // Seconds is how long to listen, for a capture. Zero means the default of three - // seconds, which is what the detection of §14.4 spends on each port. - Seconds int `json:"seconds"` -} - -// detectScale is POST /admin/api/scale/detect: it opens the port, applies the parsers -// and says what answered — « COM8 : 12 trames valides, GRAM XFOC ». -func (s *Server) detectScale(w http.ResponseWriter, r *http.Request) { - var body detectRequest - if !decodeJSON(w, r, &body) { - return - } - if s.hardware == nil { - unavailable(w, "la détection de balance n'est pas câblée") - return - } - report, err := s.hardware.DetectScale(r.Context(), body.Port) - if err != nil { - writeProblem(w, http.StatusBadGateway, "ERR-SCL-03", err.Error()) - return - } - writeJSON(w, http.StatusOK, struct { - Port string `json:"port"` - Driver string `json:"driver"` - ValidCount int `json:"valid_frames_count"` - Frames []string `json:"frames"` - Message string `json:"message"` - }{report.Port, report.Driver, report.ValidCount, report.Frames, report.Message}) -} - -// captureScale is POST /admin/api/scale/capture: the raw frames, for a support call. -func (s *Server) captureScale(w http.ResponseWriter, r *http.Request) { - var body detectRequest - if !decodeJSON(w, r, &body) { - return - } - if s.hardware == nil { - unavailable(w, "la capture de trames n'est pas câblée") - return - } - seconds := body.Seconds - if seconds <= 0 || seconds > 60 { - seconds = 3 - } - frames, err := s.hardware.CaptureFrames(r.Context(), body.Port, time.Duration(seconds)*time.Second) - if err != nil { - writeProblem(w, http.StatusBadGateway, "ERR-SCL-03", err.Error()) - return - } - writeJSON(w, http.StatusOK, struct { - Frames []string `json:"frames"` - }{frames}) -} - -// labelPreview is GET /admin/api/label/preview.png: the same rendering that would be -// printed, which is what A2 buys (one renderer, not two). -func (s *Server) labelPreview(w http.ResponseWriter, r *http.Request) { - if s.hardware == nil { - unavailable(w, "l'aperçu d'étiquette n'est pas câblé") - return - } - png, err := s.hardware.LabelPreview(r.Context(), PreviewQuery{ - Template: r.URL.Query().Get("template"), - Demo: r.URL.Query().Get("demo") == "1", - Dual: r.URL.Query().Get("dual") == "1", - }) - if err != nil { - writeProblem(w, http.StatusUnprocessableEntity, "", err.Error()) - return - } - w.Header().Set("Content-Type", "image/png") - // The preview is refreshed at every keystroke on the settings screen: a cached - // one would show the previous offset. - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(png) -} - -// replayRequest is the body of POST /admin/api/replay. -type replayRequest struct { - // Frame is the raw frame, exactly as the journal recorded it. - Frame string `json:"frame"` -} - -// replay is POST /admin/api/replay: « Rejouer cette trame » (§14.4, Journal). -// -// It is what turns a frame that caused an unexplained refusal into a permanent test, -// without a trip to the shop and without a scale. -func (s *Server) replay(w http.ResponseWriter, r *http.Request) { - var body replayRequest - if !decodeJSON(w, r, &body) { - return - } - if s.hardware == nil { - unavailable(w, "le rejeu de trame n'est pas câblé") - return - } - if body.Frame == "" { - writeProblem(w, http.StatusBadRequest, "", "Aucune trame n'est fournie.") - return - } - if err := s.hardware.Replay(r.Context(), body.Frame); err != nil { - writeProblem(w, http.StatusUnprocessableEntity, "", err.Error()) - return - } - writeJSON(w, http.StatusAccepted, actionDTO{ - Done: true, Message: "La trame a été rejouée."}) -} - -// --- Query string helpers ---------------------------------------------------- - -// intParam reads one integer off the query string, with a fallback. -func intParam(r *http.Request, name string, fallback int) int { - raw := r.URL.Query().Get(name) - if raw == "" { - return fallback - } - value, err := strconv.Atoi(raw) - if err != nil || value < 0 { - return fallback - } - return value -} - -// instantParam reads one RFC 3339 instant off the query string. An unreadable one is -// the ZERO instant, which every filter reads as « no bound »: a screen that mistypes a -// date must get the whole page, never an empty one it would read as « no weighings ». -func instantParam(r *http.Request, name string) time.Time { - raw := r.URL.Query().Get(name) - if raw == "" { - return time.Time{} - } - instant, err := time.Parse(time.RFC3339, raw) - if err != nil { - return time.Time{} - } - return instant -} diff --git a/internal/web/admin_test.go b/internal/web/admin_test.go index ba86b9f..21f9f07 100644 --- a/internal/web/admin_test.go +++ b/internal/web/admin_test.go @@ -16,6 +16,15 @@ import ( "openscale/internal/domain" ) +// The administration routes that touch the DOCUMENT and the JOURNAL: saving a +// configuration and then applying it, exporting and re-importing it without applying +// anything, restoring a version, reading back the weighings, the technical journal and the +// imports, and recording then forgetting a decision. +// +// The nine troubleshooting buttons, the self-tests and the hardware routes are in +// troubleshooting_test.go, next to troubleshooting.go. The doubles stay here: they already +// served six other test files of this package. + // TestSavingAConfigurationWritesItThenAppliesIt — the five steps of §11.4, in order, // and the order is the whole point: the file is written BEFORE the station is asked to // live with it, so a station that crashes while reloading comes back on the @@ -326,325 +335,6 @@ func TestTheWeightWaiverIsTheSameRouteAsTheWithdrawal(t *testing.T) { } } -// TestTheNineTroubleshootingButtonsAnswerWithoutAPassword (ADR-018). -func TestTheNineTroubleshootingButtonsAnswerWithoutAPassword(t *testing.T) { - actions := &fakeTroubleshooting{} - catalog := &fakeCatalogAdmin{} - b := newBench(t, func(o *benchOptions) { - o.troubleshooting = actions - o.catalogAdmin = catalog - o.printer = b2Printer{} - }) - // « Basculer en saisie manuelle » est devenue un acte PROTÉGÉ (ADR-033) : elle coupe - // la balance et laisse le client taper son propre poids. Les sept autres restent - // libres — aucune ne change ce que le poste vend ni la façon dont il pèse. - b.setPassword("un-mot-de-passe", "ABCD2345") - b.login("un-mot-de-passe") - - for _, action := range []struct { - path, body string - want int - }{ - {"/admin/api/troubleshooting/reprint", `{}`, http.StatusOK}, - {"/admin/api/troubleshooting/reload-catalog", `{}`, http.StatusAccepted}, - {"/admin/api/troubleshooting/manual-entry", `{"on":true}`, http.StatusOK}, - {"/admin/api/troubleshooting/roll-changed", `{}`, http.StatusOK}, - {"/admin/api/troubleshooting/fallback-printer", `{"on":true}`, http.StatusOK}, - {"/admin/api/troubleshooting/test-scale", `{}`, http.StatusOK}, - {"/admin/api/troubleshooting/test-printer", `{}`, http.StatusOK}, - {"/admin/api/troubleshooting/test-label", `{}`, http.StatusAccepted}, - } { - response := b.post(action.path, action.body) - if response.StatusCode != action.want { - t.Errorf("POST %s = %d, attendu %d : %s", - action.path, response.StatusCode, action.want, body(t, response)) - continue - } - response.Body.Close() - } - if !actions.manual || !actions.roll || !actions.fallback { - t.Fatalf("les actions n'ont pas été exécutées : %+v", actions) - } - if catalog.reloads != 1 { - t.Fatalf("%d relectures de catalogue, attendu 1", catalog.reloads) - } -} - -// TestTestingTheScaleAndThePrinterReadsWhatIsAlreadyObserved. -// -// Reopening the serial port to test it would mean closing the driver in service on a -// platform where the port is exclusive — a diagnosis that breaks what it diagnoses. And -// the live median is a better answer than a three-second sample. -func TestTestingTheScaleAndThePrinterReadsWhatIsAlreadyObserved(t *testing.T) { - b := newBench(t) - b.feed(1236, 10) - - scale := decodeStatus[scaleTestDTO](t, - b.post("/admin/api/troubleshooting/test-scale", `{}`), http.StatusOK) - if !scale.Connected || scale.LastWeightG != 1236 { - t.Fatalf("test balance = %+v", scale) - } - if !strings.Contains(scale.Message, "répond") { - t.Fatalf("message = %q", scale.Message) - } - - printer := decodeStatus[struct { - Health string `json:"health"` - Message string `json:"message"` - }](t, b.post("/admin/api/troubleshooting/test-printer", `{}`), http.StatusOK) - if printer.Message == "" { - t.Fatalf("test imprimante = %+v", printer) - } -} - -// TestAnUnknownSelfTestIsRefusedByName. -func TestAnUnknownSelfTestIsRefusedByName(t *testing.T) { - b := newBench(t, func(o *benchOptions) { o.printer = b2Printer{} }) - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - response := b.post("/admin/api/printer/test?what=inconnu", `{}`) - defer response.Body.Close() - if response.StatusCode != http.StatusBadRequest { - t.Fatalf("auto-test inconnu = %d, attendu 400", response.StatusCode) - } -} - -// TestTheReloadAnswerNamesWhatIsWatchedAndTheImportInForce. -// -// « Le catalogue va être relu. » was written before anything had been looked at, and the -// answer carried nothing else: no file, no directory, no instant, no result. The screen -// therefore had no way to recognise the import that followed, and on the dominant case — -// nothing where the station is looking — the promise was followed by a silence that never -// ended, because the watch returns without a word when it finds no file (§10.5). -func TestTheReloadAnswerNamesWhatIsWatchedAndTheImportInForce(t *testing.T) { - const seen = "Aucun fichier flv_2.csv dans D:\\catalog\\incoming : il n'y a rien à relire." - b := newBench(t, func(o *benchOptions) { - o.catalogAdmin = &fakeCatalogAdmin{seen: seen} - o.dashboard = stubDashboard{DashboardFacts{Source: &CatalogSourceState{ - Type: domain.CatalogSourceLocalDrop, - Label: "dépôt local, flv_2.csv dans D:\\catalog\\incoming", - }}} - }) - b.store.imports = []domain.Import{{ - ID: 7, OccurredAt: epoch, Source: domain.CatalogSourceLocalDrop, - FileName: "flv_1.csv", Result: domain.ImportApplied, - }} - - got := decodeStatus[reloadDTO](t, - b.post("/admin/api/troubleshooting/reload-catalog", `{}`), http.StatusAccepted) - if got.Message != seen { - t.Fatalf("message = %q, attendu ce que le poste a VU du fichier surveillé", got.Message) - } - if !strings.Contains(got.Watched, "flv_2.csv") { - t.Fatalf("surveillé = %q, attendu la ligne permanente du catalogue", got.Watched) - } - // L'écran reconnaît l'import SUIVANT en comparant son identifiant à celui-ci : sans - // lui, il ne peut ni annoncer l'issue ni cesser de l'attendre. - if got.LastImportID != 7 || got.LastImportAt == "" { - t.Fatalf("import en vigueur = %d à %q, attendu le dernier import du journal", - got.LastImportID, got.LastImportAt) - } -} - -// TestAStationWithNoJournalAndNoDashboardStillAnswersTheReload. -// -// Both collaborators are optional — a station whose journal is unavailable still serves -// (ADR-013), and a station wired without a Dashboard publishes no source line. The answer -// then says nothing about either, rather than panicking or inventing a sentence. -func TestAStationWithNoJournalAndNoDashboardStillAnswersTheReload(t *testing.T) { - b := newBench(t, func(o *benchOptions) { - o.catalogAdmin = &fakeCatalogAdmin{} - o.noStore = true - }) - - got := decodeStatus[reloadDTO](t, - b.post("/admin/api/troubleshooting/reload-catalog", `{}`), http.StatusAccepted) - if !got.Done { - t.Fatalf("relecture = %+v, attendu acceptée", got) - } - if got.Watched != "" || got.LastImportID != 0 { - t.Fatalf("relecture = %+v, attendu aucune affirmation sur ce que rien n'a publié", got) - } - if got.Message == "" { - t.Fatal("un poste sans journal ni tableau de bord ne dit rien du tout de sa relecture") - } -} - -// TestACatalogDroppedOnTheScreenGoesThroughTheOrdinaryWatcher (A4, ADR-011). -func TestACatalogDroppedOnTheScreenGoesThroughTheOrdinaryWatcher(t *testing.T) { - catalog := &fakeCatalogAdmin{} - b := newBench(t, func(o *benchOptions) { o.catalogAdmin = catalog }) - // Le dépôt remplace toute la grille par un fichier qu'on apporte : acte protégé (ADR-033). - b.setPassword("un-mot-de-passe", "ABCD2345") - b.login("un-mot-de-passe") - - payload, contentType := multipartCSV(t, "flv_2.csv", "id;nom;prix\n20;AIL;5.32\n") - response := b.do(http.MethodPost, "/admin/api/catalog/import", payload, - http.Header{"Content-Type": {contentType}}) - got := decodeStatus[importDTO](t, response, http.StatusAccepted) - if got.FileName != "flv_2.csv" { - t.Fatalf("import = %+v", got) - } - if catalog.imported != "flv_2.csv" { - t.Fatalf("le fichier remis à la source est %q", catalog.imported) - } - - empty := b.do(http.MethodPost, "/admin/api/catalog/import", "", - http.Header{"Content-Type": {contentType}}) - empty.Body.Close() - if empty.StatusCode != http.StatusBadRequest { - t.Fatalf("dépôt sans fichier = %d, attendu 400", empty.StatusCode) - } -} - -// TestARouteWhoseCollaboratorIsMissingSays501 — and 501, not 404: the route EXISTS, it -// is in the contract of §14.5, and it is this binary's wiring that does not carry the -// capability. A 404 would send a volunteer looking for a typo. -func TestARouteWhoseCollaboratorIsMissingSays501(t *testing.T) { - b := adminBench(t) - - for _, route := range []struct{ method, path, body string }{ - {http.MethodPost, "/admin/api/troubleshooting/reload-catalog", `{}`}, - {http.MethodPost, "/admin/api/troubleshooting/manual-entry", `{"on":true}`}, - {http.MethodPost, "/admin/api/troubleshooting/roll-changed", `{}`}, - {http.MethodPost, "/admin/api/troubleshooting/fallback-printer", `{"on":false}`}, - {http.MethodPost, "/admin/api/troubleshooting/test-label", `{}`}, - {http.MethodGet, "/admin/api/diagnostic.zip", ""}, - {http.MethodGet, "/admin/api/ports", ""}, - {http.MethodGet, "/admin/api/printers", ""}, - {http.MethodPost, "/admin/api/printers/discover", `{}`}, - {http.MethodPost, "/admin/api/scale/detect", `{"port":"COM8"}`}, - {http.MethodPost, "/admin/api/scale/capture", `{"port":"COM8"}`}, - {http.MethodGet, "/admin/api/label/preview.png", ""}, - {http.MethodPost, "/admin/api/catalog/reload", `{}`}, - {http.MethodPost, "/admin/api/catalog/forget-quarantine", `{}`}, - {http.MethodPost, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`}, - } { - response := b.do(route.method, route.path, route.body, nil) - if response.StatusCode != http.StatusNotImplemented { - t.Errorf("%s %s = %d, attendu 501", route.method, route.path, response.StatusCode) - } - if !strings.Contains(body(t, response), "pas disponible") { - t.Errorf("%s %s ne dit pas ce qui manque", route.method, route.path) - } - } -} - -// TestTheHardwareRoutesAnswerWhenThePlatformIsWired. -func TestTheHardwareRoutesAnswerWhenThePlatformIsWired(t *testing.T) { - hardware := &fakeHardware{} - b := adminBench(t, func(o *benchOptions) { o.hardware, o.diagnostician = hardware, hardware }) - - ports := decodeStatus[struct { - Ports []portDTO `json:"ports"` - }](t, b.get("/admin/api/ports"), http.StatusOK) - if len(ports.Ports) != 1 || ports.Ports[0].Description == "" { - t.Fatalf("ports = %+v : « COM8 » ne nomme rien, « COM8 — FTDI » nomme un câble", ports.Ports) - } - - printers := decodeStatus[struct { - Printers []printerDeviceDTO `json:"printers"` - }](t, b.get("/admin/api/printers"), http.StatusOK) - if len(printers.Printers) != 1 { - t.Fatalf("imprimantes = %+v", printers.Printers) - } - // The key travels with the name, and the two routes do not answer the same one. Without - // it the screen wrote every destination a volunteer clicked into printer.options.queue, - // address of a network printer included — a configuration nothing refuses and no - // transport can open. - if printers.Printers[0].Key != domain.DeviceKeyQueue { - t.Fatalf("file énumérée = %+v : elle doit dire qu'elle va dans %q", - printers.Printers[0], domain.DeviceKeyQueue) - } - discovered := decodeStatus[struct { - Printers []printerDeviceDTO `json:"printers"` - }](t, b.post("/admin/api/printers/discover", `{}`), http.StatusOK) - if len(discovered.Printers) != 2 { - t.Fatalf("découverte = %+v", discovered.Printers) - } - for _, found := range discovered.Printers { - if found.Key != domain.DeviceKeyAddress { - t.Errorf("candidat réseau %+v : il doit dire qu'il va dans %q", - found, domain.DeviceKeyAddress) - } - } - - detected := decodeStatus[struct { - Driver string `json:"driver"` - ValidCount int `json:"valid_frames_count"` - }](t, b.post("/admin/api/scale/detect", `{"port":"COM8"}`), http.StatusOK) - if detected.Driver == "" || detected.ValidCount != 12 { - t.Fatalf("détection = %+v : c'est la détection qui répond, pas l'exploitant", detected) - } - - captured := decodeStatus[struct { - Frames []string `json:"frames"` - }](t, b.post("/admin/api/scale/capture", `{"port":"COM8","seconds":3}`), http.StatusOK) - if len(captured.Frames) == 0 { - t.Fatal("aucune trame capturée") - } - - preview := b.get("/admin/api/label/preview.png?template=weighing_identical&demo=1") - defer preview.Body.Close() - if preview.StatusCode != http.StatusOK || - preview.Header.Get("Content-Type") != "image/png" { - t.Fatalf("aperçu = %d, %q", preview.StatusCode, preview.Header.Get("Content-Type")) - } - - archive := b.get("/admin/api/diagnostic.zip") - defer archive.Body.Close() - if archive.Header.Get("Content-Disposition") == "" { - t.Fatal("le fichier de diagnostic ne se télécharge pas") - } - - replayed := b.post("/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) - replayed.Body.Close() - if replayed.StatusCode != http.StatusAccepted { - t.Fatalf("rejeu = %d, attendu 202", replayed.StatusCode) - } - if empty := b.post("/admin/api/replay", `{"frame":""}`); empty.StatusCode != http.StatusBadRequest { - t.Fatalf("rejeu sans trame = %d, attendu 400", empty.StatusCode) - } -} - -// TestAStationWithoutAJournalSaysSoRatherThanLying. -func TestAStationWithoutAJournalSaysSoRatherThanLying(t *testing.T) { - b := newBench(t, func(o *benchOptions) { o.noStore = true }) - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - for _, path := range []string{ - "/admin/api/journal", "/admin/api/journal/export.csv", - "/admin/api/technical", "/admin/api/imports", - } { - response := b.get(path) - response.Body.Close() - if response.StatusCode != http.StatusNotImplemented { - t.Errorf("GET %s = %d, attendu 501", path, response.StatusCode) - } - } - response := b.post("/admin/api/products/4412/decision", `{"offered":false,"reason":"x"}`) - response.Body.Close() - if response.StatusCode != http.StatusNotImplemented { - t.Fatalf("décision sans base = %d, attendu 501", response.StatusCode) - } -} - -// TestADatabaseThatRefusesToReadIsReportedAndNotHidden. -func TestADatabaseThatRefusesToReadIsReportedAndNotHidden(t *testing.T) { - b := adminBench(t) - b.store.err = errors.New("base verrouillée") - - for _, path := range []string{"/admin/api/journal", "/admin/api/technical", "/admin/api/imports"} { - response := b.get(path) - response.Body.Close() - if response.StatusCode != http.StatusInternalServerError { - t.Errorf("GET %s = %d, attendu 500", path, response.StatusCode) - } - } -} - // --- Helpers and doubles ---------------------------------------------------- // adminBench is a bench with a session already open, for the routes behind ADR-018's diff --git a/internal/web/answers.go b/internal/web/answers.go new file mode 100644 index 0000000..1fb9a09 --- /dev/null +++ b/internal/web/answers.go @@ -0,0 +1,83 @@ +// This file holds the FOUR SHAPES an answer can take -- a JSON body, a problem, a +// 404, a 503 -- and the one way a request body is read. +// +// writeJSON marshals BEFORE the status line goes out, and that ordering is the +// whole point: a marshalling failure after WriteHeader leaves the client with a 200 +// and a truncated document, which is the one failure mode a screen cannot detect. + +package web + +import ( + "encoding/json" + "io" + "net/http" +) + +// writeJSON renders one body, and never lets a half-written one look like a whole. +// +// The body is marshalled BEFORE the status line goes out: a marshalling failure +// after WriteHeader would leave the client with a 200 and a truncated document, +// which is the one failure mode a screen cannot detect. +func writeJSON(w http.ResponseWriter, status int, body any) { + raw, err := json.Marshal(body) + if err != nil { + http.Error(w, `{"message":"Réponse illisible."}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _, _ = w.Write(raw) +} + +// problem is what every refusal of this layer looks like. +// +// Message is FRENCH and complete: it is read by a volunteer on the administration +// screen. Code is an ERR-xxx-nn when one is allocated, and empty otherwise — an +// invented code is worse than none, because somebody would look it up. +type problem struct { + Code string `json:"code"` + Message string `json:"message"` + // Faults carries the configuration controls of §11.3, ALL of them at once. + Faults []faultDTO `json:"faults,omitempty"` +} + +// writeProblem renders one refusal. +func writeProblem(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, problem{Code: code, Message: message}) +} + +// notFound is the answer of an /api path nobody serves. It is JSON and not the +// front end: an API that answers a route with an HTML page teaches a front end to +// parse HTML. +func notFound(w http.ResponseWriter, _ *http.Request) { + writeProblem(w, http.StatusNotFound, "", "Cette adresse n'existe pas.") +} + +// unavailable answers a route whose collaborator this station was not given. +// +// 501 and not 404: the route EXISTS, it is in the contract of §14.5, and it is this +// binary's wiring that does not carry the capability yet. A 404 would send a +// volunteer looking for a typo. +func unavailable(w http.ResponseWriter, what string) { + writeProblem(w, http.StatusNotImplemented, "", + "Cette fonction n'est pas disponible sur ce poste : "+what+".") +} + +// decodeJSON reads one request body, and refuses what it cannot understand. +// +// The body is BOUNDED: a command from the screen is a few hundred bytes, and an +// unbounded read is an unbounded allocation on a station with 4 GB of RAM. +func decodeJSON(w http.ResponseWriter, r *http.Request, into any) bool { + decoder := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(into); err != nil { + writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) + return false + } + return true +} + +// maxBodyBytes bounds a JSON command body. A weigh command is under 200 bytes; a +// whole configuration, which travels on PUT /admin/api/config, is a few kilobytes. +const maxBodyBytes = 1 << 20 diff --git a/internal/web/argon2id.go b/internal/web/argon2id.go new file mode 100644 index 0000000..badde0e --- /dev/null +++ b/internal/web/argon2id.go @@ -0,0 +1,161 @@ +// This file holds the CRYPTOGRAPHY of the administration: how a secret becomes a +// PHC string, how one is read back, and the eight-character code printed on the +// installation sheet. +// +// The cost parameters are read FROM THE STORED HASH and never assumed, which is +// what lets a cooperative raise them without locking itself out of the stations +// that were installed before. + +package web + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "golang.org/x/crypto/argon2" + "math/big" + "strings" +) + +// The argon2id parameters used when THIS binary hashes a password. +// +// They are the cost of one login on the target hardware (an i3 of 2015), and they +// are deliberately not configurable: an operator has no legitimate choice to make +// about a key derivation cost, and a station where it could be lowered would be a +// station where it WOULD be lowered. Verification reads the parameters from the +// stored string, so raising them later keeps every existing hash valid. +const ( + argonMemory = 64 * 1024 // KiB + argonTime = 3 + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// errBadHash reports a stored hash this binary cannot read. +var errBadHash = errors.New("web: empreinte argon2id illisible") + +// HashSecret produces the PHC string a configuration file carries. +// +// The format is the one §11.2 shows and the one Config.Validate checks the shape of: +// $argon2id$v=19$m=…,t=…,p=…$salt$hash, both parts in unpadded base64. +// +// It is EXPORTED for one caller outside this package: `openscale config password`, the +// command line §14.4 keeps beside the screen for a station in Assigned Access whose +// wizard was never run. Two implementations of this format would be two ways of writing +// the same field, and the day they drifted the station would refuse a password nobody +// mistyped. +func HashSecret(secret string) (string, error) { + return hashWithCost(secret, argonMemory, argonTime, argonThreads) +} + +// hashWithCost is HashSecret with the cost spelled out. +// +// Production has exactly one caller and it passes the constants above. The parameters +// exist so that a test can produce a hash written by an OLDER binary — which is the +// case VerifySecret has to keep opening. +func hashWithCost(secret string, memory, iterations uint32, threads uint8) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("web: tirage du sel impossible : %w", err) + } + key := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, memory, iterations, threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +// RecoveryCodeLength is the eight characters §14.4 prints on the installation sheet. +const RecoveryCodeLength = 8 + +// recoveryAlphabet is what those eight characters are drawn from. +// +// Neither I, L, O, U, 0 nor 1. This code is not typed by whoever generated it: it is read +// off a sheet of paper filed in the shop's folder, months later, by a volunteer who is +// already having a bad morning. The pair O/0 alone accounts for most of what a printed +// code loses on its way back to a keyboard, and U leaves with them so that eight random +// characters never spell a word somebody would then keep in their head instead of the +// folder. +const recoveryAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" + +// NewRecoveryCode draws the recovery code of §14.4, in clear, ONCE. +// +// The station never stores it: what goes into the configuration is its argon2id hash, and +// the only copy in existence is the one printed on the installation sheet. That is the +// whole point — it is a possession factor, and a possession factor a machine can read +// back is not one. +func NewRecoveryCode() (string, error) { + code := make([]byte, RecoveryCodeLength) + for i := range code { + // rand.Int and not a modulo of one byte: the alphabet has 30 characters, 256 is + // not a multiple of 30, and the bias that follows would make six of them a third + // more likely than the rest. + drawn, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet)))) + if err != nil { + return "", fmt.Errorf("web: tirage du code de secours impossible : %w", err) + } + code[i] = recoveryAlphabet[drawn.Int64()] + } + return string(code), nil +} + +// NormalizeRecoveryCode is what both ends apply before hashing or comparing. +// +// The alphabet is upper case, so a code copied in lower case out of the folder is the +// SAME code and must open the same door. Refusing it would be refusing a volunteer for a +// shift key. +func NormalizeRecoveryCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// VerifySecret reports whether secret is the one behind encoded. +// +// The cost parameters come from the STORED string and not from the constants above: +// raising the cost of new hashes must never invalidate the ones already written, and +// a station whose password was set by an older binary has to keep opening. +func VerifySecret(encoded, secret string) bool { + salt, want, memory, iterations, threads, err := parsePHC(encoded) + if err != nil { + return false + } + got := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// parsePHC takes a stored argon2id string apart. +func parsePHC(encoded string) (salt, key []byte, memory, iterations uint32, threads uint8, err error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return nil, nil, 0, 0, 0, errBadHash + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return nil, nil, 0, 0, 0, errBadHash + } + var parallelism int + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil { + return nil, nil, 0, 0, 0, errBadHash + } + if parallelism < 1 || parallelism > 255 { + return nil, nil, 0, 0, 0, errBadHash + } + if salt, err = decodeBase64(parts[4]); err != nil { + return nil, nil, 0, 0, 0, errBadHash + } + if key, err = decodeBase64(parts[5]); err != nil { + return nil, nil, 0, 0, 0, errBadHash + } + return salt, key, memory, iterations, uint8(parallelism), nil +} + +// decodeBase64 accepts the padded and the unpadded spelling: a hash written by hand, +// or by another tool, must not be refused over a trailing equals sign. +func decodeBase64(s string) ([]byte, error) { + if raw, err := base64.RawStdEncoding.DecodeString(s); err == nil { + return raw, nil + } + return base64.StdEncoding.DecodeString(s) +} diff --git a/internal/web/config.go b/internal/web/config.go index 5a745a8..1e55746 100644 --- a/internal/web/config.go +++ b/internal/web/config.go @@ -1,15 +1,18 @@ +// This file holds WHAT A CONFIGURATION LOOKS LIKE FROM THE OUTSIDE: the DTO the +// administration screen edits, and the read that serves it. +// +// Reading is open and writing is not (ADR-033), and the reason is here: configPayload +// REDACTS both hashes before the payload leaves, so there is nothing on this route a +// password would be keeping. + package web import ( "encoding/json" "errors" - "io" "net/http" - "strconv" - "time" - "openscale/internal/domain" - "openscale/internal/station" + "time" ) // faultDTO is one configuration control that failed (§11.3). @@ -168,411 +171,6 @@ func retiredOrEmpty(keys []string) []string { return keys } -// writeConfig is PUT /admin/api/config — the five steps of §11.4, in order. -// -// 1. json.Unmarshal → 400 when it is not a document -// 2. Config.Validate → 422 with EVERY fault at once -// 3. rotation and atomic write → the ConfigStore's business -// 4. Station.Reload → restarts only the blocks that moved -// 5. hardware or listen moved → the 60-second countdown, with automatic rollback -// -// # Why the secrets come back from the configuration in force -// -// The screen never receives them, so it cannot send them back. Taking them from the -// running configuration is what lets a save of the edited document leave the password -// alone instead of erasing it. -func (s *Server) writeConfig(w http.ResponseWriter, r *http.Request) { - if s.configStore == nil || s.controller == nil { - unavailable(w, "la configuration n'est pas modifiable ici") - return - } - - // A second save INSIDE the window is refused, exactly as a confirmation outside it is. - // The write of step 3 happens before the countdown of step 5, so accepting one would - // move the file the rollback aims at onto a version nobody confirmed either — and the - // one somebody really did validate would be the version lost. - if deadline := s.controller.PendingConfirmation(); !deadline.IsZero() { - writeProblem(w, http.StatusConflict, "", - "Une configuration attend encore d'être confirmée. Confirmez-la, ou laissez le "+ - "poste revenir tout seul à la version précédente, puis enregistrez de nouveau.") - return - } - - // The document GET serves is the DECODED Go structure, re-marshalled: a retired key - // never survives that round trip, because encoding/json drops what no field claims - // (§11.3, `configPayload`). A PUT of exactly what GET served can therefore never - // re-declare `coef_num` or `coef_den`, and control 20 on the SUBMITTED document -- - // `next`, below -- finds nothing to refuse: the save would silently rewrite the file - // with MEMBER at 0 %. What is asked here is the FILE itself, read the same way - // `readConfig` reads it, which still carries whatever nobody repaired. Refusing the - // write is the same reasoning control 20 already applies to an upload that names a - // retired key outright, extended to a key already sitting on disk (ADR-034): - // repairing the file is done IN the file, not by laundering it through this screen. - // - // A file only PART of which decoded still answers both questions this block asks: its - // READ blocks are the shop's own, and a retired key sitting in one of them is still - // there. Treating it as « unreadable » skipped the guard entirely, and skipped - // `served` below with it — so the catalog password was taken from the neutral profile, - // which has none, and a save about the polling interval erased a producer's account. - onDisk, onDiskErr := s.configStore.Read(r.Context()) - var unreadable *domain.UnreadableBlocksError - if errors.As(onDiskErr, &unreadable) { - onDisk, onDiskErr = unreadable.Config, nil - } - if onDiskErr == nil { - if faults := retiredFaultsOf(onDisk, s.registries); len(faults) > 0 { - writeJSON(w, http.StatusUnprocessableEntity, problem{ - Code: "ERR-CFG-01", - Message: "Le fichier de configuration en service porte encore une clé que ce " + - "binaire refuse. L'administration ne peut pas la corriger : changez-la " + - "dans le fichier de configuration lui-même.", - Faults: faults, - }) - return - } - } - - raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) - if err != nil { - writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) - return - } - var next domain.Config - if err := json.Unmarshal(raw, &next); err != nil { - writeProblem(w, http.StatusBadRequest, "", "Configuration illisible : "+err.Error()) - return - } - - current := s.hub.Config() - next.Admin.PasswordHash = current.Admin.PasswordHash - next.Admin.RecoveryCodeHash = current.Admin.RecoveryCodeHash - next.ModifiedAt = s.clock.Now() - - // served is the document the screen was GIVEN and edited, which is what a submission - // has to be compared against: the FILE, as readConfig serves it, and only what is - // running when no file could be read. - // - // The distinction is the difference between repairing a station and destroying it. A - // station that started out of service runs the NEUTRAL profile, whose catalog carries - // no password at all (serve.go, fallbackProfile): taking the secret back from what is - // running would erase the cooperative's WebDAV account on the very save that repaired - // the file. The two hashes above escape that trap only because the fallback profile - // copies Admin over by hand. - served := current - if onDiskErr == nil { - served = onDisk - } - next.Catalog.Options = carriedOverSecret(served.Catalog, next.Catalog) - - // The drop probe touches the filesystem, so it runs only when the block it is about has - // MOVED: a save about the weighing thresholds must not fail because a producer's share - // happens to be down that morning. The decision belongs here, to the layer that holds - // both versions; the execution stays in the domain (§11.3, control 46). - registries := s.registries - if registries.Paths != nil && - domain.BlockFingerprint(served.Catalog) == domain.BlockFingerprint(next.Catalog) { - registries.Paths = readOnlyPaths{inner: registries.Paths} - } - - if faults := (&next).Validate(registries); len(faults) > 0 { - writeJSON(w, http.StatusUnprocessableEntity, problem{ - Code: "ERR-CFG-01", - Message: "Cette configuration ne peut pas être appliquée.", - Faults: faultsOf(faults), - }) - return - } - if err := s.configStore.Save(r.Context(), next); err != nil { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration non écrite : "+err.Error()) - return - } - - // The rollback of §11.4 puts THE FILE AS IT WAS back, and `onDisk` is that document: it - // was read above, before the Save, and it is the same variable `served` already stands - // for. Handing it over is what keeps a station whose file is faulty — and which - // therefore RUNS the neutral profile — from having the factory settings written over - // its own file sixty seconds after a volunteer repaired it. - // - // A file that could not be read hands over nothing, and the station falls back on what - // it is running: that is all such a station has left. - reload := station.ReloadRequest{Next: next} - if onDiskErr == nil { - reload.FileBefore = &onDisk - } - outcome, err := s.controller.Reload(reload) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration écrite mais non appliquée : "+err.Error()) - return - } - s.technical.Technical(domain.LevelInfo, "config", "", - "Configuration enregistrée.", next.Fingerprint()) - - s.moveListener(current, next, outcome.ConfirmBefore) - writeJSON(w, http.StatusOK, s.configPayload(next, - s.confirmationOf(outcome.Changed, outcome.ConfirmBefore))) -} - -// carriedOverSecret puts the catalog password back when the submitted document carries -// none, and leaves a typed one alone. -// -// The screen never received it — configPayload blanks it — so it cannot send it back. This -// is the same reasoning as the two hashes of writeConfig and the same repair: without it, a -// save about the polling interval would take the catalog down at the next poll, silently. -// -// A password can therefore not be EMPTIED from this screen. That is the price of a -// write-only field, and it is paid where every other irreversible repair is paid: in the -// file itself (ADR-034). -// -// # What says « this share really has no password », and what does not -// -// It is the SOURCE that says it, never the shape of the key. A blank value and an absent -// key are two spellings of the same silence, and a browser produces the second without -// anybody meaning to: the Station page copies an imported file into the draft, the export -// it came from carries no password at all (Config.Export deletes it whatever `hardware` -// says), and JSON.stringify drops a property whose value is undefined. Reading that as a -// deletion erased the cooperative's WebDAV account through Importer → Recopier → -// Enregistrer, on a save about something else entirely. -func carriedOverSecret(served, submitted domain.CatalogConfig) domain.DriverOptions { - // Changing the SOURCE is the one gesture that legitimately drops the account: the - // Catalogue screen deletes the url, the user and the password when somebody moves the - // station to a local directory, because control 39 refuses their mere presence there. - // Writing the secret back would answer that move with a refusal on a field the screen no - // longer shows, and no screen could ever repair it. - if submitted.Type != served.Type { - return submitted.Options - } - if typed, ok := submitted.Options.Text(catalogPasswordOption); ok && typed != "" { - return submitted.Options - } - inForce, ok := served.Options.Text(catalogPasswordOption) - if !ok || inForce == "" { - return submitted.Options - } - return submitted.Options.WithText(catalogPasswordOption, inForce) -} - -// readOnlyPaths answers every DROP question with "nothing to check". -// -// It is what says « this save is not about the catalog ». The READ question of control 44 -// still travels, because it is about another key entirely and costs one stat. -// -// inner is never nil: writeConfig wraps a probe that exists, and wraps nothing otherwise — -// a nil PathChecker already means « we cannot know » to every control. -type readOnlyPaths struct{ inner domain.PathChecker } - -func (p readOnlyPaths) Readable(path string) error { return p.inner.Readable(path) } - -func (readOnlyPaths) Droppable(string) error { return nil } - -// confirmationOf renders the countdown, or nothing when there is none. -func (s *Server) confirmationOf(changed []string, before time.Time) *confirmationDTO { - if before.IsZero() { - return nil - } - left := before.Sub(s.clock.Now()) - if left < 0 { - left = 0 - } - return &confirmationDTO{ - Changed: changed, ConfirmBefore: stamp(before), - SecondsLeft: int(left.Seconds()), - } -} - -// confirmConfig is POST /admin/api/config/confirm: the branch is not being cut. -// -// It confirms BOTH halves — the station stops its countdown, and the socket stops -// waiting to be put back. They are two objects and one decision. -func (s *Server) confirmConfig(w http.ResponseWriter, _ *http.Request) { - if s.controller == nil { - unavailable(w, "la configuration n'est pas modifiable ici") - return - } - if err := s.controller.Confirm(); err != nil { - writeProblem(w, http.StatusConflict, "", "Aucune confirmation n'est attendue.") - return - } - if s.binder != nil { - s.binder.Confirm() - } - writeJSON(w, http.StatusOK, actionDTO{ - Done: true, Message: "La configuration est confirmée."}) -} - -// exportConfig is GET /admin/api/config/export?hardware=0 — what §11.5 clones. -func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) { - includeHardware := r.URL.Query().Get("hardware") != "0" - cfg := s.hub.Config() - exported := cfg.Export(includeHardware) - // Config.Export drops the password hash always and the RECOVERY code hash only - // when the hardware is excluded. That asymmetry has no reason to exist here: the - // recovery code is printed on the installation sheet OF THIS STATION, and carrying - // it into a clone is precisely the « four stations sharing one secret nobody - // chose » that the same function refuses for the password. It is signalled to the - // domain; until then, this route redacts it. - exported.Admin.RecoveryCodeHash = "" - - raw, err := json.MarshalIndent(exported, "", " ") - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", "Export impossible : "+err.Error()) - return - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Content-Disposition", `attachment; filename="config-export.json"`) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(raw) -} - -// importConfig is POST /admin/api/config/import: it VALIDATES and returns the diff, -// and applies nothing. -// -// Applying is PUT, which the screen calls once a human has read the field-by-field -// diff of §14.4. An import that applied itself would be a station reconfigured by a -// file somebody double-clicked. -func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) { - raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) - if err != nil { - writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) - return - } - var candidate domain.Config - if err := json.Unmarshal(raw, &candidate); err != nil { - writeProblem(w, http.StatusBadRequest, "", "Configuration illisible : "+err.Error()) - return - } - current := s.hub.Config() - // The two secrets and the station number are NOT imported: a clone must not - // inherit the password of the station it was cloned from, nor its number (§11.5). - candidate.Admin = current.Admin - candidate.Station.Number = current.Station.Number - - body := struct { - configDTO - Faults []faultDTO `json:"faults"` - Changed []string `json:"changed_blocks"` - }{ - configDTO: s.configPayload(candidate, nil), - Faults: faultsOf((&candidate).Validate(s.registries)), - Changed: changedBlocks(current, candidate), - } - writeJSON(w, http.StatusOK, body) -} - -// configVersions is GET /admin/api/config/versions. -func (s *Server) configVersions(w http.ResponseWriter, r *http.Request) { - if s.configStore == nil { - unavailable(w, "la configuration n'est pas versionnée ici") - return - } - versions, err := s.configStore.Versions(r.Context()) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", err.Error()) - return - } - out := make([]configVersionDTO, 0, len(versions)) - for _, v := range versions { - out = append(out, configVersionDTO{ - Version: v.Version, ModifiedAt: stamp(v.ModifiedAt), Fingerprint: v.Fingerprint, - }) - } - writeJSON(w, http.StatusOK, struct { - Versions []configVersionDTO `json:"versions"` - }{out}) -} - -// restoreRequest is the body of POST /admin/api/config/restore. -type restoreRequest struct { - Version int `json:"version"` -} - -// restoreConfig is POST /admin/api/config/restore: one of the five backups comes back -// into service, through the SAME path as any other save. -func (s *Server) restoreConfig(w http.ResponseWriter, r *http.Request) { - var body restoreRequest - if !decodeJSON(w, r, &body) { - return - } - if s.configStore == nil || s.controller == nil { - unavailable(w, "la configuration n'est pas versionnée ici") - return - } - // A restoration is a save like any other and it arms the same countdown, so it is - // refused inside the window for the same reason writeConfig is. - if deadline := s.controller.PendingConfirmation(); !deadline.IsZero() { - writeProblem(w, http.StatusConflict, "", - "Une configuration attend encore d'être confirmée. Confirmez-la, ou laissez le "+ - "poste revenir tout seul à la version précédente, puis restaurez de nouveau.") - return - } - restored, err := s.configStore.Restore(r.Context(), body.Version) - // A backup one of whose blocks did not decode EXISTS, and « introuvable » would send a - // volunteer looking for a file that is right there beside config.json — listed by - // Versions, on the screen, one line above the button they just pressed. It cannot be - // applied as it stands, which is the answer the Validate branch below already gives. - var unreadable *domain.UnreadableBlocksError - if errors.As(err, &unreadable) { - writeJSON(w, http.StatusUnprocessableEntity, problem{ - Code: "ERR-CFG-01", - Message: "La version " + strconv.Itoa(body.Version) + " existe, mais " + - unreadable.BlockPhrase() + " " + unreadable.NotRead() + " : la restaurer " + - "poserait la configuration d'usine " + unreadable.InTheirPlace() + ".", - Faults: faultsOf(unreadable.Faults), - }) - return - } - if err != nil { - writeProblem(w, http.StatusNotFound, "", - "Version "+strconv.Itoa(body.Version)+" introuvable : "+err.Error()) - return - } - current := s.hub.Config() - restored.Admin = current.Admin - - if faults := (&restored).Validate(s.registries); len(faults) > 0 { - writeJSON(w, http.StatusUnprocessableEntity, problem{ - Code: "ERR-CFG-01", - Message: "Cette version ne peut plus être appliquée telle quelle.", - Faults: faultsOf(faults), - }) - return - } - // READ BEFORE THE WRITE, and for the reason writeConfig reads it: restoring a version - // arms the same countdown, and a rollback that put the RUNNING configuration back would - // write the factory profile onto the file of a station that started out of service. - // This is the route that reaches it — writeConfig already refuses a station whose file - // carries a retired key, and this one does not. - // - // A file only PART of which decoded is still a better rollback target than memory: its - // read blocks are the shop's. Leaving FileBefore nil there is what made the countdown - // write the neutral profile onto the file sixty seconds later, unattended. - fileBefore, fileErr := s.configStore.Read(r.Context()) - var unreadableBefore *domain.UnreadableBlocksError - if errors.As(fileErr, &unreadableBefore) { - fileBefore, fileErr = unreadableBefore.Config, nil - } - - if err := s.configStore.Save(r.Context(), restored); err != nil { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration non écrite : "+err.Error()) - return - } - reload := station.ReloadRequest{Next: restored} - if fileErr == nil { - reload.FileBefore = &fileBefore - } - outcome, err := s.controller.Reload(reload) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration écrite mais non appliquée : "+err.Error()) - return - } - s.moveListener(current, restored, outcome.ConfirmBefore) - writeJSON(w, http.StatusOK, s.configPayload(restored, - s.confirmationOf(outcome.Changed, outcome.ConfirmBefore))) -} - // retiredFaultsOf reports what control 20 says about each retired key a configuration // still carries, or nothing when it carries none. // @@ -606,57 +204,3 @@ func faultsOf(faults []domain.Fault) []faultDTO { } return out } - -// changedBlocks names the blocks two configurations disagree about, by the SAME -// normalized fingerprint the station compares (§11.4). -// -// Normalized, and not a byte comparison: two documents that differ only by the order -// of their keys describe the same station, and cutting a serial port over a key order -// is the failure mode this comparison exists to avoid. -func changedBlocks(previous, next domain.Config) []string { - var changed []string - blocks := []struct { - name string - before, after any - }{ - {"station", previous.Station, next.Station}, - {"network", previous.Network, next.Network}, - {"ui", previous.UI, next.UI}, - {"scale", previous.Scale, next.Scale}, - {"printer", previous.Printer, next.Printer}, - {"pricing", previous.Pricing, next.Pricing}, - {"barcode", previous.Barcode, next.Barcode}, - {"limits", previous.Limits, next.Limits}, - {"stability", previous.Stability, next.Stability}, - {"catalog", previous.Catalog, next.Catalog}, - {"journal", previous.Journal, next.Journal}, - {"maintenance", previous.Maintenance, next.Maintenance}, - } - for _, b := range blocks { - if domain.BlockFingerprint(b.before) != domain.BlockFingerprint(b.after) { - changed = append(changed, b.name) - } - } - return changed -} - -// moveListener applies a change of network.listen to the socket (§11.4, ADR-027). -// -// A net.Listener closes and reopens in three lines: there has never been a technical -// reason to demand a process restart for it. It goes through the same three-step -// window as the hardware — apply, count down, roll back if nobody confirms — and the -// station's own countdown puts the CONFIGURATION back at the same instant this puts -// the SOCKET back. -func (s *Server) moveListener(previous, next domain.Config, confirmBefore time.Time) { - if s.binder == nil || previous.Network.Listen == next.Network.Listen { - return - } - if err := s.binder.Rebind(next.Network.Listen, confirmBefore); err != nil { - s.technical.Technical(domain.LevelError, "http", "ERR-SYS-02", - "Nouvelle adresse d'écoute refusée : l'ancienne reste en service.", err.Error()) - return - } - s.technical.Technical(domain.LevelWarn, "http", "", - "Adresse d'écoute changée : confirmez sous 60 s ou elle reviendra.", - previous.Network.Listen+" → "+next.Network.Listen) -} diff --git a/internal/web/config_test.go b/internal/web/config_test.go index a917f9b..1136d68 100644 --- a/internal/web/config_test.go +++ b/internal/web/config_test.go @@ -1,7 +1,6 @@ package web import ( - "bytes" "context" "encoding/json" "io" @@ -15,6 +14,15 @@ import ( "openscale/internal/domain" ) +// The configuration document as the administration screen serves it and takes it back: no +// list ever null, the file served exactly AS IT IS ON DISK, a retired key that blocks the +// save, and a version restore that also remembers the file it replaced. +// +// The catalog password has a place of its own, and it deserves one: it must never reach the +// browser, nor disappear because a screen sent the document back without it. +// +// What happens to a file one block of which did not decode is in substituted_test.go. + // TestNoListOfTheAdminPayloadIsEverNull. // // A nil slice marshals to `null`, and `null.length` is a TypeError. This is the EXACT @@ -431,193 +439,3 @@ func writeFileOf(t *testing.T, saved *savedConfig, cfg domain.Config) { t.Fatalf("préparation du fichier : %v", err) } } - -// --- Un fichier dont un bloc n'a pas décodé, porte par porte ----------------- -// -// The callers of ConfigStore.Read do three different things with the file, and each -// needs its own answer. One flat verdict for all of them is what produced a defect in each -// direction on 02/08/2026 (domain.UnreadableBlocksError). One test per door, below. - -// TestTheAdminScreenShowsTheReadBlocksAndNamesTheSubstitutedOnes is the DISPLAY door. -// -// The screen must show what the file really says — a station out of service runs the -// factory profile, and feeding the screen from memory is « la différence entre le réparer -// et le détruire » — and it must say which blocks it could NOT read, or a volunteer saves -// the factory tariffs over the shop's own without ever being told. -func TestTheAdminScreenShowsTheReadBlocksAndNamesTheSubstitutedOnes(t *testing.T) { - b, _, shop := benchOverADamagedFile(t, nil) - - got := decodeStatus[configDTO](t, b.get("/admin/api/config"), http.StatusOK) - - var served domain.Config - if err := json.Unmarshal(got.Config, &served); err != nil { - // The payload is re-marshalled from a decoded Config, so the damaged block travels - // as the neutral one and this always parses. - t.Fatalf("charge illisible : %v", err) - } - if served.Station.Coop != shop.Station.Coop { - t.Errorf("station.coop = %q, attendu %q : l'écran montre la mémoire, pas le fichier", - served.Station.Coop, shop.Station.Coop) - } - if len(got.Unreadable) != 1 { - t.Fatalf("%d bloc(s) signalé(s) comme illisible(s), attendu 1 : %+v", len(got.Unreadable), - got.Unreadable) - } - if got.Unreadable[0].Field != "pricing" { - t.Errorf("le bloc signalé est %q, attendu pricing", got.Unreadable[0].Field) - } - if got.Unreadable[0].Message == "" { - t.Error("le bloc est nommé sans dire pourquoi il n'a pas été lu") - } -} - -// TestRestoringABackupWithAnUnreadableBlockIsNotAMissingVersion is the RESTORE door. -// -// The backup is right there, listed on the screen one line above the button. « Introuvable » -// sends a volunteer looking for a file that exists; what is true is that it cannot be -// applied as it stands, which is what the validation branch beside it already answers. -func TestRestoringABackupWithAnUnreadableBlockIsNotAMissingVersion(t *testing.T) { - b, path, _ := benchOverADamagedFile(t, nil) - // .1 is a copy of the damaged file: a backup taken before somebody hand-edited it badly. - if err := os.WriteFile(path+".1", readRaw(t, path), 0o644); err != nil { - t.Fatalf("écriture de la sauvegarde : %v", err) - } - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - response := b.post("/admin/api/config/restore", `{"version":1}`) - - if response.StatusCode == http.StatusNotFound { - t.Fatal("une sauvegarde qui existe a été annoncée introuvable") - } - got := decodeStatus[problem](t, response, http.StatusUnprocessableEntity) - if !strings.Contains(got.Message, "pricing") { - t.Errorf("le refus ne nomme pas le bloc : %q", got.Message) - } - if len(got.Faults) == 0 { - t.Error("le refus ne porte pas la raison, que l'écran affiche champ par champ") - } -} - -// TestReloadingAFileWithAnUnreadableBlockIsRefusedByName is the PUT-IN-SERVICE door, and -// the one caller for which refusing is the whole right answer: the station would run the -// factory tariffs while its file declares the shop's. -func TestReloadingAFileWithAnUnreadableBlockIsRefusedByName(t *testing.T) { - b, _, _ := benchOverADamagedFile(t, nil) - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - response := b.post("/admin/api/config/reload", "") - - got := decodeStatus[problem](t, response, http.StatusUnprocessableEntity) - if !strings.Contains(got.Message, "pricing") { - t.Errorf("le refus ne nomme pas le bloc à ouvrir : %q", got.Message) - } - if b.hub.Config().Station.Coop != domain.NeutralProfile().Station.Coop { - t.Error("le poste s'est mis à tourner sur un fichier dont un bloc est celui d'usine") - } -} - -// TestSavingOverAFileWithAnUnreadableBlockKeepsTheCatalogPassword is the REWRITE door, and -// the trap is second-order: `served` is what the submitted document is compared against, -// and a read treated as a failure made it the configuration IN FORCE — the neutral profile, -// whose catalog carries no password. A save about anything at all then erased a producer's -// WebDAV account, silently. -func TestSavingOverAFileWithAnUnreadableBlockKeepsTheCatalogPassword(t *testing.T) { - const account = "s3cr3t-du-producteur" - b, path, shop := benchOverADamagedFile(t, func(cfg *domain.Config) { - cfg.Catalog.Options["password"] = json.RawMessage(`"` + account + `"`) - }) - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - // What the screen received, edited on one harmless field, and sent back. The password - // is never served, so it is never resubmitted: carriedOverSecret has to put it back. - served := decodeStatus[configDTO](t, b.get("/admin/api/config"), http.StatusOK) - var next domain.Config - if err := json.Unmarshal(served.Config, &next); err != nil { - t.Fatalf("charge illisible : %v", err) - } - next.Journal.MaxRows = shop.Journal.MaxRows + 100 - - response := b.do(http.MethodPut, "/admin/api/config", marshal(t, next), nil) - if response.StatusCode != http.StatusOK { - t.Fatalf("PUT = %d : %s", response.StatusCode, body(t, response)) - } - response.Body.Close() - - if !bytes.Contains(readRaw(t, path), []byte(account)) { - t.Error("le compte WebDAV du producteur a été effacé par un enregistrement qui ne " + - "le concernait pas") - } -} - -// TestAnUnconfirmedRestorationOverAnUnreadableBlockPutsTheShopsFileBack is the ROLLBACK -// door, and the one whose failure nobody is standing in front of. -// -// The restoration arms the sixty-second countdown of §11.4, and what the countdown writes -// back is FileBefore. Leaving it nil — which is what a read treated as a plain failure -// does — makes the rollback fall back on the configuration IN SERVICE, and on a station -// that started out of service that is the neutral profile. The shop's file is therefore -// overwritten with the factory one a full minute after the volunteer walked away, with -// nothing on any screen. -// -// Everything is real: a file on disk, a platform.ConfigStore, the station's own rollback. -func TestAnUnconfirmedRestorationOverAnUnreadableBlockPutsTheShopsFileBack(t *testing.T) { - b, path, shop := benchOverADamagedFile(t, nil) - // A backup that differs on the HARDWARE, so the restoration arms a countdown at all. - backup := reread(t, shop) - backup.Scale.Options["port"] = json.RawMessage(`"COM9"`) - writeRawConfig(t, path+".1", backup) - b.setPassword("mot-de-passe-long", "ABCD2345") - b.login("mot-de-passe-long") - - restored := decodeStatus[configDTO](t, - b.post("/admin/api/config/restore", `{"version":1}`), http.StatusOK) - if restored.Pending == nil { - t.Fatal("restaurer une version qui change le matériel n'arme aucun compte à rebours") - } - - // Nobody confirms. - b.advance(61 * time.Second) - written := awaitFileWithout(t, b, path, "COM9") - - if written.Station.Coop != shop.Station.Coop { - t.Errorf("station.coop = %q, attendu %q : le retour arrière a écrit le profil "+ - "d'usine sur le fichier du magasin, soixante secondes après", - written.Station.Coop, shop.Station.Coop) - } - if written.Catalog.Type != shop.Catalog.Type { - t.Errorf("catalog.type = %q, attendu %q : la source du catalogue a été remplacée "+ - "par le retour arrière", written.Catalog.Type, shop.Catalog.Type) - } - if written.Limits.BasketMin != shop.Limits.BasketMin { - t.Errorf("limits.basket_min = %v, attendu %v : les garde-fous ont été remplacés", - written.Limits.BasketMin, shop.Limits.BasketMin) - } -} - -// awaitFileWithout waits until the file on disk no longer carries the unconfirmed port, -// which is what says the rollback has run, and returns it decoded the way a station decodes -// it. -func awaitFileWithout(t *testing.T, b *bench, path, unconfirmedPort string) domain.Config { - t.Helper() - deadline := time.Now().Add(hang) - for time.Now().Before(deadline) { - // A transient read failure is EXPECTED here and is not the answer: §11.4 replaces - // the file by renaming a temporary over it, and on Windows that window is an open - // that fails. Polling through it is what makes this test about the rollback rather - // than about the atomic write beside it. - if raw, err := os.ReadFile(path); err == nil { - written, _ := domain.DecodeConfigBlockByBlock(raw) - if port, declared := written.Scale.Options.Text("port"); !declared || port != unconfirmedPort { - return written - } - } - b.clock.Advance(time.Second) - time.Sleep(time.Millisecond) - } - t.Fatal("le fichier porte encore la configuration non confirmée : le retour arrière ne " + - "l'a jamais réécrit, et le prochain démarrage repartirait dessus") - return domain.Config{} -} diff --git a/internal/web/configtransfer.go b/internal/web/configtransfer.go new file mode 100644 index 0000000..4aa65a5 --- /dev/null +++ b/internal/web/configtransfer.go @@ -0,0 +1,190 @@ +// This file holds HOW A CONFIGURATION TRAVELS: the export that seeds another +// station, the import that merges into this one, and the versions one can come back +// to. +// +// Export is the only read that stays PROTECTED, and §11.5 says why: it is the one +// payload that still carries the password hash. + +package web + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "openscale/internal/domain" + "openscale/internal/station" + "strconv" +) + +// exportConfig is GET /admin/api/config/export?hardware=0 — what §11.5 clones. +func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) { + includeHardware := r.URL.Query().Get("hardware") != "0" + cfg := s.hub.Config() + exported := cfg.Export(includeHardware) + // Config.Export drops the password hash always and the RECOVERY code hash only + // when the hardware is excluded. That asymmetry has no reason to exist here: the + // recovery code is printed on the installation sheet OF THIS STATION, and carrying + // it into a clone is precisely the « four stations sharing one secret nobody + // chose » that the same function refuses for the password. It is signalled to the + // domain; until then, this route redacts it. + exported.Admin.RecoveryCodeHash = "" + + raw, err := json.MarshalIndent(exported, "", " ") + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", "Export impossible : "+err.Error()) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="config-export.json"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(raw) +} + +// importConfig is POST /admin/api/config/import: it VALIDATES and returns the diff, +// and applies nothing. +// +// Applying is PUT, which the screen calls once a human has read the field-by-field +// diff of §14.4. An import that applied itself would be a station reconfigured by a +// file somebody double-clicked. +func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) + if err != nil { + writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) + return + } + var candidate domain.Config + if err := json.Unmarshal(raw, &candidate); err != nil { + writeProblem(w, http.StatusBadRequest, "", "Configuration illisible : "+err.Error()) + return + } + current := s.hub.Config() + // The two secrets and the station number are NOT imported: a clone must not + // inherit the password of the station it was cloned from, nor its number (§11.5). + candidate.Admin = current.Admin + candidate.Station.Number = current.Station.Number + + body := struct { + configDTO + Faults []faultDTO `json:"faults"` + Changed []string `json:"changed_blocks"` + }{ + configDTO: s.configPayload(candidate, nil), + Faults: faultsOf((&candidate).Validate(s.registries)), + Changed: changedBlocks(current, candidate), + } + writeJSON(w, http.StatusOK, body) +} + +// configVersions is GET /admin/api/config/versions. +func (s *Server) configVersions(w http.ResponseWriter, r *http.Request) { + if s.configStore == nil { + unavailable(w, "la configuration n'est pas versionnée ici") + return + } + versions, err := s.configStore.Versions(r.Context()) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", err.Error()) + return + } + out := make([]configVersionDTO, 0, len(versions)) + for _, v := range versions { + out = append(out, configVersionDTO{ + Version: v.Version, ModifiedAt: stamp(v.ModifiedAt), Fingerprint: v.Fingerprint, + }) + } + writeJSON(w, http.StatusOK, struct { + Versions []configVersionDTO `json:"versions"` + }{out}) +} + +// restoreRequest is the body of POST /admin/api/config/restore. +type restoreRequest struct { + Version int `json:"version"` +} + +// restoreConfig is POST /admin/api/config/restore: one of the five backups comes back +// into service, through the SAME path as any other save. +func (s *Server) restoreConfig(w http.ResponseWriter, r *http.Request) { + var body restoreRequest + if !decodeJSON(w, r, &body) { + return + } + if s.configStore == nil || s.controller == nil { + unavailable(w, "la configuration n'est pas versionnée ici") + return + } + // A restoration is a save like any other and it arms the same countdown, so it is + // refused inside the window for the same reason writeConfig is. + if deadline := s.controller.PendingConfirmation(); !deadline.IsZero() { + writeProblem(w, http.StatusConflict, "", + "Une configuration attend encore d'être confirmée. Confirmez-la, ou laissez le "+ + "poste revenir tout seul à la version précédente, puis restaurez de nouveau.") + return + } + restored, err := s.configStore.Restore(r.Context(), body.Version) + // A backup one of whose blocks did not decode EXISTS, and « introuvable » would send a + // volunteer looking for a file that is right there beside config.json — listed by + // Versions, on the screen, one line above the button they just pressed. It cannot be + // applied as it stands, which is the answer the Validate branch below already gives. + var unreadable *domain.UnreadableBlocksError + if errors.As(err, &unreadable) { + writeJSON(w, http.StatusUnprocessableEntity, problem{ + Code: "ERR-CFG-01", + Message: "La version " + strconv.Itoa(body.Version) + " existe, mais " + + unreadable.BlockPhrase() + " " + unreadable.NotRead() + " : la restaurer " + + "poserait la configuration d'usine " + unreadable.InTheirPlace() + ".", + Faults: faultsOf(unreadable.Faults), + }) + return + } + if err != nil { + writeProblem(w, http.StatusNotFound, "", + "Version "+strconv.Itoa(body.Version)+" introuvable : "+err.Error()) + return + } + current := s.hub.Config() + restored.Admin = current.Admin + + if faults := (&restored).Validate(s.registries); len(faults) > 0 { + writeJSON(w, http.StatusUnprocessableEntity, problem{ + Code: "ERR-CFG-01", + Message: "Cette version ne peut plus être appliquée telle quelle.", + Faults: faultsOf(faults), + }) + return + } + // READ BEFORE THE WRITE, and for the reason writeConfig reads it: restoring a version + // arms the same countdown, and a rollback that put the RUNNING configuration back would + // write the factory profile onto the file of a station that started out of service. + // This is the route that reaches it — writeConfig already refuses a station whose file + // carries a retired key, and this one does not. + // + // A file only PART of which decoded is still a better rollback target than memory: its + // read blocks are the shop's. Leaving FileBefore nil there is what made the countdown + // write the neutral profile onto the file sixty seconds later, unattended. + fileBefore, fileErr := s.configStore.Read(r.Context()) + var unreadableBefore *domain.UnreadableBlocksError + if errors.As(fileErr, &unreadableBefore) { + fileBefore, fileErr = unreadableBefore.Config, nil + } + + if err := s.configStore.Save(r.Context(), restored); err != nil { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration non écrite : "+err.Error()) + return + } + reload := station.ReloadRequest{Next: restored} + if fileErr == nil { + reload.FileBefore = &fileBefore + } + outcome, err := s.controller.Reload(reload) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration écrite mais non appliquée : "+err.Error()) + return + } + s.moveListener(current, restored, outcome.ConfirmBefore) + writeJSON(w, http.StatusOK, s.configPayload(restored, + s.confirmationOf(outcome.Changed, outcome.ConfirmBefore))) +} diff --git a/internal/web/configwrite.go b/internal/web/configwrite.go new file mode 100644 index 0000000..439a1f6 --- /dev/null +++ b/internal/web/configwrite.go @@ -0,0 +1,306 @@ +// This file holds THE THREE-STEP WINDOW of §11.4: a configuration is written, the +// blocks that really changed are reloaded, and the operator has to CONFIRM before +// the old one is dropped. +// +// Without that window, saving a listening address from a browser would cut the very +// connection that has to confirm it. moveListener is where that is handled, and it +// is the reason the window exists at all. + +package web + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "openscale/internal/domain" + "openscale/internal/station" + "time" +) + +// writeConfig is PUT /admin/api/config — the five steps of §11.4, in order. +// +// 1. json.Unmarshal → 400 when it is not a document +// 2. Config.Validate → 422 with EVERY fault at once +// 3. rotation and atomic write → the ConfigStore's business +// 4. Station.Reload → restarts only the blocks that moved +// 5. hardware or listen moved → the 60-second countdown, with automatic rollback +// +// # Why the secrets come back from the configuration in force +// +// The screen never receives them, so it cannot send them back. Taking them from the +// running configuration is what lets a save of the edited document leave the password +// alone instead of erasing it. +func (s *Server) writeConfig(w http.ResponseWriter, r *http.Request) { + if s.configStore == nil || s.controller == nil { + unavailable(w, "la configuration n'est pas modifiable ici") + return + } + + // A second save INSIDE the window is refused, exactly as a confirmation outside it is. + // The write of step 3 happens before the countdown of step 5, so accepting one would + // move the file the rollback aims at onto a version nobody confirmed either — and the + // one somebody really did validate would be the version lost. + if deadline := s.controller.PendingConfirmation(); !deadline.IsZero() { + writeProblem(w, http.StatusConflict, "", + "Une configuration attend encore d'être confirmée. Confirmez-la, ou laissez le "+ + "poste revenir tout seul à la version précédente, puis enregistrez de nouveau.") + return + } + + // The document GET serves is the DECODED Go structure, re-marshalled: a retired key + // never survives that round trip, because encoding/json drops what no field claims + // (§11.3, `configPayload`). A PUT of exactly what GET served can therefore never + // re-declare `coef_num` or `coef_den`, and control 20 on the SUBMITTED document -- + // `next`, below -- finds nothing to refuse: the save would silently rewrite the file + // with MEMBER at 0 %. What is asked here is the FILE itself, read the same way + // `readConfig` reads it, which still carries whatever nobody repaired. Refusing the + // write is the same reasoning control 20 already applies to an upload that names a + // retired key outright, extended to a key already sitting on disk (ADR-034): + // repairing the file is done IN the file, not by laundering it through this screen. + // + // A file only PART of which decoded still answers both questions this block asks: its + // READ blocks are the shop's own, and a retired key sitting in one of them is still + // there. Treating it as « unreadable » skipped the guard entirely, and skipped + // `served` below with it — so the catalog password was taken from the neutral profile, + // which has none, and a save about the polling interval erased a producer's account. + onDisk, onDiskErr := s.configStore.Read(r.Context()) + var unreadable *domain.UnreadableBlocksError + if errors.As(onDiskErr, &unreadable) { + onDisk, onDiskErr = unreadable.Config, nil + } + if onDiskErr == nil { + if faults := retiredFaultsOf(onDisk, s.registries); len(faults) > 0 { + writeJSON(w, http.StatusUnprocessableEntity, problem{ + Code: "ERR-CFG-01", + Message: "Le fichier de configuration en service porte encore une clé que ce " + + "binaire refuse. L'administration ne peut pas la corriger : changez-la " + + "dans le fichier de configuration lui-même.", + Faults: faults, + }) + return + } + } + + raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) + if err != nil { + writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) + return + } + var next domain.Config + if err := json.Unmarshal(raw, &next); err != nil { + writeProblem(w, http.StatusBadRequest, "", "Configuration illisible : "+err.Error()) + return + } + + current := s.hub.Config() + next.Admin.PasswordHash = current.Admin.PasswordHash + next.Admin.RecoveryCodeHash = current.Admin.RecoveryCodeHash + next.ModifiedAt = s.clock.Now() + + // served is the document the screen was GIVEN and edited, which is what a submission + // has to be compared against: the FILE, as readConfig serves it, and only what is + // running when no file could be read. + // + // The distinction is the difference between repairing a station and destroying it. A + // station that started out of service runs the NEUTRAL profile, whose catalog carries + // no password at all (serve.go, fallbackProfile): taking the secret back from what is + // running would erase the cooperative's WebDAV account on the very save that repaired + // the file. The two hashes above escape that trap only because the fallback profile + // copies Admin over by hand. + served := current + if onDiskErr == nil { + served = onDisk + } + next.Catalog.Options = carriedOverSecret(served.Catalog, next.Catalog) + + // The drop probe touches the filesystem, so it runs only when the block it is about has + // MOVED: a save about the weighing thresholds must not fail because a producer's share + // happens to be down that morning. The decision belongs here, to the layer that holds + // both versions; the execution stays in the domain (§11.3, control 46). + registries := s.registries + if registries.Paths != nil && + domain.BlockFingerprint(served.Catalog) == domain.BlockFingerprint(next.Catalog) { + registries.Paths = readOnlyPaths{inner: registries.Paths} + } + + if faults := (&next).Validate(registries); len(faults) > 0 { + writeJSON(w, http.StatusUnprocessableEntity, problem{ + Code: "ERR-CFG-01", + Message: "Cette configuration ne peut pas être appliquée.", + Faults: faultsOf(faults), + }) + return + } + if err := s.configStore.Save(r.Context(), next); err != nil { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration non écrite : "+err.Error()) + return + } + + // The rollback of §11.4 puts THE FILE AS IT WAS back, and `onDisk` is that document: it + // was read above, before the Save, and it is the same variable `served` already stands + // for. Handing it over is what keeps a station whose file is faulty — and which + // therefore RUNS the neutral profile — from having the factory settings written over + // its own file sixty seconds after a volunteer repaired it. + // + // A file that could not be read hands over nothing, and the station falls back on what + // it is running: that is all such a station has left. + reload := station.ReloadRequest{Next: next} + if onDiskErr == nil { + reload.FileBefore = &onDisk + } + outcome, err := s.controller.Reload(reload) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration écrite mais non appliquée : "+err.Error()) + return + } + s.technical.Technical(domain.LevelInfo, "config", "", + "Configuration enregistrée.", next.Fingerprint()) + + s.moveListener(current, next, outcome.ConfirmBefore) + writeJSON(w, http.StatusOK, s.configPayload(next, + s.confirmationOf(outcome.Changed, outcome.ConfirmBefore))) +} + +// carriedOverSecret puts the catalog password back when the submitted document carries +// none, and leaves a typed one alone. +// +// The screen never received it — configPayload blanks it — so it cannot send it back. This +// is the same reasoning as the two hashes of writeConfig and the same repair: without it, a +// save about the polling interval would take the catalog down at the next poll, silently. +// +// A password can therefore not be EMPTIED from this screen. That is the price of a +// write-only field, and it is paid where every other irreversible repair is paid: in the +// file itself (ADR-034). +// +// # What says « this share really has no password », and what does not +// +// It is the SOURCE that says it, never the shape of the key. A blank value and an absent +// key are two spellings of the same silence, and a browser produces the second without +// anybody meaning to: the Station page copies an imported file into the draft, the export +// it came from carries no password at all (Config.Export deletes it whatever `hardware` +// says), and JSON.stringify drops a property whose value is undefined. Reading that as a +// deletion erased the cooperative's WebDAV account through Importer → Recopier → +// Enregistrer, on a save about something else entirely. +func carriedOverSecret(served, submitted domain.CatalogConfig) domain.DriverOptions { + // Changing the SOURCE is the one gesture that legitimately drops the account: the + // Catalogue screen deletes the url, the user and the password when somebody moves the + // station to a local directory, because control 39 refuses their mere presence there. + // Writing the secret back would answer that move with a refusal on a field the screen no + // longer shows, and no screen could ever repair it. + if submitted.Type != served.Type { + return submitted.Options + } + if typed, ok := submitted.Options.Text(catalogPasswordOption); ok && typed != "" { + return submitted.Options + } + inForce, ok := served.Options.Text(catalogPasswordOption) + if !ok || inForce == "" { + return submitted.Options + } + return submitted.Options.WithText(catalogPasswordOption, inForce) +} + +// readOnlyPaths answers every DROP question with "nothing to check". +// +// It is what says « this save is not about the catalog ». The READ question of control 44 +// still travels, because it is about another key entirely and costs one stat. +// +// inner is never nil: writeConfig wraps a probe that exists, and wraps nothing otherwise — +// a nil PathChecker already means « we cannot know » to every control. +type readOnlyPaths struct{ inner domain.PathChecker } + +func (p readOnlyPaths) Readable(path string) error { return p.inner.Readable(path) } + +func (readOnlyPaths) Droppable(string) error { return nil } + +// confirmationOf renders the countdown, or nothing when there is none. +func (s *Server) confirmationOf(changed []string, before time.Time) *confirmationDTO { + if before.IsZero() { + return nil + } + left := before.Sub(s.clock.Now()) + if left < 0 { + left = 0 + } + return &confirmationDTO{ + Changed: changed, ConfirmBefore: stamp(before), + SecondsLeft: int(left.Seconds()), + } +} + +// confirmConfig is POST /admin/api/config/confirm: the branch is not being cut. +// +// It confirms BOTH halves — the station stops its countdown, and the socket stops +// waiting to be put back. They are two objects and one decision. +func (s *Server) confirmConfig(w http.ResponseWriter, _ *http.Request) { + if s.controller == nil { + unavailable(w, "la configuration n'est pas modifiable ici") + return + } + if err := s.controller.Confirm(); err != nil { + writeProblem(w, http.StatusConflict, "", "Aucune confirmation n'est attendue.") + return + } + if s.binder != nil { + s.binder.Confirm() + } + writeJSON(w, http.StatusOK, actionDTO{ + Done: true, Message: "La configuration est confirmée."}) +} + +// changedBlocks names the blocks two configurations disagree about, by the SAME +// normalized fingerprint the station compares (§11.4). +// +// Normalized, and not a byte comparison: two documents that differ only by the order +// of their keys describe the same station, and cutting a serial port over a key order +// is the failure mode this comparison exists to avoid. +func changedBlocks(previous, next domain.Config) []string { + var changed []string + blocks := []struct { + name string + before, after any + }{ + {"station", previous.Station, next.Station}, + {"network", previous.Network, next.Network}, + {"ui", previous.UI, next.UI}, + {"scale", previous.Scale, next.Scale}, + {"printer", previous.Printer, next.Printer}, + {"pricing", previous.Pricing, next.Pricing}, + {"barcode", previous.Barcode, next.Barcode}, + {"limits", previous.Limits, next.Limits}, + {"stability", previous.Stability, next.Stability}, + {"catalog", previous.Catalog, next.Catalog}, + {"journal", previous.Journal, next.Journal}, + {"maintenance", previous.Maintenance, next.Maintenance}, + } + for _, b := range blocks { + if domain.BlockFingerprint(b.before) != domain.BlockFingerprint(b.after) { + changed = append(changed, b.name) + } + } + return changed +} + +// moveListener applies a change of network.listen to the socket (§11.4, ADR-027). +// +// A net.Listener closes and reopens in three lines: there has never been a technical +// reason to demand a process restart for it. It goes through the same three-step +// window as the hardware — apply, count down, roll back if nobody confirms — and the +// station's own countdown puts the CONFIGURATION back at the same instant this puts +// the SOCKET back. +func (s *Server) moveListener(previous, next domain.Config, confirmBefore time.Time) { + if s.binder == nil || previous.Network.Listen == next.Network.Listen { + return + } + if err := s.binder.Rebind(next.Network.Listen, confirmBefore); err != nil { + s.technical.Technical(domain.LevelError, "http", "ERR-SYS-02", + "Nouvelle adresse d'écoute refusée : l'ancienne reste en service.", err.Error()) + return + } + s.technical.Technical(domain.LevelWarn, "http", "", + "Adresse d'écoute changée : confirmez sous 60 s ou elle reviendra.", + previous.Network.Listen+" → "+next.Network.Listen) +} diff --git a/internal/web/contracts.go b/internal/web/contracts.go new file mode 100644 index 0000000..35b692a --- /dev/null +++ b/internal/web/contracts.go @@ -0,0 +1,318 @@ +// This file holds WHAT THE HTTP LAYER NEEDS FROM THE REST OF THE STATION, and +// nothing it provides. +// +// Every interface here is declared ON THE CONSUMER'S SIDE: the real components +// satisfy them as they stand, and a test drives the routes with doubles that start +// no goroutine and open no port. That is what makes this package testable without +// a station, and what keeps §5.2 true -- no arrow leaves the domain, and the ones +// that arrive here are named here. + +package web + +import ( + "context" + "io" + "openscale/internal/domain" + "openscale/internal/station" + "openscale/internal/update" + "time" +) + +// Hub is what the HTTP layer needs from the single decision-making goroutine. +// +// Declared HERE, on the consumer's side: *station.Hub satisfies it as it stands, +// and a test drives the routes with a double that never starts a goroutine. +type Hub interface { + // State returns the last published snapshot, without blocking. + State() station.Snapshot + // Submit hands one command to the loop and waits for its answer, on ctx. + Submit(ctx context.Context, ev domain.Event, key string) (domain.Ack, error) + // Subscribe returns the snapshot channel of one subscriber and its unsubscribe. + Subscribe() (<-chan station.Snapshot, func()) + // Config returns the configuration in force. + Config() domain.Config + // Catalog returns the catalog in service, or nil before the first one. + Catalog() *domain.Catalog + // CatalogUpdatedAt returns when that catalog entered service, or the zero time. + CatalogUpdatedAt() time.Time + // DowntimeGuard reports whether the station may be taken down, and says in French + // why not when it may not. + // + // The rule belongs to the station and is asked, never deduced: an HTTP layer that + // read a state to conclude « somebody is weighing » would hold a second copy of a + // rule that already has an owner. + DowntimeGuard() (bool, string) +} + +// Controller is what the HTTP layer needs from the station AROUND the loop: the +// liveness of §14.5 and the hot reload of §11.4. +type Controller interface { + // Alive reports that the Hub loop is publishing. + Alive() bool + // Reload publishes a new configuration and restarts what actually changed. + // + // The request carries the FILE as it was before the change, and not only the new + // configuration, because a rollback has two documents to put back: the station goes + // back to what it was running, the file to what it carried. On the one station §11.3 + // serves — a faulty file, the neutral profile in memory — those are not the same + // document, and handing over only one wrote the factory profile onto the shop's file. + Reload(req station.ReloadRequest) (station.ReloadOutcome, error) + // Confirm accepts the configuration in force and stops the 60 s countdown. + Confirm() error + // PendingConfirmation reports the end of the countdown still running, or the zero time. + PendingConfirmation() time.Time +} + +// Store is the persistence as the administration screens read it. +// +// It is declared HERE and not imported: internal/web knows no database package +// (§5.2). cmd/openscale adapts *store.DB to it, which is a handful of lines and the +// price of the cut. +type Store interface { + // Weighings returns one page of the journal, most recent first. + Weighings(ctx context.Context, q JournalQuery) ([]domain.Weighing, error) + // CountWeighings reports how many rows the journal holds. + CountWeighings(ctx context.Context) (int, error) + // TechnicalEntries returns one page of the technical journal. + TechnicalEntries(ctx context.Context, q TechnicalQuery) ([]TechnicalLine, error) + // Imports returns the history of catalog imports, most recent first. + Imports(ctx context.Context, limit, offset int) ([]domain.Import, error) + // LastAppliedImport returns the most recent import that PUT A CATALOG IN SERVICE. + // + // It is not the same question as the first row of Imports: 'unchanged', 'rejected' + // and 'failed' are rows too, and none of them changed what the station serves. The + // error is « this station has never applied one » and carries no sentinel: every + // caller here treats it as an absence. + LastAppliedImport(ctx context.Context) (domain.Import, error) + // Findings returns what one import had to say about the rows it read. + Findings(ctx context.Context, importID int64) ([]domain.Finding, error) + // LocalDecisions returns the human judgements in force (§10.6). + LocalDecisions(ctx context.Context) ([]domain.LocalDecision, error) + // SaveDecision records one human judgement about one product. + SaveDecision(ctx context.Context, d domain.LocalDecision) error + // ClearDecision removes the judgement about one product. + ClearDecision(ctx context.Context, productID string) error + // Image returns the metadata of one photo, addressed by its content. + Image(ctx context.Context, sha string) (domain.Image, error) +} + +// ConfigStore is the configuration FILE, with its five rotating versions (§11.4). +type ConfigStore interface { + // Read returns the configuration AS IT STANDS ON DISK, which is not always the one + // in force. + // + // The difference is the whole reason this method is on the interface. A station that + // started out of service runs the NEUTRAL PROFILE (§11.3) while the file keeps the + // shop's settings and the faults that put it there; a station that fell back to + // manual entry runs something else again (§11.4). What the expert pages edit, and + // what a rescue writes back, has to be « ce que l'exploitant a demandé » — otherwise + // the first save replaces the tariffs, the safeguards and the categories of a + // cooperative with the factory ones. + Read(ctx context.Context) (domain.Config, error) + // Save rotates the versions and writes atomically: tmp, fsync, rename. + Save(ctx context.Context, cfg domain.Config) error + // Versions lists the restorable versions, most recent first. + Versions(ctx context.Context) ([]ConfigVersion, error) + // Restore reads back one version WITHOUT applying it. + Restore(ctx context.Context, version int) (domain.Config, error) +} + +// CatalogAdmin is the catalog source as the administration screen acts on it. +type CatalogAdmin interface { + // Reload asks the source for a fresh batch now, and reports IN FRENCH what it saw of + // the file it watches. + // + // That sentence is the ONE fact the watch never produces: it polls, finds nothing and + // returns in silence, so « Recharger le catalogue » used to be followed by nothing at + // all. It is EMPTY when the source watches no file of this machine — a share is + // watched over the network — because an absence nobody checked must not be asserted. + Reload(ctx context.Context) (string, error) + // Import takes a CSV dropped on the screen and writes it where the ordinary + // watcher will find it — same parser, same qualification (A4, ADR-011). + Import(ctx context.Context, name string, r io.Reader) (domain.Import, error) + // ForgetQuarantine clears the memory of the files that were refused. + ForgetQuarantine(ctx context.Context) error +} + +// Hardware answers the « what is actually plugged in? » questions of the expert +// screens (§14.4). Every method is platform-specific, which is why none of them +// lives here. +type Hardware interface { + // Ports enumerates the serial ports, with their USB description. + Ports(ctx context.Context) ([]PortInfo, error) + // Printers enumerates the print queues the platform knows about. + Printers(ctx context.Context) ([]PrinterInfo, error) + // DiscoverPrinters looks for a label printer beyond the declared queues. + DiscoverPrinters(ctx context.Context) ([]PrinterInfo, error) + // DetectScale opens one port, applies the parsers and says what answered. + DetectScale(ctx context.Context, port string) (ScaleDetection, error) + // CaptureFrames records raw frames from one port for a bounded duration. + CaptureFrames(ctx context.Context, port string, d time.Duration) ([]string, error) + // LabelPreview renders the label as a PNG, identical to what would print (A2). + LabelPreview(ctx context.Context, q PreviewQuery) ([]byte, error) + // Replay pushes one recorded frame back through the decoder (§14.4, Journal). + Replay(ctx context.Context, frame string) error +} + +// Diagnostician writes diagnostic.zip (§15.4). +// +// It is its OWN interface and not a method of Hardware, for two reasons that both matter. +// The archive is not a platform question — internal/diag builds it out of the configuration, +// the journal and the fifteen controls — and its route is the one route of this group that +// carries NO password: §15.4 gives it « un seul bouton, sans mot de passe » because it is the +// only realistic remote support mechanism for a team of volunteers. Grouping it with the +// expert hardware calls would make one nil collaborator disable both. +type Diagnostician interface { + // Diagnostic writes the archive into w. It never returns before the archive is complete + // or the reason it is not is recorded inside it. + Diagnostic(ctx context.Context, w io.Writer) error +} + +// Dashboard answers the four questions of §14.4 that no HTTP layer can put to itself: +// how far the roll has gone, how much room is left on the disk, whether this machine +// comes back on its own after a power cut, and what the catalog source is watching. +// +// It is one collaborator and not four because it has one caller — the dashboard route — +// and because the composition root holds all four in the same hand: the print service, +// the data directory, the platform and the source in service. Nil leaves the four facts +// out of the payload, and the screen SAYS what it cannot see. +type Dashboard interface { + // Dashboard reports what it could establish. Every field is optional and its absence + // is the honest answer: nothing here is worth a 500 on the one page a volunteer opens + // when the station is already broken. + Dashboard(ctx context.Context) DashboardFacts +} + +// DashboardFacts is what only the composition root knows. +type DashboardFacts struct { + Roll *RollGauge + Disk *DiskSpace + Restart *RestartReadiness + Source *CatalogSourceState + Routing *PrintRouting +} + +// PrintRouting is which printer the labels are coming out of. +// +// Available is what decides whether the troubleshooting page offers « Imprimer sur +// l'imprimante du poste N » (§14.4) — a button offered on a station with no fallback +// configured would be a button that answers 501 to somebody already in trouble. +type PrintRouting struct { + Available bool + OnFallback bool + // Name is the FRENCH name of the printer in use, and Banner the permanent line of + // §8.4 — both come from the print service, which is where the wording belongs. + Name string + Banner string +} + +// RollGauge is the label counter of §8.5 as the « rouleau » light reads it. +// +// The wording travels WITH the numbers, because it is printing.RollCounter that knows +// when « environ 100 étiquettes restantes » becomes « le rouleau est probablement fini », +// and a screen that recomputed the sentence from the numbers would be a second opinion on +// a threshold that already has an owner. +type RollGauge struct { + Printed int64 + Capacity int + // Remaining CAN be negative: a roll that held more than the configured capacity, or + // one changed without anybody saying so, which is the ordinary case (§8.5). + Remaining int64 + Level string + Message string + Known bool +} + +// DiskSpace is the room left on the volume the station writes to. +type DiskSpace struct { + Path string + FreeBytes int64 + TotalBytes int64 +} + +// RestartReadiness is bloquant-7: after a power cut, does this station come back to the +// client screen without anybody typing a Windows password? +type RestartReadiness struct { + Configured bool + // Known is false when the question could not be put to the system at all. + Known bool + Detail string + Remedy string +} + +// CatalogSourceState is the permanent catalog line of §14.4: the source, the path or the +// URL watched, and the account used. +type CatalogSourceState struct { + Type string + Label string +} + +// SelfTester prints one of the three built-in patterns (§8.6). ports.Printer +// satisfies it, and that is the only reason it is one method wide. +type SelfTester interface { + // SelfTest prints "label", "alignment" or "ruler". + SelfTest(ctx context.Context, what string) error +} + +// Troubleshooting is what the repair buttons of §14.4 act on and that nothing else in +// this package can reach. +// +// None of the three writes the configuration file: manual entry is a STATE the station +// enters, the roll counter is a counter, and the fallback printer is a route for the +// current session. That was the criterion of ADR-018, and it is no longer the one that +// decides the door — ADR-033 asks what an act CHANGES. Two of the three stay open, and +// ManualEntry is authenticated: it cuts the scale out and lets the customer type their own +// weight. The route table below is where that is settled, not this interface. +type Troubleshooting interface { + // ManualEntry switches the station into, or out of, manual weight entry. + ManualEntry(ctx context.Context, on bool) error + // RollChanged resets the label counter of the roll (§8.5). + RollChanged(ctx context.Context) error + // UseFallbackPrinter routes printing to the neighbouring station's printer. + UseFallbackPrinter(ctx context.Context, on bool) error +} + +// Updater is what the HTTP layer needs to move the station to a newer release. +// +// Declared here, on the consumer's side; *update.Service satisfies it. Nil answers +// 501 on the act and « not supported » on the read, which is what a Linux station +// honestly is: hiding the routes would leave a screen guessing, and a button doing +// nothing would be worse than none. +type Updater interface { + // Status answers the screen from what is on disk, without polling. + Status(repository string) (update.Status, error) + // Check polls the repository now and records what it found. + Check(ctx context.Context, repository string) (update.Check, error) + // Apply brings the wanted version down and hands the swap over. It returns + // as soon as the swap has STARTED: what finishes it also stops this process. + Apply(ctx context.Context, repository, wanted string) error +} + +// Restarter stops the station so that its supervisor starts it again. +// +// Declared here, on the consumer's side; *stationRestarter of cmd/openscale satisfies +// it. NIL MEANS « nobody would relaunch it », and the route then answers 501 instead of +// stopping a station that would stay down — which is what `openscale serve` typed into +// a terminal is. +// +// This is the route ADR-027 removed, and it is not that route. What the ADR refuses is +// a restart DEMANDED BY A SETTING: no configuration block may ask for one, and none +// does. This one is a repair, and it goes through the only restart that ADR calls +// legitimate — the one the SCM or systemd triggers on its own. +type Restarter interface { + // Restart asks the station to stop. It returns as soon as the demand is recorded, + // because what carries it out also ends this process, and a *station.DowntimeRefused + // when the station must not be taken down right now. + Restart() error +} + +// Rebooter restarts THE MACHINE. +// +// Declared here, on the consumer's side; platform.Reboot satisfies it once adapted. Nil +// answers 501: a station whose platform cannot restart must say so rather than offer a +// button that fails at the last click, and « ce poste ne sait pas faire » is a different +// piece of news from « ça n'a pas marché ». +type Rebooter interface { + // Reboot restarts the machine. It returns as soon as the demand is accepted. + Reboot() error +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go new file mode 100644 index 0000000..f603ff8 --- /dev/null +++ b/internal/web/dashboard.go @@ -0,0 +1,461 @@ +// This file holds THE ONE SCREEN A VOLUNTEER LOOKS AT when something is wrong: the +// hardware, the print routing, the roll, the disk, the catalogue in force and the +// findings it raised. +// +// It gathers from three places that can each be slow or absent -- the platform, the +// store, the hub -- and none of them is allowed to hold the page: every fill is +// bounded, and a source that does not answer leaves its block empty rather than +// taking the screen down with it. + +package web + +import ( + "context" + "net/http" + "openscale/internal/domain" + "sort" +) + +// adminHealthDTO is the dashboard of §14.4: the lights, the cadence, the inventory, +// and the two figures a volunteer reads out over the telephone. +type adminHealthDTO struct { + Version string `json:"version"` + Fingerprint string `json:"config_fingerprint"` + Station int `json:"station"` + StationName string `json:"station_name"` + Coop string `json:"coop"` + Alive bool `json:"alive"` + State stateDTO `json:"state"` + // ScalePresent carries the declaration of §11.2 so that the screen can turn the + // scale light OFF instead of drawing it red on a station that has no scale. Without + // it the screen would have to read the configuration, which needs a password. + ScalePresent bool `json:"scale_present"` + // PrinterSelfTests are the patterns of §8.6 the driver IN SERVICE honours, by the name + // the self-test route takes: "label", "alignment", "ruler". + // + // It travels HERE and not in the snapshot, for the reason the field above travels + // here: it is a DECLARATION about how this station is set up, not something the + // supervisor observed, and it changes only when a configuration is reloaded. The + // snapshot goes out ten times a second to a screen that has no self-test button on it. + // + // What it buys is one screen telling the truth. The Matériel page drew all three + // buttons whatever the driver, and on `preview` two of them answered a refusal on the + // click — in front of somebody already looking for why nothing prints. A button whose + // only possible answer is a refusal is not a choice (ADR-025). + // + // It is a LIST AND NEVER `null`, like every list of §14.5: the TypeScript contract + // declares an array and the page filters it the instant it has read it. + PrinterSelfTests []string `json:"printer_self_tests"` + // PrinterTransports are the byte transports THIS BINARY carries (§8.4), each with the + // wording a volunteer reads and the printer.options key it designates its device by. + // + // It travels for the reason the field above does, and it answers the same kind of + // question one notch further along: not « which button may I draw » but « where does + // what somebody types in this box get written ». The Matériel screen had no answer at + // all — `transport` was a free text box, and the single device field under it was wired + // to `queue` whatever was typed above. A station set to `tcp` therefore saved its + // printer's address into printer.options.queue, which validates and cannot print. + // + // The list comes from the registry and never from a table in the screen, exactly as + // control 8 of Config.Validate reads the same registry to refuse an unknown name: a + // fifth transport must not be able to exist for the validation and not for the form. + // + // EMPTY on a server built with no transport registry — the HTTP bench of this package — + // and never `null`, like every list of §14.5. + PrinterTransports []transportDTO `json:"printer_transports"` + + Counters countersDTO `json:"counters"` + // Events is the ten last technical lines, which is what §14.4 puts on the + // dashboard. Empty when this station has no journal wired. + Events []technicalLineDTO `json:"events"` + // Catalog is the one-line inventory of the last import. + Catalog *importDTO `json:"catalog"` + // CatalogFindings names the import whose findings DESCRIBE THE CATALOG IN SERVICE, + // and it is not always the one above. Zero when there is none to read. + // + // A byte-identical export dropped a second night is recorded 'unchanged' and writes no + // finding of its own — they belong to the import that produced the grid, one row above + // (importer.unchanged) — and a batch the database refused writes none either. Reading + // the last row emptied every list of the Catalogue page on the most ordinary event + // there is, while its counters kept announcing sixteen anomalies to correct. + CatalogFindings int64 `json:"catalog_findings_id"` + // CatalogMotives breaks the « non pesables » figure down by motive, because that is + // how §14.4 writes the line: « 8 non pesables — préemballés (7), code interne 0490 + // (1) ». Empty when there is no import to read it from. + CatalogMotives []motiveDTO `json:"catalog_motives"` + // CatalogSource is the permanent line of §14.4: the source, the path or the URL + // watched, and the account used. Nil when nothing published it. + CatalogSource *catalogSourceDTO `json:"catalog_source"` + // Decisions are the human judgements in force, with their reason and their date. + Decisions []decisionDTO `json:"decisions"` + + // Roll is the third light. Nil means « nothing counts labels on this station », which + // the screen says as such: a light drawn green for want of an answer would be the + // worst of the three possible outcomes. + Roll *rollDTO `json:"roll"` + // Disk is the fifth light, with the threshold beside the measurement (§10.4, §14.4). + Disk *diskDTO `json:"disk"` + // Restart is « redémarrage sans intervention : OK / NON CONFIGURÉ » (bloquant-7). + Restart *restartDTO `json:"unattended_restart"` + // Routing says which printer the labels come out of, and whether a fallback exists at + // all: that is what decides whether the troubleshooting page offers « Imprimer sur + // l'imprimante du poste N » (§14.4, §8.4). + Routing *routingDTO `json:"printing"` + // NewVersion is the version published that is newer than the one running, or the + // empty string. + // + // It travels HERE, in the payload the dashboard already reads, and not on a + // route of its own: the volunteer page opens without a password and calls + // exactly one route, which is a property a test holds. A second call from that + // page would have widened, for a courtesy, what an unauthenticated screen does. + // + // It is read from the last poll left on disk, never by asking the repository: + // this handler answers every three seconds. + NewVersion string `json:"new_version"` +} + +// transportDTO is one byte transport a volunteer may choose from, and where choosing it +// sends what they type next. +type transportDTO struct { + // ID is the value that goes into printer.options.transport: "winspool", "devfile", + // "tcp", "file". + ID string `json:"id"` + // Label is the French wording of the drop-down list: « Imprimante réseau, port 9100 ». + Label string `json:"label"` + // Key is the printer.options key this transport DESIGNATES ITS DEVICE by, and the one + // the screen writes the device field into: "queue", "path" or "address". + Key string `json:"key"` +} + +// routingDTO is which printer is in service. +type routingDTO struct { + FallbackAvailable bool `json:"fallback_available"` + OnFallback bool `json:"on_fallback"` + Name string `json:"name"` + Banner string `json:"banner"` +} + +// motiveDTO counts the rows of one import that share one motive. +// +// It carries the CODE and not a sentence: the screen writes « préemballés (7) » and the +// expert page shows the whole finding, and both then say the same thing about the same +// rows without this payload having to choose a wording for them. +type motiveDTO struct { + Code string `json:"code"` + // Value is the four-digit prefix when the motive is one, so that « code interne 0490 » + // names the number somebody has to correct in Odoo and not a category of number. + Value string `json:"value"` + Count int `json:"count"` +} + +// catalogSourceDTO is what feeds the catalog line of the dashboard. +type catalogSourceDTO struct { + Type string `json:"type"` + // Label is FRENCH and comes from the source itself — « dépôt local, flv_2.csv dans + // C:\ProgramData\OpenScale\catalog\incoming », « WebDAV, https://… (compte odoo) ». + Label string `json:"label"` +} + +// rollDTO is the label counter of §8.5 as the « rouleau » light reads it. +type rollDTO struct { + Printed int64 `json:"printed_count"` + Capacity int `json:"capacity_count"` + Remaining int64 `json:"remaining_count"` + // Level is "info" or "warn" and NEVER "error": a roll about to run out is a + // maintenance job, not a breakdown (§8.5). + Level string `json:"level"` + Message string `json:"message"` + // Known reports whether the counter has ever been written. A station installed this + // morning has no counter, and « environ 1000 étiquettes restantes » about a roll + // nobody described would be a number invented on the spot. + Known bool `json:"known"` +} + +// diskDTO is the room left where this station writes. +type diskDTO struct { + Path string `json:"path"` + FreeBytes int64 `json:"free_bytes"` + TotalBytes int64 `json:"total_bytes"` + // AlertMB is maintenance.disk_alert_mb, sent BESIDE the measurement so that a + // threshold with no relation to reality is visible at a glance (§10.4, §14.4). + AlertMB int `json:"alert_mb"` +} + +// restartDTO is bloquant-7 on the dashboard, and it is the same verdict `openscale +// doctor` gives at its third control (§15.4). +type restartDTO struct { + Configured bool `json:"configured"` + // Known is false when the system could not be asked. « Je ne sais pas » and « non + // configuré » call for two different gestures. + Known bool `json:"known"` + // Detail and Remedy are FRENCH. Remedy is what makes the amber line actionable. + Detail string `json:"detail"` + Remedy string `json:"remedy"` +} + +// countersDTO is what the station counts about itself. +type countersDTO struct { + // Unlogged is the counter of ADR-013, and the only one that is a RED light. + Unlogged int64 `json:"unlogged_weighings_count"` + // Journal is how many rows the journal holds, or -1 when there is no journal. + Journal int `json:"journal_rows_count"` +} + +// adminHealth is GET /admin/api/health, NOT authenticated (ADR-018): it reads, it +// writes nothing, and a volunteer in front of a mute station has to be able to open +// it. +func (s *Server) adminHealth(w http.ResponseWriter, r *http.Request) { + cfg := s.hub.Config() + snap := s.hub.State() + body := adminHealthDTO{ + Version: s.version, + Fingerprint: cfg.Fingerprint(), + Station: cfg.Station.Number, + StationName: cfg.Station.Name, + Coop: cfg.Station.Coop, + Alive: s.alive(), + State: s.stateOf(snap), + ScalePresent: cfg.Scale.Present, + PrinterSelfTests: s.selfTestsOf(cfg.Printer.Type), + PrinterTransports: s.transports(), + Counters: countersDTO{Unlogged: snap.UnloggedWeighings, Journal: -1}, + // The three lists are EMPTY and not nil, because that is the difference between + // « there is none » and `null`. A station with no journal (ADR-013) reads none of + // them, a station installed this morning has no import to break down, and the + // screen spreads and filters them the instant it has read them: `null` is an + // uncaught TypeError that closes the administration in a volunteer's face. + Events: []technicalLineDTO{}, + CatalogMotives: []motiveDTO{}, + Decisions: []decisionDTO{}, + NewVersion: s.newVersion(cfg.Update.Repository), + } + s.fillHealthFromStore(r.Context(), &body) + s.fillHealthFromPlatform(r.Context(), &body, cfg) + writeJSON(w, http.StatusOK, body) +} + +// selfTestsOf reports the self-tests the driver named by printer.type honours, as its +// registry entry declared them (§8.6). +// +// EMPTY when no descriptor answers to that name, and that answer is exact on a station +// that is running: printer.type is read from the configuration IN FORCE, which is either +// one this binary validated against its own registry or the neutral profile of §11.3 — +// both name a driver this binary carries. What is left is a server built with no printer +// registry at all: `openscale config validate` on a laptop and the HTTP bench of this +// package, neither of which has a printer to launch anything on either. +func (s *Server) selfTestsOf(driver string) []string { + for _, descriptor := range s.registries.Printers { + if descriptor.ID == driver { + // A COPY: this slice leaves for a JSON encoder, and the registry it comes from + // describes the binary for as long as the process runs. + return append([]string{}, descriptor.SelfTests...) + } + } + return []string{} +} + +// transports reports the byte transports this binary carries, in the order the registry +// declares them — which is the order §8.4 presents them in, the two local defaults first. +// +// EMPTY and never nil: a server built with no transport registry is a legitimate state, +// and the screen then draws no drop-down list rather than a broken one. +func (s *Server) transports() []transportDTO { + out := make([]transportDTO, 0, len(s.registries.Transports)) + for _, descriptor := range s.registries.Transports { + out = append(out, transportDTO{ + ID: descriptor.ID, Label: descriptor.Label, Key: descriptor.DeviceKey, + }) + } + return out +} + +// fillHealthFromPlatform adds the three facts only the composition root can answer, and +// leaves them ABSENT when nobody answered. +// +// Absent and not zero: a roll counter at 0, a disk with 0 free bytes and « redémarrage +// sans intervention : OK » are three sentences a screen would draw in good faith, and all +// three would be false on a station that simply has no Dashboard wired. +func (s *Server) fillHealthFromPlatform(ctx context.Context, body *adminHealthDTO, cfg domain.Config) { + if s.dashboard == nil { + return + } + facts := s.dashboard.Dashboard(ctx) + if facts.Roll != nil { + body.Roll = &rollDTO{ + Printed: facts.Roll.Printed, Capacity: facts.Roll.Capacity, + Remaining: facts.Roll.Remaining, Level: facts.Roll.Level, + Message: facts.Roll.Message, Known: facts.Roll.Known, + } + } + if facts.Disk != nil { + body.Disk = &diskDTO{ + Path: facts.Disk.Path, FreeBytes: facts.Disk.FreeBytes, + TotalBytes: facts.Disk.TotalBytes, AlertMB: cfg.Maintenance.DiskAlertMB, + } + } + if facts.Restart != nil { + body.Restart = &restartDTO{ + Configured: facts.Restart.Configured, Known: facts.Restart.Known, + Detail: facts.Restart.Detail, Remedy: facts.Restart.Remedy, + } + } + if facts.Source != nil { + body.CatalogSource = &catalogSourceDTO{Type: facts.Source.Type, Label: facts.Source.Label} + } + if facts.Routing != nil { + body.Routing = &routingDTO{ + FallbackAvailable: facts.Routing.Available, OnFallback: facts.Routing.OnFallback, + Name: facts.Routing.Name, Banner: facts.Routing.Banner, + } + } +} + +// alive reports the liveness of the loop WITHOUT probing it. +// +// The dashboard is read by a human who is already looking at the state; submitting a +// command to draw a light would put a Hub turn behind every refresh of a screen +// somebody left open. +func (s *Server) alive() bool { + if s.controller == nil { + return true + } + return s.controller.Alive() +} + +// fillHealthFromStore adds what only the database knows, and says nothing when there +// is no database: a station whose journal is unavailable still has to draw its +// dashboard (ADR-013). +func (s *Server) fillHealthFromStore(ctx context.Context, body *adminHealthDTO) { + if s.store == nil { + return + } + if rows, err := s.store.CountWeighings(ctx); err == nil { + body.Counters.Journal = rows + } + if lines, err := s.store.TechnicalEntries(ctx, TechnicalQuery{Limit: 10}); err == nil { + body.Events = technicalLinesOf(lines) + } + if last := s.lastImport(ctx); last != nil { + body.Catalog = last + body.CatalogFindings = s.findingsInForce(ctx, *last) + if findings, err := s.store.Findings(ctx, body.CatalogFindings); err == nil { + body.CatalogMotives = motivesOf(findings) + } + } + if decisions, err := s.store.LocalDecisions(ctx); err == nil { + body.Decisions = decisionsOf(decisions) + } +} + +// lastImport is the import in force, or nil when there is none to read. +// +// Nil covers three states on purpose, because the screen owes the same prudence to all +// three: no journal at all (ADR-013), a journal that refused the read, and a station +// installed this morning that has never received a catalog. +func (s *Server) lastImport(ctx context.Context) *importDTO { + if s.store == nil { + return nil + } + list, err := s.store.Imports(ctx, 1, 0) + if err != nil || len(list) == 0 { + return nil + } + last := importOf(list[0]) + return &last +} + +// findingsInForce names the import whose findings describe the catalog in service. +// +// Two of the four outcomes speak for themselves and are answered with their own row: an +// APPLIED import produced the grid, and a REJECTED one wrote no product at all — its +// remarks are exactly what somebody must fix for the next file to get in (§10.5), and +// answering a refusal with the remarks of a healthy catalog would be the wrong list +// entirely. +// +// The other two wrote NO finding, on purpose, and it is the catalog in service they leave +// alone: 'unchanged' saw a file this station had already applied, and 'failed' rolled the +// transaction back. What describes the grid is then the last applied import, which is the +// same row the client screen dates itself from (ADR-053) — two screens, one line of one +// table, and no way left for them to disagree. +// +// Zero when a station has never applied one: the screen says « aucun » rather than draw +// the findings of some other import. +func (s *Server) findingsInForce(ctx context.Context, last importDTO) int64 { + if last.Result == domain.ImportApplied || last.Result == domain.ImportRejected { + return last.ID + } + applied, err := s.store.LastAppliedImport(ctx) + if err != nil { + return 0 + } + return applied.ID +} + +// watchedCatalog is the permanent catalog line of §14.4, or an empty string. +// +// The wording comes from the SOURCE itself — « dépôt local, flv_2.csv dans … » — which is +// why nothing here composes it: only the source knows whether it has an account and which +// file name a station number derives. +func (s *Server) watchedCatalog(ctx context.Context) string { + if s.dashboard == nil { + return "" + } + source := s.dashboard.Dashboard(ctx).Source + if source == nil { + return "" + } + return source.Label +} + +// notWeighableMotives are the three reasons a row has no tile, and the only findings this +// breakdown counts (§10.3). +// +// An anomaly is deliberately NOT in the list: « 16 anomalies à corriger dans Odoo » is +// already its own line of the inventory, and mixing the two would rebuild the « 46 +// produits en erreur » §14.4 refuses. +var notWeighableMotives = map[string]bool{ + domain.FindingNoBarcode: true, + domain.FindingPrepackagedProduct: true, + domain.FindingInternalCodeNotWeighable: true, +} + +// prefixWidth is how many digits of a barcode name a family of codes (§6.2). +const prefixWidth = 4 + +// motivesOf counts the non-weighable rows of one import by motive, most numerous first. +// +// The internal codes are counted PER PREFIX, because that is the difference between +// « code interne (1) » and « code interne 0490 (1) »: the second names the number to +// correct in Odoo, and it is the sentence §14.4 quotes. +func motivesOf(findings []domain.Finding) []motiveDTO { + counts := make(map[motiveDTO]int) + for _, f := range findings { + if !notWeighableMotives[f.Code] { + continue + } + key := motiveDTO{Code: f.Code} + if f.Code == domain.FindingInternalCodeNotWeighable && len(f.Value) >= prefixWidth { + key.Value = f.Value[:prefixWidth] + } + counts[key]++ + } + + out := make([]motiveDTO, 0, len(counts)) + for motive, count := range counts { + motive.Count = count + out = append(out, motive) + } + // Sorted, and by more than the count: a map iterates in a different order every run, + // and a dashboard whose sentence reshuffles itself between two refreshes is a + // dashboard nobody trusts. + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + if out[i].Code != out[j].Code { + return out[i].Code < out[j].Code + } + return out[i].Value < out[j].Value + }) + return out +} diff --git a/internal/web/decisions.go b/internal/web/decisions.go new file mode 100644 index 0000000..a950fec --- /dev/null +++ b/internal/web/decisions.go @@ -0,0 +1,132 @@ +// This file holds the ONE TABLE OF HUMAN DECISIONS (§10.6, §14.4): a volunteer +// looked at a product and said something about it, and that survives the next +// import. +// +// It is the only place where a person overrides what the catalogue says, which is +// why the row records WHO decided and not merely what -- and why the route is +// PROTECTED: it changes what the station sells (ADR-033). + +package web + +import ( + "net/http" + "openscale/internal/domain" +) + +// decisionDTO is one human judgement about one product (§10.6, ADR-017). +type decisionDTO struct { + ProductID string `json:"product_id"` + Offered bool `json:"offered"` + // MinWeightG is the per-product light-product waiver. Null means the general + // limit applies — the absence of a decision is not a refusal. + MinWeightG *int64 `json:"min_weight_g"` + Reason string `json:"reason"` + DecidedBy string `json:"decided_by"` + DecidedAt string `json:"decided_at"` +} + +// decisionRequest is the body of POST /admin/api/products/{id}/decision. +// +// ONE route for the ONE table of human decisions: « ne plus proposer ce produit » and +// « ce produit peut peser moins de 10 g » are two columns of local_decisions, not two +// mechanisms (§14.5). +type decisionRequest struct { + Offered *bool `json:"offered"` + MinWeightG *int64 `json:"min_weight_g"` + Reason string `json:"reason"` + DecidedBy *string `json:"decided_by"` +} + +// productDecision is POST /admin/api/products/{id}/decision. +func (s *Server) productDecision(w http.ResponseWriter, r *http.Request) { + var body decisionRequest + if !decodeJSON(w, r, &body) { + return + } + if s.store == nil { + unavailable(w, "ce poste n'enregistre pas de décision locale") + return + } + id := r.PathValue("id") + if id == "" { + writeProblem(w, http.StatusBadRequest, "", "Aucun produit n'est désigné.") + return + } + + offered := true + if body.Offered != nil { + offered = *body.Offered + } + // « Offered again, and no waiver » is the ABSENCE of a decision, not a row saying + // nothing: leaving one would make the screen list a product nobody decided + // anything about (§10.6). + if offered && body.MinWeightG == nil { + if err := s.store.ClearDecision(r.Context(), id); err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + writeJSON(w, http.StatusOK, actionDTO{ + Done: true, Message: "Ce produit est de nouveau proposé sans dérogation."}) + return + } + if body.Reason == "" { + // The reason is what makes the decision readable in six months, by somebody + // who was not there. A decision without one is a mystery with a date. + writeProblem(w, http.StatusUnprocessableEntity, "", + "Indiquez le motif de cette décision.") + return + } + + decision := domain.LocalDecision{ + ProductID: id, Offered: offered, MinWeightG: gramsOf(body.MinWeightG), + Reason: body.Reason, DecidedAt: s.clock.Now(), DecidedBy: decidedBy(body.DecidedBy), + } + if err := s.store.SaveDecision(r.Context(), decision); err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + s.technical.Technical(domain.LevelInfo, "catalog", "", + "Décision locale enregistrée.", id+" : "+body.Reason) + writeJSON(w, http.StatusOK, actionDTO{Done: true, Message: "La décision est enregistrée."}) +} + +// decidedBy names who decided, with the honest default. +// +// « bénévole » and not an empty string: nobody signs in by name on this station, and +// leaving the field empty would suggest a name could have been known. +func decidedBy(who *string) string { + if who == nil || *who == "" { + return "bénévole" + } + return *who +} + +// gramsOf carries the optional waiver across the DTO boundary, absence included. +func gramsOf(value *int64) *domain.Grams { + if value == nil { + return nil + } + grams := domain.Grams(*value) + return &grams +} + +// gramsValue is the reverse: a waiver as the screen reads it, or null. +func gramsValue(value *domain.Grams) *int64 { + if value == nil { + return nil + } + plain := int64(*value) + return &plain +} + +// decisionsOf converts the human judgements in force. +func decisionsOf(decisions []domain.LocalDecision) []decisionDTO { + out := make([]decisionDTO, 0, len(decisions)) + for _, d := range decisions { + out = append(out, decisionDTO{ + ProductID: d.ProductID, Offered: d.Offered, MinWeightG: gramsValue(d.MinWeightG), + Reason: d.Reason, DecidedBy: d.DecidedBy, DecidedAt: stamp(d.DecidedAt), + }) + } + return out +} diff --git a/internal/web/devices.go b/internal/web/devices.go new file mode 100644 index 0000000..1dc041c --- /dev/null +++ b/internal/web/devices.go @@ -0,0 +1,263 @@ +// This file holds WHAT IS PLUGGED IN (§14.4), and the gestures that interrogate it: +// enumerate the serial ports and the print queues, detect a scale, capture its +// frames, render a life-size label, replay a frame. +// +// Enumerating and previewing are OPEN -- looking at a port number costs nothing +// whoever stands behind the counter could not already read off the cable. Going and +// ASKING the hardware is protected: discover, detect, capture and replay all put +// something on a wire (ADR-033). +// +// Every gesture that reaches a device is bounded by deviceBudget: the volunteer +// pressing the button is standing in front of the screen. + +package web + +import ( + "net/http" + "openscale/internal/station/ports" + "time" +) + +// PortInfo is one serial port the platform enumerated, with the USB description that +// makes it recognisable — « COM8 » names nothing, « COM8 — FTDI FT232R » names a +// cable somebody can see (§14.4). +type PortInfo struct { + Name string + Description string + VID string + PID string +} + +// PrinterInfo is one print queue or one device the platform knows about. +type PrinterInfo struct { + Name string + // Key is the printer.options key this destination goes into: "queue", "path" or + // "address" (domain.DeviceKey*). The enumeration that found it is the only layer that + // knows, and the screen has no way of telling the three apart by looking at the name. + Key string + Detail string + Default bool +} + +// ScaleDetection is what one port answered when the parsers were applied to it. +// +// It is the detection that answers « is there a scale? », not the operator (§14.4). +type ScaleDetection struct { + Port string + // Driver is the registry key of the parser that recognised the frames, empty when + // none did. + Driver string + ValidCount int + // Frames is what was read, decoded, so that a support call can look at them. + Frames []string + Message string +} + +// PreviewQuery is what GET /admin/api/label/preview.png renders. +type PreviewQuery struct { + Template string + // Demo asks for the demonstration values rather than the weighing in flight, + // which is what the settings screen shows while nobody is weighing. + Demo bool + // Dual asks for the two-tier layout, so that an operator sees the crowded case + // without having to configure it first. + Dual bool +} + +// listPorts is GET /admin/api/ports. +func (s *Server) listPorts(w http.ResponseWriter, r *http.Request) { + if s.hardware == nil { + unavailable(w, "l'énumération des ports n'est pas câblée") + return + } + ports, err := s.hardware.Ports(r.Context()) + if err != nil { + writeProblem(w, http.StatusBadGateway, "", err.Error()) + return + } + body := struct { + Ports []portDTO `json:"ports"` + }{make([]portDTO, 0, len(ports))} + for _, p := range ports { + body.Ports = append(body.Ports, portDTO{ + Name: p.Name, Description: p.Description, VID: p.VID, PID: p.PID, + }) + } + writeJSON(w, http.StatusOK, body) +} + +// portDTO is one serial port. +type portDTO struct { + Name string `json:"name"` + Description string `json:"description"` + VID string `json:"vid"` + PID string `json:"pid"` +} + +// printerDeviceDTO is one print destination this station can reach. +type printerDeviceDTO struct { + Name string `json:"name"` + // Key is the printer.options key this destination goes INTO, as the enumeration that + // found it declared: "queue", "path" or "address". + // + // The screen writes what a volunteer clicks into THAT key and no other. It wrote every + // one of them into `queue`, and the two routes served by this handler do not answer the + // same kind of thing: one lists the queues of the spooler, the other the hosts that + // replied on port 9100. + Key string `json:"key"` + Detail string `json:"detail"` + Default bool `json:"default"` +} + +// listPrinters is GET /admin/api/printers. +func (s *Server) listPrinters(w http.ResponseWriter, r *http.Request) { + s.answerPrinters(w, r, false) +} + +// discoverPrinters is POST /admin/api/printers/discover: the deeper search, which may +// take seconds and is therefore a POST and not a GET. +func (s *Server) discoverPrinters(w http.ResponseWriter, r *http.Request) { + s.answerPrinters(w, r, true) +} + +// answerPrinters serves both printer routes. +func (s *Server) answerPrinters(w http.ResponseWriter, r *http.Request, discover bool) { + if s.hardware == nil { + unavailable(w, "l'énumération des imprimantes n'est pas câblée") + return + } + // Enumerating a Windows spooler can take seconds, and discovering can take more. + // A handler never waits on the platform without a deadline. + ctx, cancel := ports.WithBudget(r.Context(), s.clock, deviceBudget) + defer cancel() + + list, err := s.hardware.Printers(ctx) + if discover { + list, err = s.hardware.DiscoverPrinters(ctx) + } + if err != nil { + writeProblem(w, http.StatusBadGateway, "", err.Error()) + return + } + body := struct { + Printers []printerDeviceDTO `json:"printers"` + }{make([]printerDeviceDTO, 0, len(list))} + for _, p := range list { + body.Printers = append(body.Printers, printerDeviceDTO{ + Name: p.Name, Key: p.Key, Detail: p.Detail, Default: p.Default, + }) + } + writeJSON(w, http.StatusOK, body) +} + +// detectRequest is the body of POST /admin/api/scale/detect and /scale/capture. +type detectRequest struct { + Port string `json:"port"` + // Seconds is how long to listen, for a capture. Zero means the default of three + // seconds, which is what the detection of §14.4 spends on each port. + Seconds int `json:"seconds"` +} + +// detectScale is POST /admin/api/scale/detect: it opens the port, applies the parsers +// and says what answered — « COM8 : 12 trames valides, GRAM XFOC ». +func (s *Server) detectScale(w http.ResponseWriter, r *http.Request) { + var body detectRequest + if !decodeJSON(w, r, &body) { + return + } + if s.hardware == nil { + unavailable(w, "la détection de balance n'est pas câblée") + return + } + report, err := s.hardware.DetectScale(r.Context(), body.Port) + if err != nil { + writeProblem(w, http.StatusBadGateway, "ERR-SCL-03", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Port string `json:"port"` + Driver string `json:"driver"` + ValidCount int `json:"valid_frames_count"` + Frames []string `json:"frames"` + Message string `json:"message"` + }{report.Port, report.Driver, report.ValidCount, report.Frames, report.Message}) +} + +// captureScale is POST /admin/api/scale/capture: the raw frames, for a support call. +func (s *Server) captureScale(w http.ResponseWriter, r *http.Request) { + var body detectRequest + if !decodeJSON(w, r, &body) { + return + } + if s.hardware == nil { + unavailable(w, "la capture de trames n'est pas câblée") + return + } + seconds := body.Seconds + if seconds <= 0 || seconds > 60 { + seconds = 3 + } + frames, err := s.hardware.CaptureFrames(r.Context(), body.Port, time.Duration(seconds)*time.Second) + if err != nil { + writeProblem(w, http.StatusBadGateway, "ERR-SCL-03", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Frames []string `json:"frames"` + }{frames}) +} + +// labelPreview is GET /admin/api/label/preview.png: the same rendering that would be +// printed, which is what A2 buys (one renderer, not two). +func (s *Server) labelPreview(w http.ResponseWriter, r *http.Request) { + if s.hardware == nil { + unavailable(w, "l'aperçu d'étiquette n'est pas câblé") + return + } + png, err := s.hardware.LabelPreview(r.Context(), PreviewQuery{ + Template: r.URL.Query().Get("template"), + Demo: r.URL.Query().Get("demo") == "1", + Dual: r.URL.Query().Get("dual") == "1", + }) + if err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "", err.Error()) + return + } + w.Header().Set("Content-Type", "image/png") + // The preview is refreshed at every keystroke on the settings screen: a cached + // one would show the previous offset. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(png) +} + +// replayRequest is the body of POST /admin/api/replay. +type replayRequest struct { + // Frame is the raw frame, exactly as the journal recorded it. + Frame string `json:"frame"` +} + +// replay is POST /admin/api/replay: « Rejouer cette trame » (§14.4, Journal). +// +// It is what turns a frame that caused an unexplained refusal into a permanent test, +// without a trip to the shop and without a scale. +func (s *Server) replay(w http.ResponseWriter, r *http.Request) { + var body replayRequest + if !decodeJSON(w, r, &body) { + return + } + if s.hardware == nil { + unavailable(w, "le rejeu de trame n'est pas câblé") + return + } + if body.Frame == "" { + writeProblem(w, http.StatusBadRequest, "", "Aucune trame n'est fournie.") + return + } + if err := s.hardware.Replay(r.Context(), body.Frame); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "", err.Error()) + return + } + writeJSON(w, http.StatusAccepted, actionDTO{ + Done: true, Message: "La trame a été rejouée."}) +} diff --git a/internal/web/dist/admin.html b/internal/web/dist/admin.html index 9770d80..9ea47b5 100644 --- a/internal/web/dist/admin.html +++ b/internal/web/dist/admin.html @@ -5,11 +5,11 @@ Administration - - - + + + - +
diff --git a/internal/web/dist/assets/admin-BJRgB0wR.js b/internal/web/dist/assets/admin-BJRgB0wR.js new file mode 100644 index 0000000..2e9fc94 --- /dev/null +++ b/internal/web/dist/assets/admin-BJRgB0wR.js @@ -0,0 +1 @@ +import"./app-CFgWi82J.js";import{mountAdmin as m}from"./mount-DmM_mBVD.js";m(document.getElementById("app")); diff --git a/internal/web/dist/assets/admin-Dt_UkZwi.js b/internal/web/dist/assets/admin-Dt_UkZwi.js deleted file mode 100644 index ec9a12c..0000000 --- a/internal/web/dist/assets/admin-Dt_UkZwi.js +++ /dev/null @@ -1 +0,0 @@ -import"./app-oxQr6rjd.js";import{mountAdmin as m}from"./mount-BVSrRvN7.js";m(document.getElementById("app")); diff --git a/internal/web/dist/assets/app-oxQr6rjd.js b/internal/web/dist/assets/app-CFgWi82J.js similarity index 99% rename from internal/web/dist/assets/app-oxQr6rjd.js rename to internal/web/dist/assets/app-CFgWi82J.js index 2c88fb9..f585b62 100644 --- a/internal/web/dist/assets/app-oxQr6rjd.js +++ b/internal/web/dist/assets/app-CFgWi82J.js @@ -1,2 +1,2 @@ (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const f of s.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&r(f)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();const wn=!1;var mn=Array.isArray,wr=Array.prototype.indexOf,ut=Array.prototype.includes,pt=Array.from,mr=Object.defineProperty,Ce=Object.getOwnPropertyDescriptor,br=Object.getOwnPropertyDescriptors,Er=Object.prototype,yr=Array.prototype,bn=Object.getPrototypeOf,rn=Object.isExtensible;const Sr=()=>{};function Tr(e){for(var t=0;t{e=r,t=i});return{promise:n,resolve:e,reject:t}}const C=2,Pe=4,gt=8,yn=1<<24,X=16,G=32,oe=64,kt=128,B=512,N=1024,M=2048,W=4096,R=8192,H=16384,je=32768,Ct=1<<25,Re=65536,ct=1<<17,Ar=1<<18,He=1<<19,Mr=1<<20,$=1<<25,ye=65536,dt=1<<21,xe=1<<22,ve=1<<23,we=Symbol("$state"),Or=Symbol("legacy props"),Nr=Symbol(""),Sn=Symbol("attributes"),xt=Symbol("class"),Pt=Symbol("style"),Rt=Symbol("text"),ft=Symbol("form reset"),Qe=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"},kr=!!globalThis.document?.contentType&&globalThis.document.contentType.includes("xml");function Cr(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function xr(e,t,n){throw new Error("https://svelte.dev/e/each_key_duplicate")}function Pr(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function Rr(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function Ir(e){throw new Error("https://svelte.dev/e/effect_orphan")}function Lr(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function Dr(e){throw new Error("https://svelte.dev/e/props_invalid_value")}function Fr(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function jr(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function Hr(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function zr(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const Ur=1,qr=2,Tn=4,Br=8,Gr=16,Vr=1,Yr=4,Xr=8,Kr=16,Zr=1,Wr=2,O=Symbol("uninitialized"),Jr="http://www.w3.org/1999/xhtml";function Qr(){console.warn("https://svelte.dev/e/derived_inert")}function Is(){console.warn("https://svelte.dev/e/select_multiple_invalid_value")}function $r(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}function An(e){return e===this.v}function ei(e,t){return e!=e?t==t:e!==t||e!==null&&typeof e=="object"||typeof e=="function"}function Mn(e){return!ei(e,this.v)}let ti=!1,I=null;function Ie(e){I=e}function On(e,t=!1,n){I={p:I,i:!1,c:null,e:null,s:e,x:null,r:w,l:null}}function Nn(e){var t=I,n=t.e;if(n!==null){t.e=null;for(var r of n)Kn(r)}return t.i=!0,I=t.p,{}}function kn(){return!0}let pe=[];function Cn(){var e=pe;pe=[],Tr(e)}function ee(e){if(pe.length===0&&!Xe){var t=pe;queueMicrotask(()=>{t===pe&&Cn()})}pe.push(e)}function ni(){for(;pe.length>0;)Cn()}function xn(e){var t=w;if(t===null)return m.f|=ve,e;if((t.f&je)===0&&(t.f&Pe)===0)throw e;he(e,t)}function he(e,t){if(!(t!==null&&(t.f&H)!==0)){for(;t!==null;){if((t.f&kt)!==0){if((t.f&je)===0)throw e;try{t.b.error(e);return}catch(n){e=n}}t=t.parent}throw e}}const ri=-7169;function A(e,t){e.f=e.f&ri|t}function qt(e){(e.f&B)!==0||e.deps===null?A(e,N):A(e,W)}function Pn(e){if(e!==null)for(const t of e)(t.f&C)===0||(t.f&ye)===0||(t.f^=ye,Pn(t.deps))}function Rn(e,t,n){(e.f&M)!==0?t.add(e):(e.f&W)!==0&&n.add(e),Pn(e.deps),A(e,N)}let nt=!1;function ii(e){var t=nt;try{return nt=!1,[e(),nt]}finally{nt=t}}function Ls(e,t){{const n=document.body;e.autofocus=!0,ee(()=>{document.activeElement===n&&e.focus()})}}let sn=!1;function si(){sn||(sn=!0,document.addEventListener("reset",e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(const t of e.target.elements)t[ft]?.()})},{capture:!0}))}function ze(e){var t=m,n=w;V(null),ne(null);try{return e()}finally{V(t),ne(n)}}function Ds(e,t,n,r=n){e.addEventListener(t,()=>ze(n));const i=e[ft];i?e[ft]=()=>{i(),r(!0)}:e[ft]=()=>r(!0),si()}function fi(e){let t=0,n=Se(0),r;return()=>{Yt()&&(T(n),Zn(()=>(t===0&&(r=Wt(()=>e(()=>Ke(n)))),t+=1,()=>{ee(()=>{t-=1,t===0&&(r?.(),r=void 0,Ke(n))})})))}}var ai=Re|He;function li(e,t,n,r){new oi(e,t,n,r)}class oi{parent;is_pending=!1;transform_error;#t;#f=null;#e;#l;#r;#s=null;#n=null;#a=null;#i=null;#_=0;#o=0;#u=!1;#d=new Set;#p=new Set;#c=null;#w=fi(()=>(this.#c=Se(this.#_),()=>{this.#c=null}));constructor(t,n,r,i){this.#t=t,this.#e=n,this.#l=s=>{var f=w;f.b=this,f.f|=kt,r(s)},this.parent=w.b,this.transform_error=i??this.parent?.transform_error??(s=>s),this.#r=Xt(()=>{this.#h()},ai)}#g(){try{this.#s=q(()=>this.#l(this.#t))}catch(t){this.error(t)}}#E(t){const n=this.#e.failed,{reset:r,invoke_onerror:i}=this.#m(t);ee(i),n&&(this.#a=q(()=>{n(this.#t,()=>t,()=>r)}))}#m(t){var n=!1,r=!1;const i=()=>{if(n){$r();return}n=!0,r&&zr(),this.#a!==null&&be(this.#a,()=>{this.#a=null}),this.#v(()=>{this.#h()})};return{reset:i,invoke_onerror:()=>{try{r=!0,this.#e.onerror?.(t,i),r=!1}catch(f){he(f,this.#r&&this.#r.parent)}}}}#y(){const t=this.#e.pending;t&&(this.is_pending=!0,this.#n=q(()=>t(this.#t)),ee(()=>{var n=this.#i=document.createDocumentFragment(),r=le();n.append(r),this.#s=this.#v(()=>q(()=>this.#l(r))),this.#o===0&&(this.#t.before(n),this.#i=null,be(this.#n,()=>{this.#n=null}),this.#b(b))}))}#h(){try{if(this.is_pending=this.has_pending_snippet(),this.#o=0,this.#_=0,this.#s=q(()=>{this.#l(this.#t)}),this.#o>0){var t=this.#i=document.createDocumentFragment();Zt(this.#s,t);const n=this.#e.pending;this.#n=q(()=>n(this.#t))}else this.#b(b)}catch(n){this.error(n)}}#b(t){this.is_pending=!1,t.transfer_effects(this.#d,this.#p)}defer_effect(t){Rn(t,this.#d,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#e.pending}#v(t){var n=w,r=m,i=I;ne(this.#r),V(this.#r),Ie(this.#r.ctx);try{return _e.ensure(),t()}catch(s){return xn(s),null}finally{ne(n),V(r),Ie(i)}}#S(t,n){if(!this.has_pending_snippet()){this.parent&&this.parent.#S(t,n);return}this.#o+=t,this.#o===0&&(this.#b(n),this.#n&&be(this.#n,()=>{this.#n=null}),this.#i&&(this.#t.before(this.#i),this.#i=null))}update_pending_count(t,n){this.#S(t,n),this.#_+=t,!(!this.#c||this.#u)&&(this.#u=!0,ee(()=>{this.#u=!1,this.#c&&Le(this.#c,this.#_)}))}get_effect_pending(){return this.#w(),T(this.#c)}error(t){if(!this.#e.onerror&&!this.#e.failed)throw t;b?.is_fork?(this.#s&&b.skip_effect(this.#s),this.#n&&b.skip_effect(this.#n),this.#a&&b.skip_effect(this.#a),b.oncommit(()=>{this.#T(t)})):this.#T(t)}#T(t){this.#s&&(D(this.#s),this.#s=null),this.#n&&(D(this.#n),this.#n=null),this.#a&&(D(this.#a),this.#a=null);let n=this.#e.failed;const r=i=>{const{reset:s,invoke_onerror:f}=this.#m(i);f(),n&&(this.#a=this.#v(()=>{try{return q(()=>{var l=w;l.b=this,l.f|=kt,n(this.#t,()=>i,()=>s)})}catch(l){return he(l,this.#r.parent),null}}))};ee(()=>{var i;try{i=this.transform_error(t)}catch(s){he(s,this.#r&&this.#r.parent);return}i!==null&&typeof i=="object"&&typeof i.then=="function"?i.then(r,s=>he(s,this.#r&&this.#r.parent)):r(i)})}}function ui(e,t,n,r){const i=Ze;var s=e.filter(h=>!h.settled),f=t.map(i);if(n.length===0&&s.length===0){r(f);return}var l=w,a=ci(),o=s.length===1?s[0].promise:s.length>1?Promise.all(s.map(h=>h.promise)):null;function c(h){if((l.f&H)===0){a();try{r([...f,...h])}catch(v){he(v,l)}ht()}}var d=In();if(n.length===0){o.then(()=>c([])).finally(d);return}function u(){Promise.all(n.map(h=>di(h))).then(c).catch(h=>he(h,l)).finally(d)}o?o.then(()=>{a(),u(),ht()}):u()}function ci(){var e=w,t=m,n=I,r=b;return function(s=!0){ne(e),V(t),Ie(n),s&&(e.f&H)===0&&(r?.activate(),r?.apply())}}function ht(e=!0){ne(null),V(null),Ie(null),e&&b?.deactivate()}function In(){var e=w,t=e.b,n=b,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function Ze(e){var t=C|M;return w!==null&&(w.f|=He),{ctx:I,deps:null,effects:null,equals:An,f:t,fn:e,reactions:null,rv:0,v:O,wv:0,parent:w,ac:null}}const Be=Symbol("obsolete");function di(e,t,n){let r=w;r===null&&Cr();var i=void 0,s=Se(O),f=!m,l=new Set;return Oi(()=>{var a=w,o=En();i=o.promise;try{Promise.resolve(e()).then(o.resolve,h=>{h!==Qe&&o.reject(h)}).finally(ht)}catch(h){o.reject(h),ht()}var c=b;if(f){if((a.f&je)!==0)var d=In();if(r.b?.is_rendered())c.async_deriveds.get(a)?.reject(Be);else for(const h of l.values())h.reject(Be);l.add(o),c.async_deriveds.set(a,o)}const u=(h,v=void 0)=>{d?.(),l.delete(o),v!==Be&&(c.activate(),v?(s.f|=ve,Le(s,v)):((s.f&ve)!==0&&(s.f^=ve),Le(s,h)),c.deactivate())};o.promise.then(u,h=>u(null,h||"unknown"))}),Xn(()=>{for(const a of l)a.reject(Be)}),new Promise(a=>{function o(c){function d(){c===i?a(s):o(i)}c.then(d,d)}o(i)})}function rt(e){const t=Ze(e);return er(t),t}function Ln(e){const t=Ze(e);return t.equals=Mn,t}function hi(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(Qe),t.ac=null}),t.fn!==null&&(t.teardown=Sr),We(t,0),Kt(t))}function Fn(e){if(e.effects!==null)for(const t of e.effects)t.teardown&&t.fn!==null&&Fe(t)}let yt=null,Me=null,b=null,It=null,K=null,Lt=null,Xe=!1,St=!1,Ne=null,at=null;var fn=0;let _i=1;class _e{id=_i++;#t=!1;linked=!0;#f=null;#e=null;async_deriveds=new Map;current=new Map;previous=new Map;#l=new Set;#r=new Set;#s=0;#n=new Map;#a=null;#i=[];#_=[];#o=new Set;#u=new Set;#d=new Map;#p=new Set;is_fork=!1;#c=!1;constructor(){Me===null?yt=Me=this:(Me.#e=this,this.#f=Me),Me=this}#w(){if(this.is_fork)return!0;for(const r of this.#n.keys()){for(var t=r,n=!1;t.parent!==null;){if(this.#d.has(t)){n=!0;break}t=t.parent}if(!n)return!0}return!1}skip_effect(t){this.#d.has(t)||this.#d.set(t,{d:[],m:[]}),this.#p.delete(t)}unskip_effect(t,n=r=>this.schedule(r)){var r=this.#d.get(t);if(r){this.#d.delete(t);for(var i of r.d)A(i,M),n(i);for(i of r.m)A(i,W),n(i)}this.#p.add(t)}#g(){this.#t=!0,fn++>1e3&&(this.#v(),gi());for(const a of this.#o)this.#u.delete(a),A(a,M),this.schedule(a);for(const a of this.#u)A(a,W),this.schedule(a);const t=this.#i;this.#i=[],this.apply();var n=Ne=[],r=[],i=at=[];for(const a of t)try{this.#E(a,n,r)}catch(o){throw zn(a),this.#w()||this.discard(),o}if(b=null,i.length>0){var s=_e.ensure();for(const a of i)s.schedule(a)}if(Ne=null,at=null,this.#w()){this.#h(r),this.#h(n);for(const[a,o]of this.#d)Hn(a,o);i.length>0&&b.#g();return}const f=this.#m();if(f){this.#h(r),this.#h(n),f.#y(this);return}this.#o.clear(),this.#u.clear();for(const a of this.#l)a(this);this.#l.clear(),It=this,an(r),an(n),It=null,this.#a?.resolve();var l=b;if(this.#s===0&&(this.#i.length===0||l!==null)&&this.#v(),this.#i.length>0)if(l!==null){const a=l;a.#i.push(...this.#i.filter(o=>!a.#i.includes(o)))}else l=this;l!==null&&l.#g()}#E(t,n,r){t.f^=N;for(var i=t.first;i!==null;){var s=i.f,f=(s&(G|oe))!==0,l=f&&(s&N)!==0,a=l||(s&R)!==0||this.#d.has(i);if(!a&&i.fn!==null){f?i.f^=N:(s&Pe)!==0?n.push(i):et(i)&&((s&X)!==0&&this.#u.add(i),Fe(i));var o=i.first;if(o!==null){i=o;continue}}for(;i!==null;){var c=i.next;if(c!==null){i=c;break}i=i.parent}}}#m(){for(var t=this.#f;t!==null;){if(!t.is_fork){for(const[n,[,r]]of this.current)if(t.current.has(n)&&!r)return t}t=t.#f}return null}#y(t){for(const[r,i]of t.current)!this.previous.has(r)&&t.previous.has(r)&&this.previous.set(r,t.previous.get(r)),this.current.set(r,i);for(const[r,i]of t.async_deriveds){const s=this.async_deriveds.get(r);s&&i.promise.then(s.resolve).catch(s.reject)}t.async_deriveds.clear(),this.transfer_effects(t.#o,t.#u);const n=r=>{var i=r.reactions;if(i!==null&&!((r.f&C)!==0&&(r.f&(M|W))===0))for(const l of i){var s=l.f;if((s&C)!==0)n(l);else{var f=l;s&(xe|X)&&!this.async_deriveds.has(f)&&(this.#u.delete(f),A(f,M),this.schedule(f))}}};for(const r of this.current.keys())n(r);this.oncommit(()=>t.discard()),t.#v(),b=this,this.#g()}#h(t){for(var n=0;n!d.current.get(u)[1]);if(!(!d.#t||i.length===0)){var s=i.filter(u=>!this.current.has(u));if(s.length===0)t&&d.discard();else if(n.length>0){if(t)for(const u of this.#p)d.unskip_effect(u,h=>{(h.f&(X|xe))!==0?d.schedule(h):d.#h([h])});d.activate();var f=new Set,l=new Map;for(var a of n)jn(a,s,f,l);l=new Map;var o=[...d.current].filter(([u,h])=>{const v=this.current.get(u);return v?v[0]!==h[0]||v[1]!==h[1]:!0}).map(([u])=>u);if(o.length>0)for(const u of this.#_)(u.f&(H|R|ct))===0&&Gt(u,o,l)&&((u.f&(xe|X))!==0?(A(u,M),d.schedule(u)):d.#o.add(u));if(d.#i.length>0&&!d.#c){d.apply();for(var c of d.#i)d.#E(c,[],[]);d.#i=[]}d.deactivate()}}}}increment(t,n){if(this.#s+=1,t){let r=this.#n.get(n)??0;this.#n.set(n,r+1)}}decrement(t,n){if(this.#s-=1,t){let r=this.#n.get(n)??0;r===1?this.#n.delete(n):this.#n.set(n,r-1)}this.#c||(this.#c=!0,ee(()=>{this.#c=!1,this.linked&&this.flush()}))}transfer_effects(t,n){for(const r of t)this.#o.add(r);for(const r of n)this.#u.add(r);t.clear(),n.clear()}oncommit(t){this.#l.add(t)}ondiscard(t){this.#r.add(t)}settled(){return(this.#a??=En()).promise}static ensure(){if(b===null){const t=b=new _e;!St&&!Xe&&ee(()=>{t.#t||t.flush()})}return b}apply(){{K=null;return}}schedule(t){if(Lt=t,t.b?.is_pending&&(t.f&(Pe|gt|yn))!==0&&(t.f&je)===0){t.b.defer_effect(t);return}for(var n=t;n.parent!==null;){n=n.parent;var r=n.f;if(Ne!==null&&n===w&&(m===null||(m.f&C)===0))return;if((r&(oe|G))!==0){if((r&N)===0)return;n.f^=N}}this.#i.push(n)}#v(){if(this.linked){var t=this.#f,n=this.#e;t===null?yt=n:t.#e=n,n===null?Me=t:n.#f=t,this.linked=!1}}}function pi(e){var t=Xe;Xe=!0;try{for(var n;;){if(ni(),b===null)return n;b.flush()}}finally{Xe=t}}function gi(){try{Lr()}catch(e){he(e,Lt)}}let fe=null;function an(e){var t=e.length;if(t!==0){for(var n=0;n0)){me.clear();for(const i of fe){if((i.f&(H|R))!==0)continue;const s=[i];let f=i.parent;for(;f!==null;)fe.has(f)&&(fe.delete(f),s.push(f)),f=f.parent;for(let l=s.length-1;l>=0;l--){const a=s[l];(a.f&(H|R))===0&&Fe(a)}}fe.clear()}}fe=null}}function jn(e,t,n,r){if(!n.has(e)&&(n.add(e),e.reactions!==null))for(const i of e.reactions){const s=i.f;(s&C)!==0?jn(i,t,n,r):(s&(xe|X))!==0&&(s&M)===0&&Gt(i,t,r)&&(A(i,M),Vt(i))}}function Gt(e,t,n){const r=n.get(e);if(r!==void 0)return r;if(e.deps!==null)for(const i of e.deps){if(ut.call(t,i))return!0;if((i.f&C)!==0&&Gt(i,t,n))return n.set(i,!0),!0}return n.set(e,!1),!1}function Vt(e){b.schedule(e)}function Hn(e,t){if(!((e.f&G)!==0&&(e.f&N)!==0)){(e.f&M)!==0?t.d.push(e):(e.f&W)!==0&&t.m.push(e),A(e,N);for(var n=e.first;n!==null;)Hn(n,t),n=n.next}}function zn(e){A(e,N);for(var t=e.first;t!==null;)zn(t),t=t.next}let vt=new Set;const me=new Map;let Un=!1;function Se(e,t){var n={f:0,v:e,reactions:null,equals:An,rv:0,wv:0};return n}function ie(e,t){const n=Se(e);return er(n),n}function wi(e,t=!1,n=!0){const r=Se(e);return t||(r.equals=Mn),r}function ae(e,t,n=!1){m!==null&&(!Z||(m.f&ct)!==0)&&kn()&&(m.f&(C|X|xe|ct))!==0&&(te===null||!te.has(e))&&Hr();let r=n?ke(t):t;return Le(e,r,at)}function Le(e,t,n=null){if(!e.equals(t)){me.set(e,ue?t:e.v);var r=_e.ensure();if(r.capture(e,t),(e.f&C)!==0){const i=e;(e.f&M)!==0&&Bt(i),K===null&&qt(i)}e.wv=nr(),qn(e,M,n),w!==null&&(w.f&N)!==0&&(w.f&(G|oe))===0&&(U===null?Ci([e]):U.push(e)),!r.is_fork&&vt.size>0&&!Un&&mi()}return t}function mi(){Un=!1;for(const e of vt){(e.f&N)!==0&&A(e,W);let t;try{t=et(e)}catch{t=!0}t&&Fe(e)}vt.clear()}function Ke(e){ae(e,e.v+1)}function qn(e,t,n){var r=e.reactions;if(r!==null)for(var i=r.length,s=0;s{if(Ee===s)return l();var a=m,o=Ee;V(null),cn(s);var c=l();return V(a),cn(o),c};return r&&n.set("length",ie(e.length)),new Proxy(e,{defineProperty(l,a,o){(!("value"in o)||o.configurable===!1||o.enumerable===!1||o.writable===!1)&&Fr();var c=n.get(a);return c===void 0?f(()=>{var d=ie(o.value);return n.set(a,d),d}):ae(c,o.value,!0),!0},deleteProperty(l,a){var o=n.get(a);if(o===void 0){if(a in l){const c=f(()=>ie(O));n.set(a,c),Ke(i)}}else ae(o,O),Ke(i);return!0},get(l,a,o){if(a===we)return e;var c=n.get(a),d=a in l;if(c===void 0&&(!d||Ce(l,a)?.writable)&&(c=f(()=>{var h=ke(d?l[a]:O),v=ie(h);return v}),n.set(a,c)),c!==void 0){var u=T(c);return u===O?void 0:u}return Reflect.get(l,a,o)},getOwnPropertyDescriptor(l,a){var o=Reflect.getOwnPropertyDescriptor(l,a);if(o&&"value"in o){var c=n.get(a);c&&(o.value=T(c))}else if(o===void 0){var d=n.get(a),u=d?.v;if(d!==void 0&&u!==O)return{enumerable:!0,configurable:!0,value:u,writable:!0}}return o},has(l,a){if(a===we)return!0;var o=n.get(a),c=o!==void 0&&o.v!==O||Reflect.has(l,a);if(o!==void 0||w!==null&&(!c||Ce(l,a)?.writable)){o===void 0&&(o=f(()=>{var u=c?ke(l[a]):O,h=ie(u);return h}),n.set(a,o));var d=T(o);if(d===O)return!1}return c},set(l,a,o,c){var d=n.get(a),u=a in l;if(r&&a==="length")for(var h=o;hie(O)),n.set(h+"",v))}if(d===void 0)(!u||Ce(l,a)?.writable)&&(d=f(()=>ie(void 0)),ae(d,ke(o)),n.set(a,d));else{u=d.v!==O;var p=f(()=>ke(o));ae(d,p)}var _=Reflect.getOwnPropertyDescriptor(l,a);if(_?.set&&_.set.call(c,o),!u){if(r&&typeof a=="string"){var g=n.get("length"),S=Number(a);Number.isInteger(S)&&S>=g.v&&ae(g,S+1)}Ke(i)}return!0},ownKeys(l){T(i);var a=Reflect.ownKeys(l).filter(d=>{var u=n.get(d);return u===void 0||u.v!==O});for(var[o,c]of n)c.v!==O&&!(o in l)&&a.push(o);return a},setPrototypeOf(){jr()}})}function ln(e){try{if(e!==null&&typeof e=="object"&&we in e)return e[we]}catch{}return e}function Fs(e,t){return Object.is(ln(e),ln(t))}var on,Bn,Gn,Vn;function bi(){if(on===void 0){on=window,Bn=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;Gn=Ce(t,"firstChild").get,Vn=Ce(t,"nextSibling").get,rn(e)&&(e[xt]=void 0,e[Sn]=null,e[Pt]=void 0,e.__e=void 0),rn(n)&&(n[Rt]=void 0)}}function le(e=""){return document.createTextNode(e)}function De(e){return Gn.call(e)}function $e(e){return Vn.call(e)}function Q(e,t){return De(e)}function js(e,t=!1){{var n=De(e);return n instanceof Comment&&n.data===""?$e(n):n}}function it(e,t=1,n=!1){let r=e;for(;t--;)r=$e(r);return r}function Ei(e){e.textContent=""}function Yn(){return!1}function yi(e,t,n){return n?document.createElement(e,{is:n}):document.createElement(e)}function Si(e){w===null&&(m===null&&Ir(),Rr()),ue&&Pr()}function Ti(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function ce(e,t){var n=w;n!==null&&(n.f&R)!==0&&(e|=R);var r={ctx:I,deps:null,nodes:null,f:e|M|B,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};b?.register_created_effect(r);var i=r;if((e&Pe)!==0)Ne!==null?Ne.push(r):_e.ensure().schedule(r);else if(t!==null){try{Fe(r)}catch(f){throw D(r),f}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&(i.f&He)===0&&(i=i.first,(e&X)!==0&&(e&Re)!==0&&i!==null&&(i.f|=Re))}if(i!==null&&(i.parent=n,n!==null&&Ti(i,n),m!==null&&(m.f&C)!==0&&(e&oe)===0)){var s=m;(s.effects??=[]).push(i)}return r}function Yt(){return m!==null&&!Z}function Xn(e){const t=ce(gt,null);return A(t,N),t.teardown=e,t}function Hs(e){Si();var t=w.f,n=!m&&(t&G)!==0&&I!==null&&!I.i;if(n){var r=I;(r.e??=[]).push(e)}else return Kn(e)}function Kn(e){return ce(Pe|Mr,e)}function Ai(e){_e.ensure();const t=ce(oe|He,e);return(n={})=>new Promise(r=>{n.outro?be(t,()=>{D(t),r(void 0)}):(D(t),r(void 0))})}function Mi(e){return ce(Pe,e)}function Oi(e){return ce(xe|He,e)}function Zn(e,t=0){return ce(gt|t,e)}function Ge(e,t=[],n=[],r=[]){ui(r,t,n,i=>{ce(gt,()=>{e(...i.map(T))})})}function Xt(e,t=0){var n=ce(X|t,e);return n}function q(e){return ce(G|He,e)}function Wn(e){var t=e.teardown;if(t!==null){const n=ue,r=m;un(!0),V(null);try{t.call(null)}finally{un(n),V(r)}}}function Kt(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){const i=n.ac;i!==null&&ze(()=>{i.abort(Qe)});var r=n.next;(n.f&oe)!==0?n.parent=null:D(n,t),n=r}}function Ni(e){for(var t=e.first;t!==null;){var n=t.next;(t.f&G)===0&&D(t),t=n}}function D(e,t=!0){var n=!1;(t||(e.f&Ar)!==0)&&e.nodes!==null&&e.nodes.end!==null&&(ki(e.nodes.start,e.nodes.end),n=!0),e.f|=Ct,Kt(e,t&&!n),We(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(const s of r)s.stop();Wn(e),e.f^=Ct,e.f|=H;var i=e.parent;i!==null&&i.first!==null&&Jn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function ki(e,t){for(;e!==null;){var n=e===t?null:$e(e);e.remove(),e=n}}function Jn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function be(e,t,n=!0){var r=[];Qn(e,r,!0);var i=()=>{n&&D(e),t&&t()},s=r.length;if(s>0){var f=()=>--s||i();for(var l of r)l.out(f)}else i()}function Qn(e,t,n){if((e.f&R)===0){e.f^=R;var r=e.nodes&&e.nodes.t;if(r!==null)for(const l of r)(l.is_global||n)&&t.push(l);for(var i=e.first;i!==null;){var s=i.next;if((i.f&oe)===0){var f=(i.f&Re)!==0||(i.f&G)!==0&&(e.f&X)!==0;Qn(i,t,f?n:!1)}i=s}}}function _t(e){$n(e,!0)}function $n(e,t){if((e.f&R)!==0){e.f^=R,(e.f&N)===0&&(A(e,M),_e.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=(n.f&Re)!==0||(n.f&G)!==0;$n(n,i?t:!1),n=r}var s=e.nodes&&e.nodes.t;if(s!==null)for(const f of s)(f.is_global||t)&&f.in()}}function Zt(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:$e(n);t.append(n),n=i}}let lt=!1,ue=!1;function un(e){ue=e}let m=null,Z=!1;function V(e){m=e}let w=null;function ne(e){w=e}let te=null;function er(e){m!==null&&(te??=new Set).add(e)}let L=null,j=0,U=null;function Ci(e){U=e}let tr=1,ge=0,Ee=ge;function cn(e){Ee=e}function nr(){return++tr}function et(e){var t=e.f;if((t&M)!==0)return!0;if(t&C&&(e.f&=~ye),(t&W)!==0){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}(t&B)!==0&&K===null&&A(e,N)}return!1}function rr(e,t,n=!0){var r=e.reactions;if(r!==null&&!(te!==null&&te.has(e)))for(var i=0;i{e.ac.abort(Qe)}),e.ac=null);try{e.f|=dt;var c=e.fn,d=c();e.f|=je;var u=e.deps,h=b?.is_fork;if(L!==null){var v;if(h||We(e,j),u!==null&&j>0)for(u.length=j+L.length,v=0;v{s.ac.abort(Qe),s.ac=null,A(s,M)}),vi(s),We(s,0)}}function We(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,s))}return e.startsWith("pointer")||e.startsWith("touch")||e==="wheel"?ee(()=>{t.addEventListener(e,i,r)}):t.addEventListener(e,i,r),i}function Li(e,t,n,r,i){var s={capture:r,passive:i},f=Ii(e,t,n,s);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&Xn(()=>{t.removeEventListener(e,f,s)})}function Di(e,t,n){(t[Ve]??={})[e]=n}function Fi(e){for(var t=0;t{throw p});throw u}}finally{e[Ve]=t,delete e.currentTarget,V(c),ne(d)}}}const ji=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:e=>e});function Hi(e){return ji?.createHTML(e)??e}function lr(e){var t=yi("template");return t.innerHTML=Hi(e.replaceAll("","")),t.content}function Je(e,t){var n=w;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function tt(e,t){var n=(t&Zr)!==0,r=(t&Wr)!==0,i,s=!e.startsWith("");return()=>{i===void 0&&(i=lr(s?e:""+e),n||(i=De(i)));var f=r||Bn?document.importNode(i,!0):i.cloneNode(!0);if(n){var l=De(f),a=f.lastChild;Je(l,a)}else Je(f,f);return f}}function zi(e,t,n="svg"){var r=!e.startsWith(""),i=`<${n}>${r?e:""+e}`,s;return()=>{if(!s){var f=lr(i),l=De(f);s=De(l)}var a=s.cloneNode(!0);return Je(a,a),a}}function Ui(e,t){return zi(e,t,"svg")}function Us(e=""){{var t=le(e+"");return Je(t,t),t}}function qs(){var e=document.createDocumentFragment(),t=document.createComment(""),n=le();return e.append(t,n),Je(t,n),e}function Oe(e,t){e!==null&&e.before(t)}function Ue(e,t){var n=t==null?"":typeof t=="object"?`${t}`:t;n!==(e[Rt]??=e.nodeValue)&&(e[Rt]=n,e.nodeValue=`${n}`)}function Bs(e,t){return qi(e,t)}const st=new Map;function qi(e,{target:t,anchor:n,props:r={},events:i,context:s,intro:f=!0,transformError:l}){bi();var a=void 0,o=Ai(()=>{var c=n??t.appendChild(le());li(c,{pending:()=>{}},h=>{On({});var v=I;s&&(v.c=s),i&&(r.$$events=i),a=e(h,r)||{},Nn()},l);var d=new Set,u=h=>{for(var v=0;v{for(var h of d)for(const _ of[t,document]){var v=st.get(_),p=v.get(h);--p==0?(_.removeEventListener(h,Ft),v.delete(h),v.size===0&&st.delete(_)):v.set(h,p)}Dt.delete(u),c!==n&&c.parentNode?.removeChild(c)}});return jt.set(a,o),a}let jt=new WeakMap;function Gs(e,t){const n=jt.get(e);return n?(jt.delete(e),n(t)):Promise.resolve()}class Bi{anchor;#t=new Map;#f=new Map;#e=new Map;#l=new Set;#r=!0;constructor(t,n=!0){this.anchor=t,this.#r=n}#s=t=>{if(this.#t.has(t)){var n=this.#t.get(t),r=this.#f.get(n);if(r)_t(r),this.#l.delete(n);else{var i=this.#e.get(n);i&&(_t(i.effect),this.#f.set(n,i.effect),this.#e.delete(n),i.fragment.lastChild.remove(),this.anchor.before(i.fragment),r=i.effect)}for(const[s,f]of this.#t){if(this.#t.delete(s),s===t)break;const l=this.#e.get(f);l&&(D(l.effect),this.#e.delete(f))}for(const[s,f]of this.#f){if(s===n||this.#l.has(s))continue;const l=()=>{if(Array.from(this.#t.values()).includes(s)){var o=document.createDocumentFragment();Zt(f,o),o.append(le()),this.#e.set(s,{effect:f,fragment:o})}else D(f);this.#l.delete(s),this.#f.delete(s)};this.#r||!r?(this.#l.add(s),be(f,l,!1)):l()}}};#n=t=>{this.#t.delete(t);const n=Array.from(this.#t.values());for(const[r,i]of this.#e)n.includes(r)||(D(i.effect),this.#e.delete(r))};ensure(t,n){var r=b,i=Yn();if(n&&!this.#f.has(t)&&!this.#e.has(t))if(i){var s=document.createDocumentFragment(),f=le();s.append(f),this.#e.set(t,{effect:q(()=>n(f)),fragment:s})}else this.#f.set(t,q(()=>n(this.anchor)));if(this.#t.set(r,t),i){for(const[l,a]of this.#f)l===t?r.unskip_effect(a):r.skip_effect(a);for(const[l,a]of this.#e)l===t?r.unskip_effect(a.effect):r.skip_effect(a.effect);r.oncommit(this.#s),r.ondiscard(this.#n)}else this.#s(r)}}function hn(e,t,n=!1){var r=new Bi(e),i=n?Re:0;function s(f,l){r.ensure(f,l)}Xt(()=>{var f=!1;t((l,a=0)=>{f=!0,s(a,l)}),f||s(-1,null)},i)}function Vs(e,t){return t}function Gi(e,t,n){for(var r=[],i=t.length,s,f=t.length,l=0;l{if(s){if(s.pending.delete(d),s.done.add(d),s.pending.size===0){var u=e.outrogroups;Ht(e,pt(s.done)),u.delete(s),u.size===0&&(e.outrogroups=null)}}else f-=1},!1)}if(f===0){var a=r.length===0&&n!==null;if(a){var o=n,c=o.parentNode;Ei(c),c.append(o),e.items.clear()}Ht(e,t,!a)}else s={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(s)}function Ht(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(const f of e.pending.values())for(const l of f)r.add(e.items.get(l).e)}for(var i=0;i{var y=n();return mn(y)?y:y==null?[]:pt(y)}),u,h=new Map,v=!0;function p(y){(S.effect.f&H)===0&&(S.pending.delete(y),S.fallback=c,Yi(S,u,f,t,r),c!==null&&(u.length===0?(c.f&$)===0?_t(c):(c.f^=$,Ye(c,null,f)):be(c,()=>{c=null})))}function _(y){S.pending.delete(y)}var g=Xt(()=>{u=T(d);for(var y=u.length,E=new Set,x=b,Y=Yn(),P=0;Ps(f)):(c=q(()=>s(vn??=le())),c.f|=$)),y>E.size&&xr(),!v)if(h.set(x,E),Y){for(const[wt,mt]of l)E.has(wt)||x.skip_effect(mt.e);x.oncommit(p),x.ondiscard(_)}else p(x);T(d)}),S={effect:g,items:l,pending:h,outrogroups:null,fallback:c};v=!1}function qe(e){for(;e!==null&&(e.f&G)===0;)e=e.next;return e}function Yi(e,t,n,r,i){var s=(r&Br)!==0,f=t.length,l=e.items,a=qe(e.effect.first),o,c=null,d,u=[],h=[],v,p,_,g;if(s)for(g=0;g0){var re=(r&Tn)!==0&&f===0?n:null;if(s){for(g=0;g{if(d!==void 0)for(_ of d)_.nodes?.a?.apply()})}function Xi(e,t,n,r,i,s,f,l){var a=(f&Ur)!==0?(f&Gr)===0?wi(n,!1,!1):Se(n):null,o=(f&qr)!==0?Se(i):null;return{v:a,i:o,e:q(()=>(s(t,a??n,o??i,l),()=>{e.delete(r)}))}}function Ye(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,s=t&&(t.f&$)===0?t.nodes.start:n;r!==null;){var f=$e(r);if(s.before(r),r===i)return;r=f}}function de(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}const _n=[...` -\r\f \v\uFEFF`];function Ki(e,t,n){var r=e==null?"":""+e;if(t&&(r=r?r+" "+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+" "+i:i;else if(r.length)for(var s=i.length,f=0;(f=r.indexOf(i,f))>=0;){var l=f+s;(f===0||_n.includes(r[f-1]))&&(l===r.length||_n.includes(r[l]))?r=(f===0?"":r.substring(0,f))+r.substring(l+1):f=l}}return r===""?null:r}function pn(e,t=!1){var n=t?" !important;":";",r="";for(var i of Object.keys(e)){var s=e[i];s!=null&&s!==""&&(r+=" "+i+": "+s+n)}return r}function Tt(e){return e[0]!=="-"||e[1]!=="-"?e.toLowerCase():e}function Zi(e,t){if(t){var n="",r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\s*\/\*.*?\*\/\s*/g,"").trim();var s=!1,f=0,l=!1,a=[];r&&a.push(...Object.keys(r).map(Tt)),i&&a.push(...Object.keys(i).map(Tt));var o=0,c=-1;const p=e.length;for(var d=0;d{var f,l;return Zn(()=>{f=l,l=[],Wt(()=>{Ot(n(...l),e)||(t(e,...l),f&&Ot(n(...f),e)&&t(null,...f))})}),()=>{let a=s;for(;a!==i&&a.parent!==null&&a.parent.f&Ct;)a=a.parent;const o=()=>{l&&Ot(n(...l),e)&&t(null,...l)},c=a.teardown;a.teardown=()=>{o(),c?.()}}}),e}function se(e,t,n,r){var i=!0,s=(n&Xr)!==0,f=(n&Kr)!==0,l=r,a=!0,o=void 0,c=()=>f&&i?(o??=Ze(r),T(o)):(a&&(a=!1,l=f?Wt(r):r),l);let d;if(s){var u=we in e||Or in e;d=Ce(e,t)?.set??(u&&t in e?E=>e[t]=E:void 0)}var h,v=!1;s?[h,v]=ii(()=>e[t]):h=e[t],h===void 0&&r!==void 0&&(h=c(),d&&(Dr(),d(h)));var p;if(p=()=>{var E=e[t];return E===void 0?c():(a=!0,E)},(n&Yr)===0)return p;if(d){var _=e.$$legacy;return(function(E,x){return arguments.length>0?((!x||_||v)&&d(x?p():E),E):p()})}var g=!1,S=((n&Vr)!==0?Ze:Ln)(()=>(g=!1,p()));s&&T(S);var y=w;return(function(E,x){if(arguments.length>0){const Y=x?T(S):s?ke(E):E;return ae(S,Y),g=!0,l!==void 0&&(l=Y),E}return ue&&g||(y.f&H)!==0?S.v:T(S)})}const es="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(es);var ts=Ui('');function Zs(e,t){const n=se(t,"size",3,"1.5rem"),r={search:"M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14M16.5 16.5 21 21",tare:"M8 4h8M9 4v3.2a3 3 0 0 1-.5 1.7l-.8 1.2a4 4 0 0 0-.7 2.2V18a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-5.7a4 4 0 0 0-.7-2.2l-.8-1.2a3 3 0 0 1-.5-1.7V4M7 14h10",printer:"M7 9V4h10v5M7 20h10v-6H7zM7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2",alert:"M12 3.5 21.5 20h-19zM12 10v4M12 17.2v.1",backspace:"M9 5h11a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-7-7zM17 9l-6 6M11 9l6 6",check:"M4 12.5 9.5 18 20 7",close:"M6 6l12 12M18 6 6 18",settings:"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5v.2a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1h.2a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1"};var i=ts();let s;var f=Q(i);Ge(()=>{s=ot(i,"",s,{width:n(),height:n()}),zt(f,"d",r[t.name])}),Oe(e,i)}async function Ws(){const e=await fetch("/api/v1/catalog",{headers:{accept:"application/json"}});if(!e.ok)throw new Error(`GET /api/v1/catalog: ${e.status}`);return await e.json()}async function Js(e){await Qt("/api/v1/weigh",e)}async function Qs(e,t){await Qt("/api/v1/reprint",{job_id:e,key:t})}async function $s(){await Qt("/api/v1/dismiss",{})}function ef(e,t){fetch("/api/v1/ui/error",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({message:e,stack:t}),keepalive:!0}).catch(()=>{})}function tf(e){fetch("/api/v1/ui/layout-notice",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({message:e}),keepalive:!0}).catch(()=>{})}async function Qt(e,t){const n=await fetch(e,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(t)});if(!n.ok)throw new Error(`POST ${e}: ${n.status}`)}const or={Œ:"oe",œ:"oe",Æ:"ae",æ:"ae",ß:"ss",Ø:"o",ø:"o"},ns=new RegExp(`[${Object.keys(or).join("")}]`,"gu"),rs=/\p{Mn}/u,is=/[\p{L}\p{Nd}]/u;function ss(e){const n=e.replace(ns,s=>or[s]).normalize("NFD");let r="",i=!1;for(const s of n)if(!rs.test(s)){if(is.test(s)){i&&r.length>0&&(r+=" "),i=!1,r+=s.toLowerCase();continue}i=!0}return r}const fs=5,ur="",as="by_unit";function ls(e){const t=new Set(e.categories.filter(r=>!r.visible).map(r=>r.code)),n=!e.presentation.show_by_unit_products;return t.size===0&&!n?e.products:e.products.filter(r=>!t.has(r.category_code)&&!(n&&r.mode===as))}function nf(e){const t=ls(e),n=new Map;for(const i of t)n.set(i.category_code,(n.get(i.category_code)??0)+1);const r=e.categories.filter(i=>i.visible&&(n.get(i.code)??0)>=fs).slice().sort((i,s)=>i.rank-s.rank||i.code.localeCompare(s.code)).map(i=>({code:i.code,label:i.label,color:i.color,count:n.get(i.code)??0}));return[{code:ur,label:"Tout",color:"var(--ink-muted)",count:t.length},...r]}function rf(e,t,n){const r=ss(n),i=r.length===0?[]:r.split(" ");return e.filter(s=>t!==ur&&s.category_code!==t?!1:i.every(f=>s.search.includes(f)))}const os=4.5,us=.1,cs={r:138,g:134,b:124};function cr(e){const t=/^#([\da-f])([\da-f])([\da-f])$/iu.exec(e);if(t!==null){const[,f,l,a]=t;return{r:Number.parseInt(f+f,16),g:Number.parseInt(l+l,16),b:Number.parseInt(a+a,16)}}const n=/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/iu.exec(e);if(n===null)return cs;const[,r,i,s]=n;return{r:Number.parseInt(r,16),g:Number.parseInt(i,16),b:Number.parseInt(s,16)}}function Nt(e){const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4}function ds({r:e,g:t,b:n}){return .2126*Nt(e)+.7152*Nt(t)+.0722*Nt(n)}function hs(e){return 1.05/(ds(e)+.05)}function vs(e){const{r:t,g:n,b:r}=cr(e),i=s=>Math.round(255-us*(255-s));return`rgb(${i(t)}, ${i(n)}, ${i(r)})`}function _s(e){let t=cr(e);for(let n=0;n<16&&hs(t)Ut;a-=ps){const o=a/dr;if(Ts(s,f,o,l)<=bs(a,r))return a}return Ut}const Es=/(?<=[-‐–—])(?!\d)/u;function ys(e,t){const n=[];for(const r of e.split(/\s+/u).filter(i=>i.length>0)){const i=r.split(Es).filter(s=>s.length>0);for(const[s,f]of i.entries())n.push({width:t(f),length:[...f].length,spaced:s===0})}return n}function Ss(e){return Math.max(0,e("a a")-e("aa"))}function Ts(e,t,n,r){let i=1,s=0;for(const f of e){const l=f.width*n,a=f.spaced?t*n:0;if(s>0&&s+a+l<=r){s+=a+l;continue}if(s>0&&(i++,s=0),l<=r){s=l;continue}i+=As(l,f.length,r)-1,s=r}return i}function As(e,t,n){const r=Math.max(1,Math.floor(n*t/e));return Math.ceil(t/r)}function af(e,t){const r=document.createElement("canvas").getContext("2d");if(r===null||typeof r.measureText!="function")return null;r.font=`${t} ${dr}px ${e}`;const i=new Map;return s=>{const f=i.get(s);if(f!==void 0)return f;const l=r.measureText(s).width;return i.set(s,l),l}}var Ms=tt(''),Os=tt(''),Ns=tt(' '),ks=tt(''),Cs=tt('');function lf(e,t){On(t,!0);const n=se(t,"nameSizePx",3,$t),r=se(t,"categoryColor",3,"#8a867c"),i=se(t,"primaryCode",3,""),s=se(t,"tierAbbrev",19,()=>({})),f=se(t,"selected",3,!1),l=se(t,"rejected",3,!1),a=se(t,"busy",3,!1),o=se(t,"showPrice",3,!0);let c=ie("");const d=rt(()=>t.product.image_url!==""&&t.product.image_url!==T(c)),u=rt(()=>p(t.product.name)),h=rt(()=>vs(r())),v=rt(()=>_s(r()));function p(z){const F=z.match(/\p{L}/u);return F===null?"·":F[0].toUpperCase()}var _=Cs();let g;var S=Q(_);let y;var E=Q(S);{var x=z=>{var F=Ms();Ge(()=>zt(F,"src",t.product.image_url)),Li("error",F,()=>ae(c,t.product.image_url,!0)),Oe(z,F)},Y=z=>{var F=Os();let Te;var Ae=Q(F);Ge(()=>{Te=ot(F,"",Te,{color:T(v)}),Ue(Ae,T(u))}),Oe(z,F)};hn(E,z=>{T(d)?z(x):z(Y,-1)})}var P=it(S,2),J=Q(P);let re;var k=Q(J),wt=it(P,2);{var mt=z=>{var F=ks();Vi(F,21,()=>t.product.prices,Te=>Te.code,(Te,Ae)=>{var bt=Ns();let en;var Et=Q(bt);let tn;var hr=Q(Et),nn=it(Et,2),vr=Q(nn),_r=it(nn,2),pr=Q(_r);Ge(gr=>{en=At(bt,1,"price svelte-1ctu6qg",null,en,{secondary:T(Ae).code!==i()}),tn=At(Et,1,"abbrev svelte-1ctu6qg",null,tn,{hollow:T(Ae).code!==i()}),Ue(hr,s()[T(Ae).code]??""),Ue(vr,T(Ae).text),Ue(pr,gr)},[()=>t.product.price_suffix.trim()]),Oe(Te,bt)}),Oe(z,F)};hn(wt,z=>{o()&&z(mt)})}Ge(()=>{g=At(_,1,"tile touch-target svelte-1ctu6qg",null,g,{selected:f(),rejected:l()}),zt(_,"data-product-id",t.product.id),_.disabled=a(),y=ot(S,"",y,{background:T(h)}),re=ot(J,"",re,{"font-size":`${n()??""}px`}),Ue(k,t.product.name)}),Di("pointerdown",_,()=>t.onpick(t.product)),Oe(e,_),Nn()}Fi(["pointerdown"]);const xs=0,Ps="repeat(auto-fill, minmax(var(--tile-min), 1fr))";function of(e){return e===xs?Ps:`repeat(${String(e)}, minmax(0, 1fr))`}export{b as $,ie as A,Vs as B,of as C,ff as D,sf as E,af as F,Ys as G,ke as H,Zs as I,Ws as J,ur as K,Js as L,rf as M,ms as N,nf as O,Qs as P,ls as Q,dr as R,$s as S,lf as T,tf as U,ef as V,Bs as W,Xt as X,Re as Y,Bi as Z,Ds as _,se as a,Xn as a0,mn as a1,Is as a2,Fs as a3,Zn as a4,zs as a5,I as a6,Ls as a7,xs as a8,Xs as a9,Ut as aa,Li as ab,qs as ac,Sr as ad,Us as ae,Gs as af,Di as b,Oe as c,Fi as d,Mi as e,Nn as f,tt as g,T as h,hn as i,zt as j,ot as k,At as l,Ue as m,rt as n,Q as o,On as p,js as q,Vi as r,it as s,Ge as t,Wt as u,_s as v,vs as w,Hs as x,Ks as y,ae as z}; +\r\f \v\uFEFF`];function Ki(e,t,n){var r=e==null?"":""+e;if(t&&(r=r?r+" "+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+" "+i:i;else if(r.length)for(var s=i.length,f=0;(f=r.indexOf(i,f))>=0;){var l=f+s;(f===0||_n.includes(r[f-1]))&&(l===r.length||_n.includes(r[l]))?r=(f===0?"":r.substring(0,f))+r.substring(l+1):f=l}}return r===""?null:r}function pn(e,t=!1){var n=t?" !important;":";",r="";for(var i of Object.keys(e)){var s=e[i];s!=null&&s!==""&&(r+=" "+i+": "+s+n)}return r}function Tt(e){return e[0]!=="-"||e[1]!=="-"?e.toLowerCase():e}function Zi(e,t){if(t){var n="",r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\s*\/\*.*?\*\/\s*/g,"").trim();var s=!1,f=0,l=!1,a=[];r&&a.push(...Object.keys(r).map(Tt)),i&&a.push(...Object.keys(i).map(Tt));var o=0,c=-1;const p=e.length;for(var d=0;d{var f,l;return Zn(()=>{f=l,l=[],Wt(()=>{Ot(n(...l),e)||(t(e,...l),f&&Ot(n(...f),e)&&t(null,...f))})}),()=>{let a=s;for(;a!==i&&a.parent!==null&&a.parent.f&Ct;)a=a.parent;const o=()=>{l&&Ot(n(...l),e)&&t(null,...l)},c=a.teardown;a.teardown=()=>{o(),c?.()}}}),e}function se(e,t,n,r){var i=!0,s=(n&Xr)!==0,f=(n&Kr)!==0,l=r,a=!0,o=void 0,c=()=>f&&i?(o??=Ze(r),T(o)):(a&&(a=!1,l=f?Wt(r):r),l);let d;if(s){var u=we in e||Or in e;d=Ce(e,t)?.set??(u&&t in e?E=>e[t]=E:void 0)}var h,v=!1;s?[h,v]=ii(()=>e[t]):h=e[t],h===void 0&&r!==void 0&&(h=c(),d&&(Dr(),d(h)));var p;if(p=()=>{var E=e[t];return E===void 0?c():(a=!0,E)},(n&Yr)===0)return p;if(d){var _=e.$$legacy;return(function(E,x){return arguments.length>0?((!x||_||v)&&d(x?p():E),E):p()})}var g=!1,S=((n&Vr)!==0?Ze:Ln)(()=>(g=!1,p()));s&&T(S);var y=w;return(function(E,x){if(arguments.length>0){const Y=x?T(S):s?ke(E):E;return ae(S,Y),g=!0,l!==void 0&&(l=Y),E}return ue&&g||(y.f&H)!==0?S.v:T(S)})}const es="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(es);var ts=Ui('');function Zs(e,t){const n=se(t,"size",3,"1.5rem"),r={search:"M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14M16.5 16.5 21 21",tare:"M8 4h8M9 4v3.2a3 3 0 0 1-.5 1.7l-.8 1.2a4 4 0 0 0-.7 2.2V18a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-5.7a4 4 0 0 0-.7-2.2l-.8-1.2a3 3 0 0 1-.5-1.7V4M7 14h10",printer:"M7 9V4h10v5M7 20h10v-6H7zM7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2",alert:"M12 3.5 21.5 20h-19zM12 10v4M12 17.2v.1",backspace:"M9 5h11a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-7-7zM17 9l-6 6M11 9l6 6",check:"M4 12.5 9.5 18 20 7",close:"M6 6l12 12M18 6 6 18",settings:"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5v.2a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1h.2a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1"};var i=ts();let s;var f=Q(i);Ge(()=>{s=ot(i,"",s,{width:n(),height:n()}),zt(f,"d",r[t.name])}),Oe(e,i)}async function Ws(){const e=await fetch("/api/v1/catalog",{headers:{accept:"application/json"}});if(!e.ok)throw new Error(`GET /api/v1/catalog: ${e.status}`);return await e.json()}async function Js(e){await Qt("/api/v1/weigh",e)}async function Qs(e,t){await Qt("/api/v1/reprint",{job_id:e,key:t})}async function $s(){await Qt("/api/v1/dismiss",{})}function ef(e,t){fetch("/api/v1/ui/error",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({message:e,stack:t}),keepalive:!0}).catch(()=>{})}function tf(e){fetch("/api/v1/ui/layout-notice",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({message:e}),keepalive:!0}).catch(()=>{})}async function Qt(e,t){const n=await fetch(e,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(t)});if(!n.ok)throw new Error(`POST ${e}: ${n.status}`)}const or={Œ:"oe",œ:"oe",Æ:"ae",æ:"ae",ß:"ss",Ø:"o",ø:"o"},ns=new RegExp(`[${Object.keys(or).join("")}]`,"gu"),rs=/\p{Mn}/u,is=/[\p{L}\p{Nd}]/u;function ss(e){const n=e.replace(ns,s=>or[s]).normalize("NFD");let r="",i=!1;for(const s of n)if(!rs.test(s)){if(is.test(s)){i&&r.length>0&&(r+=" "),i=!1,r+=s.toLowerCase();continue}i=!0}return r}const fs=5,ur="",as="by_unit";function ls(e){const t=new Set(e.categories.filter(r=>!r.visible).map(r=>r.code)),n=!e.presentation.show_by_unit_products;return t.size===0&&!n?e.products:e.products.filter(r=>!t.has(r.category_code)&&!(n&&r.mode===as))}function nf(e){const t=ls(e),n=new Map;for(const i of t)n.set(i.category_code,(n.get(i.category_code)??0)+1);const r=e.categories.filter(i=>i.visible&&(n.get(i.code)??0)>=fs).slice().sort((i,s)=>i.rank-s.rank||i.code.localeCompare(s.code)).map(i=>({code:i.code,label:i.label,color:i.color,count:n.get(i.code)??0}));return[{code:ur,label:"Tout",color:"var(--ink-muted)",count:t.length},...r]}function rf(e,t,n){const r=ss(n),i=r.length===0?[]:r.split(" ");return e.filter(s=>t!==ur&&s.category_code!==t?!1:i.every(f=>s.search.includes(f)))}const os=4.5,us=.1,cs={r:138,g:134,b:124};function cr(e){const t=/^#([\da-f])([\da-f])([\da-f])$/iu.exec(e);if(t!==null){const[,f,l,a]=t;return{r:Number.parseInt(f+f,16),g:Number.parseInt(l+l,16),b:Number.parseInt(a+a,16)}}const n=/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/iu.exec(e);if(n===null)return cs;const[,r,i,s]=n;return{r:Number.parseInt(r,16),g:Number.parseInt(i,16),b:Number.parseInt(s,16)}}function Nt(e){const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4}function ds({r:e,g:t,b:n}){return .2126*Nt(e)+.7152*Nt(t)+.0722*Nt(n)}function hs(e){return 1.05/(ds(e)+.05)}function vs(e){const{r:t,g:n,b:r}=cr(e),i=s=>Math.round(255-us*(255-s));return`rgb(${i(t)}, ${i(n)}, ${i(r)})`}function _s(e){let t=cr(e);for(let n=0;n<16&&hs(t)Ut;a-=ps){const o=a/dr;if(Ts(s,f,o,l)<=bs(a,r))return a}return Ut}const Es=/(?<=[-‐–—])(?!\d)/u;function ys(e,t){const n=[];for(const r of e.split(/\s+/u).filter(i=>i.length>0)){const i=r.split(Es).filter(s=>s.length>0);for(const[s,f]of i.entries())n.push({width:t(f),length:[...f].length,spaced:s===0})}return n}function Ss(e){return Math.max(0,e("a a")-e("aa"))}function Ts(e,t,n,r){let i=1,s=0;for(const f of e){const l=f.width*n,a=f.spaced?t*n:0;if(s>0&&s+a+l<=r){s+=a+l;continue}if(s>0&&(i++,s=0),l<=r){s=l;continue}i+=As(l,f.length,r)-1,s=r}return i}function As(e,t,n){const r=Math.max(1,Math.floor(n*t/e));return Math.ceil(t/r)}function af(e,t){const r=document.createElement("canvas").getContext("2d");if(r===null||typeof r.measureText!="function")return null;r.font=`${t} ${dr}px ${e}`;const i=new Map;return s=>{const f=i.get(s);if(f!==void 0)return f;const l=r.measureText(s).width;return i.set(s,l),l}}var Ms=tt(''),Os=tt(''),Ns=tt(' '),ks=tt(''),Cs=tt('');function lf(e,t){On(t,!0);const n=se(t,"nameSizePx",3,$t),r=se(t,"categoryColor",3,"#8a867c"),i=se(t,"primaryCode",3,""),s=se(t,"tierAbbrev",19,()=>({})),f=se(t,"selected",3,!1),l=se(t,"rejected",3,!1),a=se(t,"busy",3,!1),o=se(t,"showPrice",3,!0);let c=ie("");const d=rt(()=>t.product.image_url!==""&&t.product.image_url!==T(c)),u=rt(()=>p(t.product.name)),h=rt(()=>vs(r())),v=rt(()=>_s(r()));function p(z){const F=z.match(/\p{L}/u);return F===null?"·":F[0].toUpperCase()}var _=Cs();let g;var S=Q(_);let y;var E=Q(S);{var x=z=>{var F=Ms();Ge(()=>zt(F,"src",t.product.image_url)),Li("error",F,()=>ae(c,t.product.image_url,!0)),Oe(z,F)},Y=z=>{var F=Os();let Te;var Ae=Q(F);Ge(()=>{Te=ot(F,"",Te,{color:T(v)}),Ue(Ae,T(u))}),Oe(z,F)};hn(E,z=>{T(d)?z(x):z(Y,-1)})}var P=it(S,2),J=Q(P);let re;var k=Q(J),wt=it(P,2);{var mt=z=>{var F=ks();Vi(F,21,()=>t.product.prices,Te=>Te.code,(Te,Ae)=>{var bt=Ns();let en;var Et=Q(bt);let tn;var hr=Q(Et),nn=it(Et,2),vr=Q(nn),_r=it(nn,2),pr=Q(_r);Ge(gr=>{en=At(bt,1,"price svelte-1ctu6qg",null,en,{secondary:T(Ae).code!==i()}),tn=At(Et,1,"abbrev svelte-1ctu6qg",null,tn,{hollow:T(Ae).code!==i()}),Ue(hr,s()[T(Ae).code]??""),Ue(vr,T(Ae).text),Ue(pr,gr)},[()=>t.product.price_suffix.trim()]),Oe(Te,bt)}),Oe(z,F)};hn(wt,z=>{o()&&z(mt)})}Ge(()=>{g=At(_,1,"tile touch-target svelte-1ctu6qg",null,g,{selected:f(),rejected:l()}),zt(_,"data-product-id",t.product.id),_.disabled=a(),y=ot(S,"",y,{background:T(h)}),re=ot(J,"",re,{"font-size":`${n()??""}px`}),Ue(k,t.product.name)}),Di("pointerdown",_,()=>t.onpick(t.product)),Oe(e,_),Nn()}Fi(["pointerdown"]);const xs=0,Ps="repeat(auto-fill, minmax(var(--tile-min), 1fr))";function of(e){return e===xs?Ps:`repeat(${String(e)}, minmax(0, 1fr))`}export{b as $,ie as A,Vs as B,of as C,ff as D,sf as E,af as F,Ys as G,ke as H,Zs as I,Ws as J,ur as K,Js as L,rf as M,ms as N,nf as O,Qs as P,ls as Q,dr as R,$s as S,lf as T,tf as U,ef as V,Bs as W,Xt as X,Re as Y,Bi as Z,Ds as _,se as a,Xn as a0,mn as a1,Is as a2,Fs as a3,Zn as a4,zs as a5,I as a6,Ls as a7,Xs as a8,qs as a9,Us as aa,xs as ab,Ut as ac,Li as ad,Gs as ae,Di as b,Oe as c,Fi as d,Mi as e,Nn as f,tt as g,T as h,hn as i,zt as j,ot as k,At as l,Ue as m,rt as n,Q as o,On as p,js as q,Vi as r,it as s,Ge as t,Wt as u,_s as v,vs as w,Hs as x,Ks as y,ae as z}; diff --git a/internal/web/dist/assets/index-D4d8uX0R.js b/internal/web/dist/assets/index-DQl0r1eZ.js similarity index 98% rename from internal/web/dist/assets/index-D4d8uX0R.js rename to internal/web/dist/assets/index-DQl0r1eZ.js index f37c4ef..06e0afe 100644 --- a/internal/web/dist/assets/index-D4d8uX0R.js +++ b/internal/web/dist/assets/index-DQl0r1eZ.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/mount-BVSrRvN7.js","assets/app-oxQr6rjd.js","assets/app-Cfxw4Luj.css","assets/mount-CBfLWXOt.css"])))=>i.map(i=>d[i]); -import{e as je,u as De,d as re,p as Y,a as R,i as W,I as ee,t as C,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 Ie,v as Te,x as ne,y as ze,R as qe,z as b,A as I,B as Re,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as Ce,J as He,K as Ue,L as Ve,M as Ge,O as Ke,P as Xe,Q as Je,S as Qe,U as Ye,V as Ze,W as $e}from"./app-oxQr6rjd.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 et=new be({box:"border-box"});function he(s,e,n){var r=et.observe(s,()=>n(s[e]));je(()=>(De(()=>n(s[e])),r))}const tt="modulepreload",nt=function(s){return"/"+s},ye={},at=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let m=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=m(n.map(o=>{if(o=nt(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":tt,i||(k.as="script"),k.crossOrigin="",k.href=o,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((_,j)=>{k.addEventListener("load",_),k.addEventListener("error",()=>j(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 st=E('

kg

',1),rt=E('

kg

Poids indisponible

',1),it=E('');function lt(s,e){Y(e,!0);const n=R(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=it(),m=u(d);let o;var i=u(m);{var y=A=>{var O=st(),G=Pe(O),K=u(G),X=h(G,2),F=u(X);C(()=>{T(K,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=rt();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var _=h(m,2);let j;var g=u(_),P=h(_,2);let B;var q=u(P);ee(q,{name:"tare",size:"1.4em"});var H=h(P,2);let V;C(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),o=te(m,"",o,{"border-left-color":t(l)}),j=se(_,1,"instruction svelte-4gizvm",null,j,{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 ot=E(''),ct=E('');function ut(s,e){Y(e,!0);var n=ct(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,m)=>{var o=ot();let i,y;var k=h(u(o),2),_=u(k),j=h(k,2),g=u(j);C(P=>{i=se(o,1,"chip touch-target svelte-atgty1",null,i,{active:t(m).code===e.active}),ae(o,"aria-pressed",t(m).code===e.active),y=te(o,"",y,P),T(_,t(m).label),T(g,t(m).count)},[()=>({"--chip-ink":Te(t(m).color),"--chip-wash":Ie(t(m).color)})]),N("click",o,()=>e.onselect(t(m).code)),w(d,o)});var l=h(r,2);let f;var c=u(l);ee(c,{name:"search",size:"1.75rem"}),C(()=>{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 dt=E(''),vt=E('

'),gt=E('

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

Chargement du catalogue…

',1),pt=E('

'),mt=E('

'),_t=E('
'),bt=E('
'),yt=E('
');function wt(s,e){Y(e,!0);const n=R(e,"gridColumns",3,0),r=R(e,"colors",19,()=>({})),l=R(e,"primaryCode",3,""),f=R(e,"tierAbbrev",19,()=>({})),c=R(e,"selectedID",3,null),d=R(e,"rejectedID",3,null),m=R(e,"busyID",3,null),o=R(e,"showPrices",3,!0),i=R(e,"loading",3,!1),y=R(e,"emptyMessage",3,"Aucun produit ne correspond."),k=R(e,"emptyHint",3,""),_=8,j=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let q=I(0),H=I(0),V=I(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${qe}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=I(null),G=I(0),K=I(0);const X=v(()=>n()>0?n():t(q)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(q)+_)/(t(H)+_)))),F=v(()=>t(X)>0&&t(q)>0?(t(q)-(t(X)-1)*_)/t(X):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),z=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-j: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(K)>0?t(K):Oe;for(const D of e.products)x.set(D.id,Me(D.name,t(ie),t(A),L,t(z)));return x});var J=yt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=ft(),D=Pe(L);fe(D,21,()=>({length:B}),Re,(a,S)=>{var U=ht();w(a,U)}),C(()=>te(D,t(M))),he(D,"clientWidth",a=>b(q,a)),w(x,L)},de=x=>{var L=mt(),D=u(L),a=u(D),S=h(D,2);{var U=Q=>{var ge=pt(),me=u(ge);C(()=>T(me,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}C(()=>T(a,y())),w(x,L)},ve=x=>{var L=bt();fe(L,21,()=>e.products,D=>D.id,(D,a)=>{var S=_t(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(z)),ge=v(()=>t(a).id===c()),me=v(()=>t(a).id===d()),Se=v(()=>t(a).id===m());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(me)},get busy(){return t(Se)},get showPrice(){return o()},get onpick(){return e.onpick}})}w(D,S)}),C(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",D=>b(q,D)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}ze(J,x=>b(O,x),()=>t(O)),C(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(K,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var Et=E(''),kt=E('
');function xt(s,e){Y(e,!0);let n=I(null);ne(()=>{t(n)?.focus()});var r=kt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);ze(f,i=>b(n,i),()=>t(n));var c=h(f,2);{var d=i=>{var y=Et();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(c,i=>{e.query!==""&&i(d)})}var m=h(c,2),o=u(m);ee(o,{name:"close",size:"1.5rem"}),C(()=>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",m,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function At(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 Pt(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 '),Ct=E('Catalogue en attente'),St=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),It=E(''),Tt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=R(e,"catalogAt",3,""),r=R(e,"productCount",3,0),l=R(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var c=Tt();let d;var m=u(c),o=u(m);{var i=p=>{var z=zt(),M=h(u(z)),$=u(M);C(()=>T($,n())),w(p,z)},y=p=>{var z=Ct();w(p,z)};W(o,p=>{n()!==""?p(i):p(y,-1)})}var k=h(o,2);{var _=p=>{var z=St(),M=u(z);C(()=>T(M,t(f))),w(p,z)};W(k,p=>{t(f)!==""&&p(_)})}var j=h(m,4),g=h(u(j),2);{var P=p=>{var z=jt();w(p,z)},B=p=>{var z=Dt(),M=u(z);C($=>T(M,$),[()=>Pt(e.label)]),w(p,z)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var q=h(j,2);{var H=p=>{var z=It();C(()=>z.disabled=!e.available),N("click",z,function(...M){e.onreprint?.apply(this,M)}),w(p,z)};W(q,p=>{e.label!==null&&p(H)})}var V=h(q,2),A=u(V);let O;var G=h(A,2),K=u(G),X=h(V,2),F=u(X);ee(F,{name:"settings",size:"1.75rem"}),C(()=>{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(K,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",X,function(...p){e.onadmin?.apply(this,p)}),w(s,c),Z()}re(["click"]);var Rt=E(''),Lt=E('

Tare g

');function Nt(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Lt(),l=u(r),f=h(u(l),2),c=u(f),d=h(l,2);fe(d,20,()=>n,_=>_,(_,j)=>{var g=Rt(),P=u(g);C(()=>T(P,j)),N("click",g,()=>e.ondigit(j)),w(_,g)});var m=h(d,2),o=u(m),i=h(o,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),C(()=>T(c,e.grams===""?"0":e.grams)),N("click",o,function(..._){e.onclear?.apply(this,_)}),N("click",i,function(..._){e.oncancel?.apply(this,_)}),N("click",y,function(..._){e.onconfirm?.apply(this,_)}),w(s,r),Z()}re(["click"]);function Ot(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Mt=2e3,ke=5e3;function Wt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Mt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Bt=250,Ft={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0};class Ht{#e=I(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=I(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=I(Ce({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=I("");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??Ft}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Bt)}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=Wt(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 He(),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 Ut=E('
');function Vt(s,e){Y(e,!0);const n=new Ht;let r=I(Ce(Ue)),l=I(""),f=I(!1);const c=v(()=>t(l)!==""||t(f));let d=I(null),m=I(-1),o=I(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Je(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),_=v(()=>Ge(t(y),t(r),t(l))),j=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(()=>Ot(t(g))),q=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(()=>At(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(m)&&(b(d,null),b(m,-1))});async function K(a){if(t(d)===null){b(d,a.id,!0),b(m,n.state?.revision??-1,!0);try{await Ve({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 X(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Xe(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function z(){const a=t(_).length===1?t(_)[0]:void 0;a!==void 0&&K(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"){z();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 at(()=>import("./mount-BVSrRvN7.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Ut(),oe=u(ie);{let a=v(()=>t(o)!==null);lt(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=>{Nt(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==="");wt(le,{get products(){return t(_)},get colors(){return t(j)},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(q).message},get emptyHint(){return t(q).hint},onpick:K})}var ue=h(le,2);{var pe=a=>{xt(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:z})};W(ue,a=>{t(c)&&a(pe)})}var de=h(ue,2);ut(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:X,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:()=>{Qe()}}})}},D=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(D,1)})}w(s,ie),Z()}const Gt=5,Kt="ResizeObserver loop";function Xt(){let s=!1,e=!1;const n=(c,d)=>{s||(s=!0,Ze(c,d),Jt(),setTimeout(()=>location.reload(),Gt*1e3))},r=c=>{e||(e=!0,Ye(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 Jt(){const s=document.createElement("div");s.className="fatal",s.append(_e("h1","Une erreur est survenue"),_e("p","L’écran va se recharger tout seul."),_e("p","ERR-UI-01","code")),document.body.appendChild(s)}function _e(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Xt();document.addEventListener("contextmenu",s=>{Qt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());$e(Vt,{target:document.getElementById("app")});function Qt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/mount-DmM_mBVD.js","assets/app-CFgWi82J.js","assets/app-Cfxw4Luj.css","assets/mount-MmpjwdB2.css"])))=>i.map(i=>d[i]); +import{e as je,u as De,d as re,p as Y,a as R,i as W,I as ee,t as C,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 Ie,v as Te,x as ne,y as ze,R as qe,z as b,A as I,B as Re,T as Le,C as Ne,N as Oe,D as Me,E as We,F as Be,G as Fe,H as Ce,J as He,K as Ue,L as Ve,M as Ge,O as Ke,P as Xe,Q as Je,S as Qe,U as Ye,V as Ze,W as $e}from"./app-CFgWi82J.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 et=new be({box:"border-box"});function he(s,e,n){var r=et.observe(s,()=>n(s[e]));je(()=>(De(()=>n(s[e])),r))}const tt="modulepreload",nt=function(s){return"/"+s},ye={},at=function(e,n,r){let l=Promise.resolve();if(n&&n.length>0){let m=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=m(n.map(o=>{if(o=nt(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":tt,i||(k.as="script"),k.crossOrigin="",k.href=o,d&&k.setAttribute("nonce",d),document.head.appendChild(k),i)return new Promise((_,j)=>{k.addEventListener("load",_),k.addEventListener("error",()=>j(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 st=E('

kg

',1),rt=E('

kg

Poids indisponible

',1),it=E('');function lt(s,e){Y(e,!0);const n=R(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=it(),m=u(d);let o;var i=u(m);{var y=A=>{var O=st(),G=Pe(O),K=u(G),X=h(G,2),F=u(X);C(()=>{T(K,e.snapshot.weight.net_text),T(F,`tare ${e.snapshot.weight.tare_g??""} g`)}),w(A,O)},k=A=>{var O=rt();w(A,O)};W(i,A=>{t(f)&&e.snapshot?A(y):A(k,-1)})}var _=h(m,2);let j;var g=u(_),P=h(_,2);let B;var q=u(P);ee(q,{name:"tare",size:"1.4em"});var H=h(P,2);let V;C(()=>{ae(d,"data-state",e.snapshot?.state??"initializing"),o=te(m,"",o,{"border-left-color":t(l)}),j=se(_,1,"instruction svelte-4gizvm",null,j,{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 ot=E(''),ct=E('');function ut(s,e){Y(e,!0);var n=ct(),r=u(n);fe(r,21,()=>e.chips,d=>d.code,(d,m)=>{var o=ot();let i,y;var k=h(u(o),2),_=u(k),j=h(k,2),g=u(j);C(P=>{i=se(o,1,"chip touch-target svelte-atgty1",null,i,{active:t(m).code===e.active}),ae(o,"aria-pressed",t(m).code===e.active),y=te(o,"",y,P),T(_,t(m).label),T(g,t(m).count)},[()=>({"--chip-ink":Te(t(m).color),"--chip-wash":Ie(t(m).color)})]),N("click",o,()=>e.onselect(t(m).code)),w(d,o)});var l=h(r,2);let f;var c=u(l);ee(c,{name:"search",size:"1.75rem"}),C(()=>{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 dt=E(''),vt=E('

'),gt=E('

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

Chargement du catalogue…

',1),pt=E('

'),mt=E('

'),_t=E('
'),bt=E('
'),yt=E('
');function wt(s,e){Y(e,!0);const n=R(e,"gridColumns",3,0),r=R(e,"colors",19,()=>({})),l=R(e,"primaryCode",3,""),f=R(e,"tierAbbrev",19,()=>({})),c=R(e,"selectedID",3,null),d=R(e,"rejectedID",3,null),m=R(e,"busyID",3,null),o=R(e,"showPrices",3,!0),i=R(e,"loading",3,!1),y=R(e,"emptyMessage",3,"Aucun produit ne correspond."),k=R(e,"emptyHint",3,""),_=8,j=26,g="'Inter Variable', system-ui, sans-serif",P="'Inter Variable'",B=16;let q=I(0),H=I(0),V=I(!1);ne(()=>{const x=document.fonts;x!==void 0&&x.load(`700 ${qe}px ${P}`).catch(()=>[]).then(()=>{b(V,!0)})});const A=v(()=>(t(V),Be(g,700)));let O=I(null),G=I(0),K=I(0);const X=v(()=>n()>0?n():t(q)<=0||t(H)<=0?0:Math.max(1,Math.floor((t(q)+_)/(t(H)+_)))),F=v(()=>t(X)>0&&t(q)>0?(t(q)-(t(X)-1)*_)/t(X):0),p=v(()=>n()>0&&t(F)>0&&t(H)>0?t(F)/t(H):1),z=v(()=>We(t(p))),M=v(()=>n()>0?`grid-template-columns: ${Ne(n())}`:void 0),$=v(()=>t(F)>0?t(F)-j: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(K)>0?t(K):Oe;for(const D of e.products)x.set(D.id,Me(D.name,t(ie),t(A),L,t(z)));return x});var J=yt(),ce=u(J),le=h(ce,2),ue=h(le,2);{var pe=x=>{var L=ft(),D=Pe(L);fe(D,21,()=>({length:B}),Re,(a,S)=>{var U=ht();w(a,U)}),C(()=>te(D,t(M))),he(D,"clientWidth",a=>b(q,a)),w(x,L)},de=x=>{var L=mt(),D=u(L),a=u(D),S=h(D,2);{var U=Q=>{var ge=pt(),me=u(ge);C(()=>T(me,k())),w(Q,ge)};W(S,Q=>{k()!==""&&Q(U)})}C(()=>T(a,y())),w(x,L)},ve=x=>{var L=bt();fe(L,21,()=>e.products,D=>D.id,(D,a)=>{var S=_t(),U=u(S);{let Q=v(()=>t(oe).get(t(a).id)??t(z)),ge=v(()=>t(a).id===c()),me=v(()=>t(a).id===d()),Se=v(()=>t(a).id===m());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(me)},get busy(){return t(Se)},get showPrice(){return o()},get onpick(){return e.onpick}})}w(D,S)}),C(()=>{ae(L,"data-tile-count",e.products.length),te(L,t(M))}),he(L,"clientWidth",D=>b(q,D)),w(x,L)};W(ue,x=>{i()?x(pe):e.products.length===0?x(de,1):x(ve,-1)})}ze(J,x=>b(O,x),()=>t(O)),C(()=>{ae(J,"data-tile-scale",t(p)),te(J,`--tile-scale: ${t(p)??""}`)}),he(ce,"clientHeight",x=>b(K,x)),he(le,"clientWidth",x=>b(H,x)),w(s,J),Z()}var Et=E(''),kt=E('
');function xt(s,e){Y(e,!0);let n=I(null);ne(()=>{t(n)?.focus()});var r=kt(),l=u(r);ee(l,{name:"search",size:"1.75rem"});var f=h(l,2);ze(f,i=>b(n,i),()=>t(n));var c=h(f,2);{var d=i=>{var y=Et();N("click",y,function(...k){e.onclose?.apply(this,k)}),w(i,y)};W(c,i=>{e.query!==""&&i(d)})}var m=h(c,2),o=u(m);ee(o,{name:"close",size:"1.5rem"}),C(()=>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",m,function(...i){e.onclose?.apply(this,i)}),w(s,r),Z()}re(["input","keydown","click"]);function At(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 Pt(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 '),Ct=E('Catalogue en attente'),St=E(' '),jt=E('aucune pour le moment'),Dt=E(' '),It=E(''),Tt=E('
Dernière étiquette
');function qt(s,e){Y(e,!0);const n=R(e,"catalogAt",3,""),r=R(e,"productCount",3,0),l=R(e,"appVersion",3,""),f=v(()=>[r()>0?`${r()} produits pesables`:"",l()!==""?`application ${l()}`:""].filter(p=>p!=="").join(" · "));var c=Tt();let d;var m=u(c),o=u(m);{var i=p=>{var z=zt(),M=h(u(z)),$=u(M);C(()=>T($,n())),w(p,z)},y=p=>{var z=Ct();w(p,z)};W(o,p=>{n()!==""?p(i):p(y,-1)})}var k=h(o,2);{var _=p=>{var z=St(),M=u(z);C(()=>T(M,t(f))),w(p,z)};W(k,p=>{t(f)!==""&&p(_)})}var j=h(m,4),g=h(u(j),2);{var P=p=>{var z=jt();w(p,z)},B=p=>{var z=Dt(),M=u(z);C($=>T(M,$),[()=>Pt(e.label)]),w(p,z)};W(g,p=>{e.label===null?p(P):p(B,-1)})}var q=h(j,2);{var H=p=>{var z=It();C(()=>z.disabled=!e.available),N("click",z,function(...M){e.onreprint?.apply(this,M)}),w(p,z)};W(q,p=>{e.label!==null&&p(H)})}var V=h(q,2),A=u(V);let O;var G=h(A,2),K=u(G),X=h(V,2),F=u(X);ee(F,{name:"settings",size:"1.75rem"}),C(()=>{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(K,e.healthy?"Balance et imprimante disponibles":"Matériel indisponible")}),N("click",X,function(...p){e.onadmin?.apply(this,p)}),w(s,c),Z()}re(["click"]);var Rt=E(''),Lt=E('

Tare g

');function Nt(s,e){Y(e,!0);const n=["1","2","3","4","5","6","7","8","9","0"];var r=Lt(),l=u(r),f=h(u(l),2),c=u(f),d=h(l,2);fe(d,20,()=>n,_=>_,(_,j)=>{var g=Rt(),P=u(g);C(()=>T(P,j)),N("click",g,()=>e.ondigit(j)),w(_,g)});var m=h(d,2),o=u(m),i=h(o,2),y=h(i,2),k=u(y);ee(k,{name:"check",size:"1.5rem"}),C(()=>T(c,e.grams===""?"0":e.grams)),N("click",o,function(..._){e.onclear?.apply(this,_)}),N("click",i,function(..._){e.oncancel?.apply(this,_)}),N("click",y,function(..._){e.onconfirm?.apply(this,_)}),w(s,r),Z()}re(["click"]);function Ot(s){return s===null||s.state==="initializing"?!1:s.scale.connected&&s.printer.health!=="faulted"}const Ee=1500,Mt=2e3,ke=5e3;function Wt(s,e){return e?s>=ke?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!0}:s>=Mt?{showWeight:!1,banner:"Poids indisponible",mustReconnect:!1}:s>=Ee?{showWeight:!1,banner:"",mustReconnect:!1}:{showWeight:!0,banner:"",mustReconnect:!1}:{showWeight:s=ke}}const Bt=250,Ft={show_grid_prices:!0,idle_timeout_s:45,reprint_window_s:60,sound:!0,show_by_unit_products:!1,grid_columns:0};class Ht{#e=I(null);get state(){return t(this.#e)}set state(e){b(this.#e,e,!0)}#t=I(null);get catalog(){return t(this.#t)}set catalog(e){b(this.#t,e,!0)}#n=I(Ce({showWeight:!0,banner:"",mustReconnect:!1}));get link(){return t(this.#n)}set link(e){b(this.#n,e,!0)}#s=I("");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??Ft}start(){this.#u(),this.#c(),this.#i=setInterval(()=>this.#v(),Bt)}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=Wt(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 He(),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 Ut=E('
');function Vt(s,e){Y(e,!0);const n=new Ht;let r=I(Ce(Ue)),l=I(""),f=I(!1);const c=v(()=>t(l)!==""||t(f));let d=I(null),m=I(-1),o=I(null);const i=v(()=>n.catalog),y=v(()=>t(i)===null?[]:Je(t(i))),k=v(()=>t(i)===null?[]:Ke(t(i))),_=v(()=>Ge(t(y),t(r),t(l))),j=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(()=>Ot(t(g))),q=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(()=>At(t(i)?.updated_at??""));ne(()=>(n.start(),()=>n.stop())),ne(()=>{const a=n.state?.revision??-1;t(d)!==null&&a>t(m)&&(b(d,null),b(m,-1))});async function K(a){if(t(d)===null){b(d,a.id,!0),b(m,n.state?.revision??-1,!0);try{await Ve({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 X(){const a=n.state?.reprint.job_id;a===void 0||a===""||await Xe(a,Ae())}function F(){b(l,""),b(f,!1)}function p(){b(f,!0)}function z(){const a=t(_).length===1?t(_)[0]:void 0;a!==void 0&&K(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"){z();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 at(()=>import("./mount-DmM_mBVD.js"),__vite__mapDeps([0,1,2,3]))).mountAdmin(document.body)}var ie=Ut(),oe=u(ie);{let a=v(()=>t(o)!==null);lt(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=>{Nt(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==="");wt(le,{get products(){return t(_)},get colors(){return t(j)},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(q).message},get emptyHint(){return t(q).hint},onpick:K})}var ue=h(le,2);{var pe=a=>{xt(a,{get query(){return t(l)},onquery:S=>b(l,S,!0),onclose:F,onenter:z})};W(ue,a=>{t(c)&&a(pe)})}var de=h(ue,2);ut(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:X,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:()=>{Qe()}}})}},D=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(D,1)})}w(s,ie),Z()}const Gt=5,Kt="ResizeObserver loop";function Xt(){let s=!1,e=!1;const n=(c,d)=>{s||(s=!0,Ze(c,d),Jt(),setTimeout(()=>location.reload(),Gt*1e3))},r=c=>{e||(e=!0,Ye(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 Jt(){const s=document.createElement("div");s.className="fatal",s.append(_e("h1","Une erreur est survenue"),_e("p","L’écran va se recharger tout seul."),_e("p","ERR-UI-01","code")),document.body.appendChild(s)}function _e(s,e,n=""){const r=document.createElement(s);return r.textContent=e,n!==""&&(r.className=n),r}Xt();document.addEventListener("contextmenu",s=>{Qt(s.target)||s.preventDefault()});document.addEventListener("dragstart",s=>s.preventDefault());$e(Vt,{target:document.getElementById("app")});function Qt(s){return s instanceof Element&&s.closest("[data-admin]")!==null} diff --git a/internal/web/dist/assets/mount-BVSrRvN7.js b/internal/web/dist/assets/mount-BVSrRvN7.js deleted file mode 100644 index a1d31e8..0000000 --- a/internal/web/dist/assets/mount-BVSrRvN7.js +++ /dev/null @@ -1,99 +0,0 @@ -import{X as Ar,Y as Nr,Z as $r,_ as Ja,$ as ba,e as Dr,a0 as hr,a1 as Ir,a2 as Ur,a3 as Fr,u as $a,a4 as Ka,a5 as Mr,a6 as _r,x as Kt,A as P,h as e,z as d,H as Tt,d as Wt,a as pt,s as a,i as m,t as v,l as pa,j as Ae,m as i,b as Ze,c as l,o as r,g as u,p as $t,I as Br,f as Dt,n as o,q as K,a7 as ar,r as rt,G as Sa,C as Wr,a8 as ka,J as br,a9 as wa,B as Oa,y as ja,T as Vr,aa as Fa,ab as ya,ac as ea,ad as Ea,ae as zt,F as Gr,E as Hr,D as Jr,M as Kr,K as Yr,W as Xr,af as Qr}from"./app-oxQr6rjd.js";function wr(n){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function Zr(n,t,...s){var c=new $r(n);Ar(()=>{const w=t()??null;c.ensure(w,w&&(k=>w(k,...s)))},Nr)}function Ya(n,t,s=!1){if(n.multiple){if(t==null)return;if(!Ir(t))return Ur();for(var c of n.options)c.selected=t.includes(Ca(c));return}for(c of n.options){var w=Ca(c);if(Fr(w,t)){c.selected=!0;return}}(!s||t!==void 0)&&(n.selectedIndex=-1)}function yr(n){var t=new MutationObserver(()=>{"__value"in n&&Ya(n,n.__value)});t.observe(n,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),hr(()=>{t.disconnect()})}function en(n,t,s=t){var c=new WeakSet,w=!0;Ja(n,"change",k=>{var O=k?"[selected]":":checked",U;if(n.multiple)U=[].map.call(n.querySelectorAll(O),Ca);else{var A=n.querySelector(O)??n.querySelector("option:not([disabled])");U=A&&Ca(A)}s(U),n.__value=U,ba!==null&&c.add(ba)}),Dr(()=>{var k=t();if(n===document.activeElement){var O=ba;if(c.has(O))return}if(Ya(n,k,w),w&&k===void 0){var U=n.querySelector(":checked");U!==null&&(k=Ca(U),s(k))}n.__value=k,w=!1}),yr(n)}function Ca(n){return"__value"in n?n.__value:n.value}function Pa(n,t,s=t){var c=new WeakSet;Ja(n,"input",async w=>{var k=w?n.defaultValue:n.value;if(k=Ma(n)?Ba(k):k,s(k),ba!==null&&c.add(ba),await Mr(),k!==(k=t())){var O=n.selectionStart,U=n.selectionEnd,A=n.value.length;if(n.value=k??"",U!==null){var H=n.value.length;O===U&&U===A&&H>A?(n.selectionStart=H,n.selectionEnd=H):(n.selectionStart=O,n.selectionEnd=Math.min(U,H))}}}),$a(t)==null&&n.value&&(s(Ma(n)?Ba(n.value):n.value),ba!==null&&c.add(ba)),Ka(()=>{var w=t();if(n===document.activeElement){var k=ba;if(c.has(k))return}Ma(n)&&w===Ba(n.value)||n.type==="date"&&!w&&!n.value||w!==n.value&&(n.value=w??"")})}function rr(n,t,s=t){Ja(n,"change",c=>{var w=c?n.defaultChecked:n.checked;s(w)}),$a(t)==null&&s(n.checked),Ka(()=>{var c=t();n.checked=!!c})}function Ma(n){var t=n.type;return t==="number"||t==="range"}function Ba(n){return n===""?null:+n}function nr(n,t,s,c,w){var k=()=>{c(s[n])};s.addEventListener(t,k),w?Ka(()=>{s[n]=w()}):k(),(s===document.body||s===window||s===document)&&hr(()=>{s.removeEventListener(t,k)})}function Xa(n){_r===null&&wr(),Kt(()=>{const t=$a(n);if(typeof t=="function")return t})}function tn(n){_r===null&&wr(),Xa(()=>()=>$a(n))}const an="ERR-CFG-02";class Yt extends Error{constructor(t,s,c="",w=[]){super(s),this.status=t,this.code=c,this.faults=w,this.name="AdminError"}status;code;faults;get needsPassword(){return this.status===401}get needsFirstPassword(){return this.status===409&&this.code===an}get needsCredentials(){return this.needsPassword||this.needsFirstPassword}}function rn(){return fa("/admin/api/health")}function nn(){return vt("/admin/api/troubleshooting/reprint",{})}function sn(){return vt("/admin/api/troubleshooting/reload-catalog",{})}function ln(n){return vt("/admin/api/troubleshooting/manual-entry",{on:n})}function on(){return vt("/admin/api/troubleshooting/roll-changed",{})}function un(n){return vt("/admin/api/troubleshooting/fallback-printer",{on:n})}function cn(){return vt("/admin/api/troubleshooting/test-scale",{})}function dn(){return vt("/admin/api/troubleshooting/test-printer",{})}function vn(){return vt("/admin/api/troubleshooting/test-label",{})}async function xr(n){const t=new FormData;t.append("file",n,n.name);const s=await fetch("/admin/api/catalog/import",{method:"POST",body:t});return er(s,"POST /admin/api/catalog/import")}const pn="/admin/api/diagnostic.zip";function fn(n){return vt("/admin/api/session",{password:n})}async function gn(){await fetch("/admin/api/session",{method:"DELETE"})}function mn(n,t){return vt("/admin/api/session/recovery",{code:n,password:t})}function Qa(){return fa("/admin/api/config")}function hn(n){return Za("PUT","/admin/api/config",n)}function _n(){return vt("/admin/api/config/reload",{})}function bn(){return vt("/admin/api/restart",{})}function wn(){return vt("/admin/api/reboot",{})}function yn(){return Za("DELETE","/admin/api/reboot",{})}function xn(){return vt("/admin/api/config/confirm",{})}async function kn(){return(await fa("/admin/api/config/versions")).versions}function Sn(n){return vt("/admin/api/config/restore",{version:n})}async function qn(){return(await fa("/admin/api/ports")).ports}async function Ln(){return(await fa("/admin/api/printers")).printers}async function En(){return(await vt("/admin/api/printers/discover",{})).printers}function zn(n){return vt("/admin/api/scale/detect",{port:n})}async function sr(n,t){return(await vt("/admin/api/scale/capture",{port:n,seconds:t})).frames}function kr(n){return vt("/admin/api/printer/test?what="+n,{})}function jn(n,t,s,c){const w=new URLSearchParams({template:n,demo:t?"1":"0",dual:s?"1":"0"});return w.set("t",String(c)),"/admin/api/label/preview.png?"+w.toString()}async function Cn(n){const t=new URLSearchParams(n);return(await fa("/admin/api/journal?"+t)).weighings}function Pn(n){return"/admin/api/journal/export.csv?"+new URLSearchParams(n).toString()}async function Tn(n){const t=new URLSearchParams(n);return(await fa("/admin/api/technical?"+t)).entries}function Rn(n){const t=n===void 0?"":"?id="+String(n);return fa("/admin/api/imports"+t)}function On(){return vt("/admin/api/catalog/reload",{})}function An(){return vt("/admin/api/catalog/forget-quarantine",{})}function lr(n,t){return vt("/admin/api/products/"+encodeURIComponent(n)+"/decision",t)}function Nn(n){return vt("/admin/api/replay",{frame:n})}async function fa(n){const t=await fetch(n,{headers:{accept:"application/json"}});return er(t,"GET "+n)}function vt(n,t){return Za("POST",n,t)}async function Za(n,t,s){const c=await fetch(t,{method:n,headers:{"content-type":"application/json"},body:JSON.stringify(s)});return er(c,n+" "+t)}async function er(n,t){const s=await n.text();if(!n.ok){const c=Sr(s);throw new Yt(n.status,Dn(s,t,n.status)+$n(n),c?.code??"",c?.faults??[])}if(s!=="")return JSON.parse(s)}function $n(n){if(n.status!==429)return"";const t=Number(n.headers.get("Retry-After")??"");if(!Number.isFinite(t)||t<=0)return"";const s=Math.ceil(t/60);return s>1?` Réessayez dans ${String(s)} minutes.`:" Réessayez dans une minute."}function Dn(n,t,s){const c=Sr(n);return c?.message!==void 0&&c.message!==""?c.message:`${t} a répondu ${String(s)}.`}function Sr(n){try{return JSON.parse(n)}catch{return null}}function In(){return fa("/admin/api/update")}function Un(){return vt("/admin/api/update/check",{})}function Fn(n){return vt("/admin/api/update/apply",{version:n})}class Mn{constructor(t){this.admin=t}admin;#t=P(null);get config(){return e(this.#t)}set config(t){d(this.#t,t,!0)}#r=P("");get fingerprint(){return e(this.#r)}set fingerprint(t){d(this.#r,t,!0)}#a=P(Tt([]));get retired(){return e(this.#a)}set retired(t){d(this.#a,t,!0)}#e=P(null);get pending(){return e(this.#e)}set pending(t){d(this.#e,t,!0)}#n=P(Tt([]));get faults(){return e(this.#n)}set faults(t){d(this.#n,t,!0)}#l=P(!1);get dirty(){return e(this.#l)}set dirty(t){d(this.#l,t,!0)}async load(){const t=await this.admin.load(()=>Qa());t!==null&&(this.config=t.config,this.fingerprint=t.config_fingerprint,this.retired=t.retired_keys??[],this.pending=t.pending_confirmation,this.faults=[],this.dirty=!1)}value(t){let s=this.config;for(const c of t.split(".")){if(s===null||typeof s!="object")return;s=s[c]}return s}text(t){const s=this.value(t);return s==null?"":String(s)}number(t){const s=this.value(t);return typeof s=="number"?s:Number(s??0)}flag(t){return this.value(t)===!0}set(t,s){if(this.config===null)return;const c=t.split("."),w=c.pop();if(w===void 0)return;let k=this.config;for(const O of c){const U=k[O];(U===null||typeof U!="object")&&(k[O]={}),k=k[O]}k[w]=s,this.dirty=!0}unset(t){const s=t.split("."),c=s.pop();if(c===void 0||this.config===null)return!1;let w=this.config;for(const k of s){if(w===null||typeof w!="object")return!1;w=w[k]}return w===null||typeof w!="object"?!1:(delete w[c],this.dirty=!0,!0)}async save(){if(this.config===null)return!1;this.faults=[];const t=this.config;let s;try{s=await hn(t)}catch(c){if(ir(c))throw c;return this.admin.report(c),this.faults=Bn(this.admin),!1}return this.config=s.config,this.fingerprint=s.config_fingerprint,this.retired=s.retired_keys??[],this.pending=s.pending_confirmation,this.dirty=!1,!0}async confirm(){try{await xn()}catch(t){if(ir(t))throw t;this.admin.report(t);return}this.admin.notice="La configuration est confirmée.",this.pending=null,await this.admin.refresh()}dropRetired(t){if(t.includes("[")){this.admin.actionError=`« ${t} » est logée dans un tableau : cet écran ne sait pas la retirer de là. Modifiez le fichier de configuration lui-même.`;return}this.unset(t)&&(this.retired=this.retired.filter(s=>s!==t))}}function Bn(n){return n.lastFaults}function ir(n){return n instanceof Yt&&n.needsCredentials}const Wn={"station.number":"Numéro du poste","station.name":"Nom du poste","station.coop":"Nom de la coopérative","network.listen":"Adresse d’écoute","network.admin_on_lan":"Administration accessible depuis le réseau","ui.language":"Langue","ui.sound":"Son","ui.idle_timeout_s":"Retour à l’accueil après (secondes)","ui.reprint_window_s":"Réimpression possible pendant (secondes)","ui.show_grid_prices":"Afficher les prix sur les tuiles","ui.show_by_unit_products":"Afficher les produits vendus à l’unité","ui.grid_columns":"Colonnes de la grille","scale.type":"Protocole de la balance","scale.present":"Ce poste a une balance","scale.manual_entry_allowed":"Saisie manuelle autorisée","scale.degrade_after_s":"Passer en mode dégradé après (secondes)","scale.options.port":"Port série","scale.options.baud":"Vitesse (bauds)","scale.options.bits":"Bits de données","scale.options.parity":"Parité","scale.options.stop":"Bits d’arrêt","scale.options.backoff_min_ms":"Attente minimale avant réessai (ms)","scale.options.backoff_max_ms":"Attente maximale avant réessai (ms)","printer.type":"Pilote d’impression","printer.template":"Gabarit d’étiquette","printer.options.transport":"Transport","printer.options.queue":"File d’impression","printer.options.path":"Fichier de périphérique","printer.options.address":"Adresse réseau","printer.options.darkness":"Noircissement","printer.options.speed":"Vitesse d’impression","printer.options.offset_x":"Décalage horizontal (dots)","printer.options.offset_y":"Décalage vertical (dots)","printer.options.invert_bits":"Inverser les points","printer.options.copies":"Exemplaires","printer.options.roll_capacity":"Étiquettes par rouleau","pricing.amount_rounding":"Arrondi du montant","pricing.unit_price_rounding":"Arrondi du prix au kilo","pricing.primary_code":"Tarif principal","pricing.reference_code":"Tarif de référence","barcode.verify_reference_check_digit":"Vérifier la clé de contrôle","limits.empty_max_g":"Plateau considéré vide en dessous de (g)","limits.basket_check_enabled":"Vérifier la présence du panier","limits.basket_min_g":"Poids du panier, borne basse (g)","limits.basket_max_g":"Poids du panier, borne haute (g)","limits.min_weight_g":"Poids minimum accepté","limits.max_weight_g":"Poids maximum accepté","limits.max_tare_g":"Tare maximale","limits.min_units":"Unités minimum","limits.max_units":"Unités maximum","limits.max_amount_cents":"Montant maximum (centimes)","stability.mode":"Exigence de stabilité","stability.min_duration_ms":"Durée de stabilité (ms)","stability.tolerance_g":"Tolérance de stabilité (g)","stability.timeout_ms":"Délai d’attente de stabilité (ms)","stability.on_timeout":"Au bout du délai","stability.min_latch_rate":"Taux d’accroche minimal","stability.latch_rate_window_ms":"Fenêtre de mesure du taux (ms)","stability.expiry_floor_ms":"Péremption, plancher (ms)","stability.expiry_ceiling_ms":"Péremption, plafond (ms)","stability.expiry_factor":"Péremption, facteur","catalog.type":"Où le poste va chercher le catalogue","catalog.options.directory":"Répertoire surveillé","catalog.options.url":"Adresse du serveur","catalog.options.username":"Compte","catalog.options.password":"Mot de passe","catalog.options.separator":"Séparateur du CSV","catalog.options.poll_interval_s":"Vérifier toutes les (secondes)","catalog.options.stable_polls":"Vérifications avant lecture","catalog.options.max_file_size_mb":"Taille maximale du fichier (Mo)","catalog.options.max_image_size_kb":"Taille maximale d’une image (Ko)","catalog.options.min_readable_ratio":"Part minimale de lignes lisibles","catalog.options.max_weighable_drop":"Baisse maximale des produits pesables","catalog.options.max_archives":"Archives conservées","catalog.options.archive_days":"Archives conservées (jours)","catalog.options.failures_before_reject":"Échecs avant mise en quarantaine","catalog.images.source":"Origine des photos","catalog.images.path":"Répertoire des photos","catalog.fallback_category":"Rayon par défaut","journal.max_rows":"Pesées conservées","journal.max_days":"Pesées conservées (jours)","journal.max_technical":"Événements techniques conservés","admin.session_minutes":"Durée d’une session (minutes)","admin.attempts_per_minute":"Tentatives par minute","maintenance.weekly_integrity_check":"Contrôle d’intégrité hebdomadaire","maintenance.disk_alert_mb":"Alerte disque en dessous de (Mo)"};function ta(n){return Wn[n]??n}const Vn={station:"l’identité du poste",network:"le réseau",ui:"l’écran client",scale:"la balance",printer:"l’imprimante",pricing:"les tarifs",barcode:"le code-barres",limits:"les garde-fous",stability:"la stabilité",catalog:"le catalogue",journal:"le journal",maintenance:"la maintenance"};function Gn(n){return Vn[n]??n}const Hn={scale:"balance",printer:"imprimante",catalog:"catalogue",ui:"écran",config:"configuration",http:"réseau",system:"système"};function qr(n){return Hn[n]??"origine inconnue"}const Va="openscale.admin.preferences",Lr="technical";class Jn{#t=P(Tt(Kn()));get showTechnicalNames(){return e(this.#t)}set showTechnicalNames(t){d(this.#t,t,!0)}toggleTechnicalNames(){this.showTechnicalNames=!this.showTechnicalNames,Yn(this.showTechnicalNames)}}function Kn(){try{return globalThis.localStorage?.getItem(Va)===Lr}catch{return!1}}function Yn(n){try{n?globalThis.localStorage?.setItem(Va,Lr):globalThis.localStorage?.removeItem(Va)}catch{}}const aa=new Jn,Xn=3e3,Qn=["hardware","label","rules","catalog","journal","station","update"];function or(n){return Qn.includes(n)}function Zn(n){return n instanceof Yt&&n.needsCredentials}class es{constructor(t=Xn){this.periodMs=t}periodMs;#t=P(null);get health(){return e(this.#t)}set health(t){d(this.#t,t,!0)}#r=P("dashboard");get page(){return e(this.#r)}set page(t){d(this.#r,t,!0)}#a=P(!1);get expert(){return e(this.#a)}set expert(t){d(this.#a,t,!0)}#e=P("");get linkError(){return e(this.#e)}set linkError(t){d(this.#e,t,!0)}#n=P("");get actionError(){return e(this.#n)}set actionError(t){d(this.#n,t,!0)}#l=P(!1);get needsFirstPassword(){return e(this.#l)}set needsFirstPassword(t){d(this.#l,t,!0)}#d=P("");get notice(){return e(this.#d)}set notice(t){d(this.#d,t,!0)}#v=P(!1);get busy(){return e(this.#v)}set busy(t){d(this.#v,t,!0)}#p=P(Tt([]));get lastFaults(){return e(this.#p)}set lastFaults(t){d(this.#p,t,!0)}#f=P(null);get pending(){return e(this.#f)}set pending(t){d(this.#f,t,!0)}#i=null;#o=null;start(){this.refresh(),this.#i=setInterval(()=>{this.refresh()},this.periodMs)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null}async refresh(){try{this.health=await rn(),this.linkError=""}catch(t){this.linkError=ur(t)}}#u(){this.busy=!0,this.notice="",this.actionError="",this.needsFirstPassword=!1}#s(t){this.actionError=ur(t),this.needsFirstPassword=t instanceof Yt&&t.needsFirstPassword,this.#m(t)}async run(t){this.#u();let s=null;try{s=await t(),this.notice=s.message}catch(c){this.#s(c)}finally{this.busy=!1}return await this.refresh(),s}async login(t){this.#u();try{return await fn(t),this.expert=!0,this.notice="Session d’administration ouverte.",!0}catch(s){return this.expert=!1,this.#s(s),!1}finally{this.busy=!1}}async recover(t,s){this.#u();try{const c=await mn(t,s);return this.expert=!0,this.notice=c.warning??"Le mot de passe est remplacé et la session est ouverte.",!0}catch(c){return this.#s(c),!1}finally{this.busy=!1}}async protect(t){try{return await t()}catch(s){if(!Zn(s))return this.#s(s),null;if(!await this.#g(s))return null;try{return await t()}catch(c){return this.#s(c),null}}}#g(t){return this.pending={kind:t.needsFirstPassword?"first-password":"password",message:t.message},new Promise(s=>{this.#o=s})}async answerPassword(t){await this.login(t)&&this.#c(!0)}async answerRecovery(t,s){await this.recover(t,s)&&this.#c(!0)}cancelPassword(){this.#c(!1)}#c(t){this.pending=null,this.notice="";const s=this.#o;this.#o=null,s?.(t)}async logout(){await gn(),this.expert=!1,this.page="dashboard",this.notice="Session d’administration fermée."}open(t){this.actionError="",this.notice="",this.needsFirstPassword=!1,this.page=t}async load(t){try{const s=await t();return this.actionError="",this.lastFaults=[],s}catch(s){return this.lastFaults=s instanceof Yt?s.faults:[],this.#s(s),null}}report(t){this.lastFaults=t instanceof Yt?t.faults:[],this.#s(t)}#m(t){t instanceof Yt&&t.needsPassword&&(this.expert=!1)}}function ur(n){return n instanceof Yt?n.message:n instanceof Error?"Le poste n’a pas répondu : "+n.message:"Le poste n’a pas répondu."}var ts=u('clé'),as=u('');function at(n,t){const s=pt(t,"kind",3,"read"),c=pt(t,"busy",3,!1),w=pt(t,"disabled",3,!1),k=pt(t,"protected",3,!1),O=pt(t,"act",3,void 0);var U=as();let A;var H=r(U),Q=a(H);{var _e=ce=>{var qe=ts();l(ce,qe)};m(Q,ce=>{k()&&ce(_e)})}v(()=>{A=pa(U,1,`act ${s()??""}`,"svelte-5tpq0o",A,{"touch-target":s()==="destructive",busy:c()}),Ae(U,"data-kind",s()),Ae(U,"data-act",O()),U.disabled=w()||c(),i(H,`${(c()?"En cours…":t.label)??""} `)}),Ze("click",U,function(...ce){t.onrun?.apply(this,ce)}),l(n,U)}Wt(["click"]);var rs=u(`

Le code de secours a été tiré à l’installation de ce poste et imprimé - sur sa fiche, rangée dans le classeur du magasin. Il n’existe nulle part ailleurs.

`,1),ns=u(`

Oublié ? Le code de secours de la fiche d’installation en repose un. - Un responsable peut aussi le faire en ligne de commande, avec openscale config password.

`,1),ss=u('

'),ls=u('');function is(n,t){$t(t,!0);const s=o(()=>t.admin.pending?.kind==="first-password");let c=P(""),w=P("");const k=8,O=o(()=>e(s)?e(w).trim().length>0&&e(c).length>=k:e(c).length>0);async function U(){!e(O)||t.admin.busy||(e(s)?await t.admin.answerRecovery(e(w),e(c)):await t.admin.answerPassword(e(c)),d(c,""),d(w,""))}var A=ls(),H=r(A),Q=r(H),_e=r(Q),ce=r(_e);Br(ce,{name:"settings",size:"1.5rem"});var qe=a(_e,2),me=r(qe),j=a(Q,2),xe=r(j),Z=a(j,2);{var de=S=>{var h=rs(),x=a(K(h),2),T=a(r(x),2);ar(T);var W=a(x,2),se=r(W);se.textContent="Nouveau mot de passe — 8 caractères au moins";var le=a(se,2);Ze("keydown",T,ve=>ve.key==="Enter"&&void U()),Pa(T,()=>e(w),ve=>d(w,ve)),Ze("keydown",le,ve=>ve.key==="Enter"&&void U()),Pa(le,()=>e(c),ve=>d(c,ve)),l(S,h)},re=S=>{var h=ns(),x=K(h),T=a(r(x),2);ar(T),Ze("keydown",T,W=>W.key==="Enter"&&void U()),Pa(T,()=>e(c),W=>d(c,W)),l(S,h)};m(Z,S=>{e(s)?S(de):S(re,-1)})}var N=a(Z,2);{var ne=S=>{var h=ss(),x=r(h);v(()=>i(x,t.admin.actionError)),l(S,h)};m(N,S=>{t.admin.actionError!==""&&S(ne)})}var ke=a(N,2),Y=r(ke);at(Y,{label:"Annuler",onrun:()=>t.admin.cancelPassword()});var ee=a(Y,2);{let S=o(()=>e(s)?"Poser ce mot de passe":"Continuer"),h=o(()=>!e(O)||t.admin.busy);at(ee,{kind:"write",get label(){return e(S)},get disabled(){return e(h)},onrun:()=>{U()}})}v(()=>{i(me,e(s)?"Ce poste n’a pas encore de mot de passe":"Mot de passe d’administration"),i(xe,t.admin.pending?.message??"")}),l(n,A),Dt()}Wt(["keydown"]);var os=u(' '),us=u(""),cs=u(''),ds=u(''),vs=u(""),ps=u(""),fs=u('

'),gs=u(" "),ms=u('

'),hs=u('
');function St(n,t){$t(t,!0);const s=pt(t,"hint",3,""),c=pt(t,"kind",3,"text"),w=pt(t,"disabled",3,!1),k=pt(t,"fault",3,""),O=pt(t,"allowed",19,()=>[]),U=pt(t,"choices",19,()=>[]),A=o(()=>`field-${t.path.replace(/\./gu,"-")}`);var H=hs();let Q;var _e=r(H),ce=r(_e),qe=r(ce),me=a(ce,2);{var j=S=>{var h=os(),x=r(h);v(()=>i(x,t.path)),l(S,h)};m(me,S=>{aa.showTechnicalNames&&S(j)})}var xe=a(_e,2);{var Z=S=>{var h=cs();rt(h,21,U,T=>T.value,(T,W)=>{var se=us(),le=r(se),ve={};v(()=>{i(le,e(W).label),ve!==(ve=e(W).value)&&(se.value=(se.__value=e(W).value)??"")}),l(T,se)});var x;yr(h),v(()=>{Ae(h,"id",e(A)),h.disabled=w(),x!==(x=t.value)&&(h.value=(h.__value=t.value)??"",Ya(h,t.value))}),Ze("change",h,T=>t.onchange(T.currentTarget.value)),l(S,h)},de=S=>{var h=ds();v(()=>{Ae(h,"id",e(A)),Ae(h,"type",c()),Sa(h,t.value),h.disabled=w(),Ae(h,"list",O().length>0?e(A)+"-allowed":void 0)}),Ze("input",h,x=>t.onchange(x.currentTarget.value)),l(S,h)};m(xe,S=>{U().length>0?S(Z):S(de,-1)})}var re=a(xe,2);{var N=S=>{var h=ps();rt(h,20,O,x=>x,(x,T)=>{var W=vs(),se={};v(()=>{se!==(se=T)&&(W.value=(W.__value=T)??"")}),l(x,W)}),v(()=>Ae(h,"id",e(A)+"-allowed")),l(S,h)};m(re,S=>{O().length>0&&S(N)})}var ne=a(re,2);{var ke=S=>{var h=fs(),x=r(h);v(()=>i(x,s())),l(S,h)};m(ne,S=>{s()!==""&&S(ke)})}var Y=a(ne,2);{var ee=S=>{var h=ms(),x=r(h),T=a(x);{var W=se=>{var le=gs(),ve=r(le);v(pe=>i(ve,`Valeurs acceptées : ${pe??""}.`),[()=>O().join(", ")]),l(se,le)};m(T,se=>{O().length>0&&se(W)})}v(()=>i(x,`${k()??""} `)),l(S,h)};m(Y,S=>{k()!==""&&S(ee)})}v(()=>{Q=pa(H,1,"field svelte-1oilv5s",null,Q,{refused:k()!==""}),Ae(_e,"for",e(A)),i(qe,t.label)}),l(n,H),Dt()}Wt(["change","input"]);const cr=1e3;function Aa(n){const t=tr(n);return t===null?"":[qa(t.getDate()),qa(t.getMonth()+1),String(t.getFullYear())].join("/")}function ra(n){const t=tr(n);return t===null?"":`${Aa(n)} à ${qa(t.getHours())}:${qa(t.getMinutes())}`}function _s(n){const t=tr(n);return t===null?"":[qa(t.getHours()),qa(t.getMinutes()),qa(t.getSeconds())].join(":")}function Ga(n){const t=n/1e6;return t<1e3?`${Math.round(t)} Mo`:`${Er(t/1e3)} Go`}function ae(n){const t=String(Math.abs(Math.trunc(n))),s=[];for(let c=t.length;c>0;c-=3)s.unshift(t.slice(Math.max(0,c-3),c));return(n<0?"-":"")+s.join(" ")}function Ra(n){return n0&&k.push({count:Bt(n.anomalies_count),label:"anomalies",note:"à corriger dans Odoo",link:dr(n.anomalies_count)}),n.unit_mismatches_count>0){const O=n.unit_mismatches_count;k.push({count:"+ "+Bt(O),label:O===1?"unité divergente":"unités divergentes",note:"pesable, unité à corriger",link:dr(O)})}return{headline:`Catalogue du ${Aa(n.occurred_at)} — ${Bt(s)} produits reçus`,lines:k,oneLine:bs(n)}}function bs(n){const t=[`${Bt(n.rows_read_count)} reçus`,`${Bt(n.weighable_count)} pesables`,`${Bt(n.not_weighable_count)} non pesables`,`${Bt(n.anomalies_count)} anomalies`],s=n.unit_mismatches_count;return s>0&&t.push(`${Bt(s)} ${s===1?"unité divergente":"unités divergentes"}`),t.join(" · ")}const ws={applied:"appliqué",unchanged:"identique au précédent",rejected:"refusé",failed:"échec"},ys={local_drop:"dépôt local",webdav:"WebDAV",manual:"déposé sur l’écran"};function Da(n){return ws[n]??"résultat inconnu"}function jr(n){return ys[n]??"source inconnue"}function xs(n,t){const s=[n.file_name===""?"Le dernier fichier":n.file_name,Da(n.result)],c=ra(n.occurred_at);c!==""&&s.push("le "+c),s.push("via "+jr(n.source));const w=n.reason===""?"":" "+n.reason;return`${s.join(" ")} — ${zr(n,t).oneLine}.${w}`}function ks(n){return n===""?"Aucun nouvel import enregistré à cet instant, et ce poste ne publie pas ce qu’il surveille.":"Aucun nouvel import enregistré à cet instant. Le poste surveille "+n+" : le fichier n’y était pas, ou il n’a pas encore fini d’arriver."}function Ss(n){return"Ce poste n’a pas de journal : il ne pourra rien dire de l’issue de cette relecture."+(n===""?"":" Le poste surveille "+n+".")}function dr(n){return n===1?"voir la ligne":`voir les ${Bt(n)} lignes`}function qs(n){return n.map(t=>`${Ls(t)} (${Bt(t.count)})`).join(", ")}function Ls(n){switch(n.code){case"PREPACKAGED_PRODUCT":return"préemballés";case"INTERNAL_CODE_NOT_WEIGHABLE":return n.value===""?"code interne":"code interne "+n.value;case"NO_BARCODE":return"sans code-barres";default:return n.code}}function Bt(n){return String(n)}var Es=u(' '),zs=u(''),js=u('
'),Cs=u('

');function Cr(n,t){$t(t,!0);const s=o(()=>zr(t.record,t.motives));var c=Cs(),w=r(c),k=r(w),O=a(w,2);rt(O,21,()=>e(s).lines,H=>H.label,(H,Q)=>{var _e=js(),ce=r(_e),qe=r(ce),me=a(ce,2),j=r(me),xe=r(j),Z=a(j,2);{var de=ne=>{var ke=Es(),Y=r(ke);v(()=>i(Y,e(Q).note)),l(ne,ke)};m(Z,ne=>{e(Q).note!==""&&ne(de)})}var re=a(Z,2);{var N=ne=>{var ke=zs(),Y=r(ke);v(()=>i(Y,e(Q).link)),Ze("click",ke,()=>t.onshowrows(e(Q).label==="anomalies"?"anomalies":"units")),l(ne,ke)};m(re,ne=>{e(Q).link!==""&&t.onshowrows!==void 0&&ne(N)})}v(()=>{Ae(_e,"data-row",e(Q).label),i(qe,e(Q).count),i(xe,e(Q).label)}),l(H,_e)});var U=a(O,2),A=r(U);v(()=>{i(k,e(s).headline),i(A,e(s).oneLine)}),l(n,c),Dt()}Wt(["click"]);var Ps=u('

'),Ts=u('

');function tt(n,t){const s=pt(t,"note",3,"");var c=Ts(),w=r(c),k=r(w),O=a(w,2);{var U=H=>{var Q=Ps(),_e=r(Q);v(()=>i(_e,s())),l(H,Q)};m(O,H=>{s()!==""&&H(U)})}var A=a(O,2);Zr(A,()=>t.children),v(()=>i(k,t.title)),l(n,c)}const Rs=-1,Os=10;class Pr{#t=P("");get sentence(){return e(this.#t)}set sentence(t){d(this.#t,t,!0)}#r=0;#a="";#e=0;begin(t){this.sentence="",this.#r=t.last_import_id,this.#a=t.watched,this.#e=Os}forget(){this.sentence="",this.#e=0}observe(t){if(this.#e===0)return;if(t.counters.journal_rows_count===Rs){this.#n(Ss(this.#a));return}const s=t.catalog;if(s!==null&&s.id!==this.#r){this.#n(xs(s,t.catalog_motives));return}this.#e-=1,this.#e===0&&(this.sentence=ks(this.#a))}#n(t){this.sentence=t,this.#e=0}}const Wa=(n,t=Ea,s=Ea)=>{var c=$s(),w=r(c);rt(w,21,t,Oa,(k,O)=>{var U=Ns(),A=r(U),H=r(A),Q=a(A,2);{var _e=de=>{var re=As(),N=r(re);v(()=>i(N,e(O).product_name)),l(de,re)};m(Q,de=>{e(O).product_name!==""&&de(_e)})}var ce=a(Q,2),qe=r(ce),me=a(ce,2),j=r(me),xe=a(me,2),Z=r(xe);v(de=>{i(H,`ligne ${de??""}`),i(qe,e(O).product_id),i(j,e(O).value),i(Z,e(O).message)},[()=>ae(e(O).csv_line)]),l(k,U)}),v(()=>Ae(c,"data-rows",s())),l(n,c)};var As=u(' '),Ns=u('
  • '),$s=u('
      '),Ds=u(" "),Is=u(''),Us=u('

      Lecture des réglages du poste…

      '),Fs=u(`

      Le poste y cherche le fichier , et le supprime - une fois lu : c’est ce qui dit au producteur que la livraison est prise.

      `,1),Ms=u(`

      Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus - possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours - du jour de la mise en service.

      `,1),Bs=u(`

      Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira - chercher aucun catalogue.

      `),Ws=u('
      ',1),Vs=u('

      Aucun import enregistré sur ce poste.

      '),Gs=u('

      '),Hs=u(`

      « Oublier la quarantaine » fait relire un fichier que le poste avait écarté : c’est le - seul geste de cette page qui puisse remettre en service un catalogue refusé.

      `,1),Js=u(""),Ks=u(''),Ys=u('

      '),Xs=u('

      '),Qs=u('
      '),Zs=u(`

      Un produit masqué reste vendable : la caisse lit toujours son code-barres, et une - étiquette déjà imprimée reste valable. Ce réglage ne fait que retirer sa tuile.

      Colonnes de la grille

      Se tromper ne coûte rien d’autre que de revenir ici : le réglage ne change ni le - fichier reçu, ni les étiquettes déjà imprimées.

      `,1),el=u('

      '),tl=u(`

      Déposez ici le fichier clé

      Ce dépôt remplace toute la grille par le fichier apporté : il change ce que le poste - vend, et le mot de passe est donc demandé au moment du dépôt.

      `,1),al=u('

      '),rl=u('

      Aucune anomalie sur le dernier import.

      '),nl=u('

      ',1),sl=u('

      '),ll=u('

      Aucune unité divergente sur le dernier import.

      '),il=u('

      ',1),ol=u('

      '),ul=u('

      Aucun produit non pesable sur le dernier import.

      '),cl=u('

      ',1),dl=u('

      Aucun import enregistré : rien n’a encore pu être retiré.

      '),vl=u('

      Aucun produit retiré par le dernier import.

      '),pl=u(`

      Ils restent enregistrés avec leur historique : une étiquette déjà collée reste - lisible en caisse, et un produit qui revient dans un prochain fichier retrouve sa - tuile. Ce poste n’en publie encore que le nombre — leurs noms se lisent dans Odoo, - en comparant avec l’export précédent.

      `,1),fl=u('
    • '),gl=u(`

        Un produit retiré de la grille ne se trouve plus ici : il se reprend dans - « Décisions en vigueur », plus bas.

        `,1),ml=u(`

        Sans motif, le poste refuse la décision. Le seul acte qui s’en passe est celui qui - EFFACE la décision en vigueur : proposer de nouveau un produit qui ne porte aucune - dérogation.

        `),hl=u(`

        Produit choisi :

        Ce produit est-il proposé dans la grille ?

        La dérogation s’enregistre seule : elle ne remet pas dans la grille un produit - qui en a été retiré, et retirer un produit n’efface pas sa dérogation.

        `),_l=u(' ',1),bl=u('

        Aucune décision locale : la grille est celle du fichier.

        '),wl=u('
      • '),yl=u('

          ',1),xl=u('

          '),kl=u('

          Aucun import dans l’historique.

          '),Sl=u(' '),ql=u('

          QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
          ',1),Ll=u('
          ');function El(n,t){$t(t,!0);const s=(g,L=Ea)=>{var B=Is(),ge=r(B),Oe=a(ge,2),Ve=r(Oe),We=r(Ve),G=a(Ve,2);{var te=Ge=>{var Ye=Ds(),he=r(Ye);v(()=>i(he,L().path)),l(Ge,Ye)};m(G,Ge=>{aa.showTechnicalNames&&Ge(te)})}var De=a(G,2),Je=r(De);v((Ge,Ye)=>{Ae(B,"data-flag",L().path),Ae(B,"data-on",Ge),wa(ge,Ye),i(We,L().label),i(Je,L().hint)},[()=>String(t.draft.flag(L().path)),()=>t.draft.flag(L().path)]),Ze("change",ge,Ge=>t.draft.set(L().path,Ge.currentTarget.checked)),l(g,B)},c=pt(t,"admin",7),w=20,k=50,O=20,U=["NO_BARCODE","PREPACKAGED_PRODUCT","INTERNAL_CODE_NOT_WEIGHABLE"],A="ui.grid_columns",H=[3,4,5,6,7,8,9,10,11,12],Q={local_drop:["catalog.options.directory"],webdav:["catalog.options.url","catalog.options.username","catalog.options.password"]},_e=o(()=>t.draft.text("catalog.type"));let ce=P(Tt([])),qe=P(Tt([])),me=P(null);const j=o(()=>e(me)?.products??[]),xe=o(()=>e(me)?.presentation??{}),Z=o(()=>e(me)?.pricing?.primary_code??"");let de=P(""),re=P(""),N=P(""),ne=P(""),ke=P(!1),Y=P(""),ee=P(""),S=P("loading"),h=P("loading");const x=o(()=>c().busy||e(ee)!==""),T=o(()=>e(qe).filter(g=>g.issue==="anomaly")),W=o(()=>e(qe).filter(g=>U.includes(g.code))),se=o(()=>e(qe).filter(g=>g.code==="UNIT_MISMATCH")),le=o(()=>new Map(e(j).map(g=>[g.id,g.name]))),ve=o(()=>e(j).filter(g=>g.mode==="by_unit").length),pe=o(()=>{if(e(h)==="loading")return"Lecture du catalogue en service…";if(e(h)==="unread")return"Le catalogue en service n’a pas pu être lu : cet écran ne sait pas combien de produits se vendent à l’unité.";if(e(ve)===0)return"Aucun produit vendu à l’unité dans le catalogue en service.";const g=e(ve)>1,L=g?"produits vendus à l’unité sont":"produit vendu à l’unité est",B=t.draft.flag("ui.show_by_unit_products")?`${g?"montrés":"montré"} dans la grille de ce poste`:`${g?"masqués":"masqué"} sur ce poste`;return`${ae(e(ve))} ${L} ${B}.`}),F=o(()=>t.draft.number(A)),y=o(()=>M("ui.show_by_unit_products",e(xe).show_by_unit_products??!1)?e(j):e(j).filter(g=>g.mode!=="by_unit")),$=o(()=>e(y)[0]===void 0?null:{...e(y)[0],name:"·",image_url:"",prices:e(y)[0].prices??[]});function M(g,L){return t.draft.value(g)===void 0?L:t.draft.flag(g)}let J=P(null),be=P(null),Ie=P(null),R=P(null),_=P(null),z=P(0),I=P(null);Kt(()=>{const g=()=>{d(z,e(z)+1)};return window.addEventListener("resize",g),()=>window.removeEventListener("resize",g)}),Kt(()=>{const g=e(F);e(z),d(I,q(g),!0)});const fe=o(()=>{const g=e(I);if(g===null||g.contentWidthPx<=0||g.nameBoxPx<=0||e(y).length===0)return null;const L=X();if(L===null)return null;const B=Gr(L.family,L.weight);if(B===null)return null;const ge=Hr(g.tileScale),Oe=new Set;let Ve=0;for(const[We,G]of e(y).entries())Jr(G.name,g.contentWidthPx,B,g.nameBoxPx,ge)>Fa||(Ve+=1,Oe.add(Math.floor(We/g.columns)));return{names:Ve,rows:Oe.size}}),Me=o(()=>{const g=e(I),L=[];if(e(F)===ka)return L.push(g===null?"Automatique : la grille suit la largeur de l’écran. Un écran plus large en montre davantage sans qu’on y revienne.":`Automatique : ${Se(g)} sur cet écran. Un écran plus large en montrera davantage sans qu’on y revienne.`),L;if(g===null)return L.push(`${ae(e(F))} ${e(F)>1?"colonnes":"colonne"} sur tous les écrans. Cet écran ne sait pas dire combien de rangées cela fait ici.`),L;const B=g.columns*g.rows;if(L.push(`${Se(g)} — ${ae(B)} ${B>1?"tuiles":"tuile"} d’un coup, sur cet écran (${String(window.innerWidth)} × ${String(window.innerHeight)}).`),e(y).length>0){const Oe=Math.ceil(e(y).length/B);L.push(e(y).length>1?`Les ${ae(e(y).length)} tuiles de la grille tiennent en ${ae(Oe)} ${Oe>1?"écrans":"écran"}.`:"La seule tuile de la grille tient en un écran.")}const ge=e(fe);if(ge!==null&&ge.names>0){const Oe=ge.names>1;L.push(`${ae(ge.names)} ${Oe?"noms":"nom"} sur ${ae(e(y).length)} ${Oe?"atteignent":"atteint"} le plancher de ${ae(Fa)} px : `+(ge.rows>1?`leurs ${ae(ge.rows)} rangées peuvent être plus hautes que les autres.`:"leur rangée peut être plus haute que les autres."))}return L}),Be=o(()=>e(I)!==null&&!f()?"Cet écran n’est pas celui du poste : ce compte vaut pour l’écran que vous lisez.":"");function Se(g){const L=`${ae(g.columns)} ${g.columns>1?"colonnes":"colonne"}`,B=`${ae(g.rows)} ${g.rows>1?"rangées":"rangée"}`;return`${L} × ${B}`}function f(){return["localhost","127.0.0.1"].includes(window.location.hostname)}function q(g){const L=e(J),B=e(be),ge=e(Ie),Oe=e(R),Ve=e(_);if(L===null||B===null||Oe===null||Ve===null||ge===null)return null;B.style.gridTemplateColumns=Wr(g);const We=V(B),G=We[0];if(G===void 0)return null;const te=Oe.clientWidth,De=g===ka||te<=0?1:G/te;L.style.setProperty("--tile-scale",String(De));const Je=Number.parseFloat(getComputedStyle(B).rowGap),Ge=Ve.clientHeight,Ye=ge.offsetHeight,he=ge.querySelector(".name-box");return!Number.isFinite(Je)||Ge<=0||Ye<=0||he===null?null:{columns:We.length,rows:Math.max(1,Math.floor((Ge+Je)/(Ye+Je))),contentWidthPx:he.clientWidth,nameBoxPx:he.clientHeight,tileScale:De}}function V(g){const L=getComputedStyle(g).gridTemplateColumns.split(/\s+/u).filter(ge=>ge!=="");if(L.length===0)return[];const B=L.map(ge=>ge.endsWith("px")?Number.parseFloat(ge):Number.NaN);return B.every(ge=>Number.isFinite(ge)&&ge>0)?B:[]}function X(){const g=e(Ie)?.querySelector(".name")??null;if(g===null)return null;const L=getComputedStyle(g),B=Number.parseInt(L.fontWeight,10);return L.fontFamily===""||!Number.isFinite(B)?null:{family:L.fontFamily,weight:B}}const b=o(()=>e(de)===""?[]:Kr(e(j),Yr,e(de))),C=o(()=>e(b).slice(0,w)),D=o(()=>e(T).slice(0,k)),ie=o(()=>e(se).slice(0,k)),Ee=o(()=>e(W).slice(0,k)),Ce=o(()=>[...t.health.decisions].sort((g,L)=>L.decided_at.localeCompare(g.decided_at))),Le=o(()=>e(Ce).slice(0,O)),oe=o(()=>e(re)===""?null:e(Ce).find(g=>g.product_id===e(re))??null),Ne=o(()=>e(oe)===null?!0:e(oe).offered),Pe=o(()=>e(oe)?.min_weight_g??null),$e=o(()=>e(ne).trim()===""?null:Number(e(ne))),Ue=o(()=>e($e)!==null&&Number.isFinite(e($e))&&e($e)>0),He=o(()=>e(N).trim()),ze=o(()=>e(He)!==""||!Ft(!1,e(Pe))),Te=o(()=>e(He)!==""||!Ft(!0,e(Pe))),we=o(()=>e(Ue)&&(e(He)!==""||!Ft(e(Ne),e($e)))),Ke=o(()=>e(He)!==""||!Ft(e(Ne),null)),Re=o(()=>t.health.catalog?.products_withdrawn_count??0),nt=o(()=>e(ce).find(g=>t.health.catalog!==null&&g.id`${ae(e(Re))} ${e(Re)>1?"produits retirés":"produit retiré"}`+(e(nt)===null?".":` depuis l’import du ${ra(e(nt).occurred_at)}.`)),ct=o(()=>e(b).length>e(C).length?`${ae(e(C).length)} produits affichés sur ${ae(e(b).length)} trouvés — précisez votre recherche.`:`${ae(e(b).length)} ${e(b).length>1?"produits trouvés":"produit trouvé"}.`),je=o(()=>`${ae(e(ce).length)} ${e(ce).length>1?"imports affichés":"import affiché"} : le poste n’en publie jamais plus de vingt.`),it=o(()=>e(S)==="loading"?"Lecture des signalements du dernier import…":"Les signalements du dernier import n’ont pas pu être lus : cet écran ne sait pas ce qu’ils disent."),Ct=o(()=>e(S)==="loading"?"Lecture de l’historique des imports…":"L’historique des imports n’a pas pu être lu : cet écran ne sait pas ce qu’il contient.");let ut;Kt(()=>{const g=t.health.catalog?.id??null;g!==ut&&(ut=g,It())});const st=new Pr;Kt(()=>{st.observe(t.health)});async function It(){const g=t.health.catalog_findings_id,L=await c().load(()=>Rn(g===0?void 0:g));L===null?d(S,"unread"):(d(ce,L.imports,!0),d(qe,L.findings,!0),d(S,"read"));const B=await c().load(()=>br());if(B===null){d(h,"unread");return}d(me,B,!0),d(h,"read")}async function Ut(g,L){d(ee,g,!0),c().actionError="",c().notice="",st.forget();try{const B=await c().protect(L);return B===null?null:(c().notice=B.message,await c().refresh(),B)}finally{d(ee,"")}}async function Vt(){const g=await Ut("reload",On);g!==null&&st.begin(g)}function Rt(g){t.draft.set("catalog.type",g);for(const[L,B]of Object.entries(Q))if(L!==g)for(const ge of B)t.draft.unset(ge)}function gt(g){return t.draft.faults.find(L=>L.field===g)?.message??""}function Ot(g){d(re,g,!0),d(N,"");const L=e(Ce).find(B=>B.product_id===g)?.min_weight_g??null;d(ne,L===null?"":String(L),!0)}function Ft(g,L){return!(g&&L===null)}async function Gt(g){if(e(re)==="")return;const L=e(re),B=e(Pe),ge=e(He);await Ut("offered",()=>lr(L,{offered:g,min_weight_g:B,reason:ge}))!==null&&d(N,"")}async function Ht(g){if(e(re)==="")return;const L=e(re),B=e(Ne),ge=e(He);await Ut(g===null?"waiver-off":"waiver",()=>lr(L,{offered:B,min_weight_g:g,reason:ge}))!==null&&d(N,"")}async function na(g){if(d(ke,!1),g!=null){d(Y,""),c().actionError="",c().notice="",d(ee,"import");try{const L=await c().protect(()=>xr(g));if(L===null)return;d(Y,`${g.name} : ${ae(L.rows_read_count)} lignes lues, ${ae(L.weighable_count)} pesables. `+(L.reason===""?"Le fichier est déposé ; son résultat s’inscrira dans l’historique des imports.":L.reason)),await c().refresh()}finally{d(ee,"")}}}function Xt(g){g.preventDefault(),d(ke,!1);const L=g.dataTransfer?.files.item(0);if(e(x)){d(Y,`${L?.name??"Ce fichier"} n’a pas été déposé : un acte est déjà en cours sur cette page. Réessayez quand il aura répondu.`);return}na(L)}function Lt(g){const L=g.files?.item(0);g.value="",na(L)}function bt(g){const L=e(le).get(g);return L!==void 0?L:e(h)==="loading"?"Lecture du nom…":e(h)==="unread"?"Nom inconnu : le catalogue n’a pas pu être lu":"Produit absent du catalogue en service"}function mt(g,L,B,ge){const Oe=L>1?ge:B;return g>=L?`${ae(L)} ${Oe}.`:`${ae(g)} lignes affichées sur ${ae(L)} ${Oe}.`}function yt(g){const L=[];return g.offered||L.push("retiré de la grille"),g.min_weight_g!==null&&L.push(`peut peser à partir de ${ae(g.min_weight_g)} g`),L.length===0?"aucune restriction":L.join(" · ")}function Jt(g){const L=Da(g.result);return g.code===""?L:`${L} (${g.code})`}function Qt(g){return jr(g.source)}var ga=Ll(),sa=r(ga);tt(sa,{title:"Où le poste va chercher le catalogue",children:(g,L)=>{var B=Ws(),ge=K(B),Oe=r(ge),Ve=r(Oe),We=a(Oe,2),G=r(We),te=a(ge,2);{var De=he=>{var Fe=Us();l(he,Fe)},Je=he=>{var Fe=Fs(),Xe=K(Fe);{let wt=o(()=>ta("catalog.options.directory")),kt=o(()=>t.draft.text("catalog.options.directory")),Et=o(()=>gt("catalog.options.directory"));St(Xe,{get label(){return e(wt)},path:"catalog.options.directory",get value(){return e(kt)},hint:"Laissez vide pour le répertoire du poste, celui que le service crée lui-même. Un répertoire nommé ici doit exister : le poste ne le crée pas.",get fault(){return e(Et)},onchange:At=>t.draft.set("catalog.options.directory",At)})}var xt=a(Xe,2),qt=a(r(xt)),ht=r(qt);v(()=>i(ht,`flv_${t.health.station??""}.csv`)),l(he,Fe)},Ge=he=>{var Fe=Ms(),Xe=K(Fe);{let ht=o(()=>ta("catalog.options.url")),wt=o(()=>t.draft.text("catalog.options.url")),kt=o(()=>gt("catalog.options.url"));St(Xe,{get label(){return e(ht)},path:"catalog.options.url",get value(){return e(wt)},get fault(){return e(kt)},onchange:Et=>t.draft.set("catalog.options.url",Et)})}var xt=a(Xe,2);{let ht=o(()=>ta("catalog.options.username")),wt=o(()=>t.draft.text("catalog.options.username")),kt=o(()=>gt("catalog.options.username"));St(xt,{get label(){return e(ht)},path:"catalog.options.username",get value(){return e(wt)},get fault(){return e(kt)},onchange:Et=>t.draft.set("catalog.options.username",Et)})}var qt=a(xt,2);{let ht=o(()=>ta("catalog.options.password")),wt=o(()=>gt("catalog.options.password"));St(qt,{get label(){return e(ht)},path:"catalog.options.password",kind:"password",value:"",hint:"Laissez vide : le mot de passe actuel est conservé.",get fault(){return e(wt)},onchange:kt=>t.draft.set("catalog.options.password",kt)})}l(he,Fe)},Ye=he=>{var Fe=Bs();l(he,Fe)};m(te,he=>{t.draft.config===null?he(De):e(_e)==="local_drop"?he(Je,1):e(_e)==="webdav"?he(Ge,2):he(Ye,-1)})}v(()=>{wa(Ve,e(_e)==="local_drop"),wa(G,e(_e)==="webdav")}),Ze("change",Ve,()=>Rt("local_drop")),Ze("change",G,()=>Rt("webdav")),l(g,B)},$$slots:{default:!0}});var da=a(sa,2);tt(da,{title:"Dernier import",children:(g,L)=>{var B=Hs(),ge=K(B);{var Oe=he=>{var Fe=Vs();l(he,Fe)},Ve=he=>{Cr(he,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives}})};m(ge,he=>{t.health.catalog===null?he(Oe):he(Ve,-1)})}var We=a(ge,2),G=r(We),te=a(We,2);{var De=he=>{var Fe=Gs(),Xe=r(Fe);v(()=>i(Xe,st.sentence)),l(he,Fe)};m(te,he=>{st.sentence!==""&&he(De)})}var Je=a(te,2),Ge=r(Je);{let he=o(()=>e(ee)==="reload");at(Ge,{act:"reload",kind:"write",label:"Recharger le catalogue",protected:!0,get busy(){return e(he)},get disabled(){return e(x)},onrun:()=>{Vt()}})}var Ye=a(Ge,2);{let he=o(()=>e(ee)==="quarantine");at(Ye,{act:"quarantine",kind:"destructive",label:"Oublier la quarantaine",protected:!0,get busy(){return e(he)},get disabled(){return e(x)},onrun:()=>{Ut("quarantine",An)}})}v(()=>i(G,t.health.catalog_source===null?"Aucune source de catalogue publiée par ce poste.":"Source : "+t.health.catalog_source.label)),l(g,B)},$$slots:{default:!0}});var la=a(da,2);tt(la,{title:"Ce que la grille montre",note:"Un réglage d’affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser.",children:(g,L)=>{var B=Zs(),ge=K(B);s(ge,()=>({path:"ui.show_by_unit_products",label:"Afficher les produits vendus à l’unité",hint:"Décoché, leurs tuiles quittent la grille et la recherche ne les retrouve plus. Ce que le poste perd : une tuile vendue à l’unité imprime une étiquette sans jamais lire la balance, et c’est le seul geste que ce réglage retire."}));var Oe=a(ge,2),Ve=r(Oe),We=a(Oe,4),G=a(r(We));{var te=Qe=>{var ot=Js();ot.textContent="ui.grid_columns",l(Qe,ot)};m(G,Qe=>{aa.showTechnicalNames&&Qe(te)})}var De=a(We,2),Je=r(De),Ge=r(Je),Ye=a(Je,2);rt(Ye,16,()=>H,Qe=>Qe,(Qe,ot)=>{var _t=Ks(),jt=r(_t),p=a(jt);v((E,ye)=>{Ae(_t,"data-columns",ot),Ae(_t,"data-on",E),Sa(jt,ot),wa(jt,e(F)===ot),i(p,` ${ye??""}`)},[()=>String(e(F)===ot),()=>ae(ot)]),Ze("change",jt,()=>t.draft.set(A,ot)),l(Qe,_t)});var he=a(De,2),Fe=r(he);rt(Fe,17,()=>e(Me),Oa,(Qe,ot)=>{var _t=Ys(),jt=r(_t);v(()=>i(jt,e(ot))),l(Qe,_t)});var Xe=a(Fe,2);{var xt=Qe=>{var ot=Xs(),_t=r(ot);v(()=>i(_t,e(Be))),l(Qe,ot)};m(Xe,Qe=>{e(Be)!==""&&Qe(xt)})}var qt=a(he,4),ht=r(qt),wt=r(ht);{var kt=Qe=>{var ot=Qs(),_t=r(ot);{let jt=o(()=>M("ui.show_grid_prices",e(xe).show_grid_prices??!0));Vr(_t,{get product(){return e($)},get nameSizePx(){return Fa},get primaryCode(){return e(Z)},get showPrice(){return e(jt)},onpick:()=>{}})}ja(ot,jt=>d(Ie,jt),()=>e(Ie)),l(Qe,ot)};m(wt,Qe=>{e($)!==null&&Qe(kt)})}ja(ht,Qe=>d(be,Qe),()=>e(be));var Et=a(ht,2);ja(Et,Qe=>d(R,Qe),()=>e(R));var At=a(Et,2);ja(At,Qe=>d(_,Qe),()=>e(_)),ja(qt,Qe=>d(J,Qe),()=>e(J)),v(Qe=>{i(Ve,e(pe)),Ae(Je,"data-columns",ka),Ae(Je,"data-on",Qe),Sa(Ge,ka),wa(Ge,e(F)===ka)},[()=>String(e(F)===ka)]),Ze("change",Ge,()=>t.draft.set(A,ka)),l(g,B)},$$slots:{default:!0}});var ma=a(la,2);tt(ma,{title:"Déposer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(g,L)=>{var B=tl(),ge=K(B);{var Oe=he=>{var Fe=el(),Xe=r(Fe);v(()=>i(Xe,e(Y))),l(he,Fe)};m(ge,he=>{e(Y)!==""&&he(Oe)})}var Ve=a(ge,2);let We;var G=r(Ve),te=a(r(G)),De=r(te),Je=a(G,2),Ge=r(Je),Ye=a(Ge);v(()=>{We=pa(Ve,1,"drop svelte-3gk7u7",null,We,{dropping:e(ke),working:e(ee)==="import"}),i(De,`flv_${t.health.station??""}.csv`),i(Ge,`${e(ee)==="import"?"Import en cours…":"Choisir un fichier"} `),Ye.disabled=e(x)}),ya("dragover",Ve,he=>{he.preventDefault(),!e(x)&&d(ke,!0)}),ya("dragleave",Ve,()=>d(ke,!1)),ya("drop",Ve,Xt),Ze("change",Ye,he=>Lt(he.currentTarget)),l(g,B)},$$slots:{default:!0}});var ia=a(ma,2);tt(ia,{title:"Anomalies à corriger dans Odoo",note:"Chaque ligne porte le nom du produit, son numéro dans le CSV, son motif et la valeur fautive.",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=G=>{var te=al(),De=r(te);v(()=>i(De,e(it))),l(G,te)},Ve=G=>{var te=rl();l(G,te)},We=G=>{var te=nl(),De=K(te),Je=r(De),Ge=a(Je);{var Ye=Fe=>{var Xe=zt("Corrigez celles-ci dans Odoo : l’import suivant ne signalera que ce qui reste.");l(Fe,Xe)};m(Ge,Fe=>{e(T).length>e(D).length&&Fe(Ye)})}var he=a(De,2);Wa(he,()=>e(D),()=>"anomalies"),v(Fe=>i(Je,`${Fe??""} `),[()=>mt(e(D).length,e(T).length,"anomalie","anomalies")]),l(G,te)};m(ge,G=>{e(S)!=="read"?G(Oe):e(T).length===0?G(Ve,1):G(We,-1)})}l(g,B)},$$slots:{default:!0}});var oa=a(ia,2);tt(oa,{title:"Unités divergentes",note:"Le produit reste proposé : le code-barres fait foi, seul le libellé du prix est faux.",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=G=>{var te=sl(),De=r(te);v(()=>i(De,e(it))),l(G,te)},Ve=G=>{var te=ll();l(G,te)},We=G=>{var te=il(),De=K(te),Je=r(De),Ge=a(De,2);Wa(Ge,()=>e(ie),()=>"mismatches"),v(Ye=>i(Je,Ye),[()=>mt(e(ie).length,e(se).length,"unité divergente","unités divergentes")]),l(G,te)};m(ge,G=>{e(S)!=="read"?G(Oe):e(se).length===0?G(Ve,1):G(We,-1)})}l(g,B)},$$slots:{default:!0}});var ua=a(oa,2);tt(ua,{title:"Produits non pesables",note:"Un inventaire, pas une liste d’erreurs : ces produits portent déjà leur code-barres et n’ont aucune raison d’être pesés.",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=G=>{var te=ol(),De=r(te);v(()=>i(De,e(it))),l(G,te)},Ve=G=>{var te=ul();l(G,te)},We=G=>{var te=cl(),De=K(te),Je=r(De),Ge=a(De,2);Wa(Ge,()=>e(Ee),()=>"not-weighable"),v(Ye=>i(Je,Ye),[()=>mt(e(Ee).length,e(W).length,"produit non pesable","produits non pesables")]),l(G,te)};m(ge,G=>{e(S)!=="read"?G(Oe):e(W).length===0?G(Ve,1):G(We,-1)})}l(g,B)},$$slots:{default:!0}});var ha=a(ua,2);tt(ha,{title:"Produits retirés depuis l’import précédent",note:"Un produit absent du nouveau fichier est marqué retiré à sa date, jamais supprimé.",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=G=>{var te=dl();l(G,te)},Ve=G=>{var te=vl();l(G,te)},We=G=>{var te=pl(),De=K(te),Je=r(De);v(()=>i(Je,e(ft))),l(G,te)};m(ge,G=>{t.health.catalog===null?G(Oe):e(Re)===0?G(Ve,1):G(We,-1)})}l(g,B)},$$slots:{default:!0}});var La=a(ha,2);tt(La,{title:"Décider d’un produit",note:"Retirer un produit et l’autoriser à peser moins sont deux décisions séparées : l’une n’efface pas l’autre.",children:(g,L)=>{var B=_l(),ge=a(K(B),2),Oe=a(ge,2);{var Ve=te=>{var De=gl(),Je=K(De),Ge=r(Je),Ye=a(Je,2),he=r(Ye);rt(he,21,()=>e(C),Fe=>Fe.id,(Fe,Xe)=>{var xt=fl(),qt=r(xt),ht=r(qt),wt=a(qt,2),kt=r(wt),Et=a(wt,2),At=r(Et);v(()=>{i(ht,e(Xe).name),i(kt,e(Xe).id),i(At,`${e(Xe).unit_price_text??""}${e(Xe).price_suffix??""}`)}),Ze("click",qt,()=>Ot(e(Xe).id)),l(Fe,xt)}),v(()=>i(Ge,e(ct))),l(te,De)};m(Oe,te=>{e(de)!==""&&te(Ve)})}var We=a(Oe,2);{var G=te=>{var De=hl(),Je=r(De),Ge=a(r(Je)),Ye=r(Ge),he=a(Ge),Fe=a(Je,2),Xe=r(Fe),xt=a(Fe,4),qt=a(xt,2);{var ht=ue=>{var et=ml();l(ue,et)};m(qt,ue=>{e(He)===""&&ue(ht)})}var wt=a(qt,2),kt=a(r(wt),2),Et=r(kt);{var At=ue=>{{let et=o(()=>e(ee)==="offered"),lt=o(()=>e(x)||!e(ze));at(ue,{act:"offered",kind:"destructive",label:"Ne plus proposer ce produit",protected:!0,get busy(){return e(et)},get disabled(){return e(lt)},onrun:()=>{Gt(!1)}})}},Qe=ue=>{{let et=o(()=>e(ee)==="offered"),lt=o(()=>e(x)||!e(Te));at(ue,{act:"offered",kind:"write",label:"Le proposer de nouveau",protected:!0,get busy(){return e(et)},get disabled(){return e(lt)},onrun:()=>{Gt(!0)}})}};m(Et,ue=>{e(Ne)?ue(At):ue(Qe,-1)})}var ot=a(wt,2),_t=a(r(ot),2),jt=a(_t,2),p=r(jt);{let ue=o(()=>e(ee)==="waiver"),et=o(()=>e(x)||!e(we));at(p,{act:"waiver",kind:"write",label:"Enregistrer la dérogation",protected:!0,get busy(){return e(ue)},get disabled(){return e(et)},onrun:()=>{Ht(e($e))}})}var E=a(p,2);{var ye=ue=>{{let et=o(()=>e(ee)==="waiver-off"),lt=o(()=>e(x)||!e(Ke));at(ue,{act:"waiver-off",kind:"write",label:"Retirer la dérogation",protected:!0,get busy(){return e(et)},get disabled(){return e(lt)},onrun:()=>{Ht(null)}})}};m(E,ue=>{e(Pe)!==null&&ue(ye)})}v((ue,et)=>{Ae(De,"data-decision",e(re)),i(Ye,ue),i(he,` (${e(re)??""})`),i(Xe,`En vigueur : ${et??""}`),Sa(_t,e(ne))},[()=>bt(e(re)),()=>e(oe)===null?"aucune décision — ce produit suit les règles générales":yt(e(oe))]),Pa(xt,()=>e(N),ue=>d(N,ue)),Ze("input",_t,ue=>d(ne,ue.currentTarget.value,!0)),l(te,De)};m(We,te=>{e(re)!==""&&te(G)})}Pa(ge,()=>e(de),te=>d(de,te)),l(g,B)},$$slots:{default:!0}});var xa=a(La,2);tt(xa,{title:"Décisions en vigueur",note:"Ce qu’un humain a décidé, produit par produit. C’est ici que se reprend un produit retiré de la grille.",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=We=>{var G=bl();l(We,G)},Ve=We=>{var G=yl(),te=K(G),De=r(te),Je=a(te,2),Ge=r(Je);rt(Ge,21,()=>e(Le),Ye=>Ye.product_id,(Ye,he)=>{var Fe=wl(),Xe=r(Fe),xt=a(Xe,2),qt=r(xt),ht=a(xt,2),wt=r(ht),kt=a(ht,2),Et=r(kt),At=a(kt,2),Qe=r(At),ot=a(At,2),_t=r(ot);v((jt,p,E)=>{i(qt,jt),i(wt,e(he).product_id),i(Et,p),i(Qe,e(he).reason),i(_t,E)},[()=>bt(e(he).product_id),()=>yt(e(he)),()=>Aa(e(he).decided_at)]),Ze("click",Xe,()=>Ot(e(he).product_id)),l(Ye,Fe)}),v(Ye=>i(De,Ye),[()=>mt(e(Le).length,e(Ce).length,"décision en vigueur","décisions en vigueur")]),l(We,G)};m(ge,We=>{e(Ce).length===0?We(Oe):We(Ve,-1)})}l(g,B)},$$slots:{default:!0}});var Zt=a(xa,2);tt(Zt,{title:"Vingt derniers imports",children:(g,L)=>{var B=ea(),ge=K(B);{var Oe=G=>{var te=xl(),De=r(te);v(()=>i(De,e(Ct))),l(G,te)},Ve=G=>{var te=kl();l(G,te)},We=G=>{var te=ql(),De=K(te),Je=r(De),Ge=a(De,2),Ye=r(Ge),he=a(r(Ye));rt(he,21,()=>e(ce),Fe=>Fe.id,(Fe,Xe)=>{var xt=Sl(),qt=r(xt),ht=r(qt),wt=a(qt),kt=r(wt),Et=a(wt),At=r(Et),Qe=a(Et),ot=r(Qe),_t=a(Qe),jt=r(_t),p=a(_t),E=r(p),ye=a(p),ue=r(ye),et=a(ye),lt=r(et),dt=a(et),Nt=r(dt),Mt=a(dt),Pt=r(Mt);v((ca,va,za,Ia,Ua,Tr,Rr,Or)=>{i(ht,ca),i(kt,e(Xe).file_name),i(At,va),i(ot,za),i(jt,e(Xe).reason),i(E,Ia),i(ue,Ua),i(lt,Tr),i(Nt,Rr),i(Pt,Or)},[()=>ra(e(Xe).occurred_at),()=>Qt(e(Xe)),()=>Jt(e(Xe)),()=>ae(e(Xe).rows_read_count),()=>ae(e(Xe).weighable_count),()=>ae(e(Xe).not_weighable_count),()=>ae(e(Xe).anomalies_count),()=>ae(e(Xe).products_withdrawn_count)]),l(Fe,xt)}),v(()=>i(Je,e(je))),l(G,te)};m(ge,G=>{e(S)!=="read"?G(Oe):e(ce).length===0?G(Ve,1):G(We,-1)})}l(g,B)},$$slots:{default:!0}}),l(n,ga),Dt()}Wt(["change","click","input"]);function zl(n){switch(n){case"ok":return"OK";case"warn":return"À SURVEILLER";case"fault":return"EN PANNE";case"off":return"SANS OBJET";default:return"INCONNU"}}var jl=u('

          '),Cl=u('

          ');function Pl(n,t){$t(t,!0);var s=Cl(),c=r(s),w=r(c),k=a(w,2),O=r(k),U=a(k,2),A=r(U),H=a(c,2),Q=r(H),_e=a(H,2);{var ce=qe=>{var me=jl(),j=r(me);v(()=>i(j,t.light.remedy)),l(qe,me)};m(_e,qe=>{t.light.remedy!==""&&qe(ce)})}v(qe=>{Ae(s,"data-light",t.light.id),Ae(s,"data-level",t.light.level),Ae(w,"data-level",t.light.level),i(O,t.light.label),i(A,qe),i(Q,t.light.value)},[()=>zl(t.light.level)]),l(n,s),Dt()}function Tl(n){return[Rl(n),Ol(n),Nl(n),$l(n),Il(n),Ul(n)]}function Rl(n){const t=n.state.scale,s=Ra(t.median_ms);return n.scale_present?t.connected?t.too_slow?{id:"scale",label:"Balance",level:"warn",value:`une mesure toutes les ${s}, plus lent que la péremption`,remedy:"À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante. Vérifiez le câble et l’adaptateur USB, puis la cadence sur la page Matériel."}:{id:"scale",label:"Balance",level:"ok",value:Fl(t.provisional,`une mesure toutes les ${s}`),remedy:""}:{id:"scale",label:"Balance",level:"fault",value:"elle ne répond plus",remedy:"Vérifiez le câble et l’alimentation de la balance, puis touchez « Tester la balance ». En attendant, « Basculer en saisie manuelle » permet de continuer à servir."}:{id:"scale",label:"Balance",level:"off",value:"ce poste est déclaré sans balance",remedy:""}}function Ol(n){const t=n.state.printer;switch(t.health){case"faulted":return{id:"printer",label:"Imprimante",level:"fault",value:t.detail===""?"elle ne peut pas imprimer":t.detail,remedy:Al(n)};case"consumable":return{id:"printer",label:"Imprimante",level:"warn",value:"elle imprime, mais le rouleau arrive en fin de vie",remedy:"Changez le rouleau quand vous passez derrière le comptoir, puis touchez « J’ai changé le rouleau ». Le poste continue de servir en attendant."};case"ready":return{id:"printer",label:"Imprimante",level:"ok",value:"elle répond et n’a rien à signaler",remedy:""};default:return{id:"printer",label:"Imprimante",level:"unknown",value:"elle prend les étiquettes et ne dit rien en retour",remedy:"C’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne. Pour savoir si elle imprime, touchez « Imprimer une étiquette de test »."}}}function Al(n){const t="Regardez le capot, le rouleau et le câble, puis touchez « Tester l’imprimante ».";return n.printing?.fallback_available===!0?t+" Le poste peut servir en attendant : « Imprimer sur l’imprimante du poste voisin ».":t}function Nl(n){const t=n.roll;return t===null?{id:"roll",label:"Rouleau",level:"unknown",value:"aucun compteur d’étiquettes sur ce poste",remedy:`Sans imprimante construite, il n’y a pas de rouleau à compter : vérifiez le « ${ta("printer.type")} » et ses réglages sur la page Matériel.`}:t.known?t.level==="warn"?{id:"roll",label:"Rouleau",level:"warn",value:t.message,remedy:"Changez le rouleau, puis touchez « J’ai changé le rouleau »."}:{id:"roll",label:"Rouleau",level:"ok",value:t.message,remedy:""}:{id:"roll",label:"Rouleau",level:"unknown",value:t.message,remedy:"Touchez « J’ai changé le rouleau » en mettant un rouleau neuf : c’est le seul geste qui dise quelque chose de vrai du papier."}}function $l(n){const t=n.catalog_source?.label??"";if(n.state.catalog_count===0)return{id:"catalog",label:"Catalogue",level:"fault",value:"aucun produit dans la grille",remedy:Dl(t)};const s=n.catalog;return s!==null&&s.result!=="applied"&&s.result!=="unchanged"?{id:"catalog",label:"Catalogue",level:"warn",value:`dernier fichier refusé : ${s.reason===""?s.result:s.reason}`,remedy:"La grille tourne toujours sur le catalogue précédent. Corrigez le fichier dans Odoo, ou touchez « Oublier la quarantaine » puis redéposez-le."}:{id:"catalog",label:"Catalogue",level:"ok",value:`${ae(n.state.catalog_count)} produits dans la grille`,remedy:""}}function Dl(n){return"Déposez le fichier du catalogue là où le poste le guette, ou utilisez « Importer un catalogue » ci-contre pour le glisser depuis une clé USB."+(n===""?"":` Ce poste surveille : ${n}.`)}function Il(n){const t=n.disk;if(t===null)return{id:"disk",label:"Disque",level:"unknown",value:"la place libre n’a pas pu être mesurée",remedy:"Téléchargez le fichier de diagnostic : il porte ce que le poste a pu lire du volume, et c’est la pièce qu’un support demandera."};const s=Ga(t.free_bytes),c=`seuil ${ae(t.alert_mb)} Mo`;return t.free_bytes===0?{id:"disk",label:"Disque",level:"fault",value:`plus un octet libre sur ${t.path}`,remedy:"Le journal ne peut plus rien écrire, alors que les étiquettes continuent de sortir. Faites de la place sur le disque, puis rechargez cette page."}:t.alert_mb>0&&t.free_bytes0?{id:"journal",label:"Journal",level:"fault",value:`${ae(t)} pesées imprimées mais non enregistrées`,remedy:"Les étiquettes sortent, les ventes sont bonnes, c’est la trace qui manque. Téléchargez le fichier de diagnostic et prévenez le support : ne redémarrez rien."}:n.counters.journal_rows_count<0?{id:"journal",label:"Journal",level:"unknown",value:"ce poste n’a pas de journal ouvert",remedy:"Le poste pèse et imprime quand même. Téléchargez le fichier de diagnostic : il dit pourquoi la base n’a pas pu être ouverte."}:{id:"journal",label:"Journal",level:"ok",value:`${ae(n.counters.journal_rows_count)} pesées enregistrées`,remedy:""}}function Fl(n,t){return n?t+" (cadence encore provisoire)":t}function Ml(n){return n===null||!n.known?"unknown":n.configured?"ok":"missing"}var Bl=u("Une mesure toutes les ",1),Wl=u('

          '),Vl=u("Source : ",1),Gl=u(`

          Aucun import enregistré sur ce poste : le catalogue n’est jamais arrivé, ou le - journal ne le porte pas.

          `),Hl=u('

          ',1),Jl=u('

          ',1),Kl=u('

          Aucune décision locale : le catalogue est proposé tel qu’il arrive.

          '),Yl=u('

          '),Xl=u(' '),Ql=u('
        • '),Zl=u('
            ',1),ei=u(`INCONNU — la question n’a pas pu être posée à ce système. Ce - n’est pas « non configuré » : personne ne sait encore.`,1),ti=u('OK ',1),ai=u('NON CONFIGURÉ ',1),ri=u('

            '),ni=u('

            ',1),si=u('

            Rien à signaler depuis le démarrage du poste.

            '),li=u(' '),ii=u('
          • '),oi=u('
              '),ui=u(''),ci=u('

              '),di=u('
              Numéro de poste
              Nom
              Coopérative
              Version
              Empreinte de configuration
              ',1),vi=u('
              ');function pi(n,t){$t(t,!0);const s=o(()=>t.health.new_version),c=o(()=>Tl(t.health)),w=o(()=>t.health.state.scale),k=o(()=>t.health.unattended_restart),O=o(()=>t.health.decisions),U=5,A=o(()=>e(O).slice(0,U));var H=vi(),Q=r(H);rt(Q,21,()=>e(c),Z=>Z.id,(Z,de)=>{Pl(Z,{get light(){return e(de)}})});var _e=a(Q,2);tt(_e,{title:"Cadence de la balance",children:(Z,de)=>{var re=Wl(),N=r(re);{var ne=S=>{var h=zt("Ce poste est déclaré sans balance : le poids est saisi à la main.");l(S,h)},ke=S=>{var h=zt(`La balance ne répond pas : aucune cadence n’est mesurable tant qu’aucune trame - n’arrive.`);l(S,h)},Y=S=>{var h=zt(`Aucun intervalle n’a encore été mesuré : la cadence apparaîtra dès les premières - trames.`);l(S,h)},ee=S=>{var h=Bl(),x=a(K(h)),T=r(x),W=a(x),se=a(W);{var le=F=>{var y=zt("C’est encore une valeur d’attente : moins de huit intervalles ont été observés.");l(F,y)};m(se,F=>{e(w).provisional&&F(le)})}var ve=a(se,2);{var pe=F=>{var y=zt("À cette cadence, un poids serait périmé avant l’arrivée de la mesure suivante.");l(F,y)};m(ve,F=>{e(w).too_slow&&F(pe)})}v((F,y)=>{i(T,F),i(W,`, médiane - observée sur ${y??""} intervalles. `)},[()=>Ra(e(w).median_ms),()=>ae(e(w).observations_count)]),l(S,h)};m(N,S=>{t.health.scale_present?e(w).connected?e(w).observations_count===0?S(Y,2):S(ee,-1):S(ke,1):S(ne)})}l(Z,re)},$$slots:{default:!0}});var ce=a(_e,2);tt(ce,{title:"Catalogue",children:(Z,de)=>{var re=Jl(),N=K(re),ne=r(N);{var ke=x=>{var T=zt("Aucune source de catalogue n’est publiée par ce poste.");l(x,T)},Y=x=>{var T=Vl(),W=a(K(T)),se=r(W);v(()=>i(se,t.health.catalog_source.label)),l(x,T)};m(ne,x=>{t.health.catalog_source===null?x(ke):x(Y,-1)})}var ee=a(N,2);{var S=x=>{var T=Gl();l(x,T)},h=x=>{var T=Hl(),W=K(T),se=r(W),le=a(W,2);Cr(le,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives},get onshowrows(){return t.onshowrows}}),v((ve,pe)=>i(se,`Dernier essai : ${ve??""} — - ${pe??""}${t.health.catalog.file_name===""?"":" ("+t.health.catalog.file_name+")"}${t.health.catalog.reason===""?"":" : "+t.health.catalog.reason}`),[()=>ra(t.health.catalog.occurred_at),()=>Da(t.health.catalog.result)]),l(x,T)};m(ee,x=>{t.health.catalog===null?x(S):x(h,-1)})}l(Z,re)},$$slots:{default:!0}});var qe=a(ce,2);tt(qe,{title:"Décisions locales en vigueur",note:"Ce qu’un humain a décidé de ce catalogue, avec son motif et sa date.",children:(Z,de)=>{var re=ea(),N=K(re);{var ne=Y=>{var ee=Kl();l(Y,ee)},ke=Y=>{var ee=Zl(),S=K(ee);{var h=T=>{var W=Yl(),se=r(W);v(le=>i(se,`${le??""} décisions en vigueur — les - 5 plus récentes ci-dessous. La liste entière est sur la page - Catalogue.`),[()=>ae(e(O).length)]),l(T,W)};m(S,T=>{e(O).length>U&&T(h)})}var x=a(S,2);rt(x,21,()=>e(A),T=>T.product_id,(T,W)=>{var se=Ql(),le=r(se),ve=r(le),pe=a(le,2);{var F=be=>{var Ie=Xl(),R=r(Ie);v(_=>i(R,`peut peser à partir de ${_??""} g`),[()=>ae(e(W).min_weight_g)]),l(be,Ie)};m(pe,be=>{e(W).min_weight_g!==null&&be(F)})}var y=a(pe,2),$=r(y),M=a(y,2),J=r(M);v(be=>{i(ve,`${e(W).offered?"Dérogation de poids":"Produit retiré"} — ${e(W).product_id??""}`),i($,e(W).reason),i(J,`${be??""}, ${e(W).decided_by??""}`)},[()=>Aa(e(W).decided_at)]),l(T,se)}),l(Y,ee)};m(N,Y=>{e(O).length===0?Y(ne):Y(ke,-1)})}l(Z,re)},$$slots:{default:!0}});var me=a(qe,2);tt(me,{title:"Redémarrage sans intervention",children:(Z,de)=>{var re=ni(),N=K(re),ne=r(N);{var ke=x=>{var T=ei();l(x,T)},Y=x=>{var T=ti(),W=a(K(T));v(()=>i(W,` — après une coupure de courant, ce poste revient seul sur - l’écran client. ${e(k).detail??""}`)),l(x,T)},ee=x=>{var T=ai(),W=a(K(T));v(()=>i(W,` — ${e(k).detail??""}`)),l(x,T)};m(ne,x=>{e(k)===null||!e(k).known?x(ke):e(k).configured?x(Y,1):x(ee,-1)})}var S=a(N,2);{var h=x=>{var T=ri(),W=r(T);v(()=>i(W,e(k).remedy)),l(x,T)};m(S,x=>{e(k)!==null&&e(k).known&&!e(k).configured&&e(k).remedy!==""&&x(h)})}v(x=>Ae(N,"data-verdict",x),[()=>Ml(e(k))]),l(Z,re)},$$slots:{default:!0}});var j=a(me,2);tt(j,{title:"Dix derniers événements",children:(Z,de)=>{var re=ea(),N=K(re);{var ne=Y=>{var ee=si();l(Y,ee)},ke=Y=>{var ee=oi();rt(ee,21,()=>t.health.events,S=>S.id,(S,h)=>{var x=ii(),T=r(x),W=r(T),se=a(T,2),le=r(se),ve=a(se,2);{var pe=$=>{var M=li(),J=r(M);v(()=>i(J,e(h).code)),l($,M)};m(ve,$=>{e(h).code!==""&&aa.showTechnicalNames&&$(pe)})}var F=a(ve,2),y=r(F);v(($,M)=>{Ae(x,"data-level",e(h).level),i(W,$),i(le,M),i(y,e(h).message)},[()=>_s(e(h).occurred_at),()=>qr(e(h).source)]),l(S,x)}),l(Y,ee)};m(N,Y=>{t.health.events.length===0?Y(ne):Y(ke,-1)})}l(Z,re)},$$slots:{default:!0}});var xe=a(j,2);tt(xe,{title:"Ce poste",children:(Z,de)=>{var re=di(),N=K(re),ne=a(r(N),2),ke=r(ne),Y=a(ne,4),ee=r(Y),S=a(Y,4),h=r(S),x=a(S,4),T=r(x),W=a(x,4),se=r(W),le=a(N,2);{var ve=pe=>{var F=ci(),y=r(F),$=a(y);{var M=J=>{var be=ui();Ze("click",be,function(...Ie){t.onshowupdate?.apply(this,Ie)}),l(J,be)};m($,J=>{t.onshowupdate!==void 0&&J(M)})}v(()=>{Ae(F,"data-update-available",e(s)),i(y,`Version ${e(s)??""} disponible. `)}),l(pe,F)};m(le,pe=>{e(s)!==""&&pe(ve)})}v(()=>{i(ke,t.health.station),i(ee,t.health.station_name===""?"non renseigné":t.health.station_name),i(h,t.health.coop),i(T,t.health.version),i(se,t.health.config_fingerprint)}),l(Z,re)},$$slots:{default:!0}}),l(n,H),Dt()}Wt(["click"]);var fi=u(''),gi=u(`

              Lecture de la configuration en cours… tant qu’elle n’est pas arrivée, cette page ne - déclare rien de ce poste.

              `),vr=u('
            • '),mi=u('
                '),hi=u('

                ',1),_i=u('
              • '),bi=u('

                  ',1),wi=u('

                  '),yi=u('
                • '),xi=u('
                    '),ki=u('

                    '),Si=u('

                      ',1),qi=u('

                      '),Li=u('

                      Balance

                      L’état et la cadence viennent de ce que le poste observe vraiment, jamais d’un réglage.

                      Réglages série de la balance

                      Imprimante

                      Réglages de l’imprimante
                      ');function Ei(n,t){$t(t,!0);const s=pt(t,"admin",7),c=20,w=3,k=250,O=50,U=w*1e3+k+1e3,A=12;let H=P(Tt([])),Q=P(Tt([])),_e=P(Tt([])),ce=P(Tt([])),qe=P(""),me=P(!1),j=P(""),xe=P(0),Z=P(0),de=P(!1),re=P(null),N=P(!1),ne=P(!1),ke=!1;const Y=o(()=>t.health.state.scale),ee=o(()=>t.health.state.printer),S=o(()=>t.draft.config!==null),h=o(()=>e(S)&&!t.draft.flag("scale.present")),x=o(()=>t.draft.text("scale.options.port")),T=o(()=>e(re)!==null&&e(re).port===e(x)?e(re).reason:""),W=o(()=>e(qe)===e(x)?e(ce):[]),se=["queue","path","address"],le="queue",ve={queue:"Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.",path:"Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.",address:"L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque."},pe=o(()=>t.health.printer_transports),F=o(()=>t.draft.text("printer.options.transport")),y=o(()=>e(F)!==""&&!e(pe).some(p=>p.id===e(F))),$=o(()=>e(pe).length===0?[]:[...e(y)?[{value:e(F),label:`${e(F)} — inconnu de ce poste`}]:[],...e(pe).map(p=>({value:p.id,label:p.label}))]),M=o(()=>e(pe).find(p=>p.id===e(F))?.key??le),J=o(()=>"printer.options."+e(M)),be=o(()=>e(Q).filter(p=>p.key===e(M))),Ie=o(()=>e(Q).length-e(be).length),R=o(Pe),_=o(Ue),z=o(Te),I=o(Re),fe=o(()=>st("scale.type")!==""||st("scale.options.port")!==""),Me=o(()=>st("printer.type")!==""||st("printer.options.transport")!==""||se.some(p=>st("printer.options."+p)!==""));Kt(()=>{e(fe)&&d(N,!0)}),Kt(()=>{e(Me)&&d(ne,!0)}),tn(()=>{ke=!0}),Xa(()=>{C("ports",D)}),Kt(()=>{e(de)||!Be(e(x))||f(e(x))});function Be(p){return!ke&&e(S)&&!e(h)&&p!==""&&p===e(x)&&Se(p)&&e(T)===""&&e(j)===""}function Se(p){return e(H).some(E=>E.name===p)}async function f(p){d(de,!0);try{for(;Be(p);){try{V(p,await sr(p,w))}catch(E){d(re,{port:p,reason:Ut(E)},!0);return}await X()}}finally{d(de,!1)}}async function q(){const p=e(x),E=await s().protect(()=>sr(p,w));E!==null&&(V(p,E),d(re,null))}function V(p,E){if(ke||E.length===0)return;const ye=p===e(qe)?e(ce):[];d(qe,p,!0),d(ce,[...ye,...E].slice(-c),!0)}function X(p=k){return new Promise(E=>setTimeout(E,p))}async function b(){const p=Date.now()+U;for(;e(de)&&Date.now()kr(p));E!==null&&(s().notice=E.message,await s().refresh())}async function Le(){d(xe,0),d(Z,0),e(me)||await D();const p=e(H);if(p.length===0){d(_e,[],!0);return}await s().protect(()=>oe(p))}async function oe(p){d(_e,[],!0),d(Z,p.length,!0);for(const[E,ye]of p.entries()){d(xe,E+1);try{const ue=await zn(ye.name);d(_e,[...e(_e),{port:ye.name,message:ue.message,refused:!1}],!0)}catch(ue){if(Vt(ue))throw ue;d(_e,[...e(_e),{port:ye.name,message:Ut(ue),refused:!0}],!0)}}}function Ne(){return e(j)!=="detect"?"Détecter automatiquement":e(de)?"Détection : le port se libère…":e(Z)===0?"Détection : énumération des ports…":`Détection : port ${ae(e(xe))} sur ${ae(e(Z))}…`}function Pe(){return t.health.scale_present?e(Y).connected?e(Y).too_slow?{level:"warn",word:"Trop lente",detail:$e()+" À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante."}:{level:"ok",word:"Connectée",detail:"Elle répond. "+$e()}:{level:"fault",word:"Sans réponse",detail:"Elle ne répond plus. Vérifiez le câble et l’alimentation, puis « Tester la balance » sur la page Dépannage."}:{level:"off",word:"Sans balance",detail:"Ce poste est déclaré sans balance : le feu est éteint et le poids se saisit à la main."}}function $e(){return e(Y).observations_count===0?"Aucun intervalle n’a encore été mesuré : la cadence sera connue dès les premières trames.":`Une mesure toutes les ${Ra(e(Y).median_ms)} sur ${ae(e(Y).observations_count)} intervalles`+(e(Y).provisional?", cadence encore provisoire.":".")}function Ue(){const p=He[e(ee).health]??{level:"unknown",word:"État inconnu",detail:"Le poste a répondu un état que cet écran ne sait pas nommer."};return{...p,detail:e(ee).detail===""?p.detail:e(ee).detail}}const He={ready:{level:"ok",word:"Prête",detail:"Elle répond et n’a rien à signaler."},consumable:{level:"warn",word:"Rouleau en fin de vie",detail:"Elle imprime, mais le rouleau arrive en fin de vie."},faulted:{level:"fault",word:"En panne",detail:"Elle ne peut pas imprimer."},unknown:{level:"unknown",word:"Silencieuse",detail:"Elle prend les étiquettes et ne dit rien en retour : c’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne."}};function ze(){const p=e(ee).observed_at===""?"Jamais observée depuis le démarrage":`Observée le ${ra(e(ee).observed_at)}`,E=e(ee).pending_jobs_count;return`${p}, ${ae(E)} ${E>1?"travaux":"travail"} en attente.`}function Te(){const p=Ke();return p===""?we():`${we()} ${p}`}function we(){return e(S)?e(h)?"Ce poste est déclaré sans balance : aucun port n’est écouté.":e(x)===""?"Aucun port n’est indiqué : choisissez-en un dans la liste ci-dessus pour écouter les trames.":e(me)?Se(e(x))?e(T)!==""?`L’écoute de ${e(x)} est arrêtée.`:e(j)!==""?`L’écoute de ${e(x)} est suspendue le temps de l’acte en cours.`:`Écoute de ${e(x)}.`:`${e(x)} n’est pas visible depuis ce poste : rien n’est écouté en continu.`:e(j)==="ports"?`Énumération des ports en cours : l’écoute de ${e(x)} démarre dès qu’il est vu.`:`Les ports de ce poste n’ont pas été énumérés : « Lister les ports » dira si ${e(x)} existe.`:"Lecture de la configuration en cours : le port à écouter n’est pas encore connu."}function Ke(){return e(W).length===0?"Aucune trame reçue pour l’instant.":e(W).length===1?`Une seule trame reçue — ${ae(c)} au plus sont gardées.`:`Les ${ae(e(W).length)} dernières trames — ${ae(c)} au plus, la plus récente en bas.`}function Re(){return!e(S)||e(h)||e(x)===""?"":e(T)!==""?"Reprendre l’écoute":e(me)&&!Se(e(x))?"Écouter ce port une fois":""}function nt(p,E,ye){const ue=`${ae(ye)} ${ye>1?E:p}`;return ye<=A?ue+".":`${ue} — seules les ${ae(A)} premières lignes sont affichées.`}function ft(p){return e(pe).find(E=>E.key===p)?.label??""}function ct(){const p=[...new Set(e(Q).filter(ye=>ye.key!==e(M)).map(ye=>ft(ye.key)))].filter(ye=>ye!==""),E=`${ae(e(Ie))} ${e(Ie)>1?"destinations ne sont pas proposées":"destination n’est pas proposée"}`;return p.length===0?`${E} : aucun transport de ce poste ne les lit.`:`${E} : choisissez « ${p.join(" » ou « ")} » pour les voir.`}function je(p,E){const ye=It(p);return ye.length>0?ye:E.slice(0,A)}function it(p){return[...new TextEncoder().encode(p)].map(E=>E.toString(16).toUpperCase().padStart(2,"0")).join(" ")}const Ct={0:"NUL",2:"STX",3:"ETX",4:"EOT",5:"ENQ",6:"ACK",9:"TAB",10:"LF",13:"CR",21:"NAK",27:"ESC",127:"DEL"};function ut(p){let E="";for(const ye of p){const ue=ye.codePointAt(0)??0;if(ue>=32&&ue!==127){E+=ye;continue}E+=`⟨${Ct[ue]??ue.toString(16).toUpperCase().padStart(2,"0")}⟩`}return E}function st(p){return t.draft.faults.find(E=>E.field===p)?.message??""}function It(p){return t.draft.faults.find(E=>E.field===p)?.allowed??[]}function Ut(p){return p instanceof Yt?p.message:p instanceof Error?"Le poste n’a pas répondu : "+p.message:"Le poste n’a pas répondu."}function Vt(p){return p instanceof Yt&&p.needsCredentials}const Rt=[{what:"label",name:"étiquette"},{what:"alignment",name:"alignement"},{what:"ruler",name:"réglette"}],gt=o(()=>Rt.filter(p=>t.health.printer_self_tests.includes(p.what)));var Ot=Li(),Ft=r(Ot),Gt=r(Ft),Ht=a(r(Gt),2),na=a(r(Ht),1,!0),Xt=a(Gt,2),Lt=r(Xt),bt=a(Xt,4);{var mt=p=>{var E=fi(),ye=r(E);v(()=>wa(ye,e(h))),Ze("change",ye,ue=>t.draft.set("scale.present",!ue.currentTarget.checked)),l(p,E)},yt=p=>{var E=gi();l(p,E)};m(bt,p=>{e(S)?p(mt):p(yt,-1)})}var Jt=a(bt,2),Qt=r(Jt);{let p=o(()=>e(j)==="ports"),E=o(()=>e(j)!=="");at(Qt,{label:"Lister les ports",get busy(){return e(p)},get disabled(){return e(E)},onrun:()=>{C("ports",D)}})}var ga=a(Qt,2);{let p=o(Ne),E=o(()=>e(j)!=="");at(ga,{act:"detect",get label(){return e(p)},protected:!0,get disabled(){return e(E)},onrun:()=>{C("detect",Le)}})}var sa=a(Jt,2);{var da=p=>{var E=hi(),ye=K(E),ue=r(ye),et=a(ye,2);{var lt=dt=>{var Nt=mi();rt(Nt,21,()=>e(H).slice(0,A),Mt=>Mt.name,(Mt,Pt)=>{var ca=vr(),va=r(ca),za=r(va),Ia=a(va,2),Ua=r(Ia);v(()=>{i(za,e(Pt).name),i(Ua,`${(e(Pt).description===""?"aucune description USB":e(Pt).description)??""} - ${e(Pt).vid===""?"":` — VID ${e(Pt).vid} PID ${e(Pt).pid}`}`)}),Ze("click",va,()=>t.draft.set("scale.options.port",e(Pt).name)),l(Mt,ca)}),l(dt,Nt)};m(et,dt=>{e(H).length>0&&dt(lt)})}v(dt=>i(ue,dt),[()=>e(H).length===0?"Aucun port série n’est visible depuis ce poste.":nt("port détecté","ports détectés",e(H).length)]),l(p,E)};m(sa,p=>{e(me)&&p(da)})}var la=a(sa,2);{var ma=p=>{var E=bi(),ye=K(E),ue=r(ye),et=a(ye,2);rt(et,21,()=>e(_e).slice(0,A),lt=>lt.port,(lt,dt)=>{var Nt=_i();let Mt;var Pt=r(Nt),ca=r(Pt),va=a(Pt,2),za=r(va);v(()=>{Mt=pa(Nt,1,"svelte-1p4i0xm",null,Mt,{refused:e(dt).refused}),i(ca,e(dt).port),i(za,e(dt).message)}),l(lt,Nt)}),v(lt=>i(ue,lt),[()=>nt("port interrogé","ports interrogés",e(_e).length)]),l(p,E)};m(la,p=>{e(_e).length>0&&p(ma)})}var ia=a(la,2),oa=a(r(ia),2),ua=r(oa);{let p=o(()=>t.draft.text("scale.type")),E=o(()=>st("scale.type")),ye=o(()=>It("scale.type")),ue=o(()=>!e(S));St(ua,{label:"Protocole",path:"scale.type",get value(){return e(p)},hint:"Les valeurs acceptées apparaissent ici si l’enregistrement est refusé.",get fault(){return e(E)},get allowed(){return e(ye)},get disabled(){return e(ue)},onchange:et=>t.draft.set("scale.type",et)})}var ha=a(ua,2);{let p=o(()=>t.draft.text("scale.options.port")),E=o(()=>st("scale.options.port")),ye=o(()=>je("scale.options.port",e(H).map(et=>et.name))),ue=o(()=>!e(S));St(ha,{label:"Port série",path:"scale.options.port",get value(){return e(p)},hint:"Choisissez-le dans la liste détectée ci-dessus plutôt que de le taper : l’écoute permanente ne suit que des ports détectés.",get fault(){return e(E)},get allowed(){return e(ye)},get disabled(){return e(ue)},onchange:et=>t.draft.set("scale.options.port",et)})}var La=a(ia,2),xa=r(La),Zt=r(xa),g=a(xa,2);{var L=p=>{var E=wi(),ye=r(E),ue=a(ye);{var et=lt=>{{let dt=o(()=>e(j)==="listen"),Nt=o(()=>e(j)!=="");at(lt,{act:"listen",get label(){return e(I)},protected:!0,get busy(){return e(dt)},get disabled(){return e(Nt)},onrun:()=>{C("listen",q)}})}};m(ue,lt=>{e(I)!==""&<(et)})}v(()=>i(ye,`${e(T)??""} `)),l(p,E)};m(g,p=>{(e(T)!==""||e(I)!=="")&&p(L)})}var B=a(g,2);{var ge=p=>{var E=xi();rt(E,21,()=>e(W),Oa,(ye,ue)=>{var et=yi(),lt=r(et),dt=r(lt),Nt=a(lt,2),Mt=r(Nt);v((Pt,ca)=>{i(dt,Pt),i(Mt,ca)},[()=>it(e(ue)),()=>ut(e(ue))]),l(ye,et)}),l(p,E)};m(B,p=>{e(W).length>0&&p(ge)})}var Oe=a(Ft,2),Ve=r(Oe),We=a(r(Ve),2),G=a(r(We),1,!0),te=a(Ve,2),De=r(te),Je=a(te,2),Ge=r(Je),Ye=a(Je,2),he=r(Ye);{let p=o(()=>e(j)==="printers"),E=o(()=>e(j)!=="");at(he,{label:"Lister les files",get busy(){return e(p)},get disabled(){return e(E)},onrun:()=>{C("printers",ie)}})}var Fe=a(he,2);{let p=o(()=>e(j)==="discover"),E=o(()=>e(j)!=="");at(Fe,{label:"Rechercher l’imprimante",protected:!0,get busy(){return e(p)},get disabled(){return e(E)},onrun:()=>{C("discover",Ee)}})}var Xe=a(Fe,2);rt(Xe,17,()=>e(gt),p=>p.what,(p,E)=>{{let ye=o(()=>`Auto-test : ${e(E).name}${e(j)===e(E).what?" — en cours…":""}`),ue=o(()=>e(j)!=="");at(p,{get act(){return e(E).what},get label(){return e(ye)},protected:!0,get disabled(){return e(ue)},onrun:()=>{C(e(E).what,()=>Ce(e(E).what))}})}});var xt=a(Ye,2);{var qt=p=>{var E=ki(),ye=r(E);v(()=>i(ye,e(gt).length===0?"Le driver d’impression en service n’imprime aucun auto-test.":"Les autres auto-tests ne sont pas proposés : le driver d’impression en service ne les imprime pas.")),l(p,E)};m(xt,p=>{e(gt).length{var E=Si(),ye=K(E),ue=r(ye),et=a(ye,2);rt(et,21,()=>e(be).slice(0,A),lt=>lt.name,(lt,dt)=>{var Nt=vr(),Mt=r(Nt),Pt=r(Mt),ca=a(Mt,2),va=r(ca);v(()=>{i(Pt,e(dt).name),i(va,`${e(dt).detail??""}${e(dt).default?" — file par défaut du système":""}`)}),Ze("click",Mt,()=>t.draft.set(e(J),e(dt).name)),l(lt,Nt)}),v(lt=>i(ue,lt),[()=>nt("destination","destinations",e(be).length)]),l(p,E)};m(ht,p=>{e(be).length>0&&p(wt)})}var kt=a(ht,2);{var Et=p=>{var E=qi(),ye=r(E);v(ue=>i(ye,ue),[()=>ct()]),l(p,E)};m(kt,p=>{e(Ie)>0&&p(Et)})}var At=a(kt,2),Qe=a(r(At),2),ot=r(Qe);{let p=o(()=>t.draft.text("printer.type")),E=o(()=>st("printer.type")),ye=o(()=>It("printer.type")),ue=o(()=>!e(S));St(ot,{label:"Driver",path:"printer.type",get value(){return e(p)},hint:"Gardez le driver raster : c’est celui que les postes en service utilisent.",get fault(){return e(E)},get allowed(){return e(ye)},get disabled(){return e(ue)},onchange:et=>t.draft.set("printer.type",et)})}var _t=a(ot,2);{let p=o(()=>st("printer.options.transport")),E=o(()=>It("printer.options.transport")),ye=o(()=>!e(S));St(_t,{label:"Transport",path:"printer.options.transport",get value(){return e(F)},hint:"Local par défaut : une file Windows ou un nœud d’impression de ce poste.",get fault(){return e(p)},get allowed(){return e(E)},get choices(){return e($)},get disabled(){return e(ye)},onchange:ue=>t.draft.set("printer.options.transport",ue)})}var jt=a(_t,2);{let p=o(()=>ta(e(J))),E=o(()=>t.draft.text(e(J))),ye=o(()=>ve[e(M)]??""),ue=o(()=>st(e(J))),et=o(()=>je(e(J),e(be).map(dt=>dt.name))),lt=o(()=>!e(S));St(jt,{get label(){return e(p)},get path(){return e(J)},get value(){return e(E)},get hint(){return e(ye)},get fault(){return e(ue)},get allowed(){return e(et)},get disabled(){return e(lt)},onchange:dt=>t.draft.set(e(J),dt)})}v(p=>{Ae(Ht,"data-level",e(R).level),i(na,e(R).word),i(Lt,e(R).detail),i(Zt,e(z)),Ae(We,"data-level",e(_).level),i(G,e(_).word),i(De,e(_).detail),i(Ge,p)},[()=>ze()]),nr("open","toggle",ia,p=>d(N,p),()=>e(N)),nr("open","toggle",At,p=>d(ne,p),()=>e(ne)),l(n,Ot),Dt()}Wt(["change","click"]);var zi=u(""),ji=u('L’export sera proposé quand le journal aura répondu.'),Ci=u('L’export n’est pas proposé : ce poste n’a pas répondu à la lecture du journal.'),Pi=u('
                      Exporter en CSV'),Ti=u('

                      '),Ri=u('

                      '),Oi=u(`

                      Aucune trame brute n’a été enregistrée pour cette pesée : il n’y a - rien à rejouer.

                      `),Ai=u(`

                      La trame repart dans le décodeur du poste EN SERVICE : le poids - affiché au client change, et rien ne le remet comme il était. C’est - ce qui fait d’un refus inexpliqué un test permanent, sans - déplacement au magasin et sans balance.

                      `,1),Ni=u('

                      Produit
                      Référence
                      Vente
                      Brut / tare / net
                      Stabilité
                      Origine du poids
                      Résultat
                      Trame brute
                      '),$i=u(' ',1),Di=u('

                      QuandProduitNetCode-barresRésultatDuréeDétail
                      ',1),Ii=u('
                      ',1),Ui=u('

                      '),Fi=u(' '),Mi=u(' '),Bi=u('
                    • '),Wi=u('

                        ',1),Vi=u('
                        ');function Gi(n,t){$t(t,!0);const s=pt(t,"admin",7),c=200,w=5e3,k=50,O=500,U=7,A={sent:"envoyée à l’imprimante",rejected:"refusée",failed:"en échec",reprint:"réimpression"},H=[{value:"",label:"toutes"},{value:"sent",label:"envoyées à l’imprimante"},{value:"rejected",label:"refusées"},{value:"failed",label:"en échec"},{value:"reprint",label:"réimpressions"}],Q={scale:"balance",manual:"saisie manuelle",replay:"trame rejouée"},_e={stable:"stable",unstable:"instable",unknown:"non déclarée par la balance",not_applicable:"sans objet — saisie manuelle"},ce={by_weight:"au poids",by_unit:"à l’unité"},qe={debug:"mise au point",info:"information",warn:"avertissement",error:"erreur",critical:"critique"};let me=P(Tt([])),j=P(Tt([])),xe=P(""),Z=P(null),de=P(!1),re=P(!1),N=P("loading"),ne=P("loading"),ke=P(""),Y=P("");const ee=o(()=>({limit:String(c),...e(xe)===""?{}:{result:e(xe)}})),S=o(()=>({...e(ee),limit:String(w)})),h=o(()=>e(me).find(M=>M.id===e(Z))??null),x=o(()=>H.find(M=>M.value===e(xe))?.label??""),T=o(()=>ve(e(me).length,c,"pesée","pesées",`L’export CSV en emporte jusqu’à ${ae(w)}.`)),W=o(()=>ve(e(j).length,k,"ligne","lignes",`Le fichier de diagnostic emporte les ${ae(O)} dernières.`));se();async function se(){d(Z,null),d(re,!0),d(N,"loading"),d(ne,"loading"),d(me,[],!0),d(j,[],!0),d(ke,""),d(Y,"");try{const M=await s().load(()=>Cn(e(ee)));d(N,M===null?"unread":"read",!0),d(ke,M===null?s().actionError:"",!0),d(me,M??[],!0);const J=await s().load(()=>Tn({limit:String(k)}));d(ne,J===null?"unread":"read",!0),d(Y,J===null?s().actionError:"",!0),d(j,J??[],!0)}finally{d(re,!1),s().actionError=""}}async function le(M){s().notice="",s().actionError="",d(de,!0);try{const J=await s().protect(()=>Nn(M));J!==null&&(s().notice=J.message)}finally{d(de,!1)}}function ve(M,J,be,Ie,R){const _=M>1?Ie:be;return M{var be=Ii(),Ie=K(be),R=a(r(Ie),2);rt(R,21,()=>H,X=>X.value,(X,b)=>{var C=zi(),D=r(C),ie={};v(()=>{i(D,e(b).label),ie!==(ie=e(b).value)&&(C.value=(C.__value=e(b).value)??"")}),l(X,C)});var _=a(R,2);at(_,{label:"Rafraîchir",get busy(){return e(re)},onrun:()=>{se()}});var z=a(_,2);{var I=X=>{var b=ji();l(X,b)},fe=X=>{var b=Ci();l(X,b)},Me=X=>{var b=Pi();v(C=>Ae(b,"href",C),[()=>Pn(e(S))]),l(X,b)};m(z,X=>{e(N)==="loading"?X(I):e(N)==="unread"?X(fe,1):X(Me,-1)})}var Be=a(Ie,2);{var Se=X=>{var b=Ti(),C=r(b);v(D=>i(C,`L’export emporte le même filtre que le tableau, mais pas son plafond : il descend - jusqu’à ${D??""} pesées, en point-virgule et en UTF-8 — il s’ouvre - tel quel dans le tableur d’un Windows français. Il ne demande aucun mot de passe : la - lecture du journal n’en demande pas non plus, et le fichier de diagnostic emporte déjà - les deux cents dernières pesées.`),[()=>ae(w)]),l(X,b)};m(Be,X=>{e(N)==="read"&&X(Se)})}var f=a(Be,2);{var q=X=>{var b=Ri(),C=r(b);{var D=Le=>{var oe=zt("Lecture du journal…");l(Le,oe)},ie=Le=>{var oe=zt();v(()=>i(oe,`Le journal n’a pas pu être lu : ${e(ke)??""} Ce n’est pas « aucune pesée ».`)),l(Le,oe)},Ee=Le=>{var oe=zt("Le journal ne contient aucune pesée.");l(Le,oe)},Ce=Le=>{var oe=zt();v(()=>i(oe,`Aucune pesée ne correspond au filtre « ${e(x)??""} ».`)),l(Le,oe)};m(C,Le=>{e(N)==="loading"?Le(D):e(N)==="unread"?Le(ie,1):e(xe)===""?Le(Ee,2):Le(Ce,-1)})}l(X,b)},V=X=>{var b=Di(),C=K(b),D=r(C),ie=a(C,2),Ee=r(ie),Ce=a(r(Ee));rt(Ce,21,()=>e(me),Le=>Le.id,(Le,oe)=>{var Ne=$i(),Pe=K(Ne);let $e;var Ue=r(Pe),He=r(Ue),ze=a(Ue),Te=r(ze),we=a(ze),Ke=r(we),Re=a(we),nt=r(Re),ft=a(Re),ct=r(ft),je=a(ft),it=r(je),Ct=a(je),ut=r(Ct),st=r(ut),It=a(Pe,2);{var Ut=Vt=>{var Rt=Ni(),gt=r(Rt);Ae(gt,"colspan",U);var Ot=r(gt),Ft=r(Ot),Gt=a(Ot,2),Ht=a(r(Gt),2),na=r(Ht),Xt=a(Ht,4),Lt=r(Xt),bt=a(Xt,4),mt=r(bt),yt=a(bt,4),Jt=r(yt),Qt=a(yt,4),ga=r(Qt),sa=a(Qt,4),da=r(sa),la=a(sa,4),ma=r(la),ia=a(la,4),oa=r(ia),ua=r(oa),ha=a(Gt,2);{var La=Zt=>{var g=Oi();l(Zt,g)},xa=Zt=>{var g=Ai(),L=K(g),B=r(L);at(B,{kind:"destructive",label:"Rejouer cette trame",protected:!0,get busy(){return e(de)},onrun:()=>{le(e(h).frame)}}),l(Zt,g)};m(ha,Zt=>{e(h).frame===""?Zt(La):Zt(xa,-1)})}v((Zt,g,L,B,ge,Oe,Ve,We,G)=>{Ae(Rt,"data-detail",e(oe).id),i(Ft,`Pesée ${e(h).id??""}`),i(na,`${e(h).product_name??""} (${e(h).product_id??""})`),i(Lt,e(h).reference),i(mt,`${Zt??""} — - ${g??""} - ${e(h).quantity>1?"unités":"unité"}`),i(Jt,`${L??""} g / ${B??""} g / - ${ge??""} g`),i(ga,`${Oe??""} — cadence - médiane ${Ve??""}`),i(da,We),i(ma,`${G??""}${e(h).detail===""?"":` — ${e(h).detail}`}`),i(ua,e(h).frame===""?"aucune trame enregistrée":e(h).frame)},[()=>pe(ce,e(h).mode,"mode de vente inconnu"),()=>ae(e(h).quantity),()=>ae(e(h).gross_g),()=>ae(e(h).tare_g),()=>ae(e(h).net_g),()=>pe(_e,e(h).stability,"stabilité inconnue"),()=>Ra(e(h).rate_ms),()=>pe(Q,e(h).source,"origine inconnue"),()=>pe(A,e(h).result,"résultat inconnu")]),l(Vt,Rt)};m(It,Vt=>{e(Z)===e(oe).id&&e(h)!==null&&Vt(Ut)})}v((Vt,Rt,gt,Ot)=>{$e=pa(Pe,1,"svelte-86o8oz",null,$e,{open:e(Z)===e(oe).id}),i(He,Vt),i(Te,e(oe).product_name),i(Ke,`${Rt??""} g`),i(nt,e(oe).barcode),i(ct,gt),i(it,Ot),Ae(ut,"aria-expanded",e(Z)===e(oe).id),i(st,e(Z)===e(oe).id?"fermer":"détail")},[()=>ra(e(oe).occurred_at),()=>ae(e(oe).net_g),()=>pe(A,e(oe).result,"résultat inconnu"),()=>Ra(e(oe).duration_ms)]),Ze("click",ut,()=>d(Z,e(Z)===e(oe).id?null:e(oe).id,!0)),l(Le,Ne)}),v(()=>i(D,e(T))),l(X,b)};m(f,X=>{e(me).length===0?X(q):X(V,-1)})}Ze("change",R,()=>{se()}),en(R,()=>e(xe),X=>d(xe,X)),l(M,be)},$$slots:{default:!0}});var $=a(y,2);tt($,{title:"Journal technique",children:(M,J)=>{var be=ea(),Ie=K(be);{var R=z=>{var I=Ui(),fe=r(I);{var Me=f=>{var q=zt("Lecture du journal technique…");l(f,q)},Be=f=>{var q=zt();v(()=>i(q,`Le journal technique n’a pas pu être lu : ${e(Y)??""} Ce n’est pas « aucune - ligne ».`)),l(f,q)},Se=f=>{var q=zt("Aucune ligne technique.");l(f,q)};m(fe,f=>{e(ne)==="loading"?f(Me):e(ne)==="unread"?f(Be,1):f(Se,-1)})}l(z,I)},_=z=>{var I=Wi(),fe=K(I),Me=r(fe),Be=a(fe,2),Se=r(Be);rt(Se,21,()=>e(j),f=>f.id,(f,q)=>{var V=Bi(),X=r(V),b=r(X),C=a(X,2),D=r(C),ie=a(C,2),Ee=r(ie),Ce=a(ie,2);{var Le=Ue=>{var He=Fi(),ze=r(He);v(()=>i(ze,e(q).code)),l(Ue,He)};m(Ce,Ue=>{e(q).code!==""&&aa.showTechnicalNames&&Ue(Le)})}var oe=a(Ce,2),Ne=r(oe),Pe=a(oe,2);{var $e=Ue=>{var He=Mi(),ze=r(He);v(()=>i(ze,e(q).detail)),l(Ue,He)};m(Pe,Ue=>{e(q).detail!==""&&Ue($e)})}v((Ue,He,ze)=>{Ae(V,"data-level",e(q).level),i(b,Ue),i(D,He),i(Ee,ze),i(Ne,e(q).message)},[()=>ra(e(q).occurred_at),()=>pe(qe,e(q).level,"niveau inconnu"),()=>qr(e(q).source)]),l(f,V)}),v(()=>i(Me,e(W))),l(z,I)};m(Ie,z=>{e(j).length===0?z(R):z(_,-1)})}l(M,be)},$$slots:{default:!0}}),l(n,F),Dt()}Wt(["change","click"]);var Hi=u('

                        '),Ji=u('

                        '),Ki=u('

                        '),Yi=u('

                        '),Xi=u('

                        Lecture de la configuration en cours… les flèches attendent qu’elle soit arrivée.

                        '),Qi=u(`
                        Aperçu de l’étiquette telle qu’elle serait imprimée

                        Décalage horizontal

                        Décalage vertical

                        Le décalage ne descend pas sous zéro : le poste refuse un décalage négatif quel que - soit le gabarit. Le maximum, lui, dépend de la géométrie du gabarit ; il est annoncé - ici si l’enregistrement le dépasse.

                        Le symbole code-barres est volontairement tronqué : un symbole conforme n’entre pas - sur 40 × 25 mm avec les cinq champs texte. Ce n’est pas un défaut de rendu et il n’y - a rien à corriger.

                        Le détail chiffré du symbole — largeur de module, modules rendus, modules attendus — - n’est pas servi par ce poste. Cet écran n’affiche pas un chiffre qu’il aurait deviné.

                        `,1),Zi=u(`

                        Chaque appui sort une étiquette pour de bon : le mot de passe est demandé au moment - d’imprimer, et l’impression repart d’elle-même une fois la session ouverte.

                        `,1),eo=u('
                        ');function to(n,t){$t(t,!0);const s=pt(t,"admin",7),c="printer.template",w="printer.options.offset_x",k="printer.options.offset_y",O=0,U="L’aperçu n’a pas pu être rendu par le poste.",A="Le poste a rendu l’aperçu, mais le navigateur ne l’a pas affiché.",H=[{what:"alignment",label:"Imprimer la mire d’alignement"},{what:"ruler",label:"Imprimer la réglette"}];let Q=P(1),_e=P(!0),ce=P(!1),qe=P(""),me=P(null),j=P(""),xe=null,Z=!1;const de=o(()=>t.draft.text(c)),re=o(()=>jn(e(de),e(_e),e(ce),e(Q))),N=o(()=>t.draft.config!==null),ne=o(()=>t.draft.number(w)),ke=o(()=>t.draft.number(k)),Y=o(()=>$(w)),ee=o(()=>$(k)),S=o(pe),h=o(()=>s().busy||e(j)!=="");Kt(()=>{if(t.draft.config===null)return;if(t.draft.dirty){xe===null&&x();return}const R={x:t.draft.number(w),y:t.draft.number(k)};if(xe!==null&&xe.x===R.x&&xe.y===R.y)return;const _=xe===null;xe=R,d(me,R,!0),_||se()});async function x(){if(!Z){Z=!0;try{const R=await Qa(),_={x:T(R.config,w),y:T(R.config,k)};xe=_,d(me,_,!0)}catch{}finally{Z=!1}}}function T(R,_){let z=R;for(const I of _.split(".")){if(z===null||typeof z!="object")return 0;z=z[I]}return typeof z=="number"?z:0}function W(R,_){const z=t.draft.number(R)+_;z=2?"s":""}`}function y(R,_){const z=Number(_);_.trim()===""||Number.isNaN(z)||t.draft.set(R,z)}function $(R){return t.draft.faults.find(_=>_.field===R)?.message??""}async function M(R){if(!e(h)){d(j,R,!0),s().actionError="",s().notice="";try{const _=await s().protect(()=>kr(R));if(_===null)return;s().notice=_.message,await s().refresh()}finally{d(j,"")}}}var J=eo(),be=r(J);tt(be,{title:"Aperçu de l’étiquette",note:"Le même moteur que l’impression (A2) : le décalage se voit parce qu’il est cuit dans le bitmap. L’image porte le gabarit en cours d’édition, mais le décalage ENREGISTRÉ — jamais celui que les flèches sont en train de régler.",children:(R,_)=>{var z=Qi(),I=K(z);{var fe=je=>{var it=Hi(),Ct=r(it);v(()=>i(Ct,e(S))),l(je,it)};m(I,je=>{e(S)!==""&&je(fe)})}var Me=a(I,2),Be=r(Me),Se=r(Be),f=a(Se,2);{var q=je=>{var it=Ji(),Ct=r(it);v(()=>i(Ct,e(qe))),l(je,it)};m(f,je=>{e(qe)!==""&&je(q)})}var V=a(Be,2),X=r(V),b=a(r(X),2),C=r(b),D=a(X,2),ie=r(D);{let je=o(()=>!e(N)||e(ne)<=O);at(ie,{label:"← 1 dot",get disabled(){return e(je)},onrun:()=>W(w,-1)})}var Ee=a(ie,2);{let je=o(()=>!e(N));at(Ee,{label:"1 dot →",get disabled(){return e(je)},onrun:()=>W(w,1)})}var Ce=a(D,2);{var Le=je=>{var it=Ki(),Ct=r(it);v(()=>i(Ct,e(Y))),l(je,it)};m(Ce,je=>{e(Y)!==""&&je(Le)})}var oe=a(Ce,2),Ne=a(r(oe),2),Pe=r(Ne),$e=a(oe,2),Ue=r($e);{let je=o(()=>!e(N)||e(ke)<=O);at(Ue,{label:"↑ 1 dot",get disabled(){return e(je)},onrun:()=>W(k,-1)})}var He=a(Ue,2);{let je=o(()=>!e(N));at(He,{label:"1 dot ↓",get disabled(){return e(je)},onrun:()=>W(k,1)})}var ze=a($e,2);{var Te=je=>{var it=Yi(),Ct=r(it);v(()=>i(Ct,e(ee))),l(je,it)};m(ze,je=>{e(ee)!==""&&je(Te)})}var we=a(ze,4),Ke=r(we),Re=a(we,2),nt=r(Re),ft=a(Re,2);{var ct=je=>{var it=Xi();l(je,it)};m(ft,je=>{e(N)||je(ct)})}v((je,it)=>{Ae(Se,"src",e(re)),i(C,je),i(Pe,it)},[()=>F(e(ne)),()=>F(e(ke))]),ya("load",Se,()=>d(qe,"")),ya("error",Se,()=>{le(e(re))}),Ze("change",Ke,()=>se()),rr(Ke,()=>e(_e),je=>d(_e,je)),Ze("change",nt,()=>se()),rr(nt,()=>e(ce),je=>d(ce,je)),l(R,z)},$$slots:{default:!0}});var Ie=a(be,2);tt(Ie,{title:"Gabarit et impression",children:(R,_)=>{var z=Zi(),I=K(z);{let f=o(()=>$(c)),q=o(()=>!e(N));St(I,{label:"Gabarit",path:c,get value(){return e(de)},hint:"Le gabarit reproduit à l’identique s’appelle weighing_identical (A1).",get fault(){return e(f)},get disabled(){return e(q)},onchange:V=>{t.draft.set(c,V),se()}})}var fe=a(I,2);{let f=o(()=>t.draft.text("printer.options.darkness")),q=o(()=>$("printer.options.darkness")),V=o(()=>!e(N));St(fe,{label:"Noircissement",path:"printer.options.darkness",kind:"number",get value(){return e(f)},hint:"Trop bas, l’étiquette pâlit au soleil ; trop haut, elle bave et le scanner refuse.",get fault(){return e(q)},get disabled(){return e(V)},onchange:X=>y("printer.options.darkness",X)})}var Me=a(fe,2);{let f=o(()=>t.draft.text("printer.options.speed")),q=o(()=>$("printer.options.speed")),V=o(()=>!e(N));St(Me,{label:"Vitesse",path:"printer.options.speed",kind:"number",get value(){return e(f)},get fault(){return e(q)},get disabled(){return e(V)},onchange:X=>y("printer.options.speed",X)})}var Be=a(Me,2);{let f=o(()=>t.draft.text("printer.options.copies")),q=o(()=>$("printer.options.copies")),V=o(()=>!e(N));St(Be,{label:"Exemplaires",path:"printer.options.copies",kind:"number",get value(){return e(f)},hint:"Un client repart avec une étiquette : deux exemplaires se justifient, pas se devinent.",get fault(){return e(q)},get disabled(){return e(V)},onchange:X=>y("printer.options.copies",X)})}var Se=a(Be,2);rt(Se,21,()=>H,f=>f.what,(f,q)=>{{let V=o(()=>e(j)===e(q).what);at(f,{get act(){return e(q).what},get label(){return e(q).label},protected:!0,get busy(){return e(V)},get disabled(){return e(h)},onrun:()=>{M(e(q).what)}})}}),l(R,z)},$$slots:{default:!0}}),l(n,J),Dt()}Wt(["change"]);var ao=u(" "),ro=u(' '),no=u(''),so=u('

                        Aucun tarif déclaré dans la configuration lue.

                        '),lo=u('Prix du catalogue Odoo — pas de remise'),pr=u(' '),io=u(' % ',1),oo=u(' '),uo=u(`

                        CodeLibelléAbrégéRemiseOrdre

                        Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la - retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour - tous les adhérents.

                        `,1),co=u(" ",1),vo=u(`

                        Les deux arrondis sont distincts parce qu’ils tombent à des endroits différents du - calcul : arrondir le prix au kilo puis le multiplier, ou multiplier puis arrondir, ne - donne pas le même centime sur une étiquette. Le poste applique l’arrondi commercial, - et l’écart se voit au centime près sur la même pesée.

                        `,1),po=u('

                        Rien ne s’affiche au client : c’est une information.

                        '),fo=u('

                        '),go=u('
                        '),mo=u('

                        '),ho=u('
                      • '),_o=u(`

                        Aucun verdict en ce moment : ces lignes portent sur la pesée EN COURS, et le poste - est au repos. Elles apparaissent le temps d’un cycle, quand un client pose son sac - et touche une tuile.

                        `),bo=u(' '),wo=u(' ',1),yo=u('
                      • '),xo=u('

                          ',1),ko=u(`

                          L’ordre compte : le premier verdict bloquant décide de ce que le client lit. Les - garde-fous 1 à 7 portent sur l’état de la balance, c’est-à-dire le poids brut ; les - garde-fous 8 à 14 portent sur la vente, c’est-à-dire le net. Le code en gris est - celui du journal, celui qu’on lit au téléphone.

                            Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve - dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur.

                            Ce que les garde-fous disent de la pesée en cours

                            `,1),So=u(`

                            Le plan de numérotation n’est PAS ici, et c’est tout l’intérêt : préfixes, largeur de - la référence, largeur de la charge utile, décimales et mode de vente sont une - constante du binaire, indexée par préfixe et vérifiée au démarrage. - Un champ qui change le SENS du code lu par la caisse n’est pas un réglage, c’est un - contrat externe : il change avec une version du binaire, relue et testée, jamais - depuis l’écran d’un poste.

                            `,1),qo=u('

                            Aucune dérogation : la limite générale s’applique à tous les produits.

                            '),Lo=u(`

                            Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas - répondu. Les identifiants Odoo restent affichés.

                            `),Eo=u(`Produit retiré : le garde-fou 14 refuse le produit avant que cette - dérogation ait un sens.`),zo=u('
                          1. '),jo=u('

                              ',1),Co=u('
                              ');function Po(n,t){$t(t,!0);const s=(y,$=Ea,M=Ea,J=Ea)=>{var be=no(),Ie=r(be),R=a(Ie,2),_=r(R),z=r(_),I=a(_,2);{var fe=Se=>{var f=ao(),q=r(f);v(()=>i(q,$())),l(Se,f)};m(I,Se=>{aa.showTechnicalNames&&Se(fe)})}var Me=a(I,2);{var Be=Se=>{var f=ro(),q=r(f);v(()=>i(q,J())),l(Se,f)};m(Me,Se=>{J()!==""&&Se(Be)})}v((Se,f)=>{Ae(be,"data-flag",$()),Ae(be,"data-on",Se),wa(Ie,f),i(z,M())},[()=>String(t.draft.flag($())),()=>t.draft.flag($())]),Ze("change",Ie,Se=>t.draft.set($(),Se.currentTarget.checked)),l(y,be)},c=20,w=o(()=>_e(t.draft)),k=o(()=>t.health.state.diagnostics),O=o(()=>t.health.decisions.filter(y=>y.min_weight_g!==null)),U=o(()=>e(O).filter(y=>!y.offered)),A=o(()=>e(O).slice(0,c));let H=P(Tt({})),Q=P("loading");re();function _e(y){const $=y.value("pricing.tiers");return Array.isArray($)?$.map(M=>{const J=M??{},be=J.discount_percent;return{code:String(J.code??""),label:String(J.label??""),abbrev:String(J.abbrev??""),written:be===void 0?null:String(be),discount:ce(be)?be:null,rank:Number(J.rank??0)}}):[]}function ce(y){return typeof y!="number"||!Number.isFinite(y)||y<0||y>100?!1:Math.abs(y*10-Math.round(y*10))<1e-9}const qe=o(()=>String(t.draft.value("pricing.reference_code")??""));function me(y){return y===null?"":String(y).replace(".",",")}function j(y){const $=1e3-Math.round(y*10);return`${String(Math.trunc($/100))},${String($%100).padStart(2,"0")}`}function xe(y,$){const M=Number($);$.trim()===""||Number.isNaN(M)||t.draft.set(y,M)}function Z(y,$){const M=$.trim().replace(",",".");if(!/^\d{1,3}(\.\d)?$/u.test(M))return;const J=Number(M);J>100||t.draft.set(y,J)}function de(y,$){!(y instanceof HTMLInputElement)||y.value===$||(y.value=$)}async function re(){try{const y=await br();d(H,Object.fromEntries(y.products.map($=>[$.id,$.name])),!0),d(Q,"read")}catch{d(Q,"unread")}}function N(y){const $=e(H)[y];return $!==void 0?$:e(Q)==="loading"?"Lecture du nom…":e(Q)==="read"?"Produit absent du catalogue en service":"Nom non lu"}function ne(y){return y<=1?`${String(y)} tarif déclaré`:`${String(y)} tarifs déclarés`}const ke=o(()=>`${ae(e(k).length)} ${e(k).length>1?"verdicts":"verdict"} sur les quatorze garde-fous.`),Y=o(()=>`${ae(e(A).length)} ${e(A).length>1?"lignes affichées":"ligne affichée"} sur ${ae(e(O).length)} `+(e(U).length===0?`${e(O).length>1?"dérogations en vigueur":"dérogation en vigueur"}.`:`${e(O).length>1?"dérogations enregistrées":"dérogation enregistrée"}, dont ${ae(e(U).length)} sur un produit retiré, sans effet : le garde-fou 14 refuse le produit avant que le 8 ait un sens.`)+(e(O).length>e(A).length?" Les autres se lisent produit par produit depuis l’onglet Catalogue.":""));function ee(y){return`plancher ${ae(y)} g : refusé à ${ae(y)} g et en dessous`}const S=[{rank:1,code:"OVERLOAD",label:"Surcharge",when:"La balance annonce elle-même OL, ou le poids brut dépasse la capacité.",severity:"Bloquant",blocking:!0,message:"La balance est en surcharge. Retirez votre article.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la capacité, réglée au garde-fou 9 sous « ${ta("limits.max_weight_g")} ».`},{rank:2,code:"MEASUREMENT_EXPIRED",label:"Poids périmé",when:"La mesure est plus vieille que la péremption, dans les deux modes de stabilité.",severity:"Bloquant",blocking:!0,message:"Poids indisponible. Patientez ou appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil à régler : le poste calcule lui-même à partir du rythme de la balance."},{rank:3,code:"BASKET_MISSING",label:"Panier absent",when:"Le poids brut tombe dans la fenêtre négative du panier : il a été soulevé.",severity:"Bloquant",blocking:!0,message:"Le panier n'est pas sur la balance. Reposez-le.",thresholds:[{path:"limits.basket_min_g",label:"Bas de la fenêtre du panier",hint:"En grammes, NÉGATIF : c’est le poids du panier que la balance a perdu."},{path:"limits.basket_max_g",label:"Haut de la fenêtre du panier",hint:"Négatif lui aussi, et plus proche de zéro que le bas."}],switchPath:"limits.basket_check_enabled",switchLabel:"Ce poste travaille avec un panier taré",note:"La règle s’active ou non, en bloc : il n’y a pas de demi-mesure à régler."},{rank:4,code:"SCALE_EMPTY",label:"Plateau vide",when:"Le poids brut ne sort pas de la bande « il n’y a rien sur le plateau ».",severity:"Bloquant — un filet, hors parcours nominal",blocking:!0,message:"Posez votre produit.",thresholds:[{path:"limits.empty_max_g",label:"Plateau considéré vide",hint:"En dessous, le poste considère qu’il n’y a rien sur le plateau."}],switchPath:"",switchLabel:"",note:"Toucher une tuile sur un plateau vide ARME la sélection au lieu d’être refusé : la règle reste évaluée pour la saisie manuelle et les chemins dérivés."},{rank:5,code:"TARE_REQUIRED",label:"Remise à zéro nécessaire",when:"Le brut est sous la bande du plateau vide, et hors de la fenêtre du panier.",severity:"Bloquant",blocking:!0,message:"La balance doit être remise à zéro.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la valeur négative de celui du garde-fou 4, « ${ta("limits.empty_max_g")} ».`},{rank:6,code:"WEIGHT_UNSTABLE",label:"Pesée instable",when:"La trame déclare la mesure instable.",severity:"Information par défaut (A3)",blocking:!1,message:"Pesée en cours…",thresholds:[],switchPath:"",switchLabel:"",note:"La sévérité suit l’exigence de stabilité : elle passe à Bloquant quand celle-ci est réglée sur « blocking ». L’impression n’est jamais bloquée par défaut."},{rank:7,code:"TARE_INVALID",label:"Emballage incohérent",when:"Une tare a été saisie, et elle atteint la pesée ou dépasse le maximum.",severity:"Bloquant",blocking:!0,message:"Le poids de l'emballage est supérieur ou égal à la pesée.",thresholds:[{path:"limits.max_tare_g",label:"Tare maximum",hint:"Une tare plus lourde que le maximum est une faute de frappe."}],switchPath:"",switchLabel:"",note:""},{rank:8,code:"WEIGHT_TOO_LOW",label:"Poids trop faible",when:"Vente au poids : le NET est strictement positif et ne dépasse pas le plancher.",severity:"Bloquant",blocking:!0,message:"La balance doit être retarée, ou l'emballage est trop lourd.",thresholds:[{path:"limits.min_weight_g",label:"Poids minimum",hint:"Une dérogation par produit existe, dans l’onglet Catalogue."}],switchPath:"",switchLabel:"",note:""},{rank:9,code:"WEIGHT_TOO_HIGH",label:"Poids trop élevé",when:"Le NET dépasse la capacité — strictement, pour que la capacité reste atteignable.",severity:"Bloquant",blocking:!0,message:"{{.Weight}} kg, ça paraît un peu lourd !",thresholds:[{path:"limits.max_weight_g",label:"Poids maximum",hint:"C’est la capacité du champ NNDDD du code-barres, pas un seuil de vraisemblance."}],switchPath:"",switchLabel:"",note:""},{rank:10,code:"UNITS_OUT_OF_RANGE",label:"Nombre d’unités hors plage",when:"Vente à l’unité : la quantité sort de la plage.",severity:"Bloquant",blocking:!0,message:"{{.Quantity}} unités, ça paraît un peu beaucoup !",thresholds:[{path:"limits.min_units",label:"Unités minimum",hint:""},{path:"limits.max_units",label:"Unités maximum",hint:""}],switchPath:"",switchLabel:"",note:""},{rank:11,code:"AMOUNT_OUT_OF_CAPACITY",label:"Montant hors capacité du code-barres",when:"La charge utile encode un PRIX, et il dépasse ce que le champ peut porter.",severity:"Bloquant",blocking:!0,message:"Prix trop élevé pour le code-barres.",thresholds:[{path:"limits.max_amount_cents",label:"Montant maximum",hint:"En centimes. Aucun préfixe du plan livré n’encode un prix : la règle est éprouvée sans qu’aucun produit puisse l’atteindre."}],switchPath:"",switchLabel:"",note:""},{rank:12,code:"ZERO_PRICE",label:"Prix nul",when:"Le montant du tarif imprimé en grand vaut zéro.",severity:"Bloquant",blocking:!0,message:"Prix nul. Appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : un produit à 0 € est une anomalie sans nuance."},{rank:13,code:"LIGHT_PRODUCT_ALLOWED",label:"Produit léger autorisé",when:"Le garde-fou 8 n’a pas déclenché grâce à la dérogation du produit.",severity:"Information",blocking:!1,message:"",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil général : c’est la dérogation par produit, listée plus bas et posée depuis l’onglet Catalogue. Rien ne s’affiche au client ; l’id du produit est journalisé."},{rank:14,code:"PRODUCT_WITHDRAWN",label:"Produit retiré",when:"Quelqu’un a décidé de ne plus proposer ce produit.",severity:"Bloquant",blocking:!0,message:"Ce produit n'est pas disponible.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : c’est une décision humaine, prise depuis l’onglet Catalogue. Aucune règle d’import ne peut la déduire."}];function h(y){return S.find($=>$.code===y)?.label??"Garde-fou inconnu de cet écran"}const x=["{{.Weight}}","{{.Quantity}}"];function T(y){return y==="blocking"?"Bloquant":y==="info"?"Information":"Sévérité inconnue de cet écran"}var W=Co(),se=r(W);tt(se,{title:"Grille de tarifs",note:"Un second tarif n’est pas une case à cocher : c’est une ligne de plus dans cette grille.",children:(y,$)=>{var M=co(),J=K(M);{var be=z=>{var I=so();l(z,I)},Ie=z=>{var I=uo(),fe=K(I),Me=r(fe),Be=a(fe,2),Se=r(Be),f=a(r(Se));rt(f,21,()=>e(w),Oa,(q,V,X)=>{var b=oo(),C=r(b),D=r(C),ie=a(C),Ee=r(ie);Ae(Ee,"aria-label",`Libellé du tarif ${X+1}`);var Ce=a(ie),Le=r(Ce);Ae(Le,"aria-label",`Abrégé du tarif ${X+1}`);var oe=a(Ce),Ne=r(oe);{var Pe=we=>{var Ke=lo();l(we,Ke)},$e=we=>{var Ke=pr(),Re=r(Ke);v(()=>i(Re,`${e(V).written??""} — le tarif de référence est le prix du catalogue : il - ne peut pas porter de remise, et l’enregistrement la refusera.`)),l(we,Ke)},Ue=we=>{var Ke=pr(),Re=r(Ke);v(()=>i(Re,`${e(V).written??""} — une remise s’écrit au dixième de point ; celle-ci se - change dans le fichier de configuration.`)),l(we,Ke)},He=we=>{var Ke=io(),Re=K(Ke);Ae(Re,"aria-label",`Remise du tarif ${X+1}`);var nt=a(Re,2),ft=r(nt);v((ct,je)=>{Sa(Re,ct),i(ft,`un produit à 10,00 €/kg s’affiche ${je??""} €/kg`)},[()=>me(e(V).discount??0),()=>j(e(V).discount??0)]),Ze("input",Re,ct=>Z(`pricing.tiers.${String(X)}.discount_percent`,ct.currentTarget.value)),Ze("focusout",Re,ct=>de(ct.currentTarget,me(e(V).discount??0))),l(we,Ke)};m(Ne,we=>{e(V).code===e(qe)&&e(V).written===null?we(Pe):e(V).code===e(qe)?we($e,1):e(V).written!==null&&e(V).discount===null?we(Ue,2):we(He,-1)})}var ze=a(oe),Te=r(ze);v(()=>{i(D,e(V).code),Sa(Ee,e(V).label),Sa(Le,e(V).abbrev),i(Te,e(V).rank)}),Ze("input",Ee,we=>t.draft.set(`pricing.tiers.${String(X)}.label`,we.currentTarget.value)),Ze("input",Le,we=>t.draft.set(`pricing.tiers.${String(X)}.abbrev`,we.currentTarget.value)),l(q,b)}),v(q=>i(Me,`${q??""}.`),[()=>ne(e(w).length)]),l(z,I)};m(J,z=>{e(w).length===0?z(be):z(Ie,-1)})}var R=a(J,2);{let z=o(()=>t.draft.text("pricing.primary_code"));St(R,{label:"Tarif imprimé en grand",path:"pricing.primary_code",get value(){return e(z)},hint:"Le prix que le client lit sur l’étiquette (A7).",onchange:I=>t.draft.set("pricing.primary_code",I)})}var _=a(R,2);{let z=o(()=>t.draft.text("pricing.reference_code"));St(_,{label:"Tarif qui serait encodé si le code-barres portait un prix",path:"pricing.reference_code",get value(){return e(z)},hint:"Le plan livré ne porte pas de prix, mais un poids ou un nombre d’unités : la caisse retrouve le prix par la référence, dans Odoo, et ne doit jamais sous-facturer.",onchange:I=>t.draft.set("pricing.reference_code",I)})}l(y,M)},$$slots:{default:!0}});var le=a(se,2);tt(le,{title:"Les deux arrondis",children:(y,$)=>{var M=vo(),J=K(M);{let Ie=o(()=>t.draft.text("pricing.unit_price_rounding"));St(J,{label:"Arrondi du prix unitaire dérivé",path:"pricing.unit_price_rounding",get value(){return e(Ie)},hint:"Il s’applique au prix au kilo calculé par la remise.",onchange:R=>t.draft.set("pricing.unit_price_rounding",R)})}var be=a(J,2);{let Ie=o(()=>t.draft.text("pricing.amount_rounding"));St(be,{label:"Arrondi du montant",path:"pricing.amount_rounding",get value(){return e(Ie)},hint:"Il s’applique au montant de l’étiquette.",onchange:R=>t.draft.set("pricing.amount_rounding",R)})}l(y,M)},$$slots:{default:!0}});var ve=a(le,2);tt(ve,{title:"Les quatorze garde-fous, dans l’ordre d’évaluation",note:"Le seuil se modifie ici. La sévérité ne se règle pas : elle dit si le poste refuse ou avertit, et cela ne dépend pas du magasin. Le message affiché au client n’est pas encore modifiable depuis cet écran.",children:(y,$)=>{var M=ko(),J=a(K(M),2);rt(J,21,()=>S,I=>I.code,(I,fe)=>{var Me=ho(),Be=r(Me),Se=r(Be),f=r(Se),q=a(Se,2),V=r(q),X=a(q,2),b=r(X),C=a(X,2),D=r(C),ie=a(Be,2),Ee=r(ie),Ce=a(ie,2);{var Le=ze=>{var Te=po();l(ze,Te)},oe=ze=>{var Te=fo(),we=r(Te);v(()=>i(we,`« ${e(fe).message??""} »`)),l(ze,Te)};m(Ce,ze=>{e(fe).message===""?ze(Le):ze(oe,-1)})}var Ne=a(Ce,2);{var Pe=ze=>{s(ze,()=>e(fe).switchPath,()=>e(fe).switchLabel,()=>"")};m(Ne,ze=>{e(fe).switchPath!==""&&ze(Pe)})}var $e=a(Ne,2);rt($e,17,()=>e(fe).thresholds,ze=>ze.path,(ze,Te)=>{var we=go(),Ke=r(we);{let Re=o(()=>t.draft.text(e(Te).path));St(Ke,{get label(){return e(Te).label},get path(){return e(Te).path},kind:"number",get value(){return e(Re)},get hint(){return e(Te).hint},onchange:nt=>xe(e(Te).path,nt)})}Ze("focusout",we,Re=>de(Re.target,t.draft.text(e(Te).path))),l(ze,we)});var Ue=a($e,2);{var He=ze=>{var Te=mo(),we=r(Te);v(()=>i(we,e(fe).note)),l(ze,Te)};m(Ue,ze=>{e(fe).note!==""&&ze(He)})}v(ze=>{Ae(Me,"data-code",e(fe).code),i(f,e(fe).rank),i(V,e(fe).label),i(b,e(fe).code),Ae(C,"data-blocking",ze),i(D,e(fe).severity),i(Ee,e(fe).when)},[()=>String(e(fe).blocking)]),l(I,Me)});var be=a(J,4),Ie=r(be),R=a(be,4);{var _=I=>{var fe=_o();l(I,fe)},z=I=>{var fe=xo(),Me=K(fe),Be=r(Me),Se=a(Me,2);rt(Se,21,()=>e(k),f=>f.code,(f,q)=>{var V=yo(),X=r(V),b=r(X),C=a(X,2),D=r(C),ie=a(C,2),Ee=r(ie),Ce=a(ie,2);{var Le=Pe=>{var $e=bo(),Ue=r($e);v(()=>i(Ue,`« ${e(q).message??""} »`)),l(Pe,$e)};m(Ce,Pe=>{e(q).message!==""&&Pe(Le)})}var oe=a(Ce,2);{var Ne=Pe=>{var $e=wo(),Ue=K($e),He=r(Ue),ze=a(Ue,2),Te=r(ze);v(we=>{i(He,we),i(Te,e(q).product_id)},[()=>N(e(q).product_id)]),l(Pe,$e)};m(oe,Pe=>{e(q).product_id!==""&&Pe(Ne)})}v((Pe,$e,Ue)=>{Ae(V,"data-blocking",Pe),i(b,$e),i(D,e(q).code),i(Ee,Ue)},[()=>String(e(q).blocking),()=>h(e(q).code),()=>T(e(q).severity)]),l(f,V)}),v(f=>{Ae(Me,"data-verdicts",f),i(Be,e(ke))},[()=>String(e(k).length)]),l(I,fe)};m(R,I=>{e(k).length===0?I(_):I(z,-1)})}v(I=>i(Ie,`Les marqueurs ${I??""} sont remplacés par les valeurs de la pesée au - moment où le message s’affiche.`),[()=>x.join(" et ")]),l(y,M)},$$slots:{default:!0}});var pe=a(ve,2);tt(pe,{title:"Code-barres",note:"Un seul réglage, et c’est voulu : le reste du plan de numérotation n’est pas de la configuration.",children:(y,$)=>{var M=So(),J=K(M);s(J,()=>"barcode.verify_reference_check_digit",()=>"Refuser une référence dont la clé de contrôle est fausse",()=>"Décoché, le poste recalcule une clé juste sur une référence fausse, en silence — et la caisse encaisse un autre article."),l(y,M)},$$slots:{default:!0}});var F=a(pe,2);tt(F,{title:"Dérogations de poids minimum",note:"En lecture ici ; elles se modifient depuis l’onglet Catalogue, là où se trouve le produit.",children:(y,$)=>{var M=ea(),J=K(M);{var be=R=>{var _=qo();l(R,_)},Ie=R=>{var _=jo(),z=K(_),I=r(z),fe=a(z,2);{var Me=Se=>{var f=Lo();l(Se,f)};m(fe,Se=>{e(Q)==="unread"&&Se(Me)})}var Be=a(fe,2);rt(Be,21,()=>e(A),Se=>Se.product_id,(Se,f)=>{var q=zo(),V=r(q),X=r(V),b=a(V,2),C=r(b),D=a(b,2),ie=r(D),Ee=a(D,2),Ce=r(Ee),Le=a(Ee,2),oe=r(Le),Ne=a(Le,2);{var Pe=$e=>{var Ue=Eo();l($e,Ue)};m(Ne,$e=>{e(f).offered||$e(Pe)})}v(($e,Ue,He,ze)=>{Ae(q,"data-withdrawn",$e),i(X,Ue),i(C,e(f).product_id),i(ie,He),i(Ce,e(f).reason),i(oe,`${ze??""}, ${e(f).decided_by??""}`)},[()=>String(!e(f).offered),()=>N(e(f).product_id),()=>ee(e(f).min_weight_g??0),()=>Aa(e(f).decided_at)]),l(Se,q)}),v(()=>i(I,e(Y))),l(R,_)};m(J,R=>{e(O).length===0?R(be):R(Ie,-1)})}l(y,M)},$$slots:{default:!0}}),l(n,W),Dt()}Wt(["change","input","focusout"]);const To=2e3,Ro=300*1e3;async function Oo(){const n=Date.now()+Ro;for(;Date.now()setTimeout(t,To));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var Ao=u('

                              ',1),No=u(`

                              Touchez de nouveau pour confirmer. Rien ne défait un redémarrage une fois - l’ordinateur parti.

                              `),$o=u(" ",1),Do=u('

                              '),Io=u('
                            • '),Uo=u('
                                '),Fo=u(`

                                Trois gestes de reprise, du plus doux au plus brutal. Le premier ne coupe rien.

                                Relire le fichier de configuration
                                Met en service config.json tel qu’il est sur le disque, sans arrêter le - poste. À utiliser après une modification faite à la main dans le fichier.
                                Redémarrer le poste
                                Arrête l’application et la relance. La pesée est interrompue quelques secondes ; - l’écran client revient tout seul.
                                Redémarrer l’ordinateur
                                Redémarre la machine entière. Comptez une minute avant que l’écran revienne.
                                `,1);function Mo(n,t){$t(t,!0);let s=P(""),c=P(""),w=P(Tt([])),k=P(!1),O=P(0),U=P(0),A=P(!1);const H=o(()=>e(O)>0);Kt(()=>{if(!e(H))return;const j=setInterval(()=>{d(U,Math.max(0,Math.round((e(O)-Date.now())/1e3)),!0)},1e3);return()=>clearInterval(j)});async function Q(){d(s,"reload-config"),d(c,""),d(w,[],!0);try{const j=await t.admin.protect(async()=>{try{return await _n()}catch(xe){if(_e(xe))throw xe;return t.admin.report(xe),d(w,t.admin.lastFaults,!0),null}});if(j===null)return;d(c,`Le fichier est en service. Empreinte ${j.config_fingerprint}.`)}finally{d(s,"")}}function _e(j){return j instanceof Yt&&j.needsCredentials}async function ce(){d(s,"restart"),d(c,""),d(w,[],!0);try{const j=await t.admin.protect(()=>bn());if(j===null)return;d(c,j.message,!0),d(k,!0),d(c,await Oo()?"Le poste est revenu.":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0)}finally{d(s,""),d(k,!1)}}async function qe(){if(!e(A)){d(A,!0);return}d(s,"reboot"),d(c,""),d(w,[],!0);try{const j=await t.admin.protect(()=>wn());if(j===null)return;d(O,Date.parse(j.at),!0),d(U,j.seconds_left,!0)}finally{d(s,""),d(A,!1)}}async function me(){d(s,"cancel-reboot");try{const j=await t.admin.protect(()=>yn());if(j===null)return;d(O,0),d(c,j.message,!0)}finally{d(s,"")}}tt(n,{title:"Maintenance",children:(j,xe)=>{var Z=Fo(),de=a(K(Z),2),re=a(r(de),2),N=a(r(re),3);{let le=o(()=>e(s)==="reload-config");at(N,{kind:"write",act:"reload-config",label:"Relire le fichier",protected:!0,get busy(){return e(le)},onrun:Q})}var ne=a(re,4),ke=a(r(ne));{let le=o(()=>e(s)==="restart"||e(k));at(ke,{kind:"write",act:"restart",label:"Redémarrer le poste",protected:!0,get busy(){return e(le)},onrun:ce})}var Y=a(ne,4),ee=a(r(Y));{var S=le=>{var ve=Ao(),pe=K(ve),F=r(pe),y=a(pe,2);{let $=o(()=>e(s)==="cancel-reboot");at(y,{kind:"write",act:"cancel-reboot",label:"Annuler",get busy(){return e($)},onrun:me})}v(()=>i(F,`L’ordinateur redémarre dans ${e(U)??""} seconde${e(U)>1?"s":""}.`)),l(le,ve)},h=le=>{var ve=$o(),pe=K(ve);{var F=$=>{var M=No();l($,M)};m(pe,$=>{e(A)&&$(F)})}var y=a(pe,2);{let $=o(()=>e(A)?"Confirmer le redémarrage":"Redémarrer l’ordinateur"),M=o(()=>e(s)==="reboot");at(y,{kind:"destructive",act:"reboot",get label(){return e($)},protected:!0,get busy(){return e(M)},onrun:qe})}l(le,ve)};m(ee,le=>{e(H)?le(S):le(h,-1)})}var x=a(de,2);{var T=le=>{var ve=Do(),pe=r(ve);v(()=>i(pe,e(c))),l(le,ve)};m(x,le=>{e(c)!==""&&le(T)})}var W=a(x,2);{var se=le=>{var ve=Uo();rt(ve,21,()=>e(w),pe=>pe.field,(pe,F)=>{var y=Io(),$=r(y),M=r($),J=a($);v(()=>{i(M,e(F).field),i(J,` — ${e(F).message??""}`)}),l(pe,y)}),l(le,ve)};m(W,le=>{e(w).length>0&&le(se)})}l(j,Z)},$$slots:{default:!0}}),Dt()}function Bo(n,t){const s=[];for(const c of[...Ha(n),...Ha(t)]){if(s.some(O=>O.path===c))continue;const w=fr(Na(n,c)),k=fr(Na(t,c));w!==k&&s.push({path:c,before:w,after:k})}return s}function Na(n,t){let s=n;for(const c of t.split(".")){if(s===null||typeof s!="object")return;s=s[c]}return s}function Ha(n,t=""){if(n===null||typeof n!="object"||Array.isArray(n))return t===""?[]:[t];const s=[];for(const[c,w]of Object.entries(n))s.push(...Ha(w,t===""?c:`${t}.${c}`));return s}function fr(n){return n===void 0?"—":n===null?"vide":typeof n=="boolean"?n?"oui":"non":typeof n=="object"?JSON.stringify(n):String(n)}var Wo=u('
                                Empreinte de la configuration en service
                                Version du binaire
                                Répertoire de données
                                Espace disque
                                ',1),Vo=u('

                                '),gr=u(" "),Go=u(' '),Ho=u("
                              • "),Jo=u(`

                                  Recopier reste possible : les valeurs entrent dans le brouillon, où elles se - corrigent champ par champ avant l’enregistrement.

                                  `),Ko=u(`

                                  Ce fichier n’a été comparé à rien : la configuration en service n’a pas pu être - lue. Ni « identique », ni « n champs changent » — ce que ce fichier changerait - sur ce poste reste inconnu.

                                  `),Yo=u(`

                                  Ce fichier décrit la même configuration que celle en service : il n’y a rien à - recopier. C’est ce qu’on veut lire à la fin d’un clonage. Deux champs ne sont pas - comparés : la date du dernier enregistrement, que chaque poste écrit lui-même, et - le mot de passe du catalogue, qu’aucun des deux ne porte en clair.

                                  `),Xo=u(' '),Qo=u('

                                  '),Zo=u(`

                                  ChampEn serviceDans le fichier

                                  Recopier n’applique rien : les valeurs entrent dans le brouillon, et c’est - « Enregistrer » qui les met en service.

                                  `,1),eu=u('

                                  ',1),tu=u(`

                                  L’export emporte encore l’empreinte du mot de passe : c’est la seule lecture que le - poste garde derrière la clé. L’import, lui, est lu PAR LE POSTE, qui écarte les deux - secrets et le numéro de poste avant de dire ce qui changerait.

                                  `,1),au=u('

                                  Lecture des versions enregistrées…

                                  '),ru=u(`

                                  Les versions enregistrées n’ont pas pu être lues : cette liste ne dit donc rien de - ce que ce poste garde. Ce n’est pas « aucune version ».

                                  `),nu=u('

                                  Aucune version enregistrée : ce poste n’a jamais été reconfiguré.

                                  '),su=u('
                                • '),lu=u(`

                                    Remettre une version en service remplace la configuration du poste sur-le-champ, et - ce qui n’a pas été enregistré est perdu : c’est le seul geste de cette page qui - change le poste tout de suite, et le seul qui garde ses 72 px.

                                    `,1),iu=u('
                                    ');function ou(n,t){$t(t,!0);const s=pt(t,"admin",7),c=40,w=20,k=5,O=new Set(["modified_at","catalog.options.password"]),U=[{path:"station.name",name:"le nom du poste"},{path:"network.listen",name:"l’adresse d’écoute"},{path:"scale.options.port",name:"le port de la balance"},{path:"printer.options.queue",name:"la file d’impression"},{path:"printer.options.path",name:"le nœud d’impression"},{path:"printer.options.address",name:"l’adresse de l’imprimante"},{path:"catalog.options.url",name:"l’adresse du partage"},{path:"catalog.options.username",name:"le compte du partage"},{path:"catalog.images.path",name:"le chemin des images"}];let A=P(Tt([])),H=P("reading"),Q=P(null),_e=P(""),ce=P(null),qe=P(""),me=P(Tt([])),j=P(""),xe="";const Z=o(()=>s().busy||e(j)!==""),de=o(()=>T(e(Q),e(ce))),re=o(()=>e(Q)!==null&&e(ce)!==null),N=o(()=>W(e(Q),e(ce))),ne=o(()=>e(de).slice(0,c)),ke=o(()=>e(me).slice(0,w)),Y=o(()=>e(A).slice(0,k)),ee=o(()=>Be(e(ne).length,e(de).length,"champ qui change","champs qui changent")),S=o(()=>Be(e(ke).length,e(me).length,"contrôle refuse une clé","contrôles refusent une clé")),h=o(()=>Be(e(Y).length,e(A).length,"version enregistrée","versions enregistrées")),x=o(()=>e(de).length>1?`Recopier ces ${ae(e(de).length)} champs dans le brouillon`:"Recopier ce champ dans le brouillon");ve();function T(b,C){return b===null||C===null?[]:Bo(b,C).filter(D=>!O.has(D.path))}function W(b,C){if(b===null||C===null)return[];const D=b,ie=C;return U.filter(Ee=>se(ie,Ee.path)&&!se(D,Ee.path))}function se(b,C){const D=Na(b,C);return D==null||D===""?!0:typeof D!="object"||Array.isArray(D)?!1:Object.keys(D).length===0}function le(b){return b.length<2?b.join(""):`${b.slice(0,-1).join(", ")} et ${b[b.length-1]??""}`}Kt(()=>{const b=t.health.config_fingerprint;b!==xe&&(xe=b,pe())});async function ve(){const b=await s().load(kn);if(b===null){d(H,"unreadable");return}d(A,b,!0),d(H,"read")}async function pe(){try{d(Q,(await Qa()).config,!0),d(_e,"")}catch(b){d(Q,null),d(_e,b instanceof Error?b.message:"Le poste n’a pas répondu.",!0)}}async function F(b){d(j,b?"export-all":"export-clone",!0),s().notice="",s().actionError="";try{const C=await s().protect(()=>y(b));if(C===null)return;J(C.name,C.blob),s().notice=`${C.name} est remis au navigateur : c’est lui qui l’enregistre, voyez ses téléchargements.`}finally{d(j,"")}}async function y(b){const D=await fetch(`/admin/api/config/export?hardware=${b?"1":"0"}`,{headers:{accept:"application/json"}});if(!D.ok)throw new Yt(D.status,I(await D.text(),"L’export"));return{name:$(b),blob:await D.blob()}}function $(b){const C=b?"":"-sans-materiel";return`config-poste${String(t.health.station)}${C}-${M()}.json`}function M(){const b=new Date,C=String(b.getMonth()+1).padStart(2,"0"),D=String(b.getDate()).padStart(2,"0");return`${String(b.getFullYear())}-${C}-${D}`}function J(b,C){const D=URL.createObjectURL(C),ie=document.createElement("a");ie.href=D,ie.download=b,document.body.appendChild(ie),ie.click(),ie.remove(),setTimeout(()=>{URL.revokeObjectURL(D)},0)}async function be(b){const C=await fetch("/admin/api/config/import",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(b)}),D=await C.text();if(!C.ok)throw new Yt(C.status,I(D,"L’import"));return JSON.parse(D)}async function Ie(b){if(b==null)return;s().notice="",s().actionError="";let C;try{C=JSON.parse(await b.text())}catch{s().actionError=`${b.name} n’est pas un fichier JSON lisible.`;return}d(j,"import");try{const D=await s().protect(()=>be(C));if(D===null)return;d(ce,D.config,!0),d(qe,b.name,!0),d(me,D.faults,!0),await pe(),s().notice=R(b.name)}finally{d(j,"")}}function R(b){return e(re)?e(de).length===0?`${b} décrit la même configuration que celle en service.`:`${b} est lu. Rien n’est appliqué : relisez le tableau.`:`${b} est lu, mais la configuration en service ne l’est pas : rien ne peut être comparé.`}async function _(){if(e(ce)===null)return;const b=e(ce),C=[...e(de)];for(const D of C)t.draft.set(D.path,Na(b,D.path));s().actionError="",s().notice="Le fichier est recopié dans le brouillon. Rien n’est appliqué avant « Enregistrer ».",await pe()}async function z(b){d(j,`restore-${String(b)}`),s().notice="",s().actionError="";try{if(await s().protect(()=>Sn(b))===null)return;await t.draft.load(),await ve(),await pe(),await s().refresh(),s().notice=`La version ${String(b)} est remise en service.`}finally{d(j,"")}}function I(b,C){try{const D=JSON.parse(b);if(typeof D?.message=="string"&&D.message!=="")return D.message}catch{}return`${C} a été refusé par le poste.`}function fe(b){return t.draft.faults.find(C=>C.field===b)?.message??""}function Me(b){return t.draft.faults.find(C=>C.field===b)?.allowed??[]}function Be(b,C,D,ie){const Ee=C>1?ie:D;return b>=C?`${ae(C)} ${Ee}.`:`${ae(b)} lignes affichées sur ${ae(C)} ${Ee}.`}var Se=iu(),f=r(Se);tt(f,{title:"Identité du poste",children:(b,C)=>{var D=Wo(),ie=K(D);{let Re=o(()=>t.draft.text("station.number")),nt=o(()=>fe("station.number")),ft=o(()=>Me("station.number"));St(ie,{label:"Numéro du poste",path:"station.number",kind:"number",get value(){return e(Re)},get fault(){return e(nt)},get allowed(){return e(ft)},hint:"C’est de lui que dérive le nom du fichier de catalogue attendu, flv_.csv.",onchange:ct=>t.draft.set("station.number",Number(ct))})}var Ee=a(ie,2);{let Re=o(()=>t.draft.text("station.name")),nt=o(()=>fe("station.name")),ft=o(()=>Me("station.name"));St(Ee,{label:"Nom du poste",path:"station.name",get value(){return e(Re)},get fault(){return e(nt)},get allowed(){return e(ft)},hint:"Ce que lit un bénévole : « Poste 2 — fruits ».",onchange:ct=>t.draft.set("station.name",ct)})}var Ce=a(Ee,2);{let Re=o(()=>t.draft.text("station.coop")),nt=o(()=>fe("station.coop")),ft=o(()=>Me("station.coop"));St(Ce,{label:"Coopérative",path:"station.coop",get value(){return e(Re)},get fault(){return e(nt)},get allowed(){return e(ft)},onchange:ct=>t.draft.set("station.coop",ct)})}var Le=a(Ce,2),oe=a(r(Le),2),Ne=r(oe),Pe=a(oe,4),$e=r(Pe),Ue=a(Pe,4),He=r(Ue),ze=a(Ue,4),Te=r(ze);{var we=Re=>{var nt=zt("non mesuré");l(Re,nt)},Ke=Re=>{var nt=zt();v((ft,ct,je)=>i(nt,`${ft??""} libres sur - ${ct??""} — seuil d’alerte - ${je??""} Mo`),[()=>Ga(t.health.disk.free_bytes),()=>Ga(t.health.disk.total_bytes),()=>ae(t.health.disk.alert_mb)]),l(Re,nt)};m(Te,Re=>{t.health.disk===null?Re(we):Re(Ke,-1)})}v(()=>{i(Ne,t.health.config_fingerprint),i($e,t.health.version),i(He,t.health.disk===null?"non publié par ce poste":t.health.disk.path)}),l(b,D)},$$slots:{default:!0}});var q=a(f,2);tt(q,{title:"Exporter, importer",note:"Pour installer un autre poste : ce fichier emporte les tarifs, les garde-fous, l’étiquette, les catégories, et les réglages du matériel que les quatre postes partagent — le décalage d’étiquette, le noircissement, la vitesse, le débit de la balance. Reste ici ce qui désigne ce poste-ci ou ce magasin — le mot de passe, le code de secours, le numéro et le nom du poste, le port de la balance, la file d’impression, l’adresse du partage et son compte, le chemin des images et le réseau.",children:(b,C)=>{var D=tu(),ie=K(D),Ee=r(ie);{let Te=o(()=>e(j)==="export-all");at(Ee,{label:"Exporter tout",protected:!0,get busy(){return e(Te)},get disabled(){return e(Z)},onrun:()=>{F(!0)}})}var Ce=a(Ee,2);{let Te=o(()=>e(j)==="export-clone");at(Ce,{label:"Exporter sans le matériel",protected:!0,get busy(){return e(Te)},get disabled(){return e(Z)},onrun:()=>{F(!1)}})}var Le=a(Ce,2);let oe;var Ne=r(Le),Pe=a(Ne,3),$e=a(ie,4);{var Ue=Te=>{var we=Vo(),Ke=r(we);v(()=>i(Ke,`La configuration en service n’a pas pu être lue : ${e(_e)??""} La colonne - « En service » ne peut donc rien affirmer.`)),l(Te,we)};m($e,Te=>{e(_e)!==""&&Te(Ue)})}var He=a($e,2);{var ze=Te=>{var we=eu(),Ke=K(we),Re=r(Ke),nt=a(Ke,2);{var ft=ut=>{var st=Jo(),It=r(st),Ut=r(It),Vt=a(It,2);rt(Vt,21,()=>e(ke),Oa,(Rt,gt)=>{var Ot=Ho(),Ft=r(Ot),Gt=r(Ft),Ht=a(Ft,2);{var na=mt=>{var yt=gr(),Jt=r(yt);v(()=>i(Jt,e(gt).field)),l(mt,yt)};m(Ht,mt=>{aa.showTechnicalNames&&mt(na)})}var Xt=a(Ht),Lt=a(Xt);{var bt=mt=>{var yt=Go(),Jt=r(yt);v(Qt=>i(Jt,`Valeurs acceptées : ${Qt??""}.`),[()=>e(gt).allowed.join(", ")]),l(mt,yt)};m(Lt,mt=>{e(gt).allowed!==void 0&&e(gt).allowed.length>0&&mt(bt)})}v(mt=>{i(Gt,mt),i(Xt,` ${e(gt).message??""} `)},[()=>ta(e(gt).field)]),l(Rt,Ot)}),v(()=>i(Ut,`Ce fichier serait refusé en l’état : ${e(S)??""}`)),l(ut,st)};m(nt,ut=>{e(me).length>0&&ut(ft)})}var ct=a(nt,2);{var je=ut=>{var st=Ko();l(ut,st)},it=ut=>{var st=Yo();l(ut,st)},Ct=ut=>{var st=Zo(),It=K(st),Ut=r(It),Vt=a(Ut);{var Rt=Lt=>{var bt=zt("Les autres sont dans le fichier, et « Recopier » les prend tous.");l(Lt,bt)};m(Vt,Lt=>{e(de).length>e(ne).length&&Lt(Rt)})}var gt=a(It,2),Ot=r(gt),Ft=a(r(Ot));rt(Ft,21,()=>e(ne),Lt=>Lt.path,(Lt,bt)=>{const mt=o(()=>ta(e(bt).path));var yt=Xo(),Jt=r(yt),Qt=r(Jt),ga=a(Qt);{var sa=oa=>{var ua=gr(),ha=r(ua);v(()=>i(ha,e(bt).path)),l(oa,ua)};m(ga,oa=>{aa.showTechnicalNames&&e(mt)!==e(bt).path&&oa(sa)})}var da=a(Jt),la=r(da),ma=a(da),ia=r(ma);v(()=>{Ae(yt,"data-path",e(bt).path),i(Qt,`${e(mt)??""} `),i(la,e(bt).before),i(ia,e(bt).after)}),l(Lt,yt)});var Gt=a(gt,2);{var Ht=Lt=>{var bt=Qo(),mt=r(bt);v(yt=>i(mt,`Ce fichier ne porte pas ${yt??""} : - l’export sans le matériel les retire, et l’import ne remet que le numéro du - poste. Les lignes correspondantes sont VIDES ci-dessus, et - « Recopier » recopie ce vide dans le brouillon.`),[()=>le(e(N).map(yt=>yt.name))]),l(Lt,bt)};m(Gt,Lt=>{e(N).length>0&&Lt(Ht)})}var na=a(Gt,2),Xt=r(na);at(Xt,{kind:"write",get label(){return e(x)},get disabled(){return e(Z)},onrun:()=>{_()}}),v(()=>i(Ut,`${e(ee)??""} `)),l(ut,st)};m(ct,ut=>{e(re)?e(de).length===0?ut(it,1):ut(Ct,-1):ut(je)})}v(()=>i(Re,`Fichier lu : ${e(qe)??""}`)),l(Te,we)};m(He,Te=>{e(ce)!==null&&Te(ze)})}v(()=>{oe=pa(Le,1,"choose svelte-18wbxwu",null,oe,{working:e(j)==="import",off:e(Z)}),i(Ne,`${e(j)==="import"?"Lecture du fichier…":"Importer un fichier"} `),Pe.disabled=e(Z)}),Ze("change",Pe,Te=>{Ie(Te.currentTarget.files?.item(0))}),l(b,D)},$$slots:{default:!0}});var V=a(q,2);tt(V,{title:"Cinq versions restaurables",note:"Chaque enregistrement fait tourner les versions : la plus récente est la 1.",children:(b,C)=>{var D=ea(),ie=K(D);{var Ee=Ne=>{var Pe=au();l(Ne,Pe)},Ce=Ne=>{var Pe=ru();l(Ne,Pe)},Le=Ne=>{var Pe=nu();l(Ne,Pe)},oe=Ne=>{var Pe=lu(),$e=K(Pe),Ue=r($e),He=a($e,2),ze=r(He);rt(ze,21,()=>e(Y),Te=>Te.version,(Te,we)=>{var Ke=su(),Re=r(Ke),nt=r(Re),ft=a(Re,2),ct=r(ft),je=a(ft,2),it=r(je),Ct=a(je,2);{let ut=o(()=>e(j)===`restore-${String(e(we).version)}`);at(Ct,{kind:"destructive",label:"Remettre cette version en service",protected:!0,get busy(){return e(ut)},get disabled(){return e(Z)},onrun:()=>{z(e(we).version)}})}v((ut,st)=>{i(nt,`version ${ut??""}`),i(ct,st),i(it,e(we).config_fingerprint)},[()=>ae(e(we).version),()=>ra(e(we).modified_at)]),l(Te,Ke)}),v(()=>i(Ue,`${e(h)??""} Le poste n’en garde jamais plus de cinq.`)),l(Ne,Pe)};m(ie,Ne=>{e(H)==="reading"?Ne(Ee):e(H)==="unreadable"?Ne(Ce,1):e(A).length===0?Ne(Le,2):Ne(oe,-1)})}l(b,D)},$$slots:{default:!0}});var X=a(V,2);Mo(X,{get admin(){return s()}}),l(n,Se),Dt()}Wt(["change"]);var uu=u('clé'),cu=u(' '),du=u('');function _a(n,t){const s=pt(t,"kind",3,"read"),c=pt(t,"hint",3,""),w=pt(t,"disabled",3,!1),k=pt(t,"engaged",3,!1),O=pt(t,"busy",3,!1),U=pt(t,"protected",3,!1);var A=du();let H;var Q=r(A),_e=r(Q),ce=a(_e);{var qe=xe=>{var Z=uu();l(xe,Z)};m(ce,xe=>{U()&&xe(qe)})}var me=a(Q,2);{var j=xe=>{var Z=cu(),de=r(Z);v(()=>i(de,O()?"En cours…":c())),l(xe,Z)};m(me,xe=>{c()!==""&&xe(j)})}v(()=>{H=pa(A,1,`big touch-target ${s()??""}`,"svelte-15nlt6i",H,{engaged:k(),busy:O()}),Ae(A,"data-kind",s()),A.disabled=w()||O(),i(_e,`${t.label??""} `)}),Ze("click",A,function(...xe){t.onrun?.apply(this,xe)}),l(n,A)}Wt(["click"]);const vu="ERR-SCL-09";var pu=u('

                                    '),fu=u('

                                    '),gu=u('

                                    '),mu=u('

                                    Déposez ici le fichier clé

                                    '),hu=u('

                                    ',1),_u=u('');function bu(n,t){$t(t,!0);const s=pt(t,"admin",7);let c=P("");const w=o(()=>t.health.state.degraded?.code===vu),k=o(()=>t.health.printing),O=o(()=>e(k)?.on_fallback===!0);let U=P(!1),A=P("");const H=o(()=>s().busy||e(A)!==""),Q=new Pr;Kt(()=>{Q.observe(t.health)});const _e=o(()=>t.health.catalog_source===null?"Ce poste ne publie pas la source de son catalogue.":"Catalogue surveillé : "+t.health.catalog_source.label),ce=o(()=>t.health.catalog!==null&&(t.health.catalog.result==="rejected"||t.health.catalog.result==="failed")?t.health.catalog:null);function qe(_){d(c,""),Q.forget(),d(A,_,!0)}async function me(_,z){qe(_);try{await s().run(z)}finally{d(A,"")}}async function j(){qe("reload");try{const _=await s().run(sn);_!==null&&Q.begin(_)}finally{d(A,"")}}async function xe(_,z){qe(_);try{const I=await s().load(z);I!==null&&d(c,I.message,!0)}finally{d(A,"")}}async function Z(_,z){qe(_);try{const I=await s().protect(z);I!==null&&(s().notice=I.message,await s().refresh())}finally{d(A,"")}}async function de(_){if(d(U,!1),_!=null){qe("import");try{const z=await s().protect(()=>xr(_));if(z===null)return;d(c,z.result==="rejected"||z.result==="failed"?`${_.name} : REFUSÉ${z.reason===""?"":" — "+z.reason}. Le catalogue en service n’a pas changé.`:`${_.name} : ${String(z.rows_read_count)} lignes lues, ${String(z.weighable_count)} pesables. La veille l’appliquera dans la seconde.`,!0),await s().refresh()}finally{d(A,"")}}}var re=_u(),N=r(re);{var ne=_=>{var z=pu(),I=r(z);v(()=>i(I,e(c))),l(_,z)};m(N,_=>{e(c)!==""&&_(ne)})}var ke=a(N,2);{var Y=_=>{var z=fu(),I=r(z);v(()=>i(I,Q.sentence)),l(_,z)};m(ke,_=>{Q.sentence!==""&&_(Y)})}var ee=a(ke,2);{var S=_=>{var z=gu(),I=r(z);v(()=>i(I,e(k).banner)),l(_,z)};m(ee,_=>{e(k)!==null&&e(k).banner!==""&&_(S)})}var h=a(ee,2),x=r(h),T=a(h,2),W=r(T);{let _=o(()=>e(A)==="scale");_a(W,{label:"Tester la balance",hint:"Ce que le poste a déjà observé — le port n’est pas rouvert.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{xe("scale",cn)}})}var se=a(W,2);{let _=o(()=>e(A)==="printer");_a(se,{label:"Tester l’imprimante",hint:"Ce que le superviseur a vu il y a moins d’une seconde.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{xe("printer",dn)}})}var le=a(se,2);{let _=o(()=>e(A)==="label");_a(le,{label:"Imprimer une étiquette de test",hint:"Une étiquette de démonstration sort de l’imprimante du poste.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{me("label",vn)}})}var ve=a(le,2);{let _=o(()=>e(A)==="reprint");_a(ve,{label:"Réimprimer la dernière",hint:"La dernière étiquette imprimée sort une seconde fois.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{me("reprint",nn)}})}var pe=a(ve,2);{let _=o(()=>e(A)==="reload");_a(pe,{label:"Recharger le catalogue",kind:"write",hint:"La veille refait tout de suite le contrôle qu’elle fait toutes les cinq secondes.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{j()}})}var F=a(pe,2);{let _=o(()=>e(w)?"Revenir à la balance":"Basculer en saisie manuelle"),z=o(()=>e(w)?"Le poids sera de nouveau lu sur la balance.":"Le poids se tape à la main : le poste continue de servir sans balance."),I=o(()=>e(A)==="manual");_a(F,{get label(){return e(_)},kind:"destructive",get hint(){return e(z)},get engaged(){return e(w)},protected:!0,get busy(){return e(I)},get disabled(){return e(H)},onrun:()=>{Z("manual",()=>ln(!e(w)))}})}var y=a(F,2);{let _=o(()=>e(A)==="roll");_a(y,{label:"J’ai changé le rouleau",kind:"write",hint:"Le compteur d’étiquettes repart à zéro. C’est le seul geste qui dise quelque chose de vrai du papier.",get busy(){return e(_)},get disabled(){return e(H)},onrun:()=>{me("roll",on)}})}var $=a(y,2);{var M=_=>{{let z=o(()=>e(O)?"Revenir à l’imprimante du poste":"Imprimer sur l’imprimante du poste voisin"),I=o(()=>e(O)?"Les étiquettes repartiront sur l’imprimante de ce poste.":"Les étiquettes sortiront sur l’imprimante voisine, pour cette session seulement."),fe=o(()=>e(A)==="fallback");_a(_,{get label(){return e(z)},kind:"write",get hint(){return e(I)},get engaged(){return e(O)},get busy(){return e(fe)},get disabled(){return e(H)},onrun:()=>{me("fallback",()=>un(!e(O)))}})}};m($,_=>{e(k)!==null&&e(k).fallback_available&&_(M)})}var J=a($,2),be=a(T,2);tt(be,{title:"Importer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(_,z)=>{var I=mu();let fe;var Me=r(I),Be=a(r(Me)),Se=r(Be),f=a(Me,2),q=a(r(f));v(()=>{fe=pa(I,1,"drop svelte-1yd0nzg",null,fe,{dropping:e(U)}),i(Se,`flv_${t.health.station??""}.csv`)}),ya("dragover",I,V=>{V.preventDefault(),d(U,!0)}),ya("dragleave",I,()=>d(U,!1)),ya("drop",I,V=>{V.preventDefault(),de(V.dataTransfer?.files.item(0))}),Ze("change",q,V=>{de(V.currentTarget.files?.item(0))}),l(_,I)},$$slots:{default:!0}});var Ie=a(be,2);{var R=_=>{tt(_,{title:"Le dernier fichier n’a pas pris service",children:(z,I)=>{var fe=hu(),Me=K(fe),Be=r(Me),Se=a(Me,2),f=r(Se);v((q,V)=>{i(Be,`${q??""} — - ${(e(ce).reason===""?"aucun motif enregistré.":e(ce).reason)??""} - Le catalogue en service n’a pas changé.`),i(f,`Dernier essai : ${V??""}.`)},[()=>Da(e(ce).result),()=>ra(e(ce).occurred_at)]),l(z,fe)},$$slots:{default:!0}})};m(Ie,_=>{e(ce)!==null&&_(R)})}v(()=>{i(x,e(_e)),Ae(J,"href",pn)}),l(n,re),Dt()}Wt(["change"]);const wu={succeeded:"La dernière mise à jour a réussi.","rolled-back":"La dernière mise à jour a échoué. La version précédente a été remise et le poste fonctionne.","rolled-back-unhealthy":"La dernière mise à jour a échoué et le poste n’a pas redémarré. Appelez le support.","not-started":"La dernière mise à jour n’a pas démarré : rien n’a été remplacé. Vous pouvez réessayer."},yu=2e3,xu=300*1e3;var ku=u('

                                    '),mr=u('

                                    '),Su=u("
                                    Dernière vérification
                                    ",1),qu=u(`

                                    La mise à jour depuis cet écran n’existe que sur les postes Windows. Sur les - autres, elle se fait à la main — voir la notice d’installation.

                                    `),Lu=u('Voir les nouveautés'),Eu=u('

                                    Version disponible : .

                                    '),zu=u('

                                    Ce poste est à jour.

                                    '),ju=u("
                                    Version installée
                                    Dépôt suivi
                                    ",1),Cu=u(`

                                    Le poste va s’arrêter environ une minute. L’écran client s’éteindra puis reviendra - tout seul. Si la nouvelle version ne démarre pas, la précédente sera remise - automatiquement — mais les données enregistrées, elles, ne reviendront pas en - arrière.

                                    `),Pu=u('

                                    Mise à jour en cours. Le poste redémarre, ne le débranchez pas.

                                    '),Tu=u('
                                    ',1),Ru=u("
                                    Terminée le
                                    ",1),Ou=u("
                                    Raison
                                    ",1),Au=u('

                                    Depuis
                                    Vers
                                    ',1),Nu=u(" ",1);function $u(n,t){$t(t,!0);let s=P(null),c=P(""),w=P(""),k=P(!1),O=P("");const U=o(()=>e(s)?.outcome??null),A=o(()=>e(s)?.supported===!0&&e(s).available),H=o(()=>e(s)?.latest??"");Kt(()=>{Q()});async function Q(){try{d(s,await In(),!0),d(c,"")}catch(N){d(c,N instanceof Error?N.message:"Lecture impossible.",!0)}}async function _e(){d(w,"check");try{const N=await t.admin.protect(()=>Un());N!==null&&(d(s,N,!0),d(c,""))}finally{d(w,"")}}async function ce(N){d(w,"apply"),d(O,"");try{if(await t.admin.protect(()=>Fn(N))===null)return;d(k,!0),d(O,await qe()?"":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0),await Q()}finally{d(w,""),d(k,!1)}}async function qe(){const N=Date.now()+xu;for(;Date.now()setTimeout(ne,yu));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var me=Nu(),j=K(me);tt(j,{title:"Version de ce poste",children:(N,ne)=>{var ke=ea(),Y=K(ke);{var ee=h=>{var x=ku(),T=r(x);v(()=>i(T,e(c)===""?"Lecture…":e(c))),l(h,x)},S=h=>{var x=ju(),T=K(x);{var W=R=>{var _=mr(),z=r(_);v(()=>i(z,e(c))),l(R,_)};m(T,R=>{e(c)!==""&&R(W)})}var se=a(T,2),le=a(r(se),2),ve=r(le),pe=a(le,4),F=r(pe),y=a(pe,2);{var $=R=>{var _=Su(),z=a(K(_),2),I=r(z);v(fe=>i(I,fe),[()=>ra(e(s).checked_at)]),l(R,_)};m(y,R=>{e(s).checked_at!==""&&R($)})}var M=a(se,2);{var J=R=>{var _=qu();l(R,_)},be=R=>{var _=Eu(),z=a(r(_)),I=r(z),fe=a(z,2);{var Me=f=>{var q=zt();v(V=>i(q,`, publiée le ${V??""}`),[()=>ra(e(s).published_at)]),l(f,q)};m(fe,f=>{e(s).published_at!==""&&f(Me)})}var Be=a(fe,2);{var Se=f=>{var q=Lu();v(()=>Ae(q,"href",e(s).html_url)),l(f,q)};m(Be,f=>{e(s).html_url!==""&&f(Se)})}v(()=>i(I,e(s).latest)),l(R,_)},Ie=R=>{var _=zu();l(R,_)};m(M,R=>{e(s).supported?e(s).available?R(be,1):R(Ie,-1):R(J)})}v(()=>{i(ve,e(s).running),i(F,e(s).repository)}),l(h,x)};m(Y,h=>{e(s)===null?h(ee):h(S,-1)})}l(N,ke)},$$slots:{default:!0}});var xe=a(j,2);{var Z=N=>{tt(N,{title:"Installer",children:(ne,ke)=>{var Y=Tu(),ee=K(Y),S=r(ee);{let F=o(()=>e(w)==="check");at(S,{kind:"read",label:"Vérifier maintenant",protected:!0,act:"check",get busy(){return e(F)},get disabled(){return e(k)},onrun:()=>{_e()}})}var h=a(S,2);{var x=F=>{{let y=o(()=>`Installer la version ${e(H)}`),$=o(()=>e(w)==="apply");at(F,{kind:"destructive",get label(){return e(y)},protected:!0,act:"apply",get busy(){return e($)},onrun:()=>{ce(e(H))}})}};m(h,F=>{e(A)&&F(x)})}var T=a(ee,2);{var W=F=>{var y=Cu();l(F,y)};m(T,F=>{e(A)&&F(W)})}var se=a(T,2);{var le=F=>{var y=Pu();l(F,y)};m(se,F=>{e(k)&&F(le)})}var ve=a(se,2);{var pe=F=>{var y=mr(),$=r(y);v(()=>i($,e(O))),l(F,y)};m(ve,F=>{e(O)!==""&&F(pe)})}l(ne,Y)},$$slots:{default:!0}})};m(xe,N=>{e(s)!==null&&e(s).supported&&N(Z)})}var de=a(xe,2);{var re=N=>{tt(N,{title:"Dernière tentative",children:(ne,ke)=>{var Y=Au(),ee=K(Y),S=r(ee),h=a(ee,2),x=a(r(h),2),T=r(x),W=a(x,4),se=r(W),le=a(W,2);{var ve=y=>{var $=Ru(),M=a(K($),2),J=r(M);v(be=>i(J,be),[()=>ra(e(U).finished_at)]),l(y,$)};m(le,y=>{e(U).finished_at!==""&&y(ve)})}var pe=a(le,2);{var F=y=>{var $=Ou(),M=a(K($),2),J=r(M);v(()=>i(J,e(U).reason)),l(y,$)};m(pe,y=>{e(U).reason!==""&&y(F)})}v(()=>{Ae(ee,"data-outcome",e(U).status),i(S,wu[e(U).status]),i(T,e(U).from===""?"—":e(U).from),i(se,e(U).to===""?"—":e(U).to)}),l(ne,Y)},$$slots:{default:!0}})};m(de,N=>{e(U)!==null&&N(re)})}l(n,me),Dt()}var Du=u(''),Iu=u('

                                    ',1),Uu=u('

                                    ',1),Fu=u(''),Mu=u(''),Bu=u(''),Wu=u(''),Vu=u(' '),Gu=u(''),Hu=u('

                                    Lecture de l’état du poste…

                                    '),Ju=u('

                                    '),Ku=u(" "),Yu=u("
                                  • "),Xu=u('
                                      '),Qu=u('
                                      '),Zu=u('

                                      ');function ec(n,t){$t(t,!0);const s=new es,c=new Mn(s),w=[{title:"Au quotidien",pages:[{id:"dashboard",label:"Tableau de bord"},{id:"troubleshooting",label:"Dépannage"}]},{title:"Réglages",pages:[{id:"hardware",label:"Matériel"},{id:"label",label:"Étiquette"},{id:"rules",label:"Règles"},{id:"catalog",label:"Catalogue"},{id:"journal",label:"Journal"},{id:"station",label:"Poste"},{id:"update",label:"Mise à jour"}]}],k=o(()=>w.flatMap(f=>f.pages).find(f=>f.id===s.page)?.label??""),O=o(()=>s.health===null||s.health.station_name===""?`Poste ${String(s.health?.station??"")}`:s.health.station_name),U=o(()=>(c.pending?.changed_blocks??[]).map(f=>Gn(f)).join(", ")),A=o(()=>(c.pending?.changed_blocks??[]).join(", "));Xa(()=>(s.start(),()=>s.stop()));async function H(f){s.open(f),or(f)&&c.config===null&&await c.load()}async function Q(){await s.protect(()=>c.save())===!0&&(s.notice="La configuration est enregistrée et appliquée.")}var _e=Zu(),ce=r(_e),qe=a(r(ce),2);rt(qe,17,()=>w,f=>f.title,(f,q)=>{var V=Iu(),X=K(V),b=r(X),C=a(X,2);rt(C,17,()=>e(q).pages,D=>D.id,(D,ie)=>{var Ee=Du();let Ce;var Le=r(Ee);v(()=>{Ce=pa(Ee,1,"entry svelte-9b2mjq",null,Ce,{current:s.page===e(ie).id}),Ae(Ee,"aria-current",s.page===e(ie).id?"page":void 0),i(Le,e(ie).label)}),Ze("click",Ee,()=>{H(e(ie).id)}),l(D,Ee)}),v(()=>i(b,e(q).title)),l(f,V)});var me=a(qe,2),j=r(me);{var xe=f=>{var q=Uu(),V=K(q),X=r(V),b=a(V,2),C=r(b),D=a(b,2),ie=r(D);v(()=>{i(X,e(O)),i(C,`${s.health.coop??""} · version ${s.health.version??""}`),i(ie,`configuration ${s.health.config_fingerprint??""}`)}),l(f,q)};m(j,f=>{s.health!==null&&f(xe)})}var Z=a(j,2),de=r(Z),re=a(Z,2);{var N=f=>{var q=Fu();Ze("click",q,function(...V){t.onclose?.apply(this,V)}),l(f,q)};m(re,f=>{t.onclose!==void 0&&f(N)})}var ne=a(ce,2),ke=r(ne);{var Y=f=>{var q=Mu(),V=r(q);v(()=>i(V,s.notice)),l(f,q)};m(ke,f=>{s.notice!==""&&f(Y)})}var ee=a(ke,2);{var S=f=>{var q=Bu(),V=r(q);v(()=>i(V,s.linkError)),l(f,q)};m(ee,f=>{s.linkError!==""&&f(S)})}var h=a(ee,2);{var x=f=>{var q=Wu(),V=r(q);v(()=>i(V,s.actionError)),l(f,q)};m(h,f=>{s.actionError!==""&&f(x)})}var T=a(h,2);{var W=f=>{var q=Gu(),V=r(q),X=r(V),b=a(X);{var C=Ee=>{var Ce=Vu(),Le=r(Ce);v(()=>i(Le,e(A))),l(Ee,Ce)};m(b,Ee=>{aa.showTechnicalNames&&Ee(C)})}var D=a(b),ie=a(V,2);at(ie,{kind:"write",label:"Tout fonctionne : confirmer",onrun:()=>{s.protect(()=>c.confirm())}}),v(()=>{i(X,`Configuration appliquée mais NON CONFIRMÉE. Ce qui a changé : ${e(U)??""}. `),i(D,` Le poste reviendra tout seul à la version précédente dans - ${c.pending.seconds_left??""} secondes si personne ne confirme.`)}),l(f,q)};m(T,f=>{c.pending!==null&&f(W)})}var se=a(T,2),le=r(se),ve=r(le),pe=a(le,2);{var F=f=>{var q=Hu();l(f,q)},y=f=>{pi(f,{get health(){return s.health},onshowrows:()=>{H("catalog")},onshowupdate:()=>{H("update")}})},$=f=>{bu(f,{get admin(){return s},get health(){return s.health}})},M=f=>{Ei(f,{get admin(){return s},get draft(){return c},get health(){return s.health}})},J=f=>{to(f,{get admin(){return s},get draft(){return c}})},be=f=>{Po(f,{get draft(){return c},get health(){return s.health}})},Ie=f=>{El(f,{get admin(){return s},get draft(){return c},get health(){return s.health}})},R=f=>{Gi(f,{get admin(){return s}})},_=f=>{ou(f,{get admin(){return s},get draft(){return c},get health(){return s.health}})},z=f=>{$u(f,{get admin(){return s}})};m(pe,f=>{s.health===null?f(F):s.page==="dashboard"?f(y,1):s.page==="troubleshooting"?f($,2):s.page==="hardware"?f(M,3):s.page==="label"?f(J,4):s.page==="rules"?f(be,5):s.page==="catalog"?f(Ie,6):s.page==="journal"?f(R,7):s.page==="station"?f(_,8):s.page==="update"&&f(z,9)})}var I=a(se,2);{var fe=f=>{var q=Qu(),V=r(q);{var X=ie=>{var Ee=Ju(),Ce=r(Ee),Le=a(Ce);rt(Le,16,()=>c.retired,oe=>oe,(oe,Ne)=>{{let Pe=o(()=>`retirer ${Ne}`);at(oe,{kind:"write",get label(){return e(Pe)},onrun:()=>c.dropRetired(Ne)})}}),v(oe=>i(Ce,`Ce fichier porte des réglages que cette version du poste ne connaît plus : - ${oe??""}. `),[()=>c.retired.map(oe=>ta(oe)).join(", ")]),l(ie,Ee)};m(V,ie=>{c.retired.length>0&&ie(X)})}var b=a(V,2);{var C=ie=>{var Ee=Xu();rt(Ee,21,()=>c.faults,Ce=>Ce.field,(Ce,Le)=>{var oe=Yu(),Ne=r(oe),Pe=r(Ne),$e=a(Ne,2);{var Ue=we=>{var Ke=Ku(),Re=r(Ke);v(()=>i(Re,e(Le).field)),l(we,Ke)};m($e,we=>{aa.showTechnicalNames&&we(Ue)})}var He=a($e),ze=a(He);{var Te=we=>{var Ke=zt();v(Re=>i(Ke,`— valeurs acceptées : ${Re??""}`),[()=>e(Le).allowed.join(", ")]),l(we,Ke)};m(ze,we=>{e(Le).allowed!==void 0&&e(Le).allowed.length>0&&we(Te)})}v(we=>{i(Pe,we),i(He,` ${e(Le).message??""} `)},[()=>ta(e(Le).field)]),l(Ce,oe)}),l(ie,Ee)};m(b,ie=>{c.faults.length>0&&ie(C)})}var D=a(b,2);{let ie=o(()=>c.dirty?"Enregistrer la configuration":"Aucune modification à enregistrer"),Ee=o(()=>!c.dirty||s.busy);at(D,{kind:"write",get label(){return e(ie)},get disabled(){return e(Ee)},onrun:()=>{Q()}})}l(f,q)},Me=o(()=>or(s.page)&&c.config!==null);m(I,f=>{e(Me)&&f(fe)})}var Be=a(ne,2);{var Se=f=>{is(f,{get admin(){return s}})};m(Be,f=>{s.pending!==null&&f(Se)})}v(()=>{wa(de,aa.showTechnicalNames),i(ve,e(k))}),Ze("change",de,()=>aa.toggleTechnicalNames()),l(n,_e),Dt()}Wt(["click","change"]);let Ta=null;function rc(n){if(Ta!==null||n.querySelector("[data-admin]")!==null)return;const t=document.createElement("div");n.appendChild(t),Ta={component:Xr(ec,{target:t,props:{onclose:tc}}),host:t}}function tc(){if(Ta===null)return;const{component:n,host:t}=Ta;Ta=null,Qr(n),t.remove()}export{tc as closeAdmin,rc as mountAdmin}; diff --git a/internal/web/dist/assets/mount-CBfLWXOt.css b/internal/web/dist/assets/mount-CBfLWXOt.css deleted file mode 100644 index dfa9c83..0000000 --- a/internal/web/dist/assets/mount-CBfLWXOt.css +++ /dev/null @@ -1 +0,0 @@ -.act.svelte-5tpq0o{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.read.svelte-5tpq0o{color:var(--ink);background:var(--surface);border:1px solid var(--border)}.write.svelte-5tpq0o{color:var(--surface);background:var(--action);border:1px solid var(--action)}.destructive.svelte-5tpq0o{min-height:var(--touch-min);color:var(--surface);background:var(--danger);border:1px solid var(--danger)}@media(hover:hover){.read.svelte-5tpq0o:hover:not(:disabled){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}.write.svelte-5tpq0o:hover:not(:disabled){border-color:var(--action)}.destructive.svelte-5tpq0o:hover:not(:disabled){border-color:var(--danger)}.write.svelte-5tpq0o:hover:not(:disabled),.destructive.svelte-5tpq0o:hover:not(:disabled){box-shadow:var(--shadow-2);filter:brightness(.92)}}.act.svelte-5tpq0o:disabled{opacity:.5;box-shadow:none;cursor:default}.act.busy.svelte-5tpq0o:disabled{opacity:1}.key.svelte-5tpq0o{padding:.0625rem .375rem;border-radius:var(--radius-pill);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;background:var(--bg);color:var(--ink-muted)}.write.svelte-5tpq0o .key:where(.svelte-5tpq0o),.destructive.svelte-5tpq0o .key:where(.svelte-5tpq0o){background:var(--surface);color:var(--ink)}.scrim.svelte-zyka3t{position:fixed;inset:0;z-index:95;display:flex;align-items:center;justify-content:center;padding:2rem;background:#1c1b1973}.panel.svelte-zyka3t{display:flex;flex-direction:column;gap:.75rem;width:min(34rem,100%);padding:1.75rem;background:var(--surface);border-radius:var(--radius-lg);box-shadow:var(--shadow-2)}header.svelte-zyka3t{display:flex;align-items:center;gap:.75rem}.glyph.svelte-zyka3t{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;flex:none;border-radius:var(--radius-sm);background:var(--bg);color:var(--ink-muted)}h2.svelte-zyka3t{margin:0;font-size:1.375rem;line-height:1.2}.why.svelte-zyka3t,.how.svelte-zyka3t{margin:0;color:var(--ink-muted);font-size:1rem;line-height:1.4}.field.svelte-zyka3t{display:flex;flex-direction:column;gap:.375rem;font-size:1rem;color:var(--ink-muted)}input.svelte-zyka3t{height:2.75rem;padding:0 .75rem;font:inherit;font-size:1.125rem;color:var(--ink);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)}input.svelte-zyka3t:focus-visible{outline:3px solid var(--focus);outline-offset:1px}.refusal.svelte-zyka3t{margin:0;padding:.625rem .75rem;border-left:.25rem solid var(--fault);background:var(--fault-wash);font-size:1rem}footer.svelte-zyka3t{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.25rem}.field.svelte-1oilv5s{display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0}label.svelte-1oilv5s{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem}.name.svelte-1oilv5s{font-size:1.125rem;font-weight:700}code.svelte-1oilv5s{font-size:.9375rem;color:var(--ink-muted)}input.svelte-1oilv5s,select.svelte-1oilv5s{min-height:3rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.refused.svelte-1oilv5s input:where(.svelte-1oilv5s),.refused.svelte-1oilv5s select:where(.svelte-1oilv5s){border-left:.5rem solid var(--fault)}.hint.svelte-1oilv5s,.fault.svelte-1oilv5s{margin:0;font-size:1rem;color:var(--ink-muted)}.fault.svelte-1oilv5s{padding-left:.5rem;border-left:.25rem solid var(--fault)}.inventory.svelte-tuh4jc{display:flex;flex-direction:column;gap:.5rem}.headline.svelte-tuh4jc{margin:0;font-size:1.375rem;font-weight:700}dl.svelte-tuh4jc{margin:0;display:flex;flex-direction:column;gap:.25rem}.row.svelte-tuh4jc{display:flex;align-items:baseline;gap:.75rem}dt.svelte-tuh4jc{flex:none;width:4.5rem;text-align:right;font-size:1.375rem;font-weight:700}dd.svelte-tuh4jc{margin:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;font-size:1.125rem}.label.svelte-tuh4jc{font-weight:700}.note.svelte-tuh4jc{color:var(--ink-muted)}.rows.svelte-tuh4jc{padding:0 .5rem;min-height:var(--touch-min);text-decoration:underline;color:var(--ink-muted)}.oneline.svelte-tuh4jc{margin:0;font-size:1rem;color:var(--ink-muted)}.panel.svelte-ww3z5u{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}h2.svelte-ww3z5u{margin:0 0 .5rem;font-size:1.5rem;font-weight:700}.note.svelte-ww3z5u{margin:0 0 .75rem;font-size:1rem;color:var(--ink-muted)}.pages.svelte-3gk7u7{display:flex;flex-direction:column;gap:1rem}.fact.svelte-3gk7u7{margin:.5rem 0;font-size:1.125rem}.muted.svelte-3gk7u7{color:var(--ink-muted);font-size:1rem}.actions.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.toggle.svelte-3gk7u7{display:flex;align-items:center;gap:.75rem;min-height:2.75rem;margin:.5rem 0 0;padding:.375rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);font-weight:400;cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.toggle[data-on=true].svelte-3gk7u7{background:var(--ready-wash)}@media(hover:hover){.toggle.svelte-3gk7u7:hover{border-color:var(--ink-muted)}}.toggle.svelte-3gk7u7 input:where(.svelte-3gk7u7){flex:none;width:1.5rem;height:1.5rem;min-height:0;min-width:0;padding:0;accent-color:var(--focus)}.toggle-text.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline}.toggle-label.svelte-3gk7u7{font-size:1.0625rem;font-weight:700}.hint.svelte-3gk7u7{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.columns-label.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:1rem 0 .375rem;font-size:1.0625rem;font-weight:700}.columns.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.375rem}.column.svelte-3gk7u7{display:flex;gap:.5rem;align-items:center;min-height:2.75rem;margin:0;padding:0 .75rem;font-weight:400;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.column[data-on=true].svelte-3gk7u7{background:var(--ready-wash);border-color:var(--ink-muted);font-weight:700}@media(hover:hover){.column.svelte-3gk7u7:hover{border-color:var(--ink-muted)}}.column.svelte-3gk7u7 input:where(.svelte-3gk7u7){width:1.25rem;height:1.25rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0;accent-color:var(--focus)}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0 0 .25rem}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7):last-child{margin-bottom:0}.probes.svelte-3gk7u7{position:relative;height:0;overflow:hidden}.probe.svelte-3gk7u7{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none}.grid-probe.svelte-3gk7u7{display:grid;width:calc(100vw - var(--touch-gap) * 2);grid-auto-rows:minmax(var(--tile-height),auto);gap:var(--touch-gap);align-content:start}.calibration-probe.svelte-3gk7u7{width:var(--tile-min)}.viewport-probe.svelte-3gk7u7{height:calc(100vh - var(--banner-height) - var(--category-height) - var(--status-height) - var(--touch-gap) * 2)}.key.svelte-3gk7u7{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.scroll.svelte-3gk7u7{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-3gk7u7{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7):first-child{border-top:none}.line.svelte-3gk7u7,.id.svelte-3gk7u7,.value.svelte-3gk7u7{color:var(--ink-muted)}.line.svelte-3gk7u7{flex:none;width:7rem}.what.svelte-3gk7u7{font-weight:700}.message.svelte-3gk7u7{flex:1 1 20rem}.pick.svelte-3gk7u7{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-3gk7u7:hover:not(:disabled){background:var(--surface)}}.search.svelte-3gk7u7,label.svelte-3gk7u7{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}.choice.svelte-3gk7u7{display:flex;flex-direction:column}.choice.svelte-3gk7u7 label:where(.svelte-3gk7u7){display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin:0;font-weight:400}.choice.svelte-3gk7u7 input:where(.svelte-3gk7u7){width:1.5rem;height:1.5rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0}input.svelte-3gk7u7{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.decision.svelte-3gk7u7{margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border)}.act-block.svelte-3gk7u7{margin-top:1rem;padding:.75rem 1rem 1rem;background:var(--bg);border-radius:var(--radius)}.act-block.svelte-3gk7u7 .what:where(.svelte-3gk7u7){margin:0;font-size:1.0625rem;font-weight:700}.drop.svelte-3gk7u7{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius);transition:border-color var(--tap) var(--ease),background-color var(--tap) var(--ease)}.drop.dropping.svelte-3gk7u7{border-color:var(--focus);background:var(--waiting-wash)}.drop.working.svelte-3gk7u7{border-color:var(--waiting);background:var(--waiting-wash)}.drop.svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0;font-size:1.125rem}.choose.svelte-3gk7u7{display:inline-flex;align-items:center;margin:0;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease)}.choose.svelte-3gk7u7:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){.choose.svelte-3gk7u7:active{transform:none}}@media(hover:hover){.choose.svelte-3gk7u7:hover{filter:brightness(.92)}}.choose.svelte-3gk7u7 input:where(.svelte-3gk7u7){display:none}table.svelte-3gk7u7{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-3gk7u7,td.svelte-3gk7u7{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}.why.svelte-3gk7u7{white-space:normal;min-width:16rem;color:var(--ink-muted)}th.svelte-3gk7u7{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.light.svelte-1421wa8{display:flex;flex-direction:column;gap:.375rem;padding:.875rem 1rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--waiting);border-radius:var(--radius)}.light[data-level=ok].svelte-1421wa8{border-left-color:var(--ready)}.light[data-level=warn].svelte-1421wa8{border-left-color:var(--warning)}.light[data-level=fault].svelte-1421wa8{border-left-color:var(--fault)}header.svelte-1421wa8{display:flex;align-items:center;gap:.5rem}h3.svelte-1421wa8{margin:0;font-size:1.25rem;font-weight:700}.dot.svelte-1421wa8{width:1rem;height:1rem;border-radius:50%;background:var(--waiting);flex:none}.dot[data-level=ok].svelte-1421wa8{background:var(--ready)}.dot[data-level=warn].svelte-1421wa8{background:var(--warning)}.dot[data-level=fault].svelte-1421wa8{background:var(--fault)}.dot[data-level=unknown].svelte-1421wa8,.dot[data-level=off].svelte-1421wa8{background:transparent;box-shadow:inset 0 0 0 3px var(--waiting)}.verdict.svelte-1421wa8{margin-left:auto;font-size:1rem;font-weight:700;color:var(--ink-muted);letter-spacing:.03em}.value.svelte-1421wa8{margin:0;font-size:1.125rem}.remedy.svelte-1421wa8{margin:0;font-size:1rem;color:var(--ink-muted)}.dashboard.svelte-1w44m0y{display:flex;flex-direction:column;gap:1rem}.link.svelte-1w44m0y{padding:0;font:inherit;color:inherit;text-decoration:underline;background:none;border:none;cursor:pointer}.lights.svelte-1w44m0y{display:grid;grid-template-columns:repeat(auto-fit,minmax(20rem,1fr));grid-auto-rows:1fr;gap:var(--touch-gap)}.fact.svelte-1w44m0y{margin:0 0 .5rem;font-size:1.125rem;overflow-wrap:break-word}.remedy.svelte-1w44m0y,.count.svelte-1w44m0y{color:var(--ink-muted);font-size:1rem}.restart.svelte-1w44m0y strong:where(.svelte-1w44m0y){letter-spacing:.03em}.restart[data-verdict=missing].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--warning);background:var(--warning-wash)}.restart[data-verdict=unknown].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--waiting);background:var(--waiting-wash)}.decisions.svelte-1w44m0y,.events.svelte-1w44m0y{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.375rem}.decisions.svelte-1w44m0y li:where(.svelte-1w44m0y),.events.svelte-1w44m0y li:where(.svelte-1w44m0y){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.what.svelte-1w44m0y,.message.svelte-1w44m0y{font-weight:700}.detail.svelte-1w44m0y,.time.svelte-1w44m0y,.source.svelte-1w44m0y,.code.svelte-1w44m0y{color:var(--ink-muted)}.events.svelte-1w44m0y li[data-level=error]:where(.svelte-1w44m0y){border-left:.25rem solid var(--fault);padding-left:.5rem}.events.svelte-1w44m0y li[data-level=warn]:where(.svelte-1w44m0y){border-left:.25rem solid var(--warning);padding-left:.5rem}.identity.svelte-1w44m0y{margin:0;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.125rem}.identity.svelte-1w44m0y dt:where(.svelte-1w44m0y){color:var(--ink-muted)}.identity.svelte-1w44m0y dd:where(.svelte-1w44m0y){margin:0;font-weight:700}.pages.svelte-1p4i0xm{display:flex;flex-direction:column;gap:1rem}.panel.svelte-1p4i0xm{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1)}.head.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:space-between}h2.svelte-1p4i0xm{margin:0;font-size:1.5rem;font-weight:700}.standing.svelte-1p4i0xm{display:inline-flex;align-items:center;gap:.5rem;height:2rem;padding:0 .75rem;border-radius:var(--radius-pill);background:var(--waiting-wash);font-size:1rem;font-weight:700}.standing[data-level=ok].svelte-1p4i0xm{background:var(--ready-wash)}.standing[data-level=warn].svelte-1p4i0xm{background:var(--warning-wash)}.standing[data-level=fault].svelte-1p4i0xm{background:var(--fault-wash)}.dot.svelte-1p4i0xm{width:.625rem;height:.625rem;border-radius:var(--radius-pill);background:var(--waiting)}.standing[data-level=ok].svelte-1p4i0xm .dot:where(.svelte-1p4i0xm){background:var(--ready)}.standing[data-level=warn].svelte-1p4i0xm .dot:where(.svelte-1p4i0xm){background:var(--warning)}.standing[data-level=fault].svelte-1p4i0xm .dot:where(.svelte-1p4i0xm){background:var(--fault)}.fact.svelte-1p4i0xm{margin:.75rem 0 0;font-size:1.125rem}.note.svelte-1p4i0xm,.count.svelte-1p4i0xm,.waiting.svelte-1p4i0xm{margin:.25rem 0 0;font-size:1rem;color:var(--ink-muted)}.check.svelte-1p4i0xm{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin-top:.5rem;font-size:1.0625rem}.check.svelte-1p4i0xm input:where(.svelte-1p4i0xm){width:1.5rem;height:1.5rem;flex:0 0 auto}.check.svelte-1p4i0xm small:where(.svelte-1p4i0xm){display:block;font-size:1rem;color:var(--ink-muted)}.actions.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.5rem;margin:.75rem 0 0}@media(hover:hover){.pick.svelte-1p4i0xm:hover,summary.svelte-1p4i0xm:hover{background:var(--bg)}}.list.svelte-1p4i0xm{margin:.25rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column}.list.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.125rem 0;border-top:1px solid var(--border-soft);font-size:1.0625rem}.list.svelte-1p4i0xm li.refused:where(.svelte-1p4i0xm){padding-left:.5rem;border-left:.25rem solid var(--fault);background:var(--fault-wash)}.pick.svelte-1p4i0xm{height:2.75rem;padding:0 .75rem;font-weight:700;text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}.what.svelte-1p4i0xm{font-weight:700}.detail.svelte-1p4i0xm{color:var(--ink-muted)}.folded.svelte-1p4i0xm{margin-top:1rem;border:1px solid var(--border);border-radius:var(--radius)}summary.svelte-1p4i0xm{display:flex;align-items:center;height:2.75rem;padding:0 .75rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius);cursor:pointer;transition:background-color var(--tap) var(--ease),transform var(--tap) var(--ease)}summary.svelte-1p4i0xm:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){summary.svelte-1p4i0xm:active{transform:none}}.frames.svelte-1p4i0xm{margin-top:1rem;max-height:18rem;overflow-y:auto;background:var(--waiting-wash);border:1px solid var(--border);border-radius:var(--radius)}.frames-head.svelte-1p4i0xm{position:sticky;top:0;margin:0;padding:.5rem .75rem;background:var(--surface);border-radius:var(--radius) var(--radius) 0 0;box-shadow:var(--shadow-2);font-size:1rem;color:var(--ink-muted)}.interrupted.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:0;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);font-size:1rem}.frame-rows.svelte-1p4i0xm{margin:0;padding:.5rem .75rem;list-style:none;display:flex;flex-direction:column;gap:.25rem}.frame-rows.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:.75rem;font-size:.9375rem}.hex.svelte-1p4i0xm,.decoded.svelte-1p4i0xm{overflow-x:auto;white-space:pre}.decoded.svelte-1p4i0xm{color:var(--ink-muted)}div.journal.svelte-86o8oz{display:flex;flex-direction:column;gap:1rem;max-width:none;--reading-column: 68rem}.filters.svelte-86o8oz{display:flex;flex-wrap:wrap;gap:var(--touch-gap);align-items:center;margin-bottom:.75rem;font-size:1.0625rem}.filters.svelte-86o8oz label:where(.svelte-86o8oz){font-weight:700}select.svelte-86o8oz{min-height:2.75rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.export.svelte-86o8oz{display:inline-flex;align-items:center;gap:.5rem;height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.export.svelte-86o8oz:active{transform:scale(.975)}@media(hover:hover){.export.svelte-86o8oz:hover{border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.replay.svelte-86o8oz{margin-top:.5rem}.fact.svelte-86o8oz{margin:.5rem 0;max-width:var(--reading-column);font-size:1.125rem}.muted.svelte-86o8oz{color:var(--ink-muted);font-size:1rem}.table-box.svelte-86o8oz,.lines-box.svelte-86o8oz{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm)}.table-box.svelte-86o8oz{max-height:34rem}.lines-box.svelte-86o8oz{max-height:28rem}table.svelte-86o8oz{border-collapse:collapse;min-width:100%;font-size:1.0625rem}th.svelte-86o8oz,td.svelte-86o8oz{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}th.svelte-86o8oz{position:sticky;top:0;z-index:1;color:var(--ink-muted);font-size:1rem;background:var(--bg)}tbody.svelte-86o8oz tr:where(.svelte-86o8oz){transition:background-color var(--tap) var(--ease)}@media(hover:hover){tbody.svelte-86o8oz tr:where(.svelte-86o8oz):hover{background:var(--surface)}}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz){background:var(--surface);font-weight:700}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz)>td:where(.svelte-86o8oz):first-child{box-shadow:inset .1875rem 0 0 0 var(--focus)}.pick.svelte-86o8oz{height:2.75rem;padding:0 .5rem;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-86o8oz:hover:not(:disabled){background:var(--surface)}}.detail-row.svelte-86o8oz{background:var(--surface)}.detail-cell.svelte-86o8oz{padding:.75rem 1rem 1rem;white-space:normal;box-shadow:inset .1875rem 0 0 0 var(--focus)}.detail-cell.svelte-86o8oz h3:where(.svelte-86o8oz){margin:0 0 .5rem;font-size:1.25rem}.detail-cell.svelte-86o8oz dl:where(.svelte-86o8oz){display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;max-width:var(--reading-column);margin:0;font-size:1.0625rem;font-weight:400}.detail-cell.svelte-86o8oz dt:where(.svelte-86o8oz){color:var(--ink-muted)}.detail-cell.svelte-86o8oz dd:where(.svelte-86o8oz){margin:0}.detail-cell.svelte-86o8oz code:where(.svelte-86o8oz){overflow-wrap:anywhere}.lines.svelte-86o8oz{margin:0;padding:0 .75rem;list-style:none}.lines.svelte-86o8oz li:where(.svelte-86o8oz){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.lines.svelte-86o8oz li:where(.svelte-86o8oz):first-child{border-top:none}.lines.svelte-86o8oz li[data-level=error]:where(.svelte-86o8oz),.lines.svelte-86o8oz li[data-level=critical]:where(.svelte-86o8oz){border-left:.25rem solid var(--fault);padding-left:.5rem}.lines.svelte-86o8oz li[data-level=warn]:where(.svelte-86o8oz){border-left:.25rem solid var(--warning);padding-left:.5rem}.when.svelte-86o8oz,.level.svelte-86o8oz,.from.svelte-86o8oz,.code.svelte-86o8oz,.detail-text.svelte-86o8oz{color:var(--ink-muted)}.level.svelte-86o8oz{flex:none;width:8rem}.message.svelte-86o8oz{flex:1 1 20rem;font-weight:700}.sr-only.svelte-86o8oz{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.pages.svelte-llv1r8{display:flex;flex-direction:column;gap:1rem}.stale.svelte-llv1r8{margin:0 0 .75rem;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);border-radius:var(--radius-sm);font-size:1.0625rem}.preview.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:1.5rem;align-items:flex-start}.sheet.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0;padding:.75rem;background:var(--waiting-wash);border-radius:var(--radius-lg)}img.svelte-llv1r8{width:40rem;max-width:100%;image-rendering:pixelated;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-1)}.refused.svelte-llv1r8{margin:0;padding:.5rem .75rem;background:var(--fault-wash);border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);font-size:1.0625rem}.nudges.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0}.axis.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0;font-size:1.125rem}.pair.svelte-llv1r8{display:flex;gap:var(--touch-gap)}.fault.svelte-llv1r8{margin:0;padding-left:.5rem;border-left:.25rem solid var(--fault);font-size:1rem;color:var(--ink-muted)}.check.svelte-llv1r8{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;font-size:1.0625rem}.check.svelte-llv1r8 input:where(.svelte-llv1r8){width:1.5rem;height:1.5rem;flex:0 0 auto}.truncation.svelte-llv1r8,.absent.svelte-llv1r8,.waiting.svelte-llv1r8,.bound.svelte-llv1r8,.cost.svelte-llv1r8{margin:1rem 0 0;font-size:1rem;color:var(--ink-muted)}.absent.svelte-llv1r8{margin-top:.5rem}.bound.svelte-llv1r8{margin-top:.25rem}.cost.svelte-llv1r8{margin-top:.5rem}.actions.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin-top:.75rem}.pages.svelte-hzfkjn{display:flex;flex-direction:column;gap:1rem}.fact.svelte-hzfkjn{margin:.5rem 0;font-size:1.125rem}.muted.svelte-hzfkjn,.locked.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem}h3.svelte-hzfkjn{margin:1.5rem 0 .5rem;font-size:1.25rem}.scroll.svelte-hzfkjn{overflow-x:auto}table.svelte-hzfkjn{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-hzfkjn,td.svelte-hzfkjn{padding:.375rem .5rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem}input.svelte-hzfkjn{min-height:2.75rem;width:100%;min-width:6rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.rules.svelte-hzfkjn{margin:.75rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.75rem}.rule.svelte-hzfkjn{padding:.75rem 1rem 1rem;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm)}.rule-head.svelte-hzfkjn{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0}.rank.svelte-hzfkjn{flex:none;min-width:1.75rem;color:var(--ink-muted);font-variant-numeric:tabular-nums;font-weight:700}.rule-label.svelte-hzfkjn{font-size:1.125rem;font-weight:700}.token.svelte-hzfkjn{color:var(--ink-muted);font-size:.9375rem}.severity.svelte-hzfkjn{margin-left:auto;padding:.125rem .625rem;font-size:.9375rem;border-radius:var(--radius-pill);background:var(--waiting-wash)}.severity[data-blocking=true].svelte-hzfkjn{background:var(--warning-wash)}.when.svelte-hzfkjn{margin:.375rem 0 0;font-size:1rem}.quote.svelte-hzfkjn{margin:.5rem 0 0;padding:.5rem .75rem;font-size:1.0625rem;background:var(--surface);border-radius:var(--radius-sm);box-shadow:var(--shadow-1)}.quote.silent.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem;box-shadow:none;background:var(--waiting-wash)}.note.svelte-hzfkjn{margin:.5rem 0 0;font-size:1rem;color:var(--ink-muted)}.toggle.svelte-hzfkjn{display:flex;align-items:center;gap:.75rem;min-height:2.75rem;margin:.5rem 0 0;padding:.375rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.toggle[data-on=true].svelte-hzfkjn{background:var(--ready-wash)}@media(hover:hover){.toggle.svelte-hzfkjn:hover{border-color:var(--ink-muted)}}.toggle.svelte-hzfkjn input:where(.svelte-hzfkjn){flex:none;width:1.5rem;height:1.5rem;min-height:0;min-width:0;padding:0;accent-color:var(--focus)}.toggle-text.svelte-hzfkjn{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline}.toggle-label.svelte-hzfkjn{font-size:1.0625rem;font-weight:700}.hint.svelte-hzfkjn{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.box.svelte-hzfkjn{display:contents}.verdicts.svelte-hzfkjn{margin:0;padding:0;list-style:none}.waivers.svelte-hzfkjn{max-height:24rem;overflow-y:auto}.verdicts.svelte-hzfkjn li:where(.svelte-hzfkjn){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.verdicts.svelte-hzfkjn li[data-blocking=true]:where(.svelte-hzfkjn){border-left:.25rem solid var(--warning);background:var(--warning-wash);padding-left:.5rem}.what.svelte-hzfkjn,.message.svelte-hzfkjn{font-weight:700}.detail.svelte-hzfkjn{color:var(--ink-muted)}.dead.svelte-hzfkjn{flex:1 1 20rem;padding:.125rem .625rem;font-size:1rem;background:var(--warning-wash);border-radius:var(--radius-sm)}.unread.svelte-hzfkjn{margin:.5rem 0;padding:.5rem .75rem;font-size:1rem;background:var(--fault-wash);border-left:.25rem solid var(--fault);border-radius:var(--radius-sm)}dd.svelte-mgmug7{margin:0 0 1.25rem;display:flex;flex-direction:column;align-items:flex-start;gap:.5rem}.faults.svelte-mgmug7{margin-top:.75rem;padding:.5rem 1rem .5rem 2rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-mgmug7 li:where(.svelte-mgmug7){margin-bottom:.25rem}.pages.svelte-18wbxwu{display:flex;flex-direction:column;gap:1rem}.fact.svelte-18wbxwu{margin:.5rem 0;font-size:1.125rem}.muted.svelte-18wbxwu{color:var(--ink-muted);font-size:1rem}.filename.svelte-18wbxwu{margin-top:1rem;font-weight:700}.identity.svelte-18wbxwu{margin:1rem 0 0;padding:.75rem 1rem;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.0625rem;background:var(--bg);border-radius:var(--radius)}.identity.svelte-18wbxwu dt:where(.svelte-18wbxwu){color:var(--ink-muted)}.identity.svelte-18wbxwu dd:where(.svelte-18wbxwu){margin:0;font-weight:700}.actions.svelte-18wbxwu{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.choose.svelte-18wbxwu{display:inline-flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.choose.svelte-18wbxwu:active:not(.off){transform:scale(.975)}@media(hover:hover){.choose.svelte-18wbxwu:hover:not(.off){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.choose.off.svelte-18wbxwu{opacity:.5;box-shadow:none;cursor:default}.choose.working.svelte-18wbxwu{opacity:1;border-color:var(--waiting)}.choose.svelte-18wbxwu input:where(.svelte-18wbxwu){display:none}.key.svelte-18wbxwu{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.same.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--ready);border-radius:var(--radius-sm);background:var(--ready-wash)}.warned.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);background:var(--fault-wash)}.faults.svelte-18wbxwu{margin-top:.75rem;padding:.25rem 1rem .5rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-18wbxwu ul:where(.svelte-18wbxwu){margin:0;padding-left:1.25rem;font-size:1.0625rem}.allowed.svelte-18wbxwu{display:block;color:var(--ink-muted);font-size:1rem}.scroll.svelte-18wbxwu{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}table.svelte-18wbxwu{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-18wbxwu,td.svelte-18wbxwu{padding:.375rem .75rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-18wbxwu{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.rows.svelte-18wbxwu{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.5rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu):first-child{border-top:none}.what.svelte-18wbxwu{font-weight:700}.detail.svelte-18wbxwu{color:var(--ink-muted)}.big.svelte-15nlt6i{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.write.svelte-15nlt6i{color:var(--surface);background:var(--action);border-color:var(--action)}.destructive.svelte-15nlt6i{color:var(--surface);background:var(--danger);border-color:var(--danger)}.write.svelte-15nlt6i .hint:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .hint:where(.svelte-15nlt6i){color:var(--surface);opacity:.85}.write.svelte-15nlt6i .guarded:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .guarded:where(.svelte-15nlt6i){background:var(--surface);color:var(--ink)}.big.svelte-15nlt6i:disabled{opacity:.5}.big.busy.svelte-15nlt6i{opacity:1;border-color:var(--waiting);background:var(--waiting-wash)}.big.busy.write.svelte-15nlt6i{background:var(--action)}.big.busy.destructive.svelte-15nlt6i{background:var(--danger)}.guarded.svelte-15nlt6i{margin-left:.5rem;padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;vertical-align:middle}.big.engaged.svelte-15nlt6i{border-left:.5rem solid var(--warning)}.label.svelte-15nlt6i{font-size:1.375rem;font-weight:700}.hint.svelte-15nlt6i{font-size:1rem;color:var(--ink-muted)}.troubleshooting.svelte-1yd0nzg{display:flex;flex-direction:column;gap:1rem}.buttons.svelte-1yd0nzg{display:grid;grid-template-columns:repeat(auto-fit,minmax(22rem,1fr));gap:var(--touch-gap)}.big.svelte-1yd0nzg{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.label.svelte-1yd0nzg{font-size:1.375rem;font-weight:700}.hint.svelte-1yd0nzg{font-size:1rem;color:var(--ink-muted)}.report.svelte-1yd0nzg{margin:0;padding:.75rem 1rem;font-size:1.125rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--ready);border-radius:var(--radius)}.drop.svelte-1yd0nzg{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius)}.drop.dropping.svelte-1yd0nzg{border-color:var(--focus)}.drop.svelte-1yd0nzg p:where(.svelte-1yd0nzg){margin:0;font-size:1.125rem}.key.svelte-1yd0nzg{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.choose.svelte-1yd0nzg{display:inline-flex;align-items:center;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius);cursor:pointer}@media(hover:hover){.choose.svelte-1yd0nzg:hover{filter:brightness(.92)}}.choose.svelte-1yd0nzg input:where(.svelte-1yd0nzg){display:none}.fact.svelte-1yd0nzg{margin:0 0 .5rem;font-size:1.125rem}.muted.svelte-1yd0nzg{color:var(--ink-muted)}.row.svelte-o9s8nz{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem}.admin.svelte-9b2mjq{position:fixed;inset:0;z-index:90;display:grid;grid-template-columns:16rem 1fr;background:var(--bg);color:var(--ink);overflow:hidden}.rail.svelte-9b2mjq{display:flex;flex-direction:column;gap:.125rem;padding:1rem .75rem;overflow-y:auto;background:var(--surface);border-right:1px solid var(--border)}h1.svelte-9b2mjq{margin:0 .5rem 1rem;font-size:1.25rem;letter-spacing:-.01em}.group.svelte-9b2mjq{margin:1rem .5rem .375rem;color:var(--ink-muted);font-size:.8125rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase}.entry.svelte-9b2mjq{padding:0 .75rem;height:2.5rem;display:flex;align-items:center;text-align:left;font-size:1.0625rem;border-radius:var(--radius-sm);color:var(--ink-muted);transition:background-color var(--tap) var(--ease),color var(--tap) var(--ease)}@media(hover:hover){.entry.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.entry.current.svelte-9b2mjq{background:var(--bg);color:var(--ink);font-weight:700;box-shadow:inset .1875rem 0 0 0 var(--focus)}.foot.svelte-9b2mjq{margin-top:auto;padding:1rem .5rem 0;border-top:1px solid var(--border)}.station.svelte-9b2mjq{margin:0;font-size:1rem;font-weight:600}.build.svelte-9b2mjq{margin:.125rem 0 0;font-size:.8125rem;color:var(--ink-muted)}.technical.svelte-9b2mjq{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;margin-top:.75rem;color:var(--ink-muted);font-size:.9375rem}.technical.svelte-9b2mjq input:where(.svelte-9b2mjq){width:1.5rem;height:1.5rem;flex:0 0 auto}.back.svelte-9b2mjq{margin-top:.75rem;padding:0 .5rem;height:2.25rem;font-size:.9375rem;color:var(--ink-muted);border-radius:var(--radius-sm)}@media(hover:hover){.back.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.body.svelte-9b2mjq{display:flex;flex-direction:column;min-width:0;overflow:hidden}.page.svelte-9b2mjq{flex:1;min-height:0;overflow-y:auto;padding:1.5rem 2rem 3rem}.page.svelte-9b2mjq>*{max-width:68rem}.page-title.svelte-9b2mjq{margin:0 0 1.25rem;font-size:1.75rem;letter-spacing:-.01em}.waiting.svelte-9b2mjq{margin:0;font-size:1.125rem;color:var(--ink-muted)}.banner.svelte-9b2mjq{flex:none;margin:0;padding:.75rem 2rem;font-size:1.0625rem;background:var(--surface);border-bottom:1px solid var(--border)}.notice.svelte-9b2mjq{border-left:.375rem solid var(--ready);background:var(--ready-wash)}.failure.svelte-9b2mjq{border-left:.375rem solid var(--fault);background:var(--fault-wash)}.pending.svelte-9b2mjq{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;border-left:.375rem solid var(--warning);background:var(--warning-wash)}.pending.svelte-9b2mjq p:where(.svelte-9b2mjq){margin:0;flex:1 1 24rem}.save-bar.svelte-9b2mjq{flex:none;padding:.75rem 2rem;background:var(--surface);border-top:1px solid var(--border);box-shadow:var(--shadow-1)}.retired.svelte-9b2mjq{margin:0 0 .5rem;font-size:.9375rem;color:var(--ink-muted)}.faults.svelte-9b2mjq{margin:0 0 .5rem;padding-left:1.25rem;font-size:1rem} diff --git a/internal/web/dist/assets/mount-DmM_mBVD.js b/internal/web/dist/assets/mount-DmM_mBVD.js new file mode 100644 index 0000000..c4a3597 --- /dev/null +++ b/internal/web/dist/assets/mount-DmM_mBVD.js @@ -0,0 +1,99 @@ +import{X as Pr,Y as Tr,Z as zr,_ as Ea,$ as Jt,e as jr,a0 as ir,a1 as Rr,a2 as Or,a3 as Ar,u as ma,a4 as Ca,a5 as Nr,a6 as or,x as qt,A as G,h as e,z as v,H as ft,d as ct,a as Be,s as n,i as _,t as f,l as Mt,j as he,m as o,b as Ce,c as l,o as s,g as c,p as Me,I as Ir,f as We,n as u,q as te,a7 as Ia,r as Te,G as Zt,a8 as ta,a9 as Rt,B as da,aa as ot,ab as Xt,ac as ka,C as Dr,J as ur,y as sa,T as Fr,ad as Kt,F as Ur,E as Mr,D as Wr,M as Br,K as Vr,W as Gr,ae as Hr}from"./app-CFgWi82J.js";function cr(a){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function Jr(a,t,...r){var i=new zr(a);Pr(()=>{const d=t()??null;i.ensure(d,d&&(p=>d(p,...r)))},Tr)}function Pa(a,t,r=!1){if(a.multiple){if(t==null)return;if(!Rr(t))return Or();for(var i of a.options)i.selected=t.includes(la(i));return}for(i of a.options){var d=la(i);if(Ar(d,t)){i.selected=!0;return}}(!r||t!==void 0)&&(a.selectedIndex=-1)}function dr(a){var t=new MutationObserver(()=>{"__value"in a&&Pa(a,a.__value)});t.observe(a,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),ir(()=>{t.disconnect()})}function Kr(a,t,r=t){var i=new WeakSet,d=!0;Ea(a,"change",p=>{var k=p?"[selected]":":checked",T;if(a.multiple)T=[].map.call(a.querySelectorAll(k),la);else{var m=a.querySelector(k)??a.querySelector("option:not([disabled])");T=m&&la(m)}r(T),a.__value=T,Jt!==null&&i.add(Jt)}),jr(()=>{var p=t();if(a===document.activeElement){var k=Jt;if(i.has(k))return}if(Pa(a,p,d),d&&p===void 0){var T=a.querySelector(":checked");T!==null&&(p=la(T),r(p))}a.__value=p,d=!1}),dr(a)}function la(a){return"__value"in a?a.__value:a.value}function ia(a,t,r=t){var i=new WeakSet;Ea(a,"input",async d=>{var p=d?a.defaultValue:a.value;if(p=ba(a)?wa(p):p,r(p),Jt!==null&&i.add(Jt),await Nr(),p!==(p=t())){var k=a.selectionStart,T=a.selectionEnd,m=a.value.length;if(a.value=p??"",T!==null){var j=a.value.length;k===T&&T===m&&j>m?(a.selectionStart=j,a.selectionEnd=j):(a.selectionStart=k,a.selectionEnd=Math.min(T,j))}}}),ma(t)==null&&a.value&&(r(ba(a)?wa(a.value):a.value),Jt!==null&&i.add(Jt)),Ca(()=>{var d=t();if(a===document.activeElement){var p=Jt;if(i.has(p))return}ba(a)&&d===wa(a.value)||a.type==="date"&&!d&&!a.value||d!==a.value&&(a.value=d??"")})}function Da(a,t,r=t){Ea(a,"change",i=>{var d=i?a.defaultChecked:a.checked;r(d)}),ma(t)==null&&r(a.checked),Ca(()=>{var i=t();a.checked=!!i})}function ba(a){var t=a.type;return t==="number"||t==="range"}function wa(a){return a===""?null:+a}function Fa(a,t,r,i,d){var p=()=>{i(r[a])};r.addEventListener(t,p),d?Ca(()=>{r[a]=d()}):p(),(r===document.body||r===window||r===document)&&ir(()=>{r.removeEventListener(t,p)})}function Ta(a){or===null&&cr(),qt(()=>{const t=ma(a);if(typeof t=="function")return t})}function $r(a){or===null&&cr(),Ta(()=>()=>ma(a))}const Yr="ERR-CFG-02";class zt extends Error{constructor(t,r,i="",d=[]){super(r),this.status=t,this.code=i,this.faults=d,this.name="AdminError"}status;code;faults;get needsPassword(){return this.status===401}get needsFirstPassword(){return this.status===409&&this.code===Yr}get needsCredentials(){return this.needsPassword||this.needsFirstPassword}}function fa(a){return a instanceof zt&&a.needsCredentials}function Xr(){return Wt("/admin/api/health")}function Qr(){return He("/admin/api/troubleshooting/reprint",{})}function Zr(){return He("/admin/api/troubleshooting/reload-catalog",{})}function en(a){return He("/admin/api/troubleshooting/manual-entry",{on:a})}function tn(){return He("/admin/api/troubleshooting/roll-changed",{})}function an(a){return He("/admin/api/troubleshooting/fallback-printer",{on:a})}function rn(){return He("/admin/api/troubleshooting/test-scale",{})}function nn(){return He("/admin/api/troubleshooting/test-printer",{})}function sn(){return He("/admin/api/troubleshooting/test-label",{})}async function vr(a){const t=new FormData;t.append("file",a,a.name);const r=await fetch("/admin/api/catalog/import",{method:"POST",body:t});return Ra(r,"POST /admin/api/catalog/import")}const ln="/admin/api/diagnostic.zip";function on(a){return He("/admin/api/session",{password:a})}async function un(){await fetch("/admin/api/session",{method:"DELETE"})}function cn(a,t){return He("/admin/api/session/recovery",{code:a,password:t})}function za(){return Wt("/admin/api/config")}function dn(a){return ja("PUT","/admin/api/config",a)}function vn(){return He("/admin/api/config/reload",{})}function pn(){return He("/admin/api/restart",{})}function fn(){return He("/admin/api/reboot",{})}function gn(){return ja("DELETE","/admin/api/reboot",{})}function mn(){return He("/admin/api/config/confirm",{})}async function hn(){return(await Wt("/admin/api/config/versions")).versions}function _n(a){return He("/admin/api/config/restore",{version:a})}async function bn(){return(await Wt("/admin/api/ports")).ports}async function wn(){return(await Wt("/admin/api/printers")).printers}async function yn(){return(await He("/admin/api/printers/discover",{})).printers}function xn(a){return He("/admin/api/scale/detect",{port:a})}async function Ua(a,t){return(await He("/admin/api/scale/capture",{port:a,seconds:t})).frames}function pr(a){return He("/admin/api/printer/test?what="+a,{})}function kn(a,t,r,i){const d=new URLSearchParams({template:a,demo:t?"1":"0",dual:r?"1":"0"});return d.set("t",String(i)),"/admin/api/label/preview.png?"+d.toString()}async function qn(a){const t=new URLSearchParams(a);return(await Wt("/admin/api/journal?"+t)).weighings}function Sn(a){return"/admin/api/journal/export.csv?"+new URLSearchParams(a).toString()}async function Ln(a){const t=new URLSearchParams(a);return(await Wt("/admin/api/technical?"+t)).entries}function En(a){const t=a===void 0?"":"?id="+String(a);return Wt("/admin/api/imports"+t)}function Cn(){return He("/admin/api/catalog/reload",{})}function Pn(){return He("/admin/api/catalog/forget-quarantine",{})}function Ma(a,t){return He("/admin/api/products/"+encodeURIComponent(a)+"/decision",t)}function Tn(a){return He("/admin/api/replay",{frame:a})}async function Wt(a){const t=await fetch(a,{headers:{accept:"application/json"}});return Ra(t,"GET "+a)}function He(a,t){return ja("POST",a,t)}async function ja(a,t,r){const i=await fetch(t,{method:a,headers:{"content-type":"application/json"},body:JSON.stringify(r)});return Ra(i,a+" "+t)}async function Ra(a,t){const r=await a.text();if(!a.ok){const i=fr(r);throw new zt(a.status,jn(r,t,a.status)+zn(a),i?.code??"",i?.faults??[])}if(r!=="")return JSON.parse(r)}function zn(a){if(a.status!==429)return"";const t=Number(a.headers.get("Retry-After")??"");if(!Number.isFinite(t)||t<=0)return"";const r=Math.ceil(t/60);return r>1?` Réessayez dans ${String(r)} minutes.`:" Réessayez dans une minute."}function jn(a,t,r){const i=fr(a);return i?.message!==void 0&&i.message!==""?i.message:`${t} a répondu ${String(r)}.`}function fr(a){try{return JSON.parse(a)}catch{return null}}function Rn(){return Wt("/admin/api/update")}function On(){return He("/admin/api/update/check",{})}function An(a){return He("/admin/api/update/apply",{version:a})}class Nn{constructor(t){this.admin=t}admin;#t=G(null);get config(){return e(this.#t)}set config(t){v(this.#t,t,!0)}#r=G("");get fingerprint(){return e(this.#r)}set fingerprint(t){v(this.#r,t,!0)}#a=G(ft([]));get retired(){return e(this.#a)}set retired(t){v(this.#a,t,!0)}#e=G(null);get pending(){return e(this.#e)}set pending(t){v(this.#e,t,!0)}#n=G(ft([]));get faults(){return e(this.#n)}set faults(t){v(this.#n,t,!0)}#l=G(!1);get dirty(){return e(this.#l)}set dirty(t){v(this.#l,t,!0)}async load(){const t=await this.admin.load(()=>za());t!==null&&(this.config=t.config,this.fingerprint=t.config_fingerprint,this.retired=t.retired_keys??[],this.pending=t.pending_confirmation,this.faults=[],this.dirty=!1)}value(t){let r=this.config;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}text(t){const r=this.value(t);return r==null?"":String(r)}number(t){const r=this.value(t);return typeof r=="number"?r:Number(r??0)}flag(t){return this.value(t)===!0}set(t,r){if(this.config===null)return;const i=t.split("."),d=i.pop();if(d===void 0)return;let p=this.config;for(const k of i){const T=p[k];(T===null||typeof T!="object")&&(p[k]={}),p=p[k]}p[d]=r,this.dirty=!0}unset(t){const r=t.split("."),i=r.pop();if(i===void 0||this.config===null)return!1;let d=this.config;for(const p of r){if(d===null||typeof d!="object")return!1;d=d[p]}return d===null||typeof d!="object"?!1:(delete d[i],this.dirty=!0,!0)}async save(){if(this.config===null)return!1;this.faults=[];const t=this.config;let r;try{r=await dn(t)}catch(i){if(fa(i))throw i;return this.admin.report(i),this.faults=In(this.admin),!1}return this.config=r.config,this.fingerprint=r.config_fingerprint,this.retired=r.retired_keys??[],this.pending=r.pending_confirmation,this.dirty=!1,!0}async confirm(){try{await mn()}catch(t){if(fa(t))throw t;this.admin.report(t);return}this.admin.notice="La configuration est confirmée.",this.pending=null,await this.admin.refresh()}dropRetired(t){if(t.includes("[")){this.admin.actionError=`« ${t} » est logée dans un tableau : cet écran ne sait pas la retirer de là. Modifiez le fichier de configuration lui-même.`;return}this.unset(t)&&(this.retired=this.retired.filter(r=>r!==t))}}function In(a){return a.lastFaults}const Dn={"station.number":"Numéro du poste","station.name":"Nom du poste","station.coop":"Nom de la coopérative","network.listen":"Adresse d’écoute","network.admin_on_lan":"Administration accessible depuis le réseau","ui.language":"Langue","ui.sound":"Son","ui.idle_timeout_s":"Retour à l’accueil après (secondes)","ui.reprint_window_s":"Réimpression possible pendant (secondes)","ui.show_grid_prices":"Afficher les prix sur les tuiles","ui.show_by_unit_products":"Afficher les produits vendus à l’unité","ui.grid_columns":"Colonnes de la grille","scale.type":"Protocole de la balance","scale.present":"Ce poste a une balance","scale.manual_entry_allowed":"Saisie manuelle autorisée","scale.degrade_after_s":"Passer en mode dégradé après (secondes)","scale.options.port":"Port série","scale.options.baud":"Vitesse (bauds)","scale.options.bits":"Bits de données","scale.options.parity":"Parité","scale.options.stop":"Bits d’arrêt","scale.options.backoff_min_ms":"Attente minimale avant réessai (ms)","scale.options.backoff_max_ms":"Attente maximale avant réessai (ms)","printer.type":"Pilote d’impression","printer.template":"Gabarit d’étiquette","printer.options.transport":"Transport","printer.options.queue":"File d’impression","printer.options.path":"Fichier de périphérique","printer.options.address":"Adresse réseau","printer.options.darkness":"Noircissement","printer.options.speed":"Vitesse d’impression","printer.options.offset_x":"Décalage horizontal (dots)","printer.options.offset_y":"Décalage vertical (dots)","printer.options.invert_bits":"Inverser les points","printer.options.copies":"Exemplaires","printer.options.roll_capacity":"Étiquettes par rouleau","pricing.amount_rounding":"Arrondi du montant","pricing.unit_price_rounding":"Arrondi du prix au kilo","pricing.primary_code":"Tarif principal","pricing.reference_code":"Tarif de référence","barcode.verify_reference_check_digit":"Vérifier la clé de contrôle","limits.empty_max_g":"Plateau considéré vide en dessous de (g)","limits.basket_check_enabled":"Vérifier la présence du panier","limits.basket_min_g":"Poids du panier, borne basse (g)","limits.basket_max_g":"Poids du panier, borne haute (g)","limits.min_weight_g":"Poids minimum accepté","limits.max_weight_g":"Poids maximum accepté","limits.max_tare_g":"Tare maximale","limits.min_units":"Unités minimum","limits.max_units":"Unités maximum","limits.max_amount_cents":"Montant maximum (centimes)","stability.mode":"Exigence de stabilité","stability.min_duration_ms":"Durée de stabilité (ms)","stability.tolerance_g":"Tolérance de stabilité (g)","stability.timeout_ms":"Délai d’attente de stabilité (ms)","stability.on_timeout":"Au bout du délai","stability.min_latch_rate":"Taux d’accroche minimal","stability.latch_rate_window_ms":"Fenêtre de mesure du taux (ms)","stability.expiry_floor_ms":"Péremption, plancher (ms)","stability.expiry_ceiling_ms":"Péremption, plafond (ms)","stability.expiry_factor":"Péremption, facteur","catalog.type":"Où le poste va chercher le catalogue","catalog.options.directory":"Répertoire surveillé","catalog.options.url":"Adresse du serveur","catalog.options.username":"Compte","catalog.options.password":"Mot de passe","catalog.options.separator":"Séparateur du CSV","catalog.options.poll_interval_s":"Vérifier toutes les (secondes)","catalog.options.stable_polls":"Vérifications avant lecture","catalog.options.max_file_size_mb":"Taille maximale du fichier (Mo)","catalog.options.max_image_size_kb":"Taille maximale d’une image (Ko)","catalog.options.min_readable_ratio":"Part minimale de lignes lisibles","catalog.options.max_weighable_drop":"Baisse maximale des produits pesables","catalog.options.max_archives":"Archives conservées","catalog.options.archive_days":"Archives conservées (jours)","catalog.options.failures_before_reject":"Échecs avant mise en quarantaine","catalog.images.source":"Origine des photos","catalog.images.path":"Répertoire des photos","catalog.fallback_category":"Rayon par défaut","journal.max_rows":"Pesées conservées","journal.max_days":"Pesées conservées (jours)","journal.max_technical":"Événements techniques conservés","admin.session_minutes":"Durée d’une session (minutes)","admin.attempts_per_minute":"Tentatives par minute","maintenance.weekly_integrity_check":"Contrôle d’intégrité hebdomadaire","maintenance.disk_alert_mb":"Alerte disque en dessous de (Mo)"};function Ct(a){return Dn[a]??a}const Fn={station:"l’identité du poste",network:"le réseau",ui:"l’écran client",scale:"la balance",printer:"l’imprimante",pricing:"les tarifs",barcode:"le code-barres",limits:"les garde-fous",stability:"la stabilité",catalog:"le catalogue",journal:"le journal",maintenance:"la maintenance"};function Un(a){return Fn[a]??a}const Mn={scale:"balance",printer:"imprimante",catalog:"catalogue",ui:"écran",config:"configuration",http:"réseau",system:"système"};function gr(a){return Mn[a]??"origine inconnue"}const qa="openscale.admin.preferences",mr="technical";class Wn{#t=G(ft(Bn()));get showTechnicalNames(){return e(this.#t)}set showTechnicalNames(t){v(this.#t,t,!0)}toggleTechnicalNames(){this.showTechnicalNames=!this.showTechnicalNames,Vn(this.showTechnicalNames)}}function Bn(){try{return globalThis.localStorage?.getItem(qa)===mr}catch{return!1}}function Vn(a){try{a?globalThis.localStorage?.setItem(qa,mr):globalThis.localStorage?.removeItem(qa)}catch{}}const jt=new Wn,Gn=3e3,Hn=["hardware","label","rules","catalog","journal","station","update"];function Wa(a){return Hn.includes(a)}function Jn(a){return a instanceof zt&&a.needsCredentials}class Kn{constructor(t=Gn){this.periodMs=t}periodMs;#t=G(null);get health(){return e(this.#t)}set health(t){v(this.#t,t,!0)}#r=G("dashboard");get page(){return e(this.#r)}set page(t){v(this.#r,t,!0)}#a=G(!1);get expert(){return e(this.#a)}set expert(t){v(this.#a,t,!0)}#e=G("");get linkError(){return e(this.#e)}set linkError(t){v(this.#e,t,!0)}#n=G("");get actionError(){return e(this.#n)}set actionError(t){v(this.#n,t,!0)}#l=G(!1);get needsFirstPassword(){return e(this.#l)}set needsFirstPassword(t){v(this.#l,t,!0)}#d=G("");get notice(){return e(this.#d)}set notice(t){v(this.#d,t,!0)}#v=G(!1);get busy(){return e(this.#v)}set busy(t){v(this.#v,t,!0)}#p=G(ft([]));get lastFaults(){return e(this.#p)}set lastFaults(t){v(this.#p,t,!0)}#f=G(null);get pending(){return e(this.#f)}set pending(t){v(this.#f,t,!0)}#i=null;#o=null;start(){this.refresh(),this.#i=setInterval(()=>{this.refresh()},this.periodMs)}stop(){this.#i!==null&&clearInterval(this.#i),this.#i=null}async refresh(){try{this.health=await Xr(),this.linkError=""}catch(t){this.linkError=Ba(t)}}#u(){this.busy=!0,this.notice="",this.actionError="",this.needsFirstPassword=!1}#s(t){this.actionError=Ba(t),this.needsFirstPassword=t instanceof zt&&t.needsFirstPassword,this.#m(t)}async run(t){this.#u();let r=null;try{r=await t(),this.notice=r.message}catch(i){this.#s(i)}finally{this.busy=!1}return await this.refresh(),r}async login(t){this.#u();try{return await on(t),this.expert=!0,this.notice="Session d’administration ouverte.",!0}catch(r){return this.expert=!1,this.#s(r),!1}finally{this.busy=!1}}async recover(t,r){this.#u();try{const i=await cn(t,r);return this.expert=!0,this.notice=i.warning??"Le mot de passe est remplacé et la session est ouverte.",!0}catch(i){return this.#s(i),!1}finally{this.busy=!1}}async protect(t){try{return await t()}catch(r){if(!Jn(r))return this.#s(r),null;if(!await this.#g(r))return null;try{return await t()}catch(i){return this.#s(i),null}}}#g(t){return this.pending={kind:t.needsFirstPassword?"first-password":"password",message:t.message},new Promise(r=>{this.#o=r})}async answerPassword(t){await this.login(t)&&this.#c(!0)}async answerRecovery(t,r){await this.recover(t,r)&&this.#c(!0)}cancelPassword(){this.#c(!1)}#c(t){this.pending=null,this.notice="";const r=this.#o;this.#o=null,r?.(t)}async logout(){await un(),this.expert=!1,this.page="dashboard",this.notice="Session d’administration fermée."}open(t){this.actionError="",this.notice="",this.needsFirstPassword=!1,this.page=t}async load(t){try{const r=await t();return this.actionError="",this.lastFaults=[],r}catch(r){return this.lastFaults=r instanceof zt?r.faults:[],this.#s(r),null}}report(t){this.lastFaults=t instanceof zt?t.faults:[],this.#s(t)}#m(t){t instanceof zt&&t.needsPassword&&(this.expert=!1)}}function Ba(a){return a instanceof zt?a.message:a instanceof Error?"Le poste n’a pas répondu : "+a.message:"Le poste n’a pas répondu."}var $n=c('clé'),Yn=c('');function Pe(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"busy",3,!1),d=Be(t,"disabled",3,!1),p=Be(t,"protected",3,!1),k=Be(t,"act",3,void 0);var T=Yn();let m;var j=s(T),q=n(j);{var D=L=>{var W=$n();l(L,W)};_(q,L=>{p()&&L(D)})}f(()=>{m=Mt(T,1,`act ${r()??""}`,"svelte-5tpq0o",m,{"touch-target":r()==="destructive",busy:i()}),he(T,"data-kind",r()),he(T,"data-act",k()),T.disabled=d()||i(),o(j,`${(i()?"En cours…":t.label)??""} `)}),Ce("click",T,function(...L){t.onrun?.apply(this,L)}),l(a,T)}ct(["click"]);var Xn=c(`

                                      Le code de secours a été tiré à l’installation de ce poste et imprimé + sur sa fiche, rangée dans le classeur du magasin. Il n’existe nulle part ailleurs.

                                      `,1),Qn=c(`

                                      Oublié ? Le code de secours de la fiche d’installation en repose un. + Un responsable peut aussi le faire en ligne de commande, avec openscale config password.

                                      `,1),Zn=c('

                                      '),es=c('');function ts(a,t){Me(t,!0);const r=u(()=>t.admin.pending?.kind==="first-password");let i=G(""),d=G("");const p=8,k=u(()=>e(r)?e(d).trim().length>0&&e(i).length>=p:e(i).length>0);async function T(){!e(k)||t.admin.busy||(e(r)?await t.admin.answerRecovery(e(d),e(i)):await t.admin.answerPassword(e(i)),v(i,""),v(d,""))}var m=es(),j=s(m),q=s(j),D=s(q),L=s(D);Ir(L,{name:"settings",size:"1.5rem"});var W=n(D,2),z=s(W),R=n(q,2),K=s(R),F=n(R,2);{var E=C=>{var y=Xn(),S=n(te(y),2),O=n(s(S),2);Ia(O);var A=n(S,2),h=s(A);h.textContent="Nouveau mot de passe — 8 caractères au moins";var V=n(h,2);Ce("keydown",O,X=>X.key==="Enter"&&void T()),ia(O,()=>e(d),X=>v(d,X)),Ce("keydown",V,X=>X.key==="Enter"&&void T()),ia(V,()=>e(i),X=>v(i,X)),l(C,y)},w=C=>{var y=Qn(),S=te(y),O=n(s(S),2);Ia(O),Ce("keydown",O,A=>A.key==="Enter"&&void T()),ia(O,()=>e(i),A=>v(i,A)),l(C,y)};_(F,C=>{e(r)?C(E):C(w,-1)})}var g=n(F,2);{var U=C=>{var y=Zn(),S=s(y);f(()=>o(S,t.admin.actionError)),l(C,y)};_(g,C=>{t.admin.actionError!==""&&C(U)})}var B=n(g,2),$=s(B);Pe($,{label:"Annuler",onrun:()=>t.admin.cancelPassword()});var J=n($,2);{let C=u(()=>e(r)?"Poser ce mot de passe":"Continuer"),y=u(()=>!e(k)||t.admin.busy);Pe(J,{kind:"write",get label(){return e(C)},get disabled(){return e(y)},onrun:()=>{T()}})}f(()=>{o(z,e(r)?"Ce poste n’a pas encore de mot de passe":"Mot de passe d’administration"),o(K,t.admin.pending?.message??"")}),l(a,m),We()}ct(["keydown"]);var as=c(' '),rs=c(""),ns=c(''),ss=c(''),ls=c(""),is=c(""),os=c('

                                      '),us=c(" "),cs=c('

                                      '),ds=c('
                                      ');function at(a,t){Me(t,!0);const r=Be(t,"hint",3,""),i=Be(t,"kind",3,"text"),d=Be(t,"disabled",3,!1),p=Be(t,"fault",3,""),k=Be(t,"allowed",19,()=>[]),T=Be(t,"choices",19,()=>[]),m=u(()=>`field-${t.path.replace(/\./gu,"-")}`);var j=ds();let q;var D=s(j),L=s(D),W=s(L),z=n(L,2);{var R=C=>{var y=as(),S=s(y);f(()=>o(S,t.path)),l(C,y)};_(z,C=>{jt.showTechnicalNames&&C(R)})}var K=n(D,2);{var F=C=>{var y=ns();Te(y,21,T,O=>O.value,(O,A)=>{var h=rs(),V=s(h),X={};f(()=>{o(V,e(A).label),X!==(X=e(A).value)&&(h.value=(h.__value=e(A).value)??"")}),l(O,h)});var S;dr(y),f(()=>{he(y,"id",e(m)),y.disabled=d(),S!==(S=t.value)&&(y.value=(y.__value=t.value)??"",Pa(y,t.value))}),Ce("change",y,O=>t.onchange(O.currentTarget.value)),l(C,y)},E=C=>{var y=ss();f(()=>{he(y,"id",e(m)),he(y,"type",i()),Zt(y,t.value),y.disabled=d(),he(y,"list",k().length>0?e(m)+"-allowed":void 0)}),Ce("input",y,S=>t.onchange(S.currentTarget.value)),l(C,y)};_(K,C=>{T().length>0?C(F):C(E,-1)})}var w=n(K,2);{var g=C=>{var y=is();Te(y,20,k,S=>S,(S,O)=>{var A=ls(),h={};f(()=>{h!==(h=O)&&(A.value=(A.__value=O)??"")}),l(S,A)}),f(()=>he(y,"id",e(m)+"-allowed")),l(C,y)};_(w,C=>{k().length>0&&C(g)})}var U=n(w,2);{var B=C=>{var y=os(),S=s(y);f(()=>o(S,r())),l(C,y)};_(U,C=>{r()!==""&&C(B)})}var $=n(U,2);{var J=C=>{var y=cs(),S=s(y),O=n(S);{var A=h=>{var V=us(),X=s(V);f(ae=>o(X,`Valeurs acceptées : ${ae??""}.`),[()=>k().join(", ")]),l(h,V)};_(O,h=>{k().length>0&&h(A)})}f(()=>o(S,`${p()??""} `)),l(C,y)};_($,C=>{p()!==""&&C(J)})}f(()=>{q=Mt(j,1,"field svelte-1oilv5s",null,q,{refused:p()!==""}),he(D,"for",e(m)),o(W,t.label)}),l(a,j),We()}ct(["change","input"]);var vs=c('

                                      '),ps=c('

                                      ');function ze(a,t){const r=Be(t,"note",3,"");var i=ps(),d=s(i),p=s(d),k=n(d,2);{var T=j=>{var q=vs(),D=s(q);f(()=>o(D,r())),l(j,q)};_(k,j=>{r()!==""&&j(T)})}var m=n(k,2);Jr(m,()=>t.children),f(()=>o(p,t.title)),l(a,i)}function Ye(a,t){return a.faults.find(r=>r.field===t)?.message??""}function Qt(a,t){return a.faults.find(r=>r.field===t)?.allowed??[]}var fs=c('

                                      Lecture des réglages du poste…

                                      '),gs=c(`

                                      Le poste y cherche le fichier , et le supprime + une fois lu : c’est ce qui dit au producteur que la livraison est prise.

                                      `,1),ms=c(`

                                      Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus + possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours + du jour de la mise en service.

                                      `,1),hs=c(`

                                      Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira + chercher aucun catalogue.

                                      `),_s=c('
                                      ',1);function bs(a,t){Me(t,!0);const r={local_drop:["catalog.options.directory"],webdav:["catalog.options.url","catalog.options.username","catalog.options.password"]},i=u(()=>t.draft.text("catalog.type"));function d(p){t.draft.set("catalog.type",p);for(const[k,T]of Object.entries(r))if(k!==p)for(const m of T)t.draft.unset(m)}ze(a,{title:"Où le poste va chercher le catalogue",children:(p,k)=>{var T=_s(),m=te(T),j=s(m),q=s(j),D=n(j,2),L=s(D),W=n(m,2);{var z=E=>{var w=fs();l(E,w)},R=E=>{var w=gs(),g=te(w);{let J=u(()=>Ct("catalog.options.directory")),C=u(()=>t.draft.text("catalog.options.directory")),y=u(()=>Ye(t.draft,"catalog.options.directory"));at(g,{get label(){return e(J)},path:"catalog.options.directory",get value(){return e(C)},hint:"Laissez vide pour le répertoire du poste, celui que le service crée lui-même. Un répertoire nommé ici doit exister : le poste ne le crée pas.",get fault(){return e(y)},onchange:S=>t.draft.set("catalog.options.directory",S)})}var U=n(g,2),B=n(s(U)),$=s(B);f(()=>o($,`flv_${t.station??""}.csv`)),l(E,w)},K=E=>{var w=ms(),g=te(w);{let $=u(()=>Ct("catalog.options.url")),J=u(()=>t.draft.text("catalog.options.url")),C=u(()=>Ye(t.draft,"catalog.options.url"));at(g,{get label(){return e($)},path:"catalog.options.url",get value(){return e(J)},get fault(){return e(C)},onchange:y=>t.draft.set("catalog.options.url",y)})}var U=n(g,2);{let $=u(()=>Ct("catalog.options.username")),J=u(()=>t.draft.text("catalog.options.username")),C=u(()=>Ye(t.draft,"catalog.options.username"));at(U,{get label(){return e($)},path:"catalog.options.username",get value(){return e(J)},get fault(){return e(C)},onchange:y=>t.draft.set("catalog.options.username",y)})}var B=n(U,2);{let $=u(()=>Ct("catalog.options.password")),J=u(()=>Ye(t.draft,"catalog.options.password"));at(B,{get label(){return e($)},path:"catalog.options.password",kind:"password",value:"",hint:"Laissez vide : le mot de passe actuel est conservé.",get fault(){return e(J)},onchange:C=>t.draft.set("catalog.options.password",C)})}l(E,w)},F=E=>{var w=hs();l(E,w)};_(W,E=>{t.draft.config===null?E(z):e(i)==="local_drop"?E(R,1):e(i)==="webdav"?E(K,2):E(F,-1)})}f(()=>{ta(q,e(i)==="local_drop"),ta(L,e(i)==="webdav")}),Ce("change",q,()=>d("local_drop")),Ce("change",L,()=>d("webdav")),l(p,T)},$$slots:{default:!0}}),We()}ct(["change"]);const Va=1e3;function va(a){const t=Oa(a);return t===null?"":[ea(t.getDate()),ea(t.getMonth()+1),String(t.getFullYear())].join("/")}function Pt(a){const t=Oa(a);return t===null?"":`${va(a)} à ${ea(t.getHours())}:${ea(t.getMinutes())}`}function ws(a){const t=Oa(a);return t===null?"":[ea(t.getHours()),ea(t.getMinutes()),ea(t.getSeconds())].join(":")}function Sa(a){const t=a/1e6;return t<1e3?`${Math.round(t)} Mo`:`${hr(t/1e3)} Go`}function ne(a){const t=String(Math.abs(Math.trunc(a))),r=[];for(let i=t.length;i>0;i-=3)r.unshift(t.slice(Math.max(0,i-3),i));return(a<0?"-":"")+r.join(" ")}function ua(a){return a0}function ks(a){const t=a.motive!=="";return{canWithdraw:t||!pa(!1,a.waiverInForce),canOfferAgain:t||!pa(!0,a.waiverInForce),canSaveWaiver:xs(a.typedWaiver)&&(t||!pa(a.offeredInForce,a.typedWaiver)),canDropWaiver:t||!pa(a.offeredInForce,null)}}function _r(a){const t=[];return a.offered||t.push("retiré de la grille"),a.min_weight_g!==null&&t.push(`peut peser à partir de ${ne(a.min_weight_g)} g`),t.length===0?"aucune restriction":t.join(" · ")}function ca(a,t,r,i){const d=t>1?i:r;return a>=t?`${ne(t)} ${d}.`:`${ne(a)} lignes affichées sur ${ne(t)} ${d}.`}function qs(a,t){return t>a?`${ne(a)} produits affichés sur ${ne(t)} trouvés — précisez votre recherche.`:`${ne(t)} ${t>1?"produits trouvés":"produit trouvé"}.`}function Ss(a){return`${ne(a)} ${a>1?"imports affichés":"import affiché"} : le poste n’en publie jamais plus de vingt.`}function ya(a,t,r,i){const d=`${ne(r)} ${r>1?t:a}`;return r<=i?d+".":`${d} — seules les ${ne(i)} premières lignes sont affichées.`}function Ls(a,t){const r=`${ne(a)} ${a>1?"produits retirés":"produit retiré"}`;return t===""?r+".":`${r} depuis l’import du ${Pt(t)}.`}var Es=c('

                                      Aucune décision locale : la grille est celle du fichier.

                                      '),Cs=c('
                                    • '),Ps=c('

                                        ',1);function Ts(a,t){Me(t,!0);const r=20,i=u(()=>t.decisions.slice(0,r));ze(a,{title:"Décisions en vigueur",note:"Ce qu’un humain a décidé, produit par produit. C’est ici que se reprend un produit retiré de la grille.",children:(d,p)=>{var k=Rt(),T=te(k);{var m=q=>{var D=Es();l(q,D)},j=q=>{var D=Ps(),L=te(D),W=s(L),z=n(L,2),R=s(z);Te(R,21,()=>e(i),K=>K.product_id,(K,F)=>{var E=Cs(),w=s(E),g=n(w,2),U=s(g),B=n(g,2),$=s(B),J=n(B,2),C=s(J),y=n(J,2),S=s(y),O=n(y,2),A=s(O);f((h,V,X)=>{o(U,h),o($,e(F).product_id),o(C,V),o(S,e(F).reason),o(A,X)},[()=>t.nameOf(e(F).product_id),()=>_r(e(F)),()=>va(e(F).decided_at)]),Ce("click",w,()=>t.onchoose(e(F).product_id)),l(K,E)}),f(K=>o(W,K),[()=>ca(e(i).length,t.decisions.length,"décision en vigueur","décisions en vigueur")]),l(q,D)};_(T,q=>{t.decisions.length===0?q(m):q(j,-1)})}l(d,k)},$$slots:{default:!0}}),We()}ct(["click"]);function zs(a){return a==="loading"?"Lecture des signalements du dernier import…":"Les signalements du dernier import n’ont pas pu être lus : cet écran ne sait pas ce qu’ils disent."}function js(a){return a==="loading"?"Lecture de l’historique des imports…":"L’historique des imports n’a pas pu être lu : cet écran ne sait pas ce qu’il contient."}function Aa(a,t,r){return a!==void 0?a:t==="loading"?"Lecture du nom…":t==="unread"?r:"Produit absent du catalogue en service"}var Ga=c('

                                        '),Rs=c(' '),Os=c('
                                      • '),As=c('

                                          ',1);function xa(a,t){Me(t,!0);const r=Be(t,"remedy",3,""),i=50,d=u(()=>t.findings.slice(0,i)),p=u(()=>t.findings.length>e(d).length);ze(a,{get title(){return t.title},get note(){return t.note},children:(k,T)=>{var m=Rt(),j=te(m);{var q=W=>{var z=Ga(),R=s(z);f(K=>{he(z,"data-unread",t.list),o(R,K)},[()=>zs(t.state)]),l(W,z)},D=W=>{var z=Ga(),R=s(z);f(()=>o(R,t.none)),l(W,z)},L=W=>{var z=As(),R=te(z),K=s(R),F=n(K);{var E=U=>{var B=ot();f(()=>o(B,r())),l(U,B)};_(F,U=>{e(p)&&r()!==""&&U(E)})}var w=n(R,2),g=s(w);Te(g,21,()=>e(d),da,(U,B)=>{var $=Os(),J=s($),C=s(J),y=n(J,2);{var S=M=>{var b=Rs(),I=s(b);f(()=>o(I,e(B).product_name)),l(M,b)};_(y,M=>{e(B).product_name!==""&&M(S)})}var O=n(y,2),A=s(O),h=n(O,2),V=s(h),X=n(h,2),ae=s(X);f(M=>{o(C,`ligne ${M??""}`),o(A,e(B).product_id),o(V,e(B).value),o(ae,e(B).message)},[()=>ne(e(B).csv_line)]),l(U,$)}),f(U=>{he(R,"data-tally",t.list),o(K,`${U??""} `),he(w,"data-rows",t.list)},[()=>ca(e(d).length,t.findings.length,t.singular,t.plural)]),l(W,z)};_(j,W=>{t.state!=="read"?W(q):t.findings.length===0?W(D,1):W(L,-1)})}l(k,m)},$$slots:{default:!0}}),We()}function br(a,t){const r=a.rows_read_count,i=a.images_decoded_count,d=r-i,p=[{count:wt(a.weighable_count),label:"pesables",note:`(${wt(i)} avec photo, ${wt(d)} sans)`,link:""},{count:wt(a.not_weighable_count),label:"non pesables",note:Ws(t),link:""}];if(a.anomalies_count>0&&p.push({count:wt(a.anomalies_count),label:"anomalies",note:"à corriger dans Odoo",link:Ha(a.anomalies_count)}),a.unit_mismatches_count>0){const k=a.unit_mismatches_count;p.push({count:"+ "+wt(k),label:k===1?"unité divergente":"unités divergentes",note:"pesable, unité à corriger",link:Ha(k)})}return{headline:`Catalogue du ${va(a.occurred_at)} — ${wt(r)} produits reçus`,lines:p,oneLine:Ns(a)}}function Ns(a){const t=[`${wt(a.rows_read_count)} reçus`,`${wt(a.weighable_count)} pesables`,`${wt(a.not_weighable_count)} non pesables`,`${wt(a.anomalies_count)} anomalies`],r=a.unit_mismatches_count;return r>0&&t.push(`${wt(r)} ${r===1?"unité divergente":"unités divergentes"}`),t.join(" · ")}const Is={applied:"appliqué",unchanged:"identique au précédent",rejected:"refusé",failed:"échec"},Ds={local_drop:"dépôt local",webdav:"WebDAV",manual:"déposé sur l’écran"};function ha(a){return Is[a]??"résultat inconnu"}function wr(a){return Ds[a]??"source inconnue"}function Fs(a,t){const r=[a.file_name===""?"Le dernier fichier":a.file_name,ha(a.result)],i=Pt(a.occurred_at);i!==""&&r.push("le "+i),r.push("via "+wr(a.source));const d=a.reason===""?"":" "+a.reason;return`${r.join(" ")} — ${br(a,t).oneLine}.${d}`}function Us(a){return a===""?"Aucun nouvel import enregistré à cet instant, et ce poste ne publie pas ce qu’il surveille.":"Aucun nouvel import enregistré à cet instant. Le poste surveille "+a+" : le fichier n’y était pas, ou il n’a pas encore fini d’arriver."}function Ms(a){return"Ce poste n’a pas de journal : il ne pourra rien dire de l’issue de cette relecture."+(a===""?"":" Le poste surveille "+a+".")}function Ha(a){return a===1?"voir la ligne":`voir les ${wt(a)} lignes`}function Ws(a){return a.map(t=>`${Bs(t)} (${wt(t.count)})`).join(", ")}function Bs(a){switch(a.code){case"PREPACKAGED_PRODUCT":return"préemballés";case"INTERNAL_CODE_NOT_WEIGHABLE":return a.value===""?"code interne":"code interne "+a.value;case"NO_BARCODE":return"sans code-barres";default:return a.code}}function wt(a){return String(a)}var Vs=c('

                                          '),Gs=c('

                                          Aucun import dans l’historique.

                                          '),Hs=c(' '),Js=c('

                                          QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
                                          ',1);function Ks(a,t){Me(t,!0);function r(i){const d=ha(i.result);return i.code===""?d:`${d} (${i.code})`}ze(a,{title:"Vingt derniers imports",children:(i,d)=>{var p=Rt(),k=te(p);{var T=q=>{var D=Vs(),L=s(D);f(W=>o(L,W),[()=>js(t.state)]),l(q,D)},m=q=>{var D=Gs();l(q,D)},j=q=>{var D=Js(),L=te(D),W=s(L),z=n(L,2),R=s(z),K=n(s(R));Te(K,21,()=>t.imports,F=>F.id,(F,E)=>{var w=Hs(),g=s(w),U=s(g),B=n(g),$=s(B),J=n(B),C=s(J),y=n(J),S=s(y),O=n(y),A=s(O),h=n(O),V=s(h),X=n(h),ae=s(X),M=n(X),b=s(M),I=n(M),Y=s(I),ue=n(I),fe=s(ue);f((pe,ce,N,Q,re,Z,ie,we)=>{o(U,pe),o($,e(E).file_name),o(C,ce),o(S,N),o(A,e(E).reason),o(V,Q),o(ae,re),o(b,Z),o(Y,ie),o(fe,we)},[()=>Pt(e(E).occurred_at),()=>wr(e(E).source),()=>r(e(E)),()=>ne(e(E).rows_read_count),()=>ne(e(E).weighable_count),()=>ne(e(E).not_weighable_count),()=>ne(e(E).anomalies_count),()=>ne(e(E).products_withdrawn_count)]),l(F,w)}),f(F=>o(W,F),[()=>Ss(t.imports.length)]),l(q,D)};_(k,q=>{t.state!=="read"?q(T):t.imports.length===0?q(m,1):q(j,-1)})}l(i,p)},$$slots:{default:!0}}),We()}var $s=c(' '),Ys=c(''),Xs=c('
                                          '),Qs=c('

                                          ');function yr(a,t){Me(t,!0);const r=u(()=>br(t.record,t.motives));var i=Qs(),d=s(i),p=s(d),k=n(d,2);Te(k,21,()=>e(r).lines,j=>j.label,(j,q)=>{var D=Xs(),L=s(D),W=s(L),z=n(L,2),R=s(z),K=s(R),F=n(R,2);{var E=U=>{var B=$s(),$=s(B);f(()=>o($,e(q).note)),l(U,B)};_(F,U=>{e(q).note!==""&&U(E)})}var w=n(F,2);{var g=U=>{var B=Ys(),$=s(B);f(()=>o($,e(q).link)),Ce("click",B,()=>t.onshowrows(e(q).label==="anomalies"?"anomalies":"units")),l(U,B)};_(w,U=>{e(q).link!==""&&t.onshowrows!==void 0&&U(g)})}f(()=>{he(D,"data-row",e(q).label),o(W,e(q).count),o(K,e(q).label)}),l(j,D)});var T=n(k,2),m=s(T);f(()=>{o(p,e(r).headline),o(m,e(r).oneLine)}),l(a,i),We()}ct(["click"]);var Zs=c(" "),el=c(' '),tl=c('');function Na(a,t){Me(t,!0);const r=Be(t,"hint",3,"");var i=tl(),d=s(i),p=n(d,2),k=s(p),T=s(k),m=n(k,2);{var j=L=>{var W=Zs(),z=s(W);f(()=>o(z,t.path)),l(L,W)};_(m,L=>{jt.showTechnicalNames&&L(j)})}var q=n(m,2);{var D=L=>{var W=el(),z=s(W);f(()=>o(z,r())),l(L,W)};_(q,L=>{r()!==""&&L(D)})}f(L=>{he(i,"data-flag",t.path),he(i,"data-on",L),ta(d,t.on),o(T,t.label)},[()=>String(t.on)]),Ce("change",d,L=>t.onchange(L.currentTarget.checked)),l(a,i),We()}ct(["change"]);function Ja(a){const t=`${ne(a.columns)} ${a.columns>1?"colonnes":"colonne"}`,r=`${ne(a.rows)} ${a.rows>1?"rangées":"rangée"}`;return`${t} × ${r}`}function al(a){const t=a.layout,r=[];if(a.columns===Xt)return r.push(t===null?"Automatique : la grille suit la largeur de l’écran. Un écran plus large en montre davantage sans qu’on y revienne.":`Automatique : ${Ja(t)} sur cet écran. Un écran plus large en montrera davantage sans qu’on y revienne.`),r;if(t===null)return r.push(`${ne(a.columns)} ${a.columns>1?"colonnes":"colonne"} sur tous les écrans. Cet écran ne sait pas dire combien de rangées cela fait ici.`),r;const i=t.columns*t.rows;if(r.push(`${Ja(t)} — ${ne(i)} ${i>1?"tuiles":"tuile"} d’un coup, sur cet écran (${String(a.viewport.width)} × ${String(a.viewport.height)}).`),a.tileCount>0){const p=Math.ceil(a.tileCount/i);r.push(a.tileCount>1?`Les ${ne(a.tileCount)} tuiles de la grille tiennent en ${ne(p)} ${p>1?"écrans":"écran"}.`:"La seule tuile de la grille tient en un écran.")}const d=a.floor;if(d!==null&&d.names>0){const p=d.names>1;r.push(`${ne(d.names)} ${p?"noms":"nom"} sur ${ne(a.tileCount)} ${p?"atteignent":"atteint"} le plancher de ${ne(ka)} px : `+(d.rows>1?`leurs ${ne(d.rows)} rangées peuvent être plus hautes que les autres.`:"leur rangée peut être plus haute que les autres."))}return r}function rl(a,t,r){if(a==="loading")return"Lecture du catalogue en service…";if(a==="unread")return"Le catalogue en service n’a pas pu être lu : cet écran ne sait pas combien de produits se vendent à l’unité.";if(t===0)return"Aucun produit vendu à l’unité dans le catalogue en service.";const i=t>1,d=i?"produits vendus à l’unité sont":"produit vendu à l’unité est",p=r?`${i?"montrés":"montré"} dans la grille de ce poste`:`${i?"masqués":"masqué"} sur ce poste`;return`${ne(t)} ${d} ${p}.`}function nl(a){return["localhost","127.0.0.1"].includes(a)?"":"Cet écran n’est pas celui du poste : ce compte vaut pour l’écran que vous lisez."}const sl=-1,ll=10;class xr{#t=G("");get sentence(){return e(this.#t)}set sentence(t){v(this.#t,t,!0)}#r=0;#a="";#e=0;begin(t){this.sentence="",this.#r=t.last_import_id,this.#a=t.watched,this.#e=ll}forget(){this.sentence="",this.#e=0}observe(t){if(this.#e===0)return;if(t.counters.journal_rows_count===sl){this.#n(Ms(this.#a));return}const r=t.catalog;if(r!==null&&r.id!==this.#r){this.#n(Fs(r,t.catalog_motives));return}this.#e-=1,this.#e===0&&(this.sentence=Us(this.#a))}#n(t){this.sentence=t,this.#e=0}}var il=c('

                                          Aucun import enregistré sur ce poste.

                                          '),ol=c('

                                          '),ul=c(`

                                          « Oublier la quarantaine » fait relire un fichier que le poste avait écarté : c’est le + seul geste de cette page qui puisse remettre en service un catalogue refusé.

                                          `,1),cl=c(""),dl=c(''),vl=c('

                                          '),pl=c('

                                          '),fl=c('
                                          '),gl=c(`

                                          Un produit masqué reste vendable : la caisse lit toujours son code-barres, et une + étiquette déjà imprimée reste valable. Ce réglage ne fait que retirer sa tuile.

                                          Colonnes de la grille

                                          Se tromper ne coûte rien d’autre que de revenir ici : le réglage ne change ni le + fichier reçu, ni les étiquettes déjà imprimées.

                                          `,1),ml=c('

                                          '),hl=c(`

                                          Déposez ici le fichier clé

                                          Ce dépôt remplace toute la grille par le fichier apporté : il change ce que le poste + vend, et le mot de passe est donc demandé au moment du dépôt.

                                          `,1),_l=c('

                                          Aucun import enregistré : rien n’a encore pu être retiré.

                                          '),bl=c('

                                          Aucun produit retiré par le dernier import.

                                          '),wl=c(`

                                          Ils restent enregistrés avec leur historique : une étiquette déjà collée reste + lisible en caisse, et un produit qui revient dans un prochain fichier retrouve sa + tuile. Ce poste n’en publie encore que le nombre — leurs noms se lisent dans Odoo, + en comparant avec l’export précédent.

                                          `,1),yl=c('
                                        • '),xl=c(`

                                            Un produit retiré de la grille ne se trouve plus ici : il se reprend dans + « Décisions en vigueur », plus bas.

                                            `,1),kl=c(`

                                            Sans motif, le poste refuse la décision. Le seul acte qui s’en passe est celui qui + EFFACE la décision en vigueur : proposer de nouveau un produit qui ne porte aucune + dérogation.

                                            `),ql=c(`

                                            Produit choisi :

                                            Ce produit est-il proposé dans la grille ?

                                            La dérogation s’enregistre seule : elle ne remet pas dans la grille un produit + qui en a été retiré, et retirer un produit n’efface pas sa dérogation.

                                            `),Sl=c(' ',1),Ll=c('
                                            ');function El(a,t){Me(t,!0);const r=Be(t,"admin",7),i=20,d=["NO_BARCODE","PREPACKAGED_PRODUCT","INTERNAL_CODE_NOT_WEIGHABLE"],p="ui.grid_columns",k=[3,4,5,6,7,8,9,10,11,12];let T=G(ft([])),m=G(ft([])),j=G(null);const q=u(()=>e(j)?.products??[]),D=u(()=>e(j)?.presentation??{}),L=u(()=>e(j)?.pricing?.primary_code??"");let W=G(""),z=G(""),R=G(""),K=G(""),F=G(!1),E=G(""),w=G(""),g=G("loading"),U=G("loading");const B=u(()=>r().busy||e(w)!==""),$=u(()=>e(m).filter(H=>H.issue==="anomaly")),J=u(()=>e(m).filter(H=>d.includes(H.code))),C=u(()=>e(m).filter(H=>H.code==="UNIT_MISMATCH")),y=u(()=>new Map(e(q).map(H=>[H.id,H.name]))),S=u(()=>e(q).filter(H=>H.mode==="by_unit").length),O=u(()=>rl(e(U),e(S),t.draft.flag("ui.show_by_unit_products"))),A=u(()=>t.draft.number(p)),h=u(()=>X("ui.show_by_unit_products",e(D).show_by_unit_products??!1)?e(q):e(q).filter(H=>H.mode!=="by_unit")),V=u(()=>e(h)[0]===void 0?null:{...e(h)[0],name:"·",image_url:"",prices:e(h)[0].prices??[]});function X(H,ge){return t.draft.value(H)===void 0?ge:t.draft.flag(H)}let ae=G(null),M=G(null),b=G(null),I=G(null),Y=G(null),ue=G(0),fe=G(null);qt(()=>{const H=()=>{v(ue,e(ue)+1)};return window.addEventListener("resize",H),()=>window.removeEventListener("resize",H)}),qt(()=>{const H=e(A);e(ue),v(fe,Q(H),!0)});const pe=u(()=>{const H=e(fe);if(H===null||H.contentWidthPx<=0||H.nameBoxPx<=0||e(h).length===0)return null;const ge=Z();if(ge===null)return null;const _e=Ur(ge.family,ge.weight);if(_e===null)return null;const ke=Mr(H.tileScale),Ke=new Set;let Je=0;for(const[et,Ie]of e(h).entries())Wr(Ie.name,H.contentWidthPx,_e,H.nameBoxPx,ke)>ka||(Je+=1,Ke.add(Math.floor(et/H.columns)));return{names:Je,rows:Ke.size}}),ce=u(()=>al({columns:e(A),layout:e(fe),tileCount:e(h).length,floor:e(pe),viewport:{width:window.innerWidth,height:window.innerHeight}})),N=u(()=>e(fe)===null?"":nl(window.location.hostname));function Q(H){const ge=e(ae),_e=e(M),ke=e(b),Ke=e(I),Je=e(Y);if(ge===null||_e===null||Ke===null||Je===null||ke===null)return null;_e.style.gridTemplateColumns=Dr(H);const et=re(_e),Ie=et[0];if(Ie===void 0)return null;const Oe=Ke.clientWidth,$e=H===Xt||Oe<=0?1:Ie/Oe;ge.style.setProperty("--tile-scale",String($e));const Ge=Number.parseFloat(getComputedStyle(_e).rowGap),tt=Je.clientHeight,ut=ke.offsetHeight,xe=ke.querySelector(".name-box");return!Number.isFinite(Ge)||tt<=0||ut<=0||xe===null?null:{columns:et.length,rows:Math.max(1,Math.floor((tt+Ge)/(ut+Ge))),contentWidthPx:xe.clientWidth,nameBoxPx:xe.clientHeight,tileScale:$e}}function re(H){const ge=getComputedStyle(H).gridTemplateColumns.split(/\s+/u).filter(ke=>ke!=="");if(ge.length===0)return[];const _e=ge.map(ke=>ke.endsWith("px")?Number.parseFloat(ke):Number.NaN);return _e.every(ke=>Number.isFinite(ke)&&ke>0)?_e:[]}function Z(){const H=e(b)?.querySelector(".name")??null;if(H===null)return null;const ge=getComputedStyle(H),_e=Number.parseInt(ge.fontWeight,10);return ge.fontFamily===""||!Number.isFinite(_e)?null:{family:ge.fontFamily,weight:_e}}const ie=u(()=>e(W)===""?[]:Br(e(q),Vr,e(W))),we=u(()=>e(ie).slice(0,i)),Fe=u(()=>qs(e(we).length,e(ie).length)),x=u(()=>[...t.health.decisions].sort((H,ge)=>ge.decided_at.localeCompare(H.decided_at))),se=u(()=>e(z)===""?null:e(x).find(H=>H.product_id===e(z))??null),ve=u(()=>e(se)===null?!0:e(se).offered),oe=u(()=>e(se)?.min_weight_g??null),le=u(()=>ys(e(K))),je=u(()=>e(R).trim()),qe=u(()=>ks({motive:e(je),offeredInForce:e(ve),waiverInForce:e(oe),typedWaiver:e(le)})),Se=u(()=>t.health.catalog?.products_withdrawn_count??0),Ne=u(()=>e(T).find(H=>t.health.catalog!==null&&H.idLs(e(Se),e(Ne)?.occurred_at??""));let ye;qt(()=>{const H=t.health.catalog?.id??null;H!==ye&&(ye=H,lt())});const Re=new xr;qt(()=>{Re.observe(t.health)});async function lt(){const H=t.health.catalog_findings_id,ge=await r().load(()=>En(H===0?void 0:H));ge===null?v(g,"unread"):(v(T,ge.imports,!0),v(m,ge.findings,!0),v(g,"read"));const _e=await r().load(()=>ur());if(_e===null){v(U,"unread");return}v(j,_e,!0),v(U,"read")}async function it(H,ge){v(w,H,!0),r().actionError="",r().notice="",Re.forget();try{const _e=await r().protect(ge);return _e===null?null:(r().notice=_e.message,await r().refresh(),_e)}finally{v(w,"")}}async function gt(){const H=await it("reload",Cn);H!==null&&Re.begin(H)}function ht(H){return Aa(e(y).get(H),e(U),"Nom inconnu : le catalogue n’a pas pu être lu")}function dt(H){v(z,H,!0),v(R,"");const ge=e(x).find(_e=>_e.product_id===H)?.min_weight_g??null;v(K,ge===null?"":String(ge),!0)}async function mt(H){if(e(z)==="")return;const ge=e(z),_e=e(oe),ke=e(je);await it("offered",()=>Ma(ge,{offered:H,min_weight_g:_e,reason:ke}))!==null&&v(R,"")}async function yt(H){if(e(z)==="")return;const ge=e(z),_e=e(ve),ke=e(je);await it(H===null?"waiver-off":"waiver",()=>Ma(ge,{offered:_e,min_weight_g:H,reason:ke}))!==null&&v(R,"")}async function Ze(H){if(v(F,!1),H!=null){v(E,""),r().actionError="",r().notice="",v(w,"import");try{const ge=await r().protect(()=>vr(H));if(ge===null)return;v(E,`${H.name} : ${ne(ge.rows_read_count)} lignes lues, ${ne(ge.weighable_count)} pesables. `+(ge.reason===""?"Le fichier est déposé ; son résultat s’inscrira dans l’historique des imports.":ge.reason)),await r().refresh()}finally{v(w,"")}}}function me(H){H.preventDefault(),v(F,!1);const ge=H.dataTransfer?.files.item(0);if(e(B)){v(E,`${ge?.name??"Ce fichier"} n’a pas été déposé : un acte est déjà en cours sur cette page. Réessayez quand il aura répondu.`);return}Ze(ge)}function Le(H){const ge=H.files?.item(0);H.value="",Ze(ge)}var rt=Ll(),Ft=s(rt);bs(Ft,{get draft(){return t.draft},get station(){return t.health.station}});var Ot=n(Ft,2);ze(Ot,{title:"Dernier import",children:(H,ge)=>{var _e=ul(),ke=te(_e);{var Ke=xe=>{var Xe=il();l(xe,Xe)},Je=xe=>{yr(xe,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives}})};_(ke,xe=>{t.health.catalog===null?xe(Ke):xe(Je,-1)})}var et=n(ke,2),Ie=s(et),Oe=n(et,2);{var $e=xe=>{var Xe=ol(),vt=s(Xe);f(()=>o(vt,Re.sentence)),l(xe,Xe)};_(Oe,xe=>{Re.sentence!==""&&xe($e)})}var Ge=n(Oe,2),tt=s(Ge);{let xe=u(()=>e(w)==="reload");Pe(tt,{act:"reload",kind:"write",label:"Recharger le catalogue",protected:!0,get busy(){return e(xe)},get disabled(){return e(B)},onrun:()=>{gt()}})}var ut=n(tt,2);{let xe=u(()=>e(w)==="quarantine");Pe(ut,{act:"quarantine",kind:"destructive",label:"Oublier la quarantaine",protected:!0,get busy(){return e(xe)},get disabled(){return e(B)},onrun:()=>{it("quarantine",Pn)}})}f(()=>o(Ie,t.health.catalog_source===null?"Aucune source de catalogue publiée par ce poste.":"Source : "+t.health.catalog_source.label)),l(H,_e)},$$slots:{default:!0}});var Tt=n(Ot,2);ze(Tt,{title:"Ce que la grille montre",note:"Un réglage d’affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser.",children:(H,ge)=>{var _e=gl(),ke=te(_e);{let Ee=u(()=>t.draft.flag("ui.show_by_unit_products"));Na(ke,{path:"ui.show_by_unit_products",label:"Afficher les produits vendus à l’unité",hint:"Décoché, leurs tuiles quittent la grille et la recherche ne les retrouve plus. Ce que le poste perd : une tuile vendue à l’unité imprime une étiquette sans jamais lire la balance, et c’est le seul geste que ce réglage retire.",get on(){return e(Ee)},onchange:Ue=>t.draft.set("ui.show_by_unit_products",Ue)})}var Ke=n(ke,2),Je=s(Ke),et=n(Ke,4),Ie=n(s(et));{var Oe=Ee=>{var Ue=cl();Ue.textContent="ui.grid_columns",l(Ee,Ue)};_(Ie,Ee=>{jt.showTechnicalNames&&Ee(Oe)})}var $e=n(et,2),Ge=s($e),tt=s(Ge),ut=n(Ge,2);Te(ut,16,()=>k,Ee=>Ee,(Ee,Ue)=>{var st=dl(),_t=s(st),P=n(_t);f((ee,be)=>{he(st,"data-columns",Ue),he(st,"data-on",ee),Zt(_t,Ue),ta(_t,e(A)===Ue),o(P,` ${be??""}`)},[()=>String(e(A)===Ue),()=>ne(Ue)]),Ce("change",_t,()=>t.draft.set(p,Ue)),l(Ee,st)});var xe=n($e,2),Xe=s(xe);Te(Xe,17,()=>e(ce),da,(Ee,Ue)=>{var st=vl(),_t=s(st);f(()=>o(_t,e(Ue))),l(Ee,st)});var vt=n(Xe,2);{var St=Ee=>{var Ue=pl(),st=s(Ue);f(()=>o(st,e(N))),l(Ee,Ue)};_(vt,Ee=>{e(N)!==""&&Ee(St)})}var nt=n(xe,4),pt=s(nt),xt=s(pt);{var Lt=Ee=>{var Ue=fl(),st=s(Ue);{let _t=u(()=>X("ui.show_grid_prices",e(D).show_grid_prices??!0));Fr(st,{get product(){return e(V)},get nameSizePx(){return ka},get primaryCode(){return e(L)},get showPrice(){return e(_t)},onpick:()=>{}})}sa(Ue,_t=>v(b,_t),()=>e(b)),l(Ee,Ue)};_(xt,Ee=>{e(V)!==null&&Ee(Lt)})}sa(pt,Ee=>v(M,Ee),()=>e(M));var It=n(pt,2);sa(It,Ee=>v(I,Ee),()=>e(I));var Dt=n(It,2);sa(Dt,Ee=>v(Y,Ee),()=>e(Y)),sa(nt,Ee=>v(ae,Ee),()=>e(ae)),f(Ee=>{o(Je,e(O)),he(Ge,"data-columns",Xt),he(Ge,"data-on",Ee),Zt(tt,Xt),ta(tt,e(A)===Xt)},[()=>String(e(A)===Xt)]),Ce("change",tt,()=>t.draft.set(p,Xt)),l(H,_e)},$$slots:{default:!0}});var At=n(Tt,2);ze(At,{title:"Déposer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(H,ge)=>{var _e=hl(),ke=te(_e);{var Ke=xe=>{var Xe=ml(),vt=s(Xe);f(()=>o(vt,e(E))),l(xe,Xe)};_(ke,xe=>{e(E)!==""&&xe(Ke)})}var Je=n(ke,2);let et;var Ie=s(Je),Oe=n(s(Ie)),$e=s(Oe),Ge=n(Ie,2),tt=s(Ge),ut=n(tt);f(()=>{et=Mt(Je,1,"drop svelte-3gk7u7",null,et,{dropping:e(F),working:e(w)==="import"}),o($e,`flv_${t.health.station??""}.csv`),o(tt,`${e(w)==="import"?"Import en cours…":"Choisir un fichier"} `),ut.disabled=e(B)}),Kt("dragover",Je,xe=>{xe.preventDefault(),!e(B)&&v(F,!0)}),Kt("dragleave",Je,()=>v(F,!1)),Kt("drop",Je,me),Ce("change",ut,xe=>Le(xe.currentTarget)),l(H,_e)},$$slots:{default:!0}});var Nt=n(At,2);xa(Nt,{title:"Anomalies à corriger dans Odoo",note:"Chaque ligne porte le nom du produit, son numéro dans le CSV, son motif et la valeur fautive.",list:"anomalies",get state(){return e(g)},get findings(){return e($)},singular:"anomalie",plural:"anomalies",none:"Aucune anomalie sur le dernier import.",remedy:"Corrigez celles-ci dans Odoo : l’import suivant ne signalera que ce qui reste."});var Ut=n(Nt,2);xa(Ut,{title:"Unités divergentes",note:"Le produit reste proposé : le code-barres fait foi, seul le libellé du prix est faux.",list:"mismatches",get state(){return e(g)},get findings(){return e(C)},singular:"unité divergente",plural:"unités divergentes",none:"Aucune unité divergente sur le dernier import."});var Bt=n(Ut,2);xa(Bt,{title:"Produits non pesables",note:"Un inventaire, pas une liste d’erreurs : ces produits portent déjà leur code-barres et n’ont aucune raison d’être pesés.",list:"not-weighable",get state(){return e(g)},get findings(){return e(J)},singular:"produit non pesable",plural:"produits non pesables",none:"Aucun produit non pesable sur le dernier import."});var $t=n(Bt,2);ze($t,{title:"Produits retirés depuis l’import précédent",note:"Un produit absent du nouveau fichier est marqué retiré à sa date, jamais supprimé.",children:(H,ge)=>{var _e=Rt(),ke=te(_e);{var Ke=Ie=>{var Oe=_l();l(Ie,Oe)},Je=Ie=>{var Oe=bl();l(Ie,Oe)},et=Ie=>{var Oe=wl(),$e=te(Oe),Ge=s($e);f(()=>o(Ge,e(Ve))),l(Ie,Oe)};_(ke,Ie=>{t.health.catalog===null?Ie(Ke):e(Se)===0?Ie(Je,1):Ie(et,-1)})}l(H,_e)},$$slots:{default:!0}});var Vt=n($t,2);ze(Vt,{title:"Décider d’un produit",note:"Retirer un produit et l’autoriser à peser moins sont deux décisions séparées : l’une n’efface pas l’autre.",children:(H,ge)=>{var _e=Sl(),ke=n(te(_e),2),Ke=n(ke,2);{var Je=Oe=>{var $e=xl(),Ge=te($e),tt=s(Ge),ut=n(Ge,2),xe=s(ut);Te(xe,21,()=>e(we),Xe=>Xe.id,(Xe,vt)=>{var St=yl(),nt=s(St),pt=s(nt),xt=n(nt,2),Lt=s(xt),It=n(xt,2),Dt=s(It);f(()=>{o(pt,e(vt).name),o(Lt,e(vt).id),o(Dt,`${e(vt).unit_price_text??""}${e(vt).price_suffix??""}`)}),Ce("click",nt,()=>dt(e(vt).id)),l(Xe,St)}),f(()=>o(tt,e(Fe))),l(Oe,$e)};_(Ke,Oe=>{e(W)!==""&&Oe(Je)})}var et=n(Ke,2);{var Ie=Oe=>{var $e=ql(),Ge=s($e),tt=n(s(Ge)),ut=s(tt),xe=n(tt),Xe=n(Ge,2),vt=s(Xe),St=n(Xe,4),nt=n(St,2);{var pt=de=>{var Ae=kl();l(de,Ae)};_(nt,de=>{e(je)===""&&de(pt)})}var xt=n(nt,2),Lt=n(s(xt),2),It=s(Lt);{var Dt=de=>{{let Ae=u(()=>e(w)==="offered"),De=u(()=>e(B)||!e(qe).canWithdraw);Pe(de,{act:"offered",kind:"destructive",label:"Ne plus proposer ce produit",protected:!0,get busy(){return e(Ae)},get disabled(){return e(De)},onrun:()=>{mt(!1)}})}},Ee=de=>{{let Ae=u(()=>e(w)==="offered"),De=u(()=>e(B)||!e(qe).canOfferAgain);Pe(de,{act:"offered",kind:"write",label:"Le proposer de nouveau",protected:!0,get busy(){return e(Ae)},get disabled(){return e(De)},onrun:()=>{mt(!0)}})}};_(It,de=>{e(ve)?de(Dt):de(Ee,-1)})}var Ue=n(xt,2),st=n(s(Ue),2),_t=n(st,2),P=s(_t);{let de=u(()=>e(w)==="waiver"),Ae=u(()=>e(B)||!e(qe).canSaveWaiver);Pe(P,{act:"waiver",kind:"write",label:"Enregistrer la dérogation",protected:!0,get busy(){return e(de)},get disabled(){return e(Ae)},onrun:()=>{yt(e(le))}})}var ee=n(P,2);{var be=de=>{{let Ae=u(()=>e(w)==="waiver-off"),De=u(()=>e(B)||!e(qe).canDropWaiver);Pe(de,{act:"waiver-off",kind:"write",label:"Retirer la dérogation",protected:!0,get busy(){return e(Ae)},get disabled(){return e(De)},onrun:()=>{yt(null)}})}};_(ee,de=>{e(oe)!==null&&de(be)})}f((de,Ae)=>{he($e,"data-decision",e(z)),o(ut,de),o(xe,` (${e(z)??""})`),o(vt,`En vigueur : ${Ae??""}`),Zt(st,e(K))},[()=>ht(e(z)),()=>e(se)===null?"aucune décision — ce produit suit les règles générales":_r(e(se))]),ia(St,()=>e(R),de=>v(R,de)),Ce("input",st,de=>v(K,de.currentTarget.value,!0)),l(Oe,$e)};_(et,Oe=>{e(z)!==""&&Oe(Ie)})}ia(ke,()=>e(W),Oe=>v(W,Oe)),l(H,_e)},$$slots:{default:!0}});var aa=n(Vt,2);Ts(aa,{get decisions(){return e(x)},nameOf:ht,onchoose:dt});var ra=n(aa,2);Ks(ra,{get imports(){return e(T)},get state(){return e(g)}}),l(a,rt),We()}ct(["change","click","input"]);function Cl(a){switch(a){case"ok":return"OK";case"warn":return"À SURVEILLER";case"fault":return"EN PANNE";case"off":return"SANS OBJET";default:return"INCONNU"}}var Pl=c('

                                            '),Tl=c('

                                            ');function zl(a,t){Me(t,!0);var r=Tl(),i=s(r),d=s(i),p=n(d,2),k=s(p),T=n(p,2),m=s(T),j=n(i,2),q=s(j),D=n(j,2);{var L=W=>{var z=Pl(),R=s(z);f(()=>o(R,t.light.remedy)),l(W,z)};_(D,W=>{t.light.remedy!==""&&W(L)})}f(W=>{he(r,"data-light",t.light.id),he(r,"data-level",t.light.level),he(d,"data-level",t.light.level),o(k,t.light.label),o(m,W),o(q,t.light.value)},[()=>Cl(t.light.level)]),l(a,r),We()}function jl(a){return[Rl(a),Ol(a),Nl(a),Il(a),Fl(a),Ul(a)]}function Rl(a){const t=a.state.scale,r=ua(t.median_ms);return a.scale_present?t.connected?t.too_slow?{id:"scale",label:"Balance",level:"warn",value:`une mesure toutes les ${r}, plus lent que la péremption`,remedy:"À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante. Vérifiez le câble et l’adaptateur USB, puis la cadence sur la page Matériel."}:{id:"scale",label:"Balance",level:"ok",value:Ml(t.provisional,`une mesure toutes les ${r}`),remedy:""}:{id:"scale",label:"Balance",level:"fault",value:"elle ne répond plus",remedy:"Vérifiez le câble et l’alimentation de la balance, puis touchez « Tester la balance ». En attendant, « Basculer en saisie manuelle » permet de continuer à servir."}:{id:"scale",label:"Balance",level:"off",value:"ce poste est déclaré sans balance",remedy:""}}function Ol(a){const t=a.state.printer;switch(t.health){case"faulted":return{id:"printer",label:"Imprimante",level:"fault",value:t.detail===""?"elle ne peut pas imprimer":t.detail,remedy:Al(a)};case"consumable":return{id:"printer",label:"Imprimante",level:"warn",value:"elle imprime, mais le rouleau arrive en fin de vie",remedy:"Changez le rouleau quand vous passez derrière le comptoir, puis touchez « J’ai changé le rouleau ». Le poste continue de servir en attendant."};case"ready":return{id:"printer",label:"Imprimante",level:"ok",value:"elle répond et n’a rien à signaler",remedy:""};default:return{id:"printer",label:"Imprimante",level:"unknown",value:"elle prend les étiquettes et ne dit rien en retour",remedy:"C’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne. Pour savoir si elle imprime, touchez « Imprimer une étiquette de test »."}}}function Al(a){const t="Regardez le capot, le rouleau et le câble, puis touchez « Tester l’imprimante ».";return a.printing?.fallback_available===!0?t+" Le poste peut servir en attendant : « Imprimer sur l’imprimante du poste voisin ».":t}function Nl(a){const t=a.roll;return t===null?{id:"roll",label:"Rouleau",level:"unknown",value:"aucun compteur d’étiquettes sur ce poste",remedy:`Sans imprimante construite, il n’y a pas de rouleau à compter : vérifiez le « ${Ct("printer.type")} » et ses réglages sur la page Matériel.`}:t.known?t.level==="warn"?{id:"roll",label:"Rouleau",level:"warn",value:t.message,remedy:"Changez le rouleau, puis touchez « J’ai changé le rouleau »."}:{id:"roll",label:"Rouleau",level:"ok",value:t.message,remedy:""}:{id:"roll",label:"Rouleau",level:"unknown",value:t.message,remedy:"Touchez « J’ai changé le rouleau » en mettant un rouleau neuf : c’est le seul geste qui dise quelque chose de vrai du papier."}}function Il(a){const t=a.catalog_source?.label??"";if(a.state.catalog_count===0)return{id:"catalog",label:"Catalogue",level:"fault",value:"aucun produit dans la grille",remedy:Dl(t)};const r=a.catalog;return r!==null&&r.result!=="applied"&&r.result!=="unchanged"?{id:"catalog",label:"Catalogue",level:"warn",value:`dernier fichier refusé : ${r.reason===""?r.result:r.reason}`,remedy:"La grille tourne toujours sur le catalogue précédent. Corrigez le fichier dans Odoo, ou touchez « Oublier la quarantaine » puis redéposez-le."}:{id:"catalog",label:"Catalogue",level:"ok",value:`${ne(a.state.catalog_count)} produits dans la grille`,remedy:""}}function Dl(a){return"Déposez le fichier du catalogue là où le poste le guette, ou utilisez « Importer un catalogue » ci-contre pour le glisser depuis une clé USB."+(a===""?"":` Ce poste surveille : ${a}.`)}function Fl(a){const t=a.disk;if(t===null)return{id:"disk",label:"Disque",level:"unknown",value:"la place libre n’a pas pu être mesurée",remedy:"Téléchargez le fichier de diagnostic : il porte ce que le poste a pu lire du volume, et c’est la pièce qu’un support demandera."};const r=Sa(t.free_bytes),i=`seuil ${ne(t.alert_mb)} Mo`;return t.free_bytes===0?{id:"disk",label:"Disque",level:"fault",value:`plus un octet libre sur ${t.path}`,remedy:"Le journal ne peut plus rien écrire, alors que les étiquettes continuent de sortir. Faites de la place sur le disque, puis rechargez cette page."}:t.alert_mb>0&&t.free_bytes0?{id:"journal",label:"Journal",level:"fault",value:`${ne(t)} pesées imprimées mais non enregistrées`,remedy:"Les étiquettes sortent, les ventes sont bonnes, c’est la trace qui manque. Téléchargez le fichier de diagnostic et prévenez le support : ne redémarrez rien."}:a.counters.journal_rows_count<0?{id:"journal",label:"Journal",level:"unknown",value:"ce poste n’a pas de journal ouvert",remedy:"Le poste pèse et imprime quand même. Téléchargez le fichier de diagnostic : il dit pourquoi la base n’a pas pu être ouverte."}:{id:"journal",label:"Journal",level:"ok",value:`${ne(a.counters.journal_rows_count)} pesées enregistrées`,remedy:""}}function Ml(a,t){return a?t+" (cadence encore provisoire)":t}function Wl(a){return a===null||!a.known?"unknown":a.configured?"ok":"missing"}var Bl=c("Une mesure toutes les ",1),Vl=c('

                                            '),Gl=c("Source : ",1),Hl=c(`

                                            Aucun import enregistré sur ce poste : le catalogue n’est jamais arrivé, ou le + journal ne le porte pas.

                                            `),Jl=c('

                                            ',1),Kl=c('

                                            ',1),$l=c('

                                            Aucune décision locale : le catalogue est proposé tel qu’il arrive.

                                            '),Yl=c('

                                            '),Xl=c(' '),Ql=c('
                                          • '),Zl=c('
                                              ',1),ei=c(`INCONNU — la question n’a pas pu être posée à ce système. Ce + n’est pas « non configuré » : personne ne sait encore.`,1),ti=c('OK ',1),ai=c('NON CONFIGURÉ ',1),ri=c('

                                              '),ni=c('

                                              ',1),si=c('

                                              Rien à signaler depuis le démarrage du poste.

                                              '),li=c(' '),ii=c('
                                            • '),oi=c('
                                                '),ui=c(''),ci=c('

                                                '),di=c('
                                                Numéro de poste
                                                Nom
                                                Coopérative
                                                Version
                                                Empreinte de configuration
                                                ',1),vi=c('
                                                ');function pi(a,t){Me(t,!0);const r=u(()=>t.health.new_version),i=u(()=>jl(t.health)),d=u(()=>t.health.state.scale),p=u(()=>t.health.unattended_restart),k=u(()=>t.health.decisions),T=5,m=u(()=>e(k).slice(0,T));var j=vi(),q=s(j);Te(q,21,()=>e(i),F=>F.id,(F,E)=>{zl(F,{get light(){return e(E)}})});var D=n(q,2);ze(D,{title:"Cadence de la balance",children:(F,E)=>{var w=Vl(),g=s(w);{var U=C=>{var y=ot("Ce poste est déclaré sans balance : le poids est saisi à la main.");l(C,y)},B=C=>{var y=ot(`La balance ne répond pas : aucune cadence n’est mesurable tant qu’aucune trame + n’arrive.`);l(C,y)},$=C=>{var y=ot(`Aucun intervalle n’a encore été mesuré : la cadence apparaîtra dès les premières + trames.`);l(C,y)},J=C=>{var y=Bl(),S=n(te(y)),O=s(S),A=n(S),h=n(A);{var V=M=>{var b=ot("C’est encore une valeur d’attente : moins de huit intervalles ont été observés.");l(M,b)};_(h,M=>{e(d).provisional&&M(V)})}var X=n(h,2);{var ae=M=>{var b=ot("À cette cadence, un poids serait périmé avant l’arrivée de la mesure suivante.");l(M,b)};_(X,M=>{e(d).too_slow&&M(ae)})}f((M,b)=>{o(O,M),o(A,`, médiane + observée sur ${b??""} intervalles. `)},[()=>ua(e(d).median_ms),()=>ne(e(d).observations_count)]),l(C,y)};_(g,C=>{t.health.scale_present?e(d).connected?e(d).observations_count===0?C($,2):C(J,-1):C(B,1):C(U)})}l(F,w)},$$slots:{default:!0}});var L=n(D,2);ze(L,{title:"Catalogue",children:(F,E)=>{var w=Kl(),g=te(w),U=s(g);{var B=S=>{var O=ot("Aucune source de catalogue n’est publiée par ce poste.");l(S,O)},$=S=>{var O=Gl(),A=n(te(O)),h=s(A);f(()=>o(h,t.health.catalog_source.label)),l(S,O)};_(U,S=>{t.health.catalog_source===null?S(B):S($,-1)})}var J=n(g,2);{var C=S=>{var O=Hl();l(S,O)},y=S=>{var O=Jl(),A=te(O),h=s(A),V=n(A,2);yr(V,{get record(){return t.health.catalog},get motives(){return t.health.catalog_motives},get onshowrows(){return t.onshowrows}}),f((X,ae)=>o(h,`Dernier essai : ${X??""} — + ${ae??""}${t.health.catalog.file_name===""?"":" ("+t.health.catalog.file_name+")"}${t.health.catalog.reason===""?"":" : "+t.health.catalog.reason}`),[()=>Pt(t.health.catalog.occurred_at),()=>ha(t.health.catalog.result)]),l(S,O)};_(J,S=>{t.health.catalog===null?S(C):S(y,-1)})}l(F,w)},$$slots:{default:!0}});var W=n(L,2);ze(W,{title:"Décisions locales en vigueur",note:"Ce qu’un humain a décidé de ce catalogue, avec son motif et sa date.",children:(F,E)=>{var w=Rt(),g=te(w);{var U=$=>{var J=$l();l($,J)},B=$=>{var J=Zl(),C=te(J);{var y=O=>{var A=Yl(),h=s(A);f(V=>o(h,`${V??""} décisions en vigueur — les + 5 plus récentes ci-dessous. La liste entière est sur la page + Catalogue.`),[()=>ne(e(k).length)]),l(O,A)};_(C,O=>{e(k).length>T&&O(y)})}var S=n(C,2);Te(S,21,()=>e(m),O=>O.product_id,(O,A)=>{var h=Ql(),V=s(h),X=s(V),ae=n(V,2);{var M=fe=>{var pe=Xl(),ce=s(pe);f(N=>o(ce,`peut peser à partir de ${N??""} g`),[()=>ne(e(A).min_weight_g)]),l(fe,pe)};_(ae,fe=>{e(A).min_weight_g!==null&&fe(M)})}var b=n(ae,2),I=s(b),Y=n(b,2),ue=s(Y);f(fe=>{o(X,`${e(A).offered?"Dérogation de poids":"Produit retiré"} — ${e(A).product_id??""}`),o(I,e(A).reason),o(ue,`${fe??""}, ${e(A).decided_by??""}`)},[()=>va(e(A).decided_at)]),l(O,h)}),l($,J)};_(g,$=>{e(k).length===0?$(U):$(B,-1)})}l(F,w)},$$slots:{default:!0}});var z=n(W,2);ze(z,{title:"Redémarrage sans intervention",children:(F,E)=>{var w=ni(),g=te(w),U=s(g);{var B=S=>{var O=ei();l(S,O)},$=S=>{var O=ti(),A=n(te(O));f(()=>o(A,` — après une coupure de courant, ce poste revient seul sur + l’écran client. ${e(p).detail??""}`)),l(S,O)},J=S=>{var O=ai(),A=n(te(O));f(()=>o(A,` — ${e(p).detail??""}`)),l(S,O)};_(U,S=>{e(p)===null||!e(p).known?S(B):e(p).configured?S($,1):S(J,-1)})}var C=n(g,2);{var y=S=>{var O=ri(),A=s(O);f(()=>o(A,e(p).remedy)),l(S,O)};_(C,S=>{e(p)!==null&&e(p).known&&!e(p).configured&&e(p).remedy!==""&&S(y)})}f(S=>he(g,"data-verdict",S),[()=>Wl(e(p))]),l(F,w)},$$slots:{default:!0}});var R=n(z,2);ze(R,{title:"Dix derniers événements",children:(F,E)=>{var w=Rt(),g=te(w);{var U=$=>{var J=si();l($,J)},B=$=>{var J=oi();Te(J,21,()=>t.health.events,C=>C.id,(C,y)=>{var S=ii(),O=s(S),A=s(O),h=n(O,2),V=s(h),X=n(h,2);{var ae=I=>{var Y=li(),ue=s(Y);f(()=>o(ue,e(y).code)),l(I,Y)};_(X,I=>{e(y).code!==""&&jt.showTechnicalNames&&I(ae)})}var M=n(X,2),b=s(M);f((I,Y)=>{he(S,"data-level",e(y).level),o(A,I),o(V,Y),o(b,e(y).message)},[()=>ws(e(y).occurred_at),()=>gr(e(y).source)]),l(C,S)}),l($,J)};_(g,$=>{t.health.events.length===0?$(U):$(B,-1)})}l(F,w)},$$slots:{default:!0}});var K=n(R,2);ze(K,{title:"Ce poste",children:(F,E)=>{var w=di(),g=te(w),U=n(s(g),2),B=s(U),$=n(U,4),J=s($),C=n($,4),y=s(C),S=n(C,4),O=s(S),A=n(S,4),h=s(A),V=n(g,2);{var X=ae=>{var M=ci(),b=s(M),I=n(b);{var Y=ue=>{var fe=ui();Ce("click",fe,function(...pe){t.onshowupdate?.apply(this,pe)}),l(ue,fe)};_(I,ue=>{t.onshowupdate!==void 0&&ue(Y)})}f(()=>{he(M,"data-update-available",e(r)),o(b,`Version ${e(r)??""} disponible. `)}),l(ae,M)};_(V,ae=>{e(r)!==""&&ae(X)})}f(()=>{o(B,t.health.station),o(J,t.health.station_name===""?"non renseigné":t.health.station_name),o(y,t.health.coop),o(O,t.health.version),o(h,t.health.config_fingerprint)}),l(F,w)},$$slots:{default:!0}}),l(a,j),We()}ct(["click"]);var fi=c('

                                                ');function Ka(a,t){Me(t,!0);var r=fi(),i=s(r),d=s(i),p=n(i,2),k=n(s(p),1,!0);f(()=>{o(d,t.title),he(p,"data-standing",t.name),he(p,"data-level",t.standing.level),o(k,t.standing.word)}),l(a,r),We()}const gi={0:"NUL",2:"STX",3:"ETX",4:"EOT",5:"ENQ",6:"ACK",9:"TAB",10:"LF",13:"CR",21:"NAK",27:"ESC",127:"DEL"};function mi(a){return[...new TextEncoder().encode(a)].map(t=>t.toString(16).toUpperCase().padStart(2,"0")).join(" ")}function hi(a){let t="";for(const r of a){const i=r.codePointAt(0)??0;if(i>=32&&i!==127){t+=r;continue}t+=`⟨${gi[i]??i.toString(16).toUpperCase().padStart(2,"0")}⟩`}return t}function _i(a){const t=bi(a);return t===""?$a(a):`${$a(a)} ${t}`}function $a(a){return a.configRead?a.declaredWithoutScale?"Ce poste est déclaré sans balance : aucun port n’est écouté.":a.port===""?"Aucun port n’est indiqué : choisissez-en un dans la liste ci-dessus pour écouter les trames.":a.listed?a.portKnown?a.halt!==""?`L’écoute de ${a.port} est arrêtée.`:a.acting!==""?`L’écoute de ${a.port} est suspendue le temps de l’acte en cours.`:`Écoute de ${a.port}.`:`${a.port} n’est pas visible depuis ce poste : rien n’est écouté en continu.`:a.acting==="ports"?`Énumération des ports en cours : l’écoute de ${a.port} démarre dès qu’il est vu.`:`Les ports de ce poste n’ont pas été énumérés : « Lister les ports » dira si ${a.port} existe.`:"Lecture de la configuration en cours : le port à écouter n’est pas encore connu."}function bi(a){return a.framesShown===0?"Aucune trame reçue pour l’instant.":a.framesShown===1?`Une seule trame reçue — ${ne(a.framesKept)} au plus sont gardées.`:`Les ${ne(a.framesShown)} dernières trames — ${ne(a.framesKept)} au plus, la plus récente en bas.`}function wi(a){return!a.configRead||a.declaredWithoutScale||a.port===""?"":a.halt!==""?"Reprendre l’écoute":a.listed&&!a.portKnown?"Écouter ce port une fois":""}function yi(a){return a.acting!=="detect"?"Détecter automatiquement":a.listening?"Détection : le port se libère…":a.toScan===0?"Détection : énumération des ports…":`Détection : port ${ne(a.scanned)} sur ${ne(a.toScan)}…`}function xi(a,t){return a?t.connected?t.too_slow?{level:"warn",word:"Trop lente",detail:Ya(t)+" À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante."}:{level:"ok",word:"Connectée",detail:"Elle répond. "+Ya(t)}:{level:"fault",word:"Sans réponse",detail:"Elle ne répond plus. Vérifiez le câble et l’alimentation, puis « Tester la balance » sur la page Dépannage."}:{level:"off",word:"Sans balance",detail:"Ce poste est déclaré sans balance : le feu est éteint et le poids se saisit à la main."}}function Ya(a){return a.observations_count===0?"Aucun intervalle n’a encore été mesuré : la cadence sera connue dès les premières trames.":`Une mesure toutes les ${ua(a.median_ms)} sur ${ne(a.observations_count)} intervalles`+(a.provisional?", cadence encore provisoire.":".")}const ki={ready:{level:"ok",word:"Prête",detail:"Elle répond et n’a rien à signaler."},consumable:{level:"warn",word:"Rouleau en fin de vie",detail:"Elle imprime, mais le rouleau arrive en fin de vie."},faulted:{level:"fault",word:"En panne",detail:"Elle ne peut pas imprimer."},unknown:{level:"unknown",word:"Silencieuse",detail:"Elle prend les étiquettes et ne dit rien en retour : c’est la réponse normale d’une file Windows en RAW ou d’un fichier de périphérique, pas une panne."}};function qi(a){const t=ki[a.health]??{level:"unknown",word:"État inconnu",detail:"Le poste a répondu un état que cet écran ne sait pas nommer."};return{...t,detail:a.detail===""?t.detail:a.detail}}function Si(a){const t=a.observed_at===""?"Jamais observée depuis le démarrage":`Observée le ${Pt(a.observed_at)}`,r=a.pending_jobs_count;return`${t}, ${ne(r)} ${r>1?"travaux":"travail"} en attente.`}const Li=["queue","path","address"],Ei="queue",Ci={queue:"Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.",path:"Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.",address:"L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque."};function Pi(a,t){return t!==""&&!a.some(r=>r.id===t)}function Ti(a,t){return a.length===0?[]:[...Pi(a,t)?[{value:t,label:`${t} — inconnu de ce poste`}]:[],...a.map(r=>({value:r.id,label:r.label}))]}function zi(a,t){return a.find(r=>r.id===t)?.key??Ei}function ji(a,t,r){const i=[...new Set(a.filter(k=>k.key!==r).map(k=>Ri(t,k.key)))].filter(k=>k!==""),d=a.filter(k=>k.key!==r).length,p=`${ne(d)} ${d>1?"destinations ne sont pas proposées":"destination n’est pas proposée"}`;return i.length===0?`${p} : aucun transport de ce poste ne les lit.`:`${p} : choisissez « ${i.join(" » ou « ")} » pour les voir.`}function Ri(a,t){return a.find(r=>r.key===t)?.label??""}var Oi=c(''),Ai=c(`

                                                Lecture de la configuration en cours… tant qu’elle n’est pas arrivée, cette page ne + déclare rien de ce poste.

                                                `),Xa=c('
                                              • '),Ni=c('
                                                  '),Ii=c('

                                                  ',1),Di=c('
                                                • '),Fi=c('

                                                    ',1),Ui=c('

                                                    '),Mi=c('
                                                  • '),Wi=c('
                                                      '),Bi=c('

                                                      '),Vi=c('

                                                        ',1),Gi=c('

                                                        '),Hi=c('

                                                        L’état et la cadence viennent de ce que le poste observe vraiment, jamais d’un réglage.

                                                        Réglages série de la balance

                                                        Réglages de l’imprimante
                                                        ');function Ji(a,t){Me(t,!0);const r=Be(t,"admin",7),i=20,d=3,p=250,k=50,T=d*1e3+p+1e3,m=12;let j=G(ft([])),q=G(ft([])),D=G(ft([])),L=G(ft([])),W=G(""),z=G(!1),R=G(""),K=G(0),F=G(0),E=G(!1),w=G(null),g=G(!1),U=G(!1),B=!1;const $=u(()=>t.health.state.scale),J=u(()=>t.health.state.printer),C=u(()=>t.draft.config!==null),y=u(()=>e(C)&&!t.draft.flag("scale.present")),S=u(()=>t.draft.text("scale.options.port")),O=u(()=>e(w)!==null&&e(w).port===e(S)?e(w).reason:""),A=u(()=>e(W)===e(S)?e(L):[]),h=u(()=>t.health.printer_transports),V=u(()=>t.draft.text("printer.options.transport")),X=u(()=>Ti(e(h),e(V))),ae=u(()=>zi(e(h),e(V))),M=u(()=>"printer.options."+e(ae)),b=u(()=>e(q).filter(P=>P.key===e(ae))),I=u(()=>e(q).length-e(b).length),Y=u(()=>xi(t.health.scale_present,e($))),ue=u(()=>qi(e(J))),fe=u(()=>({configRead:e(C),declaredWithoutScale:e(y),port:e(S),listed:e(z),portKnown:ie(e(S)),halt:e(O),acting:e(R),framesShown:e(A).length,framesKept:i})),pe=u(()=>_i(e(fe))),ce=u(()=>wi(e(fe))),N=u(()=>yi({acting:e(R),listening:e(E),scanned:e(K),toScan:e(F)})),Q=u(()=>Ye(t.draft,"scale.type")!==""||Ye(t.draft,"scale.options.port")!==""),re=u(()=>Ye(t.draft,"printer.type")!==""||Ye(t.draft,"printer.options.transport")!==""||Li.some(P=>Ye(t.draft,"printer.options."+P)!==""));qt(()=>{e(Q)&&v(g,!0)}),qt(()=>{e(re)&&v(U,!0)}),$r(()=>{B=!0}),Ta(()=>{oe("ports",le)}),qt(()=>{e(E)||!Z(e(S))||we(e(S))});function Z(P){return!B&&e(C)&&!e(y)&&P!==""&&P===e(S)&&ie(P)&&e(O)===""&&e(R)===""}function ie(P){return e(j).some(ee=>ee.name===P)}async function we(P){v(E,!0);try{for(;Z(P);){try{x(P,await Ua(P,d))}catch(ee){v(w,{port:P,reason:Re(ee)},!0);return}await se()}}finally{v(E,!1)}}async function Fe(){const P=e(S),ee=await r().protect(()=>Ua(P,d));ee!==null&&(x(P,ee),v(w,null))}function x(P,ee){if(B||ee.length===0)return;const be=P===e(W)?e(L):[];v(W,P,!0),v(L,[...be,...ee].slice(-i),!0)}function se(P=p){return new Promise(ee=>setTimeout(ee,P))}async function ve(){const P=Date.now()+T;for(;e(E)&&Date.now()pr(P));ee!==null&&(r().notice=ee.message,await r().refresh())}async function Ne(){v(K,0),v(F,0),e(z)||await le();const P=e(j);if(P.length===0){v(D,[],!0);return}await r().protect(()=>Ve(P))}async function Ve(P){v(D,[],!0),v(F,P.length,!0);for(const[ee,be]of P.entries()){v(K,ee+1);try{const de=await xn(be.name);v(D,[...e(D),{port:be.name,message:de.message,refused:!1}],!0)}catch(de){if(fa(de))throw de;v(D,[...e(D),{port:be.name,message:Re(de),refused:!0}],!0)}}}function ye(P,ee){const be=Qt(t.draft,P);return be.length>0?be:ee.slice(0,m)}function Re(P){return P instanceof zt?P.message:P instanceof Error?"Le poste n’a pas répondu : "+P.message:"Le poste n’a pas répondu."}const lt=[{what:"label",name:"étiquette"},{what:"alignment",name:"alignement"},{what:"ruler",name:"réglette"}],it=u(()=>lt.filter(P=>t.health.printer_self_tests.includes(P.what)));var gt=Hi(),ht=s(gt),dt=s(ht);Ka(dt,{title:"Balance",name:"scale",get standing(){return e(Y)}});var mt=n(dt,2),yt=s(mt),Ze=n(mt,4);{var me=P=>{var ee=Oi(),be=s(ee);f(()=>ta(be,e(y))),Ce("change",be,de=>t.draft.set("scale.present",!de.currentTarget.checked)),l(P,ee)},Le=P=>{var ee=Ai();l(P,ee)};_(Ze,P=>{e(C)?P(me):P(Le,-1)})}var rt=n(Ze,2),Ft=s(rt);{let P=u(()=>e(R)==="ports"),ee=u(()=>e(R)!=="");Pe(Ft,{label:"Lister les ports",get busy(){return e(P)},get disabled(){return e(ee)},onrun:()=>{oe("ports",le)}})}var Ot=n(Ft,2);{let P=u(()=>e(R)!=="");Pe(Ot,{act:"detect",get label(){return e(N)},protected:!0,get disabled(){return e(P)},onrun:()=>{oe("detect",Ne)}})}var Tt=n(rt,2);{var At=P=>{var ee=Ii(),be=te(ee),de=s(be),Ae=n(be,2);{var De=Qe=>{var kt=Ni();Te(kt,21,()=>e(j).slice(0,m),Et=>Et.name,(Et,bt)=>{var Gt=Xa(),Yt=s(Gt),_a=s(Yt),Er=n(Yt,2),Cr=s(Er);f(()=>{o(_a,e(bt).name),o(Cr,`${(e(bt).description===""?"aucune description USB":e(bt).description)??""} + ${e(bt).vid===""?"":` — VID ${e(bt).vid} PID ${e(bt).pid}`}`)}),Ce("click",Yt,()=>t.draft.set("scale.options.port",e(bt).name)),l(Et,Gt)}),l(Qe,kt)};_(Ae,Qe=>{e(j).length>0&&Qe(De)})}f(Qe=>o(de,Qe),[()=>e(j).length===0?"Aucun port série n’est visible depuis ce poste.":ya("port détecté","ports détectés",e(j).length,m)]),l(P,ee)};_(Tt,P=>{e(z)&&P(At)})}var Nt=n(Tt,2);{var Ut=P=>{var ee=Fi(),be=te(ee),de=s(be),Ae=n(be,2);Te(Ae,21,()=>e(D).slice(0,m),De=>De.port,(De,Qe)=>{var kt=Di();let Et;var bt=s(kt),Gt=s(bt),Yt=n(bt,2),_a=s(Yt);f(()=>{Et=Mt(kt,1,"svelte-1p4i0xm",null,Et,{refused:e(Qe).refused}),o(Gt,e(Qe).port),o(_a,e(Qe).message)}),l(De,kt)}),f(De=>o(de,De),[()=>ya("port interrogé","ports interrogés",e(D).length,m)]),l(P,ee)};_(Nt,P=>{e(D).length>0&&P(Ut)})}var Bt=n(Nt,2),$t=n(s(Bt),2),Vt=s($t);{let P=u(()=>t.draft.text("scale.type")),ee=u(()=>Ye(t.draft,"scale.type")),be=u(()=>Qt(t.draft,"scale.type")),de=u(()=>!e(C));at(Vt,{label:"Protocole",path:"scale.type",get value(){return e(P)},hint:"Les valeurs acceptées apparaissent ici si l’enregistrement est refusé.",get fault(){return e(ee)},get allowed(){return e(be)},get disabled(){return e(de)},onchange:Ae=>t.draft.set("scale.type",Ae)})}var aa=n(Vt,2);{let P=u(()=>t.draft.text("scale.options.port")),ee=u(()=>Ye(t.draft,"scale.options.port")),be=u(()=>ye("scale.options.port",e(j).map(Ae=>Ae.name))),de=u(()=>!e(C));at(aa,{label:"Port série",path:"scale.options.port",get value(){return e(P)},hint:"Choisissez-le dans la liste détectée ci-dessus plutôt que de le taper : l’écoute permanente ne suit que des ports détectés.",get fault(){return e(ee)},get allowed(){return e(be)},get disabled(){return e(de)},onchange:Ae=>t.draft.set("scale.options.port",Ae)})}var ra=n(Bt,2),H=s(ra),ge=s(H),_e=n(H,2);{var ke=P=>{var ee=Ui(),be=s(ee),de=n(be);{var Ae=De=>{{let Qe=u(()=>e(R)==="listen"),kt=u(()=>e(R)!=="");Pe(De,{act:"listen",get label(){return e(ce)},protected:!0,get busy(){return e(Qe)},get disabled(){return e(kt)},onrun:()=>{oe("listen",Fe)}})}};_(de,De=>{e(ce)!==""&&De(Ae)})}f(()=>o(be,`${e(O)??""} `)),l(P,ee)};_(_e,P=>{(e(O)!==""||e(ce)!=="")&&P(ke)})}var Ke=n(_e,2);{var Je=P=>{var ee=Wi();Te(ee,21,()=>e(A),da,(be,de)=>{var Ae=Mi(),De=s(Ae),Qe=s(De),kt=n(De,2),Et=s(kt);f((bt,Gt)=>{o(Qe,bt),o(Et,Gt)},[()=>mi(e(de)),()=>hi(e(de))]),l(be,Ae)}),l(P,ee)};_(Ke,P=>{e(A).length>0&&P(Je)})}var et=n(ht,2),Ie=s(et);Ka(Ie,{title:"Imprimante",name:"printer",get standing(){return e(ue)}});var Oe=n(Ie,2),$e=s(Oe),Ge=n(Oe,2),tt=s(Ge),ut=n(Ge,2),xe=s(ut);{let P=u(()=>e(R)==="printers"),ee=u(()=>e(R)!=="");Pe(xe,{label:"Lister les files",get busy(){return e(P)},get disabled(){return e(ee)},onrun:()=>{oe("printers",je)}})}var Xe=n(xe,2);{let P=u(()=>e(R)==="discover"),ee=u(()=>e(R)!=="");Pe(Xe,{label:"Rechercher l’imprimante",protected:!0,get busy(){return e(P)},get disabled(){return e(ee)},onrun:()=>{oe("discover",qe)}})}var vt=n(Xe,2);Te(vt,17,()=>e(it),P=>P.what,(P,ee)=>{{let be=u(()=>`Auto-test : ${e(ee).name}${e(R)===e(ee).what?" — en cours…":""}`),de=u(()=>e(R)!=="");Pe(P,{get act(){return e(ee).what},get label(){return e(be)},protected:!0,get disabled(){return e(de)},onrun:()=>{oe(e(ee).what,()=>Se(e(ee).what))}})}});var St=n(ut,2);{var nt=P=>{var ee=Bi(),be=s(ee);f(()=>o(be,e(it).length===0?"Le driver d’impression en service n’imprime aucun auto-test.":"Les autres auto-tests ne sont pas proposés : le driver d’impression en service ne les imprime pas.")),l(P,ee)};_(St,P=>{e(it).length{var ee=Vi(),be=te(ee),de=s(be),Ae=n(be,2);Te(Ae,21,()=>e(b).slice(0,m),De=>De.name,(De,Qe)=>{var kt=Xa(),Et=s(kt),bt=s(Et),Gt=n(Et,2),Yt=s(Gt);f(()=>{o(bt,e(Qe).name),o(Yt,`${e(Qe).detail??""}${e(Qe).default?" — file par défaut du système":""}`)}),Ce("click",Et,()=>t.draft.set(e(M),e(Qe).name)),l(De,kt)}),f(De=>o(de,De),[()=>ya("destination","destinations",e(b).length,m)]),l(P,ee)};_(pt,P=>{e(b).length>0&&P(xt)})}var Lt=n(pt,2);{var It=P=>{var ee=Gi(),be=s(ee);f(de=>o(be,de),[()=>ji(e(q),e(h),e(ae))]),l(P,ee)};_(Lt,P=>{e(I)>0&&P(It)})}var Dt=n(Lt,2),Ee=n(s(Dt),2),Ue=s(Ee);{let P=u(()=>t.draft.text("printer.type")),ee=u(()=>Ye(t.draft,"printer.type")),be=u(()=>Qt(t.draft,"printer.type")),de=u(()=>!e(C));at(Ue,{label:"Driver",path:"printer.type",get value(){return e(P)},hint:"Gardez le driver raster : c’est celui que les postes en service utilisent.",get fault(){return e(ee)},get allowed(){return e(be)},get disabled(){return e(de)},onchange:Ae=>t.draft.set("printer.type",Ae)})}var st=n(Ue,2);{let P=u(()=>Ye(t.draft,"printer.options.transport")),ee=u(()=>Qt(t.draft,"printer.options.transport")),be=u(()=>!e(C));at(st,{label:"Transport",path:"printer.options.transport",get value(){return e(V)},hint:"Local par défaut : une file Windows ou un nœud d’impression de ce poste.",get fault(){return e(P)},get allowed(){return e(ee)},get choices(){return e(X)},get disabled(){return e(be)},onchange:de=>t.draft.set("printer.options.transport",de)})}var _t=n(st,2);{let P=u(()=>Ct(e(M))),ee=u(()=>t.draft.text(e(M))),be=u(()=>Ci[e(ae)]??""),de=u(()=>Ye(t.draft,e(M))),Ae=u(()=>ye(e(M),e(b).map(Qe=>Qe.name))),De=u(()=>!e(C));at(_t,{get label(){return e(P)},get path(){return e(M)},get value(){return e(ee)},get hint(){return e(be)},get fault(){return e(de)},get allowed(){return e(Ae)},get disabled(){return e(De)},onchange:Qe=>t.draft.set(e(M),Qe)})}f(P=>{o(yt,e(Y).detail),o(ge,e(pe)),o($e,e(ue).detail),o(tt,P)},[()=>Si(e(J))]),Fa("open","toggle",Bt,P=>v(g,P),()=>e(g)),Fa("open","toggle",Dt,P=>v(U,P),()=>e(U)),l(a,gt),We()}ct(["change","click"]);const Qa={sent:"envoyée à l’imprimante",rejected:"refusée",failed:"en échec",reprint:"réimpression"},Za=[{value:"",label:"toutes"},{value:"sent",label:"envoyées à l’imprimante"},{value:"rejected",label:"refusées"},{value:"failed",label:"en échec"},{value:"reprint",label:"réimpressions"}],Ki={scale:"balance",manual:"saisie manuelle",replay:"trame rejouée"},$i={stable:"stable",unstable:"instable",unknown:"non déclarée par la balance",not_applicable:"sans objet — saisie manuelle"},Yi={by_weight:"au poids",by_unit:"à l’unité"},Xi={debug:"mise au point",info:"information",warn:"avertissement",error:"erreur",critical:"critique"};function na(a,t,r){return a[t]??r}var Qi=c(' '),Zi=c(' '),eo=c('
                                                      • '),to=c('
                                                          ');function ao(a,t){Me(t,!0);var r=to(),i=s(r);Te(i,21,()=>t.lines,d=>d.id,(d,p)=>{var k=eo(),T=s(k),m=s(T),j=n(T,2),q=s(j),D=n(j,2),L=s(D),W=n(D,2);{var z=w=>{var g=Qi(),U=s(g);f(()=>o(U,e(p).code)),l(w,g)};_(W,w=>{e(p).code!==""&&jt.showTechnicalNames&&w(z)})}var R=n(W,2),K=s(R),F=n(R,2);{var E=w=>{var g=Zi(),U=s(g);f(()=>o(U,e(p).detail)),l(w,g)};_(F,w=>{e(p).detail!==""&&w(E)})}f((w,g,U)=>{he(k,"data-level",e(p).level),o(m,w),o(q,g),o(L,U),o(K,e(p).message)},[()=>Pt(e(p).occurred_at),()=>na(Xi,e(p).level,"niveau inconnu"),()=>gr(e(p).source)]),l(d,k)}),l(a,r),We()}var ro=c(""),no=c('L’export sera proposé quand le journal aura répondu.'),so=c('L’export n’est pas proposé : ce poste n’a pas répondu à la lecture du journal.'),lo=c('Exporter en CSV'),io=c('

                                                          '),oo=c('

                                                          '),uo=c(`

                                                          Aucune trame brute n’a été enregistrée pour cette pesée : il n’y a + rien à rejouer.

                                                          `),co=c(`

                                                          La trame repart dans le décodeur du poste EN SERVICE : le poids + affiché au client change, et rien ne le remet comme il était. C’est + ce qui fait d’un refus inexpliqué un test permanent, sans + déplacement au magasin et sans balance.

                                                          `,1),vo=c('

                                                          Produit
                                                          Référence
                                                          Vente
                                                          Brut / tare / net
                                                          Stabilité
                                                          Origine du poids
                                                          Résultat
                                                          Trame brute
                                                          '),po=c(' ',1),fo=c('

                                                          QuandProduitNetCode-barresRésultatDuréeDétail
                                                          ',1),go=c('
                                                          ',1),mo=c('

                                                          '),ho=c('

                                                          ',1),_o=c('
                                                          ');function bo(a,t){Me(t,!0);const r=Be(t,"admin",7),i=200,d=5e3,p=50,k=500,T=7;let m=G(ft([])),j=G(ft([])),q=G(""),D=G(null),L=G(!1),W=G(!1),z=G("loading"),R=G("loading"),K=G(""),F=G("");const E=u(()=>({limit:String(i),...e(q)===""?{}:{result:e(q)}})),w=u(()=>({...e(E),limit:String(d)})),g=u(()=>e(m).find(h=>h.id===e(D))??null),U=u(()=>Za.find(h=>h.value===e(q))?.label??""),B=u(()=>y(e(m).length,i,"pesée","pesées",`L’export CSV en emporte jusqu’à ${ne(d)}.`)),$=u(()=>y(e(j).length,p,"ligne","lignes",`Le fichier de diagnostic emporte les ${ne(k)} dernières.`));J();async function J(){v(D,null),v(W,!0),v(z,"loading"),v(R,"loading"),v(m,[],!0),v(j,[],!0),v(K,""),v(F,"");try{const h=await r().load(()=>qn(e(E)));v(z,h===null?"unread":"read",!0),v(K,h===null?r().actionError:"",!0),v(m,h??[],!0);const V=await r().load(()=>Ln({limit:String(p)}));v(R,V===null?"unread":"read",!0),v(F,V===null?r().actionError:"",!0),v(j,V??[],!0)}finally{v(W,!1),r().actionError=""}}async function C(h){r().notice="",r().actionError="",v(L,!0);try{const V=await r().protect(()=>Tn(h));V!==null&&(r().notice=V.message)}finally{v(L,!1)}}function y(h,V,X,ae,M){const b=h>1?ae:X;return h{var X=go(),ae=te(X),M=n(s(ae),2);Te(M,21,()=>Za,Z=>Z.value,(Z,ie)=>{var we=ro(),Fe=s(we),x={};f(()=>{o(Fe,e(ie).label),x!==(x=e(ie).value)&&(we.value=(we.__value=e(ie).value)??"")}),l(Z,we)});var b=n(M,2);Pe(b,{label:"Rafraîchir",get busy(){return e(W)},onrun:()=>{J()}});var I=n(b,2);{var Y=Z=>{var ie=no();l(Z,ie)},ue=Z=>{var ie=so();l(Z,ie)},fe=Z=>{var ie=lo();f(we=>he(ie,"href",we),[()=>Sn(e(w))]),l(Z,ie)};_(I,Z=>{e(z)==="loading"?Z(Y):e(z)==="unread"?Z(ue,1):Z(fe,-1)})}var pe=n(ae,2);{var ce=Z=>{var ie=io(),we=s(ie);f(Fe=>o(we,`L’export emporte le même filtre que le tableau, mais pas son plafond : il descend + jusqu’à ${Fe??""} pesées, en point-virgule et en UTF-8 — il s’ouvre + tel quel dans le tableur d’un Windows français. Il ne demande aucun mot de passe : la + lecture du journal n’en demande pas non plus, et le fichier de diagnostic emporte déjà + les deux cents dernières pesées.`),[()=>ne(d)]),l(Z,ie)};_(pe,Z=>{e(z)==="read"&&Z(ce)})}var N=n(pe,2);{var Q=Z=>{var ie=oo(),we=s(ie);{var Fe=oe=>{var le=ot("Lecture du journal…");l(oe,le)},x=oe=>{var le=ot();f(()=>o(le,`Le journal n’a pas pu être lu : ${e(K)??""} Ce n’est pas « aucune pesée ».`)),l(oe,le)},se=oe=>{var le=ot("Le journal ne contient aucune pesée.");l(oe,le)},ve=oe=>{var le=ot();f(()=>o(le,`Aucune pesée ne correspond au filtre « ${e(U)??""} ».`)),l(oe,le)};_(we,oe=>{e(z)==="loading"?oe(Fe):e(z)==="unread"?oe(x,1):e(q)===""?oe(se,2):oe(ve,-1)})}l(Z,ie)},re=Z=>{var ie=fo(),we=te(ie),Fe=s(we),x=n(we,2),se=s(x),ve=n(s(se));Te(ve,21,()=>e(m),oe=>oe.id,(oe,le)=>{var je=po(),qe=te(je);let Se;var Ne=s(qe),Ve=s(Ne),ye=n(Ne),Re=s(ye),lt=n(ye),it=s(lt),gt=n(lt),ht=s(gt),dt=n(gt),mt=s(dt),yt=n(dt),Ze=s(yt),me=n(yt),Le=s(me),rt=s(Le),Ft=n(qe,2);{var Ot=Tt=>{var At=vo(),Nt=s(At);he(Nt,"colspan",T);var Ut=s(Nt),Bt=s(Ut),$t=n(Ut,2),Vt=n(s($t),2),aa=s(Vt),ra=n(Vt,4),H=s(ra),ge=n(ra,4),_e=s(ge),ke=n(ge,4),Ke=s(ke),Je=n(ke,4),et=s(Je),Ie=n(Je,4),Oe=s(Ie),$e=n(Ie,4),Ge=s($e),tt=n($e,4),ut=s(tt),xe=s(ut),Xe=n($t,2);{var vt=nt=>{var pt=uo();l(nt,pt)},St=nt=>{var pt=co(),xt=te(pt),Lt=s(xt);Pe(Lt,{kind:"destructive",label:"Rejouer cette trame",protected:!0,get busy(){return e(L)},onrun:()=>{C(e(g).frame)}}),l(nt,pt)};_(Xe,nt=>{e(g).frame===""?nt(vt):nt(St,-1)})}f((nt,pt,xt,Lt,It,Dt,Ee,Ue,st)=>{he(At,"data-detail",e(le).id),o(Bt,`Pesée ${e(g).id??""}`),o(aa,`${e(g).product_name??""} (${e(g).product_id??""})`),o(H,e(g).reference),o(_e,`${nt??""} — + ${pt??""} + ${e(g).quantity>1?"unités":"unité"}`),o(Ke,`${xt??""} g / ${Lt??""} g / + ${It??""} g`),o(et,`${Dt??""} — cadence + médiane ${Ee??""}`),o(Oe,Ue),o(Ge,`${st??""}${e(g).detail===""?"":` — ${e(g).detail}`}`),o(xe,e(g).frame===""?"aucune trame enregistrée":e(g).frame)},[()=>na(Yi,e(g).mode,"mode de vente inconnu"),()=>ne(e(g).quantity),()=>ne(e(g).gross_g),()=>ne(e(g).tare_g),()=>ne(e(g).net_g),()=>na($i,e(g).stability,"stabilité inconnue"),()=>ua(e(g).rate_ms),()=>na(Ki,e(g).source,"origine inconnue"),()=>na(Qa,e(g).result,"résultat inconnu")]),l(Tt,At)};_(Ft,Tt=>{e(D)===e(le).id&&e(g)!==null&&Tt(Ot)})}f((Tt,At,Nt,Ut)=>{Se=Mt(qe,1,"svelte-86o8oz",null,Se,{open:e(D)===e(le).id}),o(Ve,Tt),o(Re,e(le).product_name),o(it,`${At??""} g`),o(ht,e(le).barcode),o(mt,Nt),o(Ze,Ut),he(Le,"aria-expanded",e(D)===e(le).id),o(rt,e(D)===e(le).id?"fermer":"détail")},[()=>Pt(e(le).occurred_at),()=>ne(e(le).net_g),()=>na(Qa,e(le).result,"résultat inconnu"),()=>ua(e(le).duration_ms)]),Ce("click",Le,()=>v(D,e(D)===e(le).id?null:e(le).id,!0)),l(oe,je)}),f(()=>o(Fe,e(B))),l(Z,ie)};_(N,Z=>{e(m).length===0?Z(Q):Z(re,-1)})}Ce("change",M,()=>{J()}),Kr(M,()=>e(q),Z=>v(q,Z)),l(h,X)},$$slots:{default:!0}});var A=n(O,2);ze(A,{title:"Journal technique",children:(h,V)=>{var X=Rt(),ae=te(X);{var M=I=>{var Y=mo(),ue=s(Y);{var fe=N=>{var Q=ot("Lecture du journal technique…");l(N,Q)},pe=N=>{var Q=ot();f(()=>o(Q,`Le journal technique n’a pas pu être lu : ${e(F)??""} Ce n’est pas « aucune + ligne ».`)),l(N,Q)},ce=N=>{var Q=ot("Aucune ligne technique.");l(N,Q)};_(ue,N=>{e(R)==="loading"?N(fe):e(R)==="unread"?N(pe,1):N(ce,-1)})}l(I,Y)},b=I=>{var Y=ho(),ue=te(Y),fe=s(ue),pe=n(ue,2);ao(pe,{get lines(){return e(j)}}),f(()=>o(fe,e($))),l(I,Y)};_(ae,I=>{e(j).length===0?I(M):I(b,-1)})}l(h,X)},$$slots:{default:!0}}),l(a,S),We()}ct(["change","click"]);const er="L’aperçu n’a pas pu être rendu par le poste.",wo="Le poste a rendu l’aperçu, mais le navigateur ne l’a pas affiché.";async function yo(a){try{const t=await fetch(a,{headers:{accept:"application/json"}});if(t.ok)return wo;const r=JSON.parse(await t.text());return typeof r.message=="string"&&r.message!==""?r.message:er}catch{return er}}function tr(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return 0;r=r[i]}return typeof r=="number"?r:0}function kr(a){const t=Number(a);return a.trim()===""||Number.isNaN(t)?null:t}function xo(a){const t=a.trim().replace(",",".");if(!/^\d{1,3}(\.\d)?$/u.test(t))return null;const r=Number(t);return r>100?null:r}function qr(a,t){!(a instanceof HTMLInputElement)||a.value===t||(a.value=t)}var ko=c('

                                                          '),qo=c('

                                                          '),So=c('

                                                          '),Lo=c('

                                                          '),Eo=c('

                                                          Lecture de la configuration en cours… les flèches attendent qu’elle soit arrivée.

                                                          '),Co=c(`
                                                          Aperçu de l’étiquette telle qu’elle serait imprimée

                                                          Décalage horizontal

                                                          Décalage vertical

                                                          Le décalage ne descend pas sous zéro : le poste refuse un décalage négatif quel que + soit le gabarit. Le maximum, lui, dépend de la géométrie du gabarit ; il est annoncé + ici si l’enregistrement le dépasse.

                                                          Le symbole code-barres est volontairement tronqué : un symbole conforme n’entre pas + sur 40 × 25 mm avec les cinq champs texte. Ce n’est pas un défaut de rendu et il n’y + a rien à corriger.

                                                          Le détail chiffré du symbole — largeur de module, modules rendus, modules attendus — + n’est pas servi par ce poste. Cet écran n’affiche pas un chiffre qu’il aurait deviné.

                                                          `,1),Po=c(`

                                                          Chaque appui sort une étiquette pour de bon : le mot de passe est demandé au moment + d’imprimer, et l’impression repart d’elle-même une fois la session ouverte.

                                                          `,1),To=c('
                                                          ');function zo(a,t){Me(t,!0);const r=Be(t,"admin",7),i="printer.template",d="printer.options.offset_x",p="printer.options.offset_y",k=0,T=[{what:"alignment",label:"Imprimer la mire d’alignement"},{what:"ruler",label:"Imprimer la réglette"}];let m=G(1),j=G(!0),q=G(!1),D=G(""),L=G(null),W=G(""),z=null,R=!1;const K=u(()=>t.draft.text(i)),F=u(()=>kn(e(K),e(j),e(q),e(m))),E=u(()=>t.draft.config!==null),w=u(()=>t.draft.number(d)),g=u(()=>t.draft.number(p)),U=u(()=>Ye(t.draft,d)),B=u(()=>Ye(t.draft,p)),$=u(A),J=u(()=>r().busy||e(W)!=="");qt(()=>{if(t.draft.config===null)return;if(t.draft.dirty){z===null&&C();return}const I={x:t.draft.number(d),y:t.draft.number(p)};if(z!==null&&z.x===I.x&&z.y===I.y)return;const Y=z===null;z=I,v(L,I,!0),Y||S()});async function C(){if(!R){R=!0;try{const I=await za(),Y={x:tr(I.config,d),y:tr(I.config,p)};z=Y,v(L,Y,!0)}catch{}finally{R=!1}}}function y(I,Y){const ue=t.draft.number(I)+Y;ue=2?"s":""}`}function V(I,Y){const ue=kr(Y);ue!==null&&t.draft.set(I,ue)}async function X(I){if(!e(J)){v(W,I,!0),r().actionError="",r().notice="";try{const Y=await r().protect(()=>pr(I));if(Y===null)return;r().notice=Y.message,await r().refresh()}finally{v(W,"")}}}var ae=To(),M=s(ae);ze(M,{title:"Aperçu de l’étiquette",note:"Le même moteur que l’impression (A2) : le décalage se voit parce qu’il est cuit dans le bitmap. L’image porte le gabarit en cours d’édition, mais le décalage ENREGISTRÉ — jamais celui que les flèches sont en train de régler.",children:(I,Y)=>{var ue=Co(),fe=te(ue);{var pe=me=>{var Le=ko(),rt=s(Le);f(()=>o(rt,e($))),l(me,Le)};_(fe,me=>{e($)!==""&&me(pe)})}var ce=n(fe,2),N=s(ce),Q=s(N),re=n(Q,2);{var Z=me=>{var Le=qo(),rt=s(Le);f(()=>o(rt,e(D))),l(me,Le)};_(re,me=>{e(D)!==""&&me(Z)})}var ie=n(N,2),we=s(ie),Fe=n(s(we),2),x=s(Fe),se=n(we,2),ve=s(se);{let me=u(()=>!e(E)||e(w)<=k);Pe(ve,{label:"← 1 dot",get disabled(){return e(me)},onrun:()=>y(d,-1)})}var oe=n(ve,2);{let me=u(()=>!e(E));Pe(oe,{label:"1 dot →",get disabled(){return e(me)},onrun:()=>y(d,1)})}var le=n(se,2);{var je=me=>{var Le=So(),rt=s(Le);f(()=>o(rt,e(U))),l(me,Le)};_(le,me=>{e(U)!==""&&me(je)})}var qe=n(le,2),Se=n(s(qe),2),Ne=s(Se),Ve=n(qe,2),ye=s(Ve);{let me=u(()=>!e(E)||e(g)<=k);Pe(ye,{label:"↑ 1 dot",get disabled(){return e(me)},onrun:()=>y(p,-1)})}var Re=n(ye,2);{let me=u(()=>!e(E));Pe(Re,{label:"1 dot ↓",get disabled(){return e(me)},onrun:()=>y(p,1)})}var lt=n(Ve,2);{var it=me=>{var Le=Lo(),rt=s(Le);f(()=>o(rt,e(B))),l(me,Le)};_(lt,me=>{e(B)!==""&&me(it)})}var gt=n(lt,4),ht=s(gt),dt=n(gt,2),mt=s(dt),yt=n(dt,2);{var Ze=me=>{var Le=Eo();l(me,Le)};_(yt,me=>{e(E)||me(Ze)})}f((me,Le)=>{he(Q,"src",e(F)),o(x,me),o(Ne,Le)},[()=>h(e(w)),()=>h(e(g))]),Kt("load",Q,()=>v(D,"")),Kt("error",Q,()=>{O(e(F))}),Ce("change",ht,()=>S()),Da(ht,()=>e(j),me=>v(j,me)),Ce("change",mt,()=>S()),Da(mt,()=>e(q),me=>v(q,me)),l(I,ue)},$$slots:{default:!0}});var b=n(M,2);ze(b,{title:"Gabarit et impression",children:(I,Y)=>{var ue=Po(),fe=te(ue);{let re=u(()=>Ye(t.draft,i)),Z=u(()=>!e(E));at(fe,{label:"Gabarit",path:i,get value(){return e(K)},hint:"Le gabarit reproduit à l’identique s’appelle weighing_identical (A1).",get fault(){return e(re)},get disabled(){return e(Z)},onchange:ie=>{t.draft.set(i,ie),S()}})}var pe=n(fe,2);{let re=u(()=>t.draft.text("printer.options.darkness")),Z=u(()=>Ye(t.draft,"printer.options.darkness")),ie=u(()=>!e(E));at(pe,{label:"Noircissement",path:"printer.options.darkness",kind:"number",get value(){return e(re)},hint:"Trop bas, l’étiquette pâlit au soleil ; trop haut, elle bave et le scanner refuse.",get fault(){return e(Z)},get disabled(){return e(ie)},onchange:we=>V("printer.options.darkness",we)})}var ce=n(pe,2);{let re=u(()=>t.draft.text("printer.options.speed")),Z=u(()=>Ye(t.draft,"printer.options.speed")),ie=u(()=>!e(E));at(ce,{label:"Vitesse",path:"printer.options.speed",kind:"number",get value(){return e(re)},get fault(){return e(Z)},get disabled(){return e(ie)},onchange:we=>V("printer.options.speed",we)})}var N=n(ce,2);{let re=u(()=>t.draft.text("printer.options.copies")),Z=u(()=>Ye(t.draft,"printer.options.copies")),ie=u(()=>!e(E));at(N,{label:"Exemplaires",path:"printer.options.copies",kind:"number",get value(){return e(re)},hint:"Un client repart avec une étiquette : deux exemplaires se justifient, pas se devinent.",get fault(){return e(Z)},get disabled(){return e(ie)},onchange:we=>V("printer.options.copies",we)})}var Q=n(N,2);Te(Q,21,()=>T,re=>re.what,(re,Z)=>{{let ie=u(()=>e(W)===e(Z).what);Pe(re,{get act(){return e(Z).what},get label(){return e(Z).label},protected:!0,get busy(){return e(ie)},get disabled(){return e(J)},onrun:()=>{X(e(Z).what)}})}}),l(I,ue)},$$slots:{default:!0}}),l(a,ae),We()}ct(["change"]);const Sr=[{rank:1,code:"OVERLOAD",label:"Surcharge",when:"La balance annonce elle-même OL, ou le poids brut dépasse la capacité.",severity:"Bloquant",blocking:!0,message:"La balance est en surcharge. Retirez votre article.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la capacité, réglée au garde-fou 9 sous « ${Ct("limits.max_weight_g")} ».`},{rank:2,code:"MEASUREMENT_EXPIRED",label:"Poids périmé",when:"La mesure est plus vieille que la péremption, dans les deux modes de stabilité.",severity:"Bloquant",blocking:!0,message:"Poids indisponible. Patientez ou appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil à régler : le poste calcule lui-même à partir du rythme de la balance."},{rank:3,code:"BASKET_MISSING",label:"Panier absent",when:"Le poids brut tombe dans la fenêtre négative du panier : il a été soulevé.",severity:"Bloquant",blocking:!0,message:"Le panier n'est pas sur la balance. Reposez-le.",thresholds:[{path:"limits.basket_min_g",label:"Bas de la fenêtre du panier",hint:"En grammes, NÉGATIF : c’est le poids du panier que la balance a perdu."},{path:"limits.basket_max_g",label:"Haut de la fenêtre du panier",hint:"Négatif lui aussi, et plus proche de zéro que le bas."}],switchPath:"limits.basket_check_enabled",switchLabel:"Ce poste travaille avec un panier taré",note:"La règle s’active ou non, en bloc : il n’y a pas de demi-mesure à régler."},{rank:4,code:"SCALE_EMPTY",label:"Plateau vide",when:"Le poids brut ne sort pas de la bande « il n’y a rien sur le plateau ».",severity:"Bloquant — un filet, hors parcours nominal",blocking:!0,message:"Posez votre produit.",thresholds:[{path:"limits.empty_max_g",label:"Plateau considéré vide",hint:"En dessous, le poste considère qu’il n’y a rien sur le plateau."}],switchPath:"",switchLabel:"",note:"Toucher une tuile sur un plateau vide ARME la sélection au lieu d’être refusé : la règle reste évaluée pour la saisie manuelle et les chemins dérivés."},{rank:5,code:"TARE_REQUIRED",label:"Remise à zéro nécessaire",when:"Le brut est sous la bande du plateau vide, et hors de la fenêtre du panier.",severity:"Bloquant",blocking:!0,message:"La balance doit être remise à zéro.",thresholds:[],switchPath:"",switchLabel:"",note:`Seuil : la valeur négative de celui du garde-fou 4, « ${Ct("limits.empty_max_g")} ».`},{rank:6,code:"WEIGHT_UNSTABLE",label:"Pesée instable",when:"La trame déclare la mesure instable.",severity:"Information par défaut (A3)",blocking:!1,message:"Pesée en cours…",thresholds:[],switchPath:"",switchLabel:"",note:"La sévérité suit l’exigence de stabilité : elle passe à Bloquant quand celle-ci est réglée sur « blocking ». L’impression n’est jamais bloquée par défaut."},{rank:7,code:"TARE_INVALID",label:"Emballage incohérent",when:"Une tare a été saisie, et elle atteint la pesée ou dépasse le maximum.",severity:"Bloquant",blocking:!0,message:"Le poids de l'emballage est supérieur ou égal à la pesée.",thresholds:[{path:"limits.max_tare_g",label:"Tare maximum",hint:"Une tare plus lourde que le maximum est une faute de frappe."}],switchPath:"",switchLabel:"",note:""},{rank:8,code:"WEIGHT_TOO_LOW",label:"Poids trop faible",when:"Vente au poids : le NET est strictement positif et ne dépasse pas le plancher.",severity:"Bloquant",blocking:!0,message:"La balance doit être retarée, ou l'emballage est trop lourd.",thresholds:[{path:"limits.min_weight_g",label:"Poids minimum",hint:"Une dérogation par produit existe, dans l’onglet Catalogue."}],switchPath:"",switchLabel:"",note:""},{rank:9,code:"WEIGHT_TOO_HIGH",label:"Poids trop élevé",when:"Le NET dépasse la capacité — strictement, pour que la capacité reste atteignable.",severity:"Bloquant",blocking:!0,message:"{{.Weight}} kg, ça paraît un peu lourd !",thresholds:[{path:"limits.max_weight_g",label:"Poids maximum",hint:"C’est la capacité du champ NNDDD du code-barres, pas un seuil de vraisemblance."}],switchPath:"",switchLabel:"",note:""},{rank:10,code:"UNITS_OUT_OF_RANGE",label:"Nombre d’unités hors plage",when:"Vente à l’unité : la quantité sort de la plage.",severity:"Bloquant",blocking:!0,message:"{{.Quantity}} unités, ça paraît un peu beaucoup !",thresholds:[{path:"limits.min_units",label:"Unités minimum",hint:""},{path:"limits.max_units",label:"Unités maximum",hint:""}],switchPath:"",switchLabel:"",note:""},{rank:11,code:"AMOUNT_OUT_OF_CAPACITY",label:"Montant hors capacité du code-barres",when:"La charge utile encode un PRIX, et il dépasse ce que le champ peut porter.",severity:"Bloquant",blocking:!0,message:"Prix trop élevé pour le code-barres.",thresholds:[{path:"limits.max_amount_cents",label:"Montant maximum",hint:"En centimes. Aucun préfixe du plan livré n’encode un prix : la règle est éprouvée sans qu’aucun produit puisse l’atteindre."}],switchPath:"",switchLabel:"",note:""},{rank:12,code:"ZERO_PRICE",label:"Prix nul",when:"Le montant du tarif imprimé en grand vaut zéro.",severity:"Bloquant",blocking:!0,message:"Prix nul. Appelez un bénévole.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : un produit à 0 € est une anomalie sans nuance."},{rank:13,code:"LIGHT_PRODUCT_ALLOWED",label:"Produit léger autorisé",when:"Le garde-fou 8 n’a pas déclenché grâce à la dérogation du produit.",severity:"Information",blocking:!1,message:"",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil général : c’est la dérogation par produit, listée plus bas et posée depuis l’onglet Catalogue. Rien ne s’affiche au client ; l’id du produit est journalisé."},{rank:14,code:"PRODUCT_WITHDRAWN",label:"Produit retiré",when:"Quelqu’un a décidé de ne plus proposer ce produit.",severity:"Bloquant",blocking:!0,message:"Ce produit n'est pas disponible.",thresholds:[],switchPath:"",switchLabel:"",note:"Aucun seuil : c’est une décision humaine, prise depuis l’onglet Catalogue. Aucune règle d’import ne peut la déduire."}],jo=["{{.Weight}}","{{.Quantity}}"];function Ro(a){return Sr.find(t=>t.code===a)?.label??"Garde-fou inconnu de cet écran"}function Oo(a){return a==="blocking"?"Bloquant":a==="info"?"Information":"Sévérité inconnue de cet écran"}var Ao=c('

                                                          Rien ne s’affiche au client : c’est une information.

                                                          '),No=c('

                                                          '),Io=c('
                                                          '),Do=c('

                                                          '),Fo=c('
                                                        • '),Uo=c(`

                                                            Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve + dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur.

                                                            `,1);function Mo(a,t){Me(t,!0);function r(T,m){const j=kr(m);j!==null&&t.draft.set(T,j)}var i=Uo(),d=te(i);Te(d,21,()=>Sr,T=>T.code,(T,m)=>{var j=Fo(),q=s(j),D=s(q),L=s(D),W=n(D,2),z=s(W),R=n(W,2),K=s(R),F=n(R,2),E=s(F),w=n(q,2),g=s(w),U=n(w,2);{var B=A=>{var h=Ao();l(A,h)},$=A=>{var h=No(),V=s(h);f(()=>o(V,`« ${e(m).message??""} »`)),l(A,h)};_(U,A=>{e(m).message===""?A(B):A($,-1)})}var J=n(U,2);{var C=A=>{{let h=u(()=>t.draft.flag(e(m).switchPath));Na(A,{get path(){return e(m).switchPath},get label(){return e(m).switchLabel},get on(){return e(h)},onchange:V=>t.draft.set(e(m).switchPath,V)})}};_(J,A=>{e(m).switchPath!==""&&A(C)})}var y=n(J,2);Te(y,17,()=>e(m).thresholds,A=>A.path,(A,h)=>{var V=Io(),X=s(V);{let ae=u(()=>t.draft.text(e(h).path));at(X,{get label(){return e(h).label},get path(){return e(h).path},kind:"number",get value(){return e(ae)},get hint(){return e(h).hint},onchange:M=>r(e(h).path,M)})}Ce("focusout",V,ae=>qr(ae.target,t.draft.text(e(h).path))),l(A,V)});var S=n(y,2);{var O=A=>{var h=Do(),V=s(h);f(()=>o(V,e(m).note)),l(A,h)};_(S,A=>{e(m).note!==""&&A(O)})}f(A=>{he(j,"data-code",e(m).code),o(L,e(m).rank),o(z,e(m).label),o(K,e(m).code),he(F,"data-blocking",A),o(E,e(m).severity),o(g,e(m).when)},[()=>String(e(m).blocking)]),l(T,j)});var p=n(d,4),k=s(p);f(T=>o(k,`Les marqueurs ${T??""} sont remplacés par les valeurs de la pesée au + moment où le message s’affiche.`),[()=>jo.join(" et ")]),l(a,i),We()}ct(["focusout"]);function Wo(a){const t=a.value("pricing.tiers");return Array.isArray(t)?t.map(r=>{const i=r??{},d=i.discount_percent;return{code:String(i.code??""),label:String(i.label??""),abbrev:String(i.abbrev??""),written:d===void 0?null:String(d),discount:Bo(d)?d:null,rank:Number(i.rank??0)}}):[]}function Bo(a){return typeof a!="number"||!Number.isFinite(a)||a<0||a>100?!1:Math.abs(a*10-Math.round(a*10))<1e-9}function ar(a){return a===null?"":String(a).replace(".",",")}function Vo(a){const t=1e3-Math.round(a*10);return`${String(Math.trunc(t/100))},${String(t%100).padStart(2,"0")}`}var Go=c('

                                                            Aucun tarif déclaré dans la configuration lue.

                                                            '),Ho=c('Prix du catalogue Odoo — pas de remise'),rr=c(' '),Jo=c(' % ',1),Ko=c(' '),$o=c(`

                                                            CodeLibelléAbrégéRemiseOrdre

                                                            Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la + retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour + tous les adhérents.

                                                            `,1);function Yo(a,t){Me(t,!0);const r=u(()=>Wo(t.draft)),i=u(()=>String(t.draft.value("pricing.reference_code")??""));function d(q){return q<=1?`${String(q)} tarif déclaré`:`${String(q)} tarifs déclarés`}function p(q,D){const L=xo(D);L!==null&&t.draft.set(q,L)}var k=Rt(),T=te(k);{var m=q=>{var D=Go();l(q,D)},j=q=>{var D=$o(),L=te(D),W=s(L),z=n(L,2),R=s(z),K=n(s(R));Te(K,21,()=>e(r),da,(F,E,w)=>{var g=Ko(),U=s(g),B=s(U),$=n(U),J=s($);he(J,"aria-label",`Libellé du tarif ${w+1}`);var C=n($),y=s(C);he(y,"aria-label",`Abrégé du tarif ${w+1}`);var S=n(C),O=s(S);{var A=b=>{var I=Ho();l(b,I)},h=b=>{var I=rr(),Y=s(I);f(()=>o(Y,`${e(E).written??""} — le tarif de référence est le prix du catalogue : il + ne peut pas porter de remise, et l’enregistrement la refusera.`)),l(b,I)},V=b=>{var I=rr(),Y=s(I);f(()=>o(Y,`${e(E).written??""} — une remise s’écrit au dixième de point ; celle-ci se + change dans le fichier de configuration.`)),l(b,I)},X=b=>{var I=Jo(),Y=te(I);he(Y,"aria-label",`Remise du tarif ${w+1}`);var ue=n(Y,2),fe=s(ue);f((pe,ce)=>{Zt(Y,pe),o(fe,`un produit à 10,00 €/kg s’affiche ${ce??""} €/kg`)},[()=>ar(e(E).discount??0),()=>Vo(e(E).discount??0)]),Ce("input",Y,pe=>p(`pricing.tiers.${String(w)}.discount_percent`,pe.currentTarget.value)),Ce("focusout",Y,pe=>qr(pe.currentTarget,ar(e(E).discount??0))),l(b,I)};_(O,b=>{e(E).code===e(i)&&e(E).written===null?b(A):e(E).code===e(i)?b(h,1):e(E).written!==null&&e(E).discount===null?b(V,2):b(X,-1)})}var ae=n(S),M=s(ae);f(()=>{o(B,e(E).code),Zt(J,e(E).label),Zt(y,e(E).abbrev),o(M,e(E).rank)}),Ce("input",J,b=>t.draft.set(`pricing.tiers.${String(w)}.label`,b.currentTarget.value)),Ce("input",y,b=>t.draft.set(`pricing.tiers.${String(w)}.abbrev`,b.currentTarget.value)),l(F,g)}),f(F=>o(W,`${F??""}.`),[()=>d(e(r).length)]),l(q,D)};_(T,q=>{e(r).length===0?q(m):q(j,-1)})}l(a,k),We()}ct(["input","focusout"]);var Xo=c('

                                                            Aucune dérogation : la limite générale s’applique à tous les produits.

                                                            '),Qo=c(`

                                                            Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas + répondu. Les identifiants Odoo restent affichés.

                                                            `),Zo=c(`Produit retiré : le garde-fou 14 refuse le produit avant que cette + dérogation ait un sens.`),eu=c('
                                                          1. '),tu=c('

                                                              ',1);function au(a,t){Me(t,!0);function r(L){return Aa(t.names[L],t.namesState,"Nom non lu")}const i=20,d=u(()=>t.waivers.filter(L=>!L.offered)),p=u(()=>t.waivers.slice(0,i)),k=u(()=>`${ne(e(p).length)} ${e(p).length>1?"lignes affichées":"ligne affichée"} sur ${ne(t.waivers.length)} `+(e(d).length===0?`${t.waivers.length>1?"dérogations en vigueur":"dérogation en vigueur"}.`:`${t.waivers.length>1?"dérogations enregistrées":"dérogation enregistrée"}, dont ${ne(e(d).length)} sur un produit retiré, sans effet : le garde-fou 14 refuse le produit avant que le 8 ait un sens.`)+(t.waivers.length>e(p).length?" Les autres se lisent produit par produit depuis l’onglet Catalogue.":""));function T(L){return`plancher ${ne(L)} g : refusé à ${ne(L)} g et en dessous`}var m=Rt(),j=te(m);{var q=L=>{var W=Xo();l(L,W)},D=L=>{var W=tu(),z=te(W),R=s(z),K=n(z,2);{var F=w=>{var g=Qo();l(w,g)};_(K,w=>{t.namesState==="unread"&&w(F)})}var E=n(K,2);Te(E,21,()=>e(p),w=>w.product_id,(w,g)=>{var U=eu(),B=s(U),$=s(B),J=n(B,2),C=s(J),y=n(J,2),S=s(y),O=n(y,2),A=s(O),h=n(O,2),V=s(h),X=n(h,2);{var ae=M=>{var b=Zo();l(M,b)};_(X,M=>{e(g).offered||M(ae)})}f((M,b,I,Y)=>{he(U,"data-withdrawn",M),o($,b),o(C,e(g).product_id),o(S,I),o(A,e(g).reason),o(V,`${Y??""}, ${e(g).decided_by??""}`)},[()=>String(!e(g).offered),()=>r(e(g).product_id),()=>T(e(g).min_weight_g??0),()=>va(e(g).decided_at)]),l(w,U)}),f(()=>o(R,e(k))),l(L,W)};_(j,L=>{t.waivers.length===0?L(q):L(D,-1)})}l(a,m),We()}var ru=c(" ",1),nu=c(`

                                                              Les deux arrondis sont distincts parce qu’ils tombent à des endroits différents du + calcul : arrondir le prix au kilo puis le multiplier, ou multiplier puis arrondir, ne + donne pas le même centime sur une étiquette. Le poste applique l’arrondi commercial, + et l’écart se voit au centime près sur la même pesée.

                                                              `,1),su=c(`

                                                              Aucun verdict en ce moment : ces lignes portent sur la pesée EN COURS, et le poste + est au repos. Elles apparaissent le temps d’un cycle, quand un client pose son sac + et touche une tuile.

                                                              `),lu=c(' '),iu=c(' ',1),ou=c('
                                                            • '),uu=c('

                                                                ',1),cu=c(`

                                                                L’ordre compte : le premier verdict bloquant décide de ce que le client lit. Les + garde-fous 1 à 7 portent sur l’état de la balance, c’est-à-dire le poids brut ; les + garde-fous 8 à 14 portent sur la vente, c’est-à-dire le net. Le code en gris est + celui du journal, celui qu’on lit au téléphone.

                                                                Ce que les garde-fous disent de la pesée en cours

                                                                `,1),du=c(`

                                                                Le plan de numérotation n’est PAS ici, et c’est tout l’intérêt : préfixes, largeur de + la référence, largeur de la charge utile, décimales et mode de vente sont une + constante du binaire, indexée par préfixe et vérifiée au démarrage. + Un champ qui change le SENS du code lu par la caisse n’est pas un réglage, c’est un + contrat externe : il change avec une version du binaire, relue et testée, jamais + depuis l’écran d’un poste.

                                                                `,1),vu=c('
                                                                ');function pu(a,t){Me(t,!0);const r=u(()=>t.health.state.diagnostics),i=u(()=>t.health.decisions.filter(R=>R.min_weight_g!==null));let d=G(ft({})),p=G("loading");k();async function k(){try{const R=await ur();v(d,Object.fromEntries(R.products.map(K=>[K.id,K.name])),!0),v(p,"read")}catch{v(p,"unread")}}function T(R){return Aa(e(d)[R],e(p),"Nom non lu")}const m=u(()=>`${ne(e(r).length)} ${e(r).length>1?"verdicts":"verdict"} sur les quatorze garde-fous.`);var j=vu(),q=s(j);ze(q,{title:"Grille de tarifs",note:"Un second tarif n’est pas une case à cocher : c’est une ligne de plus dans cette grille.",children:(R,K)=>{var F=ru(),E=te(F);Yo(E,{get draft(){return t.draft}});var w=n(E,2);{let U=u(()=>t.draft.text("pricing.primary_code"));at(w,{label:"Tarif imprimé en grand",path:"pricing.primary_code",get value(){return e(U)},hint:"Le prix que le client lit sur l’étiquette (A7).",onchange:B=>t.draft.set("pricing.primary_code",B)})}var g=n(w,2);{let U=u(()=>t.draft.text("pricing.reference_code"));at(g,{label:"Tarif qui serait encodé si le code-barres portait un prix",path:"pricing.reference_code",get value(){return e(U)},hint:"Le plan livré ne porte pas de prix, mais un poids ou un nombre d’unités : la caisse retrouve le prix par la référence, dans Odoo, et ne doit jamais sous-facturer.",onchange:B=>t.draft.set("pricing.reference_code",B)})}l(R,F)},$$slots:{default:!0}});var D=n(q,2);ze(D,{title:"Les deux arrondis",children:(R,K)=>{var F=nu(),E=te(F);{let g=u(()=>t.draft.text("pricing.unit_price_rounding"));at(E,{label:"Arrondi du prix unitaire dérivé",path:"pricing.unit_price_rounding",get value(){return e(g)},hint:"Il s’applique au prix au kilo calculé par la remise.",onchange:U=>t.draft.set("pricing.unit_price_rounding",U)})}var w=n(E,2);{let g=u(()=>t.draft.text("pricing.amount_rounding"));at(w,{label:"Arrondi du montant",path:"pricing.amount_rounding",get value(){return e(g)},hint:"Il s’applique au montant de l’étiquette.",onchange:U=>t.draft.set("pricing.amount_rounding",U)})}l(R,F)},$$slots:{default:!0}});var L=n(D,2);ze(L,{title:"Les quatorze garde-fous, dans l’ordre d’évaluation",note:"Le seuil se modifie ici. La sévérité ne se règle pas : elle dit si le poste refuse ou avertit, et cela ne dépend pas du magasin. Le message affiché au client n’est pas encore modifiable depuis cet écran.",children:(R,K)=>{var F=cu(),E=n(te(F),2);Mo(E,{get draft(){return t.draft}});var w=n(E,4);{var g=B=>{var $=su();l(B,$)},U=B=>{var $=uu(),J=te($),C=s(J),y=n(J,2);Te(y,21,()=>e(r),S=>S.code,(S,O)=>{var A=ou(),h=s(A),V=s(h),X=n(h,2),ae=s(X),M=n(X,2),b=s(M),I=n(M,2);{var Y=pe=>{var ce=lu(),N=s(ce);f(()=>o(N,`« ${e(O).message??""} »`)),l(pe,ce)};_(I,pe=>{e(O).message!==""&&pe(Y)})}var ue=n(I,2);{var fe=pe=>{var ce=iu(),N=te(ce),Q=s(N),re=n(N,2),Z=s(re);f(ie=>{o(Q,ie),o(Z,e(O).product_id)},[()=>T(e(O).product_id)]),l(pe,ce)};_(ue,pe=>{e(O).product_id!==""&&pe(fe)})}f((pe,ce,N)=>{he(A,"data-blocking",pe),o(V,ce),o(ae,e(O).code),o(b,N)},[()=>String(e(O).blocking),()=>Ro(e(O).code),()=>Oo(e(O).severity)]),l(S,A)}),f(S=>{he(J,"data-verdicts",S),o(C,e(m))},[()=>String(e(r).length)]),l(B,$)};_(w,B=>{e(r).length===0?B(g):B(U,-1)})}l(R,F)},$$slots:{default:!0}});var W=n(L,2);ze(W,{title:"Code-barres",note:"Un seul réglage, et c’est voulu : le reste du plan de numérotation n’est pas de la configuration.",children:(R,K)=>{var F=du(),E=te(F);{let w=u(()=>t.draft.flag("barcode.verify_reference_check_digit"));Na(E,{path:"barcode.verify_reference_check_digit",label:"Refuser une référence dont la clé de contrôle est fausse",hint:"Décoché, le poste recalcule une clé juste sur une référence fausse, en silence — et la caisse encaisse un autre article.",get on(){return e(w)},onchange:g=>t.draft.set("barcode.verify_reference_check_digit",g)})}l(R,F)},$$slots:{default:!0}});var z=n(W,2);ze(z,{title:"Dérogations de poids minimum",note:"En lecture ici ; elles se modifient depuis l’onglet Catalogue, là où se trouve le produit.",children:(R,K)=>{au(R,{get waivers(){return e(i)},get names(){return e(d)},get namesState(){return e(p)}})},$$slots:{default:!0}}),l(a,j),We()}var fu=c(" "),gu=c(' '),mu=c('
                                                                ChampEn serviceDans le fichier
                                                                ');function hu(a,t){Me(t,!0);var r=mu(),i=s(r),d=n(s(i));Te(d,21,()=>t.rows,p=>p.path,(p,k)=>{const T=u(()=>Ct(e(k).path));var m=gu(),j=s(m),q=s(j),D=n(q);{var L=F=>{var E=fu(),w=s(E);f(()=>o(w,e(k).path)),l(F,E)};_(D,F=>{jt.showTechnicalNames&&e(T)!==e(k).path&&F(L)})}var W=n(j),z=s(W),R=n(W),K=s(R);f(()=>{he(m,"data-path",e(k).path),o(q,`${e(T)??""} `),o(z,e(k).before),o(K,e(k).after)}),l(p,m)}),l(a,r),We()}var _u=c(" "),bu=c(' '),wu=c("
                                                              • "),yu=c(`

                                                                  Recopier reste possible : les valeurs entrent dans le brouillon, où elles se + corrigent champ par champ avant l’enregistrement.

                                                                  `);function xu(a,t){Me(t,!0);const r=20,i=u(()=>t.faults.slice(0,r)),d=u(()=>ca(e(i).length,t.faults.length,"contrôle refuse une clé","contrôles refusent une clé"));var p=yu(),k=s(p),T=s(k),m=n(k,2);Te(m,21,()=>e(i),da,(j,q)=>{var D=wu(),L=s(D),W=s(L),z=n(L,2);{var R=w=>{var g=_u(),U=s(g);f(()=>o(U,e(q).field)),l(w,g)};_(z,w=>{jt.showTechnicalNames&&w(R)})}var K=n(z),F=n(K);{var E=w=>{var g=bu(),U=s(g);f(B=>o(U,`Valeurs acceptées : ${B??""}.`),[()=>e(q).allowed.join(", ")]),l(w,g)};_(F,w=>{e(q).allowed!==void 0&&e(q).allowed.length>0&&w(E)})}f(w=>{o(W,w),o(K,` ${e(q).message??""} `)},[()=>Ct(e(q).field)]),l(j,D)}),f(()=>o(T,`Ce fichier serait refusé en l’état : ${e(d)??""}`)),l(a,p),We()}const ku=2e3,qu=300*1e3;async function Su(){const a=Date.now()+qu;for(;Date.now()setTimeout(t,ku));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var Lu=c('

                                                                  ',1),Eu=c(`

                                                                  Touchez de nouveau pour confirmer. Rien ne défait un redémarrage une fois + l’ordinateur parti.

                                                                  `),Cu=c(" ",1),Pu=c('

                                                                  '),Tu=c('
                                                                • '),zu=c('
                                                                    '),ju=c(`

                                                                    Trois gestes de reprise, du plus doux au plus brutal. Le premier ne coupe rien.

                                                                    Relire le fichier de configuration
                                                                    Met en service config.json tel qu’il est sur le disque, sans arrêter le + poste. À utiliser après une modification faite à la main dans le fichier.
                                                                    Redémarrer le poste
                                                                    Arrête l’application et la relance. La pesée est interrompue quelques secondes ; + l’écran client revient tout seul.
                                                                    Redémarrer l’ordinateur
                                                                    Redémarre la machine entière. Comptez une minute avant que l’écran revienne.
                                                                    `,1);function Ru(a,t){Me(t,!0);let r=G(""),i=G(""),d=G(ft([])),p=G(!1),k=G(0),T=G(0),m=G(!1);const j=u(()=>e(k)>0);qt(()=>{if(!e(j))return;const z=setInterval(()=>{v(T,Math.max(0,Math.round((e(k)-Date.now())/1e3)),!0)},1e3);return()=>clearInterval(z)});async function q(){v(r,"reload-config"),v(i,""),v(d,[],!0);try{const z=await t.admin.protect(async()=>{try{return await vn()}catch(R){if(fa(R))throw R;return t.admin.report(R),v(d,t.admin.lastFaults,!0),null}});if(z===null)return;v(i,`Le fichier est en service. Empreinte ${z.config_fingerprint}.`)}finally{v(r,"")}}async function D(){v(r,"restart"),v(i,""),v(d,[],!0);try{const z=await t.admin.protect(()=>pn());if(z===null)return;v(i,z.message,!0),v(p,!0),v(i,await Su()?"Le poste est revenu.":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0)}finally{v(r,""),v(p,!1)}}async function L(){if(!e(m)){v(m,!0);return}v(r,"reboot"),v(i,""),v(d,[],!0);try{const z=await t.admin.protect(()=>fn());if(z===null)return;v(k,Date.parse(z.at),!0),v(T,z.seconds_left,!0)}finally{v(r,""),v(m,!1)}}async function W(){v(r,"cancel-reboot");try{const z=await t.admin.protect(()=>gn());if(z===null)return;v(k,0),v(i,z.message,!0)}finally{v(r,"")}}ze(a,{title:"Maintenance",children:(z,R)=>{var K=ju(),F=n(te(K),2),E=n(s(F),2),w=n(s(E),3);{let h=u(()=>e(r)==="reload-config");Pe(w,{kind:"write",act:"reload-config",label:"Relire le fichier",protected:!0,get busy(){return e(h)},onrun:q})}var g=n(E,4),U=n(s(g));{let h=u(()=>e(r)==="restart"||e(p));Pe(U,{kind:"write",act:"restart",label:"Redémarrer le poste",protected:!0,get busy(){return e(h)},onrun:D})}var B=n(g,4),$=n(s(B));{var J=h=>{var V=Lu(),X=te(V),ae=s(X),M=n(X,2);{let b=u(()=>e(r)==="cancel-reboot");Pe(M,{kind:"write",act:"cancel-reboot",label:"Annuler",get busy(){return e(b)},onrun:W})}f(()=>o(ae,`L’ordinateur redémarre dans ${e(T)??""} seconde${e(T)>1?"s":""}.`)),l(h,V)},C=h=>{var V=Cu(),X=te(V);{var ae=b=>{var I=Eu();l(b,I)};_(X,b=>{e(m)&&b(ae)})}var M=n(X,2);{let b=u(()=>e(m)?"Confirmer le redémarrage":"Redémarrer l’ordinateur"),I=u(()=>e(r)==="reboot");Pe(M,{kind:"destructive",act:"reboot",get label(){return e(b)},protected:!0,get busy(){return e(I)},onrun:L})}l(h,V)};_($,h=>{e(j)?h(J):h(C,-1)})}var y=n(F,2);{var S=h=>{var V=Pu(),X=s(V);f(()=>o(X,e(i))),l(h,V)};_(y,h=>{e(i)!==""&&h(S)})}var O=n(y,2);{var A=h=>{var V=zu();Te(V,21,()=>e(d),X=>X.field,(X,ae)=>{var M=Tu(),b=s(M),I=s(b),Y=n(b);f(()=>{o(I,e(ae).field),o(Y,` — ${e(ae).message??""}`)}),l(X,M)}),l(h,V)};_(O,h=>{e(d).length>0&&h(A)})}l(z,K)},$$slots:{default:!0}}),We()}function Ou(a,t){const r=[];for(const i of[...La(a),...La(t)]){if(r.some(k=>k.path===i))continue;const d=nr(ga(a,i)),p=nr(ga(t,i));d!==p&&r.push({path:i,before:d,after:p})}return r}function ga(a,t){let r=a;for(const i of t.split(".")){if(r===null||typeof r!="object")return;r=r[i]}return r}function La(a,t=""){if(a===null||typeof a!="object"||Array.isArray(a))return t===""?[]:[t];const r=[];for(const[i,d]of Object.entries(a))r.push(...La(d,t===""?i:`${t}.${i}`));return r}function nr(a){return a===void 0?"—":a===null?"vide":typeof a=="boolean"?a?"oui":"non":typeof a=="object"?JSON.stringify(a):String(a)}const Au=new Set(["modified_at","catalog.options.password"]),Nu=[{path:"station.name",name:"le nom du poste"},{path:"network.listen",name:"l’adresse d’écoute"},{path:"scale.options.port",name:"le port de la balance"},{path:"printer.options.queue",name:"la file d’impression"},{path:"printer.options.path",name:"le nœud d’impression"},{path:"printer.options.address",name:"l’adresse de l’imprimante"},{path:"catalog.options.url",name:"l’adresse du partage"},{path:"catalog.options.username",name:"le compte du partage"},{path:"catalog.images.path",name:"le chemin des images"}];function Iu(a,t){return a===null||t===null?[]:Ou(a,t).filter(r=>!Au.has(r.path))}function Du(a,t){if(a===null||t===null)return[];const r=a,i=t;return Nu.filter(d=>sr(i,d.path)&&!sr(r,d.path))}function sr(a,t){const r=ga(a,t);return r==null||r===""?!0:typeof r!="object"||Array.isArray(r)?!1:Object.keys(r).length===0}function Fu(a){return a.length<2?a.join(""):`${a.slice(0,-1).join(", ")} et ${a[a.length-1]??""}`}async function Uu(a,t){const i=await fetch(`/admin/api/config/export?hardware=${t?"1":"0"}`,{headers:{accept:"application/json"}});if(!i.ok)throw new zt(i.status,Lr(await i.text(),"L’export"));return{name:Wu(a,t),blob:await i.blob()}}async function Mu(a){const t=await fetch("/admin/api/config/import",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(a)}),r=await t.text();if(!t.ok)throw new zt(t.status,Lr(r,"L’import"));return JSON.parse(r)}function Wu(a,t){const r=t?"":"-sans-materiel";return`config-poste${String(a)}${r}-${Bu()}.json`}function Bu(){const a=new Date,t=String(a.getMonth()+1).padStart(2,"0"),r=String(a.getDate()).padStart(2,"0");return`${String(a.getFullYear())}-${t}-${r}`}function Vu(a,t){const r=URL.createObjectURL(t),i=document.createElement("a");i.href=r,i.download=a,document.body.appendChild(i),i.click(),i.remove(),setTimeout(()=>{URL.revokeObjectURL(r)},0)}function Lr(a,t){try{const r=JSON.parse(a);if(typeof r?.message=="string"&&r.message!=="")return r.message}catch{}return`${t} a été refusé par le poste.`}var Gu=c('
                                                                    Empreinte de la configuration en service
                                                                    Version du binaire
                                                                    Répertoire de données
                                                                    Espace disque
                                                                    ',1),Hu=c('

                                                                    '),Ju=c(`

                                                                    Ce fichier n’a été comparé à rien : la configuration en service n’a pas pu être + lue. Ni « identique », ni « n champs changent » — ce que ce fichier changerait + sur ce poste reste inconnu.

                                                                    `),Ku=c(`

                                                                    Ce fichier décrit la même configuration que celle en service : il n’y a rien à + recopier. C’est ce qu’on veut lire à la fin d’un clonage. Deux champs ne sont pas + comparés : la date du dernier enregistrement, que chaque poste écrit lui-même, et + le mot de passe du catalogue, qu’aucun des deux ne porte en clair.

                                                                    `),$u=c('

                                                                    '),Yu=c(`

                                                                    Recopier n’applique rien : les valeurs entrent dans le brouillon, et c’est + « Enregistrer » qui les met en service.

                                                                    `,1),Xu=c('

                                                                    ',1),Qu=c(`

                                                                    L’export emporte encore l’empreinte du mot de passe : c’est la seule lecture que le + poste garde derrière la clé. L’import, lui, est lu PAR LE POSTE, qui écarte les deux + secrets et le numéro de poste avant de dire ce qui changerait.

                                                                    `,1),Zu=c('

                                                                    Lecture des versions enregistrées…

                                                                    '),ec=c(`

                                                                    Les versions enregistrées n’ont pas pu être lues : cette liste ne dit donc rien de + ce que ce poste garde. Ce n’est pas « aucune version ».

                                                                    `),tc=c('

                                                                    Aucune version enregistrée : ce poste n’a jamais été reconfiguré.

                                                                    '),ac=c('
                                                                  • '),rc=c(`

                                                                      Remettre une version en service remplace la configuration du poste sur-le-champ, et + ce qui n’a pas été enregistré est perdu : c’est le seul geste de cette page qui + change le poste tout de suite, et le seul qui garde ses 72 px.

                                                                      `,1),nc=c('
                                                                      ');function sc(a,t){Me(t,!0);const r=Be(t,"admin",7),i=40,d=5;let p=G(ft([])),k=G("reading"),T=G(null),m=G(""),j=G(null),q=G(""),D=G(ft([])),L=G(""),W="";const z=u(()=>r().busy||e(L)!==""),R=u(()=>Iu(e(T),e(j))),K=u(()=>e(T)!==null&&e(j)!==null),F=u(()=>Du(e(T),e(j))),E=u(()=>e(R).slice(0,i)),w=u(()=>e(p).slice(0,d)),g=u(()=>ca(e(E).length,e(R).length,"champ qui change","champs qui changent")),U=u(()=>ca(e(w).length,e(p).length,"version enregistrée","versions enregistrées")),B=u(()=>e(R).length>1?`Recopier ces ${ne(e(R).length)} champs dans le brouillon`:"Recopier ce champ dans le brouillon");$(),qt(()=>{const b=t.health.config_fingerprint;b!==W&&(W=b,J())});async function $(){const b=await r().load(hn);if(b===null){v(k,"unreadable");return}v(p,b,!0),v(k,"read")}async function J(){try{v(T,(await za()).config,!0),v(m,"")}catch(b){v(T,null),v(m,b instanceof Error?b.message:"Le poste n’a pas répondu.",!0)}}async function C(b){v(L,b?"export-all":"export-clone",!0),r().notice="",r().actionError="";try{const I=await r().protect(()=>Uu(t.health.station,b));if(I===null)return;Vu(I.name,I.blob),r().notice=`${I.name} est remis au navigateur : c’est lui qui l’enregistre, voyez ses téléchargements.`}finally{v(L,"")}}async function y(b){if(b==null)return;r().notice="",r().actionError="";let I;try{I=JSON.parse(await b.text())}catch{r().actionError=`${b.name} n’est pas un fichier JSON lisible.`;return}v(L,"import");try{const Y=await r().protect(()=>Mu(I));if(Y===null)return;v(j,Y.config,!0),v(q,b.name,!0),v(D,Y.faults,!0),await J(),r().notice=S(b.name)}finally{v(L,"")}}function S(b){return e(K)?e(R).length===0?`${b} décrit la même configuration que celle en service.`:`${b} est lu. Rien n’est appliqué : relisez le tableau.`:`${b} est lu, mais la configuration en service ne l’est pas : rien ne peut être comparé.`}async function O(){if(e(j)===null)return;const b=e(j),I=[...e(R)];for(const Y of I)t.draft.set(Y.path,ga(b,Y.path));r().actionError="",r().notice="Le fichier est recopié dans le brouillon. Rien n’est appliqué avant « Enregistrer ».",await J()}async function A(b){v(L,`restore-${String(b)}`),r().notice="",r().actionError="";try{if(await r().protect(()=>_n(b))===null)return;await t.draft.load(),await $(),await J(),await r().refresh(),r().notice=`La version ${String(b)} est remise en service.`}finally{v(L,"")}}var h=nc(),V=s(h);ze(V,{title:"Identité du poste",children:(b,I)=>{var Y=Gu(),ue=te(Y);{let oe=u(()=>t.draft.text("station.number")),le=u(()=>Ye(t.draft,"station.number")),je=u(()=>Qt(t.draft,"station.number"));at(ue,{label:"Numéro du poste",path:"station.number",kind:"number",get value(){return e(oe)},get fault(){return e(le)},get allowed(){return e(je)},hint:"C’est de lui que dérive le nom du fichier de catalogue attendu, flv_.csv.",onchange:qe=>t.draft.set("station.number",Number(qe))})}var fe=n(ue,2);{let oe=u(()=>t.draft.text("station.name")),le=u(()=>Ye(t.draft,"station.name")),je=u(()=>Qt(t.draft,"station.name"));at(fe,{label:"Nom du poste",path:"station.name",get value(){return e(oe)},get fault(){return e(le)},get allowed(){return e(je)},hint:"Ce que lit un bénévole : « Poste 2 — fruits ».",onchange:qe=>t.draft.set("station.name",qe)})}var pe=n(fe,2);{let oe=u(()=>t.draft.text("station.coop")),le=u(()=>Ye(t.draft,"station.coop")),je=u(()=>Qt(t.draft,"station.coop"));at(pe,{label:"Coopérative",path:"station.coop",get value(){return e(oe)},get fault(){return e(le)},get allowed(){return e(je)},onchange:qe=>t.draft.set("station.coop",qe)})}var ce=n(pe,2),N=n(s(ce),2),Q=s(N),re=n(N,4),Z=s(re),ie=n(re,4),we=s(ie),Fe=n(ie,4),x=s(Fe);{var se=oe=>{var le=ot("non mesuré");l(oe,le)},ve=oe=>{var le=ot();f((je,qe,Se)=>o(le,`${je??""} libres sur + ${qe??""} — seuil d’alerte + ${Se??""} Mo`),[()=>Sa(t.health.disk.free_bytes),()=>Sa(t.health.disk.total_bytes),()=>ne(t.health.disk.alert_mb)]),l(oe,le)};_(x,oe=>{t.health.disk===null?oe(se):oe(ve,-1)})}f(()=>{o(Q,t.health.config_fingerprint),o(Z,t.health.version),o(we,t.health.disk===null?"non publié par ce poste":t.health.disk.path)}),l(b,Y)},$$slots:{default:!0}});var X=n(V,2);ze(X,{title:"Exporter, importer",note:"Pour installer un autre poste : ce fichier emporte les tarifs, les garde-fous, l’étiquette, les catégories, et les réglages du matériel que les quatre postes partagent — le décalage d’étiquette, le noircissement, la vitesse, le débit de la balance. Reste ici ce qui désigne ce poste-ci ou ce magasin — le mot de passe, le code de secours, le numéro et le nom du poste, le port de la balance, la file d’impression, l’adresse du partage et son compte, le chemin des images et le réseau.",children:(b,I)=>{var Y=Qu(),ue=te(Y),fe=s(ue);{let x=u(()=>e(L)==="export-all");Pe(fe,{label:"Exporter tout",protected:!0,get busy(){return e(x)},get disabled(){return e(z)},onrun:()=>{C(!0)}})}var pe=n(fe,2);{let x=u(()=>e(L)==="export-clone");Pe(pe,{label:"Exporter sans le matériel",protected:!0,get busy(){return e(x)},get disabled(){return e(z)},onrun:()=>{C(!1)}})}var ce=n(pe,2);let N;var Q=s(ce),re=n(Q,3),Z=n(ue,4);{var ie=x=>{var se=Hu(),ve=s(se);f(()=>o(ve,`La configuration en service n’a pas pu être lue : ${e(m)??""} La colonne + « En service » ne peut donc rien affirmer.`)),l(x,se)};_(Z,x=>{e(m)!==""&&x(ie)})}var we=n(Z,2);{var Fe=x=>{var se=Xu(),ve=te(se),oe=s(ve),le=n(ve,2);{var je=ye=>{xu(ye,{get faults(){return e(D)}})};_(le,ye=>{e(D).length>0&&ye(je)})}var qe=n(le,2);{var Se=ye=>{var Re=Ju();l(ye,Re)},Ne=ye=>{var Re=Ku();l(ye,Re)},Ve=ye=>{var Re=Yu(),lt=te(Re),it=s(lt),gt=n(it);{var ht=Le=>{var rt=ot("Les autres sont dans le fichier, et « Recopier » les prend tous.");l(Le,rt)};_(gt,Le=>{e(R).length>e(E).length&&Le(ht)})}var dt=n(lt,2);hu(dt,{get rows(){return e(E)}});var mt=n(dt,2);{var yt=Le=>{var rt=$u(),Ft=s(rt);f(Ot=>o(Ft,`Ce fichier ne porte pas ${Ot??""} : + l’export sans le matériel les retire, et l’import ne remet que le numéro du + poste. Les lignes correspondantes sont VIDES ci-dessus, et + « Recopier » recopie ce vide dans le brouillon.`),[()=>Fu(e(F).map(Ot=>Ot.name))]),l(Le,rt)};_(mt,Le=>{e(F).length>0&&Le(yt)})}var Ze=n(mt,2),me=s(Ze);Pe(me,{kind:"write",get label(){return e(B)},get disabled(){return e(z)},onrun:()=>{O()}}),f(()=>o(it,`${e(g)??""} `)),l(ye,Re)};_(qe,ye=>{e(K)?e(R).length===0?ye(Ne,1):ye(Ve,-1):ye(Se)})}f(()=>o(oe,`Fichier lu : ${e(q)??""}`)),l(x,se)};_(we,x=>{e(j)!==null&&x(Fe)})}f(()=>{N=Mt(ce,1,"choose svelte-18wbxwu",null,N,{working:e(L)==="import",off:e(z)}),o(Q,`${e(L)==="import"?"Lecture du fichier…":"Importer un fichier"} `),re.disabled=e(z)}),Ce("change",re,x=>{y(x.currentTarget.files?.item(0))}),l(b,Y)},$$slots:{default:!0}});var ae=n(X,2);ze(ae,{title:"Cinq versions restaurables",note:"Chaque enregistrement fait tourner les versions : la plus récente est la 1.",children:(b,I)=>{var Y=Rt(),ue=te(Y);{var fe=Q=>{var re=Zu();l(Q,re)},pe=Q=>{var re=ec();l(Q,re)},ce=Q=>{var re=tc();l(Q,re)},N=Q=>{var re=rc(),Z=te(re),ie=s(Z),we=n(Z,2),Fe=s(we);Te(Fe,21,()=>e(w),x=>x.version,(x,se)=>{var ve=ac(),oe=s(ve),le=s(oe),je=n(oe,2),qe=s(je),Se=n(je,2),Ne=s(Se),Ve=n(Se,2);{let ye=u(()=>e(L)===`restore-${String(e(se).version)}`);Pe(Ve,{kind:"destructive",label:"Remettre cette version en service",protected:!0,get busy(){return e(ye)},get disabled(){return e(z)},onrun:()=>{A(e(se).version)}})}f((ye,Re)=>{o(le,`version ${ye??""}`),o(qe,Re),o(Ne,e(se).config_fingerprint)},[()=>ne(e(se).version),()=>Pt(e(se).modified_at)]),l(x,ve)}),f(()=>o(ie,`${e(U)??""} Le poste n’en garde jamais plus de cinq.`)),l(Q,re)};_(ue,Q=>{e(k)==="reading"?Q(fe):e(k)==="unreadable"?Q(pe,1):e(p).length===0?Q(ce,2):Q(N,-1)})}l(b,Y)},$$slots:{default:!0}});var M=n(ae,2);Ru(M,{get admin(){return r()}}),l(a,h),We()}ct(["change"]);var lc=c('clé'),ic=c(' '),oc=c('');function Ht(a,t){const r=Be(t,"kind",3,"read"),i=Be(t,"hint",3,""),d=Be(t,"disabled",3,!1),p=Be(t,"engaged",3,!1),k=Be(t,"busy",3,!1),T=Be(t,"protected",3,!1);var m=oc();let j;var q=s(m),D=s(q),L=n(D);{var W=K=>{var F=lc();l(K,F)};_(L,K=>{T()&&K(W)})}var z=n(q,2);{var R=K=>{var F=ic(),E=s(F);f(()=>o(E,k()?"En cours…":i())),l(K,F)};_(z,K=>{i()!==""&&K(R)})}f(()=>{j=Mt(m,1,`big touch-target ${r()??""}`,"svelte-15nlt6i",j,{engaged:p(),busy:k()}),he(m,"data-kind",r()),m.disabled=d()||k(),o(D,`${t.label??""} `)}),Ce("click",m,function(...K){t.onrun?.apply(this,K)}),l(a,m)}ct(["click"]);const uc="ERR-SCL-09";var cc=c('

                                                                      '),dc=c('

                                                                      '),vc=c('

                                                                      '),pc=c('

                                                                      Déposez ici le fichier clé

                                                                      '),fc=c('

                                                                      ',1),gc=c('');function mc(a,t){Me(t,!0);const r=Be(t,"admin",7);let i=G("");const d=u(()=>t.health.state.degraded?.code===uc),p=u(()=>t.health.printing),k=u(()=>e(p)?.on_fallback===!0);let T=G(!1),m=G("");const j=u(()=>r().busy||e(m)!==""),q=new xr;qt(()=>{q.observe(t.health)});const D=u(()=>t.health.catalog_source===null?"Ce poste ne publie pas la source de son catalogue.":"Catalogue surveillé : "+t.health.catalog_source.label),L=u(()=>t.health.catalog!==null&&(t.health.catalog.result==="rejected"||t.health.catalog.result==="failed")?t.health.catalog:null);function W(N){v(i,""),q.forget(),v(m,N,!0)}async function z(N,Q){W(N);try{await r().run(Q)}finally{v(m,"")}}async function R(){W("reload");try{const N=await r().run(Zr);N!==null&&q.begin(N)}finally{v(m,"")}}async function K(N,Q){W(N);try{const re=await r().load(Q);re!==null&&v(i,re.message,!0)}finally{v(m,"")}}async function F(N,Q){W(N);try{const re=await r().protect(Q);re!==null&&(r().notice=re.message,await r().refresh())}finally{v(m,"")}}async function E(N){if(v(T,!1),N!=null){W("import");try{const Q=await r().protect(()=>vr(N));if(Q===null)return;v(i,Q.result==="rejected"||Q.result==="failed"?`${N.name} : REFUSÉ${Q.reason===""?"":" — "+Q.reason}. Le catalogue en service n’a pas changé.`:`${N.name} : ${String(Q.rows_read_count)} lignes lues, ${String(Q.weighable_count)} pesables. La veille l’appliquera dans la seconde.`,!0),await r().refresh()}finally{v(m,"")}}}var w=gc(),g=s(w);{var U=N=>{var Q=cc(),re=s(Q);f(()=>o(re,e(i))),l(N,Q)};_(g,N=>{e(i)!==""&&N(U)})}var B=n(g,2);{var $=N=>{var Q=dc(),re=s(Q);f(()=>o(re,q.sentence)),l(N,Q)};_(B,N=>{q.sentence!==""&&N($)})}var J=n(B,2);{var C=N=>{var Q=vc(),re=s(Q);f(()=>o(re,e(p).banner)),l(N,Q)};_(J,N=>{e(p)!==null&&e(p).banner!==""&&N(C)})}var y=n(J,2),S=s(y),O=n(y,2),A=s(O);{let N=u(()=>e(m)==="scale");Ht(A,{label:"Tester la balance",hint:"Ce que le poste a déjà observé — le port n’est pas rouvert.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{K("scale",rn)}})}var h=n(A,2);{let N=u(()=>e(m)==="printer");Ht(h,{label:"Tester l’imprimante",hint:"Ce que le superviseur a vu il y a moins d’une seconde.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{K("printer",nn)}})}var V=n(h,2);{let N=u(()=>e(m)==="label");Ht(V,{label:"Imprimer une étiquette de test",hint:"Une étiquette de démonstration sort de l’imprimante du poste.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{z("label",sn)}})}var X=n(V,2);{let N=u(()=>e(m)==="reprint");Ht(X,{label:"Réimprimer la dernière",hint:"La dernière étiquette imprimée sort une seconde fois.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{z("reprint",Qr)}})}var ae=n(X,2);{let N=u(()=>e(m)==="reload");Ht(ae,{label:"Recharger le catalogue",kind:"write",hint:"La veille refait tout de suite le contrôle qu’elle fait toutes les cinq secondes.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{R()}})}var M=n(ae,2);{let N=u(()=>e(d)?"Revenir à la balance":"Basculer en saisie manuelle"),Q=u(()=>e(d)?"Le poids sera de nouveau lu sur la balance.":"Le poids se tape à la main : le poste continue de servir sans balance."),re=u(()=>e(m)==="manual");Ht(M,{get label(){return e(N)},kind:"destructive",get hint(){return e(Q)},get engaged(){return e(d)},protected:!0,get busy(){return e(re)},get disabled(){return e(j)},onrun:()=>{F("manual",()=>en(!e(d)))}})}var b=n(M,2);{let N=u(()=>e(m)==="roll");Ht(b,{label:"J’ai changé le rouleau",kind:"write",hint:"Le compteur d’étiquettes repart à zéro. C’est le seul geste qui dise quelque chose de vrai du papier.",get busy(){return e(N)},get disabled(){return e(j)},onrun:()=>{z("roll",tn)}})}var I=n(b,2);{var Y=N=>{{let Q=u(()=>e(k)?"Revenir à l’imprimante du poste":"Imprimer sur l’imprimante du poste voisin"),re=u(()=>e(k)?"Les étiquettes repartiront sur l’imprimante de ce poste.":"Les étiquettes sortiront sur l’imprimante voisine, pour cette session seulement."),Z=u(()=>e(m)==="fallback");Ht(N,{get label(){return e(Q)},kind:"write",get hint(){return e(re)},get engaged(){return e(k)},get busy(){return e(Z)},get disabled(){return e(j)},onrun:()=>{z("fallback",()=>an(!e(k)))}})}};_(I,N=>{e(p)!==null&&e(p).fallback_available&&N(Y)})}var ue=n(I,2),fe=n(O,2);ze(fe,{title:"Importer un catalogue",note:"Glissez le fichier CSV ici, ou choisissez-le. Il passe par le même chemin que le fichier du producteur.",children:(N,Q)=>{var re=pc();let Z;var ie=s(re),we=n(s(ie)),Fe=s(we),x=n(ie,2),se=n(s(x));f(()=>{Z=Mt(re,1,"drop svelte-1yd0nzg",null,Z,{dropping:e(T)}),o(Fe,`flv_${t.health.station??""}.csv`)}),Kt("dragover",re,ve=>{ve.preventDefault(),v(T,!0)}),Kt("dragleave",re,()=>v(T,!1)),Kt("drop",re,ve=>{ve.preventDefault(),E(ve.dataTransfer?.files.item(0))}),Ce("change",se,ve=>{E(ve.currentTarget.files?.item(0))}),l(N,re)},$$slots:{default:!0}});var pe=n(fe,2);{var ce=N=>{ze(N,{title:"Le dernier fichier n’a pas pris service",children:(Q,re)=>{var Z=fc(),ie=te(Z),we=s(ie),Fe=n(ie,2),x=s(Fe);f((se,ve)=>{o(we,`${se??""} — + ${(e(L).reason===""?"aucun motif enregistré.":e(L).reason)??""} + Le catalogue en service n’a pas changé.`),o(x,`Dernier essai : ${ve??""}.`)},[()=>ha(e(L).result),()=>Pt(e(L).occurred_at)]),l(Q,Z)},$$slots:{default:!0}})};_(pe,N=>{e(L)!==null&&N(ce)})}f(()=>{o(S,e(D)),he(ue,"href",ln)}),l(a,w),We()}ct(["change"]);const hc={succeeded:"La dernière mise à jour a réussi.","rolled-back":"La dernière mise à jour a échoué. La version précédente a été remise et le poste fonctionne.","rolled-back-unhealthy":"La dernière mise à jour a échoué et le poste n’a pas redémarré. Appelez le support.","not-started":"La dernière mise à jour n’a pas démarré : rien n’a été remplacé. Vous pouvez réessayer."},_c=2e3,bc=300*1e3;var wc=c('

                                                                      '),lr=c('

                                                                      '),yc=c("
                                                                      Dernière vérification
                                                                      ",1),xc=c(`

                                                                      La mise à jour depuis cet écran n’existe que sur les postes Windows. Sur les + autres, elle se fait à la main — voir la notice d’installation.

                                                                      `),kc=c('Voir les nouveautés'),qc=c('

                                                                      Version disponible : .

                                                                      '),Sc=c('

                                                                      Ce poste est à jour.

                                                                      '),Lc=c("
                                                                      Version installée
                                                                      Dépôt suivi
                                                                      ",1),Ec=c(`

                                                                      Le poste va s’arrêter environ une minute. L’écran client s’éteindra puis reviendra + tout seul. Si la nouvelle version ne démarre pas, la précédente sera remise + automatiquement — mais les données enregistrées, elles, ne reviendront pas en + arrière.

                                                                      `),Cc=c('

                                                                      Mise à jour en cours. Le poste redémarre, ne le débranchez pas.

                                                                      '),Pc=c('
                                                                      ',1),Tc=c("
                                                                      Terminée le
                                                                      ",1),zc=c("
                                                                      Raison
                                                                      ",1),jc=c('

                                                                      Depuis
                                                                      Vers
                                                                      ',1),Rc=c(" ",1);function Oc(a,t){Me(t,!0);let r=G(null),i=G(""),d=G(""),p=G(!1),k=G("");const T=u(()=>e(r)?.outcome??null),m=u(()=>e(r)?.supported===!0&&e(r).available),j=u(()=>e(r)?.latest??"");qt(()=>{q()});async function q(){try{v(r,await Rn(),!0),v(i,"")}catch(g){v(i,g instanceof Error?g.message:"Lecture impossible.",!0)}}async function D(){v(d,"check");try{const g=await t.admin.protect(()=>On());g!==null&&(v(r,g,!0),v(i,""))}finally{v(d,"")}}async function L(g){v(d,"apply"),v(k,"");try{if(await t.admin.protect(()=>An(g))===null)return;v(p,!0),v(k,await W()?"":"Le poste n’a pas répondu dans les cinq minutes. Allez le voir.",!0),await q()}finally{v(d,""),v(p,!1)}}async function W(){const g=Date.now()+bc;for(;Date.now()setTimeout(U,_c));try{if((await fetch("/healthz",{cache:"no-store"})).ok)return!0}catch{}}return!1}var z=Rc(),R=te(z);ze(R,{title:"Version de ce poste",children:(g,U)=>{var B=Rt(),$=te(B);{var J=y=>{var S=wc(),O=s(S);f(()=>o(O,e(i)===""?"Lecture…":e(i))),l(y,S)},C=y=>{var S=Lc(),O=te(S);{var A=ce=>{var N=lr(),Q=s(N);f(()=>o(Q,e(i))),l(ce,N)};_(O,ce=>{e(i)!==""&&ce(A)})}var h=n(O,2),V=n(s(h),2),X=s(V),ae=n(V,4),M=s(ae),b=n(ae,2);{var I=ce=>{var N=yc(),Q=n(te(N),2),re=s(Q);f(Z=>o(re,Z),[()=>Pt(e(r).checked_at)]),l(ce,N)};_(b,ce=>{e(r).checked_at!==""&&ce(I)})}var Y=n(h,2);{var ue=ce=>{var N=xc();l(ce,N)},fe=ce=>{var N=qc(),Q=n(s(N)),re=s(Q),Z=n(Q,2);{var ie=x=>{var se=ot();f(ve=>o(se,`, publiée le ${ve??""}`),[()=>Pt(e(r).published_at)]),l(x,se)};_(Z,x=>{e(r).published_at!==""&&x(ie)})}var we=n(Z,2);{var Fe=x=>{var se=kc();f(()=>he(se,"href",e(r).html_url)),l(x,se)};_(we,x=>{e(r).html_url!==""&&x(Fe)})}f(()=>o(re,e(r).latest)),l(ce,N)},pe=ce=>{var N=Sc();l(ce,N)};_(Y,ce=>{e(r).supported?e(r).available?ce(fe,1):ce(pe,-1):ce(ue)})}f(()=>{o(X,e(r).running),o(M,e(r).repository)}),l(y,S)};_($,y=>{e(r)===null?y(J):y(C,-1)})}l(g,B)},$$slots:{default:!0}});var K=n(R,2);{var F=g=>{ze(g,{title:"Installer",children:(U,B)=>{var $=Pc(),J=te($),C=s(J);{let M=u(()=>e(d)==="check");Pe(C,{kind:"read",label:"Vérifier maintenant",protected:!0,act:"check",get busy(){return e(M)},get disabled(){return e(p)},onrun:()=>{D()}})}var y=n(C,2);{var S=M=>{{let b=u(()=>`Installer la version ${e(j)}`),I=u(()=>e(d)==="apply");Pe(M,{kind:"destructive",get label(){return e(b)},protected:!0,act:"apply",get busy(){return e(I)},onrun:()=>{L(e(j))}})}};_(y,M=>{e(m)&&M(S)})}var O=n(J,2);{var A=M=>{var b=Ec();l(M,b)};_(O,M=>{e(m)&&M(A)})}var h=n(O,2);{var V=M=>{var b=Cc();l(M,b)};_(h,M=>{e(p)&&M(V)})}var X=n(h,2);{var ae=M=>{var b=lr(),I=s(b);f(()=>o(I,e(k))),l(M,b)};_(X,M=>{e(k)!==""&&M(ae)})}l(U,$)},$$slots:{default:!0}})};_(K,g=>{e(r)!==null&&e(r).supported&&g(F)})}var E=n(K,2);{var w=g=>{ze(g,{title:"Dernière tentative",children:(U,B)=>{var $=jc(),J=te($),C=s(J),y=n(J,2),S=n(s(y),2),O=s(S),A=n(S,4),h=s(A),V=n(A,2);{var X=b=>{var I=Tc(),Y=n(te(I),2),ue=s(Y);f(fe=>o(ue,fe),[()=>Pt(e(T).finished_at)]),l(b,I)};_(V,b=>{e(T).finished_at!==""&&b(X)})}var ae=n(V,2);{var M=b=>{var I=zc(),Y=n(te(I),2),ue=s(Y);f(()=>o(ue,e(T).reason)),l(b,I)};_(ae,b=>{e(T).reason!==""&&b(M)})}f(()=>{he(J,"data-outcome",e(T).status),o(C,hc[e(T).status]),o(O,e(T).from===""?"—":e(T).from),o(h,e(T).to===""?"—":e(T).to)}),l(U,$)},$$slots:{default:!0}})};_(E,g=>{e(T)!==null&&g(w)})}l(a,z),We()}var Ac=c(''),Nc=c('

                                                                      ',1),Ic=c('

                                                                      ',1),Dc=c(''),Fc=c(''),Uc=c(''),Mc=c(''),Wc=c(' '),Bc=c(''),Vc=c('

                                                                      Lecture de l’état du poste…

                                                                      '),Gc=c('

                                                                      '),Hc=c(" "),Jc=c("
                                                                    • "),Kc=c('
                                                                        '),$c=c('
                                                                        '),Yc=c('

                                                                        ');function Xc(a,t){Me(t,!0);const r=new Kn,i=new Nn(r),d=[{title:"Au quotidien",pages:[{id:"dashboard",label:"Tableau de bord"},{id:"troubleshooting",label:"Dépannage"}]},{title:"Réglages",pages:[{id:"hardware",label:"Matériel"},{id:"label",label:"Étiquette"},{id:"rules",label:"Règles"},{id:"catalog",label:"Catalogue"},{id:"journal",label:"Journal"},{id:"station",label:"Poste"},{id:"update",label:"Mise à jour"}]}],p=u(()=>d.flatMap(x=>x.pages).find(x=>x.id===r.page)?.label??""),k=u(()=>r.health===null||r.health.station_name===""?`Poste ${String(r.health?.station??"")}`:r.health.station_name),T=u(()=>(i.pending?.changed_blocks??[]).map(x=>Un(x)).join(", ")),m=u(()=>(i.pending?.changed_blocks??[]).join(", "));Ta(()=>(r.start(),()=>r.stop()));async function j(x){r.open(x),Wa(x)&&i.config===null&&await i.load()}async function q(){await r.protect(()=>i.save())===!0&&(r.notice="La configuration est enregistrée et appliquée.")}var D=Yc(),L=s(D),W=n(s(L),2);Te(W,17,()=>d,x=>x.title,(x,se)=>{var ve=Nc(),oe=te(ve),le=s(oe),je=n(oe,2);Te(je,17,()=>e(se).pages,qe=>qe.id,(qe,Se)=>{var Ne=Ac();let Ve;var ye=s(Ne);f(()=>{Ve=Mt(Ne,1,"entry svelte-9b2mjq",null,Ve,{current:r.page===e(Se).id}),he(Ne,"aria-current",r.page===e(Se).id?"page":void 0),o(ye,e(Se).label)}),Ce("click",Ne,()=>{j(e(Se).id)}),l(qe,Ne)}),f(()=>o(le,e(se).title)),l(x,ve)});var z=n(W,2),R=s(z);{var K=x=>{var se=Ic(),ve=te(se),oe=s(ve),le=n(ve,2),je=s(le),qe=n(le,2),Se=s(qe);f(()=>{o(oe,e(k)),o(je,`${r.health.coop??""} · version ${r.health.version??""}`),o(Se,`configuration ${r.health.config_fingerprint??""}`)}),l(x,se)};_(R,x=>{r.health!==null&&x(K)})}var F=n(R,2),E=s(F),w=n(F,2);{var g=x=>{var se=Dc();Ce("click",se,function(...ve){t.onclose?.apply(this,ve)}),l(x,se)};_(w,x=>{t.onclose!==void 0&&x(g)})}var U=n(L,2),B=s(U);{var $=x=>{var se=Fc(),ve=s(se);f(()=>o(ve,r.notice)),l(x,se)};_(B,x=>{r.notice!==""&&x($)})}var J=n(B,2);{var C=x=>{var se=Uc(),ve=s(se);f(()=>o(ve,r.linkError)),l(x,se)};_(J,x=>{r.linkError!==""&&x(C)})}var y=n(J,2);{var S=x=>{var se=Mc(),ve=s(se);f(()=>o(ve,r.actionError)),l(x,se)};_(y,x=>{r.actionError!==""&&x(S)})}var O=n(y,2);{var A=x=>{var se=Bc(),ve=s(se),oe=s(ve),le=n(oe);{var je=Ne=>{var Ve=Wc(),ye=s(Ve);f(()=>o(ye,e(m))),l(Ne,Ve)};_(le,Ne=>{jt.showTechnicalNames&&Ne(je)})}var qe=n(le),Se=n(ve,2);Pe(Se,{kind:"write",label:"Tout fonctionne : confirmer",onrun:()=>{r.protect(()=>i.confirm())}}),f(()=>{o(oe,`Configuration appliquée mais NON CONFIRMÉE. Ce qui a changé : ${e(T)??""}. `),o(qe,` Le poste reviendra tout seul à la version précédente dans + ${i.pending.seconds_left??""} secondes si personne ne confirme.`)}),l(x,se)};_(O,x=>{i.pending!==null&&x(A)})}var h=n(O,2),V=s(h),X=s(V),ae=n(V,2);{var M=x=>{var se=Vc();l(x,se)},b=x=>{pi(x,{get health(){return r.health},onshowrows:()=>{j("catalog")},onshowupdate:()=>{j("update")}})},I=x=>{mc(x,{get admin(){return r},get health(){return r.health}})},Y=x=>{Ji(x,{get admin(){return r},get draft(){return i},get health(){return r.health}})},ue=x=>{zo(x,{get admin(){return r},get draft(){return i}})},fe=x=>{pu(x,{get draft(){return i},get health(){return r.health}})},pe=x=>{El(x,{get admin(){return r},get draft(){return i},get health(){return r.health}})},ce=x=>{bo(x,{get admin(){return r}})},N=x=>{sc(x,{get admin(){return r},get draft(){return i},get health(){return r.health}})},Q=x=>{Oc(x,{get admin(){return r}})};_(ae,x=>{r.health===null?x(M):r.page==="dashboard"?x(b,1):r.page==="troubleshooting"?x(I,2):r.page==="hardware"?x(Y,3):r.page==="label"?x(ue,4):r.page==="rules"?x(fe,5):r.page==="catalog"?x(pe,6):r.page==="journal"?x(ce,7):r.page==="station"?x(N,8):r.page==="update"&&x(Q,9)})}var re=n(h,2);{var Z=x=>{var se=$c(),ve=s(se);{var oe=Se=>{var Ne=Gc(),Ve=s(Ne),ye=n(Ve);Te(ye,16,()=>i.retired,Re=>Re,(Re,lt)=>{{let it=u(()=>`retirer ${lt}`);Pe(Re,{kind:"write",get label(){return e(it)},onrun:()=>i.dropRetired(lt)})}}),f(Re=>o(Ve,`Ce fichier porte des réglages que cette version du poste ne connaît plus : + ${Re??""}. `),[()=>i.retired.map(Re=>Ct(Re)).join(", ")]),l(Se,Ne)};_(ve,Se=>{i.retired.length>0&&Se(oe)})}var le=n(ve,2);{var je=Se=>{var Ne=Kc();Te(Ne,21,()=>i.faults,Ve=>Ve.field,(Ve,ye)=>{var Re=Jc(),lt=s(Re),it=s(lt),gt=n(lt,2);{var ht=Ze=>{var me=Hc(),Le=s(me);f(()=>o(Le,e(ye).field)),l(Ze,me)};_(gt,Ze=>{jt.showTechnicalNames&&Ze(ht)})}var dt=n(gt),mt=n(dt);{var yt=Ze=>{var me=ot();f(Le=>o(me,`— valeurs acceptées : ${Le??""}`),[()=>e(ye).allowed.join(", ")]),l(Ze,me)};_(mt,Ze=>{e(ye).allowed!==void 0&&e(ye).allowed.length>0&&Ze(yt)})}f(Ze=>{o(it,Ze),o(dt,` ${e(ye).message??""} `)},[()=>Ct(e(ye).field)]),l(Ve,Re)}),l(Se,Ne)};_(le,Se=>{i.faults.length>0&&Se(je)})}var qe=n(le,2);{let Se=u(()=>i.dirty?"Enregistrer la configuration":"Aucune modification à enregistrer"),Ne=u(()=>!i.dirty||r.busy);Pe(qe,{kind:"write",get label(){return e(Se)},get disabled(){return e(Ne)},onrun:()=>{q()}})}l(x,se)},ie=u(()=>Wa(r.page)&&i.config!==null);_(re,x=>{e(ie)&&x(Z)})}var we=n(U,2);{var Fe=x=>{ts(x,{get admin(){return r}})};_(we,x=>{r.pending!==null&&x(Fe)})}f(()=>{ta(E,jt.showTechnicalNames),o(X,e(p))}),Ce("change",E,()=>jt.toggleTechnicalNames()),l(a,D),We()}ct(["click","change"]);let oa=null;function ed(a){if(oa!==null||a.querySelector("[data-admin]")!==null)return;const t=document.createElement("div");a.appendChild(t),oa={component:Gr(Xc,{target:t,props:{onclose:Qc}}),host:t}}function Qc(){if(oa===null)return;const{component:a,host:t}=oa;oa=null,Hr(a),t.remove()}export{Qc as closeAdmin,ed as mountAdmin}; diff --git a/internal/web/dist/assets/mount-MmpjwdB2.css b/internal/web/dist/assets/mount-MmpjwdB2.css new file mode 100644 index 0000000..7ca2cf5 --- /dev/null +++ b/internal/web/dist/assets/mount-MmpjwdB2.css @@ -0,0 +1 @@ +.act.svelte-5tpq0o{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.read.svelte-5tpq0o{color:var(--ink);background:var(--surface);border:1px solid var(--border)}.write.svelte-5tpq0o{color:var(--surface);background:var(--action);border:1px solid var(--action)}.destructive.svelte-5tpq0o{min-height:var(--touch-min);color:var(--surface);background:var(--danger);border:1px solid var(--danger)}@media(hover:hover){.read.svelte-5tpq0o:hover:not(:disabled){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}.write.svelte-5tpq0o:hover:not(:disabled){border-color:var(--action)}.destructive.svelte-5tpq0o:hover:not(:disabled){border-color:var(--danger)}.write.svelte-5tpq0o:hover:not(:disabled),.destructive.svelte-5tpq0o:hover:not(:disabled){box-shadow:var(--shadow-2);filter:brightness(.92)}}.act.svelte-5tpq0o:disabled{opacity:.5;box-shadow:none;cursor:default}.act.busy.svelte-5tpq0o:disabled{opacity:1}.key.svelte-5tpq0o{padding:.0625rem .375rem;border-radius:var(--radius-pill);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;background:var(--bg);color:var(--ink-muted)}.write.svelte-5tpq0o .key:where(.svelte-5tpq0o),.destructive.svelte-5tpq0o .key:where(.svelte-5tpq0o){background:var(--surface);color:var(--ink)}.scrim.svelte-zyka3t{position:fixed;inset:0;z-index:95;display:flex;align-items:center;justify-content:center;padding:2rem;background:#1c1b1973}.panel.svelte-zyka3t{display:flex;flex-direction:column;gap:.75rem;width:min(34rem,100%);padding:1.75rem;background:var(--surface);border-radius:var(--radius-lg);box-shadow:var(--shadow-2)}header.svelte-zyka3t{display:flex;align-items:center;gap:.75rem}.glyph.svelte-zyka3t{display:flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;flex:none;border-radius:var(--radius-sm);background:var(--bg);color:var(--ink-muted)}h2.svelte-zyka3t{margin:0;font-size:1.375rem;line-height:1.2}.why.svelte-zyka3t,.how.svelte-zyka3t{margin:0;color:var(--ink-muted);font-size:1rem;line-height:1.4}.field.svelte-zyka3t{display:flex;flex-direction:column;gap:.375rem;font-size:1rem;color:var(--ink-muted)}input.svelte-zyka3t{height:2.75rem;padding:0 .75rem;font:inherit;font-size:1.125rem;color:var(--ink);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)}input.svelte-zyka3t:focus-visible{outline:3px solid var(--focus);outline-offset:1px}.refusal.svelte-zyka3t{margin:0;padding:.625rem .75rem;border-left:.25rem solid var(--fault);background:var(--fault-wash);font-size:1rem}footer.svelte-zyka3t{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.25rem}.field.svelte-1oilv5s{display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0}label.svelte-1oilv5s{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem}.name.svelte-1oilv5s{font-size:1.125rem;font-weight:700}code.svelte-1oilv5s{font-size:.9375rem;color:var(--ink-muted)}input.svelte-1oilv5s,select.svelte-1oilv5s{min-height:3rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.refused.svelte-1oilv5s input:where(.svelte-1oilv5s),.refused.svelte-1oilv5s select:where(.svelte-1oilv5s){border-left:.5rem solid var(--fault)}.hint.svelte-1oilv5s,.fault.svelte-1oilv5s{margin:0;font-size:1rem;color:var(--ink-muted)}.fault.svelte-1oilv5s{padding-left:.5rem;border-left:.25rem solid var(--fault)}.panel.svelte-ww3z5u{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}h2.svelte-ww3z5u{margin:0 0 .5rem;font-size:1.5rem;font-weight:700}.note.svelte-ww3z5u{margin:0 0 .75rem;font-size:1rem;color:var(--ink-muted)}.fact.svelte-stwcs8{margin:.5rem 0;font-size:1.125rem}.muted.svelte-stwcs8{color:var(--ink-muted);font-size:1rem}.choice.svelte-stwcs8{display:flex;flex-direction:column}label.svelte-stwcs8{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}.choice.svelte-stwcs8 label:where(.svelte-stwcs8){display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin:0;font-weight:400}input.svelte-stwcs8{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.choice.svelte-stwcs8 input:where(.svelte-stwcs8){width:1.5rem;height:1.5rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0}.fact.svelte-xldam3{margin:.5rem 0;font-size:1.125rem}.muted.svelte-xldam3{color:var(--ink-muted);font-size:1rem}.scroll.svelte-xldam3{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-xldam3{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-xldam3 li:where(.svelte-xldam3){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-xldam3 li:where(.svelte-xldam3):first-child{border-top:none}.line.svelte-xldam3,.id.svelte-xldam3,.value.svelte-xldam3{color:var(--ink-muted)}.line.svelte-xldam3{flex:none;width:7rem}.what.svelte-xldam3{font-weight:700}.message.svelte-xldam3{flex:1 1 20rem}.pick.svelte-xldam3{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-xldam3:hover:not(:disabled){background:var(--surface)}}.fact.svelte-140iwqw{margin:.5rem 0;font-size:1.125rem}.muted.svelte-140iwqw{color:var(--ink-muted);font-size:1rem}.scroll.svelte-140iwqw{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-140iwqw{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-140iwqw li:where(.svelte-140iwqw){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-140iwqw li:where(.svelte-140iwqw):first-child{border-top:none}.line.svelte-140iwqw,.id.svelte-140iwqw,.value.svelte-140iwqw{color:var(--ink-muted)}.line.svelte-140iwqw{flex:none;width:7rem}.what.svelte-140iwqw{font-weight:700}.message.svelte-140iwqw{flex:1 1 20rem}.fact.svelte-on3isx{margin:.5rem 0;font-size:1.125rem}.muted.svelte-on3isx{color:var(--ink-muted);font-size:1rem}.scroll.svelte-on3isx{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}table.svelte-on3isx{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-on3isx,td.svelte-on3isx{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}.why.svelte-on3isx{white-space:normal;min-width:16rem;color:var(--ink-muted)}th.svelte-on3isx{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.inventory.svelte-tuh4jc{display:flex;flex-direction:column;gap:.5rem}.headline.svelte-tuh4jc{margin:0;font-size:1.375rem;font-weight:700}dl.svelte-tuh4jc{margin:0;display:flex;flex-direction:column;gap:.25rem}.row.svelte-tuh4jc{display:flex;align-items:baseline;gap:.75rem}dt.svelte-tuh4jc{flex:none;width:4.5rem;text-align:right;font-size:1.375rem;font-weight:700}dd.svelte-tuh4jc{margin:0;display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;font-size:1.125rem}.label.svelte-tuh4jc{font-weight:700}.note.svelte-tuh4jc{color:var(--ink-muted)}.rows.svelte-tuh4jc{padding:0 .5rem;min-height:var(--touch-min);text-decoration:underline;color:var(--ink-muted)}.oneline.svelte-tuh4jc{margin:0;font-size:1rem;color:var(--ink-muted)}.toggle.svelte-1ozk3k{display:flex;align-items:center;gap:.75rem;min-height:2.75rem;margin:.5rem 0 0;padding:.375rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);font-weight:400;cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.toggle[data-on=true].svelte-1ozk3k{background:var(--ready-wash)}@media(hover:hover){.toggle.svelte-1ozk3k:hover{border-color:var(--ink-muted)}}.toggle.svelte-1ozk3k input:where(.svelte-1ozk3k){flex:none;width:1.5rem;height:1.5rem;min-height:0;min-width:0;padding:0;accent-color:var(--focus);font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.toggle-text.svelte-1ozk3k{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline}.toggle-label.svelte-1ozk3k{font-size:1.0625rem;font-weight:700}.hint.svelte-1ozk3k{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.pages.svelte-3gk7u7{display:flex;flex-direction:column;gap:1rem}.fact.svelte-3gk7u7{margin:.5rem 0;font-size:1.125rem}.muted.svelte-3gk7u7{color:var(--ink-muted);font-size:1rem}.actions.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.columns-label.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:1rem 0 .375rem;font-size:1.0625rem;font-weight:700}.columns.svelte-3gk7u7{display:flex;flex-wrap:wrap;gap:.375rem}.column.svelte-3gk7u7{display:flex;gap:.5rem;align-items:center;min-height:2.75rem;margin:0;padding:0 .75rem;font-weight:400;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--waiting-wash);cursor:pointer;transition:background-color var(--tap) var(--ease),border-color var(--tap) var(--ease)}.column[data-on=true].svelte-3gk7u7{background:var(--ready-wash);border-color:var(--ink-muted);font-weight:700}@media(hover:hover){.column.svelte-3gk7u7:hover{border-color:var(--ink-muted)}}.column.svelte-3gk7u7 input:where(.svelte-3gk7u7){width:1.25rem;height:1.25rem;min-height:0;flex:0 0 auto;padding:0;background:none;border:none;border-radius:0;accent-color:var(--focus)}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0 0 .25rem}[data-grid-count].svelte-3gk7u7 p:where(.svelte-3gk7u7):last-child{margin-bottom:0}.probes.svelte-3gk7u7{position:relative;height:0;overflow:hidden}.probe.svelte-3gk7u7{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none}.grid-probe.svelte-3gk7u7{display:grid;width:calc(100vw - var(--touch-gap) * 2);grid-auto-rows:minmax(var(--tile-height),auto);gap:var(--touch-gap);align-content:start}.calibration-probe.svelte-3gk7u7{width:var(--tile-min)}.viewport-probe.svelte-3gk7u7{height:calc(100vh - var(--banner-height) - var(--category-height) - var(--status-height) - var(--touch-gap) * 2)}.key.svelte-3gk7u7{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.scroll.svelte-3gk7u7{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-sm);background:var(--bg)}.rows.svelte-3gk7u7{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-3gk7u7 li:where(.svelte-3gk7u7):first-child{border-top:none}.id.svelte-3gk7u7,.value.svelte-3gk7u7{color:var(--ink-muted)}.what.svelte-3gk7u7{font-weight:700}.pick.svelte-3gk7u7{height:2.75rem;padding:0 .5rem;font-weight:700;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-3gk7u7:hover:not(:disabled){background:var(--surface)}}.search.svelte-3gk7u7,label.svelte-3gk7u7{display:block;margin-top:.5rem;font-size:1.0625rem;font-weight:700}input.svelte-3gk7u7{min-height:2.75rem;width:100%;max-width:34rem;padding:0 .75rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.decision.svelte-3gk7u7{margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border)}.act-block.svelte-3gk7u7{margin-top:1rem;padding:.75rem 1rem 1rem;background:var(--bg);border-radius:var(--radius)}.act-block.svelte-3gk7u7 .what:where(.svelte-3gk7u7){margin:0;font-size:1.0625rem;font-weight:700}.drop.svelte-3gk7u7{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius);transition:border-color var(--tap) var(--ease),background-color var(--tap) var(--ease)}.drop.dropping.svelte-3gk7u7{border-color:var(--focus);background:var(--waiting-wash)}.drop.working.svelte-3gk7u7{border-color:var(--waiting);background:var(--waiting-wash)}.drop.svelte-3gk7u7 p:where(.svelte-3gk7u7){margin:0;font-size:1.125rem}.choose.svelte-3gk7u7{display:inline-flex;align-items:center;margin:0;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease)}.choose.svelte-3gk7u7:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){.choose.svelte-3gk7u7:active{transform:none}}@media(hover:hover){.choose.svelte-3gk7u7:hover{filter:brightness(.92)}}.choose.svelte-3gk7u7 input:where(.svelte-3gk7u7){display:none}.light.svelte-1421wa8{display:flex;flex-direction:column;gap:.375rem;padding:.875rem 1rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--waiting);border-radius:var(--radius)}.light[data-level=ok].svelte-1421wa8{border-left-color:var(--ready)}.light[data-level=warn].svelte-1421wa8{border-left-color:var(--warning)}.light[data-level=fault].svelte-1421wa8{border-left-color:var(--fault)}header.svelte-1421wa8{display:flex;align-items:center;gap:.5rem}h3.svelte-1421wa8{margin:0;font-size:1.25rem;font-weight:700}.dot.svelte-1421wa8{width:1rem;height:1rem;border-radius:50%;background:var(--waiting);flex:none}.dot[data-level=ok].svelte-1421wa8{background:var(--ready)}.dot[data-level=warn].svelte-1421wa8{background:var(--warning)}.dot[data-level=fault].svelte-1421wa8{background:var(--fault)}.dot[data-level=unknown].svelte-1421wa8,.dot[data-level=off].svelte-1421wa8{background:transparent;box-shadow:inset 0 0 0 3px var(--waiting)}.verdict.svelte-1421wa8{margin-left:auto;font-size:1rem;font-weight:700;color:var(--ink-muted);letter-spacing:.03em}.value.svelte-1421wa8{margin:0;font-size:1.125rem}.remedy.svelte-1421wa8{margin:0;font-size:1rem;color:var(--ink-muted)}.dashboard.svelte-1w44m0y{display:flex;flex-direction:column;gap:1rem}.link.svelte-1w44m0y{padding:0;font:inherit;color:inherit;text-decoration:underline;background:none;border:none;cursor:pointer}.lights.svelte-1w44m0y{display:grid;grid-template-columns:repeat(auto-fit,minmax(20rem,1fr));grid-auto-rows:1fr;gap:var(--touch-gap)}.fact.svelte-1w44m0y{margin:0 0 .5rem;font-size:1.125rem;overflow-wrap:break-word}.remedy.svelte-1w44m0y,.count.svelte-1w44m0y{color:var(--ink-muted);font-size:1rem}.restart.svelte-1w44m0y strong:where(.svelte-1w44m0y){letter-spacing:.03em}.restart[data-verdict=missing].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--warning);background:var(--warning-wash)}.restart[data-verdict=unknown].svelte-1w44m0y{margin-bottom:.25rem;padding:.5rem .75rem;border-left:.25rem solid var(--waiting);background:var(--waiting-wash)}.decisions.svelte-1w44m0y,.events.svelte-1w44m0y{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.375rem}.decisions.svelte-1w44m0y li:where(.svelte-1w44m0y),.events.svelte-1w44m0y li:where(.svelte-1w44m0y){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.what.svelte-1w44m0y,.message.svelte-1w44m0y{font-weight:700}.detail.svelte-1w44m0y,.time.svelte-1w44m0y,.source.svelte-1w44m0y,.code.svelte-1w44m0y{color:var(--ink-muted)}.events.svelte-1w44m0y li[data-level=error]:where(.svelte-1w44m0y){border-left:.25rem solid var(--fault);padding-left:.5rem}.events.svelte-1w44m0y li[data-level=warn]:where(.svelte-1w44m0y){border-left:.25rem solid var(--warning);padding-left:.5rem}.identity.svelte-1w44m0y{margin:0;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.125rem}.identity.svelte-1w44m0y dt:where(.svelte-1w44m0y){color:var(--ink-muted)}.identity.svelte-1w44m0y dd:where(.svelte-1w44m0y){margin:0;font-weight:700}.head.svelte-1bhb91v{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:space-between}h2.svelte-1bhb91v{margin:0;font-size:1.5rem;font-weight:700}.standing.svelte-1bhb91v{display:inline-flex;align-items:center;gap:.5rem;height:2rem;padding:0 .75rem;border-radius:var(--radius-pill);background:var(--waiting-wash);font-size:1rem;font-weight:700}.standing[data-level=ok].svelte-1bhb91v{background:var(--ready-wash)}.standing[data-level=warn].svelte-1bhb91v{background:var(--warning-wash)}.standing[data-level=fault].svelte-1bhb91v{background:var(--fault-wash)}.dot.svelte-1bhb91v{width:.625rem;height:.625rem;border-radius:var(--radius-pill);background:var(--waiting)}.standing[data-level=ok].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--ready)}.standing[data-level=warn].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--warning)}.standing[data-level=fault].svelte-1bhb91v .dot:where(.svelte-1bhb91v){background:var(--fault)}.pages.svelte-1p4i0xm{display:flex;flex-direction:column;gap:1rem}.panel.svelte-1p4i0xm{padding:1rem 1.25rem 1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1)}.fact.svelte-1p4i0xm{margin:.75rem 0 0;font-size:1.125rem}.note.svelte-1p4i0xm,.count.svelte-1p4i0xm,.waiting.svelte-1p4i0xm{margin:.25rem 0 0;font-size:1rem;color:var(--ink-muted)}.check.svelte-1p4i0xm{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;margin-top:.5rem;font-size:1.0625rem}.check.svelte-1p4i0xm input:where(.svelte-1p4i0xm){width:1.5rem;height:1.5rem;flex:0 0 auto}.check.svelte-1p4i0xm small:where(.svelte-1p4i0xm){display:block;font-size:1rem;color:var(--ink-muted)}.actions.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.5rem;margin:.75rem 0 0}@media(hover:hover){.pick.svelte-1p4i0xm:hover,summary.svelte-1p4i0xm:hover{background:var(--bg)}}.list.svelte-1p4i0xm{margin:.25rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column}.list.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.125rem 0;border-top:1px solid var(--border-soft);font-size:1.0625rem}.list.svelte-1p4i0xm li.refused:where(.svelte-1p4i0xm){padding-left:.5rem;border-left:.25rem solid var(--fault);background:var(--fault-wash)}.pick.svelte-1p4i0xm{height:2.75rem;padding:0 .75rem;font-weight:700;text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}.what.svelte-1p4i0xm{font-weight:700}.detail.svelte-1p4i0xm{color:var(--ink-muted)}.folded.svelte-1p4i0xm{margin-top:1rem;border:1px solid var(--border);border-radius:var(--radius)}summary.svelte-1p4i0xm{display:flex;align-items:center;height:2.75rem;padding:0 .75rem;font-size:1.0625rem;font-weight:700;border-radius:var(--radius);cursor:pointer;transition:background-color var(--tap) var(--ease),transform var(--tap) var(--ease)}summary.svelte-1p4i0xm:active{transform:scale(.975)}@media(prefers-reduced-motion:reduce){summary.svelte-1p4i0xm:active{transform:none}}.frames.svelte-1p4i0xm{margin-top:1rem;max-height:18rem;overflow-y:auto;background:var(--waiting-wash);border:1px solid var(--border);border-radius:var(--radius)}.frames-head.svelte-1p4i0xm{position:sticky;top:0;margin:0;padding:.5rem .75rem;background:var(--surface);border-radius:var(--radius) var(--radius) 0 0;box-shadow:var(--shadow-2);font-size:1rem;color:var(--ink-muted)}.interrupted.svelte-1p4i0xm{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:0;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);font-size:1rem}.frame-rows.svelte-1p4i0xm{margin:0;padding:.5rem .75rem;list-style:none;display:flex;flex-direction:column;gap:.25rem}.frame-rows.svelte-1p4i0xm li:where(.svelte-1p4i0xm){display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:.75rem;font-size:.9375rem}.hex.svelte-1p4i0xm,.decoded.svelte-1p4i0xm{overflow-x:auto;white-space:pre}.decoded.svelte-1p4i0xm{color:var(--ink-muted)}.lines-box.svelte-hsqr6e{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:28rem}.lines.svelte-hsqr6e{margin:0;padding:0 .75rem;list-style:none}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.lines.svelte-hsqr6e li:where(.svelte-hsqr6e):first-child{border-top:none}.lines.svelte-hsqr6e li[data-level=error]:where(.svelte-hsqr6e),.lines.svelte-hsqr6e li[data-level=critical]:where(.svelte-hsqr6e){border-left:.25rem solid var(--fault);padding-left:.5rem}.lines.svelte-hsqr6e li[data-level=warn]:where(.svelte-hsqr6e){border-left:.25rem solid var(--warning);padding-left:.5rem}.when.svelte-hsqr6e,.level.svelte-hsqr6e,.from.svelte-hsqr6e,.code.svelte-hsqr6e,.detail-text.svelte-hsqr6e{color:var(--ink-muted)}.level.svelte-hsqr6e{flex:none;width:8rem}.message.svelte-hsqr6e{flex:1 1 20rem;font-weight:700}div.journal.svelte-86o8oz{display:flex;flex-direction:column;gap:1rem;max-width:none;--reading-column: 68rem}.filters.svelte-86o8oz{display:flex;flex-wrap:wrap;gap:var(--touch-gap);align-items:center;margin-bottom:.75rem;font-size:1.0625rem}.filters.svelte-86o8oz label:where(.svelte-86o8oz){font-weight:700}select.svelte-86o8oz{min-height:2.75rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.export.svelte-86o8oz{display:inline-flex;align-items:center;gap:.5rem;height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);transition:transform var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.export.svelte-86o8oz:active{transform:scale(.975)}@media(hover:hover){.export.svelte-86o8oz:hover{border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.replay.svelte-86o8oz{margin-top:.5rem}.fact.svelte-86o8oz{margin:.5rem 0;max-width:var(--reading-column);font-size:1.125rem}.muted.svelte-86o8oz{color:var(--ink-muted);font-size:1rem}.table-box.svelte-86o8oz{overflow:auto;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm);max-height:34rem}table.svelte-86o8oz{border-collapse:collapse;min-width:100%;font-size:1.0625rem}th.svelte-86o8oz,td.svelte-86o8oz{padding:.375rem .5rem;text-align:left;white-space:nowrap;border-bottom:1px solid var(--border)}th.svelte-86o8oz{position:sticky;top:0;z-index:1;color:var(--ink-muted);font-size:1rem;background:var(--bg)}tbody.svelte-86o8oz tr:where(.svelte-86o8oz){transition:background-color var(--tap) var(--ease)}@media(hover:hover){tbody.svelte-86o8oz tr:where(.svelte-86o8oz):hover{background:var(--surface)}}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz){background:var(--surface);font-weight:700}tbody.svelte-86o8oz tr.open:where(.svelte-86o8oz)>td:where(.svelte-86o8oz):first-child{box-shadow:inset .1875rem 0 0 0 var(--focus)}.pick.svelte-86o8oz{height:2.75rem;padding:0 .5rem;color:var(--ink);text-decoration:underline;border-radius:var(--radius-sm);transition:background-color var(--tap) var(--ease)}@media(hover:hover){.pick.svelte-86o8oz:hover:not(:disabled){background:var(--surface)}}.detail-row.svelte-86o8oz{background:var(--surface)}.detail-cell.svelte-86o8oz{padding:.75rem 1rem 1rem;white-space:normal;box-shadow:inset .1875rem 0 0 0 var(--focus)}.detail-cell.svelte-86o8oz h3:where(.svelte-86o8oz){margin:0 0 .5rem;font-size:1.25rem}.detail-cell.svelte-86o8oz dl:where(.svelte-86o8oz){display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;max-width:var(--reading-column);margin:0;font-size:1.0625rem;font-weight:400}.detail-cell.svelte-86o8oz dt:where(.svelte-86o8oz){color:var(--ink-muted)}.detail-cell.svelte-86o8oz dd:where(.svelte-86o8oz){margin:0}.detail-cell.svelte-86o8oz code:where(.svelte-86o8oz){overflow-wrap:anywhere}.sr-only.svelte-86o8oz{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.pages.svelte-llv1r8{display:flex;flex-direction:column;gap:1rem}.stale.svelte-llv1r8{margin:0 0 .75rem;padding:.5rem .75rem;background:var(--warning-wash);border-left:.375rem solid var(--warning);border-radius:var(--radius-sm);font-size:1.0625rem}.preview.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:1.5rem;align-items:flex-start}.sheet.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0;padding:.75rem;background:var(--waiting-wash);border-radius:var(--radius-lg)}img.svelte-llv1r8{width:40rem;max-width:100%;image-rendering:pixelated;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-1)}.refused.svelte-llv1r8{margin:0;padding:.5rem .75rem;background:var(--fault-wash);border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);font-size:1.0625rem}.nudges.svelte-llv1r8{display:flex;flex-direction:column;gap:.5rem;min-width:0}.axis.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0;font-size:1.125rem}.pair.svelte-llv1r8{display:flex;gap:var(--touch-gap)}.fault.svelte-llv1r8{margin:0;padding-left:.5rem;border-left:.25rem solid var(--fault);font-size:1rem;color:var(--ink-muted)}.check.svelte-llv1r8{display:flex;gap:.75rem;align-items:center;min-height:2.75rem;font-size:1.0625rem}.check.svelte-llv1r8 input:where(.svelte-llv1r8){width:1.5rem;height:1.5rem;flex:0 0 auto}.truncation.svelte-llv1r8,.absent.svelte-llv1r8,.waiting.svelte-llv1r8,.bound.svelte-llv1r8,.cost.svelte-llv1r8{margin:1rem 0 0;font-size:1rem;color:var(--ink-muted)}.absent.svelte-llv1r8{margin-top:.5rem}.bound.svelte-llv1r8{margin-top:.25rem}.cost.svelte-llv1r8{margin-top:.5rem}.actions.svelte-llv1r8{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin-top:.75rem}.fact.svelte-geg7e6{margin:.5rem 0;font-size:1.125rem}.muted.svelte-geg7e6{color:var(--ink-muted);font-size:1rem}.rules.svelte-geg7e6{margin:.75rem 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.75rem}.rule.svelte-geg7e6{padding:.75rem 1rem 1rem;background:var(--bg);border:1px solid var(--border-soft);border-radius:var(--radius-sm)}.rule-head.svelte-geg7e6{display:flex;flex-wrap:wrap;gap:.5rem;align-items:baseline;margin:0}.rank.svelte-geg7e6{flex:none;min-width:1.75rem;color:var(--ink-muted);font-variant-numeric:tabular-nums;font-weight:700}.rule-label.svelte-geg7e6{font-size:1.125rem;font-weight:700}.token.svelte-geg7e6{color:var(--ink-muted);font-size:.9375rem}.severity.svelte-geg7e6{margin-left:auto;padding:.125rem .625rem;font-size:.9375rem;border-radius:var(--radius-pill);background:var(--waiting-wash)}.severity[data-blocking=true].svelte-geg7e6{background:var(--warning-wash)}.when.svelte-geg7e6{margin:.375rem 0 0;font-size:1rem}.quote.svelte-geg7e6{margin:.5rem 0 0;padding:.5rem .75rem;font-size:1.0625rem;background:var(--surface);border-radius:var(--radius-sm);box-shadow:var(--shadow-1)}.quote.silent.svelte-geg7e6{color:var(--ink-muted);font-size:1rem;box-shadow:none;background:var(--waiting-wash)}.note.svelte-geg7e6{margin:.5rem 0 0;font-size:1rem;color:var(--ink-muted)}.box.svelte-geg7e6{display:contents}.fact.svelte-9g4joo{margin:.5rem 0;font-size:1.125rem}.muted.svelte-9g4joo,.locked.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}.scroll.svelte-9g4joo{overflow-x:auto}table.svelte-9g4joo{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-9g4joo,td.svelte-9g4joo{padding:.375rem .5rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-9g4joo{color:var(--ink-muted);font-size:1rem}input.svelte-9g4joo{min-height:2.75rem;width:100%;min-width:6rem;padding:0 .5rem;font:inherit;font-variant-numeric:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm)}.hint.svelte-9g4joo{flex:1 1 20rem;color:var(--ink-muted);font-size:1rem}.fact.svelte-65t3t4{margin:.5rem 0;font-size:1.125rem}.muted.svelte-65t3t4{color:var(--ink-muted);font-size:1rem}.verdicts.svelte-65t3t4{margin:0;padding:0;list-style:none}.waivers.svelte-65t3t4{max-height:24rem;overflow-y:auto}.verdicts.svelte-65t3t4 li:where(.svelte-65t3t4){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.token.svelte-65t3t4{color:var(--ink-muted);font-size:.9375rem}.what.svelte-65t3t4,.message.svelte-65t3t4{font-weight:700}.detail.svelte-65t3t4{color:var(--ink-muted)}.dead.svelte-65t3t4{flex:1 1 20rem;padding:.125rem .625rem;font-size:1rem;background:var(--warning-wash);border-radius:var(--radius-sm)}.unread.svelte-65t3t4{margin:.5rem 0;padding:.5rem .75rem;font-size:1rem;background:var(--fault-wash);border-left:.25rem solid var(--fault);border-radius:var(--radius-sm)}.pages.svelte-hzfkjn{display:flex;flex-direction:column;gap:1rem}.fact.svelte-hzfkjn{margin:.5rem 0;font-size:1.125rem}.muted.svelte-hzfkjn{color:var(--ink-muted);font-size:1rem}h3.svelte-hzfkjn{margin:1.5rem 0 .5rem;font-size:1.25rem}.verdicts.svelte-hzfkjn{margin:0;padding:0;list-style:none}.verdicts.svelte-hzfkjn li:where(.svelte-hzfkjn){display:flex;flex-wrap:wrap;gap:.75rem;align-items:baseline;padding:.375rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.verdicts.svelte-hzfkjn li[data-blocking=true]:where(.svelte-hzfkjn){border-left:.25rem solid var(--warning);background:var(--warning-wash);padding-left:.5rem}.token.svelte-hzfkjn{color:var(--ink-muted);font-size:.9375rem}.what.svelte-hzfkjn,.message.svelte-hzfkjn{font-weight:700}.detail.svelte-hzfkjn{color:var(--ink-muted)}.scroll.svelte-1fkeh3r{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}table.svelte-1fkeh3r{border-collapse:collapse;width:100%;font-size:1.0625rem}th.svelte-1fkeh3r,td.svelte-1fkeh3r{padding:.375rem .75rem;text-align:left;border-bottom:1px solid var(--border)}th.svelte-1fkeh3r{position:sticky;top:0;color:var(--ink-muted);font-size:1rem;background:var(--bg)}.fact.svelte-8utf8s{margin:.5rem 0;font-size:1.125rem}.muted.svelte-8utf8s{color:var(--ink-muted);font-size:1rem}.faults.svelte-8utf8s{margin-top:.75rem;padding:.25rem 1rem .5rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-8utf8s ul:where(.svelte-8utf8s){margin:0;padding-left:1.25rem;font-size:1.0625rem}.allowed.svelte-8utf8s{display:block;color:var(--ink-muted);font-size:1rem}dd.svelte-mgmug7{margin:0 0 1.25rem;display:flex;flex-direction:column;align-items:flex-start;gap:.5rem}.faults.svelte-mgmug7{margin-top:.75rem;padding:.5rem 1rem .5rem 2rem;border-left:.375rem solid var(--warning);border-radius:var(--radius);background:var(--warning-wash)}.faults.svelte-mgmug7 li:where(.svelte-mgmug7){margin-bottom:.25rem}.pages.svelte-18wbxwu{display:flex;flex-direction:column;gap:1rem}.fact.svelte-18wbxwu{margin:.5rem 0;font-size:1.125rem}.muted.svelte-18wbxwu{color:var(--ink-muted);font-size:1rem}.filename.svelte-18wbxwu{margin-top:1rem;font-weight:700}.identity.svelte-18wbxwu{margin:1rem 0 0;padding:.75rem 1rem;display:grid;grid-template-columns:auto 1fr;gap:.25rem 1rem;font-size:1.0625rem;background:var(--bg);border-radius:var(--radius)}.identity.svelte-18wbxwu dt:where(.svelte-18wbxwu){color:var(--ink-muted)}.identity.svelte-18wbxwu dd:where(.svelte-18wbxwu){margin:0;font-weight:700}.actions.svelte-18wbxwu{display:flex;flex-wrap:wrap;gap:var(--touch-gap);margin:.75rem 0 0}.choose.svelte-18wbxwu{display:inline-flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:0 1rem;font-size:1.0625rem;font-weight:700;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-1);cursor:pointer;transition:transform var(--tap) var(--ease),background-color var(--tap) var(--ease),border-color var(--tap) var(--ease),box-shadow var(--slide) var(--ease)}.choose.svelte-18wbxwu:active:not(.off){transform:scale(.975)}@media(hover:hover){.choose.svelte-18wbxwu:hover:not(.off){border-color:var(--ink-muted);box-shadow:var(--shadow-2)}}.choose.off.svelte-18wbxwu{opacity:.5;box-shadow:none;cursor:default}.choose.working.svelte-18wbxwu{opacity:1;border-color:var(--waiting)}.choose.svelte-18wbxwu input:where(.svelte-18wbxwu){display:none}.key.svelte-18wbxwu{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.same.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--ready);border-radius:var(--radius-sm);background:var(--ready-wash)}.warned.svelte-18wbxwu{padding:.5rem .75rem;border-left:.375rem solid var(--fault);border-radius:var(--radius-sm);background:var(--fault-wash)}.scroll.svelte-18wbxwu{max-height:24rem;overflow:auto;border:1px solid var(--border-soft);border-radius:var(--radius-lg);background:var(--bg)}.rows.svelte-18wbxwu{margin:0;padding:0 .75rem;list-style:none}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu){display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;padding:.5rem 0;border-top:1px solid var(--border);font-size:1.0625rem}.rows.svelte-18wbxwu li:where(.svelte-18wbxwu):first-child{border-top:none}.what.svelte-18wbxwu{font-weight:700}.detail.svelte-18wbxwu{color:var(--ink-muted)}.big.svelte-15nlt6i{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.write.svelte-15nlt6i{color:var(--surface);background:var(--action);border-color:var(--action)}.destructive.svelte-15nlt6i{color:var(--surface);background:var(--danger);border-color:var(--danger)}.write.svelte-15nlt6i .hint:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .hint:where(.svelte-15nlt6i){color:var(--surface);opacity:.85}.write.svelte-15nlt6i .guarded:where(.svelte-15nlt6i),.destructive.svelte-15nlt6i .guarded:where(.svelte-15nlt6i){background:var(--surface);color:var(--ink)}.big.svelte-15nlt6i:disabled{opacity:.5}.big.busy.svelte-15nlt6i{opacity:1;border-color:var(--waiting);background:var(--waiting-wash)}.big.busy.write.svelte-15nlt6i{background:var(--action)}.big.busy.destructive.svelte-15nlt6i{background:var(--danger)}.guarded.svelte-15nlt6i{margin-left:.5rem;padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;vertical-align:middle}.big.engaged.svelte-15nlt6i{border-left:.5rem solid var(--warning)}.label.svelte-15nlt6i{font-size:1.375rem;font-weight:700}.hint.svelte-15nlt6i{font-size:1rem;color:var(--ink-muted)}.troubleshooting.svelte-1yd0nzg{display:flex;flex-direction:column;gap:1rem}.buttons.svelte-1yd0nzg{display:grid;grid-template-columns:repeat(auto-fit,minmax(22rem,1fr));gap:var(--touch-gap)}.big.svelte-1yd0nzg{display:flex;flex-direction:column;justify-content:center;gap:.25rem;min-height:6rem;padding:1rem 1.25rem;text-align:left;text-decoration:none;color:var(--ink);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.label.svelte-1yd0nzg{font-size:1.375rem;font-weight:700}.hint.svelte-1yd0nzg{font-size:1rem;color:var(--ink-muted)}.report.svelte-1yd0nzg{margin:0;padding:.75rem 1rem;font-size:1.125rem;background:var(--surface);border:1px solid var(--border);border-left:.5rem solid var(--ready);border-radius:var(--radius)}.drop.svelte-1yd0nzg{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem;border:2px dashed var(--border);border-radius:var(--radius)}.drop.dropping.svelte-1yd0nzg{border-color:var(--focus)}.drop.svelte-1yd0nzg p:where(.svelte-1yd0nzg){margin:0;font-size:1.125rem}.key.svelte-1yd0nzg{padding:.0625rem .375rem;border-radius:var(--radius-pill);background:var(--bg);color:var(--ink-muted);font-size:.75rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase}.choose.svelte-1yd0nzg{display:inline-flex;align-items:center;padding:0 1rem;font-size:1.125rem;font-weight:700;color:var(--surface);background:var(--danger);border:1px solid var(--danger);border-radius:var(--radius);cursor:pointer}@media(hover:hover){.choose.svelte-1yd0nzg:hover{filter:brightness(.92)}}.choose.svelte-1yd0nzg input:where(.svelte-1yd0nzg){display:none}.fact.svelte-1yd0nzg{margin:0 0 .5rem;font-size:1.125rem}.muted.svelte-1yd0nzg{color:var(--ink-muted)}.row.svelte-o9s8nz{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem}.admin.svelte-9b2mjq{position:fixed;inset:0;z-index:90;display:grid;grid-template-columns:16rem 1fr;background:var(--bg);color:var(--ink);overflow:hidden}.rail.svelte-9b2mjq{display:flex;flex-direction:column;gap:.125rem;padding:1rem .75rem;overflow-y:auto;background:var(--surface);border-right:1px solid var(--border)}h1.svelte-9b2mjq{margin:0 .5rem 1rem;font-size:1.25rem;letter-spacing:-.01em}.group.svelte-9b2mjq{margin:1rem .5rem .375rem;color:var(--ink-muted);font-size:.8125rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase}.entry.svelte-9b2mjq{padding:0 .75rem;height:2.5rem;display:flex;align-items:center;text-align:left;font-size:1.0625rem;border-radius:var(--radius-sm);color:var(--ink-muted);transition:background-color var(--tap) var(--ease),color var(--tap) var(--ease)}@media(hover:hover){.entry.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.entry.current.svelte-9b2mjq{background:var(--bg);color:var(--ink);font-weight:700;box-shadow:inset .1875rem 0 0 0 var(--focus)}.foot.svelte-9b2mjq{margin-top:auto;padding:1rem .5rem 0;border-top:1px solid var(--border)}.station.svelte-9b2mjq{margin:0;font-size:1rem;font-weight:600}.build.svelte-9b2mjq{margin:.125rem 0 0;font-size:.8125rem;color:var(--ink-muted)}.technical.svelte-9b2mjq{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;margin-top:.75rem;color:var(--ink-muted);font-size:.9375rem}.technical.svelte-9b2mjq input:where(.svelte-9b2mjq){width:1.5rem;height:1.5rem;flex:0 0 auto}.back.svelte-9b2mjq{margin-top:.75rem;padding:0 .5rem;height:2.25rem;font-size:.9375rem;color:var(--ink-muted);border-radius:var(--radius-sm)}@media(hover:hover){.back.svelte-9b2mjq:hover{background:var(--bg);color:var(--ink)}}.body.svelte-9b2mjq{display:flex;flex-direction:column;min-width:0;overflow:hidden}.page.svelte-9b2mjq{flex:1;min-height:0;overflow-y:auto;padding:1.5rem 2rem 3rem}.page.svelte-9b2mjq>*{max-width:68rem}.page-title.svelte-9b2mjq{margin:0 0 1.25rem;font-size:1.75rem;letter-spacing:-.01em}.waiting.svelte-9b2mjq{margin:0;font-size:1.125rem;color:var(--ink-muted)}.banner.svelte-9b2mjq{flex:none;margin:0;padding:.75rem 2rem;font-size:1.0625rem;background:var(--surface);border-bottom:1px solid var(--border)}.notice.svelte-9b2mjq{border-left:.375rem solid var(--ready);background:var(--ready-wash)}.failure.svelte-9b2mjq{border-left:.375rem solid var(--fault);background:var(--fault-wash)}.pending.svelte-9b2mjq{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;border-left:.375rem solid var(--warning);background:var(--warning-wash)}.pending.svelte-9b2mjq p:where(.svelte-9b2mjq){margin:0;flex:1 1 24rem}.save-bar.svelte-9b2mjq{flex:none;padding:.75rem 2rem;background:var(--surface);border-top:1px solid var(--border);box-shadow:var(--shadow-1)}.retired.svelte-9b2mjq{margin:0 0 .5rem;font-size:.9375rem;color:var(--ink-muted)}.faults.svelte-9b2mjq{margin:0 0 .5rem;padding-left:1.25rem;font-size:1rem} diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 1e83a6c..f6e00b1 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -6,8 +6,8 @@ Pesée - - + + diff --git a/internal/web/guard_test.go b/internal/web/guard_test.go index c427f10..ff3afd7 100644 --- a/internal/web/guard_test.go +++ b/internal/web/guard_test.go @@ -2,8 +2,11 @@ package web import ( "net/http" + "net/http/httptest" "strings" "testing" + + "openscale/internal/domain" ) // securityHeaders is what every answer of this layer must carry, and the value it must @@ -108,3 +111,82 @@ func directiveOf(policy, name string) string { } return "" } + +// --- The cross-site guard ---------------------------------------------------- + +// TestAMutatingRequestFromAnotherOriginIsRefused: cross-site request forgery, and DNS +// rebinding against 127.0.0.1, which is the attack a loopback service really faces — +// no firewall protects it. +func TestAMutatingRequestFromAnotherOriginIsRefused(t *testing.T) { + b := newBench(t) + + refused := b.do(http.MethodPost, "/api/v1/cancel", `{}`, + http.Header{"Origin": {"http://ailleurs.example"}}) + refused.Body.Close() + if refused.StatusCode != http.StatusForbidden { + t.Fatalf("origine étrangère = %d, attendu 403", refused.StatusCode) + } + + // The station's own origin passes. + accepted := b.do(http.MethodPost, "/api/v1/cancel", `{}`, + http.Header{"Origin": {b.http.URL}}) + accepted.Body.Close() + if accepted.StatusCode == http.StatusForbidden { + t.Fatal("la propre origine du poste est refusée") + } + + // No Origin at all is not a browser: curl, the kiosk supervisor, a test. + bare := b.post("/api/v1/cancel", `{}`) + bare.Body.Close() + if bare.StatusCode == http.StatusForbidden { + t.Fatal("une requête sans origine est refusée : plus rien ne peut piloter le poste") + } +} + +// TestARequestAddressedToAForeignNameIsRefused closes the rebinding half. +func TestARequestAddressedToAForeignNameIsRefused(t *testing.T) { + b := newBench(t) + request := httptest.NewRequest(http.MethodPost, "/api/v1/cancel", strings.NewReader(`{}`)) + request.Host = "poste.attaquant.example" + recorder := httptest.NewRecorder() + b.server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("Host étranger = %d, attendu 403", recorder.Code) + } +} + +// TestTheAdministrationStaysOnTheLoopbackUnlessItIsOpened is network.admin_on_lan, +// which would otherwise be a setting that does nothing. +func TestTheAdministrationStaysOnTheLoopbackUnlessItIsOpened(t *testing.T) { + b := newBench(t) + + fromLAN := httptest.NewRequest(http.MethodGet, "/admin/api/health", nil) + fromLAN.RemoteAddr = "10.0.0.5:51234" + recorder := httptest.NewRecorder() + b.server.Handler().ServeHTTP(recorder, fromLAN) + if recorder.Code != http.StatusForbidden { + t.Fatalf("administration depuis le LAN = %d, attendu 403", recorder.Code) + } + + // The CLIENT screen is untouched: a station whose grid stopped answering because + // somebody tightened an administration setting would be a station that stopped + // selling. + client := httptest.NewRequest(http.MethodGet, "/api/v1/catalog", nil) + client.RemoteAddr = "10.0.0.5:51234" + recorder = httptest.NewRecorder() + b.server.Handler().ServeHTTP(recorder, client) + if recorder.Code != http.StatusOK { + t.Fatalf("écran client depuis le LAN = %d, attendu 200", recorder.Code) + } + + opened := newBench(t, func(o *benchOptions) { + o.config = func(cfg *domain.Config) { cfg.Network.AdminOnLAN = true } + }) + recorder = httptest.NewRecorder() + fromLAN = httptest.NewRequest(http.MethodGet, "/admin/api/health", nil) + fromLAN.RemoteAddr = "10.0.0.5:51234" + opened.server.Handler().ServeHTTP(recorder, fromLAN) + if recorder.Code != http.StatusOK { + t.Fatalf("administration ouverte sur le LAN = %d, attendu 200", recorder.Code) + } +} diff --git a/internal/web/harness_test.go b/internal/web/harness_test.go index e4f2701..91a2196 100644 --- a/internal/web/harness_test.go +++ b/internal/web/harness_test.go @@ -24,6 +24,21 @@ import ( "openscale/internal/station" ) +// What every test of this package drives the HTTP layer with: a whole station on a FAKE +// CLOCK behind the real routes, the four verbs its handlers are exercised through, and the +// doubles that stand in for the store, the journal and the technical log. +// +// # Why this file stays whole at 705 lines +// +// It is the largest file of the repository, and splitting it was considered and refused. +// The two candidates for a file of their own — the configuration store double and the +// administration bench — are used by nearly every test here, so moving them out replaces +// one long file that is read top to bottom by two files that are read together. Measured: +// the split adds the two package clauses, the two import blocks and the two headers, and +// takes the pair to roughly 880 lines for the same content. A bench is not read the way a +// production file is; what it owes its reader is that everything it hands over be in one +// place. + // epoch is the instant every test starts at. // // A fixed instant and not « now »: a snapshot, a golden file and a countdown then read diff --git a/internal/web/health.go b/internal/web/health.go index 235dc3c..c773387 100644 --- a/internal/web/health.go +++ b/internal/web/health.go @@ -1,10 +1,15 @@ +// This file holds the TWO PROBES a supervisor polls: /healthz, which asks whether +// the decision loop still answers, and /readyz, which asks whether the station can +// serve a customer. +// +// They are deliberately different questions. A station whose scale is silent is +// ALIVE and not READY, and answering one for the other is how a restart gets +// triggered on a station that only needed its cable plugged back in. + package web import ( - "context" "net/http" - "sort" - "openscale/internal/domain" "openscale/internal/station" "openscale/internal/station/ports" @@ -142,447 +147,3 @@ func catalogReadiness(snap station.Snapshot) string { } return "loaded" } - -// adminHealthDTO is the dashboard of §14.4: the lights, the cadence, the inventory, -// and the two figures a volunteer reads out over the telephone. -type adminHealthDTO struct { - Version string `json:"version"` - Fingerprint string `json:"config_fingerprint"` - Station int `json:"station"` - StationName string `json:"station_name"` - Coop string `json:"coop"` - Alive bool `json:"alive"` - State stateDTO `json:"state"` - // ScalePresent carries the declaration of §11.2 so that the screen can turn the - // scale light OFF instead of drawing it red on a station that has no scale. Without - // it the screen would have to read the configuration, which needs a password. - ScalePresent bool `json:"scale_present"` - // PrinterSelfTests are the patterns of §8.6 the driver IN SERVICE honours, by the name - // the self-test route takes: "label", "alignment", "ruler". - // - // It travels HERE and not in the snapshot, for the reason the field above travels - // here: it is a DECLARATION about how this station is set up, not something the - // supervisor observed, and it changes only when a configuration is reloaded. The - // snapshot goes out ten times a second to a screen that has no self-test button on it. - // - // What it buys is one screen telling the truth. The Matériel page drew all three - // buttons whatever the driver, and on `preview` two of them answered a refusal on the - // click — in front of somebody already looking for why nothing prints. A button whose - // only possible answer is a refusal is not a choice (ADR-025). - // - // It is a LIST AND NEVER `null`, like every list of §14.5: the TypeScript contract - // declares an array and the page filters it the instant it has read it. - PrinterSelfTests []string `json:"printer_self_tests"` - // PrinterTransports are the byte transports THIS BINARY carries (§8.4), each with the - // wording a volunteer reads and the printer.options key it designates its device by. - // - // It travels for the reason the field above does, and it answers the same kind of - // question one notch further along: not « which button may I draw » but « where does - // what somebody types in this box get written ». The Matériel screen had no answer at - // all — `transport` was a free text box, and the single device field under it was wired - // to `queue` whatever was typed above. A station set to `tcp` therefore saved its - // printer's address into printer.options.queue, which validates and cannot print. - // - // The list comes from the registry and never from a table in the screen, exactly as - // control 8 of Config.Validate reads the same registry to refuse an unknown name: a - // fifth transport must not be able to exist for the validation and not for the form. - // - // EMPTY on a server built with no transport registry — the HTTP bench of this package — - // and never `null`, like every list of §14.5. - PrinterTransports []transportDTO `json:"printer_transports"` - - Counters countersDTO `json:"counters"` - // Events is the ten last technical lines, which is what §14.4 puts on the - // dashboard. Empty when this station has no journal wired. - Events []technicalLineDTO `json:"events"` - // Catalog is the one-line inventory of the last import. - Catalog *importDTO `json:"catalog"` - // CatalogFindings names the import whose findings DESCRIBE THE CATALOG IN SERVICE, - // and it is not always the one above. Zero when there is none to read. - // - // A byte-identical export dropped a second night is recorded 'unchanged' and writes no - // finding of its own — they belong to the import that produced the grid, one row above - // (importer.unchanged) — and a batch the database refused writes none either. Reading - // the last row emptied every list of the Catalogue page on the most ordinary event - // there is, while its counters kept announcing sixteen anomalies to correct. - CatalogFindings int64 `json:"catalog_findings_id"` - // CatalogMotives breaks the « non pesables » figure down by motive, because that is - // how §14.4 writes the line: « 8 non pesables — préemballés (7), code interne 0490 - // (1) ». Empty when there is no import to read it from. - CatalogMotives []motiveDTO `json:"catalog_motives"` - // CatalogSource is the permanent line of §14.4: the source, the path or the URL - // watched, and the account used. Nil when nothing published it. - CatalogSource *catalogSourceDTO `json:"catalog_source"` - // Decisions are the human judgements in force, with their reason and their date. - Decisions []decisionDTO `json:"decisions"` - - // Roll is the third light. Nil means « nothing counts labels on this station », which - // the screen says as such: a light drawn green for want of an answer would be the - // worst of the three possible outcomes. - Roll *rollDTO `json:"roll"` - // Disk is the fifth light, with the threshold beside the measurement (§10.4, §14.4). - Disk *diskDTO `json:"disk"` - // Restart is « redémarrage sans intervention : OK / NON CONFIGURÉ » (bloquant-7). - Restart *restartDTO `json:"unattended_restart"` - // Routing says which printer the labels come out of, and whether a fallback exists at - // all: that is what decides whether the troubleshooting page offers « Imprimer sur - // l'imprimante du poste N » (§14.4, §8.4). - Routing *routingDTO `json:"printing"` - // NewVersion is the version published that is newer than the one running, or the - // empty string. - // - // It travels HERE, in the payload the dashboard already reads, and not on a - // route of its own: the volunteer page opens without a password and calls - // exactly one route, which is a property a test holds. A second call from that - // page would have widened, for a courtesy, what an unauthenticated screen does. - // - // It is read from the last poll left on disk, never by asking the repository: - // this handler answers every three seconds. - NewVersion string `json:"new_version"` -} - -// transportDTO is one byte transport a volunteer may choose from, and where choosing it -// sends what they type next. -type transportDTO struct { - // ID is the value that goes into printer.options.transport: "winspool", "devfile", - // "tcp", "file". - ID string `json:"id"` - // Label is the French wording of the drop-down list: « Imprimante réseau, port 9100 ». - Label string `json:"label"` - // Key is the printer.options key this transport DESIGNATES ITS DEVICE by, and the one - // the screen writes the device field into: "queue", "path" or "address". - Key string `json:"key"` -} - -// routingDTO is which printer is in service. -type routingDTO struct { - FallbackAvailable bool `json:"fallback_available"` - OnFallback bool `json:"on_fallback"` - Name string `json:"name"` - Banner string `json:"banner"` -} - -// motiveDTO counts the rows of one import that share one motive. -// -// It carries the CODE and not a sentence: the screen writes « préemballés (7) » and the -// expert page shows the whole finding, and both then say the same thing about the same -// rows without this payload having to choose a wording for them. -type motiveDTO struct { - Code string `json:"code"` - // Value is the four-digit prefix when the motive is one, so that « code interne 0490 » - // names the number somebody has to correct in Odoo and not a category of number. - Value string `json:"value"` - Count int `json:"count"` -} - -// catalogSourceDTO is what feeds the catalog line of the dashboard. -type catalogSourceDTO struct { - Type string `json:"type"` - // Label is FRENCH and comes from the source itself — « dépôt local, flv_2.csv dans - // C:\ProgramData\OpenScale\catalog\incoming », « WebDAV, https://… (compte odoo) ». - Label string `json:"label"` -} - -// rollDTO is the label counter of §8.5 as the « rouleau » light reads it. -type rollDTO struct { - Printed int64 `json:"printed_count"` - Capacity int `json:"capacity_count"` - Remaining int64 `json:"remaining_count"` - // Level is "info" or "warn" and NEVER "error": a roll about to run out is a - // maintenance job, not a breakdown (§8.5). - Level string `json:"level"` - Message string `json:"message"` - // Known reports whether the counter has ever been written. A station installed this - // morning has no counter, and « environ 1000 étiquettes restantes » about a roll - // nobody described would be a number invented on the spot. - Known bool `json:"known"` -} - -// diskDTO is the room left where this station writes. -type diskDTO struct { - Path string `json:"path"` - FreeBytes int64 `json:"free_bytes"` - TotalBytes int64 `json:"total_bytes"` - // AlertMB is maintenance.disk_alert_mb, sent BESIDE the measurement so that a - // threshold with no relation to reality is visible at a glance (§10.4, §14.4). - AlertMB int `json:"alert_mb"` -} - -// restartDTO is bloquant-7 on the dashboard, and it is the same verdict `openscale -// doctor` gives at its third control (§15.4). -type restartDTO struct { - Configured bool `json:"configured"` - // Known is false when the system could not be asked. « Je ne sais pas » and « non - // configuré » call for two different gestures. - Known bool `json:"known"` - // Detail and Remedy are FRENCH. Remedy is what makes the amber line actionable. - Detail string `json:"detail"` - Remedy string `json:"remedy"` -} - -// countersDTO is what the station counts about itself. -type countersDTO struct { - // Unlogged is the counter of ADR-013, and the only one that is a RED light. - Unlogged int64 `json:"unlogged_weighings_count"` - // Journal is how many rows the journal holds, or -1 when there is no journal. - Journal int `json:"journal_rows_count"` -} - -// adminHealth is GET /admin/api/health, NOT authenticated (ADR-018): it reads, it -// writes nothing, and a volunteer in front of a mute station has to be able to open -// it. -func (s *Server) adminHealth(w http.ResponseWriter, r *http.Request) { - cfg := s.hub.Config() - snap := s.hub.State() - body := adminHealthDTO{ - Version: s.version, - Fingerprint: cfg.Fingerprint(), - Station: cfg.Station.Number, - StationName: cfg.Station.Name, - Coop: cfg.Station.Coop, - Alive: s.alive(), - State: s.stateOf(snap), - ScalePresent: cfg.Scale.Present, - PrinterSelfTests: s.selfTestsOf(cfg.Printer.Type), - PrinterTransports: s.transports(), - Counters: countersDTO{Unlogged: snap.UnloggedWeighings, Journal: -1}, - // The three lists are EMPTY and not nil, because that is the difference between - // « there is none » and `null`. A station with no journal (ADR-013) reads none of - // them, a station installed this morning has no import to break down, and the - // screen spreads and filters them the instant it has read them: `null` is an - // uncaught TypeError that closes the administration in a volunteer's face. - Events: []technicalLineDTO{}, - CatalogMotives: []motiveDTO{}, - Decisions: []decisionDTO{}, - NewVersion: s.newVersion(cfg.Update.Repository), - } - s.fillHealthFromStore(r.Context(), &body) - s.fillHealthFromPlatform(r.Context(), &body, cfg) - writeJSON(w, http.StatusOK, body) -} - -// selfTestsOf reports the self-tests the driver named by printer.type honours, as its -// registry entry declared them (§8.6). -// -// EMPTY when no descriptor answers to that name, and that answer is exact on a station -// that is running: printer.type is read from the configuration IN FORCE, which is either -// one this binary validated against its own registry or the neutral profile of §11.3 — -// both name a driver this binary carries. What is left is a server built with no printer -// registry at all: `openscale config validate` on a laptop and the HTTP bench of this -// package, neither of which has a printer to launch anything on either. -func (s *Server) selfTestsOf(driver string) []string { - for _, descriptor := range s.registries.Printers { - if descriptor.ID == driver { - // A COPY: this slice leaves for a JSON encoder, and the registry it comes from - // describes the binary for as long as the process runs. - return append([]string{}, descriptor.SelfTests...) - } - } - return []string{} -} - -// transports reports the byte transports this binary carries, in the order the registry -// declares them — which is the order §8.4 presents them in, the two local defaults first. -// -// EMPTY and never nil: a server built with no transport registry is a legitimate state, -// and the screen then draws no drop-down list rather than a broken one. -func (s *Server) transports() []transportDTO { - out := make([]transportDTO, 0, len(s.registries.Transports)) - for _, descriptor := range s.registries.Transports { - out = append(out, transportDTO{ - ID: descriptor.ID, Label: descriptor.Label, Key: descriptor.DeviceKey, - }) - } - return out -} - -// fillHealthFromPlatform adds the three facts only the composition root can answer, and -// leaves them ABSENT when nobody answered. -// -// Absent and not zero: a roll counter at 0, a disk with 0 free bytes and « redémarrage -// sans intervention : OK » are three sentences a screen would draw in good faith, and all -// three would be false on a station that simply has no Dashboard wired. -func (s *Server) fillHealthFromPlatform(ctx context.Context, body *adminHealthDTO, cfg domain.Config) { - if s.dashboard == nil { - return - } - facts := s.dashboard.Dashboard(ctx) - if facts.Roll != nil { - body.Roll = &rollDTO{ - Printed: facts.Roll.Printed, Capacity: facts.Roll.Capacity, - Remaining: facts.Roll.Remaining, Level: facts.Roll.Level, - Message: facts.Roll.Message, Known: facts.Roll.Known, - } - } - if facts.Disk != nil { - body.Disk = &diskDTO{ - Path: facts.Disk.Path, FreeBytes: facts.Disk.FreeBytes, - TotalBytes: facts.Disk.TotalBytes, AlertMB: cfg.Maintenance.DiskAlertMB, - } - } - if facts.Restart != nil { - body.Restart = &restartDTO{ - Configured: facts.Restart.Configured, Known: facts.Restart.Known, - Detail: facts.Restart.Detail, Remedy: facts.Restart.Remedy, - } - } - if facts.Source != nil { - body.CatalogSource = &catalogSourceDTO{Type: facts.Source.Type, Label: facts.Source.Label} - } - if facts.Routing != nil { - body.Routing = &routingDTO{ - FallbackAvailable: facts.Routing.Available, OnFallback: facts.Routing.OnFallback, - Name: facts.Routing.Name, Banner: facts.Routing.Banner, - } - } -} - -// alive reports the liveness of the loop WITHOUT probing it. -// -// The dashboard is read by a human who is already looking at the state; submitting a -// command to draw a light would put a Hub turn behind every refresh of a screen -// somebody left open. -func (s *Server) alive() bool { - if s.controller == nil { - return true - } - return s.controller.Alive() -} - -// fillHealthFromStore adds what only the database knows, and says nothing when there -// is no database: a station whose journal is unavailable still has to draw its -// dashboard (ADR-013). -func (s *Server) fillHealthFromStore(ctx context.Context, body *adminHealthDTO) { - if s.store == nil { - return - } - if rows, err := s.store.CountWeighings(ctx); err == nil { - body.Counters.Journal = rows - } - if lines, err := s.store.TechnicalEntries(ctx, TechnicalQuery{Limit: 10}); err == nil { - body.Events = technicalLinesOf(lines) - } - if last := s.lastImport(ctx); last != nil { - body.Catalog = last - body.CatalogFindings = s.findingsInForce(ctx, *last) - if findings, err := s.store.Findings(ctx, body.CatalogFindings); err == nil { - body.CatalogMotives = motivesOf(findings) - } - } - if decisions, err := s.store.LocalDecisions(ctx); err == nil { - body.Decisions = decisionsOf(decisions) - } -} - -// lastImport is the import in force, or nil when there is none to read. -// -// Nil covers three states on purpose, because the screen owes the same prudence to all -// three: no journal at all (ADR-013), a journal that refused the read, and a station -// installed this morning that has never received a catalog. -func (s *Server) lastImport(ctx context.Context) *importDTO { - if s.store == nil { - return nil - } - list, err := s.store.Imports(ctx, 1, 0) - if err != nil || len(list) == 0 { - return nil - } - last := importOf(list[0]) - return &last -} - -// findingsInForce names the import whose findings describe the catalog in service. -// -// Two of the four outcomes speak for themselves and are answered with their own row: an -// APPLIED import produced the grid, and a REJECTED one wrote no product at all — its -// remarks are exactly what somebody must fix for the next file to get in (§10.5), and -// answering a refusal with the remarks of a healthy catalog would be the wrong list -// entirely. -// -// The other two wrote NO finding, on purpose, and it is the catalog in service they leave -// alone: 'unchanged' saw a file this station had already applied, and 'failed' rolled the -// transaction back. What describes the grid is then the last applied import, which is the -// same row the client screen dates itself from (ADR-053) — two screens, one line of one -// table, and no way left for them to disagree. -// -// Zero when a station has never applied one: the screen says « aucun » rather than draw -// the findings of some other import. -func (s *Server) findingsInForce(ctx context.Context, last importDTO) int64 { - if last.Result == domain.ImportApplied || last.Result == domain.ImportRejected { - return last.ID - } - applied, err := s.store.LastAppliedImport(ctx) - if err != nil { - return 0 - } - return applied.ID -} - -// watchedCatalog is the permanent catalog line of §14.4, or an empty string. -// -// The wording comes from the SOURCE itself — « dépôt local, flv_2.csv dans … » — which is -// why nothing here composes it: only the source knows whether it has an account and which -// file name a station number derives. -func (s *Server) watchedCatalog(ctx context.Context) string { - if s.dashboard == nil { - return "" - } - source := s.dashboard.Dashboard(ctx).Source - if source == nil { - return "" - } - return source.Label -} - -// notWeighableMotives are the three reasons a row has no tile, and the only findings this -// breakdown counts (§10.3). -// -// An anomaly is deliberately NOT in the list: « 16 anomalies à corriger dans Odoo » is -// already its own line of the inventory, and mixing the two would rebuild the « 46 -// produits en erreur » §14.4 refuses. -var notWeighableMotives = map[string]bool{ - domain.FindingNoBarcode: true, - domain.FindingPrepackagedProduct: true, - domain.FindingInternalCodeNotWeighable: true, -} - -// prefixWidth is how many digits of a barcode name a family of codes (§6.2). -const prefixWidth = 4 - -// motivesOf counts the non-weighable rows of one import by motive, most numerous first. -// -// The internal codes are counted PER PREFIX, because that is the difference between -// « code interne (1) » and « code interne 0490 (1) »: the second names the number to -// correct in Odoo, and it is the sentence §14.4 quotes. -func motivesOf(findings []domain.Finding) []motiveDTO { - counts := make(map[motiveDTO]int) - for _, f := range findings { - if !notWeighableMotives[f.Code] { - continue - } - key := motiveDTO{Code: f.Code} - if f.Code == domain.FindingInternalCodeNotWeighable && len(f.Value) >= prefixWidth { - key.Value = f.Value[:prefixWidth] - } - counts[key]++ - } - - out := make([]motiveDTO, 0, len(counts)) - for motive, count := range counts { - motive.Count = count - out = append(out, motive) - } - // Sorted, and by more than the count: a map iterates in a different order every run, - // and a dashboard whose sentence reshuffles itself between two refreshes is a - // dashboard nobody trusts. - sort.Slice(out, func(i, j int) bool { - if out[i].Count != out[j].Count { - return out[i].Count > out[j].Count - } - if out[i].Code != out[j].Code { - return out[i].Code < out[j].Code - } - return out[i].Value < out[j].Value - }) - return out -} diff --git a/internal/web/imports.go b/internal/web/imports.go new file mode 100644 index 0000000..78a8e7f --- /dev/null +++ b/internal/web/imports.go @@ -0,0 +1,133 @@ +// This file holds WHAT A CATALOG IMPORT LEFT BEHIND (§14.4): the batches, the +// findings each one raised, and the gesture that forgets a quarantine. +// +// An import is the only thing that changes the grid, so its history is what answers +// « pourquoi ce produit n'est plus là ? » without anyone opening a file. +// +// Reading is open; forgetting a quarantine is PROTECTED, because it puts back into +// the grid a product an import had pulled out of it (ADR-033). + +package web + +import ( + "net/http" + "openscale/internal/domain" +) + +// importDTO is the inventory of one import, and it is written the way §14.4 reads it +// out loud: received, weighable, not weighable, anomalies. +// +// Never « 46 produits en erreur ». It is false — a prepackaged boulgour is not an +// error, it is not the scale's business — it alarms without giving anything to do, +// and it drowns the only figure that deserves the eye: the rows somebody can fix. +type importDTO struct { + ID int64 `json:"id"` + OccurredAt string `json:"occurred_at"` + Source string `json:"source"` + FileName string `json:"file_name"` + Result string `json:"result"` + Code string `json:"code"` + Reason string `json:"reason"` + + RowsRead int `json:"rows_read_count"` + UnreadableRows int `json:"unreadable_rows_count"` + Weighable int `json:"weighable_count"` + NotWeighable int `json:"not_weighable_count"` + Anomalies int `json:"anomalies_count"` + UnitMismatches int `json:"unit_mismatches_count"` + ImagesDecoded int `json:"images_decoded_count"` + ImagesRejected int `json:"images_rejected_count"` + ProductsWithdrawn int `json:"products_withdrawn_count"` + DurationMS int `json:"duration_ms"` +} + +// imports is GET /admin/api/imports: the twenty last imports, and the findings of the +// one named by ?id=. +func (s *Server) imports(w http.ResponseWriter, r *http.Request) { + if s.store == nil { + unavailable(w, "ce poste n'a pas d'historique d'imports") + return + } + list, err := s.store.Imports(r.Context(), intParam(r, "limit", 20), intParam(r, "offset", 0)) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + // Both lists are BUILT, including the one this call may never fill: a station with no + // catalog has no import to name, so `?id=` is absent, so the findings are never read — + // and a nil slice would go out as `null` against a contract that declares an array. + // That is what took the Catalogue page down on a station installed this morning. + body := struct { + Imports []importDTO `json:"imports"` + Findings []findingDTO `json:"findings"` + }{Imports: make([]importDTO, 0, len(list)), Findings: []findingDTO{}} + for _, record := range list { + body.Imports = append(body.Imports, importOf(record)) + } + + if id := intParam(r, "id", 0); id > 0 { + findings, err := s.store.Findings(r.Context(), int64(id)) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + body.Findings = findingsOf(findings) + } + writeJSON(w, http.StatusOK, body) +} + +// importOf converts one import record. +func importOf(record domain.Import) importDTO { + return importDTO{ + ID: record.ID, OccurredAt: stamp(record.OccurredAt), + Source: record.Source, FileName: record.FileName, + Result: record.Result, Code: record.Code, Reason: record.Reason, + RowsRead: record.RowsRead, UnreadableRows: record.UnreadableRows, + Weighable: record.Weighable, NotWeighable: record.NotWeighable, + Anomalies: record.Anomalies, UnitMismatches: record.UnitMismatches, + ImagesDecoded: record.ImagesDecoded, ImagesRejected: record.ImagesRejected, + ProductsWithdrawn: record.ProductsWithdrawn, DurationMS: record.DurationMS, + } +} + +// findingDTO is one row an import had something to say about. +// +// CSVLine is what makes the report usable: it names the row to fix IN ODOO, which is +// the only place anybody can fix it. ProductName is what makes it readable: it is the +// name the import itself read, and the screen shows it rather than send whoever corrects +// the file looking up an Odoo id first. +type findingDTO struct { + CSVLine int `json:"csv_line"` + ProductID string `json:"product_id"` + ProductName string `json:"product_name"` + Code string `json:"code"` + Issue string `json:"issue"` + Message string `json:"message"` + Value string `json:"value"` +} + +// findingsOf converts what one import reported. +func findingsOf(findings []domain.Finding) []findingDTO { + out := make([]findingDTO, 0, len(findings)) + for _, f := range findings { + out = append(out, findingDTO{ + CSVLine: f.CSVLine, ProductID: f.ProductID, ProductName: f.ProductName, + Code: f.Code, Issue: f.Issue, Message: f.Message, Value: f.Value, + }) + } + return out +} + +// forgetQuarantine is POST /admin/api/catalog/forget-quarantine. +func (s *Server) forgetQuarantine(w http.ResponseWriter, r *http.Request) { + if s.catalog == nil { + unavailable(w, "aucune source de catalogue n'est configurée") + return + } + if err := s.catalog.ForgetQuarantine(r.Context()); err != nil { + writeProblem(w, http.StatusInternalServerError, "", err.Error()) + return + } + writeJSON(w, http.StatusOK, actionDTO{ + Done: true, Message: "La quarantaine est oubliée : le prochain fichier sera relu."}) +} diff --git a/internal/web/journal.go b/internal/web/journal.go new file mode 100644 index 0000000..9dc4ca3 --- /dev/null +++ b/internal/web/journal.go @@ -0,0 +1,232 @@ +// This file holds the TWO JOURNALS an operator reads (§14.4): the weighings of +// §12.3, and what the station has to say about itself. +// +// The weighings journal exists for the till, so its DTO carries what the LABEL +// carried. The CSV export is the same rows through the same conversion: one shape, +// read twice. +// +// All four routes here are OPEN, export included: the page already shows the two +// hundred weighings, and diagnostic.zip -- open too -- carries them as well. A lock +// on the third door is not one. + +package web + +import ( + "encoding/csv" + "net/http" + "openscale/internal/domain" + "strconv" + "time" +) + +// JournalQuery narrows one page of the weighing journal. +// +// It is declared HERE and not imported from the store: internal/web knows no database +// package (§5.2), so cmd/openscale translates this into whatever the store speaks. +type JournalQuery struct { + Since time.Time + Until time.Time + Result string + Limit int + Offset int +} + +// TechnicalQuery narrows one page of the technical journal. +type TechnicalQuery struct { + Since time.Time + Until time.Time + // Level keeps one level only. It is NOT a threshold: the screen filters by what a + // line IS, and « everything at least as bad as a warning » is a question nobody + // asked at the counter. + Level string + Source string + Code string + Limit int + Offset int +} + +// TechnicalLine is one line of the technical journal as the screen reads it. +type TechnicalLine struct { + ID int64 + OccurredAt time.Time + Level string + Source string + Code string + Message string + Detail string +} + +// weighingDTO is one row of the journal. +type weighingDTO struct { + ID int64 `json:"id"` + OccurredAt string `json:"occurred_at"` + Station int `json:"station"` + JobID string `json:"job_id"` + ProductID string `json:"product_id"` + ProductName string `json:"product_name"` + Reference string `json:"reference"` + Mode string `json:"mode"` + GrossG int64 `json:"gross_g"` + TareG int64 `json:"tare_g"` + NetG int64 `json:"net_g"` + Quantity int `json:"quantity"` + Barcode string `json:"barcode"` + Source string `json:"source"` + Stability string `json:"stability"` + RateMS int `json:"rate_ms"` + // Frame is the RAW serial frame, kept as the living corpus of the replay driver: + // any frame that caused an unexplained refusal becomes a permanent test (§15.4). + Frame string `json:"frame"` + Result string `json:"result"` + Detail string `json:"detail"` + DurationMS int `json:"duration_ms"` + Lines []lineDTO `json:"lines"` +} + +// lineDTO is one price line of one journalled weighing. +type lineDTO struct { + TierCode string `json:"tier_code"` + UnitPriceCents int64 `json:"unit_price_cents"` + AmountCents int64 `json:"amount_cents"` +} + +// journal is GET /admin/api/journal: the 200 last weighings, filtered. +func (s *Server) journal(w http.ResponseWriter, r *http.Request) { + if s.store == nil { + unavailable(w, "ce poste n'a pas de journal") + return + } + rows, err := s.store.Weighings(r.Context(), journalQueryOf(r)) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + out := make([]weighingDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, weighingOf(row)) + } + writeJSON(w, http.StatusOK, struct { + Weighings []weighingDTO `json:"weighings"` + }{out}) +} + +// journalCSV is GET /admin/api/journal/export.csv. +// +// A semicolon and a UTF-8 BOM: this file is opened in the spreadsheet of a French +// Windows, and a comma-separated file lands in one column there. It is the same +// trade-off the producer's own export makes (§10.2). +func (s *Server) journalCSV(w http.ResponseWriter, r *http.Request) { + if s.store == nil { + unavailable(w, "ce poste n'a pas de journal") + return + } + rows, err := s.store.Weighings(r.Context(), journalQueryOf(r)) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="journal.csv"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) + + out := csv.NewWriter(w) + out.Comma = ';' + defer out.Flush() + _ = out.Write([]string{"occurred_at", "station", "job_id", "product_id", "product_name", + "reference", "mode", "gross_g", "tare_g", "net_g", "quantity", "barcode", + "source", "stability", "result", "detail", "duration_ms"}) + for _, row := range rows { + _ = out.Write([]string{ + stamp(row.OccurredAt), strconv.Itoa(row.Station), row.JobID, + row.ProductID, row.ProductName, string(row.Reference), row.Mode.String(), + strconv.FormatInt(int64(row.GrossWeight), 10), + strconv.FormatInt(int64(row.Tare), 10), + strconv.FormatInt(int64(row.NetWeight), 10), + strconv.Itoa(row.Quantity), string(row.Barcode), + row.Source, row.Stability.String(), row.Result, row.Detail, + strconv.Itoa(row.DurationMS), + }) + } +} + +// journalQueryOf reads the filters off the query string. +func journalQueryOf(r *http.Request) JournalQuery { + q := JournalQuery{ + Result: r.URL.Query().Get("result"), + Limit: intParam(r, "limit", 200), + Offset: intParam(r, "offset", 0), + } + q.Since = instantParam(r, "since") + q.Until = instantParam(r, "until") + return q +} + +// weighingOf converts one journalled weighing. +func weighingOf(row domain.Weighing) weighingDTO { + out := weighingDTO{ + ID: row.ID, OccurredAt: stamp(row.OccurredAt), Station: row.Station, + JobID: row.JobID, ProductID: row.ProductID, ProductName: row.ProductName, + Reference: string(row.Reference), Mode: row.Mode.String(), + GrossG: int64(row.GrossWeight), TareG: int64(row.Tare), NetG: int64(row.NetWeight), + Quantity: row.Quantity, Barcode: string(row.Barcode), + Source: row.Source, Stability: row.Stability.String(), RateMS: row.RateMS, + Frame: row.Frame, Result: row.Result, Detail: row.Detail, + DurationMS: row.DurationMS, + Lines: make([]lineDTO, 0, len(row.Lines)), + } + for _, line := range row.Lines { + out.Lines = append(out.Lines, lineDTO{ + TierCode: line.TierCode, UnitPriceCents: int64(line.UnitPrice), + AmountCents: int64(line.Amount), + }) + } + return out +} + +// technicalLineDTO is one line of the technical journal. +type technicalLineDTO struct { + ID int64 `json:"id"` + OccurredAt string `json:"occurred_at"` + Level string `json:"level"` + Source string `json:"source"` + Code string `json:"code"` + Message string `json:"message"` + Detail string `json:"detail"` +} + +// technicalJournal is GET /admin/api/technical. +func (s *Server) technicalJournal(w http.ResponseWriter, r *http.Request) { + if s.store == nil { + unavailable(w, "ce poste n'a pas de journal technique") + return + } + query := TechnicalQuery{ + Level: r.URL.Query().Get("level"), Source: r.URL.Query().Get("source"), + Code: r.URL.Query().Get("code"), + Limit: intParam(r, "limit", 200), Offset: intParam(r, "offset", 0), + } + query.Since, query.Until = instantParam(r, "since"), instantParam(r, "until") + + lines, err := s.store.TechnicalEntries(r.Context(), query) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "ERR-DB-01", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Entries []technicalLineDTO `json:"entries"` + }{technicalLinesOf(lines)}) +} + +// technicalLinesOf converts a page of the technical journal. +func technicalLinesOf(lines []TechnicalLine) []technicalLineDTO { + out := make([]technicalLineDTO, 0, len(lines)) + for _, line := range lines { + out = append(out, technicalLineDTO{ + ID: line.ID, OccurredAt: stamp(line.OccurredAt), Level: line.Level, + Source: line.Source, Code: line.Code, Message: line.Message, Detail: line.Detail, + }) + } + return out +} diff --git a/internal/web/query.go b/internal/web/query.go new file mode 100644 index 0000000..78c9419 --- /dev/null +++ b/internal/web/query.go @@ -0,0 +1,42 @@ +// This file holds how a QUERY STRING becomes a bound: a page size, an offset, an +// instant. +// +// Both read a value that a browser, a script or a hand-typed URL may have written, +// so both fall back rather than refuse: a listing that answers nothing because a +// parameter was mistyped is worse than a listing that answers the default page. + +package web + +import ( + "net/http" + "strconv" + "time" +) + +// intParam reads one integer off the query string, with a fallback. +func intParam(r *http.Request, name string, fallback int) int { + raw := r.URL.Query().Get(name) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value < 0 { + return fallback + } + return value +} + +// instantParam reads one RFC 3339 instant off the query string. An unreadable one is +// the ZERO instant, which every filter reads as « no bound »: a screen that mistypes a +// date must get the whole page, never an empty one it would read as « no weighings ». +func instantParam(r *http.Request, name string) time.Time { + raw := r.URL.Query().Get(name) + if raw == "" { + return time.Time{} + } + instant, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{} + } + return instant +} diff --git a/internal/web/recovery_test.go b/internal/web/recovery_test.go new file mode 100644 index 0000000..6377771 --- /dev/null +++ b/internal/web/recovery_test.go @@ -0,0 +1,388 @@ +package web + +import ( + "bytes" + "context" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "openscale/internal/domain" + "openscale/internal/platform" +) + +// The recovery code as a WAY BACK IN, and the one rule that matters: it reopens the door +// WITHOUT touching the shop's configuration. +// +// A rescue that put the factory profile back would cost the station its prices, its +// templates and its safeguards at the exact moment somebody is already in trouble. Every +// test here checks that the file comes out with its blocks — including when it is damaged, +// when it comes from an older binary, and when it cannot be rewritten at all. + +// TestTheRecoveryCodeResetsThePasswordFromTheScreen (important-10). +// +// On a station in Assigned Access there is neither desktop nor prompt: « run openscale +// config password » is not an instruction anybody can follow. The code on the +// installation sheet is the possession factor. +func TestTheRecoveryCodeResetsThePasswordFromTheScreen(t *testing.T) { + saved := &savedConfig{} + b := newBench(t, func(o *benchOptions) { o.configStore = saved }) + b.setPassword("oublie", "ABCD2345") + b.login("oublie") + + wrong := b.post("/admin/api/session/recovery", `{"code":"ZZZZ9999","password":"nouveau-mot"}`) + wrong.Body.Close() + if wrong.StatusCode != http.StatusUnauthorized { + t.Fatalf("code faux = %d, attendu 401", wrong.StatusCode) + } + + response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("code de secours = %d : %s", response.StatusCode, body(t, response)) + } + response.Body.Close() + + if hash := saved.saved().Admin.PasswordHash; hash == "" || !VerifySecret(hash, "nouveau-mot") { + t.Fatal("le nouveau mot de passe n'a pas été écrit dans la configuration") + } + // The volunteer who just proved possession of the sheet is logged in, and every + // session minted under the old password is gone. + if got := b.get("/admin/api/config"); got.StatusCode != http.StatusOK { + t.Fatalf("la session délivrée par le code de secours ne vaut rien : %d", got.StatusCode) + } + if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { + t.Fatal("la configuration en service porte encore l'ancien mot de passe") + } +} + +// TestARescueDoesNotReplaceTheShopsConfigurationWithTheFactoryOne. +// +// The one station that needs the recovery code most is the one that started OUT OF +// SERVICE: no password, no screen, nothing but the eight characters on the installation +// sheet. And that station runs the NEUTRAL PROFILE in memory while its file keeps the +// cooperative's tariffs, safeguards and categories (§11.3). Writing the running +// configuration back would have wiped all of it on the single gesture meant to rescue it. +// +// It proves the ROUTE and nothing else: configStore is an in-memory double that never +// refuses a read, so this test is blind to everything ConfigStore.Read decides. Saying it +// held « one half » of the property was wrong, and expensively so: it stayed green through +// the whole time the route was writing the fourteen factory blocks onto the shop's file. +// What holds the property end to end is +// TestARescueThroughTheRealStoreKeepsTheShopsBlocks, below, on a real file and a real store. +func TestARescueDoesNotReplaceTheShopsConfigurationWithTheFactoryOne(t *testing.T) { + shop := loadConfig(t) + saved := &savedConfig{} + if err := saved.Save(context.Background(), shop); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } + + b := newBench(t, func(o *benchOptions) { + o.configStore = saved + // What a station in factory configuration RUNS (§11.3), which is not what its + // file says. + o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } + }) + b.setPassword("oublie", "ABCD2345") + + response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("code de secours = %d : %s", response.StatusCode, body(t, response)) + } + response.Body.Close() + + written := saved.saved() + if !VerifySecret(written.Admin.PasswordHash, "nouveau-mot") { + t.Fatal("le nouveau mot de passe n'est pas dans le fichier") + } + if got, want := len(written.Pricing.Tiers), len(shop.Pricing.Tiers); got != want { + t.Fatalf("le fichier porte %d tarifs au lieu de %d : la configuration du magasin "+ + "a été remplacée par celle d'usine", got, want) + } + if written.Limits.BasketMin != shop.Limits.BasketMin || written.Station.Coop != shop.Station.Coop { + t.Fatal("le fichier a perdu les réglages du magasin") + } + // And the station keeps running what it was running: a rescue is not the moment to + // hand a station a configuration nobody validated. + if b.hub.Config().Station.Coop == shop.Station.Coop { + t.Fatal("le poste s'est mis à faire tourner le fichier au lieu de son profil neutre") + } +} + +// TestARescueThroughTheRealStoreKeepsTheShopsBlocks is the half the test above cannot +// reach, and the one that was open. +// +// Everything here is REAL: a file on disk, a platform.ConfigStore over it, and the +// recovery route. The double the test above uses never refuses a read, so it went on +// passing through the whole time this was broken — a station out of service, whose file +// has ONE unreadable block, had the fourteen FACTORY blocks written over it by the single +// gesture meant to rescue it: identity, tariffs, catalog source and its credentials, +// safeguards. HTTP 200, no warning. +func TestARescueThroughTheRealStoreKeepsTheShopsBlocks(t *testing.T) { + shop := loadConfig(t) + path := filepath.Join(t.TempDir(), "config.json") + writeRawConfig(t, path, shop) + damagePricingBlock(t, path) + + file, err := platform.NewConfigStore(path) + if err != nil { + t.Fatalf("NewConfigStore : %v", err) + } + b := newBench(t, func(o *benchOptions) { + o.configStore = realConfigStore{file} + // What a station in factory configuration RUNS (§11.3), which is not its file. + o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } + }) + b.setPassword("oublie", "ABCD2345") + + before := readRaw(t, path) + + response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) + got := decodeStatus[sessionDTO](t, response, http.StatusOK) + + // The FILE, decoded the way a station decodes it: what matters is what boots tomorrow. + written, _ := domain.DecodeConfigBlockByBlock(readRaw(t, path)) + if coop := written.Station.Coop; coop != shop.Station.Coop { + t.Errorf("station.coop = %q, attendu %q : le profil d'usine a été écrit sur le "+ + "fichier du magasin", coop, shop.Station.Coop) + } + // pricing is the block that was damaged, so it cannot be asserted through a decode — + // it is asserted on the BYTES, which is where the members' discount has to survive. + if !bytes.Contains(readRaw(t, path), []byte(`"discount_percent":10`)) { + t.Error("la remise des adhérents a disparu du fichier") + } + if source := written.Catalog.Type; source != shop.Catalog.Type { + t.Errorf("catalog.type = %q, attendu %q : la source du catalogue a été remplacée", + source, shop.Catalog.Type) + } + if basket, want := written.Limits.BasketMin, shop.Limits.BasketMin; basket != want { + t.Errorf("limits.basket_min = %v, attendu %v : les garde-fous ont été remplacés", + basket, want) + } + // Nothing at all was written, which is the strongest form of the four assertions above + // and the one that also covers the blocks this test does not name. + if !bytes.Equal(before, readRaw(t, path)) { + t.Error("le fichier a été réécrit alors qu'un de ses blocs n'a pas pu être lu") + } + + // The door still opens — a rescue that refused would leave this station with no way in + // at all — and it says plainly what is not saved, naming the block to repair. + if got.Warning == "" { + t.Fatal("la session s'ouvre sans dire que le mot de passe n'est pas enregistré") + } + if !strings.Contains(got.Warning, "pricing") { + t.Errorf("l'avertissement ne nomme pas le bloc à corriger : %q", got.Warning) + } + // And the password is in force IN MEMORY, which is what makes the session usable. + if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { + t.Error("le nouveau mot de passe n'est pas en service") + } +} + +// TestARecoveryOnALegacyFileDoesNotLaunderTheDiscount (the defect this fix closes). +// +// This is the station the guard exists for: an upgraded site whose file control 20 +// refuses runs the neutral profile, the volunteer cannot log in, and reaches for the +// recovery code on the installation sheet. Before this fix, that single gesture read +// the on-disk file, set the new password hash, and wrote the WHOLE struct back — +// which drops a retired key, because encoding/json only ever kept what a field claims. +// Whatever weight_decimals stood for on a numbering plan this binary no longer trusts +// would be gone from the file, silently, and control 20 would find nothing on the +// station's next start. The real ConfigStore is used here, and not the in-memory double: +// the guard lives in Save, and a double that never calls it would prove nothing about the +// file on disk. +func TestARecoveryOnALegacyFileDoesNotLaunderTheDiscount(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := legacyLaCagetteRawWithARefusedKey(t) + if err := os.WriteFile(path, before, 0o644); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } + store, err := platform.NewConfigStore(path) + if err != nil { + t.Fatalf("NewConfigStore : %v", err) + } + + b := newBench(t, func(o *benchOptions) { o.configStore = realConfigStore{store} }) + b.setPassword("oublie", "ABCD2345") + + response := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":"nouveau-mot"}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("code de secours sur fichier legacy = %d : %s", + response.StatusCode, body(t, response)) + } + response.Body.Close() + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture : %v", err) + } + if string(after) != string(before) { + t.Fatalf("le fichier a été réécrit : la clé retirée a disparu.\navant :\n%s\naprès :\n%s", + before, after) + } + if !strings.Contains(string(after), "weight_decimals") { + t.Fatal("weight_decimals n'est plus dans le fichier : la clé retirée a été blanchie") + } +} + +// TestARecoveryStillOpensASessionWhenTheFileCannotBeSaved is the corner this fix must +// not get wrong: refusing to persist the new password must not lock the volunteer out +// of their own station. The volunteer reaching for the recovery code is very often +// standing in front of the ONE station whose file control 20 refuses — that is what +// put it out of service in the first place, and the screen that explains it is behind +// the very door this request opens. Failing loudly here would trade a silent +// overcharge for a station nobody can administer. +func TestARecoveryStillOpensASessionWhenTheFileCannotBeSaved(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, legacyLaCagetteRawWithARefusedKey(t), 0o644); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } + store, err := platform.NewConfigStore(path) + if err != nil { + t.Fatalf("NewConfigStore : %v", err) + } + + b := newBench(t, func(o *benchOptions) { o.configStore = realConfigStore{store} }) + b.setPassword("oublie", "ABCD2345") + + response := b.post("/admin/api/session/recovery", + `{"code":"ABCD2345","password":"nouveau-mot"}`) + got := decodeStatus[sessionDTO](t, response, http.StatusOK) + if got.Warning == "" || !strings.Contains(got.Warning, "weight_decimals") { + t.Fatalf("l'avertissement ne nomme pas weight_decimals : %q", got.Warning) + } + + // The volunteer really is in: the new password is in force, and a session was + // issued and is usable. + if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { + t.Fatal("le nouveau mot de passe n'est pas en service") + } + if got := b.get("/admin/api/config"); got.StatusCode != http.StatusOK { + t.Fatalf("la session délivrée par le code de secours ne vaut rien : %d", got.StatusCode) + } + if !b.technical.has("ERR-CFG-01") { + t.Fatal("l'incapacité à écrire le mot de passe n'est pas journalisée") + } +} + +// TestARecoveryTooShortIsRefused: resetting without setting would leave the station +// unprotected for as long as nobody came back to it. +func TestARecoveryTooShortIsRefused(t *testing.T) { + b := newBench(t, func(o *benchOptions) { o.configStore = &savedConfig{} }) + b.setPassword("oublie", "ABCD2345") + + response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"court"}`) + defer response.Body.Close() + if response.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("mot de passe trop court = %d, attendu 422", response.StatusCode) + } +} + +// TestAStationWithNoPasswordSaysSoInsteadOfRefusingEverything: a station that has never +// been through the first-start wizard has no password to check, and refusing silently +// would make the wizard itself unreachable. +func TestAStationWithNoPasswordSaysSoInsteadOfRefusingEverything(t *testing.T) { + b := newBench(t, func(o *benchOptions) { + o.config = func(cfg *domain.Config) { cfg.Admin.PasswordHash = "" } + }) + response := b.post("/admin/api/session", `{"password":"quoi"}`) + defer response.Body.Close() + if response.StatusCode != http.StatusConflict { + t.Fatalf("statut = %d, attendu 409", response.StatusCode) + } + // La route PROTÉGÉE dit par où l'on entre. L'assistant en cinq étapes de §14.4 + // n'existe pas dans ce code, et y renvoyer envoyait chercher un écran que personne + // n'a écrit ; le chemin qui existe est le code de secours de la fiche. + protected := b.do(http.MethodPut, "/admin/api/config", `{}`, nil) + defer protected.Body.Close() + if protected.StatusCode != http.StatusConflict { + t.Fatalf("acte protégé sans mot de passe = %d, attendu 409", protected.StatusCode) + } + if got := body(t, protected); !strings.Contains(got, "code de secours") { + t.Fatalf("la route protégée ne dit pas par où l'on entre : %s", got) + } +} + +// TestTheMissingPasswordIsTheONLY409TheScreenMayTreatAsAnAuthentication. +// +// L'écran ouvre son panneau « code de secours + nouveau mot de passe » sur un 409, et 409 +// est AUSSI ce que répondent un compte à rebours déjà armé, une confirmation que personne +// n'attend et une mise à jour sur un poste occupé. Sans un code qui les distingue, +// « Aucune confirmation n'est attendue » envoyait un bénévole chercher la fiche +// d'installation d'un poste dont le mot de passe est posé depuis des mois. +func TestTheMissingPasswordIsTheONLY409TheScreenMayTreatAsAnAuthentication(t *testing.T) { + blank := newBench(t, func(o *benchOptions) { + o.config = func(cfg *domain.Config) { cfg.Admin.PasswordHash = "" } + }) + // Les deux portes qui constatent l'absence de mot de passe le NOMMENT, chacune de son + // côté : la route protégée (le garde) et l'ouverture de session. + protected := decodeStatus[problem](t, + blank.do(http.MethodPut, "/admin/api/config", `{}`, nil), http.StatusConflict) + if protected.Code != codeNoPassword { + t.Fatalf("acte protégé sans mot de passe : code %q, attendu %q", + protected.Code, codeNoPassword) + } + opening := decodeStatus[problem](t, + blank.post("/admin/api/session", `{"password":"quoi"}`), http.StatusConflict) + if opening.Code != codeNoPassword { + t.Fatalf("ouverture de session sans mot de passe : code %q, attendu %q", + opening.Code, codeNoPassword) + } + + // Et le conflit MÉTIER qui partage le statut ne le porte pas. + b := newBench(t) + b.setPassword("openscale", "ABCD2345") + b.login("openscale") + conflict := decodeStatus[problem](t, + b.post("/admin/api/config/confirm", `{}`), http.StatusConflict) + if conflict.Code == codeNoPassword { + t.Fatalf("« %s » se fait passer pour un poste sans mot de passe", conflict.Message) + } +} + +// TestARescueDoesNotOverwriteAFileItCouldNotOpen closes a blind spot OLDER than the +// block-by-block decode, found while fixing the one next to it. +// +// A file that EXISTS and will not open — a permission, an I/O error, a mount that went +// away — is not a file that is gone. The read failed, `stored` stayed at the configuration +// in force, and on a station that started out of service that is the neutral profile: the +// rescue wrote the fourteen factory blocks onto a file it had never managed to read. Same +// destruction as the typed case, by a road the type does not cover. +func TestARescueDoesNotOverwriteAFileItCouldNotOpen(t *testing.T) { + shop := loadConfig(t) + saved := &savedConfig{} + if err := saved.Save(context.Background(), shop); err != nil { + t.Fatalf("préparation du fichier : %v", err) + } + // The file is there — it was just written — and now it will not open. + saved.readErr = errors.New("accès refusé") + + b := newBench(t, func(o *benchOptions) { + o.configStore = saved + o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } + }) + b.setPassword("oublie", "ABCD2345") + + response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) + got := decodeStatus[sessionDTO](t, response, http.StatusOK) + + written := saved.saved() + if written.Station.Coop != shop.Station.Coop { + t.Errorf("station.coop = %q, attendu %q : le profil d'usine a été écrit sur un "+ + "fichier que le poste n'a pas su lire", written.Station.Coop, shop.Station.Coop) + } + if got, want := len(written.Pricing.Tiers), len(shop.Pricing.Tiers); got != want { + t.Errorf("%d tarif(s) au lieu de %d : la grille du magasin a été remplacée", got, want) + } + // The door opens anyway — refusing would leave this station with no way in at all — + // and it says what is not saved. + if got.Warning == "" { + t.Error("la session s'ouvre sans dire que le mot de passe n'est pas enregistré") + } + if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { + t.Error("le nouveau mot de passe n'est pas en service") + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..4d9ec6a --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,112 @@ +// This file holds THE TABLE OF §14.5, in the order the document writes it, and the +// line ADR-033 drew through it. +// +// That line is on the ACT and not on the door: « ce qui change ce que le poste vend, +// ou la façon dont il pèse » is protected, everything one can merely LOOK AT is not. +// The two sections below say, route by route, which side each one is on and why -- +// and they are the only place that says it. + +package web + +import "net/http" + +// routes is the table of §14.5, in the order the document writes it. +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + // --- The client screen ------------------------------------------------- + mux.HandleFunc("GET /", s.index) + mux.HandleFunc("GET /admin", s.adminIndex) + mux.HandleFunc("GET /admin/", s.adminIndex) + mux.HandleFunc("GET /assets/", s.staticAsset) + mux.HandleFunc("GET /images/{name}", s.image) + + mux.HandleFunc("GET /api/v1/stream", s.stream) + mux.HandleFunc("GET /api/v1/screens", s.screens) + mux.HandleFunc("GET /api/v1/catalog", s.catalogPage) + mux.HandleFunc("POST /api/v1/weigh", s.weigh) + mux.HandleFunc("POST /api/v1/reprint", s.reprint) + mux.HandleFunc("POST /api/v1/cancel", s.cancel) + mux.HandleFunc("POST /api/v1/dismiss", s.dismiss) + mux.HandleFunc("POST /api/v1/ui/error", s.uiError) + mux.HandleFunc("POST /api/v1/ui/layout-notice", s.layoutNotice) + mux.HandleFunc("GET /api/v1/", notFound) + + mux.HandleFunc("GET /healthz", s.healthz) + mux.HandleFunc("GET /readyz", s.readyz) + + // --- Open: everything one can LOOK AT, and the gestures that repair ----- + // + // ADR-033 moved the criterion from the DOOR to the ACT: « ce qui change ce que le + // poste vend, ou la façon dont il pèse » is protected, and the rest is not. Reading + // a configuration is not one of those — `configPayload` redacts both hashes before + // it leaves, so there is nothing here a password would be keeping. + // + // Making a volunteer type a password to LOOK at a port number, while whoever stands + // behind the counter can already unplug the printer, bought nothing and cost the + // whole of the troubleshooting. + mux.HandleFunc("POST /admin/api/troubleshooting/reprint", s.troubleshootingReprint) + mux.HandleFunc("POST /admin/api/troubleshooting/reload-catalog", s.reloadCatalog) + mux.HandleFunc("POST /admin/api/troubleshooting/roll-changed", s.rollChanged) + mux.HandleFunc("POST /admin/api/troubleshooting/fallback-printer", s.fallbackPrinter) + mux.HandleFunc("POST /admin/api/troubleshooting/test-scale", s.testScale) + mux.HandleFunc("POST /admin/api/troubleshooting/test-printer", s.testPrinter) + mux.HandleFunc("POST /admin/api/troubleshooting/test-label", s.testLabel) + mux.HandleFunc("GET /admin/api/diagnostic.zip", s.diagnostic) + mux.HandleFunc("GET /admin/api/health", s.adminHealth) + mux.HandleFunc("GET /admin/api/config", s.readConfig) + mux.HandleFunc("GET /admin/api/config/versions", s.configVersions) + mux.HandleFunc("GET /admin/api/ports", s.listPorts) + mux.HandleFunc("GET /admin/api/printers", s.listPrinters) + mux.HandleFunc("GET /admin/api/update", s.updateStatus) + mux.HandleFunc("GET /admin/api/label/preview.png", s.labelPreview) + // The journal is open, EXPORT INCLUDED: the page already shows the 200 weighings, + // and diagnostic.zip — open — carries them too. A lock on the third door is not one. + mux.HandleFunc("GET /admin/api/journal", s.journal) + mux.HandleFunc("GET /admin/api/journal/export.csv", s.journalCSV) + mux.HandleFunc("GET /admin/api/technical", s.technicalJournal) + mux.HandleFunc("GET /admin/api/imports", s.imports) + + mux.HandleFunc("POST /admin/api/session", s.openSession) + mux.HandleFunc("DELETE /admin/api/session", s.closeSession) + mux.HandleFunc("POST /admin/api/session/recovery", s.recoverSession) + + // --- Protected: what changes what the station sells, or how it weighs --- + // + // `manual-entry` and `catalog/import` are here and were not: the first cuts the + // scale out and lets the CUSTOMER type their own weight, the second replaces the + // whole grid with a file somebody brought. Both leave their trace at the till, and + // both were heavier than anything the password was guarding. + // + // `config/export` is here although it only reads: it is the one payload that still + // carries the password hash (§11.5). + guarded := map[string]http.HandlerFunc{ + "PUT /admin/api/config": s.writeConfig, + "POST /admin/api/config/confirm": s.confirmConfig, + "GET /admin/api/config/export": s.exportConfig, + "POST /admin/api/config/import": s.importConfig, + "POST /admin/api/config/restore": s.restoreConfig, + "POST /admin/api/config/reload": s.reloadConfigFromDisk, + "POST /admin/api/restart": s.restart, + "POST /admin/api/reboot": s.armReboot, + "DELETE /admin/api/reboot": s.cancelReboot, + "POST /admin/api/troubleshooting/manual-entry": s.manualEntry, + "POST /admin/api/catalog/import": s.importCatalog, + "POST /admin/api/printers/discover": s.discoverPrinters, + "POST /admin/api/scale/detect": s.detectScale, + "POST /admin/api/scale/capture": s.captureScale, + "POST /admin/api/printer/test": s.printerTest, + "POST /admin/api/catalog/reload": s.reloadCatalog, + "POST /admin/api/catalog/forget-quarantine": s.forgetQuarantine, + "POST /admin/api/products/{id}/decision": s.productDecision, + "POST /admin/api/replay": s.replay, + "POST /admin/api/update/check": s.updateCheck, + "POST /admin/api/update/apply": s.updateApply, + } + for pattern, handler := range guarded { + mux.HandleFunc(pattern, s.authenticated(handler)) + } + mux.HandleFunc("GET /admin/api/", notFound) + + return s.guard(mux) +} diff --git a/internal/web/server.go b/internal/web/server.go index 26381b8..0d55328 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,20 +1,22 @@ +// This file holds the SERVER ITSELF: what it is built from, what it holds, and the +// three ways the outside world reaches it. +// +// HTTPServer is where the three shutdown traps of §13.4 are closed -- BaseContext +// above all, without which cancelling the root context would not cancel a single +// request. + package web import ( "context" - "encoding/json" "errors" - "io" "io/fs" "net" "net/http" - "sync/atomic" - "time" - "openscale/internal/domain" - "openscale/internal/station" "openscale/internal/station/ports" - "openscale/internal/update" + "sync/atomic" + "time" ) // maxSubscribers is how many SSE streams one station serves at once (§13.1). @@ -38,305 +40,6 @@ const probeBudget = 500 * time.Millisecond // button is standing in front of the screen. const deviceBudget = 10 * time.Second -// Hub is what the HTTP layer needs from the single decision-making goroutine. -// -// Declared HERE, on the consumer's side: *station.Hub satisfies it as it stands, -// and a test drives the routes with a double that never starts a goroutine. -type Hub interface { - // State returns the last published snapshot, without blocking. - State() station.Snapshot - // Submit hands one command to the loop and waits for its answer, on ctx. - Submit(ctx context.Context, ev domain.Event, key string) (domain.Ack, error) - // Subscribe returns the snapshot channel of one subscriber and its unsubscribe. - Subscribe() (<-chan station.Snapshot, func()) - // Config returns the configuration in force. - Config() domain.Config - // Catalog returns the catalog in service, or nil before the first one. - Catalog() *domain.Catalog - // CatalogUpdatedAt returns when that catalog entered service, or the zero time. - CatalogUpdatedAt() time.Time - // DowntimeGuard reports whether the station may be taken down, and says in French - // why not when it may not. - // - // The rule belongs to the station and is asked, never deduced: an HTTP layer that - // read a state to conclude « somebody is weighing » would hold a second copy of a - // rule that already has an owner. - DowntimeGuard() (bool, string) -} - -// Controller is what the HTTP layer needs from the station AROUND the loop: the -// liveness of §14.5 and the hot reload of §11.4. -type Controller interface { - // Alive reports that the Hub loop is publishing. - Alive() bool - // Reload publishes a new configuration and restarts what actually changed. - // - // The request carries the FILE as it was before the change, and not only the new - // configuration, because a rollback has two documents to put back: the station goes - // back to what it was running, the file to what it carried. On the one station §11.3 - // serves — a faulty file, the neutral profile in memory — those are not the same - // document, and handing over only one wrote the factory profile onto the shop's file. - Reload(req station.ReloadRequest) (station.ReloadOutcome, error) - // Confirm accepts the configuration in force and stops the 60 s countdown. - Confirm() error - // PendingConfirmation reports the end of the countdown still running, or the zero time. - PendingConfirmation() time.Time -} - -// Store is the persistence as the administration screens read it. -// -// It is declared HERE and not imported: internal/web knows no database package -// (§5.2). cmd/openscale adapts *store.DB to it, which is a handful of lines and the -// price of the cut. -type Store interface { - // Weighings returns one page of the journal, most recent first. - Weighings(ctx context.Context, q JournalQuery) ([]domain.Weighing, error) - // CountWeighings reports how many rows the journal holds. - CountWeighings(ctx context.Context) (int, error) - // TechnicalEntries returns one page of the technical journal. - TechnicalEntries(ctx context.Context, q TechnicalQuery) ([]TechnicalLine, error) - // Imports returns the history of catalog imports, most recent first. - Imports(ctx context.Context, limit, offset int) ([]domain.Import, error) - // LastAppliedImport returns the most recent import that PUT A CATALOG IN SERVICE. - // - // It is not the same question as the first row of Imports: 'unchanged', 'rejected' - // and 'failed' are rows too, and none of them changed what the station serves. The - // error is « this station has never applied one » and carries no sentinel: every - // caller here treats it as an absence. - LastAppliedImport(ctx context.Context) (domain.Import, error) - // Findings returns what one import had to say about the rows it read. - Findings(ctx context.Context, importID int64) ([]domain.Finding, error) - // LocalDecisions returns the human judgements in force (§10.6). - LocalDecisions(ctx context.Context) ([]domain.LocalDecision, error) - // SaveDecision records one human judgement about one product. - SaveDecision(ctx context.Context, d domain.LocalDecision) error - // ClearDecision removes the judgement about one product. - ClearDecision(ctx context.Context, productID string) error - // Image returns the metadata of one photo, addressed by its content. - Image(ctx context.Context, sha string) (domain.Image, error) -} - -// ConfigStore is the configuration FILE, with its five rotating versions (§11.4). -type ConfigStore interface { - // Read returns the configuration AS IT STANDS ON DISK, which is not always the one - // in force. - // - // The difference is the whole reason this method is on the interface. A station that - // started out of service runs the NEUTRAL PROFILE (§11.3) while the file keeps the - // shop's settings and the faults that put it there; a station that fell back to - // manual entry runs something else again (§11.4). What the expert pages edit, and - // what a rescue writes back, has to be « ce que l'exploitant a demandé » — otherwise - // the first save replaces the tariffs, the safeguards and the categories of a - // cooperative with the factory ones. - Read(ctx context.Context) (domain.Config, error) - // Save rotates the versions and writes atomically: tmp, fsync, rename. - Save(ctx context.Context, cfg domain.Config) error - // Versions lists the restorable versions, most recent first. - Versions(ctx context.Context) ([]ConfigVersion, error) - // Restore reads back one version WITHOUT applying it. - Restore(ctx context.Context, version int) (domain.Config, error) -} - -// CatalogAdmin is the catalog source as the administration screen acts on it. -type CatalogAdmin interface { - // Reload asks the source for a fresh batch now, and reports IN FRENCH what it saw of - // the file it watches. - // - // That sentence is the ONE fact the watch never produces: it polls, finds nothing and - // returns in silence, so « Recharger le catalogue » used to be followed by nothing at - // all. It is EMPTY when the source watches no file of this machine — a share is - // watched over the network — because an absence nobody checked must not be asserted. - Reload(ctx context.Context) (string, error) - // Import takes a CSV dropped on the screen and writes it where the ordinary - // watcher will find it — same parser, same qualification (A4, ADR-011). - Import(ctx context.Context, name string, r io.Reader) (domain.Import, error) - // ForgetQuarantine clears the memory of the files that were refused. - ForgetQuarantine(ctx context.Context) error -} - -// Hardware answers the « what is actually plugged in? » questions of the expert -// screens (§14.4). Every method is platform-specific, which is why none of them -// lives here. -type Hardware interface { - // Ports enumerates the serial ports, with their USB description. - Ports(ctx context.Context) ([]PortInfo, error) - // Printers enumerates the print queues the platform knows about. - Printers(ctx context.Context) ([]PrinterInfo, error) - // DiscoverPrinters looks for a label printer beyond the declared queues. - DiscoverPrinters(ctx context.Context) ([]PrinterInfo, error) - // DetectScale opens one port, applies the parsers and says what answered. - DetectScale(ctx context.Context, port string) (ScaleDetection, error) - // CaptureFrames records raw frames from one port for a bounded duration. - CaptureFrames(ctx context.Context, port string, d time.Duration) ([]string, error) - // LabelPreview renders the label as a PNG, identical to what would print (A2). - LabelPreview(ctx context.Context, q PreviewQuery) ([]byte, error) - // Replay pushes one recorded frame back through the decoder (§14.4, Journal). - Replay(ctx context.Context, frame string) error -} - -// Diagnostician writes diagnostic.zip (§15.4). -// -// It is its OWN interface and not a method of Hardware, for two reasons that both matter. -// The archive is not a platform question — internal/diag builds it out of the configuration, -// the journal and the fifteen controls — and its route is the one route of this group that -// carries NO password: §15.4 gives it « un seul bouton, sans mot de passe » because it is the -// only realistic remote support mechanism for a team of volunteers. Grouping it with the -// expert hardware calls would make one nil collaborator disable both. -type Diagnostician interface { - // Diagnostic writes the archive into w. It never returns before the archive is complete - // or the reason it is not is recorded inside it. - Diagnostic(ctx context.Context, w io.Writer) error -} - -// Dashboard answers the four questions of §14.4 that no HTTP layer can put to itself: -// how far the roll has gone, how much room is left on the disk, whether this machine -// comes back on its own after a power cut, and what the catalog source is watching. -// -// It is one collaborator and not four because it has one caller — the dashboard route — -// and because the composition root holds all four in the same hand: the print service, -// the data directory, the platform and the source in service. Nil leaves the four facts -// out of the payload, and the screen SAYS what it cannot see. -type Dashboard interface { - // Dashboard reports what it could establish. Every field is optional and its absence - // is the honest answer: nothing here is worth a 500 on the one page a volunteer opens - // when the station is already broken. - Dashboard(ctx context.Context) DashboardFacts -} - -// DashboardFacts is what only the composition root knows. -type DashboardFacts struct { - Roll *RollGauge - Disk *DiskSpace - Restart *RestartReadiness - Source *CatalogSourceState - Routing *PrintRouting -} - -// PrintRouting is which printer the labels are coming out of. -// -// Available is what decides whether the troubleshooting page offers « Imprimer sur -// l'imprimante du poste N » (§14.4) — a button offered on a station with no fallback -// configured would be a button that answers 501 to somebody already in trouble. -type PrintRouting struct { - Available bool - OnFallback bool - // Name is the FRENCH name of the printer in use, and Banner the permanent line of - // §8.4 — both come from the print service, which is where the wording belongs. - Name string - Banner string -} - -// RollGauge is the label counter of §8.5 as the « rouleau » light reads it. -// -// The wording travels WITH the numbers, because it is printing.RollCounter that knows -// when « environ 100 étiquettes restantes » becomes « le rouleau est probablement fini », -// and a screen that recomputed the sentence from the numbers would be a second opinion on -// a threshold that already has an owner. -type RollGauge struct { - Printed int64 - Capacity int - // Remaining CAN be negative: a roll that held more than the configured capacity, or - // one changed without anybody saying so, which is the ordinary case (§8.5). - Remaining int64 - Level string - Message string - Known bool -} - -// DiskSpace is the room left on the volume the station writes to. -type DiskSpace struct { - Path string - FreeBytes int64 - TotalBytes int64 -} - -// RestartReadiness is bloquant-7: after a power cut, does this station come back to the -// client screen without anybody typing a Windows password? -type RestartReadiness struct { - Configured bool - // Known is false when the question could not be put to the system at all. - Known bool - Detail string - Remedy string -} - -// CatalogSourceState is the permanent catalog line of §14.4: the source, the path or the -// URL watched, and the account used. -type CatalogSourceState struct { - Type string - Label string -} - -// SelfTester prints one of the three built-in patterns (§8.6). ports.Printer -// satisfies it, and that is the only reason it is one method wide. -type SelfTester interface { - // SelfTest prints "label", "alignment" or "ruler". - SelfTest(ctx context.Context, what string) error -} - -// Troubleshooting is what the repair buttons of §14.4 act on and that nothing else in -// this package can reach. -// -// None of the three writes the configuration file: manual entry is a STATE the station -// enters, the roll counter is a counter, and the fallback printer is a route for the -// current session. That was the criterion of ADR-018, and it is no longer the one that -// decides the door — ADR-033 asks what an act CHANGES. Two of the three stay open, and -// ManualEntry is authenticated: it cuts the scale out and lets the customer type their own -// weight. The route table below is where that is settled, not this interface. -type Troubleshooting interface { - // ManualEntry switches the station into, or out of, manual weight entry. - ManualEntry(ctx context.Context, on bool) error - // RollChanged resets the label counter of the roll (§8.5). - RollChanged(ctx context.Context) error - // UseFallbackPrinter routes printing to the neighbouring station's printer. - UseFallbackPrinter(ctx context.Context, on bool) error -} - -// Updater is what the HTTP layer needs to move the station to a newer release. -// -// Declared here, on the consumer's side; *update.Service satisfies it. Nil answers -// 501 on the act and « not supported » on the read, which is what a Linux station -// honestly is: hiding the routes would leave a screen guessing, and a button doing -// nothing would be worse than none. -type Updater interface { - // Status answers the screen from what is on disk, without polling. - Status(repository string) (update.Status, error) - // Check polls the repository now and records what it found. - Check(ctx context.Context, repository string) (update.Check, error) - // Apply brings the wanted version down and hands the swap over. It returns - // as soon as the swap has STARTED: what finishes it also stops this process. - Apply(ctx context.Context, repository, wanted string) error -} - -// Restarter stops the station so that its supervisor starts it again. -// -// Declared here, on the consumer's side; *stationRestarter of cmd/openscale satisfies -// it. NIL MEANS « nobody would relaunch it », and the route then answers 501 instead of -// stopping a station that would stay down — which is what `openscale serve` typed into -// a terminal is. -// -// This is the route ADR-027 removed, and it is not that route. What the ADR refuses is -// a restart DEMANDED BY A SETTING: no configuration block may ask for one, and none -// does. This one is a repair, and it goes through the only restart that ADR calls -// legitimate — the one the SCM or systemd triggers on its own. -type Restarter interface { - // Restart asks the station to stop. It returns as soon as the demand is recorded, - // because what carries it out also ends this process, and a *station.DowntimeRefused - // when the station must not be taken down right now. - Restart() error -} - -// Rebooter restarts THE MACHINE. -// -// Declared here, on the consumer's side; platform.Reboot satisfies it once adapted. Nil -// answers 501: a station whose platform cannot restart must say so rather than offer a -// button that fails at the last click, and « ce poste ne sait pas faire » is a different -// piece of news from « ça n'a pas marché ». -type Rebooter interface { - // Reboot restarts the machine. It returns as soon as the demand is accepted. - Reboot() error -} - // Options is everything the HTTP layer is given. Clock and Hub are required; every // other collaborator is optional and its absence is answered honestly. type Options struct { @@ -486,175 +189,3 @@ func (s *Server) HTTPServer(root context.Context, closeSubscribers func()) *http } return srv } - -// routes is the table of §14.5, in the order the document writes it. -func (s *Server) routes() http.Handler { - mux := http.NewServeMux() - - // --- The client screen ------------------------------------------------- - mux.HandleFunc("GET /", s.index) - mux.HandleFunc("GET /admin", s.adminIndex) - mux.HandleFunc("GET /admin/", s.adminIndex) - mux.HandleFunc("GET /assets/", s.staticAsset) - mux.HandleFunc("GET /images/{name}", s.image) - - mux.HandleFunc("GET /api/v1/stream", s.stream) - mux.HandleFunc("GET /api/v1/screens", s.screens) - mux.HandleFunc("GET /api/v1/catalog", s.catalogPage) - mux.HandleFunc("POST /api/v1/weigh", s.weigh) - mux.HandleFunc("POST /api/v1/reprint", s.reprint) - mux.HandleFunc("POST /api/v1/cancel", s.cancel) - mux.HandleFunc("POST /api/v1/dismiss", s.dismiss) - mux.HandleFunc("POST /api/v1/ui/error", s.uiError) - mux.HandleFunc("POST /api/v1/ui/layout-notice", s.layoutNotice) - mux.HandleFunc("GET /api/v1/", notFound) - - mux.HandleFunc("GET /healthz", s.healthz) - mux.HandleFunc("GET /readyz", s.readyz) - - // --- Open: everything one can LOOK AT, and the gestures that repair ----- - // - // ADR-033 moved the criterion from the DOOR to the ACT: « ce qui change ce que le - // poste vend, ou la façon dont il pèse » is protected, and the rest is not. Reading - // a configuration is not one of those — `configPayload` redacts both hashes before - // it leaves, so there is nothing here a password would be keeping. - // - // Making a volunteer type a password to LOOK at a port number, while whoever stands - // behind the counter can already unplug the printer, bought nothing and cost the - // whole of the troubleshooting. - mux.HandleFunc("POST /admin/api/troubleshooting/reprint", s.troubleshootingReprint) - mux.HandleFunc("POST /admin/api/troubleshooting/reload-catalog", s.reloadCatalog) - mux.HandleFunc("POST /admin/api/troubleshooting/roll-changed", s.rollChanged) - mux.HandleFunc("POST /admin/api/troubleshooting/fallback-printer", s.fallbackPrinter) - mux.HandleFunc("POST /admin/api/troubleshooting/test-scale", s.testScale) - mux.HandleFunc("POST /admin/api/troubleshooting/test-printer", s.testPrinter) - mux.HandleFunc("POST /admin/api/troubleshooting/test-label", s.testLabel) - mux.HandleFunc("GET /admin/api/diagnostic.zip", s.diagnostic) - mux.HandleFunc("GET /admin/api/health", s.adminHealth) - mux.HandleFunc("GET /admin/api/config", s.readConfig) - mux.HandleFunc("GET /admin/api/config/versions", s.configVersions) - mux.HandleFunc("GET /admin/api/ports", s.listPorts) - mux.HandleFunc("GET /admin/api/printers", s.listPrinters) - mux.HandleFunc("GET /admin/api/update", s.updateStatus) - mux.HandleFunc("GET /admin/api/label/preview.png", s.labelPreview) - // The journal is open, EXPORT INCLUDED: the page already shows the 200 weighings, - // and diagnostic.zip — open — carries them too. A lock on the third door is not one. - mux.HandleFunc("GET /admin/api/journal", s.journal) - mux.HandleFunc("GET /admin/api/journal/export.csv", s.journalCSV) - mux.HandleFunc("GET /admin/api/technical", s.technicalJournal) - mux.HandleFunc("GET /admin/api/imports", s.imports) - - mux.HandleFunc("POST /admin/api/session", s.openSession) - mux.HandleFunc("DELETE /admin/api/session", s.closeSession) - mux.HandleFunc("POST /admin/api/session/recovery", s.recoverSession) - - // --- Protected: what changes what the station sells, or how it weighs --- - // - // `manual-entry` and `catalog/import` are here and were not: the first cuts the - // scale out and lets the CUSTOMER type their own weight, the second replaces the - // whole grid with a file somebody brought. Both leave their trace at the till, and - // both were heavier than anything the password was guarding. - // - // `config/export` is here although it only reads: it is the one payload that still - // carries the password hash (§11.5). - guarded := map[string]http.HandlerFunc{ - "PUT /admin/api/config": s.writeConfig, - "POST /admin/api/config/confirm": s.confirmConfig, - "GET /admin/api/config/export": s.exportConfig, - "POST /admin/api/config/import": s.importConfig, - "POST /admin/api/config/restore": s.restoreConfig, - "POST /admin/api/config/reload": s.reloadConfigFromDisk, - "POST /admin/api/restart": s.restart, - "POST /admin/api/reboot": s.armReboot, - "DELETE /admin/api/reboot": s.cancelReboot, - "POST /admin/api/troubleshooting/manual-entry": s.manualEntry, - "POST /admin/api/catalog/import": s.importCatalog, - "POST /admin/api/printers/discover": s.discoverPrinters, - "POST /admin/api/scale/detect": s.detectScale, - "POST /admin/api/scale/capture": s.captureScale, - "POST /admin/api/printer/test": s.printerTest, - "POST /admin/api/catalog/reload": s.reloadCatalog, - "POST /admin/api/catalog/forget-quarantine": s.forgetQuarantine, - "POST /admin/api/products/{id}/decision": s.productDecision, - "POST /admin/api/replay": s.replay, - "POST /admin/api/update/check": s.updateCheck, - "POST /admin/api/update/apply": s.updateApply, - } - for pattern, handler := range guarded { - mux.HandleFunc(pattern, s.authenticated(handler)) - } - mux.HandleFunc("GET /admin/api/", notFound) - - return s.guard(mux) -} - -// --- Answers --------------------------------------------------------------- - -// writeJSON renders one body, and never lets a half-written one look like a whole. -// -// The body is marshalled BEFORE the status line goes out: a marshalling failure -// after WriteHeader would leave the client with a 200 and a truncated document, -// which is the one failure mode a screen cannot detect. -func writeJSON(w http.ResponseWriter, status int, body any) { - raw, err := json.Marshal(body) - if err != nil { - http.Error(w, `{"message":"Réponse illisible."}`, http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - _, _ = w.Write(raw) -} - -// problem is what every refusal of this layer looks like. -// -// Message is FRENCH and complete: it is read by a volunteer on the administration -// screen. Code is an ERR-xxx-nn when one is allocated, and empty otherwise — an -// invented code is worse than none, because somebody would look it up. -type problem struct { - Code string `json:"code"` - Message string `json:"message"` - // Faults carries the configuration controls of §11.3, ALL of them at once. - Faults []faultDTO `json:"faults,omitempty"` -} - -// writeProblem renders one refusal. -func writeProblem(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, problem{Code: code, Message: message}) -} - -// notFound is the answer of an /api path nobody serves. It is JSON and not the -// front end: an API that answers a route with an HTML page teaches a front end to -// parse HTML. -func notFound(w http.ResponseWriter, _ *http.Request) { - writeProblem(w, http.StatusNotFound, "", "Cette adresse n'existe pas.") -} - -// unavailable answers a route whose collaborator this station was not given. -// -// 501 and not 404: the route EXISTS, it is in the contract of §14.5, and it is this -// binary's wiring that does not carry the capability yet. A 404 would send a -// volunteer looking for a typo. -func unavailable(w http.ResponseWriter, what string) { - writeProblem(w, http.StatusNotImplemented, "", - "Cette fonction n'est pas disponible sur ce poste : "+what+".") -} - -// decodeJSON reads one request body, and refuses what it cannot understand. -// -// The body is BOUNDED: a command from the screen is a few hundred bytes, and an -// unbounded read is an unbounded allocation on a station with 4 GB of RAM. -func decodeJSON(w http.ResponseWriter, r *http.Request, into any) bool { - decoder := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(into); err != nil { - writeProblem(w, http.StatusBadRequest, "", "Requête illisible : "+err.Error()) - return false - } - return true -} - -// maxBodyBytes bounds a JSON command body. A weigh command is under 200 bytes; a -// whole configuration, which travels on PUT /admin/api/config, is a few kilobytes. -const maxBodyBytes = 1 << 20 diff --git a/internal/web/session.go b/internal/web/session.go index 207679a..cc6db8c 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -1,25 +1,19 @@ +// This file holds WHO IS LOGGED IN: the tokens in force, their expiry, and the +// per-address rate limit that stands between a keypad and a password. +// +// The store is guarded by a mutex and nothing here escapes it. A session is a +// value read under the lock and copied out; a token is compared in constant time. +// Both are properties of this file and of no other. + package web import ( "crypto/rand" - "crypto/subtle" "encoding/base64" - "errors" "fmt" - "io/fs" - "math/big" - "net" - "net/http" - "strconv" - "strings" + "openscale/internal/station/ports" "sync" "time" - - "golang.org/x/crypto/argon2" - - "openscale/internal/domain" - "openscale/internal/station" - "openscale/internal/station/ports" ) // sessionCookie is the name of the cookie an authenticated administrator carries. @@ -31,21 +25,6 @@ const lockout = 5 * time.Minute // attemptWindow is the span the attempt count is measured over. const attemptWindow = time.Minute -// The argon2id parameters used when THIS binary hashes a password. -// -// They are the cost of one login on the target hardware (an i3 of 2015), and they -// are deliberately not configurable: an operator has no legitimate choice to make -// about a key derivation cost, and a station where it could be lowered would be a -// station where it WOULD be lowered. Verification reads the parameters from the -// stored string, so raising them later keeps every existing hash valid. -const ( - argonMemory = 64 * 1024 // KiB - argonTime = 3 - argonThreads = 2 - argonKeyLen = 32 - argonSaltLen = 16 -) - // codeNoPassword names the ONE 409 that is a question of authentication. // // The screen asks for the installation sheet's recovery code when a protected act comes @@ -56,9 +35,6 @@ const ( // stays what it is — those really are conflicts — and this code is what tells them apart. const codeNoPassword = "ERR-CFG-02" -// errBadHash reports a stored hash this binary cannot read. -var errBadHash = errors.New("web: empreinte argon2id illisible") - // session is one open administration session. type session struct { expiresAt time.Time @@ -213,423 +189,3 @@ func newToken() (string, error) { } return base64.RawURLEncoding.EncodeToString(raw), nil } - -// --- argon2id --------------------------------------------------------------- - -// HashSecret produces the PHC string a configuration file carries. -// -// The format is the one §11.2 shows and the one Config.Validate checks the shape of: -// $argon2id$v=19$m=…,t=…,p=…$salt$hash, both parts in unpadded base64. -// -// It is EXPORTED for one caller outside this package: `openscale config password`, the -// command line §14.4 keeps beside the screen for a station in Assigned Access whose -// wizard was never run. Two implementations of this format would be two ways of writing -// the same field, and the day they drifted the station would refuse a password nobody -// mistyped. -func HashSecret(secret string) (string, error) { - return hashWithCost(secret, argonMemory, argonTime, argonThreads) -} - -// hashWithCost is HashSecret with the cost spelled out. -// -// Production has exactly one caller and it passes the constants above. The parameters -// exist so that a test can produce a hash written by an OLDER binary — which is the -// case VerifySecret has to keep opening. -func hashWithCost(secret string, memory, iterations uint32, threads uint8) (string, error) { - salt := make([]byte, argonSaltLen) - if _, err := rand.Read(salt); err != nil { - return "", fmt.Errorf("web: tirage du sel impossible : %w", err) - } - key := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, argonKeyLen) - return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", - argon2.Version, memory, iterations, threads, - base64.RawStdEncoding.EncodeToString(salt), - base64.RawStdEncoding.EncodeToString(key)), nil -} - -// RecoveryCodeLength is the eight characters §14.4 prints on the installation sheet. -const RecoveryCodeLength = 8 - -// recoveryAlphabet is what those eight characters are drawn from. -// -// Neither I, L, O, U, 0 nor 1. This code is not typed by whoever generated it: it is read -// off a sheet of paper filed in the shop's folder, months later, by a volunteer who is -// already having a bad morning. The pair O/0 alone accounts for most of what a printed -// code loses on its way back to a keyboard, and U leaves with them so that eight random -// characters never spell a word somebody would then keep in their head instead of the -// folder. -const recoveryAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" - -// NewRecoveryCode draws the recovery code of §14.4, in clear, ONCE. -// -// The station never stores it: what goes into the configuration is its argon2id hash, and -// the only copy in existence is the one printed on the installation sheet. That is the -// whole point — it is a possession factor, and a possession factor a machine can read -// back is not one. -func NewRecoveryCode() (string, error) { - code := make([]byte, RecoveryCodeLength) - for i := range code { - // rand.Int and not a modulo of one byte: the alphabet has 30 characters, 256 is - // not a multiple of 30, and the bias that follows would make six of them a third - // more likely than the rest. - drawn, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet)))) - if err != nil { - return "", fmt.Errorf("web: tirage du code de secours impossible : %w", err) - } - code[i] = recoveryAlphabet[drawn.Int64()] - } - return string(code), nil -} - -// NormalizeRecoveryCode is what both ends apply before hashing or comparing. -// -// The alphabet is upper case, so a code copied in lower case out of the folder is the -// SAME code and must open the same door. Refusing it would be refusing a volunteer for a -// shift key. -func NormalizeRecoveryCode(code string) string { - return strings.ToUpper(strings.TrimSpace(code)) -} - -// VerifySecret reports whether secret is the one behind encoded. -// -// The cost parameters come from the STORED string and not from the constants above: -// raising the cost of new hashes must never invalidate the ones already written, and -// a station whose password was set by an older binary has to keep opening. -func VerifySecret(encoded, secret string) bool { - salt, want, memory, iterations, threads, err := parsePHC(encoded) - if err != nil { - return false - } - got := argon2.IDKey([]byte(secret), salt, iterations, memory, threads, uint32(len(want))) - return subtle.ConstantTimeCompare(got, want) == 1 -} - -// parsePHC takes a stored argon2id string apart. -func parsePHC(encoded string) (salt, key []byte, memory, iterations uint32, threads uint8, err error) { - parts := strings.Split(encoded, "$") - if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { - return nil, nil, 0, 0, 0, errBadHash - } - var version int - if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { - return nil, nil, 0, 0, 0, errBadHash - } - var parallelism int - if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil { - return nil, nil, 0, 0, 0, errBadHash - } - if parallelism < 1 || parallelism > 255 { - return nil, nil, 0, 0, 0, errBadHash - } - if salt, err = decodeBase64(parts[4]); err != nil { - return nil, nil, 0, 0, 0, errBadHash - } - if key, err = decodeBase64(parts[5]); err != nil { - return nil, nil, 0, 0, 0, errBadHash - } - return salt, key, memory, iterations, uint8(parallelism), nil -} - -// decodeBase64 accepts the padded and the unpadded spelling: a hash written by hand, -// or by another tool, must not be refused over a trailing equals sign. -func decodeBase64(s string) ([]byte, error) { - if raw, err := base64.RawStdEncoding.DecodeString(s); err == nil { - return raw, nil - } - return base64.StdEncoding.DecodeString(s) -} - -// --- The three session routes ---------------------------------------------- - -// sessionRequest is the body of POST /admin/api/session. -type sessionRequest struct { - Password string `json:"password"` -} - -// sessionDTO is what an opened session answers. -type sessionDTO struct { - ExpiresAt string `json:"expires_at"` - // Minutes is repeated so that a screen can show a countdown without parsing two - // instants and subtracting them. - Minutes int `json:"session_minutes"` - // Warning is set only by a recovery that could not write the new password to - // disk because the file still carries a retired key (ADR-034): the session opens - // anyway — refusing would lock the volunteer out of the one door left on a - // station the same retired key already put out of service — but the password - // will not survive a restart until the file is repaired, which this says, in - // French, naming the keys. - Warning string `json:"warning,omitempty"` -} - -// openSession is POST /admin/api/session. -func (s *Server) openSession(w http.ResponseWriter, r *http.Request) { - var body sessionRequest - if !decodeJSON(w, r, &body) { - return - } - cfg := s.hub.Config() - address := callerAddress(r) - - if remaining, waiting := s.sessions.locked(address); waiting { - w.Header().Set("Retry-After", strconv.Itoa(int(remaining.Seconds())+1)) - writeProblem(w, http.StatusTooManyRequests, "", - fmt.Sprintf("Trop d'essais. Réessayez dans %d minutes.", int(remaining.Minutes())+1)) - return - } - if cfg.Admin.PasswordHash == "" { - writeProblem(w, http.StatusConflict, codeNoPassword, - "Aucun mot de passe n'est défini sur ce poste : lancez l'assistant de premier démarrage.") - return - } - if !VerifySecret(cfg.Admin.PasswordHash, body.Password) { - s.sessions.failed(address, cfg.Admin.AttemptsPerMinute) - s.technical.Technical(domain.LevelWarn, "http", "", - "Mot de passe d'administration refusé.", address) - writeProblem(w, http.StatusUnauthorized, "", "Mot de passe incorrect.") - return - } - s.sessions.succeeded(address) - s.issueSession(w, cfg, "") -} - -// closeSession is DELETE /admin/api/session. -// -// §14.5 does not list it, and it is here anyway: without it the only way to leave the -// administration screen is to wait thirty minutes or to close the browser, and on a -// station in kiosk mode there is no browser to close. It reads nothing and writes no -// configuration, so it protects nothing that ADR-018 protects. -func (s *Server) closeSession(w http.ResponseWriter, r *http.Request) { - if cookie, err := r.Cookie(sessionCookie); err == nil { - s.sessions.close(cookie.Value) - } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, Value: "", Path: "/admin", MaxAge: -1, - HttpOnly: true, SameSite: http.SameSiteStrictMode, - }) - w.WriteHeader(http.StatusNoContent) -} - -// recoveryRequest is the body of POST /admin/api/session/recovery. -type recoveryRequest struct { - // Code is the eight characters printed on the installation sheet and filed in the - // shop's folder (§14.4, important-10). - Code string `json:"code"` - // Password is the new one. Resetting without setting would leave the station - // unprotected for as long as nobody came back to it. - Password string `json:"password"` -} - -// recoverSession is POST /admin/api/session/recovery: the forgotten password, reset -// FROM THE SCREEN. -// -// It exists because of Assigned Access: on a locked-down station there is no desktop -// and no command prompt, so « run openscale config password » is not an instruction -// anybody can follow. The code is the possession factor, the installation sheet is -// where it lives, and the shop's folder is the safe. -func (s *Server) recoverSession(w http.ResponseWriter, r *http.Request) { - var body recoveryRequest - if !decodeJSON(w, r, &body) { - return - } - cfg := s.hub.Config() - address := callerAddress(r) - - if remaining, waiting := s.sessions.locked(address); waiting { - w.Header().Set("Retry-After", strconv.Itoa(int(remaining.Seconds())+1)) - writeProblem(w, http.StatusTooManyRequests, "", - fmt.Sprintf("Trop d'essais. Réessayez dans %d minutes.", int(remaining.Minutes())+1)) - return - } - if cfg.Admin.RecoveryCodeHash == "" { - writeProblem(w, http.StatusConflict, "", - "Ce poste n'a pas de code de secours. Utilisez « openscale config password ».") - return - } - if !VerifySecret(cfg.Admin.RecoveryCodeHash, NormalizeRecoveryCode(body.Code)) { - // The SAME counter as the password: a code of eight characters is worth - // brute-forcing, and two independent budgets would be two doors. - s.sessions.failed(address, cfg.Admin.AttemptsPerMinute) - s.technical.Technical(domain.LevelWarn, "http", "", - "Code de secours refusé.", address) - writeProblem(w, http.StatusUnauthorized, "", "Code de secours incorrect.") - return - } - if len(body.Password) < 8 { - writeProblem(w, http.StatusUnprocessableEntity, "", - "Le nouveau mot de passe doit faire au moins 8 caractères.") - return - } - if s.configStore == nil || s.controller == nil { - unavailable(w, "la configuration n'est pas modifiable ici") - return - } - - hash, err := HashSecret(body.Password) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", err.Error()) - return - } - // ONE field changes, and it changes in TWO documents that are not always the same one. - // - // The file is the operator's, and it is what the station will read at its next start. - // The configuration in force is what the station is running right now — and on a - // station that started out of service those two differ completely: the file carries - // the shop's settings and its faults, memory carries the NEUTRAL PROFILE (§11.3). - // Writing the running configuration to disk there would replace tariffs, safeguards - // and categories with the factory ones, on the single gesture whose whole purpose is - // to rescue that station. - // - // A file only PART of which decoded is a third case, and it is the dangerous one. The - // blocks that DID decode are still the shop's own and are what must go back; the ones - // that did not are the neutral profile, and writing those is the destruction this whole - // paragraph exists to prevent -- on 02/08/2026 a flat refusal here sent the fourteen - // factory blocks onto the file, because `err != nil` fell through to the configuration - // in force. So the read blocks are taken, and the write is suspended, exactly as it is - // for a retired key below. - // A file that EXISTS and cannot be read suspends the write too, whatever the reason -- - // an I/O error, a permission, a mount that went away. None of those says the file is - // gone, and all of them used to leave `stored` at the configuration in force, which is - // the same fourteen factory blocks by another road. - // - // A file that does NOT exist is the one case where writing memory is right: there is - // nothing to destroy, and a station whose file was never written still has to be able - // to accept a password. That is why the test is on the ABSENCE and not on the error. - stored := cfg - persist := true - var unreadable *domain.UnreadableBlocksError - onDisk, readErr := s.configStore.Read(r.Context()) - switch { - case readErr == nil: - stored = onDisk - case errors.As(readErr, &unreadable): - stored = unreadable.Config - persist = false - case !errors.Is(readErr, fs.ErrNotExist): - persist = false - } - stored.Admin.PasswordHash = hash - - // The write can be refused for exactly one reason that must NOT lock the - // volunteer out: the file still carries a key control 20 refuses (ADR-034), which - // is precisely what put this station on the neutral profile and sent somebody - // looking for the recovery code in the first place. Persisting is refused -- - // ConfigStore.Save launders the key otherwise, and with it the discount it stood - // for -- but the door this request opens is the only one this volunteer has, and - // the screen that would explain the problem is behind it. So the session opens - // regardless, with the password in force IN MEMORY, and `warning` says plainly - // that it will not survive a restart until the file itself is repaired. Any other - // failure to write (a full disk, a read-only mount) is not this case and stays a - // hard failure, as it always has. - // - // A file that could not be READ earns the same treatment for the same reason, decided - // one step earlier: there, the file would be laundered by a write; here, it would be - // overwritten by values nobody declared. Both leave the volunteer a way in and both say - // what is not saved. The two are told apart because only one of them can be repaired by - // opening a named block. - var warning string - switch { - case unreadable != nil: - warning = fmt.Sprintf( - "Mot de passe actif, mais NON enregistré : %s du fichier de configuration %s, "+ - "et réécrire le fichier y poserait la configuration d'usine. Il ne survivra "+ - "pas à un redémarrage tant que le fichier n'est pas corrigé.", - unreadable.BlockPhrase(), unreadable.NotRead()) - s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", - "Mot de passe réinitialisé en mémoire seulement : un bloc du fichier de "+ - "configuration n'a pas pu être lu.", strings.Join(unreadable.Blocks(), ", ")) - case !persist: - warning = "Mot de passe actif, mais NON enregistré : le fichier de configuration " + - "n'a pas pu être lu, et l'écraser remplacerait les réglages du magasin par ceux " + - "d'usine. Il ne survivra pas à un redémarrage tant que le fichier n'est pas " + - "lisible." - s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", - "Mot de passe réinitialisé en mémoire seulement : le fichier de configuration "+ - "n'a pas pu être lu.", readErr.Error()) - default: - if err := s.configStore.Save(r.Context(), stored); err != nil { - var retired *domain.RetiredKeysError - if !errors.As(err, &retired) { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration non écrite : "+err.Error()) - return - } - warning = fmt.Sprintf( - "Mot de passe actif, mais NON enregistré : le fichier de configuration porte "+ - "encore %s. Il ne survivra pas à un redémarrage tant que le fichier n'est "+ - "pas corrigé.", strings.Join(retired.Keys, ", ")) - s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", - "Mot de passe réinitialisé en mémoire seulement : le fichier de configuration "+ - "porte encore une clé retirée.", strings.Join(retired.Keys, ", ")) - } - } - // And the station keeps running what it was running, with the new password in force: - // a recovery is not a moment to hand a station a configuration nobody has validated. - // No FileBefore: this changes the admin block alone, so no hardware block moves, no - // countdown is armed, and there is no rollback for a file to be the target of. - next := cfg - next.Admin.PasswordHash = hash - if _, err := s.controller.Reload(station.ReloadRequest{Next: next}); err != nil { - writeProblem(w, http.StatusInternalServerError, "", - "Configuration non rechargée : "+err.Error()) - return - } - // Every session minted under the old password goes, and the volunteer who just - // proved possession of the installation sheet gets a fresh one: they are standing - // in front of the station with a password to set. - s.sessions.revokeAll() - s.sessions.succeeded(address) - s.technical.Technical(domain.LevelWarn, "config", "", - "Mot de passe d'administration réinitialisé par le code de secours.", address) - s.issueSession(w, next, warning) -} - -// issueSession mints the cookie and answers. -// -// HttpOnly so that no script can read it, SameSite=Strict so that no other origin can -// make the browser send it, and Path=/admin so that it never travels on the client -// screen's own requests. No Secure flag: the station serves 127.0.0.1 over plain -// HTTP, and a cookie marked Secure would simply never be sent. -// -// warning is empty on the ordinary path (openSession) and carries the French sentence -// of an incomplete recovery on the other (recoverSession): the session opens the same -// way either time, only the sentence handed back differs. -func (s *Server) issueSession(w http.ResponseWriter, cfg domain.Config, warning string) { - minutes := sessionMinutes(cfg) - token, expiry, err := s.sessions.open(cfg.Admin.PasswordHash, minutes) - if err != nil { - writeProblem(w, http.StatusInternalServerError, "", err.Error()) - return - } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, Value: token, Path: "/admin", - HttpOnly: true, SameSite: http.SameSiteStrictMode, - // MaxAge and not Expires: the browser counts on ITS clock, and an absolute - // instant read from the injected one would be a date in a test's past. - MaxAge: minutes * 60, - }) - writeJSON(w, http.StatusOK, sessionDTO{ - ExpiresAt: stamp(expiry), Minutes: minutes, Warning: warning, - }) -} - -// sessionMinutes is how long a session lasts, with the shipped default standing in -// for a value nobody set. -func sessionMinutes(cfg domain.Config) int { - if cfg.Admin.SessionMinutes <= 0 { - return 30 - } - return cfg.Admin.SessionMinutes -} - -// callerAddress is the address the rate limit counts against. -// -// The socket address and NEVER a forwarded header: there is no proxy in front of this -// station, and trusting X-Forwarded-For would let anybody reset their own counter by -// writing a header. -func callerAddress(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} diff --git a/internal/web/session_test.go b/internal/web/session_test.go index b174b1b..a60cbdc 100644 --- a/internal/web/session_test.go +++ b/internal/web/session_test.go @@ -1,14 +1,12 @@ package web import ( - "bytes" "context" "encoding/json" "errors" "fmt" "io/fs" "net/http" - "net/http/httptest" "os" "path/filepath" "strings" @@ -21,6 +19,15 @@ import ( "openscale/internal/platform" ) +// The administration session: what the password opens, what the cookie carries, what five +// failed attempts lock, and what a password change revokes. Further down, the cryptography +// itself — argon2id read back from its own cost, and a recovery code that has to be copied +// off a printed sheet. +// +// The recovery code seen as a WAY BACK IN — what it rewrites of the shop's file, and what +// it must above all not replace in it — is in recovery_test.go. The cross-site guard has +// moved to guard_test.go, next to guard.go. + // TestThePasswordOpensASessionAndTheCookieCarriesIt. func TestThePasswordOpensASessionAndTheCookieCarriesIt(t *testing.T) { b := newBench(t) @@ -150,165 +157,6 @@ func TestFiveWrongPasswordsLockTheAddressForFiveMinutes(t *testing.T) { b.login("un-mot-de-passe") } -// TestTheRecoveryCodeResetsThePasswordFromTheScreen (important-10). -// -// On a station in Assigned Access there is neither desktop nor prompt: « run openscale -// config password » is not an instruction anybody can follow. The code on the -// installation sheet is the possession factor. -func TestTheRecoveryCodeResetsThePasswordFromTheScreen(t *testing.T) { - saved := &savedConfig{} - b := newBench(t, func(o *benchOptions) { o.configStore = saved }) - b.setPassword("oublie", "ABCD2345") - b.login("oublie") - - wrong := b.post("/admin/api/session/recovery", `{"code":"ZZZZ9999","password":"nouveau-mot"}`) - wrong.Body.Close() - if wrong.StatusCode != http.StatusUnauthorized { - t.Fatalf("code faux = %d, attendu 401", wrong.StatusCode) - } - - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) - if response.StatusCode != http.StatusOK { - t.Fatalf("code de secours = %d : %s", response.StatusCode, body(t, response)) - } - response.Body.Close() - - if hash := saved.saved().Admin.PasswordHash; hash == "" || !VerifySecret(hash, "nouveau-mot") { - t.Fatal("le nouveau mot de passe n'a pas été écrit dans la configuration") - } - // The volunteer who just proved possession of the sheet is logged in, and every - // session minted under the old password is gone. - if got := b.get("/admin/api/config"); got.StatusCode != http.StatusOK { - t.Fatalf("la session délivrée par le code de secours ne vaut rien : %d", got.StatusCode) - } - if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { - t.Fatal("la configuration en service porte encore l'ancien mot de passe") - } -} - -// TestARescueDoesNotReplaceTheShopsConfigurationWithTheFactoryOne. -// -// The one station that needs the recovery code most is the one that started OUT OF -// SERVICE: no password, no screen, nothing but the eight characters on the installation -// sheet. And that station runs the NEUTRAL PROFILE in memory while its file keeps the -// cooperative's tariffs, safeguards and categories (§11.3). Writing the running -// configuration back would have wiped all of it on the single gesture meant to rescue it. -// -// It proves the ROUTE and nothing else: configStore is an in-memory double that never -// refuses a read, so this test is blind to everything ConfigStore.Read decides. Saying it -// held « one half » of the property was wrong, and expensively so: it stayed green through -// the whole time the route was writing the fourteen factory blocks onto the shop's file. -// What holds the property end to end is -// TestARescueThroughTheRealStoreKeepsTheShopsBlocks, below, on a real file and a real store. -func TestARescueDoesNotReplaceTheShopsConfigurationWithTheFactoryOne(t *testing.T) { - shop := loadConfig(t) - saved := &savedConfig{} - if err := saved.Save(context.Background(), shop); err != nil { - t.Fatalf("préparation du fichier : %v", err) - } - - b := newBench(t, func(o *benchOptions) { - o.configStore = saved - // What a station in factory configuration RUNS (§11.3), which is not what its - // file says. - o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } - }) - b.setPassword("oublie", "ABCD2345") - - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) - if response.StatusCode != http.StatusOK { - t.Fatalf("code de secours = %d : %s", response.StatusCode, body(t, response)) - } - response.Body.Close() - - written := saved.saved() - if !VerifySecret(written.Admin.PasswordHash, "nouveau-mot") { - t.Fatal("le nouveau mot de passe n'est pas dans le fichier") - } - if got, want := len(written.Pricing.Tiers), len(shop.Pricing.Tiers); got != want { - t.Fatalf("le fichier porte %d tarifs au lieu de %d : la configuration du magasin "+ - "a été remplacée par celle d'usine", got, want) - } - if written.Limits.BasketMin != shop.Limits.BasketMin || written.Station.Coop != shop.Station.Coop { - t.Fatal("le fichier a perdu les réglages du magasin") - } - // And the station keeps running what it was running: a rescue is not the moment to - // hand a station a configuration nobody validated. - if b.hub.Config().Station.Coop == shop.Station.Coop { - t.Fatal("le poste s'est mis à faire tourner le fichier au lieu de son profil neutre") - } -} - -// TestARescueThroughTheRealStoreKeepsTheShopsBlocks is the half the test above cannot -// reach, and the one that was open. -// -// Everything here is REAL: a file on disk, a platform.ConfigStore over it, and the -// recovery route. The double the test above uses never refuses a read, so it went on -// passing through the whole time this was broken — a station out of service, whose file -// has ONE unreadable block, had the fourteen FACTORY blocks written over it by the single -// gesture meant to rescue it: identity, tariffs, catalog source and its credentials, -// safeguards. HTTP 200, no warning. -func TestARescueThroughTheRealStoreKeepsTheShopsBlocks(t *testing.T) { - shop := loadConfig(t) - path := filepath.Join(t.TempDir(), "config.json") - writeRawConfig(t, path, shop) - damagePricingBlock(t, path) - - file, err := platform.NewConfigStore(path) - if err != nil { - t.Fatalf("NewConfigStore : %v", err) - } - b := newBench(t, func(o *benchOptions) { - o.configStore = realConfigStore{file} - // What a station in factory configuration RUNS (§11.3), which is not its file. - o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } - }) - b.setPassword("oublie", "ABCD2345") - - before := readRaw(t, path) - - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) - got := decodeStatus[sessionDTO](t, response, http.StatusOK) - - // The FILE, decoded the way a station decodes it: what matters is what boots tomorrow. - written, _ := domain.DecodeConfigBlockByBlock(readRaw(t, path)) - if coop := written.Station.Coop; coop != shop.Station.Coop { - t.Errorf("station.coop = %q, attendu %q : le profil d'usine a été écrit sur le "+ - "fichier du magasin", coop, shop.Station.Coop) - } - // pricing is the block that was damaged, so it cannot be asserted through a decode — - // it is asserted on the BYTES, which is where the members' discount has to survive. - if !bytes.Contains(readRaw(t, path), []byte(`"discount_percent":10`)) { - t.Error("la remise des adhérents a disparu du fichier") - } - if source := written.Catalog.Type; source != shop.Catalog.Type { - t.Errorf("catalog.type = %q, attendu %q : la source du catalogue a été remplacée", - source, shop.Catalog.Type) - } - if basket, want := written.Limits.BasketMin, shop.Limits.BasketMin; basket != want { - t.Errorf("limits.basket_min = %v, attendu %v : les garde-fous ont été remplacés", - basket, want) - } - // Nothing at all was written, which is the strongest form of the four assertions above - // and the one that also covers the blocks this test does not name. - if !bytes.Equal(before, readRaw(t, path)) { - t.Error("le fichier a été réécrit alors qu'un de ses blocs n'a pas pu être lu") - } - - // The door still opens — a rescue that refused would leave this station with no way in - // at all — and it says plainly what is not saved, naming the block to repair. - if got.Warning == "" { - t.Fatal("la session s'ouvre sans dire que le mot de passe n'est pas enregistré") - } - if !strings.Contains(got.Warning, "pricing") { - t.Errorf("l'avertissement ne nomme pas le bloc à corriger : %q", got.Warning) - } - // And the password is in force IN MEMORY, which is what makes the session usable. - if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { - t.Error("le nouveau mot de passe n'est pas en service") - } -} - // benchOverADamagedFile stands a bench on a REAL file whose pricing block does not decode, // with the station running the neutral profile — which is what §11.3 puts it on. // @@ -321,7 +169,6 @@ func TestARescueThroughTheRealStoreKeepsTheShopsBlocks(t *testing.T) { // compare the file against what the cooperative actually declared. func benchOverADamagedFile(t *testing.T, shopEdit func(*domain.Config), tweaks ...func(*benchOptions)) (*bench, string, domain.Config) { - t.Helper() shop := loadConfig(t) if shopEdit != nil { @@ -431,168 +278,6 @@ func legacyLaCagetteRawWithARefusedKey(t *testing.T) []byte { return []byte(edited) } -// TestARecoveryOnALegacyFileDoesNotLaunderTheDiscount (the defect this fix closes). -// -// This is the station the guard exists for: an upgraded site whose file control 20 -// refuses runs the neutral profile, the volunteer cannot log in, and reaches for the -// recovery code on the installation sheet. Before this fix, that single gesture read -// the on-disk file, set the new password hash, and wrote the WHOLE struct back — -// which drops a retired key, because encoding/json only ever kept what a field claims. -// Whatever weight_decimals stood for on a numbering plan this binary no longer trusts -// would be gone from the file, silently, and control 20 would find nothing on the -// station's next start. The real ConfigStore is used here, and not the in-memory double: -// the guard lives in Save, and a double that never calls it would prove nothing about the -// file on disk. -func TestARecoveryOnALegacyFileDoesNotLaunderTheDiscount(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.json") - before := legacyLaCagetteRawWithARefusedKey(t) - if err := os.WriteFile(path, before, 0o644); err != nil { - t.Fatalf("préparation du fichier : %v", err) - } - store, err := platform.NewConfigStore(path) - if err != nil { - t.Fatalf("NewConfigStore : %v", err) - } - - b := newBench(t, func(o *benchOptions) { o.configStore = realConfigStore{store} }) - b.setPassword("oublie", "ABCD2345") - - response := b.post("/admin/api/session/recovery", - `{"code":"ABCD2345","password":"nouveau-mot"}`) - if response.StatusCode != http.StatusOK { - t.Fatalf("code de secours sur fichier legacy = %d : %s", - response.StatusCode, body(t, response)) - } - response.Body.Close() - - after, err := os.ReadFile(path) - if err != nil { - t.Fatalf("relecture : %v", err) - } - if string(after) != string(before) { - t.Fatalf("le fichier a été réécrit : la clé retirée a disparu.\navant :\n%s\naprès :\n%s", - before, after) - } - if !strings.Contains(string(after), "weight_decimals") { - t.Fatal("weight_decimals n'est plus dans le fichier : la clé retirée a été blanchie") - } -} - -// TestARecoveryStillOpensASessionWhenTheFileCannotBeSaved is the corner this fix must -// not get wrong: refusing to persist the new password must not lock the volunteer out -// of their own station. The volunteer reaching for the recovery code is very often -// standing in front of the ONE station whose file control 20 refuses — that is what -// put it out of service in the first place, and the screen that explains it is behind -// the very door this request opens. Failing loudly here would trade a silent -// overcharge for a station nobody can administer. -func TestARecoveryStillOpensASessionWhenTheFileCannotBeSaved(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.json") - if err := os.WriteFile(path, legacyLaCagetteRawWithARefusedKey(t), 0o644); err != nil { - t.Fatalf("préparation du fichier : %v", err) - } - store, err := platform.NewConfigStore(path) - if err != nil { - t.Fatalf("NewConfigStore : %v", err) - } - - b := newBench(t, func(o *benchOptions) { o.configStore = realConfigStore{store} }) - b.setPassword("oublie", "ABCD2345") - - response := b.post("/admin/api/session/recovery", - `{"code":"ABCD2345","password":"nouveau-mot"}`) - got := decodeStatus[sessionDTO](t, response, http.StatusOK) - if got.Warning == "" || !strings.Contains(got.Warning, "weight_decimals") { - t.Fatalf("l'avertissement ne nomme pas weight_decimals : %q", got.Warning) - } - - // The volunteer really is in: the new password is in force, and a session was - // issued and is usable. - if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { - t.Fatal("le nouveau mot de passe n'est pas en service") - } - if got := b.get("/admin/api/config"); got.StatusCode != http.StatusOK { - t.Fatalf("la session délivrée par le code de secours ne vaut rien : %d", got.StatusCode) - } - if !b.technical.has("ERR-CFG-01") { - t.Fatal("l'incapacité à écrire le mot de passe n'est pas journalisée") - } -} - -// TestARecoveryTooShortIsRefused: resetting without setting would leave the station -// unprotected for as long as nobody came back to it. -func TestARecoveryTooShortIsRefused(t *testing.T) { - b := newBench(t, func(o *benchOptions) { o.configStore = &savedConfig{} }) - b.setPassword("oublie", "ABCD2345") - - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"court"}`) - defer response.Body.Close() - if response.StatusCode != http.StatusUnprocessableEntity { - t.Fatalf("mot de passe trop court = %d, attendu 422", response.StatusCode) - } -} - -// TestAStationWithNoPasswordSaysSoInsteadOfRefusingEverything: a station that has never -// been through the first-start wizard has no password to check, and refusing silently -// would make the wizard itself unreachable. -func TestAStationWithNoPasswordSaysSoInsteadOfRefusingEverything(t *testing.T) { - b := newBench(t, func(o *benchOptions) { - o.config = func(cfg *domain.Config) { cfg.Admin.PasswordHash = "" } - }) - response := b.post("/admin/api/session", `{"password":"quoi"}`) - defer response.Body.Close() - if response.StatusCode != http.StatusConflict { - t.Fatalf("statut = %d, attendu 409", response.StatusCode) - } - // La route PROTÉGÉE dit par où l'on entre. L'assistant en cinq étapes de §14.4 - // n'existe pas dans ce code, et y renvoyer envoyait chercher un écran que personne - // n'a écrit ; le chemin qui existe est le code de secours de la fiche. - protected := b.do(http.MethodPut, "/admin/api/config", `{}`, nil) - defer protected.Body.Close() - if protected.StatusCode != http.StatusConflict { - t.Fatalf("acte protégé sans mot de passe = %d, attendu 409", protected.StatusCode) - } - if got := body(t, protected); !strings.Contains(got, "code de secours") { - t.Fatalf("la route protégée ne dit pas par où l'on entre : %s", got) - } -} - -// TestTheMissingPasswordIsTheONLY409TheScreenMayTreatAsAnAuthentication. -// -// L'écran ouvre son panneau « code de secours + nouveau mot de passe » sur un 409, et 409 -// est AUSSI ce que répondent un compte à rebours déjà armé, une confirmation que personne -// n'attend et une mise à jour sur un poste occupé. Sans un code qui les distingue, -// « Aucune confirmation n'est attendue » envoyait un bénévole chercher la fiche -// d'installation d'un poste dont le mot de passe est posé depuis des mois. -func TestTheMissingPasswordIsTheONLY409TheScreenMayTreatAsAnAuthentication(t *testing.T) { - blank := newBench(t, func(o *benchOptions) { - o.config = func(cfg *domain.Config) { cfg.Admin.PasswordHash = "" } - }) - // Les deux portes qui constatent l'absence de mot de passe le NOMMENT, chacune de son - // côté : la route protégée (le garde) et l'ouverture de session. - protected := decodeStatus[problem](t, - blank.do(http.MethodPut, "/admin/api/config", `{}`, nil), http.StatusConflict) - if protected.Code != codeNoPassword { - t.Fatalf("acte protégé sans mot de passe : code %q, attendu %q", - protected.Code, codeNoPassword) - } - opening := decodeStatus[problem](t, - blank.post("/admin/api/session", `{"password":"quoi"}`), http.StatusConflict) - if opening.Code != codeNoPassword { - t.Fatalf("ouverture de session sans mot de passe : code %q, attendu %q", - opening.Code, codeNoPassword) - } - - // Et le conflit MÉTIER qui partage le statut ne le porte pas. - b := newBench(t) - b.setPassword("openscale", "ABCD2345") - b.login("openscale") - conflict := decodeStatus[problem](t, - b.post("/admin/api/config/confirm", `{}`), http.StatusConflict) - if conflict.Code == codeNoPassword { - t.Fatalf("« %s » se fait passer pour un poste sans mot de passe", conflict.Message) - } -} - // TestChangingThePasswordRevokesTheSessionsMintedUnderTheOldOne (§11.4). func TestChangingThePasswordRevokesTheSessionsMintedUnderTheOldOne(t *testing.T) { b := newBench(t) @@ -771,85 +456,6 @@ func TestTheStoreForgetsWhatExpired(t *testing.T) { } } -// --- The cross-site guard ---------------------------------------------------- - -// TestAMutatingRequestFromAnotherOriginIsRefused: cross-site request forgery, and DNS -// rebinding against 127.0.0.1, which is the attack a loopback service really faces — -// no firewall protects it. -func TestAMutatingRequestFromAnotherOriginIsRefused(t *testing.T) { - b := newBench(t) - - refused := b.do(http.MethodPost, "/api/v1/cancel", `{}`, - http.Header{"Origin": {"http://ailleurs.example"}}) - refused.Body.Close() - if refused.StatusCode != http.StatusForbidden { - t.Fatalf("origine étrangère = %d, attendu 403", refused.StatusCode) - } - - // The station's own origin passes. - accepted := b.do(http.MethodPost, "/api/v1/cancel", `{}`, - http.Header{"Origin": {b.http.URL}}) - accepted.Body.Close() - if accepted.StatusCode == http.StatusForbidden { - t.Fatal("la propre origine du poste est refusée") - } - - // No Origin at all is not a browser: curl, the kiosk supervisor, a test. - bare := b.post("/api/v1/cancel", `{}`) - bare.Body.Close() - if bare.StatusCode == http.StatusForbidden { - t.Fatal("une requête sans origine est refusée : plus rien ne peut piloter le poste") - } -} - -// TestARequestAddressedToAForeignNameIsRefused closes the rebinding half. -func TestARequestAddressedToAForeignNameIsRefused(t *testing.T) { - b := newBench(t) - request := httptest.NewRequest(http.MethodPost, "/api/v1/cancel", strings.NewReader(`{}`)) - request.Host = "poste.attaquant.example" - recorder := httptest.NewRecorder() - b.server.Handler().ServeHTTP(recorder, request) - if recorder.Code != http.StatusForbidden { - t.Fatalf("Host étranger = %d, attendu 403", recorder.Code) - } -} - -// TestTheAdministrationStaysOnTheLoopbackUnlessItIsOpened is network.admin_on_lan, -// which would otherwise be a setting that does nothing. -func TestTheAdministrationStaysOnTheLoopbackUnlessItIsOpened(t *testing.T) { - b := newBench(t) - - fromLAN := httptest.NewRequest(http.MethodGet, "/admin/api/health", nil) - fromLAN.RemoteAddr = "10.0.0.5:51234" - recorder := httptest.NewRecorder() - b.server.Handler().ServeHTTP(recorder, fromLAN) - if recorder.Code != http.StatusForbidden { - t.Fatalf("administration depuis le LAN = %d, attendu 403", recorder.Code) - } - - // The CLIENT screen is untouched: a station whose grid stopped answering because - // somebody tightened an administration setting would be a station that stopped - // selling. - client := httptest.NewRequest(http.MethodGet, "/api/v1/catalog", nil) - client.RemoteAddr = "10.0.0.5:51234" - recorder = httptest.NewRecorder() - b.server.Handler().ServeHTTP(recorder, client) - if recorder.Code != http.StatusOK { - t.Fatalf("écran client depuis le LAN = %d, attendu 200", recorder.Code) - } - - opened := newBench(t, func(o *benchOptions) { - o.config = func(cfg *domain.Config) { cfg.Network.AdminOnLAN = true } - }) - recorder = httptest.NewRecorder() - fromLAN = httptest.NewRequest(http.MethodGet, "/admin/api/health", nil) - fromLAN.RemoteAddr = "10.0.0.5:51234" - opened.server.Handler().ServeHTTP(recorder, fromLAN) - if recorder.Code != http.StatusOK { - t.Fatalf("administration ouverte sur le LAN = %d, attendu 200", recorder.Code) - } -} - // --- Helpers ---------------------------------------------------------------- // cookieOf finds one cookie in a response. @@ -940,47 +546,3 @@ var errNoSuchVersion = errors.New("version inconnue") var errNoConfigFile = fmt.Errorf("aucun fichier de configuration : %w", fs.ErrNotExist) var _ ConfigStore = (*savedConfig)(nil) - -// TestARescueDoesNotOverwriteAFileItCouldNotOpen closes a blind spot OLDER than the -// block-by-block decode, found while fixing the one next to it. -// -// A file that EXISTS and will not open — a permission, an I/O error, a mount that went -// away — is not a file that is gone. The read failed, `stored` stayed at the configuration -// in force, and on a station that started out of service that is the neutral profile: the -// rescue wrote the fourteen factory blocks onto a file it had never managed to read. Same -// destruction as the typed case, by a road the type does not cover. -func TestARescueDoesNotOverwriteAFileItCouldNotOpen(t *testing.T) { - shop := loadConfig(t) - saved := &savedConfig{} - if err := saved.Save(context.Background(), shop); err != nil { - t.Fatalf("préparation du fichier : %v", err) - } - // The file is there — it was just written — and now it will not open. - saved.readErr = errors.New("accès refusé") - - b := newBench(t, func(o *benchOptions) { - o.configStore = saved - o.config = func(cfg *domain.Config) { *cfg = domain.NeutralProfile() } - }) - b.setPassword("oublie", "ABCD2345") - - response := b.post("/admin/api/session/recovery", `{"code":"ABCD2345","password":"nouveau-mot"}`) - got := decodeStatus[sessionDTO](t, response, http.StatusOK) - - written := saved.saved() - if written.Station.Coop != shop.Station.Coop { - t.Errorf("station.coop = %q, attendu %q : le profil d'usine a été écrit sur un "+ - "fichier que le poste n'a pas su lire", written.Station.Coop, shop.Station.Coop) - } - if got, want := len(written.Pricing.Tiers), len(shop.Pricing.Tiers); got != want { - t.Errorf("%d tarif(s) au lieu de %d : la grille du magasin a été remplacée", got, want) - } - // The door opens anyway — refusing would leave this station with no way in at all — - // and it says what is not saved. - if got.Warning == "" { - t.Error("la session s'ouvre sans dire que le mot de passe n'est pas enregistré") - } - if !VerifySecret(b.hub.Config().Admin.PasswordHash, "nouveau-mot") { - t.Error("le nouveau mot de passe n'est pas en service") - } -} diff --git a/internal/web/sessionroutes.go b/internal/web/sessionroutes.go new file mode 100644 index 0000000..3bf2e24 --- /dev/null +++ b/internal/web/sessionroutes.go @@ -0,0 +1,313 @@ +// This file holds the THREE SESSION ROUTES: open one, close one, and the recovery +// code that reopens the administration of a station nobody can log into any more. +// +// The recovery route is the one that must never depend on the validation that put +// the station out of service to begin with -- a rescue that needs a healthy +// configuration is not a rescue. + +package web + +import ( + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "openscale/internal/domain" + "openscale/internal/station" + "strconv" + "strings" +) + +// sessionRequest is the body of POST /admin/api/session. +type sessionRequest struct { + Password string `json:"password"` +} + +// sessionDTO is what an opened session answers. +type sessionDTO struct { + ExpiresAt string `json:"expires_at"` + // Minutes is repeated so that a screen can show a countdown without parsing two + // instants and subtracting them. + Minutes int `json:"session_minutes"` + // Warning is set only by a recovery that could not write the new password to + // disk because the file still carries a retired key (ADR-034): the session opens + // anyway — refusing would lock the volunteer out of the one door left on a + // station the same retired key already put out of service — but the password + // will not survive a restart until the file is repaired, which this says, in + // French, naming the keys. + Warning string `json:"warning,omitempty"` +} + +// openSession is POST /admin/api/session. +func (s *Server) openSession(w http.ResponseWriter, r *http.Request) { + var body sessionRequest + if !decodeJSON(w, r, &body) { + return + } + cfg := s.hub.Config() + address := callerAddress(r) + + if remaining, waiting := s.sessions.locked(address); waiting { + w.Header().Set("Retry-After", strconv.Itoa(int(remaining.Seconds())+1)) + writeProblem(w, http.StatusTooManyRequests, "", + fmt.Sprintf("Trop d'essais. Réessayez dans %d minutes.", int(remaining.Minutes())+1)) + return + } + if cfg.Admin.PasswordHash == "" { + writeProblem(w, http.StatusConflict, codeNoPassword, + "Aucun mot de passe n'est défini sur ce poste : lancez l'assistant de premier démarrage.") + return + } + if !VerifySecret(cfg.Admin.PasswordHash, body.Password) { + s.sessions.failed(address, cfg.Admin.AttemptsPerMinute) + s.technical.Technical(domain.LevelWarn, "http", "", + "Mot de passe d'administration refusé.", address) + writeProblem(w, http.StatusUnauthorized, "", "Mot de passe incorrect.") + return + } + s.sessions.succeeded(address) + s.issueSession(w, cfg, "") +} + +// closeSession is DELETE /admin/api/session. +// +// §14.5 does not list it, and it is here anyway: without it the only way to leave the +// administration screen is to wait thirty minutes or to close the browser, and on a +// station in kiosk mode there is no browser to close. It reads nothing and writes no +// configuration, so it protects nothing that ADR-018 protects. +func (s *Server) closeSession(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(sessionCookie); err == nil { + s.sessions.close(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: "", Path: "/admin", MaxAge: -1, + HttpOnly: true, SameSite: http.SameSiteStrictMode, + }) + w.WriteHeader(http.StatusNoContent) +} + +// recoveryRequest is the body of POST /admin/api/session/recovery. +type recoveryRequest struct { + // Code is the eight characters printed on the installation sheet and filed in the + // shop's folder (§14.4, important-10). + Code string `json:"code"` + // Password is the new one. Resetting without setting would leave the station + // unprotected for as long as nobody came back to it. + Password string `json:"password"` +} + +// recoverSession is POST /admin/api/session/recovery: the forgotten password, reset +// FROM THE SCREEN. +// +// It exists because of Assigned Access: on a locked-down station there is no desktop +// and no command prompt, so « run openscale config password » is not an instruction +// anybody can follow. The code is the possession factor, the installation sheet is +// where it lives, and the shop's folder is the safe. +func (s *Server) recoverSession(w http.ResponseWriter, r *http.Request) { + var body recoveryRequest + if !decodeJSON(w, r, &body) { + return + } + cfg := s.hub.Config() + address := callerAddress(r) + + if remaining, waiting := s.sessions.locked(address); waiting { + w.Header().Set("Retry-After", strconv.Itoa(int(remaining.Seconds())+1)) + writeProblem(w, http.StatusTooManyRequests, "", + fmt.Sprintf("Trop d'essais. Réessayez dans %d minutes.", int(remaining.Minutes())+1)) + return + } + if cfg.Admin.RecoveryCodeHash == "" { + writeProblem(w, http.StatusConflict, "", + "Ce poste n'a pas de code de secours. Utilisez « openscale config password ».") + return + } + if !VerifySecret(cfg.Admin.RecoveryCodeHash, NormalizeRecoveryCode(body.Code)) { + // The SAME counter as the password: a code of eight characters is worth + // brute-forcing, and two independent budgets would be two doors. + s.sessions.failed(address, cfg.Admin.AttemptsPerMinute) + s.technical.Technical(domain.LevelWarn, "http", "", + "Code de secours refusé.", address) + writeProblem(w, http.StatusUnauthorized, "", "Code de secours incorrect.") + return + } + if len(body.Password) < 8 { + writeProblem(w, http.StatusUnprocessableEntity, "", + "Le nouveau mot de passe doit faire au moins 8 caractères.") + return + } + if s.configStore == nil || s.controller == nil { + unavailable(w, "la configuration n'est pas modifiable ici") + return + } + + hash, err := HashSecret(body.Password) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", err.Error()) + return + } + // ONE field changes, and it changes in TWO documents that are not always the same one. + // + // The file is the operator's, and it is what the station will read at its next start. + // The configuration in force is what the station is running right now — and on a + // station that started out of service those two differ completely: the file carries + // the shop's settings and its faults, memory carries the NEUTRAL PROFILE (§11.3). + // Writing the running configuration to disk there would replace tariffs, safeguards + // and categories with the factory ones, on the single gesture whose whole purpose is + // to rescue that station. + // + // A file only PART of which decoded is a third case, and it is the dangerous one. The + // blocks that DID decode are still the shop's own and are what must go back; the ones + // that did not are the neutral profile, and writing those is the destruction this whole + // paragraph exists to prevent -- on 02/08/2026 a flat refusal here sent the fourteen + // factory blocks onto the file, because `err != nil` fell through to the configuration + // in force. So the read blocks are taken, and the write is suspended, exactly as it is + // for a retired key below. + // A file that EXISTS and cannot be read suspends the write too, whatever the reason -- + // an I/O error, a permission, a mount that went away. None of those says the file is + // gone, and all of them used to leave `stored` at the configuration in force, which is + // the same fourteen factory blocks by another road. + // + // A file that does NOT exist is the one case where writing memory is right: there is + // nothing to destroy, and a station whose file was never written still has to be able + // to accept a password. That is why the test is on the ABSENCE and not on the error. + stored := cfg + persist := true + var unreadable *domain.UnreadableBlocksError + onDisk, readErr := s.configStore.Read(r.Context()) + switch { + case readErr == nil: + stored = onDisk + case errors.As(readErr, &unreadable): + stored = unreadable.Config + persist = false + case !errors.Is(readErr, fs.ErrNotExist): + persist = false + } + stored.Admin.PasswordHash = hash + + // The write can be refused for exactly one reason that must NOT lock the + // volunteer out: the file still carries a key control 20 refuses (ADR-034), which + // is precisely what put this station on the neutral profile and sent somebody + // looking for the recovery code in the first place. Persisting is refused -- + // ConfigStore.Save launders the key otherwise, and with it the discount it stood + // for -- but the door this request opens is the only one this volunteer has, and + // the screen that would explain the problem is behind it. So the session opens + // regardless, with the password in force IN MEMORY, and `warning` says plainly + // that it will not survive a restart until the file itself is repaired. Any other + // failure to write (a full disk, a read-only mount) is not this case and stays a + // hard failure, as it always has. + // + // A file that could not be READ earns the same treatment for the same reason, decided + // one step earlier: there, the file would be laundered by a write; here, it would be + // overwritten by values nobody declared. Both leave the volunteer a way in and both say + // what is not saved. The two are told apart because only one of them can be repaired by + // opening a named block. + var warning string + switch { + case unreadable != nil: + warning = fmt.Sprintf( + "Mot de passe actif, mais NON enregistré : %s du fichier de configuration %s, "+ + "et réécrire le fichier y poserait la configuration d'usine. Il ne survivra "+ + "pas à un redémarrage tant que le fichier n'est pas corrigé.", + unreadable.BlockPhrase(), unreadable.NotRead()) + s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", + "Mot de passe réinitialisé en mémoire seulement : un bloc du fichier de "+ + "configuration n'a pas pu être lu.", strings.Join(unreadable.Blocks(), ", ")) + case !persist: + warning = "Mot de passe actif, mais NON enregistré : le fichier de configuration " + + "n'a pas pu être lu, et l'écraser remplacerait les réglages du magasin par ceux " + + "d'usine. Il ne survivra pas à un redémarrage tant que le fichier n'est pas " + + "lisible." + s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", + "Mot de passe réinitialisé en mémoire seulement : le fichier de configuration "+ + "n'a pas pu être lu.", readErr.Error()) + default: + if err := s.configStore.Save(r.Context(), stored); err != nil { + var retired *domain.RetiredKeysError + if !errors.As(err, &retired) { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration non écrite : "+err.Error()) + return + } + warning = fmt.Sprintf( + "Mot de passe actif, mais NON enregistré : le fichier de configuration porte "+ + "encore %s. Il ne survivra pas à un redémarrage tant que le fichier n'est "+ + "pas corrigé.", strings.Join(retired.Keys, ", ")) + s.technical.Technical(domain.LevelError, "config", "ERR-CFG-01", + "Mot de passe réinitialisé en mémoire seulement : le fichier de configuration "+ + "porte encore une clé retirée.", strings.Join(retired.Keys, ", ")) + } + } + // And the station keeps running what it was running, with the new password in force: + // a recovery is not a moment to hand a station a configuration nobody has validated. + // No FileBefore: this changes the admin block alone, so no hardware block moves, no + // countdown is armed, and there is no rollback for a file to be the target of. + next := cfg + next.Admin.PasswordHash = hash + if _, err := s.controller.Reload(station.ReloadRequest{Next: next}); err != nil { + writeProblem(w, http.StatusInternalServerError, "", + "Configuration non rechargée : "+err.Error()) + return + } + // Every session minted under the old password goes, and the volunteer who just + // proved possession of the installation sheet gets a fresh one: they are standing + // in front of the station with a password to set. + s.sessions.revokeAll() + s.sessions.succeeded(address) + s.technical.Technical(domain.LevelWarn, "config", "", + "Mot de passe d'administration réinitialisé par le code de secours.", address) + s.issueSession(w, next, warning) +} + +// issueSession mints the cookie and answers. +// +// HttpOnly so that no script can read it, SameSite=Strict so that no other origin can +// make the browser send it, and Path=/admin so that it never travels on the client +// screen's own requests. No Secure flag: the station serves 127.0.0.1 over plain +// HTTP, and a cookie marked Secure would simply never be sent. +// +// warning is empty on the ordinary path (openSession) and carries the French sentence +// of an incomplete recovery on the other (recoverSession): the session opens the same +// way either time, only the sentence handed back differs. +func (s *Server) issueSession(w http.ResponseWriter, cfg domain.Config, warning string) { + minutes := sessionMinutes(cfg) + token, expiry, err := s.sessions.open(cfg.Admin.PasswordHash, minutes) + if err != nil { + writeProblem(w, http.StatusInternalServerError, "", err.Error()) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: token, Path: "/admin", + HttpOnly: true, SameSite: http.SameSiteStrictMode, + // MaxAge and not Expires: the browser counts on ITS clock, and an absolute + // instant read from the injected one would be a date in a test's past. + MaxAge: minutes * 60, + }) + writeJSON(w, http.StatusOK, sessionDTO{ + ExpiresAt: stamp(expiry), Minutes: minutes, Warning: warning, + }) +} + +// sessionMinutes is how long a session lasts, with the shipped default standing in +// for a value nobody set. +func sessionMinutes(cfg domain.Config) int { + if cfg.Admin.SessionMinutes <= 0 { + return 30 + } + return cfg.Admin.SessionMinutes +} + +// callerAddress is the address the rate limit counts against. +// +// The socket address and NEVER a forwarded header: there is no proxy in front of this +// station, and trusting X-Forwarded-For would let anybody reset their own counter by +// writing a header. +func callerAddress(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/web/substituted_test.go b/internal/web/substituted_test.go new file mode 100644 index 0000000..84514e7 --- /dev/null +++ b/internal/web/substituted_test.go @@ -0,0 +1,203 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + "time" + + "openscale/internal/domain" +) + +// --- Un fichier dont un bloc n'a pas décodé, porte par porte ----------------- +// +// The callers of ConfigStore.Read do three different things with the file, and each +// needs its own answer. One flat verdict for all of them is what produced a defect in each +// direction on 02/08/2026 (domain.UnreadableBlocksError). One test per door, below. + +// TestTheAdminScreenShowsTheReadBlocksAndNamesTheSubstitutedOnes is the DISPLAY door. +// +// The screen must show what the file really says — a station out of service runs the +// factory profile, and feeding the screen from memory is « la différence entre le réparer +// et le détruire » — and it must say which blocks it could NOT read, or a volunteer saves +// the factory tariffs over the shop's own without ever being told. +func TestTheAdminScreenShowsTheReadBlocksAndNamesTheSubstitutedOnes(t *testing.T) { + b, _, shop := benchOverADamagedFile(t, nil) + + got := decodeStatus[configDTO](t, b.get("/admin/api/config"), http.StatusOK) + + var served domain.Config + if err := json.Unmarshal(got.Config, &served); err != nil { + // The payload is re-marshalled from a decoded Config, so the damaged block travels + // as the neutral one and this always parses. + t.Fatalf("charge illisible : %v", err) + } + if served.Station.Coop != shop.Station.Coop { + t.Errorf("station.coop = %q, attendu %q : l'écran montre la mémoire, pas le fichier", + served.Station.Coop, shop.Station.Coop) + } + if len(got.Unreadable) != 1 { + t.Fatalf("%d bloc(s) signalé(s) comme illisible(s), attendu 1 : %+v", len(got.Unreadable), + got.Unreadable) + } + if got.Unreadable[0].Field != "pricing" { + t.Errorf("le bloc signalé est %q, attendu pricing", got.Unreadable[0].Field) + } + if got.Unreadable[0].Message == "" { + t.Error("le bloc est nommé sans dire pourquoi il n'a pas été lu") + } +} + +// TestRestoringABackupWithAnUnreadableBlockIsNotAMissingVersion is the RESTORE door. +// +// The backup is right there, listed on the screen one line above the button. « Introuvable » +// sends a volunteer looking for a file that exists; what is true is that it cannot be +// applied as it stands, which is what the validation branch beside it already answers. +func TestRestoringABackupWithAnUnreadableBlockIsNotAMissingVersion(t *testing.T) { + b, path, _ := benchOverADamagedFile(t, nil) + // .1 is a copy of the damaged file: a backup taken before somebody hand-edited it badly. + if err := os.WriteFile(path+".1", readRaw(t, path), 0o644); err != nil { + t.Fatalf("écriture de la sauvegarde : %v", err) + } + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + response := b.post("/admin/api/config/restore", `{"version":1}`) + + if response.StatusCode == http.StatusNotFound { + t.Fatal("une sauvegarde qui existe a été annoncée introuvable") + } + got := decodeStatus[problem](t, response, http.StatusUnprocessableEntity) + if !strings.Contains(got.Message, "pricing") { + t.Errorf("le refus ne nomme pas le bloc : %q", got.Message) + } + if len(got.Faults) == 0 { + t.Error("le refus ne porte pas la raison, que l'écran affiche champ par champ") + } +} + +// TestReloadingAFileWithAnUnreadableBlockIsRefusedByName is the PUT-IN-SERVICE door, and +// the one caller for which refusing is the whole right answer: the station would run the +// factory tariffs while its file declares the shop's. +func TestReloadingAFileWithAnUnreadableBlockIsRefusedByName(t *testing.T) { + b, _, _ := benchOverADamagedFile(t, nil) + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + response := b.post("/admin/api/config/reload", "") + + got := decodeStatus[problem](t, response, http.StatusUnprocessableEntity) + if !strings.Contains(got.Message, "pricing") { + t.Errorf("le refus ne nomme pas le bloc à ouvrir : %q", got.Message) + } + if b.hub.Config().Station.Coop != domain.NeutralProfile().Station.Coop { + t.Error("le poste s'est mis à tourner sur un fichier dont un bloc est celui d'usine") + } +} + +// TestSavingOverAFileWithAnUnreadableBlockKeepsTheCatalogPassword is the REWRITE door, and +// the trap is second-order: `served` is what the submitted document is compared against, +// and a read treated as a failure made it the configuration IN FORCE — the neutral profile, +// whose catalog carries no password. A save about anything at all then erased a producer's +// WebDAV account, silently. +func TestSavingOverAFileWithAnUnreadableBlockKeepsTheCatalogPassword(t *testing.T) { + const account = "s3cr3t-du-producteur" + b, path, shop := benchOverADamagedFile(t, func(cfg *domain.Config) { + cfg.Catalog.Options["password"] = json.RawMessage(`"` + account + `"`) + }) + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + // What the screen received, edited on one harmless field, and sent back. The password + // is never served, so it is never resubmitted: carriedOverSecret has to put it back. + served := decodeStatus[configDTO](t, b.get("/admin/api/config"), http.StatusOK) + var next domain.Config + if err := json.Unmarshal(served.Config, &next); err != nil { + t.Fatalf("charge illisible : %v", err) + } + next.Journal.MaxRows = shop.Journal.MaxRows + 100 + + response := b.do(http.MethodPut, "/admin/api/config", marshal(t, next), nil) + if response.StatusCode != http.StatusOK { + t.Fatalf("PUT = %d : %s", response.StatusCode, body(t, response)) + } + response.Body.Close() + + if !bytes.Contains(readRaw(t, path), []byte(account)) { + t.Error("le compte WebDAV du producteur a été effacé par un enregistrement qui ne " + + "le concernait pas") + } +} + +// TestAnUnconfirmedRestorationOverAnUnreadableBlockPutsTheShopsFileBack is the ROLLBACK +// door, and the one whose failure nobody is standing in front of. +// +// The restoration arms the sixty-second countdown of §11.4, and what the countdown writes +// back is FileBefore. Leaving it nil — which is what a read treated as a plain failure +// does — makes the rollback fall back on the configuration IN SERVICE, and on a station +// that started out of service that is the neutral profile. The shop's file is therefore +// overwritten with the factory one a full minute after the volunteer walked away, with +// nothing on any screen. +// +// Everything is real: a file on disk, a platform.ConfigStore, the station's own rollback. +func TestAnUnconfirmedRestorationOverAnUnreadableBlockPutsTheShopsFileBack(t *testing.T) { + b, path, shop := benchOverADamagedFile(t, nil) + // A backup that differs on the HARDWARE, so the restoration arms a countdown at all. + backup := reread(t, shop) + backup.Scale.Options["port"] = json.RawMessage(`"COM9"`) + writeRawConfig(t, path+".1", backup) + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + restored := decodeStatus[configDTO](t, + b.post("/admin/api/config/restore", `{"version":1}`), http.StatusOK) + if restored.Pending == nil { + t.Fatal("restaurer une version qui change le matériel n'arme aucun compte à rebours") + } + + // Nobody confirms. + b.advance(61 * time.Second) + written := awaitFileWithout(t, b, path, "COM9") + + if written.Station.Coop != shop.Station.Coop { + t.Errorf("station.coop = %q, attendu %q : le retour arrière a écrit le profil "+ + "d'usine sur le fichier du magasin, soixante secondes après", + written.Station.Coop, shop.Station.Coop) + } + if written.Catalog.Type != shop.Catalog.Type { + t.Errorf("catalog.type = %q, attendu %q : la source du catalogue a été remplacée "+ + "par le retour arrière", written.Catalog.Type, shop.Catalog.Type) + } + if written.Limits.BasketMin != shop.Limits.BasketMin { + t.Errorf("limits.basket_min = %v, attendu %v : les garde-fous ont été remplacés", + written.Limits.BasketMin, shop.Limits.BasketMin) + } +} + +// awaitFileWithout waits until the file on disk no longer carries the unconfirmed port, +// which is what says the rollback has run, and returns it decoded the way a station decodes +// it. +func awaitFileWithout(t *testing.T, b *bench, path, unconfirmedPort string) domain.Config { + t.Helper() + deadline := time.Now().Add(hang) + for time.Now().Before(deadline) { + // A transient read failure is EXPECTED here and is not the answer: §11.4 replaces + // the file by renaming a temporary over it, and on Windows that window is an open + // that fails. Polling through it is what makes this test about the rollback rather + // than about the atomic write beside it. + if raw, err := os.ReadFile(path); err == nil { + written, _ := domain.DecodeConfigBlockByBlock(raw) + if port, declared := written.Scale.Options.Text("port"); !declared || port != unconfirmedPort { + return written + } + } + b.clock.Advance(time.Second) + time.Sleep(time.Millisecond) + } + t.Fatal("le fichier porte encore la configuration non confirmée : le retour arrière ne " + + "l'a jamais réécrit, et le prochain démarrage repartirait dessus") + return domain.Config{} +} diff --git a/internal/web/troubleshooting_test.go b/internal/web/troubleshooting_test.go new file mode 100644 index 0000000..c406c61 --- /dev/null +++ b/internal/web/troubleshooting_test.go @@ -0,0 +1,338 @@ +package web + +import ( + "errors" + "net/http" + "strings" + "testing" + + "openscale/internal/domain" +) + +// The troubleshooting screen and the hardware routes: the nine buttons that answer WITHOUT +// a password, the self-tests that read back what has already been observed rather than +// touching a device, the catalog reload, and what happens when a collaborator is missing. +// +// A station in trouble is precisely the one whose password nobody can find: these routes +// have to answer all the same, and say what is missing instead of lying. +// +// The bench and the doubles are in admin_test.go. + +// TestTheNineTroubleshootingButtonsAnswerWithoutAPassword (ADR-018). +func TestTheNineTroubleshootingButtonsAnswerWithoutAPassword(t *testing.T) { + actions := &fakeTroubleshooting{} + catalog := &fakeCatalogAdmin{} + b := newBench(t, func(o *benchOptions) { + o.troubleshooting = actions + o.catalogAdmin = catalog + o.printer = b2Printer{} + }) + // « Basculer en saisie manuelle » est devenue un acte PROTÉGÉ (ADR-033) : elle coupe + // la balance et laisse le client taper son propre poids. Les sept autres restent + // libres — aucune ne change ce que le poste vend ni la façon dont il pèse. + b.setPassword("un-mot-de-passe", "ABCD2345") + b.login("un-mot-de-passe") + + for _, action := range []struct { + path, body string + want int + }{ + {"/admin/api/troubleshooting/reprint", `{}`, http.StatusOK}, + {"/admin/api/troubleshooting/reload-catalog", `{}`, http.StatusAccepted}, + {"/admin/api/troubleshooting/manual-entry", `{"on":true}`, http.StatusOK}, + {"/admin/api/troubleshooting/roll-changed", `{}`, http.StatusOK}, + {"/admin/api/troubleshooting/fallback-printer", `{"on":true}`, http.StatusOK}, + {"/admin/api/troubleshooting/test-scale", `{}`, http.StatusOK}, + {"/admin/api/troubleshooting/test-printer", `{}`, http.StatusOK}, + {"/admin/api/troubleshooting/test-label", `{}`, http.StatusAccepted}, + } { + response := b.post(action.path, action.body) + if response.StatusCode != action.want { + t.Errorf("POST %s = %d, attendu %d : %s", + action.path, response.StatusCode, action.want, body(t, response)) + continue + } + response.Body.Close() + } + if !actions.manual || !actions.roll || !actions.fallback { + t.Fatalf("les actions n'ont pas été exécutées : %+v", actions) + } + if catalog.reloads != 1 { + t.Fatalf("%d relectures de catalogue, attendu 1", catalog.reloads) + } +} + +// TestTestingTheScaleAndThePrinterReadsWhatIsAlreadyObserved. +// +// Reopening the serial port to test it would mean closing the driver in service on a +// platform where the port is exclusive — a diagnosis that breaks what it diagnoses. And +// the live median is a better answer than a three-second sample. +func TestTestingTheScaleAndThePrinterReadsWhatIsAlreadyObserved(t *testing.T) { + b := newBench(t) + b.feed(1236, 10) + + scale := decodeStatus[scaleTestDTO](t, + b.post("/admin/api/troubleshooting/test-scale", `{}`), http.StatusOK) + if !scale.Connected || scale.LastWeightG != 1236 { + t.Fatalf("test balance = %+v", scale) + } + if !strings.Contains(scale.Message, "répond") { + t.Fatalf("message = %q", scale.Message) + } + + printer := decodeStatus[struct { + Health string `json:"health"` + Message string `json:"message"` + }](t, b.post("/admin/api/troubleshooting/test-printer", `{}`), http.StatusOK) + if printer.Message == "" { + t.Fatalf("test imprimante = %+v", printer) + } +} + +// TestAnUnknownSelfTestIsRefusedByName. +func TestAnUnknownSelfTestIsRefusedByName(t *testing.T) { + b := newBench(t, func(o *benchOptions) { o.printer = b2Printer{} }) + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + response := b.post("/admin/api/printer/test?what=inconnu", `{}`) + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("auto-test inconnu = %d, attendu 400", response.StatusCode) + } +} + +// TestTheReloadAnswerNamesWhatIsWatchedAndTheImportInForce. +// +// « Le catalogue va être relu. » was written before anything had been looked at, and the +// answer carried nothing else: no file, no directory, no instant, no result. The screen +// therefore had no way to recognise the import that followed, and on the dominant case — +// nothing where the station is looking — the promise was followed by a silence that never +// ended, because the watch returns without a word when it finds no file (§10.5). +func TestTheReloadAnswerNamesWhatIsWatchedAndTheImportInForce(t *testing.T) { + const seen = "Aucun fichier flv_2.csv dans D:\\catalog\\incoming : il n'y a rien à relire." + b := newBench(t, func(o *benchOptions) { + o.catalogAdmin = &fakeCatalogAdmin{seen: seen} + o.dashboard = stubDashboard{DashboardFacts{Source: &CatalogSourceState{ + Type: domain.CatalogSourceLocalDrop, + Label: "dépôt local, flv_2.csv dans D:\\catalog\\incoming", + }}} + }) + b.store.imports = []domain.Import{{ + ID: 7, OccurredAt: epoch, Source: domain.CatalogSourceLocalDrop, + FileName: "flv_1.csv", Result: domain.ImportApplied, + }} + + got := decodeStatus[reloadDTO](t, + b.post("/admin/api/troubleshooting/reload-catalog", `{}`), http.StatusAccepted) + if got.Message != seen { + t.Fatalf("message = %q, attendu ce que le poste a VU du fichier surveillé", got.Message) + } + if !strings.Contains(got.Watched, "flv_2.csv") { + t.Fatalf("surveillé = %q, attendu la ligne permanente du catalogue", got.Watched) + } + // L'écran reconnaît l'import SUIVANT en comparant son identifiant à celui-ci : sans + // lui, il ne peut ni annoncer l'issue ni cesser de l'attendre. + if got.LastImportID != 7 || got.LastImportAt == "" { + t.Fatalf("import en vigueur = %d à %q, attendu le dernier import du journal", + got.LastImportID, got.LastImportAt) + } +} + +// TestAStationWithNoJournalAndNoDashboardStillAnswersTheReload. +// +// Both collaborators are optional — a station whose journal is unavailable still serves +// (ADR-013), and a station wired without a Dashboard publishes no source line. The answer +// then says nothing about either, rather than panicking or inventing a sentence. +func TestAStationWithNoJournalAndNoDashboardStillAnswersTheReload(t *testing.T) { + b := newBench(t, func(o *benchOptions) { + o.catalogAdmin = &fakeCatalogAdmin{} + o.noStore = true + }) + + got := decodeStatus[reloadDTO](t, + b.post("/admin/api/troubleshooting/reload-catalog", `{}`), http.StatusAccepted) + if !got.Done { + t.Fatalf("relecture = %+v, attendu acceptée", got) + } + if got.Watched != "" || got.LastImportID != 0 { + t.Fatalf("relecture = %+v, attendu aucune affirmation sur ce que rien n'a publié", got) + } + if got.Message == "" { + t.Fatal("un poste sans journal ni tableau de bord ne dit rien du tout de sa relecture") + } +} + +// TestACatalogDroppedOnTheScreenGoesThroughTheOrdinaryWatcher (A4, ADR-011). +func TestACatalogDroppedOnTheScreenGoesThroughTheOrdinaryWatcher(t *testing.T) { + catalog := &fakeCatalogAdmin{} + b := newBench(t, func(o *benchOptions) { o.catalogAdmin = catalog }) + // Le dépôt remplace toute la grille par un fichier qu'on apporte : acte protégé (ADR-033). + b.setPassword("un-mot-de-passe", "ABCD2345") + b.login("un-mot-de-passe") + + payload, contentType := multipartCSV(t, "flv_2.csv", "id;nom;prix\n20;AIL;5.32\n") + response := b.do(http.MethodPost, "/admin/api/catalog/import", payload, + http.Header{"Content-Type": {contentType}}) + got := decodeStatus[importDTO](t, response, http.StatusAccepted) + if got.FileName != "flv_2.csv" { + t.Fatalf("import = %+v", got) + } + if catalog.imported != "flv_2.csv" { + t.Fatalf("le fichier remis à la source est %q", catalog.imported) + } + + empty := b.do(http.MethodPost, "/admin/api/catalog/import", "", + http.Header{"Content-Type": {contentType}}) + empty.Body.Close() + if empty.StatusCode != http.StatusBadRequest { + t.Fatalf("dépôt sans fichier = %d, attendu 400", empty.StatusCode) + } +} + +// TestARouteWhoseCollaboratorIsMissingSays501 — and 501, not 404: the route EXISTS, it +// is in the contract of §14.5, and it is this binary's wiring that does not carry the +// capability. A 404 would send a volunteer looking for a typo. +func TestARouteWhoseCollaboratorIsMissingSays501(t *testing.T) { + b := adminBench(t) + + for _, route := range []struct{ method, path, body string }{ + {http.MethodPost, "/admin/api/troubleshooting/reload-catalog", `{}`}, + {http.MethodPost, "/admin/api/troubleshooting/manual-entry", `{"on":true}`}, + {http.MethodPost, "/admin/api/troubleshooting/roll-changed", `{}`}, + {http.MethodPost, "/admin/api/troubleshooting/fallback-printer", `{"on":false}`}, + {http.MethodPost, "/admin/api/troubleshooting/test-label", `{}`}, + {http.MethodGet, "/admin/api/diagnostic.zip", ""}, + {http.MethodGet, "/admin/api/ports", ""}, + {http.MethodGet, "/admin/api/printers", ""}, + {http.MethodPost, "/admin/api/printers/discover", `{}`}, + {http.MethodPost, "/admin/api/scale/detect", `{"port":"COM8"}`}, + {http.MethodPost, "/admin/api/scale/capture", `{"port":"COM8"}`}, + {http.MethodGet, "/admin/api/label/preview.png", ""}, + {http.MethodPost, "/admin/api/catalog/reload", `{}`}, + {http.MethodPost, "/admin/api/catalog/forget-quarantine", `{}`}, + {http.MethodPost, "/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`}, + } { + response := b.do(route.method, route.path, route.body, nil) + if response.StatusCode != http.StatusNotImplemented { + t.Errorf("%s %s = %d, attendu 501", route.method, route.path, response.StatusCode) + } + if !strings.Contains(body(t, response), "pas disponible") { + t.Errorf("%s %s ne dit pas ce qui manque", route.method, route.path) + } + } +} + +// TestTheHardwareRoutesAnswerWhenThePlatformIsWired. +func TestTheHardwareRoutesAnswerWhenThePlatformIsWired(t *testing.T) { + hardware := &fakeHardware{} + b := adminBench(t, func(o *benchOptions) { o.hardware, o.diagnostician = hardware, hardware }) + + ports := decodeStatus[struct { + Ports []portDTO `json:"ports"` + }](t, b.get("/admin/api/ports"), http.StatusOK) + if len(ports.Ports) != 1 || ports.Ports[0].Description == "" { + t.Fatalf("ports = %+v : « COM8 » ne nomme rien, « COM8 — FTDI » nomme un câble", ports.Ports) + } + + printers := decodeStatus[struct { + Printers []printerDeviceDTO `json:"printers"` + }](t, b.get("/admin/api/printers"), http.StatusOK) + if len(printers.Printers) != 1 { + t.Fatalf("imprimantes = %+v", printers.Printers) + } + // The key travels with the name, and the two routes do not answer the same one. Without + // it the screen wrote every destination a volunteer clicked into printer.options.queue, + // address of a network printer included — a configuration nothing refuses and no + // transport can open. + if printers.Printers[0].Key != domain.DeviceKeyQueue { + t.Fatalf("file énumérée = %+v : elle doit dire qu'elle va dans %q", + printers.Printers[0], domain.DeviceKeyQueue) + } + discovered := decodeStatus[struct { + Printers []printerDeviceDTO `json:"printers"` + }](t, b.post("/admin/api/printers/discover", `{}`), http.StatusOK) + if len(discovered.Printers) != 2 { + t.Fatalf("découverte = %+v", discovered.Printers) + } + for _, found := range discovered.Printers { + if found.Key != domain.DeviceKeyAddress { + t.Errorf("candidat réseau %+v : il doit dire qu'il va dans %q", + found, domain.DeviceKeyAddress) + } + } + + detected := decodeStatus[struct { + Driver string `json:"driver"` + ValidCount int `json:"valid_frames_count"` + }](t, b.post("/admin/api/scale/detect", `{"port":"COM8"}`), http.StatusOK) + if detected.Driver == "" || detected.ValidCount != 12 { + t.Fatalf("détection = %+v : c'est la détection qui répond, pas l'exploitant", detected) + } + + captured := decodeStatus[struct { + Frames []string `json:"frames"` + }](t, b.post("/admin/api/scale/capture", `{"port":"COM8","seconds":3}`), http.StatusOK) + if len(captured.Frames) == 0 { + t.Fatal("aucune trame capturée") + } + + preview := b.get("/admin/api/label/preview.png?template=weighing_identical&demo=1") + defer preview.Body.Close() + if preview.StatusCode != http.StatusOK || + preview.Header.Get("Content-Type") != "image/png" { + t.Fatalf("aperçu = %d, %q", preview.StatusCode, preview.Header.Get("Content-Type")) + } + + archive := b.get("/admin/api/diagnostic.zip") + defer archive.Body.Close() + if archive.Header.Get("Content-Disposition") == "" { + t.Fatal("le fichier de diagnostic ne se télécharge pas") + } + + replayed := b.post("/admin/api/replay", `{"frame":"ST,GS,+ 1.236KG"}`) + replayed.Body.Close() + if replayed.StatusCode != http.StatusAccepted { + t.Fatalf("rejeu = %d, attendu 202", replayed.StatusCode) + } + if empty := b.post("/admin/api/replay", `{"frame":""}`); empty.StatusCode != http.StatusBadRequest { + t.Fatalf("rejeu sans trame = %d, attendu 400", empty.StatusCode) + } +} + +// TestAStationWithoutAJournalSaysSoRatherThanLying. +func TestAStationWithoutAJournalSaysSoRatherThanLying(t *testing.T) { + b := newBench(t, func(o *benchOptions) { o.noStore = true }) + b.setPassword("mot-de-passe-long", "ABCD2345") + b.login("mot-de-passe-long") + + for _, path := range []string{ + "/admin/api/journal", "/admin/api/journal/export.csv", + "/admin/api/technical", "/admin/api/imports", + } { + response := b.get(path) + response.Body.Close() + if response.StatusCode != http.StatusNotImplemented { + t.Errorf("GET %s = %d, attendu 501", path, response.StatusCode) + } + } + response := b.post("/admin/api/products/4412/decision", `{"offered":false,"reason":"x"}`) + response.Body.Close() + if response.StatusCode != http.StatusNotImplemented { + t.Fatalf("décision sans base = %d, attendu 501", response.StatusCode) + } +} + +// TestADatabaseThatRefusesToReadIsReportedAndNotHidden. +func TestADatabaseThatRefusesToReadIsReportedAndNotHidden(t *testing.T) { + b := adminBench(t) + b.store.err = errors.New("base verrouillée") + + for _, path := range []string{"/admin/api/journal", "/admin/api/technical", "/admin/api/imports"} { + response := b.get(path) + response.Body.Close() + if response.StatusCode != http.StatusInternalServerError { + t.Errorf("GET %s = %d, attendu 500", path, response.StatusCode) + } + } +} diff --git a/make.ps1 b/make.ps1 index bc5caf4..b8cdce2 100644 --- a/make.ps1 +++ b/make.ps1 @@ -21,7 +21,7 @@ [CmdletBinding()] param( [Parameter(Position = 0)] - [ValidateSet('all', 'test', 'driver', 'vet', 'boundary', 'deps', 'build', 'dist', 'release', 'cover', 'front', 'front-check', 'clean', 'help')] + [ValidateSet('all', 'test', 'driver', 'vet', 'lint', 'audit', 'boundary', 'deps', 'build', 'dist', 'release', 'cover', 'front', 'front-check', 'clean', 'help')] [string]$Target = 'all', # -Version impose le numéro au lieu de le dériver de l'histoire, comme @@ -98,6 +98,88 @@ function Invoke-Vet { Assert-Success 'go vet' } +function Get-GolangciVersion { + <# + .SYNOPSIS + Rend la version de golangci-lint épinglée par le Makefile. + + .DESCRIPTION + Le Makefile écrit cette version à UN SEUL endroit et dit que la CI, ce script + et lui la lisent tous d'ici. La CI la lit par `make -s golangci-version` ; ce + script ne le peut pas, puisqu'il existe pour les postes sans `make`. Il lit + donc la ligne, et LÈVE plutôt que de retomber sur une valeur par défaut : un + numéro deviné ici rendrait à nouveau deux sources de vérité, et c'est + exactement ce que le Makefile cherche à empêcher. + #> + $makefile = Join-Path $PSScriptRoot 'Makefile' + $pinned = Select-String -Path $makefile -Pattern '^GOLANGCI_VERSION\s*\?=\s*(\S+)' + if (-not $pinned) { + throw "GOLANGCI_VERSION est introuvable dans $makefile. La version y est épinglée et nulle part ailleurs : ce script ne peut pas la deviner." + } + return $pinned.Matches[0].Groups[1].Value +} + +function Get-GolangciLint { + <# + .SYNOPSIS + Rend le chemin de golangci-lint, ou lève en disant comment l'installer. + + .DESCRIPTION + L'outil est cherché dans le PATH d'abord, puis dans le GOPATH : `go install` + l'y dépose sans que le PATH le sache toujours sous Windows. + + Le message d'installation porte la version épinglée, et non `@latest` : un + développeur qui suit un `@latest` obtient un jeu de règles autre que celui de + la CI, donc le rouge-là-où-la-CI-voit-vert que le Makefile décrit — ou son + inverse, qui est pire, parce que personne ne cherche la cause d'un vert. + #> + $inPath = (Get-Command golangci-lint -ErrorAction SilentlyContinue) + if ($inPath) { return $inPath.Source } + $inGopath = Join-Path (go env GOPATH) 'bin\golangci-lint.exe' + if (Test-Path $inGopath) { return $inGopath } + $version = Get-GolangciVersion + throw "golangci-lint introuvable. Installez-le HORS module : go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$version" +} + +function Invoke-Lint { + <# + .SYNOPSIS + Le jeu de règles bloquant, qui est VERT. + + .DESCRIPTION + Les deux vont ensemble : un jeu qui rougit dès le premier jour n'est pas lu, + il est contourné. .golangci.yml ne porte que ce que le dépôt tient + aujourd'hui, et il écrit à côté de chaque linter écarté le nombre de + signalements qu'il produirait — pour que « pas activé » ne se lise jamais + « sans valeur ». + #> + & (Get-GolangciLint) run ./... + Assert-Success 'make lint' +} + +function Invoke-Audit { + <# + .SYNOPSIS + L'inventaire complet, qui ne bloque rien. + + .DESCRIPTION + Active tout, ne fait échouer personne, et ne tourne pas dans l'intégration + continue. Elle se lance à la main quand on ouvre un lot de qualité, et son + relevé sert à le dimensionner. Ne pas lui ajouter d'Assert-Success : une + cible d'inventaire qui échoue est une cible qu'on cesse de lancer. + #> + & (Get-GolangciLint) run -c .golangci-audit.yml ./... + # Le `|| true` du Makefile est INCONDITIONNEL ; ici, ne rien faire ne suffit + # pas. L'absence d'Assert-Success s'appuie sur $PSNativeCommandUseErrorAction- + # Preference, une préférence GLOBALE que ce script ne fixe pas et qui vaut + # $true par défaut sur certaines versions de PowerShell 7 : sur un poste ainsi + # réglé, une commande native sortant en 1 ferait échouer la cible. Remettre le + # code à zéro rend la garantie identique des deux côtés, quelle que soit la + # préférence du poste. + $global:LASTEXITCODE = 0 + 'audit : relevé ci-dessus. Les raisons de chaque exclusion sont dans .golangci.yml' +} + function Invoke-Boundary { go run ./tools/boundary Assert-Success 'make boundary' @@ -257,8 +339,10 @@ function Invoke-Release { } switch ($Target) { - 'help' { 'Cibles : test - driver - vet - boundary - deps - build - dist - release - cover - front - front-check - clean' } + 'help' { 'Cibles : test - driver - vet - lint - audit - boundary - deps - build - dist - release - cover - front - front-check - clean' } 'vet' { Invoke-Vet } + 'lint' { Invoke-Lint } + 'audit' { Invoke-Audit } 'boundary' { Invoke-Boundary } 'deps' { Invoke-Deps } 'driver' { Invoke-Driver } diff --git a/tools/boundary/clock.go b/tools/boundary/clock.go new file mode 100644 index 0000000..cf04f8b --- /dev/null +++ b/tools/boundary/clock.go @@ -0,0 +1,103 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +// CUT 1 BIS — no call to time.Now anywhere under internal/, outside the two named +// exceptions. +// +// A lost tick must never be able to UNDER-count the age of a measurement and let an +// expired weight print. That is bloquant-1, and it only stays fixed if the CI keeps +// saying so. + +// clockAllowList names the ONLY two places allowed to read the real clock, and +// why. Everything else receives ports.Clock by injection. +// +// The path separator is a slash: the comparison normalizes it on every OS. +var clockAllowList = map[string]string{ + // The real implementation of Clock, which IS the call to time.Now, once, at + // the only place meant for it. + "internal/platform/clock.go": "the single real implementation of ports.Clock", + // An I/O deadline set in the TCP stack of the OS kernel, which no fake clock + // can drive. It carries no business decision: it bounds a write towards a + // zombie browser. + "internal/web/stream.go": "rc.SetWriteDeadline on a network write", +} + +// checkNoClockReads is cut 1 bis: no call to time.Now anywhere under internal/, +// outside the two named exceptions. +// +// A lost tick must never be able to UNDER-count the age of a measurement and let +// an expired weight print. That is bloquant-1, and it only stays fixed if the CI +// keeps saying so. +func checkNoClockReads(root string, report func(string, ...any)) { + err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + // Test files may read the real clock: they are not the production path, + // and a test that measures its own wall time is legitimate. + if strings.HasSuffix(path, "_test.go") { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if _, allowed := clockAllowList[relative]; allowed { + return nil + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + report("%s : %v", relative, err) + return nil + } + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := selector.X.(*ast.Ident) + if !ok || pkg.Name != "time" { + return true + } + // Now, Since and Until all read the real clock. + switch selector.Sel.Name { + case "Now", "Since", "Until", "NewTicker", "NewTimer", "Tick", "After", "AfterFunc": + position := fset.Position(call.Pos()) + report("%s:%d : appel à time.%s — coupe 1 bis : l'horloge est injectée (ports.Clock), "+ + "les deux seules exceptions sont %s", + relative, position.Line, selector.Sel.Name, allowListNames()) + } + return true + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + report("parcours de internal/ : %v", err) + } +} + +func allowListNames() string { + names := make([]string, 0, len(clockAllowList)) + for path := range clockAllowList { + names = append(names, path) + } + return strings.Join(names, " et ") +} diff --git a/tools/boundary/domain.go b/tools/boundary/domain.go new file mode 100644 index 0000000..db4c138 --- /dev/null +++ b/tools/boundary/domain.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "os/exec" + "strings" +) + +// CUT 1 — the business core has NO outgoing dependency on the outside world. +// +// What is verified is not `go list -deps`, and the reason is written in the godoc +// below: the transitive closure of the core contains os, and always will, because fmt +// imports it. Taken literally the rule would forbid fmt.Errorf, and the check would be +// either always red or quietly disabled. Neither is a boundary. + +// forbiddenInDomain are the packages the business core may not reach, directly or +// transitively. Not a style rule: it is what makes the core testable with nothing +// to simulate, and replayable offline from the journal. +var forbiddenInDomain = []string{"net/http", "database/sql", "os"} + +// checkDomainImports is cut 1: the business core has NO outgoing dependency on +// the outside world. +// +// WHY NOT `go list -deps`, which is what §5.2 prescribes: the transitive closure +// of the core contains os, and always will, because `fmt` imports it — fmt.Println +// writes to os.Stdout. Taken literally the rule would forbid fmt.Errorf, which +// opens no file and performs no I/O, and the check would be either always red or +// quietly disabled. Neither is a boundary. +// +// What is verified instead, and what actually protects the invariant: +// +// 1. no package under internal/domain IMPORTS os, net/http or database/sql +// directly; +// 2. the rule follows OUR OWN packages transitively — a helper of ours that +// looked harmless and pulled database/sql would be caught, which is the real +// risk a grep would miss. +// +// The standard library is trusted not to perform I/O behind fmt and sort. That is +// a deliberate limit of this check, written down so that nobody mistakes it for an +// oversight. +func checkDomainImports(report func(string, ...any)) { + imports, err := directImports() + if err != nil { + report("%v", err) + return + } + + const modulePrefix = "openscale/" + visited := make(map[string]bool) + var walk func(pkg, path string) + walk = func(pkg, path string) { + if visited[pkg] { + return + } + visited[pkg] = true + for _, imported := range imports[pkg] { + for _, forbidden := range forbiddenInDomain { + if imported == forbidden { + report("%s importe %q — coupe 1 : le noyau métier n'a aucune dépendance sortante%s", + pkg, forbidden, path) + break + } + } + // Follow our own packages only: the standard library is trusted. + if strings.HasPrefix(imported, modulePrefix) { + walk(imported, path+"\n (atteint depuis "+pkg+")") + } + } + } + for pkg := range imports { + if strings.HasPrefix(pkg, modulePrefix+"internal/domain") { + walk(pkg, "") + } + } +} + +// directImports lists every package of the module with the packages it imports +// itself, tests excluded: a test that reads a fixture with os.ReadFile is not the +// production path. +func directImports() (map[string][]string, error) { + out, err := exec.Command("go", "list", "-f", "{{.ImportPath}} {{join .Imports \" \"}}", "./...").Output() + if err != nil { + return nil, fmt.Errorf("`go list` a échoué : %v", err) + } + imports := make(map[string][]string) + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + imports[fields[0]] = fields[1:] + } + return imports, nil +} diff --git a/tools/boundary/drivers.go b/tools/boundary/drivers.go new file mode 100644 index 0000000..ba0b118 --- /dev/null +++ b/tools/boundary/drivers.go @@ -0,0 +1,286 @@ +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "path" + "sort" + "strconv" + "strings" +) + +// CUT 2 — ONE file of the tree names a concrete driver, and it is the composition root. +// +// The cut bears on what is REGISTRABLE, and this file COMPUTES what a driver package is +// instead of reading a list of names: any package offering a value one of the registries +// accepts. That definition is what makes the check need no maintenance when a model is +// added, and what makes it impossible to widen by adding a name to a list. + +// checkDriverImports is cut 2: ONE file of the tree names a concrete driver, and it is +// the composition root. +// +// WHAT A DRIVER PACKAGE IS, AND WHY IT IS NOT A LIST OF NAMES. The cut bears on what is +// REGISTRABLE: a package that offers a value the registry of §9.3 or §8.1 accepts — +// scale.Driver, printing.Driver — is a driver package, and every other package under +// internal/ is not. That definition is what the walk below computes, so the check needs +// no maintenance when a model is added and cannot be widened by adding a name to a list. +// +// It also settles, without an exception, the four packages a path-based rule would have +// had to argue about: +// +// - internal/scale/serial is the READER LOOP shared by every serial model — the byte +// layer, exactly like internal/printing/transport on the other side, which the +// composition root builds and hands over. It registers nothing; +// - internal/scale/replay parses the corpus format. §9.3 keeps `replay` out of the +// registry BY NAME: it is a diagnostic tool, not a weighing protocol; +// - internal/scale/absent is the STATE a station enters when scale.present is false, +// and scale.Registry.Register panics on a driver that tried to call itself that; +// - internal/printing/preview IS a driver package, and the two encoders that used to +// live in it are now printing.EncodePNG and printing.EncodePDF. They were called by +// the aperçu route and by `openscale label`, neither of which prints anything, and +// an encoder held inside a driver's package is what forced those two files to import +// a driver (internal/printing/encode.go says it again). +// +// TEST FILES ARE OUT, deliberately, and the reason is the same one directImports gives +// for excluding them from cut 1: they are not the production path. What cut 2 protects +// is the wiring of the BINARY — that a model can be removed by deleting one package and +// one line — and a _test.go file is in no binary. Forbidding them would also cost +// something real: cmd/openscale/admin_test.go declares scale.type as gramxfoc.IDRS +// rather than as the literal "gram-xfoc-rs", which is a test that breaks the day the ID +// moves instead of one that goes on passing against a protocol nobody carries any more. +func checkDriverImports(root string, report func(string, ...any)) { + drivers, err := driverPackages(root) + if err != nil { + report("%v", err) + return + } + // A check that finds nothing to protect is a check that has stopped running, and + // this one spent six lots switched off saying nothing. If the registry types are + // ever renamed, this is what says so rather than a silent pass. + if len(drivers) == 0 { + report("coupe 2 : aucun paquet driver trouvé sous internal/.\n"+ + " Un paquet driver est un paquet qui expose une entrée de registre — une déclaration\n"+ + " exportée de l'un des types %s. Si ces types ont été renommés,\n"+ + " renommez-les aussi dans tools/boundary/main.go : sans cela la coupe 2 passe au vert\n"+ + " sur n'importe quoi.", registryTypeNames()) + return + } + + err = walkGoFiles(root, func(relative, path string) error { + if relative == compositionRoot { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.SkipObjectResolution) + if err != nil { + report("%s : %v", relative, err) + return nil + } + for _, spec := range file.Imports { + imported, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + if entry, isDriver := drivers[imported]; isDriver { + report(cut2Violation, relative, fset.Position(spec.Pos()).Line, imported, + imported, entry, compositionRoot) + } + } + return nil + }) + if err != nil { + report("parcours des sources Go : %v", err) + } +} + +// cut2Violation is written to be acted upon by somebody — or something — that arrives +// with no other context: what is forbidden, what to do instead, and a file to imitate. +const cut2Violation = "%s:%d importe %s — coupe 2 : un seul fichier de l'arbre nomme un driver concret.\n" + + " %s est un paquet driver parce qu'il expose une entrée de registre (%s).\n" + + " Selon ce dont ce fichier a besoin :\n" + + " 1. un driver INSTANCIÉ — il ne le construit pas. Il reçoit un ports.Scale, un\n" + + " ports.Printer ou un ports.CatalogSource que la racine de composition lui passe ;\n" + + " cmd/openscale/serve.go montre le câblage.\n" + + " 2. une fonction que ce paquet héberge SANS QU'ELLE SOIT LE DRIVER — elle n'a rien à\n" + + " faire là. Remontez-la dans internal/printing, internal/scale ou internal/catalog,\n" + + " qui ne sont pas des paquets drivers, et importez celui-là. printing.EncodePNG a\n" + + " fait exactement ce trajet : internal/printing/encode.go dit pourquoi.\n" + + " 3. ENREGISTRER un driver de plus — la ligne va dans scaleRegistry, printerRegistry ou\n" + + " catalogSourceRegistry de %s, et nulle part ailleurs. C'est la « ONE LINE »\n" + + " de §5.2.\n" + + " La liste des paquets drivers n'est écrite nulle part et ne s'allonge pas à la main :\n" + + " c'est tout paquet qui expose une valeur scale.Driver, printing.Driver ou\n" + + " catalog.Source." + +// The packages that DEFINE what a driver is, and the type each one accepts into its +// registry. None can match itself: inside them the type is spelled without a qualifier, +// and what is looked for is the QUALIFIED name a package from outside has to write. +// +// A TABLE and not three tests, because that is the whole idea of cut 2: it COMPUTES what +// a driver package is instead of reading a list of names. A plug-in point added to §5.2 +// is one entry here, and the walk, the failure message and the refusal to find nothing all +// follow from it. +// +// catalog.Source is the third, and it was missing. Cut 2 protected the scale and the +// printer while `internal/web` could have imported `internal/catalog/localdrop` without a +// word — the same class of defect as the cut that was announced for six lots and switched +// off, one plug-in point over (ADR-052). +var registryTypes = map[string]string{ + modulePath + "/internal/scale": "Driver", + modulePath + "/internal/printing": "Driver", + modulePath + "/internal/catalog": "Source", +} + +// registryTypeNames spells those types the way the failure message reads them aloud, +// sorted so that two runs cannot phrase the same refusal differently. +func registryTypeNames() string { + names := make([]string, 0, len(registryTypes)) + for pkg, typeName := range registryTypes { + names = append(names, shortName(pkg)+"."+typeName) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// modulePath is the module of go.mod, which is what turns a directory into an import +// path. +const modulePath = "openscale" + +// compositionRoot is the ONE file §5.2 allows to name a driver package. +const compositionRoot = "cmd/openscale/drivers.go" + +// driverPackages maps each driver package to the declaration that makes it one. +// +// The declaration is carried along because it is what the failure message shows: a +// developer told « preview is a driver package » looks for the reason, and « func +// Driver() printing.Driver » is the whole of it. +func driverPackages(root string) (map[string]string, error) { + found := make(map[string]string) + err := walkGoFiles(root, func(relative, path string) error { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("%s : %v", relative, err) + } + aliases := importAliases(file) + for _, decl := range file.Decls { + entry, offers := registryEntry(decl, aliases) + if !offers { + continue + } + pkg := packagePath(relative) + if _, already := found[pkg]; !already { + found[pkg] = entry + } + } + return nil + }) + if err != nil { + return nil, err + } + return found, nil +} + +// registryEntry reports the declaration by which a package OFFERS a registry entry. +// +// Exported only, and a RESULT rather than a parameter: internal/scale/corpus takes a +// scale.Driver to run a corpus against it, which makes it a bench and not a driver. +func registryEntry(decl ast.Decl, aliases map[string]string) (string, bool) { + switch d := decl.(type) { + case *ast.FuncDecl: + if d.Recv != nil || !d.Name.IsExported() || d.Type.Results == nil { + return "", false + } + for _, result := range d.Type.Results.List { + if named, is := registryType(result.Type, aliases); is { + return "func " + d.Name.Name + "() " + named, true + } + } + case *ast.GenDecl: + if d.Tok != token.VAR && d.Tok != token.CONST { + return "", false + } + for _, spec := range d.Specs { + value, isValue := spec.(*ast.ValueSpec) + if !isValue || !anyExported(value.Names) { + continue + } + if named, is := registryType(value.Type, aliases); is { + return "var " + value.Names[0].Name + " " + named, true + } + for _, assigned := range value.Values { + literal, isLiteral := assigned.(*ast.CompositeLit) + if !isLiteral { + continue + } + if named, is := registryType(literal.Type, aliases); is { + return "var " + value.Names[0].Name + " = " + named + "{…}", true + } + } + } + } + return "", false +} + +// registryType reports the registry type an expression names, slices and pointers +// unwrapped — gramxfoc.Drivers returns []scale.Driver, and two entries in one slice are +// two registry entries. +func registryType(expr ast.Expr, aliases map[string]string) (string, bool) { + prefix := "" + for { + switch node := expr.(type) { + case *ast.ArrayType: + prefix, expr = prefix+"[]", node.Elt + case *ast.StarExpr: + prefix, expr = prefix+"*", node.X + case *ast.SelectorExpr: + qualifier, named := node.X.(*ast.Ident) + if !named { + return "", false + } + if want, isRegistry := registryTypes[aliases[qualifier.Name]]; isRegistry && + node.Sel.Name == want { + return prefix + qualifier.Name + "." + want, true + } + return "", false + default: + return "", false + } + } +} + +// importAliases maps the name a file calls each import by to its path, so that an +// aliased import is read for what it is rather than for what it is spelled. +func importAliases(file *ast.File) map[string]string { + aliases := make(map[string]string, len(file.Imports)) + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + name := shortName(path) + if spec.Name != nil { + name = spec.Name.Name + } + aliases[name] = path + } + return aliases +} + +// anyExported reports whether a declaration puts at least one name outside its package. +func anyExported(names []*ast.Ident) bool { + for _, name := range names { + if name.IsExported() { + return true + } + } + return false +} + +// packagePath turns the path of a file, relative to the root and slash-separated, into +// the import path of the package holding it. +func packagePath(relative string) string { + return modulePath + "/" + path.Dir(relative) +} diff --git a/tools/boundary/main.go b/tools/boundary/main.go index d2843d1..0ce14ca 100644 --- a/tools/boundary/main.go +++ b/tools/boundary/main.go @@ -13,36 +13,15 @@ package main import ( "fmt" - "go/ast" - "go/parser" - "go/token" "os" - "os/exec" - "path" "path/filepath" - "sort" - "strconv" "strings" ) -// forbiddenInDomain are the packages the business core may not reach, directly or -// transitively. Not a style rule: it is what makes the core testable with nothing -// to simulate, and replayable offline from the journal. -var forbiddenInDomain = []string{"net/http", "database/sql", "os"} - -// clockAllowList names the ONLY two places allowed to read the real clock, and -// why. Everything else receives ports.Clock by injection. -// -// The path separator is a slash: the comparison normalizes it on every OS. -var clockAllowList = map[string]string{ - // The real implementation of Clock, which IS the call to time.Now, once, at - // the only place meant for it. - "internal/platform/clock.go": "the single real implementation of ports.Clock", - // An I/O deadline set in the TCP stack of the OS kernel, which no fake clock - // can drive. It carries no business decision: it bounds a write towards a - // zombie browser. - "internal/web/stream.go": "rc.SetWriteDeadline on a network write", -} +// This file is the program: it finds the repository root, runs the three checks and +// reports. Each check lives in the file named after what it protects — domain.go for +// cut 1, clock.go for cut 1 bis, drivers.go for cut 2 — and the walk they share is at +// the bottom of this one. func main() { failures := 0 @@ -92,424 +71,10 @@ func repositoryRoot() (string, error) { } } -// checkDomainImports is cut 1: the business core has NO outgoing dependency on -// the outside world. -// -// WHY NOT `go list -deps`, which is what §5.2 prescribes: the transitive closure -// of the core contains os, and always will, because `fmt` imports it — fmt.Println -// writes to os.Stdout. Taken literally the rule would forbid fmt.Errorf, which -// opens no file and performs no I/O, and the check would be either always red or -// quietly disabled. Neither is a boundary. -// -// What is verified instead, and what actually protects the invariant: -// -// 1. no package under internal/domain IMPORTS os, net/http or database/sql -// directly; -// 2. the rule follows OUR OWN packages transitively — a helper of ours that -// looked harmless and pulled database/sql would be caught, which is the real -// risk a grep would miss. -// -// The standard library is trusted not to perform I/O behind fmt and sort. That is -// a deliberate limit of this check, written down so that nobody mistakes it for an -// oversight. -func checkDomainImports(report func(string, ...any)) { - imports, err := directImports() - if err != nil { - report("%v", err) - return - } - - const modulePrefix = "openscale/" - visited := make(map[string]bool) - var walk func(pkg, path string) - walk = func(pkg, path string) { - if visited[pkg] { - return - } - visited[pkg] = true - for _, imported := range imports[pkg] { - for _, forbidden := range forbiddenInDomain { - if imported == forbidden { - report("%s importe %q — coupe 1 : le noyau métier n'a aucune dépendance sortante%s", - pkg, forbidden, path) - break - } - } - // Follow our own packages only: the standard library is trusted. - if strings.HasPrefix(imported, modulePrefix) { - walk(imported, path+"\n (atteint depuis "+pkg+")") - } - } - } - for pkg := range imports { - if strings.HasPrefix(pkg, modulePrefix+"internal/domain") { - walk(pkg, "") - } - } -} - -// directImports lists every package of the module with the packages it imports -// itself, tests excluded: a test that reads a fixture with os.ReadFile is not the -// production path. -func directImports() (map[string][]string, error) { - out, err := exec.Command("go", "list", "-f", "{{.ImportPath}} {{join .Imports \" \"}}", "./...").Output() - if err != nil { - return nil, fmt.Errorf("`go list` a échoué : %v", err) - } - imports := make(map[string][]string) - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - imports[fields[0]] = fields[1:] - } - return imports, nil -} - -// checkNoClockReads is cut 1 bis: no call to time.Now anywhere under internal/, -// outside the two named exceptions. -// -// A lost tick must never be able to UNDER-count the age of a measurement and let -// an expired weight print. That is bloquant-1, and it only stays fixed if the CI -// keeps saying so. -func checkNoClockReads(root string, report func(string, ...any)) { - err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() || !strings.HasSuffix(path, ".go") { - return nil - } - // Test files may read the real clock: they are not the production path, - // and a test that measures its own wall time is legitimate. - if strings.HasSuffix(path, "_test.go") { - return nil - } - relative, err := filepath.Rel(root, path) - if err != nil { - return err - } - relative = filepath.ToSlash(relative) - if _, allowed := clockAllowList[relative]; allowed { - return nil - } - - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) - if err != nil { - report("%s : %v", relative, err) - return nil - } - ast.Inspect(file, func(n ast.Node) bool { - call, ok := n.(*ast.CallExpr) - if !ok { - return true - } - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - pkg, ok := selector.X.(*ast.Ident) - if !ok || pkg.Name != "time" { - return true - } - // Now, Since and Until all read the real clock. - switch selector.Sel.Name { - case "Now", "Since", "Until", "NewTicker", "NewTimer", "Tick", "After", "AfterFunc": - position := fset.Position(call.Pos()) - report("%s:%d : appel à time.%s — coupe 1 bis : l'horloge est injectée (ports.Clock), "+ - "les deux seules exceptions sont %s", - relative, position.Line, selector.Sel.Name, allowListNames()) - } - return true - }) - return nil - }) - if err != nil && !os.IsNotExist(err) { - report("parcours de internal/ : %v", err) - } -} - -func allowListNames() string { - names := make([]string, 0, len(clockAllowList)) - for path := range clockAllowList { - names = append(names, path) - } - return strings.Join(names, " et ") -} - -// checkDriverImports is cut 2: ONE file of the tree names a concrete driver, and it is -// the composition root. -// -// WHAT A DRIVER PACKAGE IS, AND WHY IT IS NOT A LIST OF NAMES. The cut bears on what is -// REGISTRABLE: a package that offers a value the registry of §9.3 or §8.1 accepts — -// scale.Driver, printing.Driver — is a driver package, and every other package under -// internal/ is not. That definition is what the walk below computes, so the check needs -// no maintenance when a model is added and cannot be widened by adding a name to a list. -// -// It also settles, without an exception, the four packages a path-based rule would have -// had to argue about: -// -// - internal/scale/serial is the READER LOOP shared by every serial model — the byte -// layer, exactly like internal/printing/transport on the other side, which the -// composition root builds and hands over. It registers nothing; -// - internal/scale/replay parses the corpus format. §9.3 keeps `replay` out of the -// registry BY NAME: it is a diagnostic tool, not a weighing protocol; -// - internal/scale/absent is the STATE a station enters when scale.present is false, -// and scale.Registry.Register panics on a driver that tried to call itself that; -// - internal/printing/preview IS a driver package, and the two encoders that used to -// live in it are now printing.EncodePNG and printing.EncodePDF. They were called by -// the aperçu route and by `openscale label`, neither of which prints anything, and -// an encoder held inside a driver's package is what forced those two files to import -// a driver (internal/printing/encode.go says it again). -// -// TEST FILES ARE OUT, deliberately, and the reason is the same one directImports gives -// for excluding them from cut 1: they are not the production path. What cut 2 protects -// is the wiring of the BINARY — that a model can be removed by deleting one package and -// one line — and a _test.go file is in no binary. Forbidding them would also cost -// something real: cmd/openscale/admin_test.go declares scale.type as gramxfoc.IDRS -// rather than as the literal "gram-xfoc-rs", which is a test that breaks the day the ID -// moves instead of one that goes on passing against a protocol nobody carries any more. -func checkDriverImports(root string, report func(string, ...any)) { - drivers, err := driverPackages(root) - if err != nil { - report("%v", err) - return - } - // A check that finds nothing to protect is a check that has stopped running, and - // this one spent six lots switched off saying nothing. If the registry types are - // ever renamed, this is what says so rather than a silent pass. - if len(drivers) == 0 { - report("coupe 2 : aucun paquet driver trouvé sous internal/.\n"+ - " Un paquet driver est un paquet qui expose une entrée de registre — une déclaration\n"+ - " exportée de l'un des types %s. Si ces types ont été renommés,\n"+ - " renommez-les aussi dans tools/boundary/main.go : sans cela la coupe 2 passe au vert\n"+ - " sur n'importe quoi.", registryTypeNames()) - return - } - - err = walkGoFiles(root, func(relative, path string) error { - if relative == compositionRoot { - return nil - } - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.SkipObjectResolution) - if err != nil { - report("%s : %v", relative, err) - return nil - } - for _, spec := range file.Imports { - imported, err := strconv.Unquote(spec.Path.Value) - if err != nil { - continue - } - if entry, isDriver := drivers[imported]; isDriver { - report(cut2Violation, relative, fset.Position(spec.Pos()).Line, imported, - imported, entry, compositionRoot) - } - } - return nil - }) - if err != nil { - report("parcours des sources Go : %v", err) - } -} - -// cut2Violation is written to be acted upon by somebody — or something — that arrives -// with no other context: what is forbidden, what to do instead, and a file to imitate. -const cut2Violation = "%s:%d importe %s — coupe 2 : un seul fichier de l'arbre nomme un driver concret.\n" + - " %s est un paquet driver parce qu'il expose une entrée de registre (%s).\n" + - " Selon ce dont ce fichier a besoin :\n" + - " 1. un driver INSTANCIÉ — il ne le construit pas. Il reçoit un ports.Scale, un\n" + - " ports.Printer ou un ports.CatalogSource que la racine de composition lui passe ;\n" + - " cmd/openscale/serve.go montre le câblage.\n" + - " 2. une fonction que ce paquet héberge SANS QU'ELLE SOIT LE DRIVER — elle n'a rien à\n" + - " faire là. Remontez-la dans internal/printing, internal/scale ou internal/catalog,\n" + - " qui ne sont pas des paquets drivers, et importez celui-là. printing.EncodePNG a\n" + - " fait exactement ce trajet : internal/printing/encode.go dit pourquoi.\n" + - " 3. ENREGISTRER un driver de plus — la ligne va dans scaleRegistry, printerRegistry ou\n" + - " catalogSourceRegistry de %s, et nulle part ailleurs. C'est la « ONE LINE »\n" + - " de §5.2.\n" + - " La liste des paquets drivers n'est écrite nulle part et ne s'allonge pas à la main :\n" + - " c'est tout paquet qui expose une valeur scale.Driver, printing.Driver ou\n" + - " catalog.Source." - -// The packages that DEFINE what a driver is, and the type each one accepts into its -// registry. None can match itself: inside them the type is spelled without a qualifier, -// and what is looked for is the QUALIFIED name a package from outside has to write. -// -// A TABLE and not three tests, because that is the whole idea of cut 2: it COMPUTES what -// a driver package is instead of reading a list of names. A plug-in point added to §5.2 -// is one entry here, and the walk, the failure message and the refusal to find nothing all -// follow from it. -// -// catalog.Source is the third, and it was missing. Cut 2 protected the scale and the -// printer while `internal/web` could have imported `internal/catalog/localdrop` without a -// word — the same class of defect as the cut that was announced for six lots and switched -// off, one plug-in point over (ADR-052). -var registryTypes = map[string]string{ - modulePath + "/internal/scale": "Driver", - modulePath + "/internal/printing": "Driver", - modulePath + "/internal/catalog": "Source", -} - -// registryTypeNames spells those types the way the failure message reads them aloud, -// sorted so that two runs cannot phrase the same refusal differently. -func registryTypeNames() string { - names := make([]string, 0, len(registryTypes)) - for pkg, typeName := range registryTypes { - names = append(names, shortName(pkg)+"."+typeName) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - -// modulePath is the module of go.mod, which is what turns a directory into an import -// path. -const modulePath = "openscale" - -// compositionRoot is the ONE file §5.2 allows to name a driver package. -const compositionRoot = "cmd/openscale/drivers.go" - // goTrees are the directories of the module that hold Go source. Named rather than // walked from the root so that neither web/node_modules nor testdata is parsed. var goTrees = []string{"cmd", "internal", "tools"} -// driverPackages maps each driver package to the declaration that makes it one. -// -// The declaration is carried along because it is what the failure message shows: a -// developer told « preview is a driver package » looks for the reason, and « func -// Driver() printing.Driver » is the whole of it. -func driverPackages(root string) (map[string]string, error) { - found := make(map[string]string) - err := walkGoFiles(root, func(relative, path string) error { - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) - if err != nil { - return fmt.Errorf("%s : %v", relative, err) - } - aliases := importAliases(file) - for _, decl := range file.Decls { - entry, offers := registryEntry(decl, aliases) - if !offers { - continue - } - pkg := packagePath(relative) - if _, already := found[pkg]; !already { - found[pkg] = entry - } - } - return nil - }) - if err != nil { - return nil, err - } - return found, nil -} - -// registryEntry reports the declaration by which a package OFFERS a registry entry. -// -// Exported only, and a RESULT rather than a parameter: internal/scale/corpus takes a -// scale.Driver to run a corpus against it, which makes it a bench and not a driver. -func registryEntry(decl ast.Decl, aliases map[string]string) (string, bool) { - switch d := decl.(type) { - case *ast.FuncDecl: - if d.Recv != nil || !d.Name.IsExported() || d.Type.Results == nil { - return "", false - } - for _, result := range d.Type.Results.List { - if named, is := registryType(result.Type, aliases); is { - return "func " + d.Name.Name + "() " + named, true - } - } - case *ast.GenDecl: - if d.Tok != token.VAR && d.Tok != token.CONST { - return "", false - } - for _, spec := range d.Specs { - value, isValue := spec.(*ast.ValueSpec) - if !isValue || !anyExported(value.Names) { - continue - } - if named, is := registryType(value.Type, aliases); is { - return "var " + value.Names[0].Name + " " + named, true - } - for _, assigned := range value.Values { - literal, isLiteral := assigned.(*ast.CompositeLit) - if !isLiteral { - continue - } - if named, is := registryType(literal.Type, aliases); is { - return "var " + value.Names[0].Name + " = " + named + "{…}", true - } - } - } - } - return "", false -} - -// registryType reports the registry type an expression names, slices and pointers -// unwrapped — gramxfoc.Drivers returns []scale.Driver, and two entries in one slice are -// two registry entries. -func registryType(expr ast.Expr, aliases map[string]string) (string, bool) { - prefix := "" - for { - switch node := expr.(type) { - case *ast.ArrayType: - prefix, expr = prefix+"[]", node.Elt - case *ast.StarExpr: - prefix, expr = prefix+"*", node.X - case *ast.SelectorExpr: - qualifier, named := node.X.(*ast.Ident) - if !named { - return "", false - } - if want, isRegistry := registryTypes[aliases[qualifier.Name]]; isRegistry && - node.Sel.Name == want { - return prefix + qualifier.Name + "." + want, true - } - return "", false - default: - return "", false - } - } -} - -// importAliases maps the name a file calls each import by to its path, so that an -// aliased import is read for what it is rather than for what it is spelled. -func importAliases(file *ast.File) map[string]string { - aliases := make(map[string]string, len(file.Imports)) - for _, spec := range file.Imports { - path, err := strconv.Unquote(spec.Path.Value) - if err != nil { - continue - } - name := shortName(path) - if spec.Name != nil { - name = spec.Name.Name - } - aliases[name] = path - } - return aliases -} - -// anyExported reports whether a declaration puts at least one name outside its package. -func anyExported(names []*ast.Ident) bool { - for _, name := range names { - if name.IsExported() { - return true - } - } - return false -} - -// packagePath turns the path of a file, relative to the root and slash-separated, into -// the import path of the package holding it. -func packagePath(relative string) string { - return modulePath + "/" + path.Dir(relative) -} - // shortName is the last element of an import path, which is what a file calls it by // when it declares no alias. func shortName(importPath string) string { diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..2e1799c --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,89 @@ +# Ce que Prettier ne doit pas toucher. +# +# `internal/web/dist` est le bundle COMMITÉ (§14.1) : il est produit par vite, jamais +# écrit à la main, et le reformater ferait diverger les octets livrés de ceux que la CI +# reconstruit. Il est hors de ce dossier, donc hors de portée d'un `prettier .` lancé +# ici ; la ligne est écrite pour celui qui le lancera un jour depuis la racine du dépôt. +../internal/web/dist/ + +node_modules/ + +# Les données de banc : des exports Odoo réels et des tables de référence. Ce sont des +# pièces à conviction, et leur forme fait foi contre toute documentation. +testdata/ +public/ + +# --------------------------------------------------------------------------------- +# LA DETTE, nommée fichier par fichier — et c'est un CLIQUET, pas une amnistie. +# +# Prettier arrive sur un dépôt écrit à la main. Le style de base coïncide déjà — pas de +# point-virgule, guillemets simples, 100 colonnes — et l'écart tient au seul retour à la +# ligne : 543 lignes sur 33 fichiers de `src`, 21 fichiers de plus dans `test`. Les +# reformater d'un coup noierait le lot de rangement qui vient d'être livré, et rendrait +# illisible la comparaison de DOM qui prouve qu'il n'a rien changé. +# +# Ces 55 fichiers sont donc EXEMPTÉS, et rien d'autre : tout fichier créé à partir +# d'aujourd'hui est vérifié. Le jour où l'un d'eux est reformaté — `npx prettier --write` +# sur lui seul — sa ligne part d'ici, et la liste se vide sans qu'aucun lot n'ait à +# s'arrêter pour ça. +# +# Ce qu'il reste à faire tient en une commande : +# npx prettier --write . +# et en une vérification : les bancs, et la comparaison de DOM du lot de rangement. +# --------------------------------------------------------------------------------- +src/admin/App.svelte +src/admin/components/CatalogSourcePanel.svelte +src/admin/components/FindingsPanel.svelte +src/admin/components/ImportFaults.svelte +src/admin/components/Light.svelte +src/admin/components/Maintenance.svelte +src/admin/components/PasswordPanel.svelte +src/admin/components/SafeguardList.svelte +src/admin/components/TierGrid.svelte +src/admin/components/WaiverList.svelte +src/admin/lib/api.ts +src/admin/lib/config-file.ts +src/admin/lib/dto.ts +src/admin/lib/format.ts +src/admin/lib/inventory.ts +src/admin/lib/lights.ts +src/admin/lib/read-state.ts +src/admin/lib/safeguards.ts +src/admin/pages/Catalog.svelte +src/admin/pages/Dashboard.svelte +src/admin/pages/Hardware.svelte +src/admin/pages/Journal.svelte +src/admin/pages/Label.svelte +src/admin/pages/Rules.svelte +src/admin/pages/Station.svelte +src/admin/pages/Troubleshooting.svelte +src/admin/pages/Update.svelte +src/app.css +src/App.svelte +src/components/Icon.svelte +src/components/SearchField.svelte +src/components/Tile.svelte +src/lib/color.ts +src/main.ts +test/admin-act.test.ts +test/admin-catalog.test.ts +test/admin-hardware.test.ts +test/admin-inventory.test.ts +test/admin-journal.test.ts +test/admin-lights.test.ts +test/admin-rules.test.ts +test/admin-source.test.ts +test/admin-station.test.ts +test/admin-troubleshooting.test.ts +test/admin-two-levels.test.ts +test/catalog-fixture.test.ts +test/chips.test.ts +test/client-health.test.ts +test/fixtures/odoo.ts +test/layers.test.ts +test/normalize.test.ts +test/screen.test.ts +test/tokens.test.ts +test/typography.test.ts +test/unit-products.test.ts + diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..bd2741b --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..da8dd79 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,285 @@ +import js from '@eslint/js' +import svelte from 'eslint-plugin-svelte' +import globals from 'globals' +import svelteParser from 'svelte-eslint-parser' +import tseslint from 'typescript-eslint' + +/** + * Le linter des deux écrans — celui du client et celui de l'administration. + * + * Il existe pour une raison précise, et elle n'est pas cosmétique : `svelte-check` ne + * vérifie que des TYPES, et rien dans ce dépôt ne regardait jusqu'ici une promesse qu'on + * oublie d'attendre, une condition qui ne peut pas être fausse, ou un `${}` posé sur un + * objet qui rendra « [object Object] » à un bénévole. + * + * Le jeu est TYPÉ (`…TypeChecked`) : sans le type, la moitié de ce qui précède est + * indécidable. Il coûte une passe de compilation à chaque exécution, ce qui est le prix + * du seul contrôle capable de voir ces trois-là. + * + * DEUX RÉGIMES, et c'est délibéré : + * + * - `src/` est du code LIVRÉ, tenu au jeu strict ; + * - `test/` est un banc, tenu au jeu recommandé. Un banc affirme volontiers ce que le + * strict interdit — `as HTMLElement` sur ce qu'il vient de poser dans le DOM, une + * comparaison que le type dit toujours vraie mais qui est justement ce qu'il vérifie. + * Les mesures sont dans le rapport du lot : 196 des 281 signalements du jeu strict + * tombaient là, et pas un ne décrivait un défaut. + */ +export default tseslint.config( + { + ignores: [ + 'node_modules/**', + 'public/**', + 'testdata/**', + // Le bundle COMMITÉ (§14.1). Il est produit par vite, pas écrit à la main. + '../internal/web/dist/**', + ], + }, + + js.configs.recommended, + svelte.configs.recommended, + + { + languageOptions: { + parserOptions: { + // Les fichiers d'outillage ne sont dans aucun `include` du tsconfig : ils sont + // nommés ici plutôt que d'être laissés hors du linter. + projectService: { + allowDefaultProject: ['*.js', '*.ts', 'scripts/*.mjs'], + }, + // Sans quoi le service de types ne sait pas quoi faire d'un `.svelte`, et + // chaque composant sort une erreur d'analyse au lieu d'un verdict. + extraFileExtensions: ['.svelte'], + tsconfigRootDir: import.meta.dirname, + }, + globals: { ...globals.browser }, + }, + }, + + // Le code livré : le jeu strict. + { + files: ['src/**/*.ts', 'src/**/*.svelte'], + extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], + rules: { + /* + * Les quatre règles que ce dépôt NE PEUT PAS tenir, avec ce qu'elles coûtaient. + * Chacune est écartée pour une raison de conception, pas pour avoir la paix. + */ + + // 25 signalements. Le document de configuration voyage EXACTEMENT comme le fichier + // l'écrit (§11.4) : `Draft.value` rend un `unknown`, et `String(unknown)` est ce que + // `Draft.text` fait par contrat. La règle voit « [object Object] » là où le poste + // a déjà refusé tout ce qui n'est pas une valeur scalaire. + '@typescript-eslint/no-base-to-string': 'off', + + // 8 signalements, et ce sont des GARDE-FOUS VOULUS. `body.retired_keys ?? []` est + // documenté sur place : « bien que le service ne serve plus null — ce poste peut + // tourner un binaire plus ancien, et c'est exactement ce null qui rendait + // l'administration inatteignable ». Le type décrit le binaire d'aujourd'hui ; le + // navigateur, lui, parle à celui qui est installé. + '@typescript-eslint/no-unnecessary-condition': 'off', + + // 6 signalements, purement stylistiques : `as HTMLElement` contre `!` sur les deux + // points de montage. Aucun des deux ne vérifie quoi que ce soit à l'exécution. + '@typescript-eslint/non-nullable-type-assertion-style': 'off', + + // 16 signalements, tous des NOMBRES. Ce qui doit rester interdit dans un `${}` est + // l'objet — c'est lui qui rend « [object Object] » à un bénévole ; un entier rend + // ses chiffres. La règle est donc gardée, et l'option dit lesquels sont sûrs. + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], + + // 1 signalement, et c'est la convention que le dépôt suit déjà : `_` nomme ce + // qu'une déstructuration doit sauter. La règle reste entière, on lui apprend le nom. + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'all' }, + ], + + /* + * LA DETTE DE TYPAGE, nommée plutôt que tue — 11 signalements en tout. + * + * Elles décrivent toutes le même point : un `any` qui entre par une frontière non + * typée — `JSON.parse` d'une réponse, une valeur d'un `Object.entries`, la propriété + * `stack` d'un `catch`. Ce sont de vraies faiblesses, et aucune n'est un défaut + * visible : le poste refuse déjà ce qui n'a pas la forme attendue, côté service. + * + * Les rendre rouges aujourd'hui demanderait de retyper huit fichiers que ce lot n'a + * pas ouverts — dont l'écran client, qui n'est pas dans son périmètre. Elles sont + * donc écartées ICI et nulle part ailleurs : le jour où ces frontières sont typées, + * ce bloc disparaît d'un coup. + */ + '@typescript-eslint/no-unsafe-argument': 'off', // 5, dont 3 sur un aperçu d'étiquette + '@typescript-eslint/no-unsafe-return': 'off', // 2, App.svelte + '@typescript-eslint/no-unsafe-assignment': 'off', // 2, App.svelte + '@typescript-eslint/no-unsafe-member-access': 'off', // 1, error-net.ts + '@typescript-eslint/restrict-plus-operands': 'off', // 3, même origine + + /* + * LES SEPT DERNIÈRES, toutes stylistiques, une occurrence ou deux chacune. + * + * Aucune ne décrit un comportement : elles proposent une autre écriture de la même + * chose. Les tenir coûterait de retoucher `App.svelte`, `Grid.svelte`, `Tile.svelte` + * et `typography.ts` — quatre fichiers de l'écran CLIENT, celui qui tourne toute la + * journée devant un client, pour un gain de forme et zéro gain de sens. + */ + '@typescript-eslint/prefer-optional-chain': 'off', // 4 + '@typescript-eslint/no-confusing-void-expression': 'off', // 4 dans les `.ts` + '@typescript-eslint/prefer-regexp-exec': 'off', // 1, Tile.svelte + '@typescript-eslint/no-useless-default-assignment': 'off', // 1, Act.svelte + '@typescript-eslint/no-dynamic-delete': 'off', // 1, et `Draft.unset` EST dynamique + '@typescript-eslint/no-empty-function': 'off', // 1, un `onpick` de sonde qui ne fait rien + '@typescript-eslint/no-misused-spread': 'off', // 1, typography.ts découpe de l'ASCII + 'no-useless-assignment': 'off', // 1, typography.ts + }, + }, + + // Les composants : ce que la règle de style TypeScript ne sait pas lire d'un gabarit. + { + files: ['src/**/*.svelte'], + rules: { + /* + * 64 signalements, et pas un défaut parmi eux. + * + * `onclick={() => (dropping = false)}` est l'idiome d'un gestionnaire Svelte : + * l'affectation vaut ce qu'elle affecte, et la flèche courte la renvoie. La règle + * est écrite pour du TypeScript applicatif, où une fonction qui rend une valeur par + * accident est un signal ; ici elle demanderait d'entourer d'accolades soixante- + * quatre gestionnaires pour ne rien apprendre à personne. + * + * Elle reste ACTIVE sur les `.ts` : c'est là qu'elle attrape quelque chose. + */ + '@typescript-eslint/no-confusing-void-expression': 'off', + + /* + * 2 signalements, et la boucle sans clé est DÉLIBÉRÉE — le commentaire est dans + * `FindingsPanel.svelte` : une ligne de CSV porte autant de signalements qu'elle a + * de problèmes, donc `csv_line` n'est pas une clé, et un `each` clé dessus lèverait + * `each_key_duplicate` sur le premier fichier venu, emportant tout l'écran. + */ + 'svelte/require-each-key': 'off', + + /* + * 2 signalements. Les `Set` visés sont LOCAUX à un calcul — compter les rangées qui + * portent un nom au plancher — et meurent avec lui. `SvelteSet` sert à un état + * réactif partagé, ce qu'aucun des deux n'est. + */ + 'svelte/prefer-svelte-reactivity': 'off', + }, + }, + + /* + * Les bancs : le jeu recommandé, et six règles de moins. + * + * Un banc affirme volontiers ce que le strict interdit, et il le fait exprès : une + * assertion sur ce qu'il vient de poser dans le DOM, un `async` sans `await` pour + * fabriquer une promesse, une promesse lâchée dont il vérifie justement qu'elle ne + * casse rien. 46 des 64 signalements restants tombaient là, et pas un ne décrivait un + * défaut. `web/test/` appartient d'ailleurs à quelqu'un d'autre : ce lot n'y touche pas. + */ + { + files: ['test/**/*.ts'], + extends: [tseslint.configs.recommendedTypeChecked], + languageOptions: { globals: { ...globals.node } }, + rules: { + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/no-base-to-string': 'off', + 'no-useless-assignment': 'off', + }, + }, + + // L'outillage : il tourne sous Node, et il n'est pas typé. + { + files: ['scripts/**/*.mjs', 'eslint.config.js', 'svelte.config.js', 'vite.config.ts'], + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { globals: { ...globals.node } }, + }, + + /* + * LE CLIQUET DE TAILLE — la seule règle de ce fichier qui ne vient d'aucun preset. + * + * Elle répond à la question qui a motivé ce lot : qu'est-ce qui empêche une page de + * regonfler ? Rien, jusqu'ici. Une page de deux mille lignes n'est pas mauvaise parce + * qu'elle est longue, elle est mauvaise parce qu'on ne sait plus où regarder — et elle + * y arrive une centaine de lignes à la fois, sans que personne ne décide rien. + * + * Les plafonds sont MESURÉS sur l'arbre du jour, pas choisis : le plus gros fichier de + * chaque groupe, arrondi au-dessus avec environ un quart de marge. Ils ne demandent + * donc rien à personne aujourd'hui, et ils refusent le doublement de demain. + * + * `skipComments` est délibéré : ce dépôt écrit ses raisons dans le code, et une règle + * qui compterait la prose punirait exactement ce qu'il faut encourager. + * + * Chaque chiffre n'est écrit QU'UNE FOIS, à côté du plafond qu'il justifie : un + * compteur recopié dans un second endroit finit toujours par mentir. + */ + // Un composant d'administration : le plus gros en porte 209 (Maintenance.svelte). + { + files: ['src/admin/components/**/*.svelte'], + rules: { 'max-lines': ['error', { max: 260, skipComments: true, skipBlankLines: true }] }, + }, + // Un module d'administration : le plus gros en porte 272 (lights.ts). + { + files: ['src/admin/lib/**/*.ts'], + rules: { 'max-lines': ['error', { max: 320, skipComments: true, skipBlankLines: true }] }, + }, + // Un composant de l'écran client : le plus gros en porte 321 (Tile.svelte). + { + files: ['src/components/**/*.svelte'], + rules: { 'max-lines': ['error', { max: 400, skipComments: true, skipBlankLines: true }] }, + }, + // Un module de l'écran client : le plus gros en porte 133 (dto.ts). + { + files: ['src/lib/**/*.ts'], + rules: { 'max-lines': ['error', { max: 200, skipComments: true, skipBlankLines: true }] }, + }, + // Les deux coquilles, celle du client et celle de l'administration : 238 et 416. + { + files: ['src/App.svelte', 'src/admin/App.svelte'], + rules: { 'max-lines': ['error', { max: 520, skipComments: true, skipBlankLines: true }] }, + }, + /* + * Une page d'administration : `Catalog.svelte` en porte 1032, et c'est le plafond qui a + * le moins de marge de tout ce fichier — six pour cent. + * + * POURQUOI CE CHIFFRE EST CE QU'IL EST. Ce qui reste dans cette page n'y est pas resté + * par paresse : l'aperçu de grille et ses sondes, la zone de dépôt et les cinq actes + * protégés sont retenus par des bancs qui lisent le TEXTE SOURCE du fichier — jusqu'à + * `draftedFlag(\n 'ui.show_grid_prices',` à l'indentation près. Tant + * qu'un banc épingle la forme du code plutôt que son comportement, la page ne peut pas + * maigrir davantage. + * + * Le plafond descend donc le jour où ces bancs interrogeront le DOM plutôt que le + * fichier, et il ne monte jamais. + */ + { + files: ['src/admin/pages/**/*.svelte'], + rules: { 'max-lines': ['error', { max: 1100, skipComments: true, skipBlankLines: true }] }, + }, + + /* + * LE PARSER SVELTE VIENT EN DERNIER, et l'ordre n'est pas indifférent. + * + * Chaque `extends` de typescript-eslint repose son propre parser dans + * `languageOptions` ; posé plus haut, celui de Svelte était donc écrasé pour les + * fichiers auxquels le jeu strict s'applique, et chaque composant sortait un + * « '>' expected » — le parser TypeScript essayant de lire du markup. Quarante + * fichiers dans ce cas, tous les `.svelte` du dépôt. + */ + { + files: ['**/*.svelte', '**/*.svelte.ts'], + languageOptions: { + parser: svelteParser, + parserOptions: { + parser: tseslint.parser, + projectService: true, + extraFileExtensions: ['.svelte'], + tsconfigRootDir: import.meta.dirname, + svelteConfig: './svelte.config.js', + }, + }, + }, +) diff --git a/web/package-lock.json b/web/package-lock.json index 266d846..fe455ee 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,14 +9,22 @@ "version": "0.0.0", "license": "AGPL-3.0-or-later", "devDependencies": { + "@eslint/js": "10.0.1", "@fontsource-variable/inter": "5.3.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tsconfig/svelte": "5.0.8", "@types/node": "26.1.1", + "eslint": "10.8.0", + "eslint-plugin-svelte": "3.22.0", + "globals": "17.9.0", "jsdom": "29.1.1", + "prettier": "3.9.6", + "prettier-plugin-svelte": "4.1.1", "svelte": "5.56.8", "svelte-check": "4.7.3", + "svelte-eslint-parser": "1.8.0", "typescript": "5.9.3", + "typescript-eslint": "8.65.0", "vite": "7.3.6", "vitest": "4.1.10" } @@ -667,6 +675,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -695,6 +831,72 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1186,6 +1388,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1193,6 +1402,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1210,6 +1426,236 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1336,6 +1782,33 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1366,6 +1839,16 @@ "node": ">= 0.4" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1376,6 +1859,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1419,6 +1915,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1433,20 +1944,51 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1454,6 +1996,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1533,6 +2082,157 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.22.0.tgz", + "integrity": "sha512-O3qn0NePTWta+1o25dIThqeEP/hEQ3VxDK2LVO8SQ5wG9umLMvulK+m1yQ4JGOb2Pkl8IB0G1lpRV/HXDXSLTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1540,6 +2240,37 @@ "dev": true, "license": "MIT" }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/esrap": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", @@ -1558,6 +2289,29 @@ } } }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1568,6 +2322,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1578,6 +2342,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1596,6 +2381,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1611,6 +2447,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1624,6 +2486,49 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1641,6 +2546,13 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -1682,6 +2594,68 @@ } } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1689,6 +2663,22 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -1716,6 +2706,22 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1726,6 +2732,13 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1738,25 +2751,82 @@ } ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">=12.20.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse5": { @@ -1772,6 +2842,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1828,6 +2918,154 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1933,6 +3171,42 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2017,6 +3291,85 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", + "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.1" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2114,6 +3467,32 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2128,6 +3507,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -2145,6 +3548,23 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2378,6 +3798,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2395,6 +3831,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -2412,6 +3858,37 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/web/package.json b/web/package.json index 3cfc825..87b4051 100644 --- a/web/package.json +++ b/web/package.json @@ -8,18 +8,28 @@ "build": "vite build", "dev": "vite", "check": "svelte-check --tsconfig ./tsconfig.json", + "lint": "eslint .", + "format:check": "prettier --check .", "test": "vitest run", "budget": "node scripts/budget.mjs" }, "devDependencies": { + "@eslint/js": "10.0.1", "@fontsource-variable/inter": "5.3.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tsconfig/svelte": "5.0.8", "@types/node": "26.1.1", + "eslint": "10.8.0", + "eslint-plugin-svelte": "3.22.0", + "globals": "17.9.0", "jsdom": "29.1.1", + "prettier": "3.9.6", + "prettier-plugin-svelte": "4.1.1", "svelte": "5.56.8", "svelte-check": "4.7.3", + "svelte-eslint-parser": "1.8.0", "typescript": "5.9.3", + "typescript-eslint": "8.65.0", "vite": "7.3.6", "vitest": "4.1.10" } diff --git a/web/src/admin/components/CatalogSourcePanel.svelte b/web/src/admin/components/CatalogSourcePanel.svelte new file mode 100644 index 0000000..31cee62 --- /dev/null +++ b/web/src/admin/components/CatalogSourcePanel.svelte @@ -0,0 +1,210 @@ + + + +
                                                                        + + +
                                                                        + + {#if draft.config === null} +

                                                                        Lecture des réglages du poste…

                                                                        + {:else if source === 'local_drop'} + draft.set('catalog.options.directory', value)} + /> +

                                                                        + Le poste y cherche le fichier flv_{station}.csv, et le supprime + une fois lu : c’est ce qui dit au producteur que la livraison est prise. +

                                                                        + {:else if source === 'webdav'} + draft.set('catalog.options.url', value)} + /> + draft.set('catalog.options.username', value)} + /> + + draft.set('catalog.options.password', value)} + /> +

                                                                        + Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus + possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours + du jour de la mise en service. +

                                                                        + {:else} +

                                                                        + Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira + chercher aucun catalogue. +

                                                                        + {/if} +
                                                                        + + diff --git a/web/src/admin/components/ConfigDiffTable.svelte b/web/src/admin/components/ConfigDiffTable.svelte new file mode 100644 index 0000000..ceaabe1 --- /dev/null +++ b/web/src/admin/components/ConfigDiffTable.svelte @@ -0,0 +1,90 @@ + + +
                                                                        + + + + + + + + + + + {#each rows as entry (entry.path)} + {@const name = labelOf(entry.path)} + + + + + + {/each} + +
                                                                        ChampEn serviceDans le fichier
                                                                        + {name} + {#if preferences.showTechnicalNames && name !== entry.path} + {entry.path} + {/if} + {entry.before}{entry.after}
                                                                        +
                                                                        + + diff --git a/web/src/admin/components/DecisionsInForcePanel.svelte b/web/src/admin/components/DecisionsInForcePanel.svelte new file mode 100644 index 0000000..1e3f1f1 --- /dev/null +++ b/web/src/admin/components/DecisionsInForcePanel.svelte @@ -0,0 +1,142 @@ + + + + {#if decisions.length === 0} +

                                                                        Aucune décision locale : la grille est celle du fichier.

                                                                        + {:else} +

                                                                        + {tally(shown.length, decisions.length, 'décision en vigueur', 'décisions en vigueur')} +

                                                                        +
                                                                        +
                                                                          + {#each shown as decision (decision.product_id)} +
                                                                        • + + {nameOf(decision.product_id)} + {decision.product_id} + {decisionSentence(decision)} + {decision.reason} + {frenchDate(decision.decided_at)} +
                                                                        • + {/each} +
                                                                        +
                                                                        + {/if} +
                                                                        + + diff --git a/web/src/admin/components/FindingsPanel.svelte b/web/src/admin/components/FindingsPanel.svelte new file mode 100644 index 0000000..76a3f6f --- /dev/null +++ b/web/src/admin/components/FindingsPanel.svelte @@ -0,0 +1,163 @@ + + + + {#if state !== 'read'} +

                                                                        {findingsUnknownSentence(state)}

                                                                        + {:else if findings.length === 0} +

                                                                        {none}

                                                                        + {:else} +

                                                                        + {tally(shown.length, findings.length, singular, plural)} + {#if truncated && remedy !== ''} + {remedy} + {/if} +

                                                                        + +
                                                                        +
                                                                          + {#each shown as finding} +
                                                                        • + ligne {frenchInteger(finding.csv_line)} + + {#if finding.product_name !== ''} + {finding.product_name} + {/if} + {finding.product_id} + {finding.value} + {finding.message} +
                                                                        • + {/each} +
                                                                        +
                                                                        + {/if} +
                                                                        + + diff --git a/web/src/admin/components/ImportFaults.svelte b/web/src/admin/components/ImportFaults.svelte new file mode 100644 index 0000000..5eb3510 --- /dev/null +++ b/web/src/admin/components/ImportFaults.svelte @@ -0,0 +1,102 @@ + + +
                                                                        +

                                                                        Ce fichier serait refusé en l’état : {total}

                                                                        + +
                                                                          + {#each shown as fault} +
                                                                        • + + {labelOf(fault.field)} + {#if preferences.showTechnicalNames}{fault.field}{/if} + {fault.message} + + {#if fault.allowed !== undefined && fault.allowed.length > 0} + Valeurs acceptées : {fault.allowed.join(', ')}. + {/if} +
                                                                        • + {/each} +
                                                                        +

                                                                        + Recopier reste possible : les valeurs entrent dans le brouillon, où elles se + corrigent champ par champ avant l’enregistrement. +

                                                                        +
                                                                        + + diff --git a/web/src/admin/components/ImportHistoryPanel.svelte b/web/src/admin/components/ImportHistoryPanel.svelte new file mode 100644 index 0000000..6fc7bad --- /dev/null +++ b/web/src/admin/components/ImportHistoryPanel.svelte @@ -0,0 +1,126 @@ + + + + {#if state !== 'read'} +

                                                                        {historyUnknownSentence(state)}

                                                                        + {:else if imports.length === 0} +

                                                                        Aucun import dans l’historique.

                                                                        + {:else} +

                                                                        {importTally(imports.length)}

                                                                        +
                                                                        + + + + + + + + + + + + + + + + + {#each imports as record (record.id)} + + + + + + + + + + + + + {/each} + +
                                                                        QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
                                                                        {frenchDateTime(record.occurred_at)}{record.file_name}{importSourceWord(record.source)}{frenchResult(record)}{record.reason}{frenchInteger(record.rows_read_count)}{frenchInteger(record.weighable_count)}{frenchInteger(record.not_weighable_count)}{frenchInteger(record.anomalies_count)}{frenchInteger(record.products_withdrawn_count)}
                                                                        +
                                                                        + {/if} +
                                                                        + + diff --git a/web/src/admin/components/Maintenance.svelte b/web/src/admin/components/Maintenance.svelte index 70071f4..447c2f3 100644 --- a/web/src/admin/components/Maintenance.svelte +++ b/web/src/admin/components/Maintenance.svelte @@ -33,7 +33,7 @@ import Act from './Act.svelte' import Panel from './Panel.svelte' import * as api from '../lib/api' - import { AdminError } from '../lib/api' + import { isCredentialRefusal } from '../lib/api' import type { Admin } from '../lib/session.svelte' /** @@ -115,11 +115,6 @@ } } - /** Vrai quand un refus se règle en s'authentifiant, et non en corrigeant le fichier. */ - function isCredentialRefusal(failure: unknown): boolean { - return failure instanceof AdminError && failure.needsCredentials - } - /** Demande au poste de redémarrer, puis attend qu'il réponde de nouveau. */ async function restartStation(): Promise { working = 'restart' diff --git a/web/src/admin/components/SafeguardList.svelte b/web/src/admin/components/SafeguardList.svelte new file mode 100644 index 0000000..7fc2d06 --- /dev/null +++ b/web/src/admin/components/SafeguardList.svelte @@ -0,0 +1,204 @@ + + +
                                                                          + {#each SAFEGUARDS as rule (rule.code)} +
                                                                        1. +

                                                                          + {rule.rank} + {rule.label} + {rule.code} + {rule.severity} +

                                                                          +

                                                                          {rule.when}

                                                                          + + {#if rule.message === ''} +

                                                                          Rien ne s’affiche au client : c’est une information.

                                                                          + {:else} +

                                                                          « {rule.message} »

                                                                          + {/if} + + {#if rule.switchPath !== ''} + draft.set(rule.switchPath, on)} + /> + {/if} + + {#each rule.thresholds as threshold (threshold.path)} + +
                                                                          restoreBox(event.target, draft.text(threshold.path))} + > + writeNumber(threshold.path, value)} + /> +
                                                                          + {/each} + + {#if rule.note !== ''}

                                                                          {rule.note}

                                                                          {/if} +
                                                                        2. + {/each} +
                                                                        + +

                                                                        + Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve + dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur. +

                                                                        + +

                                                                        + Les marqueurs {PLACEHOLDERS.join(' et ')} sont remplacés par les valeurs de la pesée au + moment où le message s’affiche. +

                                                                        + + diff --git a/web/src/admin/components/StandingHeader.svelte b/web/src/admin/components/StandingHeader.svelte new file mode 100644 index 0000000..6c72e0e --- /dev/null +++ b/web/src/admin/components/StandingHeader.svelte @@ -0,0 +1,86 @@ + + +
                                                                        +

                                                                        {title}

                                                                        + + {standing.word} + +
                                                                        + + diff --git a/web/src/admin/components/TechnicalLines.svelte b/web/src/admin/components/TechnicalLines.svelte new file mode 100644 index 0000000..2af4757 --- /dev/null +++ b/web/src/admin/components/TechnicalLines.svelte @@ -0,0 +1,114 @@ + + +
                                                                        +
                                                                          + {#each lines as line (line.id)} +
                                                                        • + {frenchDateTime(line.occurred_at)} + {french(LEVELS, line.level, 'niveau inconnu')} + {logSourceLabelOf(line.source)} + + {#if line.code !== '' && preferences.showTechnicalNames} + {line.code} + {/if} + {line.message} + {#if line.detail !== ''}{line.detail}{/if} +
                                                                        • + {/each} +
                                                                        +
                                                                        + + diff --git a/web/src/admin/components/TierGrid.svelte b/web/src/admin/components/TierGrid.svelte new file mode 100644 index 0000000..4a0ffe6 --- /dev/null +++ b/web/src/admin/components/TierGrid.svelte @@ -0,0 +1,212 @@ + + +{#if tiers.length === 0} +

                                                                        Aucun tarif déclaré dans la configuration lue.

                                                                        +{:else} +

                                                                        {tierCount(tiers.length)}.

                                                                        +
                                                                        + + + + + + + + + + + + + {#each tiers as tier, index (index)} + + + + + + + + {/each} + +
                                                                        CodeLibelléAbrégéRemiseOrdre
                                                                        {tier.code} + + draft.set(`pricing.tiers.${String(index)}.label`, event.currentTarget.value)} + /> + + + draft.set(`pricing.tiers.${String(index)}.abbrev`, event.currentTarget.value)} + /> + + + {#if tier.code === referenceCode && tier.written === null} + Prix du catalogue Odoo — pas de remise + {:else if tier.code === referenceCode} + + {tier.written} — le tarif de référence est le prix du catalogue : il + ne peut pas porter de remise, et l’enregistrement la refusera. + + {:else if tier.written !== null && tier.discount === null} + + {tier.written} — une remise s’écrit au dixième de point ; celle-ci se + change dans le fichier de configuration. + + {:else} + + + writeDiscount( + `pricing.tiers.${String(index)}.discount_percent`, + event.currentTarget.value, + )} + onfocusout={(event) => + restoreBox(event.currentTarget, discountText(tier.discount ?? 0))} + /> % + + un produit à 10,00 €/kg s’affiche {previewOf(tier.discount ?? 0)} €/kg + + {/if} + {tier.rank}
                                                                        +
                                                                        +

                                                                        + Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la + retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour + tous les adhérents. +

                                                                        +{/if} + + diff --git a/web/src/admin/components/Toggle.svelte b/web/src/admin/components/Toggle.svelte new file mode 100644 index 0000000..23be039 --- /dev/null +++ b/web/src/admin/components/Toggle.svelte @@ -0,0 +1,142 @@ + + + + + diff --git a/web/src/admin/components/WaiverList.svelte b/web/src/admin/components/WaiverList.svelte new file mode 100644 index 0000000..b2a0650 --- /dev/null +++ b/web/src/admin/components/WaiverList.svelte @@ -0,0 +1,182 @@ + + +{#if waivers.length === 0} +

                                                                        Aucune dérogation : la limite générale s’applique à tous les produits.

                                                                        +{:else} +

                                                                        {total}

                                                                        + {#if namesState === 'unread'} +

                                                                        + Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas + répondu. Les identifiants Odoo restent affichés. +

                                                                        + {/if} +
                                                                          + {#each shown as waiver (waiver.product_id)} +
                                                                        • + {nameOf(waiver.product_id)} + {waiver.product_id} + {waiverFloor(waiver.min_weight_g ?? 0)} + {waiver.reason} + {frenchDate(waiver.decided_at)}, {waiver.decided_by} + {#if !waiver.offered} + + Produit retiré : le garde-fou 14 refuse le produit avant que cette + dérogation ait un sens. + + {/if} +
                                                                        • + {/each} +
                                                                        +{/if} + + diff --git a/web/src/admin/lib/api.ts b/web/src/admin/lib/api.ts index f9537cb..0f858d5 100644 --- a/web/src/admin/lib/api.ts +++ b/web/src/admin/lib/api.ts @@ -76,6 +76,26 @@ export class AdminError extends Error { } } +/** + * Vrai quand un refus se règle en s'authentifiant, et non en corrigeant sa saisie. + * + * La même question que {@link AdminError.needsCredentials}, posée à ce qu'un `catch` + * attrape : n'importe quoi peut y arriver, et seul un refus du poste porte un statut. Ceux + * qui répondent oui REMONTENT jusqu'à `Admin.protect`, qui demande de quoi s'authentifier + * puis rejoue l'acte. Tous les autres — un 422 et ses contrôles en tête, mais aussi le 409 + * d'un compte à rebours déjà armé — s'affichent là où ils sont nés : rouvrir un panneau de + * mot de passe par-dessus cacherait la faute qu'il faut lire. + * + * Elle est ici pour la raison qui a déjà fait descendre `needsCredentials` dans cette + * classe : trois fichiers en portaient une copie de trois lignes — `Maintenance.svelte`, + * `draft.svelte.ts` et `Hardware.svelte`, strictement identiques toutes les trois. + * + * @param failure - ce que le `catch` a reçu. + */ +export function isCredentialRefusal(failure: unknown): boolean { + return failure instanceof AdminError && failure.needsCredentials +} + // --- Le tableau de bord, sans mot de passe (ADR-018) ------------------------ /** Lit le tableau de bord de §14.4. C'est la seule route que la page bénévole lit. */ diff --git a/web/src/admin/lib/clone.ts b/web/src/admin/lib/clone.ts new file mode 100644 index 0000000..6cf8195 --- /dev/null +++ b/web/src/admin/lib/clone.ts @@ -0,0 +1,123 @@ +import { differences, valueAt } from './diff' +import type { Difference } from './diff' + +/** + * What one station's configuration file would change on another, and what it left behind. + * + * This is the clone of §11.5 seen from the browser: one operator exports the reference + * station, carries the file to the three others, and checks that the fingerprint on screen + * is the same. Everything here is a comparison between two documents — no field, no route, + * no layout — which is why it is held to the word with no browser at all. + */ + +/** + * The two keys the comparison LEAVES OUT, and why it has to. + * + * `modified_at` is stamped by whoever writes the file — `writeConfig` fills it from the + * station's own clock on every save — so the file carried from station 1 holds the instant + * station 1 was configured and station 2 holds its own. Compared, the two ALWAYS differ: + * the table announced « 1 champ qui change » and offered « Recopier ce champ » at the exact + * moment §11.5 wanted somebody to read « rien ne change ». The fingerprint of §11.5 clears + * the same field for the same reason (`Config.Fingerprint`), and copying it into the draft + * would change nothing anyway — the next save overwrites it. + * + * `catalog.options.password` is a secret NEITHER document carries in clear: the station + * blanks it before serving anything (`configPayload`) and `Config.Export` deletes it + * outright, whatever `hardware` says. The row therefore compared « » to « — », which is a + * difference between two ways of not saying a password — and « Recopier » treated it like + * any other, wrote `undefined` into the draft, and `JSON.stringify` dropped the key on the + * way out. The cooperative's WebDAV account disappeared from the file through Importer → + * Recopier → Enregistrer, in silence. A write-only field has nothing to do in a + * field-by-field diff; the service carries the secret over on its own side. + */ +const NOT_COMPARED = new Set(['modified_at', 'catalog.options.password']) + +/** + * What a hardware-free export does NOT carry, and the French name of each. + * + * `Config.Export(false)` clears seven things — the station number and name, the whole + * network block, the station-specific keys of the three option maps, and the image path of + * the catalog — and the import puts back only the number and the two secrets. Everything + * else comes back EMPTY, so those rows of the diff hold nothing, and « Recopier » copies + * emptiness as faithfully as it copies a value: the WebDAV account of the catalog is in + * that list. This is what the screen has to name before anybody presses the button. + * + * The rows watch the exact KEYS the export clears, and no longer the whole option maps. + * Watching a map stopped working the day the export kept its shared keys: the map comes + * back carrying the separator and the label offset, so it is not blank, so the warning + * fell silent — including for the WebDAV account, the emptiness this screen exists to + * name. The volunteer would have pressed « Recopier » on a file whose share address was + * gone, and nothing would have said so. + */ +const CLONE_STRIPS: { path: string; name: string }[] = [ + { path: 'station.name', name: 'le nom du poste' }, + { path: 'network.listen', name: 'l’adresse d’écoute' }, + { path: 'scale.options.port', name: 'le port de la balance' }, + // Les TROIS clés d'appareil de l'imprimante, parce que l'export sans matériel efface + // les trois (§8.4, `Config.Export`). La ligne n'en nommait qu'une : sur un poste réglé + // sur `tcp`, « Recopier » emportait l'adresse de l'imprimante sans un mot, ce que cet + // encadré existe précisément pour dire. + { path: 'printer.options.queue', name: 'la file d’impression' }, + { path: 'printer.options.path', name: 'le nœud d’impression' }, + { path: 'printer.options.address', name: 'l’adresse de l’imprimante' }, + { path: 'catalog.options.url', name: 'l’adresse du partage' }, + { path: 'catalog.options.username', name: 'le compte du partage' }, + { path: 'catalog.images.path', name: 'le chemin des images' }, +] + +/** + * What the file would change on the station, field by field. + * + * @param station - the configuration in service, or null when it could not be read. + * @param file - what the station read in the imported file, or null before any import. + * @returns the fields that differ, minus the ones {@link NOT_COMPARED} names. It is EMPTY + * when there is nothing to compare against, which is why nothing may read « no + * difference » out of its length alone. + */ +export function comparisonOf( + station: Record | null, + file: Record | null, +): Difference[] { + if (station === null || file === null) return [] + return differences(station, file).filter((entry) => !NOT_COMPARED.has(entry.path)) +} + +/** + * The blocks of §11.5 the file carries EMPTY while the station has a value there. + * + * @param station - the configuration in service. + * @param file - what the station read in the imported file. + */ +export function strippedBlocks( + station: Record | null, + file: Record | null, +): { path: string; name: string }[] { + if (station === null || file === null) return [] + const inService = station + const inFile = file + return CLONE_STRIPS.filter( + (block) => isBlank(inFile, block.path) && !isBlank(inService, block.path), + ) +} + +/** + * True when a document carries nothing at that path: absent, null, empty text, empty map. + * + * The four forms all come out of one export: `Config.Export(false)` writes `""` on the + * station name, `null` on the three option maps, and the zero value of the network block. + * + * @param document - the document to read. + * @param path - the dotted path of the key. + */ +function isBlank(document: Record, path: string): boolean { + const value = valueAt(document, path) + if (value === undefined || value === null || value === '') return true + if (typeof value !== 'object' || Array.isArray(value)) return false + return Object.keys(value).length === 0 +} + +/** « le nom du poste, l’adresse d’écoute et les réglages de la balance ». */ +export function frenchList(names: string[]): string { + if (names.length < 2) return names.join('') + return `${names.slice(0, -1).join(', ')} et ${names[names.length - 1] ?? ''}` +} diff --git a/web/src/admin/lib/config-file.ts b/web/src/admin/lib/config-file.ts new file mode 100644 index 0000000..e9c72c3 --- /dev/null +++ b/web/src/admin/lib/config-file.ts @@ -0,0 +1,137 @@ +import { AdminError } from './api' +import type { FaultDTO, ProblemDTO } from './dto' + +/** + * The two exchanges of a configuration FILE, and the download that follows one of them. + * + * They live apart from `lib/api.ts` because they are the only calls of the contract that + * do not read a body and parse it as JSON: one answers a document somebody is about to + * save, the other sends one somebody just picked. Doing to those what the rest of the + * module does to an answer is exactly what must not happen. + */ + +/** One exported configuration, and the name it is saved under. */ +export interface ExportedFile { + name: string + blob: Blob +} + +/** What `POST /admin/api/config/import` answers of the file it was given. */ +export interface InspectedConfig { + config: Record + faults: FaultDTO[] +} + +/** + * Fetches one export, and turns a refusal into what {@link Admin.protect} answers. + * + * `GET /admin/api/config/export` is the one read the station keeps behind the password, + * because it is the one payload that still carries the password hash (§11.5). Two bare + * `` used to fetch it, and an anchor cannot see a refusal: on an expired + * session the browser saved a file named like an export and holding « Session expirée ». + * + * @param station - the number of this station, which names the file. + * @param withHardware - what the `hardware` parameter of §11.5 selects. + */ +export async function readExport( + station: number, + withHardware: boolean, +): Promise { + const route = `/admin/api/config/export?hardware=${withHardware ? '1' : '0'}` + const response = await fetch(route, { headers: { accept: 'application/json' } }) + if (!response.ok) { + throw new AdminError(response.status, refusalOf(await response.text(), 'L’export')) + } + return { name: exportName(station, withHardware), blob: await response.blob() } +} + +/** + * Has THE STATION read the file, and turns a refusal into what `protect` answers. + * + * `changed_blocks` travels in the same body and is deliberately not read: the twelve + * block names are English tokens of `internal/web/config.go`, and the field-by-field diff + * §14.4 asks for says strictly more than « le bloc printer a changé » — which does not + * tell whether it is the print queue or the darkness. + * + * @param contents - the parsed file. + */ +export async function submitCandidate( + contents: Record, +): Promise { + const response = await fetch('/admin/api/config/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(contents), + }) + const raw = await response.text() + if (!response.ok) throw new AdminError(response.status, refusalOf(raw, 'L’import')) + return JSON.parse(raw) as InspectedConfig +} + +/** + * The name an export is saved under, and why the screen chooses it. + * + * The station names every export `config-export.json`, so the clone of §11.5 — one + * operator, one file, three other stations — ends with four identically named files in + * one folder, two of which do not carry the hardware and must not be applied as if they + * did. §11.5 names the file after the station it came from and the day it was taken. + * + * @param station - the number of this station. + * @param withHardware - whether this is the complete export. + */ +export function exportName(station: number, withHardware: boolean): string { + const variant = withHardware ? '' : '-sans-materiel' + return `config-poste${String(station)}${variant}-${isoDay()}.json` +} + +/** Today as `2026-07-27`: the one date form that sorts right in a folder listing. */ +function isoDay(): string { + const now = new Date() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${String(now.getFullYear())}-${month}-${day}` +} + +/** + * Hands one file to the browser's own download. + * + * The anchor is created, clicked and dropped: it exists for the length of one gesture and + * never sits in the page — a permanent `` is precisely what could not see the + * station refuse. + * + * The object URL is released ON THE NEXT TURN of the loop, never on this one. A click + * only QUEUES the download; a browser that reads the address afterwards finds nothing and + * cancels it, and the sentence on screen would have announced an export nobody received. + * One turn is enough, and it is what keeps the URL from leaking either. + * + * @param name - the name the file is saved under. + * @param blob - the bytes the station answered. + */ +export function handToBrowser(name: string, blob: Blob): void { + const address = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = address + link.download = name + document.body.appendChild(link) + link.click() + link.remove() + setTimeout(() => { + URL.revokeObjectURL(address) + }, 0) +} + +/** + * The French sentence of a refusal: the station's own, when it wrote one. + * + * @param raw - the body of the refused answer. + * @param what - the act, for the sentence of last resort. + */ +function refusalOf(raw: string, what: string): string { + try { + const problem = JSON.parse(raw) as ProblemDTO | null + if (typeof problem?.message === 'string' && problem.message !== '') return problem.message + } catch { + // The station answered something that is not a problem document. + } + return `${what} a été refusé par le poste.` +} diff --git a/web/src/admin/lib/decisions.ts b/web/src/admin/lib/decisions.ts new file mode 100644 index 0000000..4a2fda4 --- /dev/null +++ b/web/src/admin/lib/decisions.ts @@ -0,0 +1,109 @@ +import type { DecisionDTO } from './dto' +import { frenchInteger } from './format' + +/** + * What a human decided about ONE product, and what the station will accept. + * + * Two columns of one row of `local_decisions`, and the whole point of this module is that + * they stay two: withdrawing a product and letting it weigh less are separate acts, and a + * single call carrying both erased a waiver every time somebody withdrew a product. + * + * The rules live here rather than in the page because they are the answer to « will the + * station take this? », which is worth knowing BEFORE arming a button — and worth testing + * without a browser. + */ + +/** + * The form as the screen holds it, before anything travels. + * + * `typedWaiver` is the FIGURE and not the text: `Number('')` is 0 and `Number('abc')` is + * NaN, which `JSON.stringify` turns into `null` — an unusable field would silently write + * « this product may weigh 0 g » or silently drop a waiver somebody meant to grant. + */ +export interface DecisionForm { + /** The motive as it will TRAVEL: trimmed, because a space is not an explanation. */ + motive: string + /** Whether the product is offered today, read from the decision IN FORCE. */ + offeredInForce: boolean + /** The waiver the product already carries, read from the decision in force. */ + waiverInForce: number | null + /** The waiver typed in the form, `null` when the field is empty or unreadable. */ + typedWaiver: number | null +} + +/** Whether each of the four acts of a decision can travel as things stand. */ +export interface DecisionActs { + canWithdraw: boolean + canOfferAgain: boolean + canSaveWaiver: boolean + canDropWaiver: boolean +} + +/** + * Whether an act writing these two columns needs a motive to be ACCEPTED. + * + * The station requires one for every decision it WRITES. The single act exempt is the one + * that writes nothing: offered again AND no waiver ERASES the row (`ClearDecision`, + * internal/web/admin.go), and a row that no longer exists needs no explaining. + * + * @param offered - whether the product would go back into the grid. + * @param grams - the waiver that would travel with it. + */ +export function needsMotive(offered: boolean, grams: number | null): boolean { + return !(offered && grams === null) +} + +/** + * The waiver a field holds, or null when it holds nothing usable. + * + * @param typed - what was typed in the grams field. + */ +export function waiverTyped(typed: string): number | null { + if (typed.trim() === '') return null + return Number(typed) +} + +/** + * Whether a typed waiver can travel. + * + * @param grams - the figure the field gave, or null. + */ +export function waiverIsUsable(grams: number | null): boolean { + return grams !== null && Number.isFinite(grams) && grams > 0 +} + +/** + * Which of the four acts the station would accept, as the form stands. + * + * The motive is a CONDITION and not a comment, and the field used to present it as one: + * the station answers 422 « Indiquez le motif de cette décision. » to every decision it + * writes. Arming a button it is certain to refuse — after asking for the password, which + * is worse — is a promise the screen cannot keep. + * + * @param form - the decision in force and what the form holds. + */ +export function decisionActs(form: DecisionForm): DecisionActs { + const explained = form.motive !== '' + return { + canWithdraw: explained || !needsMotive(false, form.waiverInForce), + canOfferAgain: explained || !needsMotive(true, form.waiverInForce), + canSaveWaiver: + waiverIsUsable(form.typedWaiver) && + (explained || !needsMotive(form.offeredInForce, form.typedWaiver)), + canDropWaiver: explained || !needsMotive(form.offeredInForce, null), + } +} + +/** + * What one decision in force says, in one French line. + * + * @param decision - the row as the station serves it. + */ +export function decisionSentence(decision: DecisionDTO): string { + const parts: string[] = [] + if (!decision.offered) parts.push('retiré de la grille') + if (decision.min_weight_g !== null) { + parts.push(`peut peser à partir de ${frenchInteger(decision.min_weight_g)} g`) + } + return parts.length === 0 ? 'aucune restriction' : parts.join(' · ') +} diff --git a/web/src/admin/lib/draft.svelte.ts b/web/src/admin/lib/draft.svelte.ts index f4abecf..17fd973 100644 --- a/web/src/admin/lib/draft.svelte.ts +++ b/web/src/admin/lib/draft.svelte.ts @@ -1,5 +1,5 @@ import * as api from './api' -import { AdminError } from './api' +import { isCredentialRefusal } from './api' import type { ConfigDTO, ConfirmationDTO, FaultDTO } from './dto' import type { Admin } from './session.svelte' @@ -220,15 +220,3 @@ export class Draft { function faultsOfLastRefusal(admin: Admin): FaultDTO[] { return admin.lastFaults } - -/** - * Vrai quand un refus se règle en s'authentifiant, et non en corrigeant sa saisie. - * - * Ceux-là REMONTENT jusqu'à `Admin.protect`, qui demande de quoi s'authentifier puis rejoue - * l'acte. Tous les autres — un 422 et ses contrôles en tête, mais aussi le 409 d'un compte - * à rebours déjà armé — s'affichent ici : rouvrir un panneau de mot de passe par-dessus - * cacherait la faute qu'il faut lire. - */ -function isCredentialRefusal(failure: unknown): boolean { - return failure instanceof AdminError && failure.needsCredentials -} diff --git a/web/src/admin/lib/faults.ts b/web/src/admin/lib/faults.ts new file mode 100644 index 0000000..a5cf822 --- /dev/null +++ b/web/src/admin/lib/faults.ts @@ -0,0 +1,33 @@ +import type { Draft } from './draft.svelte' + +/** + * Ce qu'un des contrôles de §11.3 a dit d'une clé, lu depuis n'importe quel écran. + * + * Les 47 contrôles refusent TOUS D'UN COUP et nomment chacun une CLÉ : une page qui + * n'afficherait que le bandeau global laisse chercher laquelle. Les quatre pages qui + * éditent la configuration posaient donc la même question, dans quatre copies de trois + * lignes — et une copie qui oublie `allowed` jette la moitié du contrôle. + */ + +/** + * Le message du contrôle qui a refusé cette clé, vide quand il n'y en a pas. + * + * @param draft - la configuration en cours d'édition. + * @param path - le chemin pointé de la clé. + */ +export function faultOf(draft: Draft, path: string): string { + return draft.faults.find((fault) => fault.field === path)?.message ?? '' +} + +/** + * Les valeurs qu'un contrôle a nommées comme acceptables (§11.4, étape 1). + * + * « Ce port n'existe pas sur ce poste » sans la liste des ports qui existent est un refus + * sur lequel personne ne peut agir. + * + * @param draft - la configuration en cours d'édition. + * @param path - le chemin pointé de la clé. + */ +export function allowedFor(draft: Draft, path: string): string[] { + return draft.faults.find((fault) => fault.field === path)?.allowed ?? [] +} diff --git a/web/src/admin/lib/frames.ts b/web/src/admin/lib/frames.ts new file mode 100644 index 0000000..67c6156 --- /dev/null +++ b/web/src/admin/lib/frames.ts @@ -0,0 +1,58 @@ +/** + * A raw scale frame, read twice: as bytes, and as a human reads it. + * + * This is the « dump hexa + ASCII » of `openscale capture` (§15.1) carried to the screen. + * It lives in a module of its own because it is a pure translation of bytes — no state, + * no layout — and because a mute station and a station that speaks without being + * understood call for two opposite gestures, which makes this reading the one thing a + * support call starts from. + */ + +/** Les caractères de commande qu'une trame de balance porte (§9.1), par leur nom. */ +const CONTROL_NAMES: Record = { + 0: 'NUL', + 2: 'STX', + 3: 'ETX', + 4: 'EOT', + 5: 'ENQ', + 6: 'ACK', + 9: 'TAB', + 10: 'LF', + 13: 'CR', + 21: 'NAK', + 27: 'ESC', + 127: 'DEL', +} + +/** + * Les octets d'une trame, en hexadécimal : ce qu'un support demande d'abord. + * + * @param frame - une trame brute, telle que le poste l'a lue sur le câble. + */ +export function hexOf(frame: string): string { + return [...new TextEncoder().encode(frame)] + .map((byte) => byte.toString(16).toUpperCase().padStart(2, '0')) + .join(' ') +} + +/** + * La trame DÉCODÉE : les mêmes octets tels qu'un humain les lit. + * + * Les caractères de commande sont NOMMÉS plutôt que rendus : c'est le STX manquant ou le + * CR en trop qui explique un poste muet, et un caractère invisible ne se lit pas au + * téléphone. + * + * @param frame - une trame brute, telle que le poste l'a lue sur le câble. + */ +export function decodedOf(frame: string): string { + let read = '' + for (const character of frame) { + const code = character.codePointAt(0) ?? 0 + if (code >= 32 && code !== 127) { + read += character + continue + } + read += `⟨${CONTROL_NAMES[code] ?? code.toString(16).toUpperCase().padStart(2, '0')}⟩` + } + return read +} diff --git a/web/src/admin/lib/grid-count.ts b/web/src/admin/lib/grid-count.ts new file mode 100644 index 0000000..eb1b157 --- /dev/null +++ b/web/src/admin/lib/grid-count.ts @@ -0,0 +1,173 @@ +import { GRID_COLUMNS_AUTO } from '../../lib/grid' +import { NAME_SIZE_MIN_PX } from '../../lib/typography' +import { frenchInteger } from './format' +import type { ReadState } from './read-state' + +/** + * What the drafted client grid comes to, put into French. + * + * Not one sentence here decides anything: the numbers come from the browser, which is the + * only side that knows how many columns `auto-fill` makes of a `clamp()` on this screen. + * What lives here is the READING of those numbers — the plurals, the order in which they + * are said, and the price each density is paid at — and it lives apart from the page so + * that it can be held to the word with no layout at all. + */ + +/** + * What the layout answered about the draft grid. + * + * The page keeps `null` for « it has answered nothing »: jsdom lays nothing out, an old + * browser may lay out something else, and a screen that states a count it has not read is + * the one failure the whole panel is written against. + */ +export interface ScreenCount { + columns: number + /** Whole rows of the height the client grid gives its tiles. */ + rows: number + /** Usable width inside one tile, its padding and border already removed. */ + contentWidthPx: number + /** Height of the block a name is fitted into, at the draft. */ + nameBoxPx: number + /** What the tile is scaled by, and therefore where a name starts its shrink. */ + tileScale: number +} + +/** How many names come out at the floor, and how many rows carry one. */ +export interface FloorReached { + names: number + rows: number +} + +/** The window the count is true of. */ +export interface Viewport { + width: number + height: number +} + +/** Everything the sentences below are drawn from. */ +export interface GridDraft { + /** The column setting AS DRAFTED, {@link GRID_COLUMNS_AUTO} for automatic. */ + columns: number + /** What the browser answered, or null while it has answered nothing. */ + layout: ScreenCount | null + /** How many tiles the grid would draw at the draft, the by-unit switch included. */ + tileCount: number + /** What the name measurement found, or null when nothing could be measured. */ + floor: FloorReached | null + viewport: Viewport +} + +/** « 7 colonnes × 3 rangées », the two numbers in the words of the request. */ +export function gridSize(layout: ScreenCount): string { + const columns = `${frenchInteger(layout.columns)} ${layout.columns > 1 ? 'colonnes' : 'colonne'}` + const rows = `${frenchInteger(layout.rows)} ${layout.rows > 1 ? 'rangées' : 'rangée'}` + return `${columns} × ${rows}` +} + +/** + * What the draft grid comes to, in French, and what it costs. + * + * It follows the DRAFT and not the saved file, so the trade-off is read before the save + * rather than after — and it states nothing the screen has not read. Column count first, + * because that is the word the request arrived in. + * + * @param draft - the setting, what the browser answered, and what it was asked of. + */ +export function gridSentences(draft: GridDraft): string[] { + const layout = draft.layout + const lines: string[] = [] + + if (draft.columns === GRID_COLUMNS_AUTO) { + lines.push( + layout === null + ? 'Automatique : la grille suit la largeur de l’écran. Un écran plus large en ' + + 'montre davantage sans qu’on y revienne.' + : `Automatique : ${gridSize(layout)} sur cet écran. Un écran plus large en montrera ` + + 'davantage sans qu’on y revienne.', + ) + return lines + } + + if (layout === null) { + lines.push( + `${frenchInteger(draft.columns)} ${draft.columns > 1 ? 'colonnes' : 'colonne'} sur tous ` + + 'les écrans. Cet écran ne sait pas dire combien de rangées cela fait ici.', + ) + return lines + } + + const seen = layout.columns * layout.rows + lines.push( + `${gridSize(layout)} — ${frenchInteger(seen)} ${seen > 1 ? 'tuiles' : 'tuile'} d’un coup, ` + + `sur cet écran (${String(draft.viewport.width)} × ${String(draft.viewport.height)}).`, + ) + if (draft.tileCount > 0) { + const screens = Math.ceil(draft.tileCount / seen) + lines.push( + draft.tileCount > 1 + ? `Les ${frenchInteger(draft.tileCount)} tuiles de la grille tiennent en ` + + `${frenchInteger(screens)} ${screens > 1 ? 'écrans' : 'écran'}.` + : 'La seule tuile de la grille tient en un écran.', + ) + } + // What ADR-025 demands of a setting: not what it buys, what it costs. High densities + // are paid for in uneven rows, and that has to be legible BEFORE the save, not + // discovered on the station afterwards. + const floor = draft.floor + if (floor !== null && floor.names > 0) { + const many = floor.names > 1 + lines.push( + `${frenchInteger(floor.names)} ${many ? 'noms' : 'nom'} sur ` + + `${frenchInteger(draft.tileCount)} ${many ? 'atteignent' : 'atteint'} le plancher ` + + `de ${frenchInteger(NAME_SIZE_MIN_PX)} px : ` + + (floor.rows > 1 + ? `leurs ${frenchInteger(floor.rows)} rangées peuvent être plus hautes que les autres.` + : 'leur rangée peut être plus haute que les autres.'), + ) + } + return lines +} + +/** + * What this station does with its by-unit products, in one French sentence. + * + * It follows the SWITCH and not the saved file, so that the trade-off is legible before + * the save rather than after. And it states nothing the page has not read: a station + * whose client catalog could not be opened does not know this number. + * + * @param state - where the read of the client catalog stands. + * @param count - how many products of the catalog in service are sold by unit. + * @param shown - whether the draft leaves their tiles in the grid. + */ +export function byUnitSentence(state: ReadState, count: number, shown: boolean): string { + if (state === 'loading') return 'Lecture du catalogue en service…' + if (state === 'unread') { + return ( + 'Le catalogue en service n’a pas pu être lu : cet écran ne sait pas combien de ' + + 'produits se vendent à l’unité.' + ) + } + if (count === 0) { + return 'Aucun produit vendu à l’unité dans le catalogue en service.' + } + const many = count > 1 + const subject = many ? 'produits vendus à l’unité sont' : 'produit vendu à l’unité est' + const said = shown + ? `${many ? 'montrés' : 'montré'} dans la grille de ce poste` + : `${many ? 'masqués' : 'masqué'} sur ce poste` + return `${frenchInteger(count)} ${subject} ${said}.` +} + +/** + * The sentence that keeps « sur cet écran » honest, empty when it is. + * + * The administration is reachable from a laptop over the network, and the count is then + * true of the laptop and not of the station. Zero extra data, zero extra route: the case + * is NAMED instead of being silently wrong. + * + * @param hostname - the host the administration is being read from. + */ +export function otherScreenWarning(hostname: string): string { + if (['localhost', '127.0.0.1'].includes(hostname)) return '' + return 'Cet écran n’est pas celui du poste : ce compte vaut pour l’écran que vous lisez.' +} diff --git a/web/src/admin/lib/journal-words.ts b/web/src/admin/lib/journal-words.ts new file mode 100644 index 0000000..3bba70b --- /dev/null +++ b/web/src/admin/lib/journal-words.ts @@ -0,0 +1,70 @@ +/** + * The tokens the journal writes, and the French word each one reads as. + * + * A value this file has never heard of must not reach a volunteer as it came: it would + * appear as an English word in a French column, and nobody would know it was new. That is + * what {@link french} is for, and why no table is ever read directly. + * + * `sent` is the SUCCESS, and it is spelled out rather than shortened to « imprimée » + * because the station does not know whether the label came out: it knows it handed the + * bytes over (important-7). There is no `printed` and there never was — a filter asking + * for one selected NOTHING on a station that had printed all day. + */ + +/** How a weighing ended. The four values of `internal/domain/journal.go`. */ +export const RESULTS: Record = { + sent: 'envoyée à l’imprimante', + rejected: 'refusée', + failed: 'en échec', + reprint: 'réimpression', +} + +/** The same four values as a filter, plus « toutes ». The plural reads as a heading. */ +export const RESULT_FILTERS: { value: string; label: string }[] = [ + { value: '', label: 'toutes' }, + { value: 'sent', label: 'envoyées à l’imprimante' }, + { value: 'rejected', label: 'refusées' }, + { value: 'failed', label: 'en échec' }, + { value: 'reprint', label: 'réimpressions' }, +] + +/** Where a weight came from, in French. The three values of §12.3. */ +export const SOURCES: Record = { + scale: 'balance', + manual: 'saisie manuelle', + replay: 'trame rejouée', +} + +/** What the frame said about stability, in French. The four values of §9.2. */ +export const STABILITIES: Record = { + stable: 'stable', + unstable: 'instable', + unknown: 'non déclarée par la balance', + not_applicable: 'sans objet — saisie manuelle', +} + +/** How the product is sold, in French. The two modes of ADR-021. */ +export const MODES: Record = { + by_weight: 'au poids', + by_unit: 'à l’unité', +} + +/** The severity of a technical line, in French. The five levels of `internal/store`. */ +export const LEVELS: Record = { + debug: 'mise au point', + info: 'information', + warn: 'avertissement', + error: 'erreur', + critical: 'critique', +} + +/** + * Reads one token of the service in French, and never lets an unknown one through. + * + * @param table - the translations of that field. + * @param token - what the service wrote. + * @param unknown - the French sentence for a token nobody has declared. + */ +export function french(table: Record, token: string, unknown: string): string { + return table[token] ?? unknown +} diff --git a/web/src/admin/lib/listening.ts b/web/src/admin/lib/listening.ts new file mode 100644 index 0000000..87fde2d --- /dev/null +++ b/web/src/admin/lib/listening.ts @@ -0,0 +1,144 @@ +import { frenchInteger } from './format' + +/** + * What the Matériel page says about a serial port it is holding — or not holding. + * + * Everything here exists because under Windows a serial port is EXCLUSIVE and the service + * exposes no stream of frames but a bounded capture that HOLDS the port for three + * seconds. The permanent listening, the scan, and the one button that appears when + * neither can run are three readings of that same fact, so they are worded in one place: + * two of them saying different things about the same port is how a screen sends somebody + * looking for a fault that is not there. + * + * Nothing is asserted that has not been checked — « je ne sais pas encore » while the + * configuration has not arrived — which is why every sentence takes the whole state + * rather than guessing at part of it. + */ + +/** Everything the sentences of the frame viewer are drawn from. */ +export interface Listening { + /** Whether the configuration has arrived. Before that, nothing is known. */ + configRead: boolean + /** Whether the station is declared with no scale at all. */ + declaredWithoutScale: boolean + /** The port the configuration names, empty when it names none. */ + port: string + /** Whether the port enumeration has answered, even with zero port. */ + listed: boolean + /** Whether that port is one the station REALLY enumerated. */ + portKnown: boolean + /** What stopped the listening on that port, empty while it runs. */ + halt: string + /** The act in flight, or an empty string. */ + acting: string + /** How many frames the viewer is showing. */ + framesShown: number + /** How many it keeps at most. */ + framesKept: number +} + +/** Where the scan has got to. */ +export interface Scan { + /** The act in flight, or an empty string. */ + acting: string + /** True while a listening round is in flight: the port is HELD. */ + listening: boolean + /** How many ports the scan has opened, and how many it has to open. */ + scanned: number + toScan: number +} + +/** + * La légende du visualiseur : ce qui est écouté, puis ce qui a été entendu. + * + * @param state - ce que la page sait du port et de ce qu'elle en a reçu. + */ +export function framesCaption(state: Listening): string { + const heard = captionOfHeard(state) + return heard === '' ? captionOfListening(state) : `${captionOfListening(state)} ${heard}` +} + +/** + * Ce que la page fait du port, en une phrase — et « je ne sais pas encore » quand c'est + * la vérité. + * + * Tant que la configuration n'est pas arrivée, le port vaut la chaîne vide parce que la + * clé est ABSENTE, et non parce que personne ne l'a renseignée. La page affirmait + * « Aucun port n'est indiqué » à trois centimètres de « cette page ne déclare rien de ce + * poste ». + * + * @param state - ce que la page sait du port. + */ +function captionOfListening(state: Listening): string { + if (!state.configRead) { + return 'Lecture de la configuration en cours : le port à écouter n’est pas encore connu.' + } + if (state.declaredWithoutScale) { + return 'Ce poste est déclaré sans balance : aucun port n’est écouté.' + } + if (state.port === '') { + return 'Aucun port n’est indiqué : choisissez-en un dans la liste ci-dessus pour écouter les trames.' + } + if (!state.listed) { + return state.acting === 'ports' + ? `Énumération des ports en cours : l’écoute de ${state.port} démarre dès qu’il est vu.` + : `Les ports de ce poste n’ont pas été énumérés : « Lister les ports » dira si ${state.port} existe.` + } + if (!state.portKnown) { + return `${state.port} n’est pas visible depuis ce poste : rien n’est écouté en continu.` + } + if (state.halt !== '') return `L’écoute de ${state.port} est arrêtée.` + if (state.acting !== '') { + return `L’écoute de ${state.port} est suspendue le temps de l’acte en cours.` + } + return `Écoute de ${state.port}.` +} + +/** + * Ce que le visualiseur montre, accordé à ce qu'il y a vraiment dedans. + * + * @param state - combien de trames sont affichées, et combien sont gardées au plus. + */ +function captionOfHeard(state: Listening): string { + if (state.framesShown === 0) return 'Aucune trame reçue pour l’instant.' + // Une balance qui n'émet qu'au posé de sac rend UNE trame par manche : « les 1 + // dernières trames » est le cas normal de la mise en service, pas un cas limite. + if (state.framesShown === 1) { + return `Une seule trame reçue — ${frenchInteger(state.framesKept)} au plus sont gardées.` + } + return ( + `Les ${frenchInteger(state.framesShown)} dernières trames — ` + + `${frenchInteger(state.framesKept)} au plus, la plus récente en bas.` + ) +} + +/** + * Ce que le bouton de capture propose, ou rien quand la boucle tourne toute seule. + * + * Deux situations le font apparaître, et une seule phrase ne couvre pas les deux : le + * poste a REFUSÉ la dernière manche — il faut insister, avec le mot de passe s'il le + * faut — ou le port n'est pas énuméré, et l'écoute permanente ne s'en saisira jamais. + * + * @param state - ce que la page sait du port. + */ +export function askLabel(state: Listening): string { + if (!state.configRead || state.declaredWithoutScale || state.port === '') return '' + if (state.halt !== '') return 'Reprendre l’écoute' + if (state.listed && !state.portKnown) return 'Écouter ce port une fois' + return '' +} + +/** + * Ce que dit le bouton du balayage, et où il en est (« port 2 sur 5 »). + * + * It is the one act of the page whose label SAYS HOW FAR IT HAS GOT: « port 2 sur 5 » on + * a scan that runs for a minute is worth more than « En cours… ». + * + * @param scan - où en est le balayage. + */ +export function detectLabel(scan: Scan): string { + if (scan.acting !== 'detect') return 'Détecter automatiquement' + if (scan.listening) return 'Détection : le port se libère…' + if (scan.toScan === 0) return 'Détection : énumération des ports…' + return `Détection : port ${frenchInteger(scan.scanned)} sur ${frenchInteger(scan.toScan)}…` +} diff --git a/web/src/admin/lib/preview.ts b/web/src/admin/lib/preview.ts new file mode 100644 index 0000000..6dfde49 --- /dev/null +++ b/web/src/admin/lib/preview.ts @@ -0,0 +1,48 @@ +/** + * Why a label preview is missing, said with the ENGINE's own sentence whenever it can be. + * + * An `` tag does not hand over the body of a refusal: it fires `error` and says + * nothing more. The same address is therefore asked once again, as JSON, to read the + * sentence of the 422 — « le décalage sort de la découpe » — which is the only one that + * says which dot to give back. + */ + +/** What the screen says when the station refused to render and said nothing readable. */ +export const RENDER_REFUSED = 'L’aperçu n’a pas pu être rendu par le poste.' + +/** What it says when the station DID render the address and the browser showed nothing. */ +export const IMAGE_UNREADABLE = 'Le poste a rendu l’aperçu, mais le navigateur ne l’a pas affiché.' + +/** + * What the station answers about an address the browser could not display. + * + * @param url - the address to ask about, as JSON this time. + */ +export async function refusalOf(url: string): Promise { + try { + const response = await fetch(url, { headers: { accept: 'application/json' } }) + if (response.ok) return IMAGE_UNREADABLE + const problem = JSON.parse(await response.text()) as { message?: string } + if (typeof problem.message === 'string' && problem.message !== '') return problem.message + return RENDER_REFUSED + } catch { + // The station answered nothing readable at all: the fallback sentence says what is + // known, and inventing a cause would say more than that. + return RENDER_REFUSED + } +} + +/** + * One number of a document, read by its dotted path; zero when the key is absent. + * + * @param document - a configuration exactly as the station serves it. + * @param path - the dotted key. + */ +export function dotsAt(document: Record, path: string): number { + let node: unknown = document + for (const key of path.split('.')) { + if (node === null || typeof node !== 'object') return 0 + node = (node as Record)[key] + } + return typeof node === 'number' ? node : 0 +} diff --git a/web/src/admin/lib/read-state.ts b/web/src/admin/lib/read-state.ts new file mode 100644 index 0000000..bc41c5b --- /dev/null +++ b/web/src/admin/lib/read-state.ts @@ -0,0 +1,61 @@ +/** + * What the Catalogue page says of what it has NOT read. + * + * Three values and not two, for the reason `Admin.load` gives about itself: an empty + * array with no explanation reads as « there is none », which is false. A station with no + * journal (ADR-013) answers 503 « ce poste n'a pas d'historique d'imports » to every read, + * and four panels of that page used to answer it with « Aucune anomalie sur le dernier + * import. » — permanently, and reassuringly. + */ +export type ReadState = 'loading' | 'read' | 'unread' + +/** + * What the three lists of findings say as long as they have not been read. + * + * @param state - where the read of the import history stands. + */ +export function findingsUnknownSentence(state: ReadState): string { + if (state === 'loading') return 'Lecture des signalements du dernier import…' + return ( + 'Les signalements du dernier import n’ont pas pu être lus : cet écran ne sait pas ce ' + + 'qu’ils disent.' + ) +} + +/** + * What the history table says as long as it has not been read. + * + * @param state - where the read of the import history stands. + */ +export function historyUnknownSentence(state: ReadState): string { + if (state === 'loading') return 'Lecture de l’historique des imports…' + return 'L’historique des imports n’a pas pu être lu : cet écran ne sait pas ce qu’il contient.' +} + +/** + * The name of a product, or an honest sentence when the screen cannot give one. + * + * THREE cases and not two. `unread` used to answer like `read`, so a failed read of the + * client catalog made a page state, of every decision in force, that its product had left + * the catalog — having never managed to open the catalog. « Produit absent du catalogue » + * is an ACCUSATION: it says the shop sells a product its own catalog does not know, and it + * may only be said once the catalog has actually answered. + * + * The lookup is left to the caller — one screen holds a `Map`, the other a `Record` — and + * so is the wording of the failed read: the Catalogue page and the Rules page do not say + * it in the same words today, and this parameter is where that shows. + * + * @param name - what the catalog holds for that id, `undefined` when it holds nothing. + * @param state - where the read of the client catalog stands. + * @param unread - what to say when the catalog itself could not be read. + */ +export function productNameOf( + name: string | undefined, + state: ReadState, + unread: string, +): string { + if (name !== undefined) return name + if (state === 'loading') return 'Lecture du nom…' + if (state === 'unread') return unread + return 'Produit absent du catalogue en service' +} diff --git a/web/src/admin/lib/safeguards.ts b/web/src/admin/lib/safeguards.ts new file mode 100644 index 0000000..5b361ef --- /dev/null +++ b/web/src/admin/lib/safeguards.ts @@ -0,0 +1,331 @@ +import { labelOf } from './fields' + +/** + * The fourteen safeguards of §6.4, in EVALUATION ORDER, with their thresholds. + * + * The list is the one of `internal/domain/safeguard.go` and nothing else — a test reads + * that file and refuses a code this table invents or forgets. The messages are QUOTED + * from the same file, straight apostrophes included: they are what the customer reads, + * not prose written for this screen. That they are only quoted is a GAP to §6.4, which + * makes them editable from the Rules screen: what is missing is a configuration key per + * message and the route that writes it — the validation of a submitted message already + * exists in Go (`domain.CheckMessage`). + * + * The table lives in a module and not in the page because it is DATA, and because two + * hundred lines of it in the middle of a page hid everything that page actually does. + */ + +/** One threshold of §11.2 that a safeguard reads, edited on the Rules page and nowhere else. */ +export interface Threshold { + path: string + label: string + hint: string +} + +/** One of the fourteen safeguards of §6.4. */ +export interface Safeguard { + /** Its rank in the EVALUATION ORDER, which is normative (§6.4). */ + rank: number + /** The identifier the journal and the telephone use. English, and secondary. */ + code: string + /** What a volunteer reads. */ + label: string + /** What makes it fire, in one sentence. */ + when: string + /** French, read-only: it says who has to act, and it is not a shop setting. */ + severity: string + /** True when the rule stops the label: it draws the row, and it is never editable. */ + blocking: boolean + /** The wording the customer reads, QUOTED from `internal/domain/safeguard.go`. */ + message: string + /** The keys this rule owns. A key belongs to exactly one rule, and is edited once. */ + thresholds: Threshold[] + /** The only rule that a configuration can switch off, empty for the other thirteen. */ + switchPath: string + switchLabel: string + /** Where the threshold lives when it is not a key this rule owns. */ + note: string +} + +/** + * The fourteen rules themselves. + * + * A rule whose threshold is EDITED ELSEWHERE says so through {@link labelOf} and never by + * writing the key: the technical name is behind the « Montrer les noms techniques » + * switch, and a note that spells it out puts back on screen what that switch hides. + */ +export const SAFEGUARDS: Safeguard[] = [ + { + rank: 1, + code: 'OVERLOAD', + label: 'Surcharge', + when: 'La balance annonce elle-même OL, ou le poids brut dépasse la capacité.', + severity: 'Bloquant', + blocking: true, + message: "La balance est en surcharge. Retirez votre article.", + thresholds: [], + switchPath: '', + switchLabel: '', + note: `Seuil : la capacité, réglée au garde-fou 9 sous « ${labelOf('limits.max_weight_g')} ».`, + }, + { + rank: 2, + code: 'MEASUREMENT_EXPIRED', + label: 'Poids périmé', + when: 'La mesure est plus vieille que la péremption, dans les deux modes de stabilité.', + severity: 'Bloquant', + blocking: true, + message: "Poids indisponible. Patientez ou appelez un bénévole.", + thresholds: [], + switchPath: '', + switchLabel: '', + note: 'Aucun seuil à régler : le poste calcule lui-même à partir du rythme de la balance.', + }, + { + rank: 3, + code: 'BASKET_MISSING', + label: 'Panier absent', + when: 'Le poids brut tombe dans la fenêtre négative du panier : il a été soulevé.', + severity: 'Bloquant', + blocking: true, + message: "Le panier n'est pas sur la balance. Reposez-le.", + thresholds: [ + { + path: 'limits.basket_min_g', + label: 'Bas de la fenêtre du panier', + hint: 'En grammes, NÉGATIF : c’est le poids du panier que la balance a perdu.', + }, + { + path: 'limits.basket_max_g', + label: 'Haut de la fenêtre du panier', + hint: 'Négatif lui aussi, et plus proche de zéro que le bas.', + }, + ], + switchPath: 'limits.basket_check_enabled', + switchLabel: 'Ce poste travaille avec un panier taré', + note: 'La règle s’active ou non, en bloc : il n’y a pas de demi-mesure à régler.', + }, + { + rank: 4, + code: 'SCALE_EMPTY', + label: 'Plateau vide', + when: 'Le poids brut ne sort pas de la bande « il n’y a rien sur le plateau ».', + severity: 'Bloquant — un filet, hors parcours nominal', + blocking: true, + message: "Posez votre produit.", + thresholds: [ + { + path: 'limits.empty_max_g', + label: 'Plateau considéré vide', + hint: 'En dessous, le poste considère qu’il n’y a rien sur le plateau.', + }, + ], + switchPath: '', + switchLabel: '', + note: + 'Toucher une tuile sur un plateau vide ARME la sélection au lieu d’être refusé : ' + + 'la règle reste évaluée pour la saisie manuelle et les chemins dérivés.', + }, + { + rank: 5, + code: 'TARE_REQUIRED', + label: 'Remise à zéro nécessaire', + when: 'Le brut est sous la bande du plateau vide, et hors de la fenêtre du panier.', + severity: 'Bloquant', + blocking: true, + message: "La balance doit être remise à zéro.", + thresholds: [], + switchPath: '', + switchLabel: '', + note: + 'Seuil : la valeur négative de celui du garde-fou 4, ' + + `« ${labelOf('limits.empty_max_g')} ».`, + }, + { + rank: 6, + code: 'WEIGHT_UNSTABLE', + label: 'Pesée instable', + when: 'La trame déclare la mesure instable.', + severity: 'Information par défaut (A3)', + blocking: false, + message: "Pesée en cours…", + thresholds: [], + switchPath: '', + switchLabel: '', + note: + 'La sévérité suit l’exigence de stabilité : elle passe à Bloquant quand celle-ci ' + + 'est réglée sur « blocking ». L’impression n’est jamais bloquée par défaut.', + }, + { + rank: 7, + code: 'TARE_INVALID', + label: 'Emballage incohérent', + when: 'Une tare a été saisie, et elle atteint la pesée ou dépasse le maximum.', + severity: 'Bloquant', + blocking: true, + message: "Le poids de l'emballage est supérieur ou égal à la pesée.", + thresholds: [ + { + path: 'limits.max_tare_g', + label: 'Tare maximum', + hint: 'Une tare plus lourde que le maximum est une faute de frappe.', + }, + ], + switchPath: '', + switchLabel: '', + note: '', + }, + { + rank: 8, + code: 'WEIGHT_TOO_LOW', + label: 'Poids trop faible', + // « ne dépasse pas » and not « n'atteint pas »: the kernel fires on `net <= floor`, + // so a net weight EQUAL to the floor is refused. The wording of this table is + // uniform on that point -- « atteint » is >=, « dépasse » is > (see rule 7). + when: 'Vente au poids : le NET est strictement positif et ne dépasse pas le plancher.', + severity: 'Bloquant', + blocking: true, + message: "La balance doit être retarée, ou l'emballage est trop lourd.", + thresholds: [ + { + path: 'limits.min_weight_g', + label: 'Poids minimum', + hint: 'Une dérogation par produit existe, dans l’onglet Catalogue.', + }, + ], + switchPath: '', + switchLabel: '', + note: '', + }, + { + rank: 9, + code: 'WEIGHT_TOO_HIGH', + label: 'Poids trop élevé', + when: 'Le NET dépasse la capacité — strictement, pour que la capacité reste atteignable.', + severity: 'Bloquant', + blocking: true, + message: "{{.Weight}} kg, ça paraît un peu lourd !", + thresholds: [ + { + path: 'limits.max_weight_g', + label: 'Poids maximum', + hint: 'C’est la capacité du champ NNDDD du code-barres, pas un seuil de vraisemblance.', + }, + ], + switchPath: '', + switchLabel: '', + note: '', + }, + { + rank: 10, + code: 'UNITS_OUT_OF_RANGE', + label: 'Nombre d’unités hors plage', + when: 'Vente à l’unité : la quantité sort de la plage.', + severity: 'Bloquant', + blocking: true, + message: "{{.Quantity}} unités, ça paraît un peu beaucoup !", + thresholds: [ + { path: 'limits.min_units', label: 'Unités minimum', hint: '' }, + { path: 'limits.max_units', label: 'Unités maximum', hint: '' }, + ], + switchPath: '', + switchLabel: '', + note: '', + }, + { + rank: 11, + code: 'AMOUNT_OUT_OF_CAPACITY', + label: 'Montant hors capacité du code-barres', + when: 'La charge utile encode un PRIX, et il dépasse ce que le champ peut porter.', + severity: 'Bloquant', + blocking: true, + message: "Prix trop élevé pour le code-barres.", + thresholds: [ + { + path: 'limits.max_amount_cents', + label: 'Montant maximum', + hint: 'En centimes. Aucun préfixe du plan livré n’encode un prix : la règle est ' + + 'éprouvée sans qu’aucun produit puisse l’atteindre.', + }, + ], + switchPath: '', + switchLabel: '', + note: '', + }, + { + rank: 12, + code: 'ZERO_PRICE', + label: 'Prix nul', + when: 'Le montant du tarif imprimé en grand vaut zéro.', + severity: 'Bloquant', + blocking: true, + message: "Prix nul. Appelez un bénévole.", + thresholds: [], + switchPath: '', + switchLabel: '', + note: 'Aucun seuil : un produit à 0 € est une anomalie sans nuance.', + }, + { + rank: 13, + code: 'LIGHT_PRODUCT_ALLOWED', + label: 'Produit léger autorisé', + when: 'Le garde-fou 8 n’a pas déclenché grâce à la dérogation du produit.', + severity: 'Information', + blocking: false, + message: '', + thresholds: [], + switchPath: '', + switchLabel: '', + note: + 'Aucun seuil général : c’est la dérogation par produit, listée plus bas et posée ' + + 'depuis l’onglet Catalogue. Rien ne s’affiche au client ; l’id du produit ' + + 'est journalisé.', + }, + { + rank: 14, + code: 'PRODUCT_WITHDRAWN', + label: 'Produit retiré', + when: 'Quelqu’un a décidé de ne plus proposer ce produit.', + severity: 'Bloquant', + blocking: true, + message: "Ce produit n'est pas disponible.", + thresholds: [], + switchPath: '', + switchLabel: '', + note: + 'Aucun seuil : c’est une décision humaine, prise depuis l’onglet Catalogue. ' + + 'Aucune règle d’import ne peut la déduire.', + }, +] + +/** + * The markers the shipped messages carry, out of the CLOSED list of `safeguard.go`. + * + * They are named on screen because they are visible in the quotations above: a reader who + * does not know what `{{.Weight}}` is takes it for a defect of the message. + */ +export const PLACEHOLDERS = ['{{.Weight}}', '{{.Quantity}}'] + +/** + * How a verdict names itself to a volunteer. + * + * The stream spells the code in English — `WEIGHT_TOO_LOW` — and a volunteer at the + * counter must not have to translate it. An unknown code says so instead of being printed + * as a label: this screen can be older than the binary it talks to. + * + * @param code - the English code, as the stream spells it. + */ +export function labelOfCode(code: string): string { + return SAFEGUARDS.find((rule) => rule.code === code)?.label ?? 'Garde-fou inconnu de cet écran' +} + +/** + * How a severity reads. The service spells it `blocking` or `info`. + * + * @param severity - the token the service wrote. + */ +export function frenchSeverity(severity: string): string { + if (severity === 'blocking') return 'Bloquant' + if (severity === 'info') return 'Information' + return 'Sévérité inconnue de cet écran' +} diff --git a/web/src/admin/lib/standing.ts b/web/src/admin/lib/standing.ts new file mode 100644 index 0000000..884952e --- /dev/null +++ b/web/src/admin/lib/standing.ts @@ -0,0 +1,126 @@ +import type { PrinterDTO, ScaleDTO } from '../../lib/dto' +import { frenchDateTime, frenchDuration, frenchInteger } from './format' +import type { LightLevel } from './lights' + +/** + * What the station OBSERVES of a piece of hardware, in one word and one sentence. + * + * The rule of the Matériel page in one line: this comes from what the station sees, never + * from what somebody declared about it. And it is always FRENCH — `printer.health` is one + * of four English tokens, which the page used to show a volunteer as they came. + */ +export interface Standing { + level: LightLevel + /** Le mot français que lit un bénévole. Jamais un jeton du service. */ + word: string + detail: string +} + +/** + * L'en-tête d'état de la balance : ce que le poste OBSERVE, jamais ce qu'on déclare. + * + * @param present - le poste déclare-t-il une balance ? + * @param scale - ce que le poste sait de sa balance sans la lui demander. + */ +export function standingOfScale(present: boolean, scale: ScaleDTO): Standing { + if (!present) { + return { + level: 'off', + word: 'Sans balance', + detail: + 'Ce poste est déclaré sans balance : le feu est éteint et le poids se saisit à la main.', + } + } + if (!scale.connected) { + return { + level: 'fault', + word: 'Sans réponse', + detail: + 'Elle ne répond plus. Vérifiez le câble et l’alimentation, puis « Tester la ' + + 'balance » sur la page Dépannage.', + } + } + if (scale.too_slow) { + return { + level: 'warn', + word: 'Trop lente', + detail: + cadenceOf(scale) + + ' À cette cadence, un poids serait déclaré périmé avant l’arrivée de la mesure suivante.', + } + } + return { level: 'ok', word: 'Connectée', detail: 'Elle répond. ' + cadenceOf(scale) } +} + +/** + * La cadence OBSERVÉE, et rien quand aucun intervalle n'a encore été mesuré. + * + * @param scale - ce que le poste sait de sa balance. + */ +export function cadenceOf(scale: ScaleDTO): string { + if (scale.observations_count === 0) { + return 'Aucun intervalle n’a encore été mesuré : la cadence sera connue dès les premières trames.' + } + const measured = `Une mesure toutes les ${frenchDuration(scale.median_ms)} sur ${frenchInteger( + scale.observations_count, + )} intervalles` + return measured + (scale.provisional ? ', cadence encore provisoire.' : '.') +} + +/** Les quatre états que le superviseur d'impression publie (§13.1), et leurs mots. */ +const PRINTER_STANDINGS: Record = { + ready: { + level: 'ok', + word: 'Prête', + detail: 'Elle répond et n’a rien à signaler.', + }, + consumable: { + level: 'warn', + word: 'Rouleau en fin de vie', + detail: 'Elle imprime, mais le rouleau arrive en fin de vie.', + }, + faulted: { + level: 'fault', + word: 'En panne', + detail: 'Elle ne peut pas imprimer.', + }, + unknown: { + level: 'unknown', + word: 'Silencieuse', + detail: + 'Elle prend les étiquettes et ne dit rien en retour : c’est la réponse normale ' + + 'd’une file Windows en RAW ou d’un fichier de périphérique, pas une panne.', + }, +} + +/** + * L'en-tête d'état de l'imprimante, EN FRANÇAIS. + * + * `printer.health` vaut `ready`, `consumable`, `faulted` ou `unknown` : quatre jetons + * anglais que la page affichait tels quels à un bénévole. Un jeton que cette table ne + * connaît pas ne passe pas non plus — il devient « État inconnu », ce qui est la vérité. + * + * @param printer - la dernière chose que le superviseur a vue de l'imprimante. + */ +export function standingOfPrinter(printer: PrinterDTO): Standing { + const said = PRINTER_STANDINGS[printer.health] ?? { + level: 'unknown' as LightLevel, + word: 'État inconnu', + detail: 'Le poste a répondu un état que cet écran ne sait pas nommer.', + } + return { ...said, detail: printer.detail === '' ? said.detail : printer.detail } +} + +/** + * Ce que l'imprimante a dit, et QUAND elle l'a dit. + * + * @param printer - la dernière chose que le superviseur a vue de l'imprimante. + */ +export function printerObservation(printer: PrinterDTO): string { + const when = + printer.observed_at === '' + ? 'Jamais observée depuis le démarrage' + : `Observée le ${frenchDateTime(printer.observed_at)}` + const pending = printer.pending_jobs_count + return `${when}, ${frenchInteger(pending)} ${pending > 1 ? 'travaux' : 'travail'} en attente.` +} diff --git a/web/src/admin/lib/tally.ts b/web/src/admin/lib/tally.ts new file mode 100644 index 0000000..7479aea --- /dev/null +++ b/web/src/admin/lib/tally.ts @@ -0,0 +1,106 @@ +import { frenchDateTime, frenchInteger } from './format' + +/** + * What a CAPPED list says of itself. + * + * A cap that does not say what it hides is a lie by omission: a screen reading « 20 + * anomalies » on a file carrying 116 makes whoever fixes the twenty believe the work + * done. Every list of the Catalogue page and of the Matériel page goes through here, so + * no two of them can announce their ceiling in two different sets of words. + * + * The sentences live in a module rather than in the pages because they are arithmetic and + * grammar — a plural, a ceiling, a comparison — and both are testable with no DOM at all. + */ + +/** + * « 50 lignes affichées sur 116 anomalies. », or « 16 anomalies. » when nothing is hidden. + * + * The worst case this shape was written for is the field-by-field diff of the Station + * page: a hundred and thirty rows, with the button that acts on them below the fold. + * + * @param shown - how many rows are drawn. + * @param total - how many there are. + * @param singular - what one of them is called. + * @param plural - what several of them are called. + */ +export function tally(shown: number, total: number, singular: string, plural: string): string { + const noun = total > 1 ? plural : singular + if (shown >= total) return `${frenchInteger(total)} ${noun}.` + return `${frenchInteger(shown)} lignes affichées sur ${frenchInteger(total)} ${noun}.` +} + +/** + * « 20 produits affichés sur 47 — précisez votre recherche. » + * + * The search is the one list whose ceiling is ACTED upon rather than merely read: what + * fixes it is typing one more word, and the sentence says so. It used to be silent on + * both counts — the search truncated at twenty without a word. + * + * @param shown - how many products the search draws. + * @param found - how many it retained before the cap. + */ +export function matchTally(shown: number, found: number): string { + if (found > shown) { + return ( + `${frenchInteger(shown)} produits affichés sur ` + + `${frenchInteger(found)} trouvés — précisez votre recherche.` + ) + } + return `${frenchInteger(found)} ${found > 1 ? 'produits trouvés' : 'produit trouvé'}.` +} + +/** + * « 7 imports affichés : le poste n'en publie jamais plus de vingt. » + * + * The ceiling is the STATION'S here and not the screen's, which is why the sentence names + * it: nothing typed on this page will ever show a twenty-first import. + * + * @param count - how many imports the route served. + */ +export function importTally(count: number): string { + return ( + `${frenchInteger(count)} ` + + `${count > 1 ? 'imports affichés' : 'import affiché'} : ` + + 'le poste n’en publie jamais plus de vingt.' + ) +} + +/** + * Le total d'une liste, et son plafond quand elle en a un. + * + * The other shape a ceiling takes, and it is the Matériel page's: there, the total is the + * point and the ceiling is a footnote to it. On the Catalogue page it is the other way + * round, which is why {@link tally} leads with what is drawn. + * + * Aucune liste de la page Matériel n'est servie entière sans le dire : un poste peut + * porter trente files d'impression — PDF, OneNote, télécopie — et une liste tronquée en + * silence est une liste qui ment. + * + * @param singular - le nom au singulier, accord compris. + * @param plural - le même au pluriel. + * @param total - combien il y en a vraiment. + * @param cap - combien de lignes sont affichées au plus. + */ +export function census(singular: string, plural: string, total: number, cap: number): string { + const head = `${frenchInteger(total)} ${total > 1 ? plural : singular}` + if (total <= cap) return head + '.' + // « lignes » et non le nom compté : l'accord reste juste quel que soit ce qu'on liste. + return `${head} — seules les ${frenchInteger(cap)} premières lignes sont affichées.` +} + +/** + * « 4 produits retirés depuis l'import du 24/07/2026 à 11:02. » + * + * The date is that of the last APPLIED import and never of the last one recorded: the + * history carries the refused, the failed and the unchanged too, and dating a withdrawal + * from a file the station discarded names an import that never served anything. + * + * @param count - how many products the last import withdrew. + * @param previousImportAt - when the previous applied import happened, empty when there + * is none to date it from. + */ +export function withdrawnSentence(count: number, previousImportAt: string): string { + const said = `${frenchInteger(count)} ${count > 1 ? 'produits retirés' : 'produit retiré'}` + if (previousImportAt === '') return said + '.' + return `${said} depuis l’import du ${frenchDateTime(previousImportAt)}.` +} diff --git a/web/src/admin/lib/tiers.ts b/web/src/admin/lib/tiers.ts new file mode 100644 index 0000000..20f5ec0 --- /dev/null +++ b/web/src/admin/lib/tiers.ts @@ -0,0 +1,89 @@ +import type { Draft } from './draft.svelte' + +/** + * The tier grid as the configuration document carries it. + * + * It is read from the DOCUMENT rather than from a type: the configuration travels exactly + * as the file writes it (§11.4), and a screen demanding a fixed shape would refuse a file + * that a station accepts. Everything here is arithmetic on what a person typed, which is + * why it is testable with no field and no browser. + */ + +/** One tier of the grid, as the document carries it. */ +export interface Tier { + code: string + label: string + abbrev: string + /** + * The raw value of `discount_percent` as the document carries it, or null when the tier + * declares none. + */ + written: string | null + /** The discount in percent when this field can show it exactly, null otherwise. */ + discount: number | null + rank: number +} + +/** + * Reads the tier grid from the draft. + * + * @param source - the configuration being edited. + */ +export function tiersOf(source: Draft): Tier[] { + const value = source.value('pricing.tiers') + if (!Array.isArray(value)) return [] + return value.map((raw) => { + const row = (raw ?? {}) as Record + const discountValue = row.discount_percent + return { + code: String(row.code ?? ''), + label: String(row.label ?? ''), + abbrev: String(row.abbrev ?? ''), + written: discountValue === undefined ? null : String(discountValue), + discount: showable(discountValue) ? (discountValue as number) : null, + rank: Number(row.rank ?? 0), + } + }) +} + +/** + * Whether a value read from the document is a discount a field can show. + * + * The draft holds whatever a file carries, including what a hand edit put there. Showing + * 33.333 as « 33,3 » would display a figure nobody declared, and one arrow key would then + * save it — so the line falls back to read-only instead. + * + * The tenth is tested with a tolerance and not with `Number.isInteger(value * 10)`, + * because `10.2 * 10` is 101.99999999999999 in binary floating point. That is the very + * reason the kernel stores tenths as an integer. + * + * @param value - what the document holds for that tier. + */ +function showable(value: unknown): boolean { + if (typeof value !== 'number' || !Number.isFinite(value)) return false + if (value < 0 || value > 100) return false + return Math.abs(value * 10 - Math.round(value * 10)) < 1e-9 +} + +/** + * A discount as a volunteer writes it: a French comma, no trailing zero. + * + * @param discount - the discount in percent, or null. + */ +export function discountText(discount: number | null): string { + return discount === null ? '' : String(discount).replace('.', ',') +} + +/** + * What the discount does to a price, on a round ten euros. + * + * Ten euros is not decoration: `1000 c x (100 - d) / 100` falls exactly on a cent for + * every discount at a tenth of a point, so this preview needs NO rounding and cannot + * contradict the label coming out of the printer. It reads no product and calls no route. + * + * @param discount - the discount in percent. + */ +export function previewOf(discount: number): string { + const cents = 1000 - Math.round(discount * 10) + return `${String(Math.trunc(cents / 100))},${String(cents % 100).padStart(2, '0')}` +} diff --git a/web/src/admin/lib/transports.ts b/web/src/admin/lib/transports.ts new file mode 100644 index 0000000..57723c0 --- /dev/null +++ b/web/src/admin/lib/transports.ts @@ -0,0 +1,133 @@ +import type { PrinterDeviceDTO, TransportDTO } from './dto' +import { frenchInteger } from './format' + +/** + * Le transport d'octets choisi, et la clé de `printer.options` dans laquelle il fait + * écrire (§8.4). + * + * **Laquelle est la bonne n'est jamais décidé ici** : c'est le poste qui le dit, transport + * par transport, dans `health.printer_transports`. Ce module ne fait que lire ce registre + * — mais il le lit à trois endroits qui doivent s'accorder : la liste déroulante, le champ + * d'appareil en dessous, et la liste des destinations qu'un clic écrirait dedans. + * + * Le défaut est ce qui arrive quand on ne les accorde pas : le champ d'appareil était + * câblé sur `queue` quoi qu'on choisisse au-dessus, et un poste réglé sur `tcp` + * enregistrait l'adresse de son imprimante dans la clé de la file Windows. Rien ne le + * refusait — aucun contrôle ne lie une clé à un transport — et le poste n'imprimait pas. + */ + +/** + * Les trois clés de `printer.options` qui DÉSIGNENT UN APPAREIL (§8.4). + * + * Elles sont énumérées pour deux choses seulement : ouvrir le volet sur celle qu'un + * contrôle a refusée, et savoir laquelle le champ d'appareil doit lâcher quand le + * transport change. + */ +export const DEVICE_KEYS = ['queue', 'path', 'address'] + +/** + * La clé sur laquelle l'écran se rabat quand il ne peut pas savoir. + * + * Deux cas, tous deux rares et tous deux honnêtes : un binaire sans registre de + * transports, et un fichier nommant un transport que ce poste ne connaît pas. La liste + * déroulante dit déjà le second en toutes lettres ; ce que ce repli achète, c'est qu'il + * reste un champ à corriger au lieu d'un volet vide. + */ +export const DEFAULT_DEVICE_KEY = 'queue' + +/** Ce que chaque clé d'appareil décide, en une phrase de bénévole. */ +export const DEVICE_HINTS: Record = { + queue: 'Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.', + path: 'Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.', + address: + 'L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque.', +} + +/** + * Vrai quand la configuration nomme un transport que ce poste ne déclare pas. + * + * @param transports - les transports que CE POSTE porte. + * @param chosen - celui que le fichier nomme. + */ +export function transportUnknown(transports: TransportDTO[], chosen: string): boolean { + return chosen !== '' && !transports.some((candidate) => candidate.id === chosen) +} + +/** + * Ce que la liste « Transport » propose, la valeur en cours COMPRISE. + * + * Un ` draft.set(field.path, event.currentTarget.checked)} - /> - - {field.label} - - {#if preferences.showTechnicalNames}{field.path}{/if} - {field.hint} - - -{/snippet} -
                                                                        - - -
                                                                        - - -
                                                                        - - {#if draft.config === null} -

                                                                        Lecture des réglages du poste…

                                                                        - {:else if source === 'local_drop'} - draft.set('catalog.options.directory', value)} - /> -

                                                                        - Le poste y cherche le fichier flv_{health.station}.csv, et le supprime - une fois lu : c’est ce qui dit au producteur que la livraison est prise. -

                                                                        - {:else if source === 'webdav'} - draft.set('catalog.options.url', value)} - /> - draft.set('catalog.options.username', value)} - /> - - draft.set('catalog.options.password', value)} - /> -

                                                                        - Sur un serveur WebDAV, le dépôt d’un fichier CSV depuis cet écran n’est plus - possible : le poste n’a plus de répertoire local où l’écrire. C’est le seul recours - du jour de la mise en service. -

                                                                        - {:else} -

                                                                        - Ce poste ne déclare aucune source : choisissez-en une ci-dessus, sinon il n’ira - chercher aucun catalogue. -

                                                                        - {/if} -
                                                                        + {#if health.catalog === null} @@ -1225,15 +807,16 @@ title="Ce que la grille montre" note="Un réglage d’affichage : il ne change ni le fichier reçu, ni ce que le poste sait peser." > - {@render toggle({ - path: 'ui.show_by_unit_products', - label: 'Afficher les produits vendus à l’unité', - hint: - 'Décoché, leurs tuiles quittent la grille et la recherche ne les retrouve plus. ' + + {byUnitSentence}

                                                                        + 'jamais lire la balance, et c’est le seul geste que ce réglage retire.'} + on={draft.flag('ui.show_by_unit_products')} + onchange={(on) => draft.set('ui.show_by_unit_products', on)} + /> +

                                                                        {byUnitSaid}

                                                                        Un produit masqué reste vendable : la caisse lit toujours son code-barres, et une étiquette déjà imprimée reste valable. Ce réglage ne fait que retirer sa tuile. @@ -1277,11 +860,11 @@ {/each}

                                                                        - {#each gridSentences as line, index (index)} + {#each gridLines as line, index (index)}

                                                                        {line}

                                                                        {/each} - {#if otherScreenWarning !== ''} -

                                                                        {otherScreenWarning}

                                                                        + {#if otherScreen !== ''} +

                                                                        {otherScreen}

                                                                        {/if}

                                                                        @@ -1374,56 +957,39 @@

                                                                        - - {#if historyState !== 'read'} -

                                                                        {findingsUnknown}

                                                                        - {:else if anomalies.length === 0} -

                                                                        Aucune anomalie sur le dernier import.

                                                                        - {:else} -

                                                                        - {tally(shownAnomalies.length, anomalies.length, 'anomalie', 'anomalies')} - {#if anomalies.length > shownAnomalies.length} - Corrigez celles-ci dans Odoo : l’import suivant ne signalera que ce qui reste. - {/if} -

                                                                        - {@render rows(shownAnomalies, 'anomalies')} - {/if} -
                                                                        - - + + - {#if historyState !== 'read'} -

                                                                        {findingsUnknown}

                                                                        - {:else if mismatches.length === 0} -

                                                                        Aucune unité divergente sur le dernier import.

                                                                        - {:else} -

                                                                        - {tally(shownMismatches.length, mismatches.length, 'unité divergente', 'unités divergentes')} -

                                                                        - {@render rows(shownMismatches, 'mismatches')} - {/if} -
                                                                        - - + + - {#if historyState !== 'read'} -

                                                                        {findingsUnknown}

                                                                        - {:else if neutral.length === 0} -

                                                                        Aucun produit non pesable sur le dernier import.

                                                                        - {:else} -

                                                                        - {tally(shownNeutral.length, neutral.length, 'produit non pesable', 'produits non pesables')} -

                                                                        - {@render rows(shownNeutral, 'not-weighable')} - {/if} -
                                                                        + list="not-weighable" + state={historyState} + findings={neutral} + singular="produit non pesable" + plural="produits non pesables" + none="Aucun produit non pesable sur le dernier import." + /> Aucun produit retiré par le dernier import.

                                                                        {:else} -

                                                                        {withdrawnSentence}

                                                                        +

                                                                        {withdrawnSaid}

                                                                        Ils restent enregistrés avec leur historique : une étiquette déjà collée reste lisible en caisse, et un produit qui revient dans un prochain fichier retrouve sa @@ -1451,7 +1017,7 @@ {#if query !== ''} -

                                                                        {matchTally}

                                                                        +

                                                                        {matchesSaid}

                                                                          {#each matches as product (product.id)} @@ -1509,7 +1075,7 @@ label="Ne plus proposer ce produit" protected busy={working === 'offered'} - disabled={busy || !canWithdraw} + disabled={busy || !acts.canWithdraw} onrun={() => void setOffered(false)} /> {:else} @@ -1519,7 +1085,7 @@ label="Le proposer de nouveau" protected busy={working === 'offered'} - disabled={busy || !canOfferAgain} + disabled={busy || !acts.canOfferAgain} onrun={() => void setOffered(true)} /> {/if} @@ -1550,7 +1116,7 @@ label="Enregistrer la dérogation" protected busy={working === 'waiver'} - disabled={busy || !canSaveWaiver} + disabled={busy || !acts.canSaveWaiver} onrun={() => void setWaiver(typedWaiver)} /> {#if waiverInForce !== null} @@ -1560,7 +1126,7 @@ label="Retirer la dérogation" protected busy={working === 'waiver-off'} - disabled={busy || !canDropWaiver} + disabled={busy || !acts.canDropWaiver} onrun={() => void setWaiver(null)} /> {/if} @@ -1574,78 +1140,9 @@ {/if} - - {#if decisions.length === 0} -

                                                                          Aucune décision locale : la grille est celle du fichier.

                                                                          - {:else} -

                                                                          - {tally(shownDecisions.length, decisions.length, 'décision en vigueur', 'décisions en vigueur')} -

                                                                          -
                                                                          -
                                                                            - {#each shownDecisions as decision (decision.product_id)} -
                                                                          • - - {nameOf(decision.product_id)} - {decision.product_id} - {decisionSentence(decision)} - {decision.reason} - {frenchDate(decision.decided_at)} -
                                                                          • - {/each} -
                                                                          -
                                                                          - {/if} -
                                                                          + - - {#if historyState !== 'read'} -

                                                                          {historyUnknown}

                                                                          - {:else if imports.length === 0} -

                                                                          Aucun import dans l’historique.

                                                                          - {:else} -

                                                                          {importTally}

                                                                          -
                                                                          - - - - - - - - - - - - - - - - - {#each imports as record (record.id)} - - - - - - - - - - - - - {/each} - -
                                                                          QuandFichierSourceRésultatMotifLuesPesablesNon pesablesAnomaliesRetirés
                                                                          {frenchDateTime(record.occurred_at)}{record.file_name}{frenchSource(record)}{frenchResult(record)}{record.reason}{frenchInteger(record.rows_read_count)}{frenchInteger(record.weighable_count)}{frenchInteger(record.not_weighable_count)}{frenchInteger(record.anomalies_count)}{frenchInteger(record.products_withdrawn_count)}
                                                                          -
                                                                          - {/if} -
                                                                          +
                                                                        diff --git a/web/src/admin/pages/Hardware.svelte b/web/src/admin/pages/Hardware.svelte index 5ad8381..01cb763 100644 --- a/web/src/admin/pages/Hardware.svelte +++ b/web/src/admin/pages/Hardware.svelte @@ -2,14 +2,25 @@ import { onDestroy, onMount } from 'svelte' import Act from '../components/Act.svelte' import Field from '../components/Field.svelte' + import StandingHeader from '../components/StandingHeader.svelte' import * as api from '../lib/api' - import { AdminError } from '../lib/api' + import { AdminError, isCredentialRefusal } from '../lib/api' import type { Draft } from '../lib/draft.svelte' import type { DetectionDTO, HealthDTO, PortDTO, PrinterDeviceDTO } from '../lib/dto' + import { allowedFor, faultOf } from '../lib/faults' import { labelOf } from '../lib/fields' - import { frenchDateTime, frenchDuration, frenchInteger } from '../lib/format' - import type { LightLevel } from '../lib/lights' + import { decodedOf, hexOf } from '../lib/frames' + import { askLabel, detectLabel, framesCaption, type Listening } from '../lib/listening' import type { Admin } from '../lib/session.svelte' + import { printerObservation, standingOfPrinter, standingOfScale } from '../lib/standing' + import { census } from '../lib/tally' + import { + DEVICE_HINTS, + DEVICE_KEYS, + deviceKeyOf, + reachElsewhere, + transportChoices, + } from '../lib/transports' /** * La page Matériel de §14.4 — celle devant laquelle on est assis le jour de la mise en @@ -87,14 +98,6 @@ refused: boolean } - /** L'en-tête d'état d'un panneau : un point, un mot, et la phrase qui va avec. */ - interface Standing { - level: LightLevel - /** Le mot français que lit un bénévole. Jamais un jeton du service. */ - word: string - detail: string - } - /** Pourquoi l'écoute d'un port s'est arrêtée. Le port est nommé : la phrase le cite. */ interface Halt { port: string @@ -159,63 +162,13 @@ /** Les trames affichées : celles du port écouté, et d'aucun autre. */ const shown = $derived(framesPort === port ? frames : []) - /** - * Les trois clés de `printer.options` qui DÉSIGNENT UN APPAREIL (§8.4). - * - * Elles sont énumérées ici pour deux choses seulement : ouvrir le volet sur celle qu'un - * contrôle a refusée, et savoir laquelle le champ d'appareil doit lâcher quand le - * transport change. **Laquelle est la bonne n'est jamais décidé ici** : c'est le poste - * qui le dit, transport par transport, dans `health.printer_transports`. - */ - const DEVICE_KEYS = ['queue', 'path', 'address'] - - /** - * La clé sur laquelle la page se rabat quand elle ne peut pas savoir. - * - * Deux cas, tous deux rares et tous deux honnêtes : un binaire sans registre de - * transports, et un fichier nommant un transport que ce poste ne connaît pas. La liste - * déroulante dit déjà le second en toutes lettres ; ce que ce repli achète, c'est qu'il - * reste un champ à corriger au lieu d'un volet vide. - */ - const DEFAULT_DEVICE_KEY = 'queue' - - /** Ce que chaque clé d'appareil décide, en une phrase de bénévole. */ - const DEVICE_HINTS: Record = { - queue: 'Choisissez-la dans la liste ci-dessus : une file mal orthographiée ne s’imprime pas.', - path: 'Le nœud d’impression de ce poste, /dev/usb/lp0 ou le lien que la règle udev lui donne.', - address: - 'L’adresse de l’imprimante sur le réseau, 192.168.0.43 — le port 9100 est ajouté s’il manque.', - } - /** Les transports que CE POSTE porte, et pour chacun la clé où il fait écrire (§8.4). */ const transports = $derived(health.printer_transports) const transport = $derived(draft.text('printer.options.transport')) - /** Vrai quand la configuration nomme un transport que ce poste ne déclare pas. */ - const transportUnknown = $derived( - transport !== '' && !transports.some((candidate) => candidate.id === transport), - ) - /** - * Ce que la liste « Transport » propose, la valeur en cours COMPRISE. - * - * Un ` draft.set(path, event.currentTarget.checked)} - /> - - {label} - - {#if preferences.showTechnicalNames}{path}{/if} - {#if hint !== ''}{hint}{/if} - - -{/snippet} -
                                                                        - {#if tiers.length === 0} -

                                                                        Aucun tarif déclaré dans la configuration lue.

                                                                        - {:else} -

                                                                        {tierCount(tiers.length)}.

                                                                        -
                                                                        - - - - - - - - - - - - - {#each tiers as tier, index (index)} - - - - - - - - {/each} - -
                                                                        CodeLibelléAbrégéRemiseOrdre
                                                                        {tier.code} - - draft.set(`pricing.tiers.${String(index)}.label`, event.currentTarget.value)} - /> - - - draft.set(`pricing.tiers.${String(index)}.abbrev`, event.currentTarget.value)} - /> - - - {#if tier.code === referenceCode && tier.written === null} - Prix du catalogue Odoo — pas de remise - {:else if tier.code === referenceCode} - - {tier.written} — le tarif de référence est le prix du catalogue : il - ne peut pas porter de remise, et l’enregistrement la refusera. - - {:else if tier.written !== null && tier.discount === null} - - {tier.written} — une remise s’écrit au dixième de point ; celle-ci se - change dans le fichier de configuration. - - {:else} - - - writeDiscount( - `pricing.tiers.${String(index)}.discount_percent`, - event.currentTarget.value, - )} - onfocusout={(event) => - restoreBox(event.currentTarget, discountText(tier.discount ?? 0))} - /> % - - un produit à 10,00 €/kg s’affiche {previewOf(tier.discount ?? 0)} €/kg - - {/if} - {tier.rank}
                                                                        -
                                                                        -

                                                                        - Un champ vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la - retrouve dès qu’on quitte le champ. Une remise effacée serait le plein tarif pour - tous les adhérents. -

                                                                        - {/if} + -
                                                                          - {#each SAFEGUARDS as rule (rule.code)} -
                                                                        1. -

                                                                          - {rule.rank} - {rule.label} - {rule.code} - {rule.severity} -

                                                                          -

                                                                          {rule.when}

                                                                          - - {#if rule.message === ''} -

                                                                          Rien ne s’affiche au client : c’est une information.

                                                                          - {:else} -

                                                                          « {rule.message} »

                                                                          - {/if} - - {#if rule.switchPath !== ''} - {@render toggle(rule.switchPath, rule.switchLabel, '')} - {/if} - - {#each rule.thresholds as threshold (threshold.path)} - -
                                                                          restoreBox(event.target, draft.text(threshold.path))} - > - writeNumber(threshold.path, value)} - /> -
                                                                          - {/each} - - {#if rule.note !== ''}

                                                                          {rule.note}

                                                                          {/if} -
                                                                        2. - {/each} -
                                                                        - -

                                                                        - Un seuil vidé garde la valeur du fichier : il n’écrit pas zéro, et la case la retrouve - dès qu’on quitte le champ. Pour changer un seuil, on tape l’autre valeur. -

                                                                        - -

                                                                        - Les marqueurs {PLACEHOLDERS.join(' et ')} sont remplacés par les valeurs de la pesée au - moment où le message s’affiche. -

                                                                        +

                                                                        Ce que les garde-fous disent de la pesée en cours

                                                                        {#if diagnostics.length === 0} @@ -871,11 +198,13 @@ title="Code-barres" note="Un seul réglage, et c’est voulu : le reste du plan de numérotation n’est pas de la configuration." > - {@render toggle( - 'barcode.verify_reference_check_digit', - 'Refuser une référence dont la clé de contrôle est fausse', - 'Décoché, le poste recalcule une clé juste sur une référence fausse, en silence — et la caisse encaisse un autre article.', - )} + draft.set('barcode.verify_reference_check_digit', on)} + />

                                                                        Le plan de numérotation n’est PAS ici, et c’est tout l’intérêt : préfixes, largeur de la référence, largeur de la charge utile, décimales et mode de vente sont une @@ -896,34 +225,7 @@ title="Dérogations de poids minimum" note="En lecture ici ; elles se modifient depuis l’onglet Catalogue, là où se trouve le produit." > - {#if waivers.length === 0} -

                                                                        Aucune dérogation : la limite générale s’applique à tous les produits.

                                                                        - {:else} -

                                                                        {waiverTotal}

                                                                        - {#if namesState === 'unread'} -

                                                                        - Les noms de produits n’ont pas pu être lus : le catalogue en service n’a pas - répondu. Les identifiants Odoo restent affichés. -

                                                                        - {/if} -
                                                                          - {#each shownWaivers as waiver (waiver.product_id)} -
                                                                        • - {nameOf(waiver.product_id)} - {waiver.product_id} - {waiverFloor(waiver.min_weight_g ?? 0)} - {waiver.reason} - {frenchDate(waiver.decided_at)}, {waiver.decided_by} - {#if !waiver.offered} - - Produit retiré : le garde-fou 14 refuse le produit avant que cette - dérogation ait un sens. - - {/if} -
                                                                        • - {/each} -
                                                                        - {/if} +
                                                                        @@ -944,229 +246,17 @@ font-size: 1rem; } - /* A cell this screen must not let the operator edit: the catalog price, or a discount - it cannot show without inventing a figure nobody declared. Text only, no field - border -- there is nothing here to click into. */ - .locked { - color: var(--ink-muted); - font-size: 1rem; - } - h3 { margin: 1.5rem 0 0.5rem; font-size: 1.25rem; } - /* A wide table scrolls INSIDE its frame: the body of the page never scrolls - horizontally. */ - .scroll { - overflow-x: auto; - } - - table { - border-collapse: collapse; - width: 100%; - font-size: 1.0625rem; - } - - th, - td { - padding: 0.375rem 0.5rem; - text-align: left; - border-bottom: 1px solid var(--border); - } - - th { - color: var(--ink-muted); - font-size: 1rem; - } - - input { - /* 44 px: the density of the settings pages, which are driven with a mouse (ADR-033). - The 72 px of the client screen stay for destructive gestures, and this page has - none. */ - min-height: 2.75rem; - width: 100%; - min-width: 6rem; - padding: 0 0.5rem; - font: inherit; - font-variant-numeric: inherit; - color: var(--ink); - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - - /* - * The fourteen rules, each in its own frame. - * - * A table would have put them side by side and forced a horizontal scroll as soon as a - * rule carries two thresholds; stacked, each rule reads as one block -- what it - * refuses, what the customer reads, and the number that decides it. - */ - .rules { - margin: 0.75rem 0 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 0.75rem; - } - - .rule { - padding: 0.75rem 1rem 1rem; - background: var(--bg); - border: 1px solid var(--border-soft); - border-radius: var(--radius-sm); - } - - .rule-head { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: baseline; - margin: 0; - } - - .rank { - flex: none; - min-width: 1.75rem; - color: var(--ink-muted); - font-variant-numeric: tabular-nums; - font-weight: 700; - } - - .rule-label { - font-size: 1.125rem; - font-weight: 700; - } - - /* The English token of the service is only good for the telephone and the journal: it - stands second, never in the place of the French label. */ - .token { - color: var(--ink-muted); - font-size: 0.9375rem; - } - - .severity { - margin-left: auto; - padding: 0.125rem 0.625rem; - font-size: 0.9375rem; - border-radius: var(--radius-pill); - background: var(--waiting-wash); - } - - .severity[data-blocking='true'] { - background: var(--warning-wash); - } - - .when { - margin: 0.375rem 0 0; - font-size: 1rem; - } - - /* - * What the customer reads, laid out as a small screen inside the screen: it is a - * QUOTATION of the shipped message (`internal/domain/safeguard.go`), not a sentence - * written here. - */ - .quote { - margin: 0.5rem 0 0; - padding: 0.5rem 0.75rem; - font-size: 1.0625rem; - background: var(--surface); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-1); - } - - .quote.silent { - color: var(--ink-muted); - font-size: 1rem; - box-shadow: none; - background: var(--waiting-wash); - } - - .note { - margin: 0.5rem 0 0; - font-size: 1rem; - color: var(--ink-muted); - } - - .toggle { - display: flex; - align-items: center; - gap: 0.75rem; - min-height: 2.75rem; - margin: 0.5rem 0 0; - padding: 0.375rem 0.75rem; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--waiting-wash); - cursor: pointer; - transition: - background-color var(--tap) var(--ease), - border-color var(--tap) var(--ease); - } - - .toggle[data-on='true'] { - background: var(--ready-wash); - } - - /* What a mouse expects, and a finger never asked for (app.css). */ - @media (hover: hover) { - .toggle:hover { - border-color: var(--ink-muted); - } - } - - .toggle input { - flex: none; - width: 1.5rem; - height: 1.5rem; - min-height: 0; - min-width: 0; - padding: 0; - accent-color: var(--focus); - } - - .toggle-text { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: baseline; - } - - .toggle-label { - font-size: 1.0625rem; - font-weight: 700; - } - - .hint { - flex: 1 1 20rem; - color: var(--ink-muted); - font-size: 1rem; - } - - /* A frame for an EVENT and not for a layout: the field keeps the place it had. */ - .box { - display: contents; - } - .verdicts { margin: 0; padding: 0; list-style: none; } - /* - * A list of waivers is counted in products, not in configuration lines: it is capped - * when drawn AND inside its frame, and its total is announced above it. - */ - .waivers { - max-height: 24rem; - overflow-y: auto; - } - .verdicts li { display: flex; flex-wrap: wrap; @@ -1183,6 +273,13 @@ padding-left: 0.5rem; } + /* The English token of the service is only good for the telephone and the journal: it + stands second, never in the place of the French label. */ + .token { + color: var(--ink-muted); + font-size: 0.9375rem; + } + .what, .message { font-weight: 700; @@ -1191,24 +288,4 @@ .detail { color: var(--ink-muted); } - - /* A waiver in force for nothing: the row stays, and it says why it decides nothing. */ - .dead { - flex: 1 1 20rem; - padding: 0.125rem 0.625rem; - font-size: 1rem; - background: var(--warning-wash); - border-radius: var(--radius-sm); - } - - /* A read that failed says so: a silent list would be read as « there are none », which - is false. */ - .unread { - margin: 0.5rem 0; - padding: 0.5rem 0.75rem; - font-size: 1rem; - background: var(--fault-wash); - border-left: 0.25rem solid var(--fault); - border-radius: var(--radius-sm); - } diff --git a/web/src/admin/pages/Station.svelte b/web/src/admin/pages/Station.svelte index ae7a0ea..03f704e 100644 --- a/web/src/admin/pages/Station.svelte +++ b/web/src/admin/pages/Station.svelte @@ -1,18 +1,20 @@
                                                                        @@ -640,8 +360,8 @@ path="station.number" kind="number" value={draft.text('station.number')} - fault={faultOf('station.number')} - allowed={allowedFor('station.number')} + fault={faultOf(draft, 'station.number')} + allowed={allowedFor(draft, 'station.number')} hint="C’est de lui que dérive le nom du fichier de catalogue attendu, flv_.csv." onchange={(value) => draft.set('station.number', Number(value))} /> @@ -649,8 +369,8 @@ label="Nom du poste" path="station.name" value={draft.text('station.name')} - fault={faultOf('station.name')} - allowed={allowedFor('station.name')} + fault={faultOf(draft, 'station.name')} + allowed={allowedFor(draft, 'station.name')} hint="Ce que lit un bénévole : « Poste 2 — fruits »." onchange={(value) => draft.set('station.name', value)} /> @@ -658,8 +378,8 @@ label="Coopérative" path="station.coop" value={draft.text('station.coop')} - fault={faultOf('station.coop')} - allowed={allowedFor('station.coop')} + fault={faultOf(draft, 'station.coop')} + allowed={allowedFor(draft, 'station.coop')} onchange={(value) => draft.set('station.coop', value)} />
                                                                        @@ -742,44 +462,7 @@

                                                                        Fichier lu : {candidateName}

                                                                        {#if candidateFaults.length > 0} -
                                                                        -

                                                                        Ce fichier serait refusé en l’état : {faultTally}

                                                                        - -
                                                                          - {#each shownFaults as fault} -
                                                                        • - - {labelOf(fault.field)} - {#if preferences.showTechnicalNames}{fault.field}{/if} - {fault.message} - - {#if fault.allowed !== undefined && fault.allowed.length > 0} - Valeurs acceptées : {fault.allowed.join(', ')}. - {/if} -
                                                                        • - {/each} -
                                                                        -

                                                                        - Recopier reste possible : les valeurs entrent dans le brouillon, où elles se - corrigent champ par champ avant l’enregistrement. -

                                                                        -
                                                                        + {/if} {#if !compared} @@ -802,44 +485,7 @@ Les autres sont dans le fichier, et « Recopier » les prend tous. {/if}

                                                                        -
                                                                        - - - - - - - - - - - {#each shownDiff as entry (entry.path)} - {@const name = labelOf(entry.path)} - - - - - - {/each} - -
                                                                        ChampEn serviceDans le fichier
                                                                        - {name} - {#if preferences.showTechnicalNames && name !== entry.path} - {entry.path} - {/if} - {entry.before}{entry.after}
                                                                        -
                                                                        + {#if stripped.length > 0}

                                                                        Ce fichier ne porte pas {frenchList(stripped.map((block) => block.name))} : @@ -1060,29 +706,6 @@ background: var(--fault-wash); } - /* A file that would be refused is not a failure of the station: it is something to fix - in the draft before saving, hence the warning wash and not the fault one. */ - .faults { - margin-top: 0.75rem; - padding: 0.25rem 1rem 0.5rem; - border-left: 0.375rem solid var(--warning); - border-radius: var(--radius); - background: var(--warning-wash); - } - - .faults ul { - margin: 0; - padding-left: 1.25rem; - font-size: 1.0625rem; - } - - /* The values that WOULD work, on their own line: they are what somebody types next. */ - .allowed { - display: block; - color: var(--ink-muted); - font-size: 1rem; - } - /* * Every list of this page is BOUNDED and scrolls inside its own box. * @@ -1098,27 +721,6 @@ background: var(--bg); } - table { - border-collapse: collapse; - width: 100%; - font-size: 1.0625rem; - } - - th, - td { - padding: 0.375rem 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border); - } - - th { - position: sticky; - top: 0; - color: var(--ink-muted); - font-size: 1rem; - background: var(--bg); - } - .rows { margin: 0; padding: 0 0.75rem; diff --git a/web/test/admin-wording.test.ts b/web/test/admin-wording.test.ts index 3656da7..65ecc84 100644 --- a/web/test/admin-wording.test.ts +++ b/web/test/admin-wording.test.ts @@ -261,10 +261,19 @@ describe('l’index des champs', () => { }) }) -/** Le fichier Go qui décide, seul, quels blocs peuvent apparaître dans le bandeau. */ +/** + * Le fichier Go qui décide, seul, quels blocs peuvent apparaître dans le bandeau. + * + * Ce chemin est une DETTE, et elle s'est déjà payée : `changedBlocks` vivait dans + * `config.go`, un découpage l'a portée dans `configwrite.go`, et ce banc est devenu rouge + * pour un déplacement qui ne changeait rien. Un banc qui épingle l'EMPLACEMENT d'une + * fonction juge la forme du code au lieu de son comportement. Le remède n'est pas de + * suivre le fichier à chaque fois : c'est que le service EXPOSE cette liste — elle + * décide déjà de ce que le navigateur affiche — et que le banc la lise là. + */ const CHANGED_BLOCKS_SOURCE = resolve( dirname(fileURLToPath(import.meta.url)), - '../../internal/web/config.go', + '../../internal/web/configwrite.go', ) /**