mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 03:27:10 +00:00
The old predicate (every row's signed amount equals delta) silently passed the double-submit bug because two same-amount rows each match the delta in isolation. Switching to the sum check (addedSum === delta) catches both the double-submit case and any future multi-row append whose total drifts from the balance change.
182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
import {
|
|
InputText,
|
|
Tap,
|
|
actions,
|
|
always,
|
|
extract,
|
|
from,
|
|
keyedBy,
|
|
next,
|
|
now,
|
|
weighted,
|
|
whenRoute,
|
|
} from "@sanderling/spec";
|
|
import { defaultActions } from "@sanderling/spec/defaults";
|
|
import { balanceMatchesAddedSum, type LedgerRow } from "./predicates";
|
|
|
|
interface Account {
|
|
name: string;
|
|
balance: number;
|
|
}
|
|
|
|
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
|
|
function parseDollarCents(text: string | undefined): number {
|
|
if (!text) return 0;
|
|
const sign = text.startsWith("-") ? -1 : 1;
|
|
const digits = text.replace(/[^0-9]/g, "");
|
|
return digits ? sign * parseInt(digits, 10) : 0;
|
|
}
|
|
|
|
// Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS)
|
|
const loggedIn = extract("loggedIn", s => s.ax.find({ testTag: "LoginScreen" }) == null);
|
|
const route = extract<string | null>("route", s => {
|
|
if (s.ax.find({ testTag: "LoginScreen" })) return "login";
|
|
if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account";
|
|
if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction";
|
|
if (s.ax.find({ testTag: "LedgerScreen" })) return "ledger";
|
|
if (s.ax.find({ testTag: "HomeScreen" })) return "home";
|
|
return null;
|
|
});
|
|
|
|
// Account cards on Home: identity is the AccountName text; balance comes from AccountBalance.
|
|
const accounts = extract<Account[]>("accounts", s =>
|
|
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({
|
|
name: card.find({ testTag: "AccountName" })?.text ?? "",
|
|
balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text),
|
|
})));
|
|
|
|
// Ledger rows: identity composed from the row's stable testTag'd cells.
|
|
const ledgerRows = extract<LedgerRow[]>("ledgerRows", s =>
|
|
s.ax.findAll([{ testTag: "LedgerScreen" }, { testTag: "LedgerRow" }]).map(row => ({
|
|
key: keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]),
|
|
signed: parseDollarCents(row.find({ testTag: "TxnAmount" })?.text),
|
|
})));
|
|
|
|
const ledgerBalance = extract("ledgerBalance", s =>
|
|
parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text));
|
|
|
|
const loginEmailField = extract("loginEmailField", s =>
|
|
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }]));
|
|
const loginPasswordField = extract("loginPasswordField", s =>
|
|
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }]));
|
|
const loginSubmit = extract("loginSubmit", s =>
|
|
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }]));
|
|
const addAccountButton = extract("addAccountButton", s =>
|
|
s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }]));
|
|
const accountNameField = extract("accountNameField", s =>
|
|
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }]));
|
|
const addAccountSubmit = extract("addAccountSubmit", s =>
|
|
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }]));
|
|
const addTxnButton = extract("addTxnButton", s =>
|
|
s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }]));
|
|
const txnAmountField = extract("txnAmountField", s =>
|
|
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }]));
|
|
const txnSubmit = extract("txnSubmit", s =>
|
|
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }]));
|
|
const accountCards = extract("accountCards", s =>
|
|
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]));
|
|
|
|
// Property 1: every newly-appearing account starts with balance === 0.
|
|
// Identity is by visible name. Guard against navigation transitions where
|
|
// accounts vanish from the visible tree.
|
|
const newAccountBalanceIsZero = always(
|
|
next(() => {
|
|
const prev = accounts.previous ?? [];
|
|
const curr = accounts.current;
|
|
if (prev.length === 0 || curr.length === 0) return true;
|
|
const prevNames = new Set(prev.map(a => a.name));
|
|
return curr.filter(a => !prevNames.has(a.name)).every(a => a.balance === 0);
|
|
})
|
|
);
|
|
|
|
// Property 2: when new ledger rows appear, the balance delta equals the SUM
|
|
// of the new rows' signed amounts. Single-row and multi-row sums both must
|
|
// match; a double-submit lands two rows whose total is twice the balance
|
|
// change and trips the sum check.
|
|
const balanceMatchesAddedTxn = always(
|
|
now(() => route.current === "ledger").implies(
|
|
next(() =>
|
|
balanceMatchesAddedSum(
|
|
ledgerRows.previous ?? [],
|
|
ledgerRows.current,
|
|
ledgerBalance.previous ?? 0,
|
|
ledgerBalance.current,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
const DEMO_EMAIL = "[email protected]";
|
|
const DEMO_PASSWORD = "ledger123";
|
|
|
|
// Login: drive the form by reading what's currently in each field, not by
|
|
// inferring intent from which one happens to be focused. A focus-driven
|
|
// approach loops forever if we re-enter the login screen with the password
|
|
// field already focused (e.g. after an exception bounces us back from
|
|
// another screen): it would type the password then tap submit with an
|
|
// empty email field, see no progress, and repeat indefinitely.
|
|
const login = actions(() => {
|
|
if (loggedIn.current) return [];
|
|
const email = loginEmailField.current;
|
|
const pwd = loginPasswordField.current;
|
|
if (email && !email.text) return [InputText({ into: email, text: DEMO_EMAIL })];
|
|
if (pwd && !pwd.text) return [InputText({ into: pwd, text: DEMO_PASSWORD })];
|
|
const submit = loginSubmit.current;
|
|
return submit ? [Tap({ on: submit })] : [];
|
|
});
|
|
|
|
const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]);
|
|
|
|
const addAccount = whenRoute(route, ["home", "add-account"], () => {
|
|
if (route.current === "home") {
|
|
const btn = addAccountButton.current;
|
|
return btn ? [Tap({ on: btn })] : [];
|
|
}
|
|
const field = accountNameField.current;
|
|
const submit = addAccountSubmit.current;
|
|
const opts = [];
|
|
if (field) opts.push(InputText({ into: field, text: accountNames.generate() }));
|
|
if (submit) opts.push(Tap({ on: submit }));
|
|
return opts;
|
|
});
|
|
|
|
const amounts = from(["10", "50", "25", "100", "5"]);
|
|
|
|
const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
|
if (route.current === "home") {
|
|
const cards = accountCards.current;
|
|
if (cards.length === 0) return [];
|
|
return [Tap({ on: from(cards).generate() })];
|
|
}
|
|
if (route.current === "ledger") {
|
|
const btn = addTxnButton.current;
|
|
return btn ? [Tap({ on: btn })] : [];
|
|
}
|
|
const field = txnAmountField.current;
|
|
const submit = txnSubmit.current;
|
|
const opts = [];
|
|
if (field) opts.push(InputText({ into: field, text: amounts.generate() }));
|
|
if (submit) opts.push(Tap({ on: submit }));
|
|
return opts;
|
|
});
|
|
|
|
export const properties = {
|
|
newAccountBalanceIsZero,
|
|
balanceMatchesAddedTxn,
|
|
};
|
|
|
|
export const setup = login;
|
|
|
|
// Targeted depth (addAccount / addTxn) drives the deep flows; defaultActions
|
|
// adds breadth so the fuzzer wanders the whole app and types edge-case values
|
|
// into every field, stressing the balance invariants above.
|
|
export const actionsRoot = weighted(
|
|
[50, addAccount],
|
|
[30, addTxn],
|
|
[20, defaultActions],
|
|
);
|
|
|
|
(globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot;
|
|
(globalThis as { properties?: unknown }).properties = properties;
|
|
(globalThis as { setup?: unknown }).setup = setup;
|