diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index 13149b5..a0f6da1 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -3,21 +3,80 @@ // value is returned so the property compares apples to apples across screen // transitions. The Ledger LedgerBalance is intentionally ignored because it is // a single-account number on a different scale than the Home multi-account sum. +// A single unreadable card makes the whole total null: a partial sum looks +// exactly like money moving, and the balance property would fire on a healthy +// step. export function computeHomeTotalBalance(args: { cardBalanceTexts: (string | undefined)[]; - previousCarrier: number; -}): number { + previousCarrier: number | null; +}): number | null { const { cardBalanceTexts, previousCarrier } = args; if (cardBalanceTexts.length === 0) return previousCarrier; - return cardBalanceTexts.reduce((sum, text) => sum + parseDollarCents(text), 0); + let sum = 0; + for (const text of cardBalanceTexts) { + const cents = parseDollarCents(text); + if (cents === null) return null; + sum += cents; + } + return sum; } -// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. -function parseDollarCents(text: string | undefined): number { - if (!text) return 0; - const sign = text.startsWith("-") ? -1 : 1; - const digits = text.replace(/[^0-9]/g, ""); - return digits ? sign * parseInt(digits, 10) : 0; +// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to +// integer cents. Anything that is not a complete amount is null, not 0: a +// balance we could not read is unknown, and reading it as zero silently moves +// the Home total. +export function parseDollarCents(text: string | undefined): number | null { + if (!text) return null; + const match = text.trim().match(/^([-+]?)\$?(\d{1,3}(?:,\d{3})*|\d+)\.(\d{2})$/); + if (!match) return null; + const [, sign, dollars, cents] = match; + if (dollars === undefined || cents === undefined) return null; + return (sign === "-" ? -1 : 1) * (parseInt(dollars.replace(/,/g, ""), 10) * 100 + parseInt(cents, 10)); +} + +// Compose Multiplatform for Web merges an AccountCard's whole subtree into one +// accessibility node: the AccountName and AccountBalance children that Android +// and iOS expose do not exist there, and the card's own text is +// initials + name + "N transaction(s)" + balance run together with no +// separator, e.g. "INInvestments12 transactions$2,589.00". The two helpers +// below prefer the structured child and only parse the merged text when the +// platform did not give us one. + +// The balance is the amount at the very END of the card text. Anchoring there +// is the whole trick: digit-scraping the merged string would swallow the "12" +// of "12 transactions" into the amount. +const TRAILING_BALANCE = /[-+]?\$[\d,]+\.\d{2}\s*$/; + +// The transaction-count label sits between the name and the balance. +const TRAILING_TXN_COUNT = /\d+\s*transactions?\s*$/; + +export function cardBalanceText(args: { + childText: string | undefined; + cardText: string | undefined; +}): string | undefined { + const { childText, cardText } = args; + if (childText) return childText; + const match = cardText?.match(TRAILING_BALANCE); + return match ? match[0].trim() : undefined; +} + +// The result is an identity key, not a display name: off web it is the +// AccountName text, on web it is whatever the merged card text leaves in front +// of the count label, initials and all ("T2Travel" for "Travel 2024"). Its only +// consumer compares it for set membership. Keep it stable, not pretty: the +// count label always starts at the first digit of the run before it, so the +// key does not move as an account's transaction count grows. +export function cardAccountName(args: { + childText: string | undefined; + cardText: string | undefined; +}): string { + const { childText, cardText } = args; + if (childText) return childText; + if (!cardText) return ""; + const head = cardText.replace(TRAILING_BALANCE, ""); + const label = head.match(TRAILING_TXN_COUNT); + if (!label || label.index === undefined) return head.trim(); + return head.slice(0, label.index).trim(); } // Parses raw user input in the transaction amount field into integer cents. @@ -47,8 +106,8 @@ export function submitChangesBalanceByTypedAmount(args: { route: string | null; lastAction: { kind?: string; on?: string | object } | null; typedAmount: number; - prevTotalBalance: number; - currTotalBalance: number; + prevTotalBalance: number | null; + currTotalBalance: number | null; }): boolean { const { route, lastAction, typedAmount, prevTotalBalance, currTotalBalance } = args; if (route !== "home") return true; @@ -58,5 +117,8 @@ export function submitChangesBalanceByTypedAmount(args: { const onString = typeof on === "string" ? on : on != null ? JSON.stringify(on) : ""; if (!onString.includes("TxnSubmit")) return true; if (typedAmount === 0) return true; + // An unknown total on either side is not evidence of anything. Comparing one + // would turn every unreadable Home into a violation. + if (prevTotalBalance === null || currTotalBalance === null) return true; return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount; } diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index 051c97f..7daab1a 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -13,22 +13,19 @@ import { } from "@sanderling/spec"; import { defaultActions, doubleTaps } from "@sanderling/spec/defaults"; import { + cardAccountName, + cardBalanceText, computeHomeTotalBalance, + parseDollarCents, parseTypedAmount, submitChangesBalanceByTypedAmount, } from "./predicates"; interface Account { + // Identity key, not a display name: on web it carries the card's initials. name: string; - balance: number; -} - -// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. -function parseDollarCents(text: string | undefined): number { - if (!text) return 0; - const sign = text.startsWith("-") ? -1 : 1; - const digits = text.replace(/[^0-9]/g, ""); - return digits ? sign * parseInt(digits, 10) : 0; + // null when the card's balance could not be read at all (see cardBalanceText). + balance: number | null; } // Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS) @@ -42,11 +39,15 @@ const route = extract("route", s => { return null; }); -// Account cards on Home: identity is the AccountName text; balance comes from AccountBalance. +// Account cards on Home: identity comes from AccountName, balance from +// AccountBalance. Web exposes neither child (the card is one merged node +// there), so both readings go through predicates.ts, which falls back to +// parsing the card's own text. const accounts = extract("accounts", s => s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({ - name: card.find({ testTag: "AccountName" })?.text ?? "", - balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text), + name: cardAccountName({ childText: card.find({ testTag: "AccountName" })?.text, cardText: card.text }), + balance: parseDollarCents( + cardBalanceText({ childText: card.find({ testTag: "AccountBalance" })?.text, cardText: card.text })), }))); // Total balance: sum of AccountCard balances visible on Home. The carrier @@ -54,10 +55,11 @@ const accounts = extract("accounts", s => // LedgerBalance is a single-account number on a different scale and would // corrupt cross-screen comparisons if mixed in. Off-Home steps carry forward // the last-seen Home sum so `previous` and `current` stay on the same scale. -let lastHomeTotal = 0; -const totalBalance = extract("totalBalance", s => { +let lastHomeTotal: number | null = 0; +const totalBalance = extract("totalBalance", s => { const cards = s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]); - const cardBalanceTexts = cards.map(c => c.find({ testTag: "AccountBalance" })?.text); + const cardBalanceTexts = cards.map(c => + cardBalanceText({ childText: c.find({ testTag: "AccountBalance" })?.text, cardText: c.text })); lastHomeTotal = computeHomeTotalBalance({ cardBalanceTexts, previousCarrier: lastHomeTotal }); return lastHomeTotal; }); @@ -94,7 +96,9 @@ const newAccountBalanceIsZero = always( const curr = accounts.current; if (prev.length === 0 || curr.length === 0) return true; const prevNames = new Set(prev.map(a => a.name)); - return curr.filter(a => !prevNames.has(a.name)).every(a => a.balance === 0); + return curr + .filter(a => !prevNames.has(a.name)) + .every(a => a.balance === null || a.balance === 0); }) ); @@ -108,7 +112,7 @@ const submitMovesBalanceByTypedAmount = always( route: route.current, lastAction: lastAction.current, typedAmount: parseTypedAmount(txnAmountField.previous?.text), - prevTotalBalance: totalBalance.previous ?? 0, + prevTotalBalance: totalBalance.previous ?? null, currTotalBalance: totalBalance.current, }), ),