diff --git a/pkg/spec/test/folio-home-card-readings.test.ts b/pkg/spec/test/folio-home-card-readings.test.ts new file mode 100644 index 0000000..0ec7a40 --- /dev/null +++ b/pkg/spec/test/folio-home-card-readings.test.ts @@ -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 | null = null; + let submits = 0; + const out: { counts: Record | null; submits: number }[] = []; + for (const step of steps) { + const reading: HomeCardReading> = 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, + ); +}); diff --git a/pkg/spec/test/folio-new-account.test.ts b/pkg/spec/test/folio-new-account.test.ts new file mode 100644 index 0000000..def23bb --- /dev/null +++ b/pkg/spec/test/folio-new-account.test.ts @@ -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, + ); +}); diff --git a/pkg/spec/test/folio-submit-window.test.ts b/pkg/spec/test/folio-submit-window.test.ts index fd738d5..da422ed 100644 --- a/pkg/spec/test/folio-submit-window.test.ts +++ b/pkg/spec/test/folio-submit-window.test.ts @@ -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 // 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 submits = 0; const out: { total: number | null; submits: number }[] = []; for (const step of steps) { const reading = readHomeTotalBalance({ - onHome: step.onHome, + route: step.route, totalText: step.totalText, previousCarrier: carrier, }); @@ -99,10 +99,10 @@ const doubleSubmit = { kind: "DoubleTap", on: submitOn }; // which is why the property can see it at all. test("clean double submit: one action in the window, delta is 2x", () => { const trace = run([ - { onHome: true, totalText: "$0.00", lastAction: null }, - { onHome: false, lastAction: idle }, - { onHome: false, lastAction: idle }, - { onHome: true, totalText: "$100.00", lastAction: doubleSubmit }, + { route: "home", totalText: "$0.00", lastAction: null }, + { route: "ledger", lastAction: idle }, + { route: "ledger", lastAction: idle }, + { route: "home", totalText: "$100.00", lastAction: doubleSubmit }, ]); assert.equal(trace[3]?.submits, 1); 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. test("two submits between Home visits: the window is not evidence", () => { const trace = run([ - { onHome: true, totalText: "$0.00", lastAction: null }, - { onHome: false, lastAction: idle }, - { onHome: false, lastAction: submit }, - { onHome: false, lastAction: idle }, - { onHome: true, totalText: "-$130.00", lastAction: doubleSubmit }, + { route: "home", totalText: "$0.00", lastAction: null }, + { route: "ledger", lastAction: idle }, + { route: "ledger", lastAction: submit }, + { route: "ledger", lastAction: idle }, + { route: "home", totalText: "-$130.00", lastAction: doubleSubmit }, ]); 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. test("a Home visit between two submits restores a one-action window", () => { const trace = run([ - { onHome: true, totalText: "$0.00", lastAction: null }, - { onHome: false, lastAction: submit }, - { onHome: true, totalText: "$262.00", lastAction: idle }, - { onHome: false, lastAction: idle }, - { onHome: true, totalText: "$66.00", lastAction: doubleSubmit }, + { route: "home", totalText: "$0.00", lastAction: null }, + { route: "ledger", lastAction: submit }, + { route: "home", totalText: "$262.00", lastAction: idle }, + { route: "ledger", lastAction: idle }, + { route: "home", totalText: "$66.00", lastAction: doubleSubmit }, ]); assert.equal(trace[1]?.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. test("an unreadable Home does not close the window", () => { const trace = run([ - { onHome: true, totalText: "$0.00", lastAction: null }, - { onHome: false, lastAction: submit }, - { onHome: true, totalText: undefined, lastAction: idle }, - { onHome: true, totalText: "$66.00", lastAction: doubleSubmit }, + { route: "home", totalText: "$0.00", lastAction: null }, + { route: "ledger", lastAction: submit }, + { route: "home", totalText: undefined, lastAction: idle }, + { route: "home", totalText: "$66.00", lastAction: doubleSubmit }, ]); assert.equal(trace[2]?.total, null); assert.equal(trace[3]?.submits, 2); diff --git a/pkg/spec/test/folio-total-balance.test.ts b/pkg/spec/test/folio-total-balance.test.ts index ea97ce1..67c8efa 100644 --- a/pkg/spec/test/folio-total-balance.test.ts +++ b/pkg/spec/test/folio-total-balance.test.ts @@ -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", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: true, totalText: "$30.00", previousCarrier: 0 }), + readHomeTotalBalance({ route: "home", totalText: "$30.00", previousCarrier: 0 }), { value: 3000, carrier: 3000, fresh: true }, ); }); test("off Home there is nothing to read, so the carrier is reported unchanged", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: 3000 }), + readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: 3000 }), { value: 3000, carrier: 3000, fresh: false }, ); }); test("off Home before any Home visit reports the null carrier, still not fresh", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: false, totalText: undefined, previousCarrier: null }), + readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: null }), { value: null, carrier: null, fresh: false }, ); }); test("a negative total parses with its sign", () => { 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 }, ); }); test("a fresh Home total overrides whatever the carrier held", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: true, totalText: "$7.50", previousCarrier: 9999 }), + readHomeTotalBalance({ route: "home", totalText: "$7.50", previousCarrier: 9999 }), { 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. test("an unreadable Home total reports null but leaves the carrier intact", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: true, totalText: undefined, previousCarrier: 3000 }), + readHomeTotalBalance({ route: "home", totalText: undefined, previousCarrier: 3000 }), { value: null, carrier: 3000, fresh: false }, ); }); test("a garbled Home total is unknown, not zero", () => { assert.deepEqual( - readHomeTotalBalance({ onHome: true, totalText: "$", previousCarrier: 3000 }), + readHomeTotalBalance({ route: "home", totalText: "$", previousCarrier: 3000 }), { 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", () => { let carrier: number | null = null; const seen: (number | null)[] = []; - const step = (onHome: boolean, totalText: string | undefined) => { - const reading = readHomeTotalBalance({ onHome, totalText, previousCarrier: carrier }); + const step = (route: string | null, totalText: string | undefined) => { + const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier }); carrier = reading.carrier; seen.push(reading.value); }; - step(true, "$30.00"); - step(true, undefined); - step(false, undefined); - step(false, undefined); - step(true, "$50.00"); + step("home", "$30.00"); + step("home", undefined); + step("ledger", undefined); + step("ledger", undefined); + step("home", "$50.00"); 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. test("Home total is one node, so an off-screen account cannot change it", () => { const withFiveCards = readHomeTotalBalance({ - onHome: true, + route: "home", totalText: "$2,589.00", previousCarrier: 0, }); diff --git a/pkg/spec/test/folio-transition-frame.test.ts b/pkg/spec/test/folio-transition-frame.test.ts new file mode 100644 index 0000000..f785642 --- /dev/null +++ b/pkg/spec/test/folio-transition-frame.test.ts @@ -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>({ 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, + ); +});