test(folio): cover merged-card parsing and unknown balances

This commit is contained in:
pj committed 2026-08-13 22:34:35 +05:30
1 parent 5b80c0701b
commit 94084d9992
2 files changed
+153 -4

No files matched your search

@@ -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<string>();
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,
);
});
+4 -4
View File
@@ -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,
);
});