feat(folio-web): predicates for counting commits against submit actions

This commit is contained in:
pj committed 2026-08-17 23:27:33 +05:30
1 parent 2aba29a6e3
commit dc2da35e2c
2 files changed
+511

No files matched your search

@@ -0,0 +1,363 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
committedTransactionsExceedSubmits,
countSubmitsInWindow,
homeTxnCountsOf,
isTxnSubmitTap,
readHomeCards,
submitCouldCommit,
} from "../../../examples/folio-web/sanderling/predicates.ts";
import type {
CardReading,
ObservedAction,
} from "../../../examples/folio-web/sanderling/predicates.ts";
const CHECKING = "acct-checking";
const SAVINGS = "acct-savings";
const submitTap = (): ObservedAction => ({ kind: "Tap", on: "id:txn-submit", applied: true });
const submitDoubleTap = (): ObservedAction => ({
kind: "DoubleTap",
on: "id:txn-submit",
applied: true,
});
const otherTap = (on: string): ObservedAction => ({ kind: "Tap", on, applied: true });
test("a submit tap and a submit double tap are both one submit action", () => {
assert.equal(isTxnSubmitTap(submitTap()), true);
assert.equal(isTxnSubmitTap(submitDoubleTap()), true);
});
test("taps on other controls are not submits", () => {
assert.equal(isTxnSubmitTap(otherTap("id:add-txn")), false);
assert.equal(isTxnSubmitTap(otherTap("id:add-account-submit")), false);
assert.equal(isTxnSubmitTap({ kind: "InputText", on: "id:txn-amount" }), false);
assert.equal(isTxnSubmitTap(null), false);
});
// The disabled button and parseCents (src/format.ts) between them refuse these,
// so they raise no bound. Of 108 add-transaction frames in the recalibration
// run, 65 carried an empty amount field.
test("amounts the app must have refused do not count as submits", () => {
assert.equal(submitCouldCommit(""), false);
assert.equal(submitCouldCommit(" "), false);
assert.equal(submitCouldCommit("0"), false);
assert.equal(submitCouldCommit("0.00"), false);
assert.equal(submitCouldCommit("-5"), false);
assert.equal(submitCouldCommit("abc"), false);
assert.equal(submitCouldCommit("1.234"), false);
assert.equal(submitCouldCommit("999999999999999999999"), false);
});
test("amounts the app accepts count as submits", () => {
assert.equal(submitCouldCommit("1"), true);
assert.equal(submitCouldCommit("12.34"), true);
assert.equal(submitCouldCommit("0.01"), true);
assert.equal(submitCouldCommit("99999"), true);
assert.equal(submitCouldCommit("1,234"), true);
});
// Off the transaction screen there is no field to read, and unknown counts:
// the bound has to hold every submit the window could contain.
test("an unreadable amount counts, because the tap may have committed", () => {
assert.equal(submitCouldCommit(undefined), true);
});
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("refused submits commit nothing, which is under the bound", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: before,
submitsInWindow: 4,
}),
false,
);
});
// The defect, stated directly: one action, two rows.
test("double submit: one action commits two transactions", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { [CHECKING]: 5, [SAVINGS]: 1 },
submitsInWindow: 1,
}),
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,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { [CHECKING]: 4, [SAVINGS]: 2 },
submitsInWindow: 1,
}),
true,
);
});
// Counting actions against transactions rather than gating on a one-submit
// window is what lets a wide window stay evidence.
test("wide window: five submits committing six transactions still fires", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { [CHECKING]: 8, [SAVINGS]: 4 },
submitsInWindow: 5,
}),
true,
);
});
// A card that could not be read drops out of the sum, so the result is a lower
// bound on what committed. Losing a card can cost a detection; it must never
// manufacture one.
test("an account missing from either reading is not counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { [CHECKING]: 3, [SAVINGS]: 1 },
countsAfter: { [CHECKING]: 3 },
submitsInWindow: 0,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { [CHECKING]: 3 },
countsAfter: { [CHECKING]: 3, [SAVINGS]: 9 },
submitsInWindow: 0,
}),
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,
);
});
test("a home frame whose cards have not rendered is unknown, not zero accounts", () => {
assert.equal(homeTxnCountsOf([]), null);
assert.equal(homeTxnCountsOf([{ accountId: CHECKING, count: undefined }]), null);
assert.deepEqual(homeTxnCountsOf([{ accountId: CHECKING, count: 0 }]), { [CHECKING]: 0 });
});
test("a card with no readable id is left out rather than guessed at", () => {
assert.deepEqual(
homeTxnCountsOf([
{ accountId: "", count: 4 },
{ accountId: SAVINGS, count: 1 },
]),
{ [SAVINGS]: 1 },
);
});
// One step of a run, as the spec sees it: whether this frame is Home, the cards
// on it, the action that landed on it, and the amount field it shows.
interface Frame {
onHome: boolean;
cards?: readonly CardReading[];
lastAction?: ObservedAction;
amountText?: string;
}
// Replays frames through the same three predicates the spec wires together,
// returning the verdict of submitCommitsOneTransactionPerAction at each step.
function replay(frames: readonly Frame[]): boolean[] {
let carrier: Record<string, number> | null = null;
let previousCounts: Record<string, number> | null = null;
let submits = 0;
return frames.map((frame) => {
const reading = readHomeCards({
onHome: frame.onHome,
reading: frame.cards ? homeTxnCountsOf(frame.cards) : null,
previousCarrier: carrier,
});
carrier = reading.carrier;
const counted = countSubmitsInWindow({
previousCount: submits,
lastAction: frame.lastAction ?? null,
amountText: frame.amountText,
fresh: reading.fresh,
});
submits = counted.next;
const violated = committedTransactionsExceedSubmits({
countsBefore: previousCounts,
countsAfter: reading.value,
submitsInWindow: counted.reported,
});
previousCounts = reading.value;
return violated;
});
}
const home = (count: number): Frame => ({
onHome: true,
cards: [{ accountId: CHECKING, count }],
});
// The walk the fuzzer takes: Home, into the account, into the form, type, tap
// submit, back out, Home again. The two compared readings are eight steps
// apart and the property has to stay quiet the whole way.
const walkToSubmit: readonly Frame[] = [
home(3),
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: { kind: "InputText", on: "id:txn-amount" }, amountText: "12.34" },
];
test("one submit committing one transaction is quiet across an eight-step window", () => {
const verdicts = replay([
...walkToSubmit,
{ onHome: false, lastAction: submitTap(), amountText: "12.34" },
{ onHome: false, lastAction: otherTap("id:back") },
{ onHome: false, lastAction: otherTap("id:back") },
home(4),
]);
assert.deepEqual(verdicts, [false, false, false, false, false, false, false, false]);
});
// The planted defect: the same walk, one double tap, two rows.
test("a double tap committing two transactions fires at the next home reading", () => {
const verdicts = replay([
...walkToSubmit,
{ onHome: false, lastAction: submitDoubleTap(), amountText: "12.34" },
{ onHome: false, lastAction: otherTap("id:back") },
{ onHome: false, lastAction: otherTap("id:back") },
home(5),
]);
assert.deepEqual(verdicts, [false, false, false, false, false, false, false, true]);
});
// A window can hold several submits and several visits to the form. Every
// transaction is accounted for by an action, so the bound holds.
test("many submits across a wide window stay under the bound", () => {
const verdicts = replay([
home(3),
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "1" },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "250" },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "99999" },
{ onHome: false, lastAction: otherTap("id:back") },
home(6),
]);
assert.equal(verdicts.some((violated) => violated), false);
});
// The same wide window with one of the three taps doubled. The rise of four
// against a budget of three is what convicts.
test("a double tap hidden among healthy submits still fires", () => {
const verdicts = replay([
home(3),
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "1" },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitDoubleTap(), amountText: "250" },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "99999" },
{ onHome: false, lastAction: otherTap("id:back") },
home(7),
]);
assert.deepEqual(verdicts[9], true);
});
// Submits the app refused raise no bound, which is what keeps the window tight
// enough to convict. The same trace with the three empty-field taps counted
// would acquit a double submit.
test("taps on the disabled submit button do not pad the budget", () => {
const padded: readonly Frame[] = [
home(3),
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "" },
{ onHome: false, lastAction: submitTap(), amountText: "" },
{ onHome: false, lastAction: submitTap(), amountText: "" },
{ onHome: false, lastAction: { kind: "InputText", on: "id:txn-amount" }, amountText: "1" },
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
{ onHome: false, lastAction: otherTap("id:back") },
home(5),
];
assert.deepEqual(replay(padded)[9], true);
});
// A fresh Home reading closes one window and opens the next, so a submit
// already accounted for cannot be spent twice.
test("the submit budget resets on every home reading", () => {
const verdicts = replay([
home(3),
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitTap(), amountText: "1" },
home(4),
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
home(6),
]);
assert.deepEqual(verdicts, [false, false, false, false, false, false, true]);
});
// A submit that lands back on Home belongs to the window that ends there, so
// the rise it caused is covered rather than convicted.
test("a submit whose landing frame is home is counted in that window", () => {
const verdicts = replay([
home(3),
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: true, cards: [{ accountId: CHECKING, count: 4 }], lastAction: submitTap() },
]);
assert.deepEqual(verdicts, [false, false, false]);
});
// Home renders its own node before its card list, and an empty list is
// UNKNOWN, not "no accounts". Writing it into the carrier is what would leave
// the next real reading comparing against nothing.
test("a home frame with no cards yet neither convicts nor closes the window", () => {
const verdicts = replay([
home(3),
{ onHome: false, lastAction: otherTap("id:add-txn") },
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
{ onHome: true, cards: [] },
home(5),
]);
assert.deepEqual(verdicts, [false, false, false, false, true]);
});