diff --git a/examples/folio-web/sanderling/spec.ts b/examples/folio-web/sanderling/spec.ts index 842a743..b7e4ebb 100644 --- a/examples/folio-web/sanderling/spec.ts +++ b/examples/folio-web/sanderling/spec.ts @@ -3,14 +3,13 @@ import { Tap, actions, always, - edgeCaseText, eventually, extract, - from, - integers, + llm, next, now, taps, + typing, waitOnce, weighted, } from "@sanderling/spec"; @@ -178,8 +177,10 @@ const openAddAccount = actions(() => { }); // Readable enumeration keeps the demo legible; repeats over a run still -// exercise duplicate-name handling. -const accountNames = from([ +// exercise duplicate-name handling. One action per name rather than one action +// for a drawn name: a draw reads the seeded picker's stream, which the model +// policy never enters, so the two would explore different action spaces. +const accountNames = [ "Checking", "Savings", "Travel", @@ -188,18 +189,14 @@ const accountNames = from([ "Investments", "Groceries", "Petty Cash", -]); +]; -function typeAccountNameWith(sampler: { generate(): string }) { - return actions(() => { - if (!onAddAccountPage.current) return []; - const field = accountNameField.current; - return field ? [InputText({ into: field, text: sampler.generate() })] : []; - }); -} - -const typeAccountName = typeAccountNameWith(accountNames); -const typeAccountNameEdge = typeAccountNameWith(edgeCaseText()); +const typeAccountName = actions(() => { + if (!onAddAccountPage.current) return []; + const field = accountNameField.current; + if (!field) return []; + return accountNames.map((name) => InputText({ into: field, text: name })); +}); const submitAddAccount = actions(() => { if (!onAddAccountPage.current) return []; @@ -209,9 +206,7 @@ const submitAddAccount = actions(() => { const openAccount = actions(() => { if (!onHomePage.current) return []; - const cards = accountCards.current; - if (cards.length === 0) return []; - return [Tap({ on: from(cards).generate().element })]; + return accountCards.current.map((card) => Tap({ on: card.element })); }); const openAddTxn = actions(() => { @@ -220,41 +215,37 @@ const openAddTxn = actions(() => { return btn ? [Tap({ on: btn })] : []; }); -// Valid happy-path amounts keep the balance properties exercised; the edge -// branch (weighted in actionsRoot) stresses parsing with the adversarial corpus. -const validAmounts = integers().between(1, 99999); +// Valid happy-path amounts keep the balance properties exercised; the `typing` +// branch in actionsRoot stresses parsing with the adversarial corpus. Four +// authored values rather than a range, because a range cannot be enumerated: +// the smallest accepted amount, one with cents, an everyday one, and one wide +// enough to format with a thousands separator. +const validAmounts = ["1", "12.34", "250", "99999"]; -function typeAmountWith(sampler: { generate(): string }) { - return actions(() => { - if (!onAddTxnPage.current) return []; - const field = txnAmountField.current; - return field ? [InputText({ into: field, text: sampler.generate() })] : []; - }); -} +const typeAmount = actions(() => { + if (!onAddTxnPage.current) return []; + const field = txnAmountField.current; + if (!field) return []; + return validAmounts.map((amount) => InputText({ into: field, text: amount })); +}); -const typeAmount = typeAmountWith({ generate: () => String(validAmounts.generate()) }); -const typeAmountEdge = typeAmountWith(edgeCaseText()); - -const noteSampler = from([ - "Coffee", - "Paycheck", - "Gas", - "Refund", - "", - "Groceries for the week", -]); +const notes = ["Coffee", "Paycheck", "Gas", "Refund", "", "Groceries for the week"]; const typeNote = actions(() => { if (!onAddTxnPage.current) return []; const field = txnNoteField.current; - return field ? [InputText({ into: field, text: noteSampler.generate() })] : []; + if (!field) return []; + return notes.map((note) => InputText({ into: field, text: note })); }); const toggleTxnType = actions(() => { if (!onAddTxnPage.current) return []; - const targets = [txnCreditButton.current, txnDebitButton.current].filter(Boolean); - if (targets.length === 0) return []; - return [Tap({ on: from(targets).generate() })]; + const credit = txnCreditButton.current; + const debit = txnDebitButton.current; + const options = []; + if (credit) options.push(Tap({ on: credit })); + if (debit) options.push(Tap({ on: debit })); + return options; }); const submitTxn = actions(() => { @@ -274,22 +265,36 @@ const logoutAction = actions(() => { return btn ? [Tap({ on: btn })] : []; }); +// `typing` carries the adversarial input the two authored edge-case leaves used +// to: it names the field and leaves the value to whichever policy is driving, +// so the seeded picker draws from the edge-case corpus and the model writes its +// own text. It holds the 8 weight those two leaves shared, so every other +// branch keeps the share it had. export const actionsRoot = weighted( [30, loginHelper], [2, adversarialLogin], [14, openAddAccount], [14, typeAccountName], - [4, typeAccountNameEdge], [14, submitAddAccount], [14, openAccount], [12, openAddTxn], [14, typeAmount], - [4, typeAmountEdge], [8, typeNote], [6, toggleTxnType], [16, submitTxn], [6, goBack], [1, logoutAction], [4, taps], + [8, typing], [2, waitOnce], ); + +// The LLM generator is orthogonal to actionsRoot: with `--generator llm` a model +// picks from the SAME weighted candidate set above, reading the screenshot and a +// numbered, weight-annotated list; the default `--generator seeded` ignores it. +// instructions describe only WHAT the app is, never HOW to test it. +export const generator = llm({ + model: "gpt-5.4-nano", + instructions: + "Folio is a personal-finance ledger app in the browser. Sign in with the demo credentials shown on the login screen. The home screen lists accounts, each with a balance. You can create accounts, open an account to see its ledger, and add credit or debit transactions; each transaction has an amount and changes that account's balance and the overall total.", +});