fix(folio): stop spending the submit budget on taps the app refused

The window is an upper bound on the transactions an interval could hold, and
a bound inflated by taps that commit nothing is a bound the app can never
exceed: #78 read a rise of 15 transactions against 37 submits. TxnSubmit is
clickable(enabled = amount.isNotBlank()) and parseCents refuses anything its
regex misses, so a tap whose landing frame shows a refused amount cannot have
committed. Over four recorded android runs that is 19, 11, 25 and 25 of 35,
26, 42 and 42 submit taps.

A relaunch is excepted: a fresh process draws an empty field whatever was
submitted.
This commit is contained in:
pj committed 2026-08-15 21:24:53 +05:30
1 parent ff7dde4be8
commit 6b276253e8
2 files changed
+129 -1

No files matched your search

+90
View File
@@ -67,6 +67,96 @@ test("a second submit with no Home reading between them counts two", () => {
);
});
// The window is a budget: an upper bound on the transactions the interval could
// hold. A tap the app's own parser must have refused spends none of it, and on
// android it does not even reach the parser, because TxnSubmit is
// clickable(enabled = amount.isNotBlank()). Measured over four recorded android
// runs, 19, 11, 25 and 25 of 35, 26, 42 and 42 submit taps landed on the
// transaction screen with the amount field empty, so more than half the budget
// was being spent on taps that cannot commit anything.
test("a submit the app must have refused does not spend the window's budget", () => {
for (const amountText of ["", " ", "0", "0.00", "00", "5.", "abc"]) {
assert.deepEqual(
countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "Tap", on: submitOn },
amountText,
fresh: false,
}),
{ reported: 0, next: 0 },
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
);
}
});
// The field as the landing frame shows it, which is the form state the tap read:
// nothing between the two changes it. Anywhere but the transaction screen there
// is no field to read, and unknown has to count.
test("an amount that could commit, or that nobody could read, spends the budget", () => {
for (const amountText of ["5", "0.01", "1,000", "999999999999999999999", undefined]) {
assert.deepEqual(
countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "Tap", on: submitOn },
amountText,
fresh: false,
}),
{ reported: 1, next: 1 },
`amount ${JSON.stringify(amountText)} was dropped from the budget`,
);
}
});
// The one thing that can put a different form state on screen than the one the
// tap read: the runner restarting the app, which the tap survives and the typed
// amount does not. The field a fresh process draws is empty whatever was
// submitted, so it proves nothing and the submit keeps its place in the budget.
test("a submit across a relaunch spends the budget whatever the field shows", () => {
assert.deepEqual(
countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
amountText: "",
fresh: false,
}),
{ reported: 1, next: 1 },
);
});
// What the budget costs the counting invariant, in the shape of the iOS run in
// #78: a stretch of the walk that never went Home, most of it taps on a submit
// button with nothing typed into the form, and one double tap that committed
// twice. Counting the refused taps hands the app five transactions of slack it
// never used, and two rows against six actions is no violation.
test("refused submits used to hide a double submit behind their own budget", () => {
const frames = [
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
{ amountText: undefined, lastAction: { kind: "DoubleTap", on: submitOn } },
];
let budget = 0;
for (const frame of frames) {
budget = countSubmitsInWindow({
previousCount: budget,
lastAction: frame.lastAction,
amountText: frame.amountText,
fresh: false,
}).next;
}
assert.equal(budget, 1);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 3 },
countsAfter: { Checking: 5 },
submitsInWindow: budget,
}),
true,
);
});
// 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: { route: string | null; totalText?: string; lastAction: unknown }[]) {