mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge branch 'folio-spec-attribution' into trial-merge
This commit is contained in:
commit
bd72b0c2f7
7 files changed
+985
-18
No files matched your search
@@ -123,6 +123,51 @@ export function readHomeCards<T>(args: {
|
||||
return { value: reading, carrier: reading, fresh: true };
|
||||
}
|
||||
|
||||
// The balance of the ONE account the ledger and the add-transaction screen are
|
||||
// showing, and the window it closes.
|
||||
//
|
||||
// It exists because the Home readings above close their window only when the
|
||||
// walk goes back to Home, and a walk inside the transaction flow does not: a
|
||||
// submit pops back to the ledger it came from, so the fuzzer can add
|
||||
// transactions all day without Home ever being redrawn. The iOS run in #78 went
|
||||
// 117 steps between two Home readings and accumulated 37 submits against a rise
|
||||
// of 15 transactions, which is no evidence about any single action. Both
|
||||
// screens here carry the account's own balance (LedgerBalance,
|
||||
// TxnCurrentBalance), so the window between two readings holds one action.
|
||||
//
|
||||
// WHICH account is never asked, because inside a run of these two routes it
|
||||
// cannot change. Route.Ledger is pushed only by tapping a card on Home,
|
||||
// Route.AddTransaction only by the ledger's own button for its own account, and
|
||||
// an accepted submit pops back to that same ledger. Reaching another account's
|
||||
// ledger means passing through Home, and one action reads one frame, so a frame
|
||||
// that is neither of these two routes always sits in between. Dropping the
|
||||
// carrier on every such frame, transition frames included, is what makes the
|
||||
// two compared numbers two readings of one account.
|
||||
export interface AccountBalanceReading {
|
||||
value: number | null;
|
||||
carrier: number | null;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
function showsOneAccount(route: string | null): boolean {
|
||||
return route === "ledger" || route === "add-transaction";
|
||||
}
|
||||
|
||||
export function readAccountBalance(args: {
|
||||
route: string | null;
|
||||
balanceText: string | undefined;
|
||||
previousCarrier: number | null;
|
||||
}): AccountBalanceReading {
|
||||
const { route, balanceText, previousCarrier } = args;
|
||||
if (!showsOneAccount(route)) return { value: null, carrier: null, fresh: false };
|
||||
const balance = parseAccountBalance(balanceText);
|
||||
// Unreadable is unknown, not a new value: the balance node scrolls off the
|
||||
// viewport like anything else. The account still cannot have changed, so the
|
||||
// last number we read is carried across and the window stays open.
|
||||
if (balance === null) return { value: previousCarrier, carrier: previousCarrier, fresh: false };
|
||||
return { value: balance, carrier: balance, fresh: true };
|
||||
}
|
||||
|
||||
// state.lastAction as the two hosts build it (internal/verifier/marshal.go
|
||||
// lastActionFields), read defensively: every field is what a Go struct decided
|
||||
// to emit, not something this file can trust a compile-time shape for.
|
||||
@@ -136,6 +181,7 @@ export interface ObservedAction {
|
||||
kind?: string;
|
||||
on?: string | object;
|
||||
applied?: true | null;
|
||||
relaunched?: true | null;
|
||||
}
|
||||
|
||||
function isTapOn(lastAction: ObservedAction | null, target: string): boolean {
|
||||
@@ -171,6 +217,19 @@ export function confirmedApplied(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.applied === true;
|
||||
}
|
||||
|
||||
// The runner reports this when its foreground guard had to relaunch the app
|
||||
// after the action. The action still happened, so it still counts toward how
|
||||
// many submits a window could hold; what nobody can promise across it is that
|
||||
// the process survived long enough to commit, or that Home is showing the same
|
||||
// slice of the account list it was.
|
||||
//
|
||||
// `true | null` for the same reason `applied` is: web and iOS cannot read the
|
||||
// foreground at all, so "no relaunch reported" is not "the app never
|
||||
// restarted", and only an explicit true licenses declining.
|
||||
export function acrossRelaunch(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.relaunched === true;
|
||||
}
|
||||
|
||||
// Counts the submit actions inside the window the balance property compares
|
||||
// over: from the last Home total we read to this step, inclusive of this step's
|
||||
// action.
|
||||
@@ -190,16 +249,54 @@ export function confirmedApplied(lastAction: ObservedAction | null): boolean {
|
||||
// well have landed. Leaving it out is what convicted a healthy app:
|
||||
// committedTransactionsExceedSubmits saw a transaction rise of one against a
|
||||
// window of zero and called it a double submit.
|
||||
//
|
||||
// A submit the app must have refused does not count, for the mirror reason: it
|
||||
// cannot have committed anything, so the bound it would raise is slack the app
|
||||
// can hide a real double submit behind. See submitCouldCommit for what "must
|
||||
// have refused" is allowed to mean.
|
||||
export function countSubmitsInWindow(args: {
|
||||
previousCount: number;
|
||||
lastAction: ObservedAction | null;
|
||||
amountText?: string;
|
||||
fresh: boolean;
|
||||
}): { reported: number; next: number } {
|
||||
const { previousCount, lastAction, fresh } = args;
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) ? 1 : 0);
|
||||
// A relaunch is the one thing that can put a form state on screen other than
|
||||
// the one the tap read, so the field it draws proves nothing about it.
|
||||
const refused = !acrossRelaunch(lastAction) && !submitCouldCommit(args.amountText);
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) && !refused ? 1 : 0);
|
||||
return { reported, next: fresh ? 0 : reported };
|
||||
}
|
||||
|
||||
// Could the app have committed anything for that submit? The amount field as
|
||||
// the LANDING frame shows it is the form state the tap read: the tap changes
|
||||
// nothing about it, and one action runs per step, so nothing else could have.
|
||||
// Off the transaction screen there is no field to read, and undefined is
|
||||
// unknown, which counts.
|
||||
//
|
||||
// False only where Folio's own code must have refused. parseCents takes
|
||||
// `^\d+(\.\d{1,2})?$` with commas stripped and refuses everything else, and
|
||||
// AddTransactionViewModel refuses a parsed zero on top of that. An empty field
|
||||
// never even reaches the parser: TxnSubmit is
|
||||
// clickable(enabled = amount.isNotBlank()), so the click does not fire.
|
||||
//
|
||||
// This is the difference between a bound and a useless one. The window is an
|
||||
// upper bound on the transactions the interval could hold, and a bound inflated
|
||||
// by taps that commit nothing is a bound the app can never exceed: the iOS run
|
||||
// in #78 read a rise of 15 transactions against a window of 37 submits and had
|
||||
// nothing to say. Measured over four recorded android runs, 19, 11, 25 and 25
|
||||
// of 35, 26, 42 and 42 submit taps landed with the amount field empty.
|
||||
//
|
||||
// An amount too large for a Kotlin Long is refused by the app too, and still
|
||||
// counts here: over-counting can only cost a detection, and the reading that
|
||||
// would have to prove the overflow is a float that cannot hold the number.
|
||||
export function submitCouldCommit(amountText: string | undefined): boolean {
|
||||
if (amountText === undefined) return true;
|
||||
const trimmed = amountText.trim().replace(/,/g, "");
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false;
|
||||
return /[1-9]/.test(trimmed);
|
||||
}
|
||||
|
||||
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to
|
||||
// integer cents. Anything that is not a complete amount is null, not 0: a
|
||||
// balance we could not read is unknown, and reading it as zero silently moves
|
||||
@@ -240,6 +337,15 @@ export function cardBalanceText(args: {
|
||||
return match ? match[0].trim() : undefined;
|
||||
}
|
||||
|
||||
// One account's own balance, off the node whose whole text it is: bare on the
|
||||
// ledger ("$196.00"), labelled in the add-transaction header
|
||||
// ("Balance: $196.00"). Anchored at the end for the same reason as above, so
|
||||
// the label cannot be read as part of the amount.
|
||||
export function parseAccountBalance(text: string | undefined): number | null {
|
||||
const match = text?.match(TRAILING_BALANCE);
|
||||
return match ? parseDollarCents(match[0].trim()) : null;
|
||||
}
|
||||
|
||||
// The result is an identity key, not a display name: off web it is the
|
||||
// AccountName text, on web it is whatever the merged card text leaves in front
|
||||
// of the count label, initials and all ("T2Travel" for "Travel 2024"). Its only
|
||||
@@ -259,6 +365,24 @@ export function cardAccountName(args: {
|
||||
return head.slice(0, label.index).trim();
|
||||
}
|
||||
|
||||
// The avatar text that opens a merged card's identity key, mirroring Folio's
|
||||
// initialsOf (app/shared/.../util/Format.kt).
|
||||
//
|
||||
// A mirror because the alternative is a suffix test, and a suffix test cannot
|
||||
// say which card a name belongs to. Drift can only cost a detection: the result
|
||||
// is compared whole against a card's key, so initials that stop matching the
|
||||
// app match no card rather than the wrong one.
|
||||
export function initialsOf(name: string): string {
|
||||
// Java's \s, which is what Kotlin's Regex("\\s+") compiles to. JS's \s also
|
||||
// matches the unicode spaces, and would split names the app keeps whole.
|
||||
const parts = name.trim().split(/[ \t\n\v\f\r]+/).filter(part => part !== "");
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
if (first === undefined || last === undefined) return "?";
|
||||
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
||||
return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
// One card's transaction count, in the strongest form its SOURCE supports. The
|
||||
// two forms are the whole reason this is not just a number:
|
||||
//
|
||||
@@ -331,13 +455,26 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null
|
||||
//
|
||||
// A name carried by more than one card is left out for the same reason, the
|
||||
// rule createdAccountHasNonZeroBalance applies with `matches.length === 1`:
|
||||
// nothing here can say which of them a count came from. Folio accepts the same
|
||||
// account name twice and Home lists whatever fits the viewport, so a reading
|
||||
// that saw one Travel card and a later one that saw two would otherwise
|
||||
// subtract two DIFFERENT accounts' counts and convict a healthy app of
|
||||
// double-submitting. The twin does not have to be readable to spoil the
|
||||
// identity, so duplicates are counted over every card, not just the usable
|
||||
// ones. Dropping a card can only ever cost a detection.
|
||||
// nothing here can say which of them a count came from. Two accounts never
|
||||
// share a NAME (Accounts.name is UNIQUE and Repository.createAccount rejects
|
||||
// one already taken, NOCASE), but they can share a KEY, because web's key is
|
||||
// the card text in front of the digit run, which is the name with any trailing
|
||||
// digits shaved off it: "Travel1" holding 25 transactions and "Travel12"
|
||||
// holding 6 both key to "TRTravel" and both read a three-digit run, so
|
||||
// subtracting one from the other subtracts two unrelated counting series. The
|
||||
// twin does not have to be readable to spoil the identity, so duplicates are
|
||||
// counted over every card, not just the usable ones. Dropping a card can only
|
||||
// ever cost a detection.
|
||||
//
|
||||
// What this cannot see is a twin that never shares a reading with its pair, and
|
||||
// Home lists only what fits the viewport. Nothing computed from the card text
|
||||
// can: the two cards' text is identical character for character ("TRTravel1"
|
||||
// followed by "25 transactions" and "TRTravel12" followed by "6 transactions"
|
||||
// are one string), so no key derived from it separates them. Refusing every run
|
||||
// that could hide a name's own digits would, at the price of the evidence web
|
||||
// convicts on today, whose measured witness is a count of 12 rising to 14.
|
||||
// Separating them needs something the tree does not carry: the account's id on
|
||||
// the card, or a separator in front of the count.
|
||||
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, TxnCount> | null {
|
||||
const cardsPerName = new Map<string, number>();
|
||||
for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1);
|
||||
@@ -381,15 +518,30 @@ export function createdAccountHasNonZeroBalance(args: {
|
||||
// card to: the card that turned up may be an older account of the same name
|
||||
// scrolling into view.
|
||||
if (!confirmedApplied(lastAction)) return false;
|
||||
// A relaunch draws Home from the top again, so the card that carries the
|
||||
// typed name may be an older account of that name laid out where the new one
|
||||
// used to be, and the create may not have reached sqlite at all.
|
||||
if (acrossRelaunch(lastAction)) return false;
|
||||
if (before === null || after === null) return false;
|
||||
const typed = (args.typedName ?? "").trim();
|
||||
if (typed === "") return false;
|
||||
// Web merges the card into one node whose text opens with the avatar
|
||||
// initials, so the identity key is "INInvestments" where android and iOS give
|
||||
// "Investments"; endsWith covers both. Two cards answering to the same typed
|
||||
// name (a second "Travel", or a card the tree exposed twice) leave the
|
||||
// appearance unattributable, so nothing is judged.
|
||||
const matches = after.filter(account => account.name.endsWith(typed));
|
||||
// "Investments". Both forms are built from the name that was typed and
|
||||
// compared whole. A suffix test covered both too, and it also let any OTHER
|
||||
// account ending in those letters answer for the created one: type "Fund"
|
||||
// next to an existing "Emergency Fund", have the new card clipped out of the
|
||||
// reading the way Home clips any card, and the old account is convicted for
|
||||
// money it has held all along. It cost detections as well, because a typed
|
||||
// name that two cards end with is judged as unattributable rather than as the
|
||||
// one card that carries it.
|
||||
//
|
||||
// Two cards answering to one key stay unattributable: Accounts.name is UNIQUE
|
||||
// and Repository.createAccount rejects a name already taken, so that pair is
|
||||
// a card the tree exposed twice, or two names the merged key cannot tell
|
||||
// apart.
|
||||
const mergedKey = initialsOf(typed) + typed;
|
||||
const matches = after.filter(account => account.name === typed || account.name === mergedKey);
|
||||
const created = matches.length === 1 ? matches[0] : undefined;
|
||||
if (created === undefined) return false;
|
||||
if (before.some(account => account.name === created.name)) return false;
|
||||
@@ -431,6 +583,54 @@ export function committedTransactionsExceedSubmits(args: {
|
||||
return committed > submitsInWindow;
|
||||
}
|
||||
|
||||
// The same rule as above, measured in money over the account's own window: one
|
||||
// submit action can commit one transaction, so the account's balance cannot
|
||||
// move by more than the amount that submit typed.
|
||||
//
|
||||
// An UPPER BOUND, not the equality submitChangesBalanceByTypedAmount uses, and
|
||||
// that is what makes a one-action window safe. A balance that has not moved is
|
||||
// a commit still in flight (createTransaction runs in a coroutine), a submit
|
||||
// the app rejected, or a tap that never landed, and none of those is evidence
|
||||
// of anything; an equality would convict all three. Moving by MORE than one
|
||||
// submit's worth is not something a correct app can do: only createTransaction
|
||||
// moves this number, only a TxnSubmit tap reaches it, and the window holds
|
||||
// exactly one such tap. A double tap is one action committing two transactions,
|
||||
// so it moves the balance by twice what was typed and lands here.
|
||||
//
|
||||
// A submit the runner could not confirm needs no case of its own for the same
|
||||
// reason: if it never landed the balance did not move, which is under the
|
||||
// bound. countSubmitsInWindow counts it either way, so it cannot smuggle a
|
||||
// second commit into a window that looks like one.
|
||||
//
|
||||
// typedAmount is the amount the app parsed for THIS submit (parseTypedAmount
|
||||
// mirrors parseCents), so every rejected amount and every amount too large to
|
||||
// hold exactly arrives here as 0 and is vacuous.
|
||||
//
|
||||
// The float guards are the ones submitChangesBalanceByTypedAmount explains:
|
||||
// each balance and the typed amount lose precision on their own past
|
||||
// Number.MAX_SAFE_INTEGER. Their difference needs none, because a difference
|
||||
// that is really within a safe typedAmount is itself safe and comes out exact.
|
||||
export function committedAmountExceedsOneSubmit(args: {
|
||||
route: string | null;
|
||||
lastAction: ObservedAction | null;
|
||||
submitsInWindow: number;
|
||||
typedAmount: number;
|
||||
prevAccountBalance: number | null;
|
||||
currAccountBalance: number | null;
|
||||
}): boolean {
|
||||
const { route, lastAction, submitsInWindow, typedAmount } = args;
|
||||
const { prevAccountBalance, currAccountBalance } = args;
|
||||
if (!showsOneAccount(route)) return false;
|
||||
if (!isTxnSubmitTap(lastAction)) return false;
|
||||
if (submitsInWindow !== 1) return false;
|
||||
if (typedAmount <= 0) return false;
|
||||
if (prevAccountBalance === null || currAccountBalance === null) return false;
|
||||
if (!Number.isSafeInteger(prevAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(currAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(typedAmount)) return false;
|
||||
return Math.abs(currAccountBalance - prevAccountBalance) > typedAmount;
|
||||
}
|
||||
|
||||
// How far one account's count rose between two readings, or null when the pair
|
||||
// is not comparable. Not comparable is not zero: the account drops out of the
|
||||
// sum entirely, which can only cost a detection.
|
||||
@@ -510,6 +710,10 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
// runner could not confirm may have committed nothing, and a balance that
|
||||
// did not move is then exactly what a healthy app looks like.
|
||||
if (!confirmedApplied(lastAction)) return true;
|
||||
// The runner restarted the app after this tap, so the process may have died
|
||||
// between the commit and the sqlite write. A balance that did not move is
|
||||
// then a healthy app, exactly as it is for a submit that may not have landed.
|
||||
if (acrossRelaunch(lastAction)) return true;
|
||||
if (submitsInWindow !== 1) return true;
|
||||
if (typedAmount === 0) return true;
|
||||
// An unknown total on either side is not evidence of anything. Comparing one
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
cardAccountName,
|
||||
cardBalanceText,
|
||||
cardTxnCount,
|
||||
committedAmountExceedsOneSubmit,
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
createdAccountHasNonZeroBalance,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
oncePerFrame,
|
||||
parseDollarCents,
|
||||
parseTypedAmount,
|
||||
readAccountBalance,
|
||||
readHomeCards,
|
||||
readHomeTotalBalance,
|
||||
routeOfFrame,
|
||||
@@ -102,6 +104,11 @@ const homeCards = oncePerFrame((s: State): CardReading[] =>
|
||||
// the last-read Home total so `previous` and `current` stay on the same scale.
|
||||
const homeTotalText = (s: State) => on("home", "TotalBalance")(s)?.text;
|
||||
|
||||
// The amount field as the frame a submit landed on shows it, which is the form
|
||||
// state that submit read. Every window below asks, because a submit the app
|
||||
// must have refused raises no bound: see submitCouldCommit.
|
||||
const txnAmountText = oncePerFrame((s: State) => on("add-transaction", "TxnAmountField")(s)?.text);
|
||||
|
||||
let lastHomeTotal: number | null = null;
|
||||
const totalBalance = extract<number | null>("totalBalance", s => {
|
||||
const reading = readHomeTotalBalance({
|
||||
@@ -127,6 +134,7 @@ const submitsInWindow = extract("submitsInWindow", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeTotal,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeTotal = window.next;
|
||||
@@ -175,12 +183,52 @@ const submitsSinceCounts = extract("submitsSinceCounts", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeCards,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeCards = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
// The account's own balance, off whichever of its two screens is up. The routes
|
||||
// are exclusive, so at most one of these resolves and the reading is always one
|
||||
// account's number. Its carrier is dropped on every other route, which is what
|
||||
// keeps two readings from spanning two accounts: see readAccountBalance.
|
||||
const accountBalanceText = (s: State) =>
|
||||
on("ledger", "LedgerBalance")(s)?.text ?? on("add-transaction", "TxnCurrentBalance")(s)?.text;
|
||||
|
||||
let lastAccountBalance: number | null = null;
|
||||
const accountBalance = extract<number | null>("accountBalance", s => {
|
||||
const reading = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: lastAccountBalance,
|
||||
});
|
||||
lastAccountBalance = reading.carrier;
|
||||
return reading.value;
|
||||
});
|
||||
|
||||
// A third window, for the same reason the counting invariant has its own: it
|
||||
// closes on this reading's freshness, which is a different event again. The
|
||||
// transaction flow redraws this balance on nearly every frame, so this window
|
||||
// is the narrow one, usually a single action wide.
|
||||
let submitsSinceAccountBalance = 0;
|
||||
const submitsSinceBalance = extract("submitsSinceAccountBalance", s => {
|
||||
const fresh = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: null,
|
||||
}).fresh;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceAccountBalance,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceAccountBalance = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
const lastAction = extract("lastAction", s => s.lastAction);
|
||||
|
||||
const loginEmailField = extract("loginEmailField", on("login", "LoginEmail"));
|
||||
@@ -232,12 +280,28 @@ const submitMovesBalanceByTypedAmount = always(
|
||||
// stays sound however wide the window between two Home readings gets, because
|
||||
// both sides of the comparison accumulate over the same window. It is the
|
||||
// double-submit stated directly: one tap, two rows.
|
||||
//
|
||||
// One rule, two windows. The counting form can only compare two Home readings,
|
||||
// and a walk that stays inside the transaction flow gives it a window hundreds
|
||||
// of steps and dozens of submits wide, which is sound and says nothing. The
|
||||
// second form says the same thing in money about the one account whose screen
|
||||
// the walk is on, and that window is usually a single action, so it can still
|
||||
// tell one commit from two: see committedAmountExceedsOneSubmit.
|
||||
const submitCommitsOneTransactionPerAction = always(
|
||||
next(() =>
|
||||
next(
|
||||
() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCounts.previous ?? null,
|
||||
countsAfter: homeTxnCounts.current,
|
||||
submitsInWindow: submitsSinceCounts.current,
|
||||
}) &&
|
||||
!committedAmountExceedsOneSubmit({
|
||||
route: route.current,
|
||||
lastAction: lastAction.current,
|
||||
submitsInWindow: submitsSinceBalance.current,
|
||||
typedAmount: parseTypedAmount(txnAmountField.previous?.text),
|
||||
prevAccountBalance: accountBalance.previous ?? null,
|
||||
currAccountBalance: accountBalance.current,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -65,6 +65,18 @@ test("name ending in digits does not leak into the balance", () => {
|
||||
assert.equal(balanceOf(card("20", "2024", 3, "-$1,234.56")), -123456);
|
||||
});
|
||||
|
||||
// The limit of a key read off merged text, and the reason homeTxnCountsOf
|
||||
// guards the ambiguity rather than resolving it: two DIFFERENT accounts render
|
||||
// the same card, character for character. Names are unique (Accounts.name is
|
||||
// UNIQUE, checked NOCASE) but the count runs straight into a name that ends in
|
||||
// digits, so nothing computed from this string can say which account it is.
|
||||
test("two accounts can render one card, so no key off it can be injective", () => {
|
||||
const travel1 = card("TR", "Travel1", 25, "$120.00");
|
||||
const travel12 = card("TR", "Travel12", 5, "$120.00");
|
||||
assert.equal(travel1, travel12);
|
||||
assert.equal(cardAccountName({ childText: undefined, cardText: travel1 }), "TRTravel");
|
||||
});
|
||||
|
||||
// The account key only has to be stable and per-account. newAccountBalanceIsZero
|
||||
// reads it as a set member: a key that drifted as an account's transaction
|
||||
// count grew would make an existing account look brand new, and the property
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
committedAmountExceedsOneSubmit,
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
homeTxnCountsOf,
|
||||
parseAccountBalance,
|
||||
parseTypedAmount,
|
||||
readAccountBalance,
|
||||
readHomeCards,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import type {
|
||||
ObservedAction,
|
||||
TxnCount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// The per-account balance the ledger and the add-transaction screen both show.
|
||||
// Its window closes on every frame of the transaction flow, where the Home
|
||||
// total's closes only when the walk goes back to Home: the iOS run in #78 went
|
||||
// 117 steps between two Home readings and accumulated 37 submits against a rise
|
||||
// of 15, so the double tap at step 32 sat in a window far too wide to judge.
|
||||
|
||||
const submit: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:AddTransactionScreen > testTag:TxnSubmit",
|
||||
applied: true,
|
||||
};
|
||||
const doubleSubmit: ObservedAction = { ...submit, kind: "DoubleTap" };
|
||||
const openLedger: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:HomeScreen > testTag:AccountCard",
|
||||
applied: true,
|
||||
};
|
||||
const openAddTxn: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:LedgerScreen > testTag:AddTransactionButton",
|
||||
applied: true,
|
||||
};
|
||||
const typeAmount: ObservedAction = {
|
||||
kind: "InputText",
|
||||
on: "testTag:AddTransactionScreen > testTag:TxnAmountField",
|
||||
applied: true,
|
||||
};
|
||||
const goBack: ObservedAction = { kind: "Tap", on: "testTag:BackButton", applied: true };
|
||||
|
||||
test("the ledger writes the balance bare and the add-transaction header labels it", () => {
|
||||
assert.equal(parseAccountBalance("$196.00"), 19600);
|
||||
assert.equal(parseAccountBalance("Balance: $196.00"), 19600);
|
||||
assert.equal(parseAccountBalance("-$1,234.56"), -123456);
|
||||
assert.equal(parseAccountBalance("Balance: -$1,234.56"), -123456);
|
||||
assert.equal(parseAccountBalance("$0.00"), 0);
|
||||
});
|
||||
|
||||
test("a balance that is not a complete amount is unknown, not zero", () => {
|
||||
assert.equal(parseAccountBalance(undefined), null);
|
||||
assert.equal(parseAccountBalance(""), null);
|
||||
assert.equal(parseAccountBalance("Balance:"), null);
|
||||
assert.equal(parseAccountBalance("$1,23.00"), null);
|
||||
});
|
||||
|
||||
// Which account these numbers belong to is never asked, because inside a run of
|
||||
// these two routes it cannot change: Route.Ledger is pushed only by tapping a
|
||||
// card on Home, Route.AddTransaction only by the ledger's own button for its
|
||||
// own account, and an accepted submit pops back to that same ledger. Reaching
|
||||
// another account means passing through Home, so every frame that is not one of
|
||||
// the two routes drops the carrier.
|
||||
test("a frame off the account's own screens drops the carrier", () => {
|
||||
for (const route of ["home", "login", "add-account", null]) {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route, balanceText: "$196.00", previousCarrier: 10000 }),
|
||||
{ value: null, carrier: null, fresh: false },
|
||||
`route ${route} kept a carrier that may belong to another account`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a readable balance on either of the two screens closes the window", () => {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route: "ledger", balanceText: "$196.00", previousCarrier: 10000 }),
|
||||
{ value: 19600, carrier: 19600, fresh: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
readAccountBalance({
|
||||
route: "add-transaction",
|
||||
balanceText: "Balance: $196.00",
|
||||
previousCarrier: 10000,
|
||||
}),
|
||||
{ value: 19600, carrier: 19600, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
// The balance node scrolled out of the viewport is unknown, not a new value.
|
||||
// The account still cannot have changed, so the carrier survives and the window
|
||||
// stays open across the frame.
|
||||
test("an unreadable balance keeps the carrier and does not close the window", () => {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route: "ledger", balanceText: undefined, previousCarrier: 10000 }),
|
||||
{ value: 10000, carrier: 10000, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("a double submit moves the account balance by twice what was typed", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a double-submitted debit is caught by the same bound", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: -29200,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("one submit moving the balance by exactly the typed amount is the app working", () => {
|
||||
for (const after of [29600, -9600]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: after,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A balance that has not moved is a commit still in flight (createTransaction
|
||||
// runs in a coroutine), a submit the app rejected, or a tap that never landed.
|
||||
// None of those is evidence, and an equality would convict all three.
|
||||
test("a balance that has not moved yet is not evidence", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 10000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a window holding anything other than one submit is not attributable", () => {
|
||||
for (const submitsInWindow of [0, 2, 37]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an amount this reading cannot represent is vacuous, not a violation", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("not an amount"),
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: Number.MAX_SAFE_INTEGER + 2,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a balance too large to hold exactly is not compared", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: Number.MAX_SAFE_INTEGER + 2,
|
||||
currAccountBalance: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 0,
|
||||
currAccountBalance: Number.MAX_SAFE_INTEGER + 2,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown balance on either side is not evidence", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: null,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a step whose action was not a submit attributes nothing", () => {
|
||||
for (const lastAction of [openLedger, openAddTxn, typeAmount, goBack, null]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Home shows every account's money, so it is not this comparison's scale", () => {
|
||||
for (const route of ["home", "login", "add-account", null]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route,
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A step of the walk: the frame it landed on, the balance node that frame
|
||||
// carried, what was in the amount field the step before, and the action that
|
||||
// got there. Driven through the same carrier and window the spec holds.
|
||||
interface Frame {
|
||||
route: string | null;
|
||||
balanceText?: string;
|
||||
typed?: string;
|
||||
lastAction: ObservedAction | null;
|
||||
}
|
||||
|
||||
function walk(frames: readonly Frame[]) {
|
||||
let carrier: number | null = null;
|
||||
let submits = 0;
|
||||
const verdicts: { violated: boolean; balance: number | null; submits: number }[] = [];
|
||||
let previous: number | null = null;
|
||||
let typedBefore = "";
|
||||
for (const frame of frames) {
|
||||
const reading = readAccountBalance({
|
||||
route: frame.route,
|
||||
balanceText: frame.balanceText,
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: frame.lastAction,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = window.next;
|
||||
verdicts.push({
|
||||
violated: committedAmountExceedsOneSubmit({
|
||||
route: frame.route,
|
||||
lastAction: frame.lastAction,
|
||||
submitsInWindow: window.reported,
|
||||
typedAmount: parseTypedAmount(typedBefore),
|
||||
prevAccountBalance: previous,
|
||||
currAccountBalance: reading.value,
|
||||
}),
|
||||
balance: reading.value,
|
||||
submits: window.reported,
|
||||
});
|
||||
previous = reading.value;
|
||||
typedBefore = frame.typed ?? "";
|
||||
}
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
// The trajectory of #78: open an account, open the transaction form, type,
|
||||
// double tap. Not one frame of it is Home, so the Home readings the counting
|
||||
// invariant compares never advance and it has nothing to say about any of it.
|
||||
// This is what a 117-step stretch of that run looked like, and it is why the
|
||||
// double tap at step 32 went unconvicted.
|
||||
test("the Home window cannot judge a walk that never goes Home", () => {
|
||||
const cards = [{ name: "Checking", balance: 10000, count: 3 as TxnCount }];
|
||||
let carrier: Record<string, TxnCount> | null = homeTxnCountsOf(cards);
|
||||
let submits = 0;
|
||||
for (const lastAction of [openLedger, openAddTxn, typeAmount, doubleSubmit]) {
|
||||
const reading = readHomeCards({ route: "ledger", reading: null, previousCarrier: carrier });
|
||||
const previous = carrier;
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh });
|
||||
submits = window.next;
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: previous,
|
||||
countsAfter: reading.value,
|
||||
submitsInWindow: window.reported,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The same trajectory, judged where the app actually is. The frame the double
|
||||
// tap lands on is the account's own ledger, so the window that closes there
|
||||
// holds exactly the one action.
|
||||
test("the double tap is convicted on the frame it lands on", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, true],
|
||||
);
|
||||
assert.equal(verdicts[3]?.submits, 1);
|
||||
});
|
||||
|
||||
test("the same walk with one transaction committed is silent throughout", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$296.00", lastAction: submit },
|
||||
{ route: "add-transaction", balanceText: "Balance: $296.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $296.00", typed: "50", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$346.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false, false, false],
|
||||
);
|
||||
});
|
||||
|
||||
// The reading a healthy app must survive: transactions arriving between two
|
||||
// readings that the window can no longer attribute to one action. The balance
|
||||
// node is off the viewport for a stretch, so the two numbers the property would
|
||||
// compare straddle two commits, and the balance moves by 296.00 against a typed
|
||||
// 50.00. Two submits in the window is not one, so there is nothing to judge.
|
||||
test("transactions arriving between two readings do not convict a healthy app", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", lastAction: submit },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", typed: "100", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$396.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false, false, false],
|
||||
);
|
||||
assert.equal(verdicts[6]?.submits, 2);
|
||||
assert.equal(verdicts[6]?.balance, 39600);
|
||||
});
|
||||
|
||||
// Attribution across accounts, which is the whole reason the carrier is dropped
|
||||
// rather than carried. A $500.00 account is left behind for an empty one whose
|
||||
// screens have not drawn their balance yet, and the submit into the new account
|
||||
// lands with exactly one submit in the window: every gate this property has is
|
||||
// open, and only the dropped carrier keeps it quiet. Carrying $500.00 across
|
||||
// that frame reads as 30400 committed against 19600 typed, on an app that did
|
||||
// nothing wrong.
|
||||
test("a ledger opened for another account never inherits the old balance", () => {
|
||||
for (const between of ["home", null]) {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$500.00", lastAction: openAddTxn },
|
||||
{ route: between, lastAction: goBack },
|
||||
{ route: "ledger", lastAction: openLedger },
|
||||
{ route: "add-transaction", typed: "196", lastAction: openAddTxn },
|
||||
{ route: "ledger", balanceText: "$196.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false],
|
||||
`an account switch through ${between} was compared across accounts`,
|
||||
);
|
||||
assert.equal(verdicts[4]?.submits, 1);
|
||||
assert.equal(verdicts[4]?.balance, 19600);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import {
|
||||
createdAccountHasNonZeroBalance,
|
||||
initialsOf,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
const created = {
|
||||
kind: "Tap",
|
||||
@@ -209,9 +212,90 @@ test("the merged web key still matches the name that was typed", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Two cards answering to one typed name leave the appearance unattributable:
|
||||
// the fuzzer creates duplicates from a five-name list, and the tree has been
|
||||
// seen exposing the same card twice on a transition frame.
|
||||
// The avatar the merged web key opens with, hand-computed off Format.kt rather
|
||||
// than off the mirror, because a mirror checked against itself checks nothing.
|
||||
// A single word gives its first two characters, several give the first letter
|
||||
// of the first and of the last, and an empty name gives "?".
|
||||
test("the initials a merged key opens with are the app's", () => {
|
||||
const named: [string, string][] = [
|
||||
["CH", "Checking"],
|
||||
["SA", "Savings"],
|
||||
["TR", "Travel"],
|
||||
["EF", "Emergency Fund"],
|
||||
["IN", "Investments"],
|
||||
["FU", "Fund"],
|
||||
["T2", "Travel 2024"],
|
||||
["A", "a"],
|
||||
["X9", "x9"],
|
||||
["-1", "-1"],
|
||||
["?", ""],
|
||||
["?", " "],
|
||||
];
|
||||
for (const [initials, name] of named) {
|
||||
assert.equal(initialsOf(name), initials, `initials for ${JSON.stringify(name)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// The attribution used to be a suffix test, and a suffix test hands the verdict
|
||||
// to whichever OTHER account happens to end with the typed name. Home lists
|
||||
// what fits the viewport, so the card that was just created is clipped out of
|
||||
// the reading exactly as easily as any other, and the older account left in it
|
||||
// is then judged for money it has held all along.
|
||||
test("an older account whose name ends with the typed one is not the created one", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Emergency Fund", 461012300)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("the merged web key is matched whole too, not by its ending", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("CHChecking", 0)],
|
||||
after: [account("CHChecking", 0), account("EFEmergency Fund", 461012300)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The card that was actually asked for is still judged, standing next to the
|
||||
// account that merely ends with its name.
|
||||
test("the created card is judged beside an account whose name ends with it", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("Emergency Fund", 461012300)],
|
||||
after: [account("Emergency Fund", 461012300), account("Fund", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("EFEmergency Fund", 461012300)],
|
||||
after: [account("EFEmergency Fund", 461012300), account("FUFund", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Two cards answering to one typed name leave the appearance unattributable.
|
||||
// Accounts.name is UNIQUE and Repository.createAccount rejects a name already
|
||||
// taken, so the pair is one card the tree exposed twice on a transition frame,
|
||||
// or two names the merged web key cannot tell apart.
|
||||
test("two cards matching the typed name are not attributable to the creation", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
@@ -219,7 +303,7 @@ test("two cards matching the typed name are not attributable to the creation", (
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)],
|
||||
after: [account("Checking", 0), account("Travel", 5000), account("Travel", 900)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
@@ -238,6 +322,40 @@ test("a card that was already there is not a card that was just created", () =>
|
||||
);
|
||||
});
|
||||
|
||||
// The runner's foreground guard restarted the app after the create. A fresh
|
||||
// launch draws Home from the top, so the visible set is whatever the new layout
|
||||
// fits rather than what was there a step ago, and "appeared in the reading" is
|
||||
// even less like "was created" than usual. The process may also have died
|
||||
// before the write landed, which makes the card that carries the typed name an
|
||||
// older account of that name coming into view.
|
||||
test("a create the runner relaunched across attributes nothing", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { ...created, relaunched: true },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("no relaunch reported still judges the account that was created", () => {
|
||||
for (const relaunched of [null, undefined]) {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { ...created, relaunched },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The apply call failed with the gesture possibly already delivered, so nobody
|
||||
// knows whether that account was created. The card carrying the typed name may
|
||||
// be an older one that scrolled into view, and attributing it to a creation
|
||||
|
||||
@@ -520,3 +520,41 @@ test("a submit the runner could not confirm demands no balance move", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// relaunched: true is the runner saying its foreground guard restarted the app
|
||||
// after this action. The tap landed, so the window still counts it, but nobody
|
||||
// can promise the process lived long enough for the write to reach sqlite. A
|
||||
// balance still sitting where it was is exactly what a healthy app looks like
|
||||
// across a relaunch, and demanding the typed amount of movement convicts it for
|
||||
// the runner's own restart.
|
||||
test("a submit the runner relaunched across demands no balance move", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The guard must not become a way of switching the property off. No relaunch
|
||||
// reported is the ordinary case, and web and iOS cannot report one at all.
|
||||
test("no relaunch reported still convicts a double submit", () => {
|
||||
for (const relaunched of [null, undefined]) {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true, relaunched },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 }[]) {
|
||||
|
||||
Reference in new issue
Block a user