From 94084d999200d8d681f139a687b576da91962181 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 22:34:35 +0530 Subject: [PATCH] test(folio): cover merged-card parsing and unknown balances --- .../test/folio-account-card-parse.test.ts | 149 ++++++++++++++++++ pkg/spec/test/folio-total-balance.test.ts | 8 +- 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 pkg/spec/test/folio-account-card-parse.test.ts diff --git a/pkg/spec/test/folio-account-card-parse.test.ts b/pkg/spec/test/folio-account-card-parse.test.ts new file mode 100644 index 0000000..91b0cfb --- /dev/null +++ b/pkg/spec/test/folio-account-card-parse.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + cardAccountName, + cardBalanceText, + computeHomeTotalBalance, + parseDollarCents, +} from "../../../examples/folio/sanderling/predicates.ts"; + +// Android and iOS expose AccountName/AccountBalance as their own nodes. Web +// merges the card into one node whose text is initials + name + +// "N transaction(s)" + balance, with no separator between the parts. Both +// shapes have to land on the same balance, and the name has to stay usable as +// an identity key. + +const balanceOf = (cardText: string) => + parseDollarCents(cardBalanceText({ childText: undefined, cardText })); + +// Renders a card the way HomeScreen.kt does, so a test states the account and +// lets the fixture do the concatenating. +const card = (initials: string, name: string, count: number, balance: string) => + `${initials}${name}${count === 1 ? "1 transaction" : `${count} transactions`}${balance}`; + +test("structured child wins over the card text", () => { + assert.equal( + cardBalanceText({ childText: "$118.00", cardText: "SASavings1 transaction$118.00" }), + "$118.00", + ); + assert.equal( + cardAccountName({ childText: "Savings", cardText: "SASavings1 transaction$118.00" }), + "Savings", + ); +}); + +test("merged card text: balance is the amount at the end, not scraped digits", () => { + // The naive reading, text.replace(/[^0-9]/g, ""), absorbs the 12 of + // "12 transactions" and returns 12258900. + assert.equal(balanceOf("INInvestments12 transactions$2,589.00"), 258900); +}); + +test("merged card text: singular transaction label", () => { + assert.equal(balanceOf("SASavings1 transaction$118.00"), 11800); +}); + +test("merged card text: negative balance keeps its sign", () => { + assert.equal(cardBalanceText({ childText: undefined, cardText: "TRTravel3 transactions-$45.50" }), "-$45.50"); + assert.equal(balanceOf("TRTravel3 transactions-$45.50"), -4550); +}); + +test("structured child: negative balance keeps its sign", () => { + assert.equal(parseDollarCents(cardBalanceText({ childText: "-$45.50", cardText: undefined })), -4550); +}); + +test("zero-balance card is 0, not unknown", () => { + assert.equal(balanceOf("EFEmergency Fund0 transactions$0.00"), 0); + assert.equal(balanceOf("Aa0 transactions$0.00"), 0); +}); + +// The trap a lazy balance regex falls into: a name ending in digits runs +// straight into the transaction count, so only anchoring the amount at the end +// of the string gets it right. +test("name ending in digits does not leak into the balance", () => { + assert.equal(balanceOf(card("T2", "Travel 2024", 12, "$75.00")), 7500); + assert.equal(balanceOf(card("T2", "Travel 2024", 0, "$0.00")), 0); + assert.equal(balanceOf(card("20", "2024", 3, "-$1,234.56")), -123456); +}); + +// The account key only has to be stable and per-account. newAccountBalanceIsZero +// reads it as a set member: a key that drifted as an account's transaction +// count grew would make an existing account look brand new, and the property +// would fire on it for holding the balance it just earned. +test("account key is stable as the transaction count and balance move", () => { + const cards: [string, string][] = [ + ["CH", "Checking"], + ["T2", "Travel 2024"], + ["A2", "Account 2"], + ["Aa", "a"], + ["?", ""], + ["5T", "5 transactions"], + ["X9", "x9"], + ]; + for (const [initials, name] of cards) { + const keys = new Set(); + for (let count = 0; count <= 130; count++) { + keys.add(cardAccountName({ + childText: undefined, + cardText: card(initials, name, count, `$${count * 7}.50`), + })); + } + assert.equal(keys.size, 1, `key for ${JSON.stringify(name)} drifted: ${[...keys].join(", ")}`); + } +}); + +test("account keys are distinct across the accounts a run creates", () => { + const names: [string, string][] = [ + ["CH", "Checking"], + ["SA", "Savings"], + ["TR", "Travel"], + ["EF", "Emergency Fund"], + ["IN", "Investments"], + ["T2", "Travel 2024"], + ["A2", "Account 2"], + ["A1", "Account 12"], + ["Aa", "a"], + ]; + const keys = names.map(([initials, name]) => + cardAccountName({ childText: undefined, cardText: card(initials, name, 4, "$9.00") })); + assert.equal(new Set(keys).size, names.length); +}); + +// 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. +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", () => { + assert.equal(parseDollarCents(undefined), null); + assert.equal(parseDollarCents(""), null); + assert.equal(parseDollarCents("no digits here"), null); + assert.equal(parseDollarCents("$12"), null); + assert.equal(cardBalanceText({ childText: undefined, cardText: undefined }), undefined); + assert.equal(cardAccountName({ childText: undefined, cardText: undefined }), ""); +}); + +test("home total sums merged-text cards the same as structured ones", () => { + 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, + ); +}); diff --git a/pkg/spec/test/folio-total-balance.test.ts b/pkg/spec/test/folio-total-balance.test.ts index 759b608..4678d38 100644 --- a/pkg/spec/test/folio-total-balance.test.ts +++ b/pkg/spec/test/folio-total-balance.test.ts @@ -34,7 +34,7 @@ test("off Home (no cards) with carrier still 0: returns 0", () => { }); test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => { - let carrier = 0; + let carrier: number | null = 0; carrier = computeHomeTotalBalance({ cardBalanceTexts: ["$10.00", "$20.00"], previousCarrier: carrier, @@ -53,7 +53,7 @@ test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => { }); test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => { - let carrier = 0; + let carrier: number | null = 0; carrier = computeHomeTotalBalance({ cardBalanceTexts: ["$10.00", "$20.00"], previousCarrier: carrier, @@ -86,12 +86,12 @@ test("single card on Home overrides any previous carrier", () => { ); }); -test("undefined card balance text is treated as 0", () => { +test("undefined card balance text makes the total unknown, not a partial sum", () => { assert.equal( computeHomeTotalBalance({ cardBalanceTexts: [undefined, "$10.00"], previousCarrier: 0, }), - 1000, + null, ); });