mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
test(folio): cover transition frames, card readings and creation
This commit is contained in:
1 parent
d31de3b247
commit
9efed1f669
5 files changed
+570
-35
No files matched your search
@@ -0,0 +1,158 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
committedTransactionsExceedSubmits,
|
||||||
|
countSubmitsInWindow,
|
||||||
|
homeAccountsOf,
|
||||||
|
homeTxnCountsOf,
|
||||||
|
readHomeCards,
|
||||||
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||||
|
import type {
|
||||||
|
CardReading,
|
||||||
|
HomeCardReading,
|
||||||
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||||
|
|
||||||
|
const card = (name: string, balance: number | null, digits: string | undefined) => ({
|
||||||
|
name,
|
||||||
|
balance,
|
||||||
|
digits,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a laid-out card list reads as an account list and a count map", () => {
|
||||||
|
const cards = [card("Checking", 0, "0"), card("Travel", 2411200, "1")];
|
||||||
|
assert.deepEqual(homeAccountsOf(cards), [
|
||||||
|
{ name: "Checking", balance: 0 },
|
||||||
|
{ name: "Travel", balance: 2411200 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(homeTxnCountsOf(cards), { Checking: "0", Travel: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Android draws Home's own node a frame or two before its list, so `findAll`
|
||||||
|
// over the cards comes back empty while the screen already claims to be Home.
|
||||||
|
// "No cards on screen" is not "no accounts".
|
||||||
|
test("Home with nothing laid out yet is unknown, not empty", () => {
|
||||||
|
assert.equal(homeAccountsOf([]), null);
|
||||||
|
assert.equal(homeTxnCountsOf([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a card with no readable name or count is left out of the map", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined), card("Savings", 0, "2")]),
|
||||||
|
{ Savings: "2" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every card unreadable leaves nothing to compare, which is unknown", () => {
|
||||||
|
assert.equal(homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined)]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("off Home the carrier is reported unchanged", () => {
|
||||||
|
const carried = { Checking: "3" };
|
||||||
|
assert.deepEqual(readHomeCards({ route: "ledger", reading: null, previousCarrier: carried }), {
|
||||||
|
value: carried,
|
||||||
|
carrier: carried,
|
||||||
|
fresh: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a readable list replaces the carrier and closes the window", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
readHomeCards({ route: "home", reading: { Checking: "5" }, previousCarrier: { Checking: "3" } }),
|
||||||
|
{ value: { Checking: "5" }, carrier: { Checking: "5" }, fresh: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The poisoned carrier, the same defect readHomeTotalBalance was fixed for and
|
||||||
|
// this reading was not. An empty reading written into the carrier is handed
|
||||||
|
// straight back on every later off-Home step, so one un-laid-out Home turns the
|
||||||
|
// comparison into {} against {} for the rest of the run. Measured on android:
|
||||||
|
// counts_prev was {} at EVERY evaluation point of all 17 runs, which is a
|
||||||
|
// counting invariant that cannot fire at all.
|
||||||
|
test("an un-laid-out Home reports unknown but leaves the carrier intact", () => {
|
||||||
|
const carried = { Checking: "3", Savings: "1" };
|
||||||
|
assert.deepEqual(readHomeCards({ route: "home", reading: null, previousCarrier: carried }), {
|
||||||
|
value: null,
|
||||||
|
carrier: carried,
|
||||||
|
fresh: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The trace the fix has to survive, stepped through the carrier and the window
|
||||||
|
// the spec holds. A double-submit commits two rows against one action, the
|
||||||
|
// Home it lands on has not drawn its list yet, and the counting invariant must
|
||||||
|
// still be able to see the pair once a real Home comes back.
|
||||||
|
function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) {
|
||||||
|
let carrier: Record<string, string> | null = null;
|
||||||
|
let submits = 0;
|
||||||
|
const out: { counts: Record<string, string> | null; submits: number }[] = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
const reading: HomeCardReading<Record<string, string>> = readHomeCards({
|
||||||
|
route: step.route,
|
||||||
|
reading: homeTxnCountsOf(step.cards),
|
||||||
|
previousCarrier: carrier,
|
||||||
|
});
|
||||||
|
carrier = reading.carrier;
|
||||||
|
const window = countSubmitsInWindow({
|
||||||
|
previousCount: submits,
|
||||||
|
lastAction: step.lastAction as { kind?: string; on?: string } | null,
|
||||||
|
fresh: reading.fresh,
|
||||||
|
});
|
||||||
|
submits = window.next;
|
||||||
|
out.push({ counts: reading.value, submits: window.reported });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = { kind: "Tap", on: "testTag:AccountCard" };
|
||||||
|
const doubleSubmit = { kind: "DoubleTap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||||
|
|
||||||
|
test("an un-laid-out Home no longer kills the counting invariant", () => {
|
||||||
|
const trace = run([
|
||||||
|
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
|
||||||
|
{ route: "ledger", cards: [], lastAction: idle },
|
||||||
|
{ route: "home", cards: [], lastAction: doubleSubmit },
|
||||||
|
{ route: "ledger", cards: [], lastAction: idle },
|
||||||
|
{ route: "home", cards: [card("Checking", 0, "5")], lastAction: idle },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The un-laid-out Home is unknown for its own step, and the two steps after
|
||||||
|
// it get the last list anyone actually read rather than an empty one.
|
||||||
|
assert.deepEqual(trace[2]?.counts, null);
|
||||||
|
assert.deepEqual(trace[3]?.counts, { Checking: "3" });
|
||||||
|
assert.deepEqual(trace[4]?.counts, { Checking: "5" });
|
||||||
|
|
||||||
|
// The window it did not close still holds the double-submit, so one action
|
||||||
|
// against two committed rows is visible at the step that can compare them.
|
||||||
|
assert.equal(trace[4]?.submits, 1);
|
||||||
|
assert.equal(
|
||||||
|
committedTransactionsExceedSubmits({
|
||||||
|
countsBefore: trace[3]?.counts ?? null,
|
||||||
|
countsAfter: trace[4]?.counts ?? null,
|
||||||
|
submitsInWindow: trace[4]?.submits ?? 0,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other half of the pairing: the counts window has to close on the counts
|
||||||
|
// reading, not on the total's. A Home frame can render its footer total while
|
||||||
|
// its list is still empty, and a window that reset there would compare a pair of
|
||||||
|
// readings spanning submits it had already forgotten.
|
||||||
|
test("an un-laid-out Home does not close the counting window", () => {
|
||||||
|
const trace = run([
|
||||||
|
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
|
||||||
|
{ route: "ledger", cards: [], lastAction: doubleSubmit },
|
||||||
|
{ route: "home", cards: [], lastAction: doubleSubmit },
|
||||||
|
{ route: "home", cards: [card("Checking", 0, "7")], lastAction: idle },
|
||||||
|
]);
|
||||||
|
assert.equal(trace[3]?.submits, 2);
|
||||||
|
assert.equal(
|
||||||
|
committedTransactionsExceedSubmits({
|
||||||
|
countsBefore: { Checking: "3" },
|
||||||
|
countsAfter: trace[3]?.counts ?? null,
|
||||||
|
submitsInWindow: trace[3]?.submits ?? 0,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||||
|
|
||||||
|
const created = { kind: "Tap", on: "testTag:AddAccountScreen > testTag:AddAccountSubmit" };
|
||||||
|
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard" };
|
||||||
|
|
||||||
|
const account = (name: string, balance: number | null) => ({ name, balance });
|
||||||
|
|
||||||
|
// The property still has teeth: the account the fuzzer just asked for, holding
|
||||||
|
// money on the step its creation landed on Home, is a real violation.
|
||||||
|
test("an account created holding money is a violation", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a double-tapped create is judged the same way", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" },
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an account created empty is what the app is supposed to do", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 0)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a balance that could not be read is not evidence", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", null)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The false positives this replaces. Home lists the accounts that fit the
|
||||||
|
// viewport, so an account arrives in a later reading for reasons that have
|
||||||
|
// nothing to do with being created: the list scrolled, a clipped card finished
|
||||||
|
// laying out, or (before the route fix) the earlier reading came off a
|
||||||
|
// half-rendered Home mid-transition. Measured on android: an existing Travel
|
||||||
|
// holding $24,112.00 and an existing Savings holding $429,585.00, convicted for
|
||||||
|
// coming into view.
|
||||||
|
test("an account scrolling into view is not an account being created", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: idle,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Emergency Fund", 461012300), account("Checking", 0)],
|
||||||
|
after: [
|
||||||
|
account("Emergency Fund", 461012300),
|
||||||
|
account("Checking", 0),
|
||||||
|
account("Travel", 2411200),
|
||||||
|
account("Savings", 0),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nor is one that appears with no action at all behind it", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: null,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 2411200)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Even on the creation step, the only card judged is the one that answers to
|
||||||
|
// the name the fuzzer typed. A card that came into view alongside it is still
|
||||||
|
// just a card that came into view.
|
||||||
|
test("a funded account arriving beside the created one is not judged", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Savings",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Savings", 0), account("Travel", 2411200)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("off Home there is no reading to judge", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "ledger",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a transition frame names no route, so nothing is judged there either", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: null,
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown reading on either side is not evidence", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: null,
|
||||||
|
after: [account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: null,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// defaultActions types edge-case text into the name field, and an empty name is
|
||||||
|
// not a name we can find a card by.
|
||||||
|
test("an empty typed name attributes nothing", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: " ",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: undefined,
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Web merges the card into one node whose text opens with the avatar initials,
|
||||||
|
// so the identity key carries them: "TRTravel" is the card for "Travel".
|
||||||
|
test("the merged web key still matches the name that was typed", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("CHChecking", 0)],
|
||||||
|
after: [account("CHChecking", 0), account("TRTravel", 5000)],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
test("two cards matching the typed name are not attributable to the creation", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a card that was already there is not a card that was just created", () => {
|
||||||
|
assert.equal(
|
||||||
|
createdAccountHasNonZeroBalance({
|
||||||
|
route: "home",
|
||||||
|
lastAction: created,
|
||||||
|
typedName: "Travel",
|
||||||
|
before: [account("Checking", 0), account("Travel", 2411200)],
|
||||||
|
after: [account("Checking", 0), account("Travel", 2411200)],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -68,13 +68,13 @@ test("a second submit with no Home reading between them counts two", () => {
|
|||||||
|
|
||||||
// The two traces the freshness rule exists to tell apart, driven step by step
|
// The two traces the freshness rule exists to tell apart, driven step by step
|
||||||
// through the same pair of carriers the spec holds.
|
// through the same pair of carriers the spec holds.
|
||||||
function run(steps: { onHome: boolean; totalText?: string; lastAction: unknown }[]) {
|
function run(steps: { route: string | null; totalText?: string; lastAction: unknown }[]) {
|
||||||
let carrier: number | null = null;
|
let carrier: number | null = null;
|
||||||
let submits = 0;
|
let submits = 0;
|
||||||
const out: { total: number | null; submits: number }[] = [];
|
const out: { total: number | null; submits: number }[] = [];
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const reading = readHomeTotalBalance({
|
const reading = readHomeTotalBalance({
|
||||||
onHome: step.onHome,
|
route: step.route,
|
||||||
totalText: step.totalText,
|
totalText: step.totalText,
|
||||||
previousCarrier: carrier,
|
previousCarrier: carrier,
|
||||||
});
|
});
|
||||||
@@ -99,10 +99,10 @@ const doubleSubmit = { kind: "DoubleTap", on: submitOn };
|
|||||||
// which is why the property can see it at all.
|
// which is why the property can see it at all.
|
||||||
test("clean double submit: one action in the window, delta is 2x", () => {
|
test("clean double submit: one action in the window, delta is 2x", () => {
|
||||||
const trace = run([
|
const trace = run([
|
||||||
{ onHome: true, totalText: "$0.00", lastAction: null },
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||||
{ onHome: false, lastAction: idle },
|
{ route: "ledger", lastAction: idle },
|
||||||
{ onHome: false, lastAction: idle },
|
{ route: "ledger", lastAction: idle },
|
||||||
{ onHome: true, totalText: "$100.00", lastAction: doubleSubmit },
|
{ route: "home", totalText: "$100.00", lastAction: doubleSubmit },
|
||||||
]);
|
]);
|
||||||
assert.equal(trace[3]?.submits, 1);
|
assert.equal(trace[3]?.submits, 1);
|
||||||
assert.equal(trace[0]?.total, 0);
|
assert.equal(trace[0]?.total, 0);
|
||||||
@@ -114,11 +114,11 @@ test("clean double submit: one action in the window, delta is 2x", () => {
|
|||||||
// transactions, so it is not evidence about either typed amount.
|
// transactions, so it is not evidence about either typed amount.
|
||||||
test("two submits between Home visits: the window is not evidence", () => {
|
test("two submits between Home visits: the window is not evidence", () => {
|
||||||
const trace = run([
|
const trace = run([
|
||||||
{ onHome: true, totalText: "$0.00", lastAction: null },
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||||
{ onHome: false, lastAction: idle },
|
{ route: "ledger", lastAction: idle },
|
||||||
{ onHome: false, lastAction: submit },
|
{ route: "ledger", lastAction: submit },
|
||||||
{ onHome: false, lastAction: idle },
|
{ route: "ledger", lastAction: idle },
|
||||||
{ onHome: true, totalText: "-$130.00", lastAction: doubleSubmit },
|
{ route: "home", totalText: "-$130.00", lastAction: doubleSubmit },
|
||||||
]);
|
]);
|
||||||
assert.equal(trace[4]?.submits, 2);
|
assert.equal(trace[4]?.submits, 2);
|
||||||
});
|
});
|
||||||
@@ -126,11 +126,11 @@ test("two submits between Home visits: the window is not evidence", () => {
|
|||||||
// Freshness is restored by seeing Home, not by time passing.
|
// Freshness is restored by seeing Home, not by time passing.
|
||||||
test("a Home visit between two submits restores a one-action window", () => {
|
test("a Home visit between two submits restores a one-action window", () => {
|
||||||
const trace = run([
|
const trace = run([
|
||||||
{ onHome: true, totalText: "$0.00", lastAction: null },
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||||
{ onHome: false, lastAction: submit },
|
{ route: "ledger", lastAction: submit },
|
||||||
{ onHome: true, totalText: "$262.00", lastAction: idle },
|
{ route: "home", totalText: "$262.00", lastAction: idle },
|
||||||
{ onHome: false, lastAction: idle },
|
{ route: "ledger", lastAction: idle },
|
||||||
{ onHome: true, totalText: "$66.00", lastAction: doubleSubmit },
|
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
||||||
]);
|
]);
|
||||||
assert.equal(trace[1]?.submits, 1);
|
assert.equal(trace[1]?.submits, 1);
|
||||||
assert.equal(trace[2]?.submits, 1);
|
assert.equal(trace[2]?.submits, 1);
|
||||||
@@ -141,10 +141,10 @@ test("a Home visit between two submits restores a one-action window", () => {
|
|||||||
// the count would go back to zero against a total nobody read.
|
// the count would go back to zero against a total nobody read.
|
||||||
test("an unreadable Home does not close the window", () => {
|
test("an unreadable Home does not close the window", () => {
|
||||||
const trace = run([
|
const trace = run([
|
||||||
{ onHome: true, totalText: "$0.00", lastAction: null },
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||||
{ onHome: false, lastAction: submit },
|
{ route: "ledger", lastAction: submit },
|
||||||
{ onHome: true, totalText: undefined, lastAction: idle },
|
{ route: "home", totalText: undefined, lastAction: idle },
|
||||||
{ onHome: true, totalText: "$66.00", lastAction: doubleSubmit },
|
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
||||||
]);
|
]);
|
||||||
assert.equal(trace[2]?.total, null);
|
assert.equal(trace[2]?.total, null);
|
||||||
assert.equal(trace[3]?.submits, 2);
|
assert.equal(trace[3]?.submits, 2);
|
||||||
|
|||||||
@@ -5,35 +5,35 @@ import { readHomeTotalBalance } from "../../../examples/folio/sanderling/predica
|
|||||||
|
|
||||||
test("on Home the app's own total is the reading, the carrier and fresh", () => {
|
test("on Home the app's own total is the reading, the carrier and fresh", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: true, totalText: "$30.00", previousCarrier: 0 }),
|
readHomeTotalBalance({ route: "home", totalText: "$30.00", previousCarrier: 0 }),
|
||||||
{ value: 3000, carrier: 3000, fresh: true },
|
{ value: 3000, carrier: 3000, fresh: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("off Home there is nothing to read, so the carrier is reported unchanged", () => {
|
test("off Home there is nothing to read, so the carrier is reported unchanged", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: 3000 }),
|
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: 3000 }),
|
||||||
{ value: 3000, carrier: 3000, fresh: false },
|
{ value: 3000, carrier: 3000, fresh: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("off Home before any Home visit reports the null carrier, still not fresh", () => {
|
test("off Home before any Home visit reports the null carrier, still not fresh", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: null }),
|
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: null }),
|
||||||
{ value: null, carrier: null, fresh: false },
|
{ value: null, carrier: null, fresh: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a negative total parses with its sign", () => {
|
test("a negative total parses with its sign", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: true, totalText: "-$1,234.56", previousCarrier: 0 }),
|
readHomeTotalBalance({ route: "home", totalText: "-$1,234.56", previousCarrier: 0 }),
|
||||||
{ value: -123456, carrier: -123456, fresh: true },
|
{ value: -123456, carrier: -123456, fresh: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a fresh Home total overrides whatever the carrier held", () => {
|
test("a fresh Home total overrides whatever the carrier held", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: true, totalText: "$7.50", previousCarrier: 9999 }),
|
readHomeTotalBalance({ route: "home", totalText: "$7.50", previousCarrier: 9999 }),
|
||||||
{ value: 750, carrier: 750, fresh: true },
|
{ value: 750, carrier: 750, fresh: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -45,14 +45,14 @@ test("a fresh Home total overrides whatever the carrier held", () => {
|
|||||||
// unreadable Home left every later step null.
|
// unreadable Home left every later step null.
|
||||||
test("an unreadable Home total reports null but leaves the carrier intact", () => {
|
test("an unreadable Home total reports null but leaves the carrier intact", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: true, totalText: undefined, previousCarrier: 3000 }),
|
readHomeTotalBalance({ route: "home", totalText: undefined, previousCarrier: 3000 }),
|
||||||
{ value: null, carrier: 3000, fresh: false },
|
{ value: null, carrier: 3000, fresh: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a garbled Home total is unknown, not zero", () => {
|
test("a garbled Home total is unknown, not zero", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
readHomeTotalBalance({ onHome: true, totalText: "$", previousCarrier: 3000 }),
|
readHomeTotalBalance({ route: "home", totalText: "$", previousCarrier: 3000 }),
|
||||||
{ value: null, carrier: 3000, fresh: false },
|
{ value: null, carrier: 3000, fresh: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -60,17 +60,17 @@ test("a garbled Home total is unknown, not zero", () => {
|
|||||||
test("an unreadable Home no longer poisons the steps after it", () => {
|
test("an unreadable Home no longer poisons the steps after it", () => {
|
||||||
let carrier: number | null = null;
|
let carrier: number | null = null;
|
||||||
const seen: (number | null)[] = [];
|
const seen: (number | null)[] = [];
|
||||||
const step = (onHome: boolean, totalText: string | undefined) => {
|
const step = (route: string | null, totalText: string | undefined) => {
|
||||||
const reading = readHomeTotalBalance({ onHome, totalText, previousCarrier: carrier });
|
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
|
||||||
carrier = reading.carrier;
|
carrier = reading.carrier;
|
||||||
seen.push(reading.value);
|
seen.push(reading.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
step(true, "$30.00");
|
step("home", "$30.00");
|
||||||
step(true, undefined);
|
step("home", undefined);
|
||||||
step(false, undefined);
|
step("ledger", undefined);
|
||||||
step(false, undefined);
|
step("ledger", undefined);
|
||||||
step(true, "$50.00");
|
step("home", "$50.00");
|
||||||
|
|
||||||
assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]);
|
assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]);
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,7 @@ test("an unreadable Home no longer poisons the steps after it", () => {
|
|||||||
// the viewport happens to fit.
|
// the viewport happens to fit.
|
||||||
test("Home total is one node, so an off-screen account cannot change it", () => {
|
test("Home total is one node, so an off-screen account cannot change it", () => {
|
||||||
const withFiveCards = readHomeTotalBalance({
|
const withFiveCards = readHomeTotalBalance({
|
||||||
onHome: true,
|
route: "home",
|
||||||
totalText: "$2,589.00",
|
totalText: "$2,589.00",
|
||||||
previousCarrier: 0,
|
previousCarrier: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
countSubmitsInWindow,
|
||||||
|
readHomeCards,
|
||||||
|
readHomeTotalBalance,
|
||||||
|
routeOfFrame,
|
||||||
|
submitChangesBalanceByTypedAmount,
|
||||||
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||||
|
|
||||||
|
// The spec's own screen table. A frame is the set of markers its accessibility
|
||||||
|
// tree carries, which is all routeOfFrame is allowed to look at.
|
||||||
|
const SCREENS = {
|
||||||
|
login: "LoginScreen",
|
||||||
|
"add-account": "AddAccountScreen",
|
||||||
|
"add-transaction": "AddTransactionScreen",
|
||||||
|
ledger: "LedgerScreen",
|
||||||
|
home: "HomeScreen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const frame =
|
||||||
|
(...tags: string[]) =>
|
||||||
|
(tag: string) =>
|
||||||
|
tags.includes(tag);
|
||||||
|
|
||||||
|
test("a frame showing one screen names its route", () => {
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("LoginScreen")), "login");
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen")), "add-account");
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen")), "add-transaction");
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("LedgerScreen")), "ledger");
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen")), "home");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a frame showing no screen at all is unknown", () => {
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame()), null);
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("SomethingElse")), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The android transition frame: 425 of 1879 steps across 17 measured runs carry
|
||||||
|
// two screens, in every combination the navigation graph allows. Ranking the
|
||||||
|
// markers and taking the first answers add-transaction for the first of these
|
||||||
|
// while a second, unscoped look answers "on Home" -- and two answers for one
|
||||||
|
// frame is the defect. There is one answer now, and on a transition frame it is
|
||||||
|
// "I do not know".
|
||||||
|
test("a transition frame showing two screens names neither", () => {
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen")), null);
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LedgerScreen")), null);
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen")), null);
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LoginScreen")), null);
|
||||||
|
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "LedgerScreen")), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the three-screen frames android also emits name nothing", () => {
|
||||||
|
assert.equal(
|
||||||
|
routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen", "LedgerScreen")),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Everything read off Home takes the route as its only input, so a frame that
|
||||||
|
// is not Home cannot be read as Home by anything.
|
||||||
|
test("a transition frame's half-drawn Home total is not a reading", () => {
|
||||||
|
const route = routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen"));
|
||||||
|
assert.deepEqual(
|
||||||
|
readHomeTotalBalance({ route, totalText: "$86,911.00", previousCarrier: 8681600 }),
|
||||||
|
{ value: 8681600, carrier: 8681600, fresh: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nor is its half-drawn card list", () => {
|
||||||
|
const route = routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen"));
|
||||||
|
const carried = { Travel: "8", Checking: "0" };
|
||||||
|
const partial = { Travel: "8" };
|
||||||
|
assert.deepEqual(readHomeCards<Record<string, string>>({ route, reading: partial, previousCarrier: carried }), {
|
||||||
|
value: carried,
|
||||||
|
carrier: carried,
|
||||||
|
fresh: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The false conviction itself, android seed 3, steps 98-102 of the recorded
|
||||||
|
// trace. Five submits deep into the window the app sits on AddTransaction with
|
||||||
|
// "339" typed; a double-tap on Back starts the trip Home; the frame that comes
|
||||||
|
// back carries BOTH screens with Home's total already drawn behind the outgoing
|
||||||
|
// one. The old spec read that total as a fresh Home reading, reset the window to
|
||||||
|
// zero, and aimed the next tap at a TxnSubmit button that had stopped existing.
|
||||||
|
// The tap landed on Home, committed nothing, and the property demanded 33900 of
|
||||||
|
// movement for it. Nine of the eleven android convictions were this, all at
|
||||||
|
// delta 0.0x, all a single Tap where the real bug is a DoubleTap.
|
||||||
|
test("the measured android transition chain no longer convicts at delta 0", () => {
|
||||||
|
let carrier: number | null = 8681600;
|
||||||
|
let submits = 5;
|
||||||
|
const step = (
|
||||||
|
tags: string[],
|
||||||
|
totalText: string | undefined,
|
||||||
|
lastAction: { kind: string; on: string } | null,
|
||||||
|
) => {
|
||||||
|
const route = routeOfFrame(SCREENS, frame(...tags));
|
||||||
|
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
|
||||||
|
const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh });
|
||||||
|
carrier = reading.carrier;
|
||||||
|
submits = window.next;
|
||||||
|
return { route, total: reading.value, submits: window.reported };
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = { kind: "DoubleTap", on: "id:BackButton" };
|
||||||
|
const phantomSubmit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||||
|
|
||||||
|
const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
|
||||||
|
assert.equal(transition.route, null);
|
||||||
|
assert.equal(transition.total, 8681600);
|
||||||
|
assert.equal(transition.submits, 5);
|
||||||
|
|
||||||
|
const landing = step(["HomeScreen"], "$86,911.00", phantomSubmit);
|
||||||
|
assert.equal(landing.submits, 6);
|
||||||
|
assert.equal(
|
||||||
|
submitChangesBalanceByTypedAmount({
|
||||||
|
route: landing.route,
|
||||||
|
lastAction: phantomSubmit,
|
||||||
|
submitsInWindow: landing.submits,
|
||||||
|
typedAmount: 33900,
|
||||||
|
prevTotalBalance: transition.total,
|
||||||
|
currTotalBalance: landing.total,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// What the reset bought the old spec: the same landing, judged against a
|
||||||
|
// window of one and a total the transition frame had already banked.
|
||||||
|
assert.equal(
|
||||||
|
submitChangesBalanceByTypedAmount({
|
||||||
|
route: "home",
|
||||||
|
lastAction: phantomSubmit,
|
||||||
|
submitsInWindow: 1,
|
||||||
|
typedAmount: 33900,
|
||||||
|
prevTotalBalance: 8691100,
|
||||||
|
currTotalBalance: 8691100,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user