import assert from "node:assert/strict"; import { test } from "node:test"; import { committedAmountExceedsOneSubmit, committedTransactionsExceedSubmits, countSubmitsInWindow, homeTxnCountsOf, parseAccountBalance, parseTypedAmount, readAccountBalance, readHomeCards, } from "../../../examples/folio/sanderling/predicates.ts"; import type { ObservedAction, TxnCount, } from "../../../examples/folio/sanderling/predicates.ts"; // The per-account balance the ledger and the add-transaction screen both show. // Its window closes on every frame of the transaction flow, where the Home // total's closes only when the walk goes back to Home: the iOS run in #78 went // 117 steps between two Home readings and accumulated 37 submits against a rise // of 15, so the double tap at step 32 sat in a window far too wide to judge. const submit: ObservedAction = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit", applied: true, }; const doubleSubmit: ObservedAction = { ...submit, kind: "DoubleTap" }; const openLedger: ObservedAction = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard", applied: true, }; const openAddTxn: ObservedAction = { kind: "Tap", on: "testTag:LedgerScreen > testTag:AddTransactionButton", applied: true, }; const typeAmount: ObservedAction = { kind: "InputText", on: "testTag:AddTransactionScreen > testTag:TxnAmountField", applied: true, }; const goBack: ObservedAction = { kind: "Tap", on: "testTag:BackButton", applied: true }; test("the ledger writes the balance bare and the add-transaction header labels it", () => { assert.equal(parseAccountBalance("$196.00"), 19600); assert.equal(parseAccountBalance("Balance: $196.00"), 19600); assert.equal(parseAccountBalance("-$1,234.56"), -123456); assert.equal(parseAccountBalance("Balance: -$1,234.56"), -123456); assert.equal(parseAccountBalance("$0.00"), 0); }); test("a balance that is not a complete amount is unknown, not zero", () => { assert.equal(parseAccountBalance(undefined), null); assert.equal(parseAccountBalance(""), null); assert.equal(parseAccountBalance("Balance:"), null); assert.equal(parseAccountBalance("$1,23.00"), null); }); // Which account these numbers belong to is never asked, because inside a run of // these two routes it cannot change: Route.Ledger is pushed only by tapping a // card on Home, Route.AddTransaction only by the ledger's own button for its // own account, and an accepted submit pops back to that same ledger. Reaching // another account means passing through Home, so every frame that is not one of // the two routes drops the carrier. test("a frame off the account's own screens drops the carrier", () => { for (const route of ["home", "login", "add-account", null]) { assert.deepEqual( readAccountBalance({ route, balanceText: "$196.00", previousCarrier: 10000 }), { value: null, carrier: null, fresh: false }, `route ${route} kept a carrier that may belong to another account`, ); } }); test("a readable balance on either of the two screens closes the window", () => { assert.deepEqual( readAccountBalance({ route: "ledger", balanceText: "$196.00", previousCarrier: 10000 }), { value: 19600, carrier: 19600, fresh: true }, ); assert.deepEqual( readAccountBalance({ route: "add-transaction", balanceText: "Balance: $196.00", previousCarrier: 10000, }), { value: 19600, carrier: 19600, fresh: true }, ); }); // The balance node scrolled out of the viewport is unknown, not a new value. // The account still cannot have changed, so the carrier survives and the window // stays open across the frame. test("an unreadable balance keeps the carrier and does not close the window", () => { assert.deepEqual( readAccountBalance({ route: "ledger", balanceText: undefined, previousCarrier: 10000 }), { value: 10000, carrier: 10000, fresh: false }, ); }); test("a double submit moves the account balance by twice what was typed", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: doubleSubmit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: 49200, }), true, ); }); test("a double-submitted debit is caught by the same bound", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: doubleSubmit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: -29200, }), true, ); }); test("one submit moving the balance by exactly the typed amount is the app working", () => { for (const after of [29600, -9600]) { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: after, }), false, ); } }); // The frames this bound is actually driven down, replayed off the recorded iOS // run at runs/folio-ios/20260815-102711 (seed 7, 240 steps). It judged 18 of // them and fired on none: every one was a single Tap on TxnSubmit landing back // on the account's own ledger with the balance moved by exactly what was typed, // which is the app working. Three of those readings are below, with the same // frame as it looks when the one action commits twice. // // A double tap is nowhere in that list, and the run took three of them: all // three landed on Home, where this conjunct has no balance to read and // committedTransactionsExceedSubmits convicted instead. What reaches here is // the interleaving where the second commit's pop does not run. test("the ledger landings a real run produces are judged, and a doubled one fires", () => { for (const [prev, typed] of [ [357900, 25100], [455800, 7900], [682500, 19300], ]) { const judge = (currAccountBalance: number) => committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: typed!, prevAccountBalance: prev!, currAccountBalance, }); assert.equal(judge(prev! + typed!), false, `the recorded ${prev} -> ${prev! + typed!} was convicted`); assert.equal(judge(prev! + 2 * typed!), true, `a second commit on ${prev} went unjudged`); } }); // A balance that has not moved is a commit still in flight (createTransaction // runs in a coroutine), a submit the app rejected, or a tap that never landed. // None of those is evidence, and an equality would convict all three. test("a balance that has not moved yet is not evidence", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: 10000, }), false, ); }); test("a window holding anything other than one submit is not attributable", () => { for (const submitsInWindow of [0, 2, 37]) { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: 49200, }), false, ); } }); test("an amount this reading cannot represent is vacuous, not a violation", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: parseTypedAmount("not an amount"), prevAccountBalance: 10000, currAccountBalance: 49200, }), false, ); assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: Number.MAX_SAFE_INTEGER + 2, prevAccountBalance: 10000, currAccountBalance: 49200, }), false, ); }); test("a balance too large to hold exactly is not compared", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: Number.MAX_SAFE_INTEGER + 2, currAccountBalance: 0, }), false, ); assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 0, currAccountBalance: Number.MAX_SAFE_INTEGER + 2, }), false, ); }); test("an unknown balance on either side is not evidence", () => { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: null, currAccountBalance: 49200, }), false, ); assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction: submit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: null, }), false, ); }); test("a step whose action was not a submit attributes nothing", () => { for (const lastAction of [openLedger, openAddTxn, typeAmount, goBack, null]) { assert.equal( committedAmountExceedsOneSubmit({ route: "ledger", lastAction, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: 49200, }), false, ); } }); test("Home shows every account's money, so it is not this comparison's scale", () => { for (const route of ["home", "login", "add-account", null]) { assert.equal( committedAmountExceedsOneSubmit({ route, lastAction: doubleSubmit, submitsInWindow: 1, typedAmount: 19600, prevAccountBalance: 10000, currAccountBalance: 49200, }), false, ); } }); // A step of the walk: the frame it landed on, the balance node that frame // carried, the amount field as that frame shows it, and the action that got // there. Driven through the same carrier and window the spec holds, `typed` // included: the spec hands the landing frame's field to countSubmitsInWindow // and the previous frame's to the property, and a walk that skips the first // half drives a composition the spec never runs. interface Frame { route: string | null; balanceText?: string; typed?: string; lastAction: ObservedAction | null; } function walk(frames: readonly Frame[]) { let carrier: number | null = null; let submits = 0; const verdicts: { violated: boolean; balance: number | null; submits: number }[] = []; let previous: number | null = null; let typedBefore = ""; for (const frame of frames) { const reading = readAccountBalance({ route: frame.route, balanceText: frame.balanceText, previousCarrier: carrier, }); carrier = reading.carrier; const window = countSubmitsInWindow({ previousCount: submits, lastAction: frame.lastAction, amountText: frame.route === "add-transaction" ? (frame.typed ?? "") : undefined, fresh: reading.fresh, }); submits = window.next; verdicts.push({ violated: committedAmountExceedsOneSubmit({ route: frame.route, lastAction: frame.lastAction, submitsInWindow: window.reported, typedAmount: parseTypedAmount(typedBefore), prevAccountBalance: previous, currAccountBalance: reading.value, }), balance: reading.value, submits: window.reported, }); previous = reading.value; typedBefore = frame.typed ?? ""; } return verdicts; } // The trajectory of #78: open an account, open the transaction form, type, // double tap. Not one frame of it is Home, so the Home readings the counting // invariant compares never advance and it has nothing to say about any of it. // This is what a 117-step stretch of that run looked like, and it is why the // double tap at step 32 went unconvicted. test("the Home window cannot judge a walk that never goes Home", () => { const cards = [{ name: "Checking", balance: 10000, count: 3 as TxnCount }]; let carrier: Record | null = homeTxnCountsOf(cards); let submits = 0; for (const lastAction of [openLedger, openAddTxn, typeAmount, doubleSubmit]) { const reading = readHomeCards({ route: "ledger", reading: null, previousCarrier: carrier }); const previous = carrier; carrier = reading.carrier; const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh }); submits = window.next; assert.equal( committedTransactionsExceedSubmits({ countsBefore: previous, countsAfter: reading.value, submitsInWindow: window.reported, }), false, ); } }); // The trajectory the recorded iOS run took to the frames this property judges, // with the taps that reach TxnSubmit over an empty field: 40 of its 61 submit // taps landed back on the transaction screen, and the field they read is the // one the landing frame shows. Counting those as submits is what the run // measures as the difference between 4 convictions and 0. Here the balance node // is off the viewport while they happen, so nothing resets the window and the // slack survives to the frame that matters. test("submits the app must have refused do not buy a double tap an alibi", () => { const verdicts = walk([ { route: "ledger", balanceText: "$100.00", lastAction: openLedger }, { route: "add-transaction", lastAction: openAddTxn }, { route: "add-transaction", lastAction: submit }, { route: "add-transaction", lastAction: submit }, { route: "add-transaction", typed: "196", lastAction: typeAmount }, { route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit }, ]); assert.equal(verdicts[5]?.submits, 1); assert.equal(verdicts[5]?.violated, true); }); // The same trajectory, judged where the app actually is. The double tap sends // two Submit events, and the frame it lands on says which of the two shapes // they took: two commits and two pops reach Home, where the counting invariant // judges them, and two commits with the second pop cancelled by the first stop // on the account's own ledger, which is this one. The recorded iOS run took // three double taps and all three landed on Home, so this frame is reasoned // from the app's code (AddTransactionViewModel.submit commits inside // viewModelScope, then pops) rather than measured. test("the double tap is convicted on the frame it lands on", () => { const verdicts = walk([ { route: "ledger", balanceText: "$100.00", lastAction: openLedger }, { route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn }, { route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount }, { route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit }, ]); assert.deepEqual( verdicts.map(v => v.violated), [false, false, false, true], ); assert.equal(verdicts[3]?.submits, 1); }); test("the same walk with one transaction committed is silent throughout", () => { const verdicts = walk([ { route: "ledger", balanceText: "$100.00", lastAction: openLedger }, { route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn }, { route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount }, { route: "ledger", balanceText: "$296.00", lastAction: submit }, { route: "add-transaction", balanceText: "Balance: $296.00", lastAction: openAddTxn }, { route: "add-transaction", balanceText: "Balance: $296.00", typed: "50", lastAction: typeAmount }, { route: "ledger", balanceText: "$346.00", lastAction: submit }, ]); assert.deepEqual( verdicts.map(v => v.violated), [false, false, false, false, false, false, false], ); }); // The reading a healthy app must survive: transactions arriving between two // readings that the window can no longer attribute to one action. The balance // node is off the viewport for a stretch, so the two numbers the property would // compare straddle two commits, and the balance moves by 296.00 against a typed // 50.00. Two submits in the window is not one, so there is nothing to judge. test("transactions arriving between two readings do not convict a healthy app", () => { const verdicts = walk([ { route: "ledger", balanceText: "$100.00", lastAction: openLedger }, { route: "add-transaction", lastAction: openAddTxn }, { route: "add-transaction", typed: "196", lastAction: typeAmount }, { route: "ledger", lastAction: submit }, { route: "add-transaction", lastAction: openAddTxn }, { route: "add-transaction", typed: "100", lastAction: typeAmount }, { route: "ledger", balanceText: "$396.00", lastAction: submit }, ]); assert.deepEqual( verdicts.map(v => v.violated), [false, false, false, false, false, false, false], ); assert.equal(verdicts[6]?.submits, 2); assert.equal(verdicts[6]?.balance, 39600); }); // Attribution across accounts, which is the whole reason the carrier is dropped // rather than carried. A $500.00 account is left behind for an empty one whose // screens have not drawn their balance yet, and the submit into the new account // lands with exactly one submit in the window: every gate this property has is // open, and only the dropped carrier keeps it quiet. Carrying $500.00 across // that frame reads as 30400 committed against 19600 typed, on an app that did // nothing wrong. test("a ledger opened for another account never inherits the old balance", () => { for (const between of ["home", null]) { const verdicts = walk([ { route: "ledger", balanceText: "$500.00", lastAction: openAddTxn }, { route: between, lastAction: goBack }, { route: "ledger", lastAction: openLedger }, { route: "add-transaction", typed: "196", lastAction: openAddTxn }, { route: "ledger", balanceText: "$196.00", lastAction: submit }, ]); assert.deepEqual( verdicts.map(v => v.violated), [false, false, false, false, false], `an account switch through ${between} was compared across accounts`, ); assert.equal(verdicts[4]?.submits, 1); assert.equal(verdicts[4]?.balance, 19600); } });