test(folio): pin the window rules and the count invariant

This commit is contained in:
pj committed 2026-08-14 13:52:28 +05:30
1 parent e5900712b8
commit 6f7e690750
5 files changed
+586 -100

No files matched your search

+8 -18
View File
@@ -4,7 +4,6 @@ import { test } from "node:test";
import {
cardAccountName,
cardBalanceText,
computeHomeTotalBalance,
parseDollarCents,
} from "../../../examples/folio/sanderling/predicates.ts";
@@ -112,21 +111,12 @@ test("account keys are distinct across the accounts a run creates", () => {
// 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.
// zero: newAccountBalanceIsZero passes an unknown balance rather than
// convicting a card it could not read.
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", () => {
@@ -138,12 +128,12 @@ test("empty and missing text are unknown, not zero", () => {
assert.equal(cardAccountName({ childText: undefined, cardText: undefined }), "");
});
test("home total sums merged-text cards the same as structured ones", () => {
// The two accessibility shapes have to read the same per-card balance, which is
// what the accounts extractor compares. The Home total is no longer a sum of
// these: it is the app's own TOTAL BALANCE node (see folio-total-balance.test.ts).
test("merged card text and a structured child give the same balance", () => {
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,
);
assert.deepEqual(merged, ["$2,589.00", "$0.00"]);
assert.deepEqual(merged.map(parseDollarCents), [258900, 0]);
});
@@ -13,6 +13,7 @@ test("single submit: delta matches typed amount", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1500,
@@ -26,6 +27,7 @@ test("double submit: delta is twice the typed amount, fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
@@ -39,6 +41,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -52,6 +55,7 @@ test("wrong action kind: vacuous true even with mismatch", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "InputText", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
@@ -65,6 +69,7 @@ test("wrong target: vacuous true even with mismatch", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
@@ -78,6 +83,7 @@ test("null lastAction: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: null,
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
@@ -91,6 +97,7 @@ test("zero typedAmount: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 0,
prevTotalBalance: 1000,
currTotalBalance: 1500,
@@ -104,6 +111,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -117,6 +125,7 @@ test("selector as object without TxnSubmit: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -130,6 +139,7 @@ test("raw whole-dollar input: single submit clears", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 10000,
@@ -143,6 +153,7 @@ test("raw whole-dollar input: double submit fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 15000,
@@ -156,6 +167,7 @@ test("decimal input from empty prior balance clears", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("5.50"),
prevTotalBalance: 0,
currTotalBalance: 550,
@@ -169,6 +181,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("100"),
prevTotalBalance: 0,
currTotalBalance: 20000,
@@ -182,6 +195,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
submitChangesBalanceByTypedAmount({
route: "ledger",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
@@ -195,6 +209,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
submitChangesBalanceByTypedAmount({
route: "add-transaction",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
@@ -208,6 +223,7 @@ test("route gate: null route is skipped", () => {
submitChangesBalanceByTypedAmount({
route: null,
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
@@ -221,6 +237,7 @@ test("route gate: home landing with matching delta passes", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 5000,
@@ -234,6 +251,7 @@ test("route gate: home landing with double-insert delta fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
@@ -258,6 +276,7 @@ test("above 2^53 a healthy single submit is not reported", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: HUGE_BALANCE + 1600,
@@ -271,6 +290,7 @@ test("above 2^53 a double-submit delta is not reported either", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: HUGE_BALANCE + 3200,
@@ -284,6 +304,7 @@ test("an unreadable previous balance above 2^53 is not evidence", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: 5000,
@@ -300,6 +321,7 @@ test("typed amount above 2^53 is not evidence", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1e23,
prevTotalBalance: 0,
currTotalBalance: 0,
@@ -315,6 +337,7 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires"
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 4503599627370495,
prevTotalBalance: 0,
currTotalBalance: 9007199254740990,
@@ -328,6 +351,7 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", ()
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 9007199254740991,
prevTotalBalance: 0,
currTotalBalance: 9007199254740991,
@@ -341,6 +365,7 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 4503599627370496,
prevTotalBalance: 0,
currTotalBalance: 9007199254740992,
@@ -357,6 +382,7 @@ test("a large but exact difference between safe balances still fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: -9007199254740991,
currTotalBalance: 9007199254740991,
@@ -372,6 +398,7 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("999999999999999999999"),
prevTotalBalance: 220900,
currTotalBalance: 220900,
@@ -379,3 +406,99 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => {
true,
);
});
// Freshness. prevTotalBalance is the last total we READ, so the window between
// it and now can hold more than one submit's transactions. A delta measured
// over such a window is not evidence about the amount typed into any one of
// them, and the android run that produced a 13000 delta against a typed 19600
// is what that looks like: the window held a double-submit's two 19600 debits
// and an unrelated 26200 credit.
test("freshness: two submits in the window is vacuous, not a conviction", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 2,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -13000,
}),
true,
);
});
test("freshness: two submits cannot convict even on a clean 2x delta", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 2,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
true,
);
});
// The boundary of the rule, from both sides. One submit is the only window the
// property judges: zero means the total moved without a submit landing in it
// (nothing to attribute the move to), and two or more means the move is shared.
test("freshness boundary: exactly one submit is the window that convicts", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -39200,
}),
false,
);
});
test("freshness boundary: one submit with a healthy 1x delta still passes", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -19600,
}),
true,
);
});
test("freshness boundary: three submits is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 3,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 2500,
}),
true,
);
});
// A zero count would mean the step's own action was not counted as a submit,
// which contradicts the action gate above it. Guard it anyway: a window with no
// submit in it explains no balance move.
test("freshness boundary: a window with no submit in it is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 0,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
true,
);
});
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
countSubmitsInWindow,
isTxnSubmitTap,
readHomeTotalBalance,
} from "../../../examples/folio/sanderling/predicates.ts";
const submitOn = "testTag:AddTransactionScreen > testTag:TxnSubmit";
test("a tap on TxnSubmit is a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: submitOn }), true);
});
test("a double-tap on TxnSubmit is ONE commit action, not two", () => {
const window = countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "DoubleTap", on: submitOn },
fresh: true,
});
assert.equal(window.reported, 1);
});
test("a selector object naming TxnSubmit is a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: { testTag: "TxnSubmit" } }), true);
});
test("typing into the amount field is not a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "InputText", on: submitOn }), false);
});
test("tapping some other button is not a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: "testTag:AddAccountSubmit" }), false);
});
test("no action at all is not a commit", () => {
assert.equal(isTxnSubmitTap(null), false);
});
test("a fresh Home reading closes the window and the next one starts empty", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: true }),
{ reported: 1, next: 0 },
);
});
test("landing off Home keeps the submit in the window for the next step", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: false }),
{ reported: 1, next: 1 },
);
});
test("a non-submit step neither adds to nor forgets the window", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "Tap", on: "testTag:AccountCard" }, fresh: false }),
{ reported: 1, next: 1 },
);
});
test("a second submit with no Home reading between them counts two", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "DoubleTap", on: submitOn }, fresh: true }),
{ reported: 2, next: 0 },
);
});
// The two traces the freshness rule exists to tell apart, driven step by step
// through the same pair of carriers the spec holds.
function run(steps: { onHome: boolean; totalText?: string; lastAction: unknown }[]) {
let carrier: number | null = null;
let submits = 0;
const out: { total: number | null; submits: number }[] = [];
for (const step of steps) {
const reading = readHomeTotalBalance({
onHome: step.onHome,
totalText: step.totalText,
previousCarrier: carrier,
});
carrier = reading.carrier;
const window = countSubmitsInWindow({
previousCount: submits,
lastAction: step.lastAction as { kind?: string; on?: string } | null,
fresh: reading.fresh,
});
submits = window.next;
out.push({ total: reading.value, submits: window.reported });
}
return out;
}
const idle = { kind: "Tap", on: "testTag:AccountCard" };
const submit = { kind: "Tap", on: submitOn };
const doubleSubmit = { kind: "DoubleTap", on: submitOn };
// A double-submit pops the back stack twice (each Submit calls
// navigator.back), so unlike a healthy single submit it lands back on Home,
// which is why the property can see it at all.
test("clean double submit: one action in the window, delta is 2x", () => {
const trace = run([
{ onHome: true, totalText: "$0.00", lastAction: null },
{ onHome: false, lastAction: idle },
{ onHome: false, lastAction: idle },
{ onHome: true, totalText: "$100.00", lastAction: doubleSubmit },
]);
assert.equal(trace[3]?.submits, 1);
assert.equal(trace[0]?.total, 0);
assert.equal(trace[3]?.total, 10000);
});
// The contaminated window from the android run: an unrelated submit committed
// while we were off Home, then the double-submit landed. The delta spans three
// transactions, so it is not evidence about either typed amount.
test("two submits between Home visits: the window is not evidence", () => {
const trace = run([
{ onHome: true, totalText: "$0.00", lastAction: null },
{ onHome: false, lastAction: idle },
{ onHome: false, lastAction: submit },
{ onHome: false, lastAction: idle },
{ onHome: true, totalText: "-$130.00", lastAction: doubleSubmit },
]);
assert.equal(trace[4]?.submits, 2);
});
// Freshness is restored by seeing Home, not by time passing.
test("a Home visit between two submits restores a one-action window", () => {
const trace = run([
{ onHome: true, totalText: "$0.00", lastAction: null },
{ onHome: false, lastAction: submit },
{ onHome: true, totalText: "$262.00", lastAction: idle },
{ onHome: false, lastAction: idle },
{ onHome: true, totalText: "$66.00", lastAction: doubleSubmit },
]);
assert.equal(trace[1]?.submits, 1);
assert.equal(trace[2]?.submits, 1);
assert.equal(trace[4]?.submits, 1);
});
// An unreadable Home is not a Home reading: it must not close the window, or
// the count would go back to zero against a total nobody read.
test("an unreadable Home does not close the window", () => {
const trace = run([
{ onHome: true, totalText: "$0.00", lastAction: null },
{ onHome: false, lastAction: submit },
{ onHome: true, totalText: undefined, lastAction: idle },
{ onHome: true, totalText: "$66.00", lastAction: doubleSubmit },
]);
assert.equal(trace[2]?.total, null);
assert.equal(trace[3]?.submits, 2);
});
+73 -82
View File
@@ -1,97 +1,88 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
import { readHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
test("on Home with two cards ($10, $20): returns $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: 0,
}),
3000,
test("on Home the app's own total is the reading, the carrier and fresh", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: true, totalText: "$30.00", previousCarrier: 0 }),
{ value: 3000, carrier: 3000, fresh: true },
);
});
test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 3000,
}),
3000,
test("off Home there is nothing to read, so the carrier is reported unchanged", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: 3000 }),
{ value: 3000, carrier: 3000, fresh: false },
);
});
test("off Home (no cards) with carrier still 0: returns 0", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 0,
}),
0,
test("off Home before any Home visit reports the null carrier, still not fresh", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: null }),
{ value: null, carrier: null, fresh: false },
);
});
test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => {
let carrier: number | null = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
test("a negative total parses with its sign", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: true, totalText: "-$1,234.56", previousCarrier: 0 }),
{ value: -123456, carrier: -123456, fresh: true },
);
});
test("a fresh Home total overrides whatever the carrier held", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: true, totalText: "$7.50", previousCarrier: 9999 }),
{ value: 750, carrier: 750, fresh: true },
);
});
// The poisoned carrier. An unreadable Home total is UNKNOWN for that step, so
// null is reported and the property goes vacuous, but the carrier must keep the
// last total we actually read. Writing null into the carrier is what used to end
// the run: off-Home steps hand the carrier straight back, so a single
// unreadable Home left every later step null.
test("an unreadable Home total reports null but leaves the carrier intact", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: true, totalText: undefined, previousCarrier: 3000 }),
{ value: null, carrier: 3000, fresh: false },
);
});
test("a garbled Home total is unknown, not zero", () => {
assert.deepEqual(
readHomeTotalBalance({ onHome: true, totalText: "$", previousCarrier: 3000 }),
{ value: null, carrier: 3000, fresh: false },
);
});
test("an unreadable Home no longer poisons the steps after it", () => {
let carrier: number | null = null;
const seen: (number | null)[] = [];
const step = (onHome: boolean, totalText: string | undefined) => {
const reading = readHomeTotalBalance({ onHome, totalText, previousCarrier: carrier });
carrier = reading.carrier;
seen.push(reading.value);
};
step(true, "$30.00");
step(true, undefined);
step(false, undefined);
step(false, undefined);
step(true, "$50.00");
assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]);
});
// The clipped fifth account card that started this: it is not a card reading
// any more, and the footer total the app renders is unaffected by which cards
// the viewport happens to fit.
test("Home total is one node, so an off-screen account cannot change it", () => {
const withFiveCards = readHomeTotalBalance({
onHome: true,
totalText: "$2,589.00",
previousCarrier: 0,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$20.00", "$30.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 5000);
});
test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => {
let carrier: number | null = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
});
test("negative card balance parses with sign and sums correctly", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["-$5.00", "$10.00"],
previousCarrier: 0,
}),
500,
);
});
test("single card on Home overrides any previous carrier", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$7.50"],
previousCarrier: 9999,
}),
750,
);
});
test("undefined card balance text makes the total unknown, not a partial sum", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [undefined, "$10.00"],
previousCarrier: 0,
}),
null,
);
assert.deepEqual(withFiveCards, { value: 258900, carrier: 258900, fresh: true });
});
@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
cardTxnCountDigits,
committedTransactionsExceedSubmits,
} 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", () => {
assert.equal(
cardTxnCountDigits({ 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" }),
"12",
);
});
test("merged card text: the singular label parses too", () => {
assert.equal(
cardTxnCountDigits({ childText: undefined, cardText: "SASavings1 transaction$118.00" }),
"1",
);
});
// 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);
});
// 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" }),
"12",
);
});
const before = { Checking: "3", Savings: "1" };
test("healthy window: three submits, three transactions", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "5", Savings: "2" },
submitsInWindow: 3,
}),
false,
);
});
test("rejected submits commit nothing, which is under the bound", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "3", Savings: "1" },
submitsInWindow: 4,
}),
false,
);
});
// The bug, stated directly: one tap, two rows.
test("double submit: one action commits two transactions", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "5", Savings: "1" },
submitsInWindow: 1,
}),
true,
);
});
// The point of counting actions against transactions rather than gating on a
// one-submit window: a wide window is still a sound comparison.
test("wide window: five submits committing six transactions still fires", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "8", Savings: "4" },
submitsInWindow: 5,
}),
true,
);
});
test("boundary: committed equal to the submit count is not a violation", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "4", Savings: "1" },
submitsInWindow: 1,
}),
false,
);
});
test("boundary: one transaction past the submit count is", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "4", Savings: "2" },
submitsInWindow: 1,
}),
true,
);
});
// Only accounts in both readings count. A card that scrolled out of the
// viewport, or one whose count was unreadable, drops out of the sum, so the
// result is a lower bound on what committed. Losing a card can only cost a
// detection; it must never manufacture one.
test("an account missing from the later reading is not counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3", Savings: "1" },
countsAfter: { Checking: "3" },
submitsInWindow: 0,
}),
false,
);
});
test("an account appearing only in the later reading is not counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3" },
countsAfter: { Checking: "3", Travel: "9" },
submitsInWindow: 0,
}),
false,
);
});
test("a card that scrolled away and back is not double counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3" },
countsAfter: { Checking: "4", Savings: "40" },
submitsInWindow: 1,
}),
false,
);
});
test("an unknown reading on either side is not evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: null,
countsAfter: { Checking: "99" },
submitsInWindow: 0,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "0" },
countsAfter: null,
submitsInWindow: 0,
}),
false,
);
});
// The real trace this came from: at the violating step of seeds 3 and 5 the
// window held exactly one submit action and the account's count moved by two.
test("the measured web witness: submits 1, count delta 2", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { INInvestments: "12", Aa: "0" },
countsAfter: { INInvestments: "14", Aa: "0" },
submitsInWindow: 1,
}),
true,
);
});
// The length rule, which is what keeps the merged-text prefix honest. The
// account named "-1" reads 19 at nine transactions and 110 at ten: same account,
// a delta of 91 out of a true delta of 1. Different run lengths are dropped.
test("a count crossing a digit boundary is dropped, not convicted on", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "19" },
countsAfter: { "-1-": "110" },
submitsInWindow: 1,
}),
false,
);
});
test("same run length keeps the prefixed delta exact", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "110" },
countsAfter: { "-1-": "112" },
submitsInWindow: 1,
}),
true,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "110" },
countsAfter: { "-1-": "111" },
submitsInWindow: 1,
}),
false,
);
});
test("an unreadably long run is not evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "1".repeat(21) },
countsAfter: { Checking: "9".repeat(21) },
submitsInWindow: 0,
}),
false,
);
});