diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index f476238..e7ea34b 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -33,6 +33,28 @@ export function routeOfFrame( return shown; } +// A reading taken once per frame, keyed on the identity of the state object the +// reading came off. +// +// Both hosts build a NEW state object per step and hand that one object to +// every extractor: goja's PushSnapshot builds it through stateObject +// (internal/verifier/worker.go), the web runtime's evaluateExtractors through +// buildState (pkg/spec/src/web-runtime.ts). So the object IS the frame, and a +// value cached against it cannot outlive the frame it was read on. Holding the +// reference is what keeps that true rather than merely likely: the cached state +// cannot be collected, so no later step's state can be the same object. +// +// Worth having because these readings walk the whole tree. On web every +// `find` is a querySelectorAll across the document and each shadow root +// beneath it, and the spec takes a dozen of them per step off one frame. +export function oncePerFrame(read: (frame: S) => T): (frame: S) => T { + let last: { frame: S; value: T } | null = null; + return frame => { + if (last === null || last.frame !== frame) last = { frame, value: read(frame) }; + return last.value; + }; +} + // Reads the Home screen's own TOTAL BALANCE node and advances the carrier the // spec holds between Home visits. // diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index f0c9b2d..a403d51 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -22,6 +22,7 @@ import { createdAccountHasNonZeroBalance, homeAccountsOf, homeTxnCountsOf, + oncePerFrame, parseDollarCents, parseTypedAmount, readHomeCards, @@ -45,9 +46,12 @@ type Route = keyof typeof SCREENS; // The screen this frame shows, or null when it does not show exactly one: see // routeOfFrame, which owns that rule and the reason for it. Everything below // takes its answer from here, so no two readings can disagree about which -// screen the app is on. -const routeOf = (s: State): Route | null => - routeOfFrame(SCREENS, tag => s.ax.find({ testTag: tag }) != null); +// screen the app is on. Every extractor asks, so the answer is read once per +// frame: see oncePerFrame for why that stays fresh. +const routeOf = oncePerFrame( + (s: State): Route | null => + routeOfFrame(SCREENS, tag => s.ax.find({ testTag: tag }) != null), +); // An element is a reading, and a target, only when the route says we are on its // screen. Scoping a find to the screen's own node is not enough on a transition @@ -73,7 +77,7 @@ const route = extract("route", routeOf); // // Everything that comes off the card list shares this parse so the readings // cannot disagree with each other about what was on screen. -const homeCards = (s: State): CardReading[] => +const homeCards = oncePerFrame((s: State): CardReading[] => allOn("home", "AccountCard")(s).map(card => ({ name: cardAccountName({ childText: card.find({ testTag: "AccountName" })?.text, @@ -88,7 +92,7 @@ const homeCards = (s: State): CardReading[] => childText: card.find({ testTag: "AccountTxnCount" })?.text, cardText: card.text, }), - })); + }))); // Total balance: Home's own TOTAL BALANCE node, which the app computes over // every account rather than over the cards that happen to be laid out inside diff --git a/pkg/spec/test/folio-per-frame-reading.test.ts b/pkg/spec/test/folio-per-frame-reading.test.ts new file mode 100644 index 0000000..bf41da6 --- /dev/null +++ b/pkg/spec/test/folio-per-frame-reading.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { oncePerFrame, routeOfFrame } from "../../../examples/folio/sanderling/predicates.ts"; + +// oncePerFrame is what stops the folio spec re-walking the accessibility tree +// once per extractor, and the whole of its safety is that the state object is a +// fresh one every step (goja's stateObject, web's buildState). These tests pin +// both halves: the same frame is read once, a different frame is a different +// answer. A cache that outlived its frame would freeze every reading the spec +// takes and the properties over them would go quietly vacuous. +const SCREENS = { login: "LoginScreen", home: "HomeScreen" } as const; + +interface Frame { + present: readonly string[]; + finds: number; +} + +const frameShowing = (...present: readonly string[]): Frame => ({ present, finds: 0 }); + +const routeOf = oncePerFrame((frame: Frame) => + routeOfFrame(SCREENS, tag => { + frame.finds++; + return frame.present.includes(tag); + }), +); + +test("one frame is walked once, however many readings ask", () => { + const home = frameShowing("HomeScreen"); + assert.equal(routeOf(home), "home"); + assert.equal(routeOf(home), "home"); + assert.equal(routeOf(home), "home"); + assert.equal(home.finds, 2); +}); + +test("a new frame is a new answer", () => { + const home = frameShowing("HomeScreen"); + const login = frameShowing("LoginScreen"); + assert.equal(routeOf(home), "home"); + assert.equal(routeOf(login), "login"); + assert.equal(login.finds, 2); +}); + +test("a transition frame is not answered off the frame before it", () => { + assert.equal(routeOf(frameShowing("HomeScreen")), "home"); + assert.equal(routeOf(frameShowing("HomeScreen", "LoginScreen")), null); +}); + +test("returning to an earlier frame re-reads it", () => { + const home = frameShowing("HomeScreen"); + routeOf(home); + routeOf(frameShowing("LoginScreen")); + assert.equal(routeOf(home), "home"); + assert.equal(home.finds, 4); +}); + +// What makes memoizing the card list worth more than memoizing the route: the +// three readings taken off it share one parse instead of three. +test("a frame's reading is handed back by identity", () => { + const cardsOf = oncePerFrame((frame: Frame) => frame.present.map(tag => ({ tag }))); + const home = frameShowing("HomeScreen"); + assert.equal(cardsOf(home), cardsOf(home)); + assert.notEqual(cardsOf(home), cardsOf(frameShowing("HomeScreen"))); +});