diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index 82fbfc9..fe94ee5 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -181,6 +181,7 @@ export interface ObservedAction { kind?: string; on?: string | object; applied?: true | null; + relaunched?: true | null; } function isTapOn(lastAction: ObservedAction | null, target: string): boolean { @@ -216,6 +217,19 @@ export function confirmedApplied(lastAction: ObservedAction | null): boolean { return lastAction != null && lastAction.applied === true; } +// The runner reports this when its foreground guard had to relaunch the app +// after the action. The action still happened, so it still counts toward how +// many submits a window could hold; what nobody can promise across it is that +// the process survived long enough to commit, or that Home is showing the same +// slice of the account list it was. +// +// `true | null` for the same reason `applied` is: web and iOS cannot read the +// foreground at all, so "no relaunch reported" is not "the app never +// restarted", and only an explicit true licenses declining. +export function acrossRelaunch(lastAction: ObservedAction | null): boolean { + return lastAction != null && lastAction.relaunched === true; +} + // Counts the submit actions inside the window the balance property compares // over: from the last Home total we read to this step, inclusive of this step's // action. @@ -453,6 +467,10 @@ export function createdAccountHasNonZeroBalance(args: { // card to: the card that turned up may be an older account of the same name // scrolling into view. if (!confirmedApplied(lastAction)) return false; + // A relaunch draws Home from the top again, so the card that carries the + // typed name may be an older account of that name laid out where the new one + // used to be, and the create may not have reached sqlite at all. + if (acrossRelaunch(lastAction)) return false; if (before === null || after === null) return false; const typed = (args.typedName ?? "").trim(); if (typed === "") return false; @@ -641,6 +659,10 @@ export function submitChangesBalanceByTypedAmount(args: { // runner could not confirm may have committed nothing, and a balance that // did not move is then exactly what a healthy app looks like. if (!confirmedApplied(lastAction)) return true; + // The runner restarted the app after this tap, so the process may have died + // between the commit and the sqlite write. A balance that did not move is + // then a healthy app, exactly as it is for a submit that may not have landed. + if (acrossRelaunch(lastAction)) return true; if (submitsInWindow !== 1) return true; if (typedAmount === 0) return true; // An unknown total on either side is not evidence of anything. Comparing one diff --git a/pkg/spec/test/folio-new-account.test.ts b/pkg/spec/test/folio-new-account.test.ts index 8c9950c..98845f8 100644 --- a/pkg/spec/test/folio-new-account.test.ts +++ b/pkg/spec/test/folio-new-account.test.ts @@ -322,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 diff --git a/pkg/spec/test/folio-submit-balance-predicate.test.ts b/pkg/spec/test/folio-submit-balance-predicate.test.ts index 672fb5b..280f18a 100644 --- a/pkg/spec/test/folio-submit-balance-predicate.test.ts +++ b/pkg/spec/test/folio-submit-balance-predicate.test.ts @@ -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, + ); + } +});