feat(folio-web): judge one commit per submit over a home-card window

Replaces totalBalanceMatchesAccounts and balanceMatchesTransactionDelta,
which compared two consecutive steps on one screen and so could not see a
double submission that lands across a navigation.
This commit is contained in:
pj committed 2026-08-17 23:27:33 +05:30
1 parent dc2da35e2c
commit bc0f286e29
1 file changed
+85 -30
+85 -30
View File
@@ -3,6 +3,7 @@ import {
Tap, Tap,
actions, actions,
always, always,
doubleTaps,
eventually, eventually,
extract, extract,
llm, llm,
@@ -13,11 +14,20 @@ import {
waitOnce, waitOnce,
weighted, weighted,
} from "@sanderling/spec"; } from "@sanderling/spec";
import type { State } from "@sanderling/spec";
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties"; import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
import {
committedTransactionsExceedSubmits,
countSubmitsInWindow,
homeTxnCountsOf,
readHomeCards,
} from "./predicates";
// Page-presence checks via stable element ids. // Page-presence checks via stable element ids.
const isHomeFrame = (s: State) => !!s.ax.find({ id: "add-account" });
const onLoginPage = extract((s) => !!s.ax.find({ id: "email" })).named("onLoginPage"); const onLoginPage = extract((s) => !!s.ax.find({ id: "email" })).named("onLoginPage");
const onHomePage = extract((s) => !!s.ax.find({ id: "add-account" })).named("onHomePage"); const onHomePage = extract(isHomeFrame).named("onHomePage");
const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" })).named("onAddAccountPage"); const onAddAccountPage = extract((s) => !!s.ax.find({ id: "account-name" })).named("onAddAccountPage");
const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" })).named("onLedgerPage"); const onLedgerPage = extract((s) => !!s.ax.find({ id: "ledger" })).named("onLedgerPage");
const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" })).named("onAddTxnPage"); const onAddTxnPage = extract((s) => !!s.ax.find({ id: "txn-amount" })).named("onAddTxnPage");
@@ -42,10 +52,13 @@ function readCents(value: string | undefined): number {
return isNaN(parsed) ? 0 : parsed; return isNaN(parsed) ? 0 : parsed;
} }
const totalBalance = extract((s) => { // A count of 0 is a real reading and a missing one is not, so this cannot fall
const el = s.ax.find({ id: "total-balance" }); // back to readCents.
return readCents(el?.attrs?.["data-cents"]); function readCount(value: string | undefined): number | undefined {
}).named("totalBalance"); if (value === undefined) return undefined;
const parsed = parseInt(value, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
}
// Account cards expose `data-account-id` + `data-balance` so the spec reads // Account cards expose `data-account-id` + `data-balance` so the spec reads
// structured data without parsing aria-label. // structured data without parsing aria-label.
@@ -62,10 +75,46 @@ const ledgerTxnCount = extract((s) => {
return readCents(el?.attrs?.["data-txn-count"]); return readCents(el?.attrs?.["data-txn-count"]);
}).named("ledgerTxnCount"); }).named("ledgerTxnCount");
const ledgerBalance = extract((s) => { // Transactions committed per account, off Home's own cards, carried across
const el = s.ax.find({ id: "ledger-balance" }); // off-Home steps: the pair a property compares has to be two Home readings, not
return readCents(el?.attrs?.["data-cents"]); // a Home reading and whatever happened to be on screen. See readHomeCards.
}).named("ledgerBalance"); const homeCardReadings = (s: State) =>
s.ax.findAll({ "data-testid": "account-card" }).map((el) => ({
accountId: el.attrs?.["data-account-id"] ?? "",
count: readCount(el.attrs?.["data-txn-count"]),
}));
let lastHomeTxnCounts: Record<string, number> | null = null;
const homeTxnCounts = extract((s) => {
const reading = readHomeCards({
onHome: isHomeFrame(s),
reading: homeTxnCountsOf(homeCardReadings(s)),
previousCarrier: lastHomeTxnCounts,
});
lastHomeTxnCounts = reading.carrier;
return reading.value;
}).named("homeTxnCounts");
// Submit actions inside the window `homeTxnCounts.previous` and
// `homeTxnCounts.current` span. Recomputed rather than shared with the
// extractor above because extractor getters may not read one another; both
// derive freshness from the same reading, so they reset on the same step.
let submitsSinceHomeCards = 0;
const submitsSinceCounts = extract((s) => {
const fresh = readHomeCards({
onHome: isHomeFrame(s),
reading: homeTxnCountsOf(homeCardReadings(s)),
previousCarrier: null,
}).fresh;
const counted = countSubmitsInWindow({
previousCount: submitsSinceHomeCards,
lastAction: s.lastAction,
amountText: s.ax.find({ id: "txn-amount" })?.attrs?.["value"],
fresh,
});
submitsSinceHomeCards = counted.next;
return counted.reported;
}).named("submitsSinceCounts");
// UI element handles. // UI element handles.
const emailField = extract((s) => s.ax.find({ id: "email" })).named("emailField"); const emailField = extract((s) => s.ax.find({ id: "email" })).named("emailField");
@@ -97,25 +146,27 @@ const loggedOutReachesLogin = always(
), ),
); );
const totalBalanceMatchesAccounts = always(() => { // One action commits at most one transaction, measured over the window between
if (!onHomePage.current) return true; // two Home card readings rather than over a single step transition.
const cards = accountCards.current; //
if (cards.length === 0) return true; // The window is what makes it work. A commit always routes through
const sum = cards.reduce((acc, c) => acc + c.balance, 0); // /transactions/new and pops back to the ledger it came from, so the fuzzer can
return sum === totalBalance.current; // add transactions all day without Home being redrawn: any rule phrased over
}); // two consecutive steps on one screen never gets a pair to judge. This one
// stays sound however wide the gap between two Home readings gets, because the
const balanceMatchesTransactionDelta = always( // submit count is taken over exactly the same interval as the two counts it
now(() => onLedgerPage.current && ledgerTxnCount.current > 0).implies( // compares.
next(() => { //
if (!onLedgerPage.current) return true; // `next` because the first step of a run has no previous reading to compare
const prevCount = ledgerTxnCount.previous ?? 0; // against.
const curCount = ledgerTxnCount.current; const submitCommitsOneTransactionPerAction = always(
if (curCount !== prevCount + 1) return true; next(
const prevBal = ledgerBalance.previous ?? 0; () =>
const curBal = ledgerBalance.current; !committedTransactionsExceedSubmits({
return curBal !== prevBal; countsBefore: homeTxnCounts.previous ?? null,
}), countsAfter: homeTxnCounts.current,
submitsInWindow: submitsSinceCounts.current,
}),
), ),
); );
@@ -136,8 +187,7 @@ const someTransactionExists = eventually(
export const properties = { export const properties = {
loggedInLeavesLogin, loggedInLeavesLogin,
loggedOutReachesLogin, loggedOutReachesLogin,
totalBalanceMatchesAccounts, submitCommitsOneTransactionPerAction,
balanceMatchesTransactionDelta,
loginReachable, loginReachable,
accountCreationReachable, accountCreationReachable,
someTransactionExists, someTransactionExists,
@@ -276,6 +326,10 @@ const logoutAction = actions(() => {
// so the seeded picker draws from the edge-case corpus and the model writes its // so the seeded picker draws from the edge-case corpus and the model writes its
// own text. It holds the 8 weight those two leaves shared, so every other // own text. It holds the 8 weight those two leaves shared, so every other
// branch keeps the share it had. // branch keeps the share it had.
//
// `doubleTaps` gets explicit weight because rapid double-submission is a
// failure mode these forms must be idempotent under, and no other branch
// provokes it: every authored leaf taps once.
export const actionsRoot = weighted( export const actionsRoot = weighted(
[30, loginHelper], [30, loginHelper],
[2, adversarialLogin], [2, adversarialLogin],
@@ -292,6 +346,7 @@ export const actionsRoot = weighted(
[1, logoutAction], [1, logoutAction],
[4, taps], [4, taps],
[8, typing], [8, typing],
[5, doubleTaps],
[2, waitOnce], [2, waitOnce],
); );