mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
dc2da35e2c
commit
bc0f286e29
1 file changed
+85
-30
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user