fix(cassettes): say when the counts are unverified instead of reporting a guess

When the dispenser throws or the dispense times out there is no per-bay
report, so nothing is debited — not the cassette rows, not HAL's bays.
Bills may well have reached the customer, and both counters then read
high with nothing to indicate it. The machine went on treating a number
it had reason to doubt as measurement.

A dispense that ends with no report now latches a countsUncertainSince
flag, which rides along in the state document as counts_uncertain_since
so the operator can see the numbers need a recount. The field is
additive: a consumer reading positions ignores it, so this needs no
coordinated release. An operator config apply clears the flag inside the
same transaction, since asserting authoritative counts is precisely what
a recount is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 22:14:06 +02:00
commit 474903c38b
7 changed files with 130 additions and 2 deletions

View file

@ -12,11 +12,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
applyOperatorCassettesConfig,
closeDatabase,
getCashbox,
getCountsUncertainSince,
getInventory,
initDatabase,
loadCassettes,
markCountsUncertain,
recordTransaction,
setCassettes,
} from '../state-store.js'
@ -322,3 +325,43 @@ describe('state-store: getInventory represents a drained machine', () => {
expect(getInventory()).toEqual({})
})
})
describe('state-store: unverified counts after a silent dispense', () => {
it('starts clear, latches the first time, and keeps the earliest time', () => {
expect(getCountsUncertainSince()).toBeNull()
markCountsUncertain(1000)
expect(getCountsUncertainSince()).toBe(1000)
// A second failure does not move the clock forward — the question is how
// long the numbers have been untrustworthy, not when we last noticed.
markCountsUncertain(2000)
expect(getCountsUncertainSince()).toBe(1000)
})
it('clears when an operator asserts real counts', () => {
markCountsUncertain(1000)
const applied = applyOperatorCassettesConfig(
{
positions: {
'1': { denomination: 20, count: 40 },
'2': { denomination: 20, count: 40 },
'3': { denomination: 50, count: 25 },
},
},
1_700_000_000
)
expect(applied.applied).toBe(true)
// A recount is exactly the operator asserting authoritative counts.
expect(getCountsUncertainSince()).toBeNull()
})
it('leaves the flag alone when the operator config is rejected', () => {
markCountsUncertain(1000)
// Position key-set mismatch — the bay layout is hardware-determined.
const applied = applyOperatorCassettesConfig(
{ positions: { '1': { denomination: 20, count: 40 } } },
1_700_000_001
)
expect(applied.applied).toBe(false)
expect(getCountsUncertainSince()).toBe(1000)
})
})