mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(folio): read balances from merged cards and treat unreadable as unknown
compose for web merges the whole accountcard subtree, so the balance child never exists there and every card parsed as 0. the property then compared 0 to 0 and fired on any submit, which is a false positive generator. unknown is now null and null is vacuously true.
This commit is contained in:
1 parent
68d5633d1d
commit
5b80c0701b
2 files changed
+94
-28
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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<string | null>("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<Account[]>("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<Account[]>("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<number | null>("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,
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user