From 6f7e690750ac76ed9854eecbfe41a0c7e6199dfe Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 14 Aug 2026 13:52:28 +0530 Subject: [PATCH] test(folio): pin the window rules and the count invariant --- .../test/folio-account-card-parse.test.ts | 26 +- .../folio-submit-balance-predicate.test.ts | 123 ++++++++++ pkg/spec/test/folio-submit-window.test.ts | 151 ++++++++++++ pkg/spec/test/folio-total-balance.test.ts | 155 ++++++------ .../test/folio-txn-count-invariant.test.ts | 231 ++++++++++++++++++ 5 files changed, 586 insertions(+), 100 deletions(-) create mode 100644 pkg/spec/test/folio-submit-window.test.ts create mode 100644 pkg/spec/test/folio-txn-count-invariant.test.ts diff --git a/pkg/spec/test/folio-account-card-parse.test.ts b/pkg/spec/test/folio-account-card-parse.test.ts index 91b0cfb..6b046b3 100644 --- a/pkg/spec/test/folio-account-card-parse.test.ts +++ b/pkg/spec/test/folio-account-card-parse.test.ts @@ -4,7 +4,6 @@ import { test } from "node:test"; import { cardAccountName, cardBalanceText, - computeHomeTotalBalance, parseDollarCents, } from "../../../examples/folio/sanderling/predicates.ts"; @@ -112,21 +111,12 @@ test("account keys are distinct across the accounts a run creates", () => { // elementHandle in the web runtime truncates node text at 200 characters, so a // long account name (the input corpus types 4096 "a"s) pushes the balance off // the end of the string. That balance is unknown, and unknown must not read as -// zero or the Home total silently drops a card. +// zero: newAccountBalanceIsZero passes an unknown balance rather than +// convicting a card it could not read. test("card text truncated past the balance reads as unknown, not zero", () => { const cardText = "AA" + "a".repeat(198); assert.equal(cardBalanceText({ childText: undefined, cardText }), undefined); assert.equal(balanceOf(cardText), null); - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: [ - cardBalanceText({ childText: undefined, cardText: "SASavings1 transaction$118.00" }), - cardBalanceText({ childText: undefined, cardText }), - ], - previousCarrier: 0, - }), - null, - ); }); test("empty and missing text are unknown, not zero", () => { @@ -138,12 +128,12 @@ test("empty and missing text are unknown, not zero", () => { assert.equal(cardAccountName({ childText: undefined, cardText: undefined }), ""); }); -test("home total sums merged-text cards the same as structured ones", () => { +// The two accessibility shapes have to read the same per-card balance, which is +// what the accounts extractor compares. The Home total is no longer a sum of +// these: it is the app's own TOTAL BALANCE node (see folio-total-balance.test.ts). +test("merged card text and a structured child give the same balance", () => { const merged = ["INInvestments12 transactions$2,589.00", "Aa0 transactions$0.00"].map(cardText => cardBalanceText({ childText: undefined, cardText })); - assert.equal(computeHomeTotalBalance({ cardBalanceTexts: merged, previousCarrier: 0 }), 258900); - assert.equal( - computeHomeTotalBalance({ cardBalanceTexts: ["$2,589.00", "$0.00"], previousCarrier: 0 }), - 258900, - ); + assert.deepEqual(merged, ["$2,589.00", "$0.00"]); + assert.deepEqual(merged.map(parseDollarCents), [258900, 0]); }); diff --git a/pkg/spec/test/folio-submit-balance-predicate.test.ts b/pkg/spec/test/folio-submit-balance-predicate.test.ts index 1d27c20..57a94ce 100644 --- a/pkg/spec/test/folio-submit-balance-predicate.test.ts +++ b/pkg/spec/test/folio-submit-balance-predicate.test.ts @@ -13,6 +13,7 @@ test("single submit: delta matches typed amount", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, currTotalBalance: 1500, @@ -26,6 +27,7 @@ test("double submit: delta is twice the typed amount, fires", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, currTotalBalance: 2000, @@ -39,6 +41,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "DoubleTap", on: submitOn }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, currTotalBalance: 1000, @@ -52,6 +55,7 @@ test("wrong action kind: vacuous true even with mismatch", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "InputText", on: submitOn }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, currTotalBalance: 1000, @@ -65,6 +69,7 @@ test("wrong target: vacuous true even with mismatch", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, currTotalBalance: 1000, @@ -78,6 +83,7 @@ test("null lastAction: vacuous true", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: null, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, currTotalBalance: 2000, @@ -91,6 +97,7 @@ test("zero typedAmount: vacuous true", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 0, prevTotalBalance: 1000, currTotalBalance: 1500, @@ -104,6 +111,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, currTotalBalance: 1000, @@ -117,6 +125,7 @@ test("selector as object without TxnSubmit: vacuous true", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, currTotalBalance: 1000, @@ -130,6 +139,7 @@ test("raw whole-dollar input: single submit clears", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: parseTypedAmount("50"), prevTotalBalance: 5000, currTotalBalance: 10000, @@ -143,6 +153,7 @@ test("raw whole-dollar input: double submit fires", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: parseTypedAmount("50"), prevTotalBalance: 5000, currTotalBalance: 15000, @@ -156,6 +167,7 @@ test("decimal input from empty prior balance clears", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: parseTypedAmount("5.50"), prevTotalBalance: 0, currTotalBalance: 550, @@ -169,6 +181,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "DoubleTap", on: submitOn }, + submitsInWindow: 1, typedAmount: parseTypedAmount("100"), prevTotalBalance: 0, currTotalBalance: 20000, @@ -182,6 +195,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => { submitChangesBalanceByTypedAmount({ route: "ledger", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, currTotalBalance: 0, @@ -195,6 +209,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped", submitChangesBalanceByTypedAmount({ route: "add-transaction", lastAction: { kind: "DoubleTap", on: submitOn }, + submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, currTotalBalance: 10000, @@ -208,6 +223,7 @@ test("route gate: null route is skipped", () => { submitChangesBalanceByTypedAmount({ route: null, lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, currTotalBalance: 0, @@ -221,6 +237,7 @@ test("route gate: home landing with matching delta passes", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, currTotalBalance: 5000, @@ -234,6 +251,7 @@ test("route gate: home landing with double-insert delta fires", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, currTotalBalance: 10000, @@ -258,6 +276,7 @@ test("above 2^53 a healthy single submit is not reported", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, currTotalBalance: HUGE_BALANCE + 1600, @@ -271,6 +290,7 @@ test("above 2^53 a double-submit delta is not reported either", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, currTotalBalance: HUGE_BALANCE + 3200, @@ -284,6 +304,7 @@ test("an unreadable previous balance above 2^53 is not evidence", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, currTotalBalance: 5000, @@ -300,6 +321,7 @@ test("typed amount above 2^53 is not evidence", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 1e23, prevTotalBalance: 0, currTotalBalance: 0, @@ -315,6 +337,7 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires" submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 4503599627370495, prevTotalBalance: 0, currTotalBalance: 9007199254740990, @@ -328,6 +351,7 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 9007199254740991, prevTotalBalance: 0, currTotalBalance: 9007199254740991, @@ -341,6 +365,7 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 4503599627370496, prevTotalBalance: 0, currTotalBalance: 9007199254740992, @@ -357,6 +382,7 @@ test("a large but exact difference between safe balances still fires", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: 500, prevTotalBalance: -9007199254740991, currTotalBalance: 9007199254740991, @@ -372,6 +398,7 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => { submitChangesBalanceByTypedAmount({ route: "home", lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, typedAmount: parseTypedAmount("999999999999999999999"), prevTotalBalance: 220900, currTotalBalance: 220900, @@ -379,3 +406,99 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => { true, ); }); + +// Freshness. prevTotalBalance is the last total we READ, so the window between +// it and now can hold more than one submit's transactions. A delta measured +// over such a window is not evidence about the amount typed into any one of +// them, and the android run that produced a 13000 delta against a typed 19600 +// is what that looks like: the window held a double-submit's two 19600 debits +// and an unrelated 26200 credit. +test("freshness: two submits in the window is vacuous, not a conviction", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "DoubleTap", on: submitOn }, + submitsInWindow: 2, + typedAmount: 19600, + prevTotalBalance: 0, + currTotalBalance: -13000, + }), + true, + ); +}); + +test("freshness: two submits cannot convict even on a clean 2x delta", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 2, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 2000, + }), + true, + ); +}); + +// The boundary of the rule, from both sides. One submit is the only window the +// property judges: zero means the total moved without a submit landing in it +// (nothing to attribute the move to), and two or more means the move is shared. +test("freshness boundary: exactly one submit is the window that convicts", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "DoubleTap", on: submitOn }, + submitsInWindow: 1, + typedAmount: 19600, + prevTotalBalance: 0, + currTotalBalance: -39200, + }), + false, + ); +}); + +test("freshness boundary: one submit with a healthy 1x delta still passes", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 1, + typedAmount: 19600, + prevTotalBalance: 0, + currTotalBalance: -19600, + }), + true, + ); +}); + +test("freshness boundary: three submits is vacuous", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 3, + typedAmount: 500, + prevTotalBalance: 0, + currTotalBalance: 2500, + }), + true, + ); +}); + +// A zero count would mean the step's own action was not counted as a submit, +// which contradicts the action gate above it. Guard it anyway: a window with no +// submit in it explains no balance move. +test("freshness boundary: a window with no submit in it is vacuous", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + submitsInWindow: 0, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 2000, + }), + true, + ); +}); diff --git a/pkg/spec/test/folio-submit-window.test.ts b/pkg/spec/test/folio-submit-window.test.ts new file mode 100644 index 0000000..fd738d5 --- /dev/null +++ b/pkg/spec/test/folio-submit-window.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + countSubmitsInWindow, + isTxnSubmitTap, + readHomeTotalBalance, +} from "../../../examples/folio/sanderling/predicates.ts"; + +const submitOn = "testTag:AddTransactionScreen > testTag:TxnSubmit"; + +test("a tap on TxnSubmit is a commit", () => { + assert.equal(isTxnSubmitTap({ kind: "Tap", on: submitOn }), true); +}); + +test("a double-tap on TxnSubmit is ONE commit action, not two", () => { + const window = countSubmitsInWindow({ + previousCount: 0, + lastAction: { kind: "DoubleTap", on: submitOn }, + fresh: true, + }); + assert.equal(window.reported, 1); +}); + +test("a selector object naming TxnSubmit is a commit", () => { + assert.equal(isTxnSubmitTap({ kind: "Tap", on: { testTag: "TxnSubmit" } }), true); +}); + +test("typing into the amount field is not a commit", () => { + assert.equal(isTxnSubmitTap({ kind: "InputText", on: submitOn }), false); +}); + +test("tapping some other button is not a commit", () => { + assert.equal(isTxnSubmitTap({ kind: "Tap", on: "testTag:AddAccountSubmit" }), false); +}); + +test("no action at all is not a commit", () => { + assert.equal(isTxnSubmitTap(null), false); +}); + +test("a fresh Home reading closes the window and the next one starts empty", () => { + assert.deepEqual( + countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: true }), + { reported: 1, next: 0 }, + ); +}); + +test("landing off Home keeps the submit in the window for the next step", () => { + assert.deepEqual( + countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: false }), + { reported: 1, next: 1 }, + ); +}); + +test("a non-submit step neither adds to nor forgets the window", () => { + assert.deepEqual( + countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "Tap", on: "testTag:AccountCard" }, fresh: false }), + { reported: 1, next: 1 }, + ); +}); + +test("a second submit with no Home reading between them counts two", () => { + assert.deepEqual( + countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "DoubleTap", on: submitOn }, fresh: true }), + { reported: 2, next: 0 }, + ); +}); + +// The two traces the freshness rule exists to tell apart, driven step by step +// through the same pair of carriers the spec holds. +function run(steps: { onHome: boolean; totalText?: string; lastAction: unknown }[]) { + let carrier: number | null = null; + let submits = 0; + const out: { total: number | null; submits: number }[] = []; + for (const step of steps) { + const reading = readHomeTotalBalance({ + onHome: step.onHome, + totalText: step.totalText, + previousCarrier: carrier, + }); + carrier = reading.carrier; + const window = countSubmitsInWindow({ + previousCount: submits, + lastAction: step.lastAction as { kind?: string; on?: string } | null, + fresh: reading.fresh, + }); + submits = window.next; + out.push({ total: reading.value, submits: window.reported }); + } + return out; +} + +const idle = { kind: "Tap", on: "testTag:AccountCard" }; +const submit = { kind: "Tap", on: submitOn }; +const doubleSubmit = { kind: "DoubleTap", on: submitOn }; + +// A double-submit pops the back stack twice (each Submit calls +// navigator.back), so unlike a healthy single submit it lands back on Home, +// which is why the property can see it at all. +test("clean double submit: one action in the window, delta is 2x", () => { + const trace = run([ + { onHome: true, totalText: "$0.00", lastAction: null }, + { onHome: false, lastAction: idle }, + { onHome: false, lastAction: idle }, + { onHome: true, totalText: "$100.00", lastAction: doubleSubmit }, + ]); + assert.equal(trace[3]?.submits, 1); + assert.equal(trace[0]?.total, 0); + assert.equal(trace[3]?.total, 10000); +}); + +// The contaminated window from the android run: an unrelated submit committed +// while we were off Home, then the double-submit landed. The delta spans three +// transactions, so it is not evidence about either typed amount. +test("two submits between Home visits: the window is not evidence", () => { + const trace = run([ + { onHome: true, totalText: "$0.00", lastAction: null }, + { onHome: false, lastAction: idle }, + { onHome: false, lastAction: submit }, + { onHome: false, lastAction: idle }, + { onHome: true, totalText: "-$130.00", lastAction: doubleSubmit }, + ]); + assert.equal(trace[4]?.submits, 2); +}); + +// Freshness is restored by seeing Home, not by time passing. +test("a Home visit between two submits restores a one-action window", () => { + const trace = run([ + { onHome: true, totalText: "$0.00", lastAction: null }, + { onHome: false, lastAction: submit }, + { onHome: true, totalText: "$262.00", lastAction: idle }, + { onHome: false, lastAction: idle }, + { onHome: true, totalText: "$66.00", lastAction: doubleSubmit }, + ]); + assert.equal(trace[1]?.submits, 1); + assert.equal(trace[2]?.submits, 1); + assert.equal(trace[4]?.submits, 1); +}); + +// An unreadable Home is not a Home reading: it must not close the window, or +// the count would go back to zero against a total nobody read. +test("an unreadable Home does not close the window", () => { + const trace = run([ + { onHome: true, totalText: "$0.00", lastAction: null }, + { onHome: false, lastAction: submit }, + { onHome: true, totalText: undefined, lastAction: idle }, + { onHome: true, totalText: "$66.00", lastAction: doubleSubmit }, + ]); + assert.equal(trace[2]?.total, null); + assert.equal(trace[3]?.submits, 2); +}); diff --git a/pkg/spec/test/folio-total-balance.test.ts b/pkg/spec/test/folio-total-balance.test.ts index 4678d38..ea97ce1 100644 --- a/pkg/spec/test/folio-total-balance.test.ts +++ b/pkg/spec/test/folio-total-balance.test.ts @@ -1,97 +1,88 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts"; +import { readHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts"; -test("on Home with two cards ($10, $20): returns $30", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: ["$10.00", "$20.00"], - previousCarrier: 0, - }), - 3000, +test("on Home the app's own total is the reading, the carrier and fresh", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: true, totalText: "$30.00", previousCarrier: 0 }), + { value: 3000, carrier: 3000, fresh: true }, ); }); -test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: [], - previousCarrier: 3000, - }), - 3000, +test("off Home there is nothing to read, so the carrier is reported unchanged", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: 3000 }), + { value: 3000, carrier: 3000, fresh: false }, ); }); -test("off Home (no cards) with carrier still 0: returns 0", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: [], - previousCarrier: 0, - }), - 0, +test("off Home before any Home visit reports the null carrier, still not fresh", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: null }), + { value: null, carrier: null, fresh: false }, ); }); -test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => { - let carrier: number | null = 0; - carrier = computeHomeTotalBalance({ - cardBalanceTexts: ["$10.00", "$20.00"], - previousCarrier: carrier, +test("a negative total parses with its sign", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: true, totalText: "-$1,234.56", previousCarrier: 0 }), + { value: -123456, carrier: -123456, fresh: true }, + ); +}); + +test("a fresh Home total overrides whatever the carrier held", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: true, totalText: "$7.50", previousCarrier: 9999 }), + { value: 750, carrier: 750, fresh: true }, + ); +}); + +// The poisoned carrier. An unreadable Home total is UNKNOWN for that step, so +// null is reported and the property goes vacuous, but the carrier must keep the +// last total we actually read. Writing null into the carrier is what used to end +// the run: off-Home steps hand the carrier straight back, so a single +// unreadable Home left every later step null. +test("an unreadable Home total reports null but leaves the carrier intact", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: true, totalText: undefined, previousCarrier: 3000 }), + { value: null, carrier: 3000, fresh: false }, + ); +}); + +test("a garbled Home total is unknown, not zero", () => { + assert.deepEqual( + readHomeTotalBalance({ onHome: true, totalText: "$", previousCarrier: 3000 }), + { value: null, carrier: 3000, fresh: false }, + ); +}); + +test("an unreadable Home no longer poisons the steps after it", () => { + let carrier: number | null = null; + const seen: (number | null)[] = []; + const step = (onHome: boolean, totalText: string | undefined) => { + const reading = readHomeTotalBalance({ onHome, totalText, previousCarrier: carrier }); + carrier = reading.carrier; + seen.push(reading.value); + }; + + step(true, "$30.00"); + step(true, undefined); + step(false, undefined); + step(false, undefined); + step(true, "$50.00"); + + assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]); +}); + +// The clipped fifth account card that started this: it is not a card reading +// any more, and the footer total the app renders is unaffected by which cards +// the viewport happens to fit. +test("Home total is one node, so an off-screen account cannot change it", () => { + const withFiveCards = readHomeTotalBalance({ + onHome: true, + totalText: "$2,589.00", + previousCarrier: 0, }); - assert.equal(carrier, 3000); - carrier = computeHomeTotalBalance({ - cardBalanceTexts: [], - previousCarrier: carrier, - }); - assert.equal(carrier, 3000); - carrier = computeHomeTotalBalance({ - cardBalanceTexts: ["$20.00", "$30.00"], - previousCarrier: carrier, - }); - assert.equal(carrier, 5000); -}); - -test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => { - let carrier: number | null = 0; - carrier = computeHomeTotalBalance({ - cardBalanceTexts: ["$10.00", "$20.00"], - previousCarrier: carrier, - }); - assert.equal(carrier, 3000); - carrier = computeHomeTotalBalance({ - cardBalanceTexts: [], - previousCarrier: carrier, - }); - assert.equal(carrier, 3000); -}); - -test("negative card balance parses with sign and sums correctly", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: ["-$5.00", "$10.00"], - previousCarrier: 0, - }), - 500, - ); -}); - -test("single card on Home overrides any previous carrier", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: ["$7.50"], - previousCarrier: 9999, - }), - 750, - ); -}); - -test("undefined card balance text makes the total unknown, not a partial sum", () => { - assert.equal( - computeHomeTotalBalance({ - cardBalanceTexts: [undefined, "$10.00"], - previousCarrier: 0, - }), - null, - ); + assert.deepEqual(withFiveCards, { value: 258900, carrier: 258900, fresh: true }); }); diff --git a/pkg/spec/test/folio-txn-count-invariant.test.ts b/pkg/spec/test/folio-txn-count-invariant.test.ts new file mode 100644 index 0000000..11d3b46 --- /dev/null +++ b/pkg/spec/test/folio-txn-count-invariant.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + cardTxnCountDigits, + committedTransactionsExceedSubmits, +} from "../../../examples/folio/sanderling/predicates.ts"; + +// Android and iOS give the count its own node; web merges the card into one +// string, where the count sits between the name and the balance. +test("structured child wins over the merged card text", () => { + assert.equal( + cardTxnCountDigits({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }), + "12", + ); +}); + +test("merged card text: the count is taken from in front of the balance", () => { + assert.equal( + cardTxnCountDigits({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }), + "12", + ); +}); + +test("merged card text: the singular label parses too", () => { + assert.equal( + cardTxnCountDigits({ childText: undefined, cardText: "SASavings1 transaction$118.00" }), + "1", + ); +}); + +// The balance has to come off first, or a name ending in digits would be read +// as the count. +test("a card with no readable count is unknown, not zero", () => { + assert.equal(cardTxnCountDigits({ childText: undefined, cardText: undefined }), undefined); + assert.equal(cardTxnCountDigits({ childText: undefined, cardText: "no digits here" }), undefined); + assert.equal(cardTxnCountDigits({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined); +}); + +// Measured on a real web run: the account named "-1" holding 2 transactions +// merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12. +test("merged text runs a digit-ending name into the count", () => { + assert.equal( + cardTxnCountDigits({ childText: undefined, cardText: "-1-12 transactions-$119.00" }), + "12", + ); +}); + +const before = { Checking: "3", Savings: "1" }; + +test("healthy window: three submits, three transactions", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "5", Savings: "2" }, + submitsInWindow: 3, + }), + false, + ); +}); + +test("rejected submits commit nothing, which is under the bound", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "3", Savings: "1" }, + submitsInWindow: 4, + }), + false, + ); +}); + +// The bug, stated directly: one tap, two rows. +test("double submit: one action commits two transactions", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "5", Savings: "1" }, + submitsInWindow: 1, + }), + true, + ); +}); + +// The point of counting actions against transactions rather than gating on a +// one-submit window: a wide window is still a sound comparison. +test("wide window: five submits committing six transactions still fires", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "8", Savings: "4" }, + submitsInWindow: 5, + }), + true, + ); +}); + +test("boundary: committed equal to the submit count is not a violation", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "4", Savings: "1" }, + submitsInWindow: 1, + }), + false, + ); +}); + +test("boundary: one transaction past the submit count is", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: { Checking: "4", Savings: "2" }, + submitsInWindow: 1, + }), + true, + ); +}); + +// Only accounts in both readings count. A card that scrolled out of the +// viewport, or one whose count was unreadable, drops out of the sum, so the +// result is a lower bound on what committed. Losing a card can only cost a +// detection; it must never manufacture one. +test("an account missing from the later reading is not counted", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "3", Savings: "1" }, + countsAfter: { Checking: "3" }, + submitsInWindow: 0, + }), + false, + ); +}); + +test("an account appearing only in the later reading is not counted", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "3" }, + countsAfter: { Checking: "3", Travel: "9" }, + submitsInWindow: 0, + }), + false, + ); +}); + +test("a card that scrolled away and back is not double counted", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "3" }, + countsAfter: { Checking: "4", Savings: "40" }, + submitsInWindow: 1, + }), + false, + ); +}); + +test("an unknown reading on either side is not evidence", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: null, + countsAfter: { Checking: "99" }, + submitsInWindow: 0, + }), + false, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "0" }, + countsAfter: null, + submitsInWindow: 0, + }), + false, + ); +}); + +// The real trace this came from: at the violating step of seeds 3 and 5 the +// window held exactly one submit action and the account's count moved by two. +test("the measured web witness: submits 1, count delta 2", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { INInvestments: "12", Aa: "0" }, + countsAfter: { INInvestments: "14", Aa: "0" }, + submitsInWindow: 1, + }), + true, + ); +}); + +// The length rule, which is what keeps the merged-text prefix honest. The +// account named "-1" reads 19 at nine transactions and 110 at ten: same account, +// a delta of 91 out of a true delta of 1. Different run lengths are dropped. +test("a count crossing a digit boundary is dropped, not convicted on", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { "-1-": "19" }, + countsAfter: { "-1-": "110" }, + submitsInWindow: 1, + }), + false, + ); +}); + +test("same run length keeps the prefixed delta exact", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { "-1-": "110" }, + countsAfter: { "-1-": "112" }, + submitsInWindow: 1, + }), + true, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { "-1-": "110" }, + countsAfter: { "-1-": "111" }, + submitsInWindow: 1, + }), + false, + ); +}); + +test("an unreadably long run is not evidence", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "1".repeat(21) }, + countsAfter: { Checking: "9".repeat(21) }, + submitsInWindow: 0, + }), + false, + ); +});