diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index 2b22c19..6b061ab 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -211,27 +211,45 @@ export function cardAccountName(args: { return head.slice(0, label.index).trim(); } -// The digit run in front of a card's "transaction(s)" label, kept as TEXT. +// One card's transaction count, in the strongest form its SOURCE supports. The +// two forms are the whole reason this is not just a number: // -// A string rather than a number because web merges the card into one node and -// an account whose name ends in digits runs them into the count: the account -// named "-1" holding 2 transactions merges to "-1-12 transactions", whose -// maximal digit run reads 12. That prefix is fixed for a given account, so two -// readings whose runs are the SAME LENGTH still differ by exactly the true -// difference (19 to 120 is impossible; 19 to 110 is a length change). Two runs -// of different lengths do not, and 9 to 10 would read as 19 to 110, a delta of -// 91 out of a delta of 1. Keeping the run as text is what lets the predicate -// see the length change and drop the pair instead of convicting on it. +// - a NUMBER when the count came from the card's own AccountTxnCount node. +// That node's text is the count label and nothing else, so its digits are +// the count and subtracting two readings is exact arithmetic; +// - the digit RUN as TEXT when the count had to be recovered from merged card +// text. Web merges the AccountCard subtree into one node, and an account +// whose name ends in digits runs them into the count: the account named +// "-1" holding 2 transactions merges to "-1-12 transactions", whose maximal +// digit run reads 12. That prefix is fixed for a given account, so two +// readings whose runs are the SAME LENGTH still differ by exactly the true +// difference (19 to 120 is impossible; 19 to 110 is a length change), while +// two of different lengths do not: 9 to 10 reads as 19 to 110, a delta of +// 91 out of a delta of 1. Keeping the run as text is what lets +// committedTransactionsExceedSubmits see the length change and drop the +// pair instead of convicting on it. // -// Android and iOS expose AccountTxnCount as its own node, where the run is just -// the count and the length rule costs nothing but a window per decade. -export function cardTxnCountDigits(args: { +// Carrying the distinction is what keeps that length rule where it belongs. +// There is no name in front of a dedicated node's digits for it to protect +// against, so applied there it buys nothing and throws away real evidence every +// time an account crosses a decade: of the three android seed-9 runs that +// finished without convicting, two had dropped a window on this rule, one +// reading 7 against 12 and the other 4 against 10. +// +// This is a fact about the reading, not about the platform. A platform that +// starts exposing the node gets exact counts by exposing it, and one that stops +// falls back to the text rule on the same step it stops. +export type TxnCount = number | string; + +export function cardTxnCount(args: { childText: string | undefined; cardText: string | undefined; -}): string | undefined { +}): TxnCount | undefined { const { childText, cardText } = args; const source = childText ?? cardText?.replace(TRAILING_BALANCE, ""); - return source?.match(TRAILING_TXN_COUNT)?.[1]; + const digits = source?.match(TRAILING_TXN_COUNT)?.[1]; + if (digits === undefined) return undefined; + return childText === undefined ? digits : parseInt(digits, 10); } export interface Account { @@ -243,7 +261,7 @@ export interface Account { // One Home card, already parsed by the three helpers above. export interface CardReading extends Account { - digits: string | undefined; + count: TxnCount | undefined; } // The two readings Home's card list yields, each null when there is nothing in @@ -262,10 +280,10 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null // A card whose name or count is unreadable is left out rather than guessed at; // committedTransactionsExceedSubmits treats a missing account as no evidence. // Every card being unreadable leaves nothing to compare, which is unknown. -export function homeTxnCountsOf(cards: readonly CardReading[]): Record | null { - const counts: Record = {}; +export function homeTxnCountsOf(cards: readonly CardReading[]): Record | null { + const counts: Record = {}; for (const card of cards) { - if (card.name !== "" && card.digits !== undefined) counts[card.name] = card.digits; + if (card.name !== "" && card.count !== undefined) counts[card.name] = card.count; } return Object.keys(counts).length === 0 ? null : counts; } @@ -329,8 +347,8 @@ export function createdAccountHasNonZeroBalance(args: { // per-account count only ever rises; the max(0, ...) is defensive, not load // bearing. export function committedTransactionsExceedSubmits(args: { - countsBefore: Record | null; - countsAfter: Record | null; + countsBefore: Record | null; + countsAfter: Record | null; submitsInWindow: number; }): boolean { const { countsBefore, countsAfter, submitsInWindow } = args; @@ -341,16 +359,32 @@ export function committedTransactionsExceedSubmits(args: { const before = countsBefore[name]; const after = countsAfter[name]; if (before === undefined || after === undefined) continue; - // Different run lengths are not comparable: see cardTxnCountDigits. - if (before.length !== after.length) continue; - const from = parseInt(before, 10); - const to = parseInt(after, 10); - if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) continue; - if (to > from) committed += to - from; + const rise = countRise(before, after); + if (rise !== null && rise > 0) committed += rise; } return committed > submitsInWindow; } +// How far one account's count rose between two readings, or null when the pair +// is not comparable. Not comparable is not zero: the account drops out of the +// sum entirely, which can only cost a detection. +function countRise(before: TxnCount, after: TxnCount): number | null { + if (typeof before === "number" && typeof after === "number") { + if (!Number.isSafeInteger(before) || !Number.isSafeInteger(after)) return null; + return after - before; + } + // Recovered from merged card text, where the run may carry an account-name + // prefix, so only equal-length runs subtract to the true difference: see + // TxnCount. A pair whose two readings came from different sources is one + // neither rule can vouch for, and it is dropped with them. + if (typeof before !== "string" || typeof after !== "string") return null; + if (before.length !== after.length) return null; + const from = parseInt(before, 10); + const to = parseInt(after, 10); + if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return null; + return to - from; +} + // Parses raw user input in the transaction amount field into integer cents. // Mirrors the Folio app's parseCents (app/shared/.../util/Format.kt): whole // numbers like "50" become 5000 cents, decimals like "5.50" become 550, more diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index fed37f4..f0c9b2d 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -16,7 +16,7 @@ import { defaultActions, doubleTaps } from "@sanderling/spec/defaults"; import { cardAccountName, cardBalanceText, - cardTxnCountDigits, + cardTxnCount, committedTransactionsExceedSubmits, countSubmitsInWindow, createdAccountHasNonZeroBalance, @@ -29,7 +29,7 @@ import { routeOfFrame, submitChangesBalanceByTypedAmount, } from "./predicates"; -import type { Account, CardReading } from "./predicates"; +import type { Account, CardReading, TxnCount } from "./predicates"; // Screen markers, and the route each one names. Detection is by testTag // (resource-id on Android, accessibilityIdentifier on iOS). @@ -84,7 +84,7 @@ const homeCards = (s: State): CardReading[] => childText: card.find({ testTag: "AccountBalance" })?.text, cardText: card.text, })), - digits: cardTxnCountDigits({ + count: cardTxnCount({ childText: card.find({ testTag: "AccountTxnCount" })?.text, cardText: card.text, }), @@ -144,8 +144,8 @@ const accounts = extract("accounts", s => { }); // Transactions committed per account, same carrier rule. -let lastHomeTxnCounts: Record | null = null; -const homeTxnCounts = extract | null>("homeTxnCounts", s => { +let lastHomeTxnCounts: Record | null = null; +const homeTxnCounts = extract | null>("homeTxnCounts", s => { const reading = readHomeCards({ route: routeOf(s), reading: homeTxnCountsOf(homeCards(s)), diff --git a/pkg/spec/test/folio-home-card-readings.test.ts b/pkg/spec/test/folio-home-card-readings.test.ts index 0ec7a40..c76babe 100644 --- a/pkg/spec/test/folio-home-card-readings.test.ts +++ b/pkg/spec/test/folio-home-card-readings.test.ts @@ -11,12 +11,13 @@ import { import type { CardReading, HomeCardReading, + TxnCount, } from "../../../examples/folio/sanderling/predicates.ts"; -const card = (name: string, balance: number | null, digits: string | undefined) => ({ +const card = (name: string, balance: number | null, count: TxnCount | undefined) => ({ name, balance, - digits, + count, }); test("a laid-out card list reads as an account list and a count map", () => { @@ -83,11 +84,11 @@ test("an un-laid-out Home reports unknown but leaves the carrier intact", () => // Home it lands on has not drawn its list yet, and the counting invariant must // still be able to see the pair once a real Home comes back. function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) { - let carrier: Record | null = null; + let carrier: Record | null = null; let submits = 0; - const out: { counts: Record | null; submits: number }[] = []; + const out: { counts: Record | null; submits: number }[] = []; for (const step of steps) { - const reading: HomeCardReading> = readHomeCards({ + const reading: HomeCardReading> = readHomeCards({ route: step.route, reading: homeTxnCountsOf(step.cards), previousCarrier: carrier, diff --git a/pkg/spec/test/folio-txn-count-invariant.test.ts b/pkg/spec/test/folio-txn-count-invariant.test.ts index 11d3b46..ed97af0 100644 --- a/pkg/spec/test/folio-txn-count-invariant.test.ts +++ b/pkg/spec/test/folio-txn-count-invariant.test.ts @@ -2,29 +2,32 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { - cardTxnCountDigits, + cardTxnCount, committedTransactionsExceedSubmits, + homeTxnCountsOf, } 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", () => { +// string, where the count sits between the name and the balance. The reading +// carries which of the two it came from: a number is a count nothing else could +// have leaked into, a string is a digit run that may have. +test("a dedicated count node reads as a number, not a digit run", () => { assert.equal( - cardTxnCountDigits({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }), - "12", + cardTxnCount({ 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" }), + cardTxnCount({ 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" }), + cardTxnCount({ childText: undefined, cardText: "SASavings1 transaction$118.00" }), "1", ); }); @@ -32,16 +35,16 @@ test("merged card text: the singular label parses too", () => { // 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); + assert.equal(cardTxnCount({ childText: undefined, cardText: undefined }), undefined); + assert.equal(cardTxnCount({ childText: undefined, cardText: "no digits here" }), undefined); + assert.equal(cardTxnCount({ 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" }), + cardTxnCount({ childText: undefined, cardText: "-1-12 transactions-$119.00" }), "12", ); }); @@ -229,3 +232,131 @@ test("an unreadably long run is not evidence", () => { false, ); }); + +// The other side of that rule, and the reason it is scoped to merged text: a +// count read off its own node has no account name in front of it, so its digits +// ARE the count and a decade crossing is just a number getting longer. Both +// windows below are real android seed-9 readings that the unscoped length rule +// threw away, in runs that then finished clean. +test("a dedicated node's count crossing a decade is usable evidence", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: 7 }, + countsAfter: { Checking: 12 }, + submitsInWindow: 1, + }), + true, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Savings: 4 }, + countsAfter: { Savings: 10 }, + submitsInWindow: 1, + }), + true, + ); +}); + +// Recovering the window is only worth anything if it still acquits the healthy +// case, so the same crossing under a submit that earned it must not fire. +test("a dedicated node's healthy decade crossing does not convict", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: 9 }, + countsAfter: { Checking: 10 }, + submitsInWindow: 1, + }), + false, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: 9 }, + countsAfter: { Checking: 11 }, + submitsInWindow: 1, + }), + true, + ); +}); + +// The same numbers off merged text, where the digits may not be the count at +// all: still dropped. +test("the merged-text equivalent of that crossing is still dropped", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "9" }, + countsAfter: { Checking: "10" }, + submitsInWindow: 0, + }), + false, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "7" }, + countsAfter: { Checking: "12" }, + submitsInWindow: 1, + }), + false, + ); +}); + +// The boundary itself. A pair whose two readings came from different sources is +// vouched for by neither rule: the string may carry a name prefix the number +// does not, so subtracting them is not a transaction count. +test("a pair straddling the two sources is not comparable", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: 7 }, + countsAfter: { Checking: "12" }, + submitsInWindow: 1, + }), + false, + ); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: { Checking: "7" }, + countsAfter: { Checking: 12 }, + submitsInWindow: 1, + }), + false, + ); +}); + +// End to end from the two accessibility shapes, which is where the distinction +// is actually made: the same account, the same true counts, read once off a +// dedicated node and once off merged card text. +const dedicated = (name: string, count: number) => ({ + name, + balance: 0, + count: cardTxnCount({ childText: `${count} transactions`, cardText: undefined }), +}); + +const merged = (initials: string, name: string, count: number) => ({ + name, + balance: 0, + count: cardTxnCount({ + childText: undefined, + cardText: `${initials}${name}${count} transactions$0.00`, + }), +}); + +test("a dedicated-node card list convicts across a decade", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: homeTxnCountsOf([dedicated("Checking", 9)]), + countsAfter: homeTxnCountsOf([dedicated("Checking", 11)]), + submitsInWindow: 1, + }), + true, + ); +}); + +test("the merged-text card list drops the same pair", () => { + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: homeTxnCountsOf([merged("CH", "Checking", 9)]), + countsAfter: homeTxnCountsOf([merged("CH", "Checking", 11)]), + submitsInWindow: 1, + }), + false, + ); +});