Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,5 @@ desktop.ini
/.swarm/
/agentdb.rvf
/agentdb.rvf.lock
# Cache et mémoires de Serena (serveur de langage MCP) — propres à un poste.
/.serena/
2 changes: 2 additions & 0 deletions SUIVI.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/02-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2856,6 +2856,8 @@ Surcharges : `--config`, `--data`, `OPENSCALE_CONFIG`, `OPENSCALE_DATA`. **Aucun
>
> **Le compte ne suffit pourtant pas à faire arriver un réglage d'affichage, et c'est ADR-057 qui l'a découvert.** Une présentation qui change **sans** changer le nombre de produits — `show_grid_prices`, `show_by_unit_products`, `grid_columns` — ne déclenchait aucune relecture : on réglait, on enregistrait, et rien ne se passait sur l'écran d'à côté. Le flux d'état porte donc, à côté de `catalog_count`, une **empreinte de présentation** que le navigateur **compare sans jamais la lire**. Elle est prise sur le **DTO de présentation** et non sur la configuration entière : une empreinte globale rechargerait toute la grille au changement de port série ou de noircissement d'impression, pour une donnée que l'écran client ne lit pas. Un champ ajouté demain à la présentation y entre **sans qu'on ait à y penser** ; un bloc qui n'y est pas n'y entrera jamais. L'ETag du catalogue fait qu'une présentation inchangée coûte un 304.
>
> **Le compte et l'empreinte ne suffisaient pas non plus à faire arriver un nouveau prix, et c'est la production qui l'a montré (04/09/2026).** Un export de nuit aux mêmes produits pesables et à des prix nouveaux ne fait bouger ni `catalog_count` ni `presentation_digest` : le Hub bascule, l'étiquette porte le nouveau prix, et la grille gardait l'ancien jusqu'au prochain F5 — plusieurs jours, sur deux postes. Le flux porte donc un troisième signal, **`catalog_updated_at`**, l'instant de l'import appliqué — la chaîne même que `GET /api/v1/catalog` sert en `updated_at` et que la barre basse affiche (§14.3, ADR-053). Le navigateur la **compare** à celle du catalogue qu'il tient et ne la lit jamais ; un fichier déjà vu (`unchanged`, §10.5) ne bascule pas, donc ne la fait pas bouger. Ce que le renommage d'une catégorie laisse ouvert est inchangé : il ne bouge aucun des trois signaux.
>
> **Le défaut `false` s'applique en silence aux fichiers existants.** La lecture du document n'applique aucun défaut : un fichier écrit avant cette clé se relit comme « masqués ». C'est ce qui est demandé, mais un poste mis à jour perd quinze tuiles sans un message. D'où deux exigences tenues : les fichiers livrés **écrivent la clé** plutôt que de compter sur le zéro du langage — `config-lacagette.json` à `false`, `config-demo.json` à `true`, `NeutralProfile` à `false` —, et la page **Catalogue** annonce combien de produits sont concernés sur le catalogue en service (§14.4). La clé traverse `Export(false)`, donc l'**empreinte de configuration** que les postes d'un parc comparent à l'œil : ajouter cette clé change les huit caractères de tous les postes, même à valeur identique (§11.5, ADR-041).

### 11.3 Validation — un numéro n'est jamais réemployé, tous les contrôles remontés d'un coup
Expand Down
19 changes: 10 additions & 9 deletions internal/web/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,13 @@ func presentationOf(ui domain.UIConfig) catalogPresentationDTO {
//
// # Why it exists at all
//
// The browser asks for the catalog again ONLY when catalog_count moves
// (web/src/lib/session.svelte.ts). A presentation that changes without changing the count
// therefore never arrives on the grid -- which is already true of show_grid_prices, and
// with a grid setting would become « on règle, on enregistre, et rien ne se passe sur
// l'écran d'à côté ». The browser only ever COMPARES this string to the previous one; it
// is opaque to it, and the ETag of the catalog makes an unchanged presentation cost a 304.
// The browser asks for the catalog again when catalog_count moves and, since
// 04/09/2026, when catalog_updated_at moves (web/src/lib/session.svelte.ts). A
// presentation that changes without an import therefore never arrives on the grid --
// which is already true of show_grid_prices, and with a grid setting would become « on
// règle, on enregistre, et rien ne se passe sur l'écran d'à côté ». The browser only ever
// COMPARES this string to the previous one; it is opaque to it, and the ETag of the
// catalog makes an unchanged presentation cost a 304.
//
// # What it is taken over, because two readings are possible
//
Expand Down Expand Up @@ -326,9 +327,9 @@ func (s *Server) catalogOf(ctx context.Context, catalog *domain.Catalog, cfg dom
//
// What is still open: this endpoint answers with the new wording the moment it is
// asked, but nothing tells a browser already holding a loaded grid to ask again —
// catalog_count and presentation_digest are what trigger that (web/src/lib/
// session.svelte.ts), and a rename moves neither. A kiosk left running keeps the
// old label until its next catalog load.
// catalog_count, catalog_updated_at and presentation_digest are what trigger that
// (web/src/lib/session.svelte.ts), and a rename moves none of the three. A kiosk
// left running keeps the old label until its next catalog load.
for _, c := range cfg.Catalog.Categories {
out.Categories = append(out.Categories, categoryDTO{
Code: c.Code, Label: c.Label, Rank: c.Rank, Color: c.Color,
Expand Down
46 changes: 38 additions & 8 deletions internal/web/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ func TestAutomaticIsServedAsTheZeroItIsAndNeverByOmission(t *testing.T) {

// TestThePresentationDigestReachesTheClientScreen is the reason this digest exists.
//
// The browser asks for the catalog again only when `catalog_count` moves
// (web/src/lib/session.svelte.ts). A presentation that changes without changing the
// count therefore never arrives — which is already true of show_grid_prices, and with a
// grid setting would become « on règle, on enregistre, et rien ne se passe sur l'écran
// d'à côté ». The state stream carries a string the browser only ever COMPARES to the
// The browser asks for the catalog again when `catalog_count` or `catalog_updated_at`
// moves (web/src/lib/session.svelte.ts). A presentation that changes without an import
// therefore never arrives — which is already true of show_grid_prices, and with a grid
// setting would become « on règle, on enregistre, et rien ne se passe sur l'écran d'à
// côté ». The state stream carries a string the browser only ever COMPARES to the
// previous one, and that string has to move when the setting does.
func TestThePresentationDigestReachesTheClientScreen(t *testing.T) {
automatic := newBench(t)
Expand All @@ -232,6 +232,36 @@ func TestThePresentationDigestReachesTheClientScreen(t *testing.T) {
}
}

// TestTheStateStreamNamesTheCatalogInService is the defect of 04/09/2026, pinned.
//
// A nightly export with the same products at new prices swapped in the Hub, printed
// the new price on every label, and never reached a grid already on screen: the
// browser asked for the catalog again on catalog_count and on presentation_digest only,
// and a re-priced catalog moves neither. Two stations showed prices several days old
// until somebody pressed F5. The stream therefore carries the instant the catalog in
// service was imported — the `updated_at` the grid already shows as « Catalogue du … »
// — and the browser compares it to the one of the catalog it holds.
func TestTheStateStreamNamesTheCatalogInService(t *testing.T) {
yesterday := newBench(t)
page := decodeStatus[catalogDTO](t, yesterday.get("/api/v1/catalog"), http.StatusOK)
before := yesterday.state()
if before.CatalogUpdatedAt == "" || before.CatalogUpdatedAt != page.UpdatedAt {
t.Fatalf("catalog_updated_at du flux = %q, updated_at du catalogue = %q : "+
"le navigateur n'a rien à comparer", before.CatalogUpdatedAt, page.UpdatedAt)
}

today := newBench(t, func(o *benchOptions) { o.catalogAt = epoch.Add(24 * time.Hour) })
after := today.state()
if after.CatalogCount != before.CatalogCount {
t.Fatalf("le compte a bougé (%d → %d) : ce banc doit éprouver un import À COMPTE ÉGAL",
before.CatalogCount, after.CatalogCount)
}
if after.CatalogUpdatedAt == before.CatalogUpdatedAt {
t.Fatalf("instant inchangé (%q) alors qu'un import plus récent est en service : "+
"l'écran garderait ses anciens prix jusqu'au prochain F5", after.CatalogUpdatedAt)
}
}

// TestThePresentationDigestFollowsThePresentationAndNothingElse is the decision of §3,
// checked rather than reviewed.
//
Expand Down Expand Up @@ -372,9 +402,9 @@ func TestTheStationServesEveryWeighableTileWhateverTheGridShows(t *testing.T) {
// The snapshot below therefore carries what the last import wrote, and the
// configuration carries what somebody has just typed. What THIS endpoint gets is the
// second one. What is still open: a browser that already loaded the grid has no
// signal telling it to ask again — a rename moves neither catalog_count nor
// presentation_digest (web/src/lib/session.svelte.ts) — so a kiosk left running keeps
// showing the old label until its next catalog load.
// signal telling it to ask again — a rename moves none of catalog_count,
// catalog_updated_at and presentation_digest (web/src/lib/session.svelte.ts) — so a
// kiosk left running keeps showing the old label until its next catalog load.
func TestTheCategoryLabelsFollowTheConfigurationAndNotTheLastImport(t *testing.T) {
imported := domain.NewCatalog([]domain.Product{
{ID: "1", Name: "AIL", Reference: "0493021000003", Mode: domain.ByWeight,
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="color-scheme" content="light" />
<title>Pesée</title>
<script type="module" crossorigin src="/assets/index-9fjLb9_b.js"></script>
<script type="module" crossorigin src="/assets/index-DLsJ3M0Q.js"></script>
<link rel="modulepreload" crossorigin href="/assets/app-BaRSXv80.js">
<link rel="stylesheet" crossorigin href="/assets/app-Cfxw4Luj.css">
<link rel="stylesheet" crossorigin href="/assets/index-BqkwrTvW.css">
Expand Down
18 changes: 15 additions & 3 deletions internal/web/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,19 @@ type stateDTO struct {
// GET /api/v1/catalog, which the browser keeps: a snapshot at 10 Hz has no
// business carrying 355 products.
CatalogCount int `json:"catalog_count"`
// CatalogUpdatedAt is when the catalog in service was IMPORTED, RFC 3339 -- the same
// string GET /api/v1/catalog serves as updated_at, so that the browser can compare
// the two and never has to parse either. It exists because the count cannot tell
// two catalogs apart: a nightly export with the same products at new prices moved
// neither CatalogCount nor PresentationDigest, and two stations showed prices
// several days old until somebody pressed F5 (04/09/2026). Empty when no import has
// ever applied one, like updated_at.
CatalogUpdatedAt string `json:"catalog_updated_at"`
// PresentationDigest moves when, and only when, the screen settings served with the
// catalog move. It rides next to CatalogCount because it answers the same question
// -- « faut-il redemander le catalogue ? » -- for the half of that payload no count
// can speak for. The browser COMPARES it and never reads it; presentationDigest in
// catalog.go owns what goes into it and why.
// -- « faut-il redemander le catalogue ? » -- for the half of that payload neither
// the count nor the import instant can speak for. The browser COMPARES it and never
// reads it; presentationDigest in catalog.go owns what goes into it and why.
PresentationDigest string `json:"presentation_digest"`
// UnloggedCount is the counter of ADR-013: labels that came out and could not be
// journalled. A red light on the dashboard, never a refusal.
Expand Down Expand Up @@ -248,6 +256,10 @@ func (s *Server) stateOf(snap station.Snapshot) stateDTO {
Degraded: degradationOf(snap.Degraded),
CatalogCount: snap.Catalog.WeighableCount(),
UnloggedCount: snap.UnloggedWeighings,
// Read from the Hub and not from the snapshot, like the digest below: the Hub
// stamps the import that produced the catalog it swaps in (§10.8), and a
// snapshot describes a plate, a product and a printer -- never an import.
CatalogUpdatedAt: rfc3339OrEmpty(s.hub.CatalogUpdatedAt()),
// Read from the Hub and not from the snapshot: the presentation is a
// configuration that reloads hot (§11.4), and a snapshot describes a plate, a
// product and a printer -- never a screen setting.
Expand Down
25 changes: 15 additions & 10 deletions internal/web/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,14 @@ type bench struct {
// clock is filled BEFORE the tweaks run, so that a test wiring a collaborator of its
// own — a Binder, typically — gives it the same injected clock as the station.
type benchOptions struct {
clock *fake.Clock
binder *Binder
config func(*domain.Config)
catalog *domain.Catalog
clock *fake.Clock
binder *Binder
config func(*domain.Config)
catalog *domain.Catalog
// catalogAt is when that catalog was IMPORTED — the instant the composition root
// reads back from the imports table. The bench's own epoch unless a case moves it,
// which is what a case about « the same products at new prices » has to do.
catalogAt time.Time
noStore bool
assets fs.FS
images fs.FS
Expand Down Expand Up @@ -164,8 +168,8 @@ func newBench(t *testing.T, tweak ...func(*benchOptions)) *bench {
t.Helper()

clock := fake.NewClock(epoch)
o := benchOptions{clock: clock, catalog: garlicCatalog(), images: fstest.MapFS{},
catalogSources: shippedCatalogSources()}
o := benchOptions{clock: clock, catalog: garlicCatalog(), catalogAt: epoch,
images: fstest.MapFS{}, catalogSources: shippedCatalogSources()}
for _, f := range tweak {
f(&o)
}
Expand All @@ -183,10 +187,11 @@ func newBench(t *testing.T, tweak ...func(*benchOptions)) *bench {

st, err := station.New(station.Options{
Clock: clock, Config: cfg, Catalog: o.catalog,
// The instant the composition root reads back from the imports table — here, the
// bench's own epoch. A station stamps the catalog it STARTS with, and it takes
// that instant from the store precisely so that a restart cannot invent one.
CatalogAt: epoch,
// The instant the composition root reads back from the imports table — the
// bench's own epoch unless the case says otherwise. A station stamps the catalog
// it STARTS with, and it takes that instant from the store precisely so that a
// restart cannot invent one.
CatalogAt: o.catalogAt,
Scale: b.scale, Printer: b.printer,
Journal: b.store, TechnicalSink: b.store,
// The rollback puts the FILE back as well as the running station, and it is the
Expand Down
1 change: 1 addition & 0 deletions internal/web/testdata/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
"reason": "Le port de la balance ne peut pas être ouvert."
},
"catalog_count": 1,
"catalog_updated_at": "2026-07-27T08:06:48Z",
"presentation_digest": "c217a4b4",
"unlogged_weighings_count": 3
}
8 changes: 8 additions & 0 deletions web/src/lib/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export interface StateDTO {
degraded: DegradationDTO | null
/** Combien de tuiles la grille porte — les tuiles, elles, viennent du catalogue. */
catalog_count: number
/**
* Quand le catalogue en service a été importé, RFC 3339 — la même chaîne que
* `updated_at` du catalogue, pour se comparer à lui sans être lue. Le compte ne
* distingue pas deux catalogues : un export de nuit aux mêmes produits et à des
* prix nouveaux ne le faisait pas bouger, et la grille gardait ses anciens prix
* jusqu'au prochain F5 (04/09/2026). Vide tant qu'aucun import n'a été appliqué.
*/
catalog_updated_at: string
/**
* L'empreinte des réglages d'écran, OPAQUE : elle se compare, elle ne se lit pas.
*
Expand Down
23 changes: 15 additions & 8 deletions web/src/lib/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,17 @@ export class Session {
/**
* Enregistre un état, et redemande le catalogue quand ce qu'il porte a bougé.
*
* DEUX raisons de le redemander, et pas une seule. Le nombre de tuiles se compare
* au catalogue chargé ; les réglages d'écran, eux, se comparent à l'état PRÉCÉDENT,
* parce que l'empreinte voyage dans le flux et non dans le catalogue. Sans cette
* seconde comparaison, un exploitant change le nombre de colonnes, enregistre, et
* rien ne se passe sur l'écran d'à côté — la conclusion « ce réglage ne fait rien »
* contre laquelle le contrôle 46 d'ADR-031 avait été écrit. C'est déjà vrai de
* `show_grid_prices`, invisible aujourd'hui, et réparé au passage.
* TROIS raisons de le redemander, et pas une seule. Le nombre de tuiles et
* l'instant d'import se comparent au catalogue chargé ; les réglages d'écran, eux,
* se comparent à l'état PRÉCÉDENT, parce que l'empreinte voyage dans le flux et non
* dans le catalogue. Sans cette comparaison-là, un exploitant change le nombre de
* colonnes, enregistre, et rien ne se passe sur l'écran d'à côté — la conclusion
* « ce réglage ne fait rien » contre laquelle le contrôle 46 d'ADR-031 avait été
* écrit. C'est déjà vrai de `show_grid_prices`, invisible aujourd'hui, et réparé au
* passage. Sans l'instant d'import, un export de nuit aux mêmes produits et à des
* prix nouveaux ne faisait bouger ni le compte ni l'empreinte : le Hub basculait,
* l'étiquette portait le nouveau prix, et la grille gardait l'ancien jusqu'au
* prochain F5 — plusieurs jours en magasin, sur deux postes (04/09/2026).
*/
#receive(event: MessageEvent<string>): void {
this.#lastMessageAt = Date.now()
Expand All @@ -111,11 +115,14 @@ export class Session {
// validée par ETag, donc un catalogue inchangé coûte un 304 — et une présentation
// inchangée, jamais de requête du tout.
const countMoved = next.catalog_count !== this.catalog.product_count
// Deux chaînes RFC 3339 écrites par le même code Go : elles se comparent, elles
// ne se lisent pas.
const importMoved = next.catalog_updated_at !== this.catalog.updated_at
// Le premier état reçu n'a pas de précédent, et il n'en a pas besoin : `start()`
// vient de charger le catalogue, donc sa présentation est déjà celle du poste.
const presentationMoved =
previous !== null && next.presentation_digest !== previous.presentation_digest
if (countMoved || presentationMoved) {
if (countMoved || importMoved || presentationMoved) {
void this.#loadCatalog()
}
}
Expand Down
3 changes: 3 additions & 0 deletions web/test/fixtures/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export function nominalState(overrides: Partial<StateDTO> = {}): StateDTO {
},
degraded: null,
catalog_count: 331,
// Figé, comme l'empreinte ci-dessous : seul son CHANGEMENT a un sens, et
// `session.test.ts` est le banc qui le fait bouger.
catalog_updated_at: '2026-07-27T08:06:48Z',
// Opaque, et figée : seul son CHANGEMENT a un sens, et aucun de ces bancs n'en
// décrit un. `session.test.ts` est celui qui la fait bouger.
presentation_digest: 'p1',
Expand Down
3 changes: 3 additions & 0 deletions web/test/screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ function restingState(overrides: Partial<StateDTO> = {}): StateDTO {
printer: { health: 'ready', detail: '', pending_jobs_count: 0, observed_at: '' },
degraded: null,
catalog_count: catalog.product_count,
// Celui du catalogue servi, pour la même raison que l'empreinte ci-dessous : un
// instant qui différerait ferait redemander le catalogue à chaque état poussé.
catalog_updated_at: catalog.updated_at,
// Une chaîne opaque : ce qu'elle vaut n'a aucun sens ici, seul son CHANGEMENT
// en a un. Figée pour que les cas qui poussent plusieurs états ne redemandent
// pas le catalogue sans le vouloir.
Expand Down
Loading