mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
test(folio): pin the window rules and the count invariant
This commit is contained in:
1 parent
e5900712b8
commit
6f7e690750
5 files changed
+586
-100
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user