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
+990
-23
No files matched your search
@@ -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