diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index e7ea34b..ab93c60 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -123,10 +123,22 @@ export function readHomeCards(args: { return { value: reading, carrier: reading, fresh: true }; } -function isTapOn( - lastAction: { kind?: string; on?: string | object } | null, - target: string, -): boolean { +// state.lastAction as the two hosts build it (internal/verifier/marshal.go +// lastActionFields), read defensively: every field is what a Go struct decided +// to emit, not something this file can trust a compile-time shape for. +// +// `applied` is true when the runner saw the action's dispatch succeed and null +// when the apply call failed with the gesture possibly already delivered: an +// RPC deadline can fire after the tap landed, and nothing can find out +// afterwards. That is unknown, not "it did not happen", which is what +// `lastAction === null` says. +export interface ObservedAction { + kind?: string; + on?: string | object; + applied?: true | null; +} + +function isTapOn(lastAction: ObservedAction | null, target: string): boolean { if (lastAction == null) return false; if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false; const on = lastAction.on; @@ -138,19 +150,27 @@ function isTapOn( // Repository.createTransaction: AddTransactionViewModel.submit() is the only // caller, AddTransactionEvent.Submit is the only thing that runs it, and the // TxnSubmit button's onClick is the only thing that sends that event. -export function isTxnSubmitTap( - lastAction: { kind?: string; on?: string | object } | null, -): boolean { +export function isTxnSubmitTap(lastAction: ObservedAction | null): boolean { return isTapOn(lastAction, "TxnSubmit"); } // Likewise the only action that creates an account. -export function isAddAccountSubmitTap( - lastAction: { kind?: string; on?: string | object } | null, -): boolean { +export function isAddAccountSubmitTap(lastAction: ObservedAction | null): boolean { return isTapOn(lastAction, "AddAccountSubmit"); } +// Did the runner see this action's dispatch succeed? `applied` is null when the +// apply call failed with the gesture possibly already delivered (an RPC +// deadline can fire after the tap landed), and the runner has no way to find +// out afterwards. Such an action may have caused anything the next reading +// shows, so it counts toward how many submits a window COULD hold, but it never +// licenses attributing an effect to it: a property that demands the effect of +// an action that may never have run convicts the app of the runner's own +// uncertainty. +export function confirmedApplied(lastAction: ObservedAction | null): boolean { + return lastAction != null && lastAction.applied === true; +} + // Counts the submit actions inside the window the balance property compares // over: from the last Home total we read to this step, inclusive of this step's // action. @@ -164,9 +184,15 @@ export function isAddAccountSubmitTap( // // The reset lands on `fresh`, the same event that advances the carrier, so the // count always describes exactly the interval the two compared totals span. +// +// A submit whose dispatch the runner could not confirm counts here, because +// this number is an upper bound on the submits the window holds and the tap may +// well have landed. Leaving it out is what convicted a healthy app: +// committedTransactionsExceedSubmits saw a transaction rise of one against a +// window of zero and called it a double submit. export function countSubmitsInWindow(args: { previousCount: number; - lastAction: { kind?: string; on?: string | object } | null; + lastAction: ObservedAction | null; fresh: boolean; }): { reported: number; next: number } { const { previousCount, lastAction, fresh } = args; @@ -343,7 +369,7 @@ export function homeTxnCountsOf(cards: readonly CardReading[]): Record testTag:AddAccountSubmit" }; -const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard" }; +const created = { + kind: "Tap", + on: "testTag:AddAccountScreen > testTag:AddAccountSubmit", + applied: true as const, +}; +const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard", applied: true as const }; const account = (name: string, balance: number | null) => ({ name, balance }); @@ -27,7 +31,7 @@ test("a double-tapped create is judged the same way", () => { assert.equal( createdAccountHasNonZeroBalance({ route: "home", - lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" }, + lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit", applied: true }, typedName: "Travel", before: [account("Checking", 0)], after: [account("Checking", 0), account("Travel", 5000)], @@ -233,3 +237,20 @@ test("a card that was already there is not a card that was just created", () => false, ); }); + +// 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 +// that may never have happened is a conviction built on a guess. +test("a create the runner could not confirm attributes nothing", () => { + assert.equal( + createdAccountHasNonZeroBalance({ + route: "home", + lastAction: { ...created, applied: null }, + typedName: "Travel", + before: [account("Checking", 0)], + after: [account("Checking", 0), account("Travel", 5000)], + }), + false, + ); +}); diff --git a/pkg/spec/test/folio-submit-balance-predicate.test.ts b/pkg/spec/test/folio-submit-balance-predicate.test.ts index 57a94ce..672fb5b 100644 --- a/pkg/spec/test/folio-submit-balance-predicate.test.ts +++ b/pkg/spec/test/folio-submit-balance-predicate.test.ts @@ -12,7 +12,7 @@ test("single submit: delta matches typed amount", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, @@ -26,7 +26,7 @@ test("double submit: delta is twice the typed amount, fires", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, @@ -40,7 +40,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "DoubleTap", on: submitOn }, + lastAction: { kind: "DoubleTap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, @@ -54,7 +54,7 @@ test("wrong action kind: vacuous true even with mismatch", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "InputText", on: submitOn }, + lastAction: { kind: "InputText", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, @@ -68,7 +68,7 @@ test("wrong target: vacuous true even with mismatch", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" }, + lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit", applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 1000, @@ -96,7 +96,7 @@ test("zero typedAmount: vacuous true", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 0, prevTotalBalance: 1000, @@ -110,7 +110,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } }, + lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" }, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, @@ -124,7 +124,7 @@ test("selector as object without TxnSubmit: vacuous true", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } }, + lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" }, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: 0, @@ -138,7 +138,7 @@ test("raw whole-dollar input: single submit clears", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: parseTypedAmount("50"), prevTotalBalance: 5000, @@ -152,7 +152,7 @@ test("raw whole-dollar input: double submit fires", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: parseTypedAmount("50"), prevTotalBalance: 5000, @@ -166,7 +166,7 @@ test("decimal input from empty prior balance clears", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: parseTypedAmount("5.50"), prevTotalBalance: 0, @@ -180,7 +180,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "DoubleTap", on: submitOn }, + lastAction: { kind: "DoubleTap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: parseTypedAmount("100"), prevTotalBalance: 0, @@ -194,7 +194,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "ledger", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, @@ -208,7 +208,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped", assert.equal( submitChangesBalanceByTypedAmount({ route: "add-transaction", - lastAction: { kind: "DoubleTap", on: submitOn }, + lastAction: { kind: "DoubleTap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, @@ -222,7 +222,7 @@ test("route gate: null route is skipped", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: null, - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, @@ -236,7 +236,7 @@ test("route gate: home landing with matching delta passes", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, @@ -250,7 +250,7 @@ test("route gate: home landing with double-insert delta fires", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 5000, prevTotalBalance: 0, @@ -275,7 +275,7 @@ test("above 2^53 a healthy single submit is not reported", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, @@ -289,7 +289,7 @@ test("above 2^53 a double-submit delta is not reported either", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, @@ -303,7 +303,7 @@ test("an unreadable previous balance above 2^53 is not evidence", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 1600, prevTotalBalance: HUGE_BALANCE, @@ -320,7 +320,7 @@ test("typed amount above 2^53 is not evidence", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 1e23, prevTotalBalance: 0, @@ -336,7 +336,7 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires" assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 4503599627370495, prevTotalBalance: 0, @@ -350,7 +350,7 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 9007199254740991, prevTotalBalance: 0, @@ -364,7 +364,7 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 4503599627370496, prevTotalBalance: 0, @@ -381,7 +381,7 @@ test("a large but exact difference between safe balances still fires", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 500, prevTotalBalance: -9007199254740991, @@ -397,7 +397,7 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: parseTypedAmount("999999999999999999999"), prevTotalBalance: 220900, @@ -417,7 +417,7 @@ test("freshness: two submits in the window is vacuous, not a conviction", () => assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "DoubleTap", on: submitOn }, + lastAction: { kind: "DoubleTap", on: submitOn, applied: true }, submitsInWindow: 2, typedAmount: 19600, prevTotalBalance: 0, @@ -431,7 +431,7 @@ test("freshness: two submits cannot convict even on a clean 2x delta", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 2, typedAmount: 500, prevTotalBalance: 1000, @@ -448,7 +448,7 @@ test("freshness boundary: exactly one submit is the window that convicts", () => assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "DoubleTap", on: submitOn }, + lastAction: { kind: "DoubleTap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 19600, prevTotalBalance: 0, @@ -462,7 +462,7 @@ test("freshness boundary: one submit with a healthy 1x delta still passes", () = assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 1, typedAmount: 19600, prevTotalBalance: 0, @@ -476,7 +476,7 @@ test("freshness boundary: three submits is vacuous", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 3, typedAmount: 500, prevTotalBalance: 0, @@ -493,7 +493,7 @@ test("freshness boundary: a window with no submit in it is vacuous", () => { assert.equal( submitChangesBalanceByTypedAmount({ route: "home", - lastAction: { kind: "Tap", on: submitOn }, + lastAction: { kind: "Tap", on: submitOn, applied: true }, submitsInWindow: 0, typedAmount: 500, prevTotalBalance: 1000, @@ -502,3 +502,21 @@ test("freshness boundary: a window with no submit in it is vacuous", () => { true, ); }); + +// applied: null is the runner saying it dispatched the tap and never learned +// whether it landed. A submit that committed nothing leaves the balance where +// it was, so demanding the typed amount of movement for it convicts an app that +// did exactly what it should have. +test("a submit the runner could not confirm demands no balance move", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn, applied: null }, + submitsInWindow: 1, + typedAmount: 500, + prevTotalBalance: 1000, + currTotalBalance: 1000, + }), + true, + ); +}); diff --git a/pkg/spec/test/folio-transition-frame.test.ts b/pkg/spec/test/folio-transition-frame.test.ts index f785642..64bcbf3 100644 --- a/pkg/spec/test/folio-transition-frame.test.ts +++ b/pkg/spec/test/folio-transition-frame.test.ts @@ -94,7 +94,7 @@ test("the measured android transition chain no longer convicts at delta 0", () = const step = ( tags: string[], totalText: string | undefined, - lastAction: { kind: string; on: string } | null, + lastAction: { kind: string; on: string; applied: true } | null, ) => { const route = routeOfFrame(SCREENS, frame(...tags)); const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier }); @@ -104,8 +104,12 @@ test("the measured android transition chain no longer convicts at delta 0", () = 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 back = { kind: "DoubleTap", on: "id:BackButton", applied: true as const }; + const phantomSubmit = { + kind: "Tap", + on: "testTag:AddTransactionScreen > testTag:TxnSubmit", + applied: true as const, + }; const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back); assert.equal(transition.route, null);