WIP: rename sample app to Folio (#20)

* refactor: rename examples/sample-app to examples/folio

Directory-level rename and path references in Go tests, bundle-check,
top-level README, and getting-started docs. Package declarations,
Gradle config, iOS bundle IDs, and class names follow in later commits.

* refactor(folio): rename Kotlin package dev.uatu.sample to app.folio

Moves source dirs and sqldelight schema from dev/uatu/sample to
app/folio, updates package declarations and imports, and switches
Android namespace/applicationId, iOS binaryOption bundleId, and
sqldelight database packageName to the new identifier.

* refactor(folio): rename SampleApplication to FolioApplication

Android manifest now points at .FolioApplication with label 'Folio'
instead of 'Uatu Sample'.

* refactor(folio): set iOS bundle id and display name to Folio

bundleIdPrefix + PRODUCT_BUNDLE_IDENTIFIER -> app.folio.
CFBundleName + CFBundleDisplayName -> 'Folio'.

* refactor(folio): update demo email to [email protected]

* refactor(folio): point justfile at app.folio bundle id

Updates xcrun simctl launch target, uatu test --bundle-id, and the
build/uninstall comments to reference folio instead of sample.

* test: update fixture package ids to app.folio

Sidecar activity-resolver test and verifier spec-integration XML
fixtures referenced the old dev.uatu.sample Android package. Updates
them to match the folio app's real package id so the tests stay
representative of what the CLI sees on-device.

* test(verifier): rename SampleApp identifiers to Folio

Renames TestSampleAppSpec* functions, bundleSampleAppSpec helper, and
sampleAppHierarchyXML const (now loginHierarchyXML for consistency with
the other per-screen fixtures). Updates trailing sample-app mentions in
comments and assertion messages.

* refactor(folio): rename Gradle/npm/wasm project identifiers to folio

settings.gradle.kts rootProject.name, package.json + package-lock.json
name, and the WasmJS index.html <title> all still read 'uatu-sample' /
'Uatu Sample'. Realigns them with the Folio brand.

* docs(folio): rewrite README title + getting-started bundle id

examples/folio/README.md is now titled 'Folio' with the Kotlin source
paths corrected to app/folio. Getting-started example uses --bundle-id
app.folio. Harness launch message is now generic ('app under test')
since uatu-sample-harness is not specific to folio.

* chore(folio): drop trailing 'sample' reference in gradle.properties

* refactor(folio): rename LoginPage composable to LoginScreen

Align with KMP/Android industry convention (NowInAndroid, Cash App,
JetBrains samples use Screen, not Page).

* refactor(folio): rename HomePage composable to HomeScreen

* refactor(folio): rename AddAccountPage composable to AddAccountScreen

* refactor(folio): rename LedgerPage composable to LedgerScreen

* refactor(folio): rename AddTransactionPage composable to AddTransactionScreen

* refactor(folio): split Models.kt into app.folio.data package

Account, Transaction (with TxnType), and Session move into their own
files under app.folio.data, matching NowInAndroid-style per-type
organization.

* refactor(folio): move data layer into app.folio.data package

Repository, LedgerStore (expect + interface), SqlLedgerStore,
WebLedgerStore, DriverFactory (expect + actuals), AndroidLedgerContext,
and Snapshot move into app.folio.data. Update all consumer imports.

* refactor(folio): move Navigation into app.folio.navigation package

Split the former Navigation.kt into Route.kt (sealed interface) and
Navigator.kt (singleton). Update consumer imports across screens,
App.kt, and FolioApplication.

* refactor(folio): move Platform and Format into app.folio.platform

Both files carry expect declarations (Platform object, formatDate);
grouping them into a dedicated platform package makes the KMP seam
obvious and mirrors the structure used by JetBrains samples.

* refactor(folio): move login into feature/auth package

Create app.folio.feature.auth with LoginScreen + LoginUiState. Inline
the former Auth.kt (DEMO_EMAIL, DEMO_PASSWORD, checkCredentials) into
LoginScreen since it is the sole caller.

* refactor(folio): move HomeScreen into feature/home package

* refactor(folio): move account creation into feature/account package

AddAccountScreen gets its own AddAccountUiState colocated with the
screen, replacing the shared UiState.addAccountError.

* refactor(folio): move ledger screens into feature/ledger package

LedgerScreen and AddTransactionScreen move into app.folio.feature.ledger
with AddTransactionUiState (txnError, txnFormType) colocated. The
former catch-all UiState.kt is removed now that each screen owns its
state alongside its UI.

* refactor(folio): split Theme.kt; move theme and icons to subpackages

Theme split into Theme.kt (tokens, layout dims, LedgerTheme) and
Type.kt (typography) under app.folio.ui.theme. Icons moves to
app.folio.ui.icon. Update every consumer's imports to match.

* refactor(folio): split ui components into per-file under ui/component

Former Widgets.kt and Components.kt become 10 focused files: AppButton,
Card, EmptyState, ErrorText, FieldLabel, Header, IconButton (w/
BackButton), Screen, Segmented, TextInput. Matches NowInAndroid style
of one composable per file in a designsystem/component package.

* chore(folio): consolidate uatu testing files under uatu/ folder

Move spec.ts, package.json, package-lock.json into examples/folio/uatu
so all uatu-specific testing artifacts live in one place. runs/ and
node_modules/ follow the same convention (both remain gitignored).
Update justfile, README, and the two Go consumers (bundle-check tool +
verifier/trace tests) that referenced the old path.

* refactor(trace): drop folio path in writer test

Round-trip only needs a non-empty string; neutralize to keep the
library free of folio references.

* refactor(sidecar): neutralize ResolveActivity test fixtures

Swap app.folio for com.example.app in the fixture strings so the
sidecar tests don't reference the example app by name.

* refactor(bundle-check): take spec path as argument

Previously the tool hardcoded examples/folio/uatu/spec.ts. Accept a
positional spec path instead so the tool works for any example and
leaves no folio reference in the library surface.

* test(verifier): add neutral integration spec and hierarchy fixtures

Adds testdata/integration_spec.ts with two routes ("list", "form"),
an InputText on text_field, a Tap on primary/secondary_action, a
safety property (itemCountNonNegative), and a liveness property
(submitEventually). Adds hierarchies_test.go with matching XML
fixtures. Constants intentionally go in a _test.go at package root
rather than testdata/hierarchies.go because go skips .go files
under testdata/.

* refactor(verifier): replace folio integration tests with neutral ones

Renames bundleFolioSpec -> bundleIntegrationSpec and the three Test*
entry points to TestIntegrationSpec*. Uses the synthetic spec and
hierarchies added in the previous commit so the library's test suite
no longer references examples/folio at all.

Folio-specific coverage remains covered by examples/folio/justfile's
'just test' which exercises the real spec on device/emulator.

* chore: remove cmd/uatu-sample-harness

Not referenced by Makefile, docs, CI, or any script. Duplicates the
adb reverse helpers already in cmd/uatu/test_run.go, and its name
implies ownership by the sample app which violates the library/
example decoupling. If a bare-protocol debugging tool is later
needed it belongs inside cmd/uatu/.

* docs(folio): drop Layout section and KMP layout paragraph; fix AVD override syntax

The directory-tree Layout section rots faster than the code and
duplicates what ls shows for free. The expect/actual paragraph in
Stack was the same kind of filler. The README also claimed 'just
AVD=Pixel_7 test' but the justfile reads AVD as an env var via
env_var_or_default, so the correct invocation is 'AVD=Pixel_7
just test'.
This commit is contained in:
pj authored and GitHub committed 2026-04-20 16:04:20 +07:00
1 parent c1de4bf57a
commit a2e96af1af
88 files changed
+949 -900

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ func TestWriteMeta_RoundTrip(t *testing.T) {
meta := Meta{
Seed: 42,
SpecPath: "examples/sample-app/spec.ts",
SpecPath: "spec.ts",
BundleSHA256: "deadbeef",
Platform: "android",
BundleID: "in.okcredit.merchant",
+19
View File
@@ -0,0 +1,19 @@
package verifier
const listHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.widget.TextView" text="Items" bounds="[100,200][900,300]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[980,80][1060,160]" />
</node>
</hierarchy>`
const formHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="text_field" clickable="true" enabled="true" bounds="[64,320][1016,440]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[32,80][112,160]" />
</node>
</hierarchy>`
+59 -196
View File
@@ -10,62 +10,12 @@ import (
"github.com/priyanshujain/uatu/internal/ltl"
)
const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.widget.LinearLayout" bounds="[64,96][1016,2336]">
<node index="0" class="android.widget.TextView" text="Sign in" bounds="[100,200][900,300]" />
<node index="1" class="android.view.View" content-desc="login_email" clickable="true" enabled="true" bounds="[100,400][900,520]" />
<node index="2" class="android.view.View" content-desc="login_password" clickable="true" enabled="true" bounds="[100,560][900,680]" />
<node index="3" class="android.view.View" content-desc="login_submit" clickable="true" enabled="true" bounds="[100,800][900,920]" />
</node>
</node>
</hierarchy>`
const homeHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="logout_button" clickable="true" bounds="[980,80][1060,160]" />
<node index="1" class="android.view.View" content-desc="account_card:acc-1" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="account_card:acc-2" clickable="true" bounds="[64,460][1016,580]" />
<node index="3" class="android.view.View" content-desc="add_account_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addAccountHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="account_name_field" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="add_account_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const ledgerHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="add_txn_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addTxnHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="txn_credit" clickable="true" bounds="[64,280][540,360]" />
<node index="2" class="android.view.View" content-desc="txn_debit" clickable="true" bounds="[540,280][1016,360]" />
<node index="3" class="android.view.View" content-desc="txn_amount" clickable="true" bounds="[64,440][1016,560]" />
<node index="4" class="android.view.View" content-desc="txn_note" clickable="true" bounds="[64,600][1016,720]" />
<node index="5" class="android.view.View" content-desc="txn_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
// bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real
// @uatu/spec API so the integration test exercises the same path the CLI uses.
func bundleSampleAppSpec(t *testing.T) string {
// bundleIntegrationSpec bundles testdata/integration_spec.ts via the real
// @uatu/spec API so the integration test exercises the same path the CLI
// uses, with no reference to any specific example app.
func bundleIntegrationSpec(t *testing.T) string {
t.Helper()
specPath, err := filepath.Abs("../../examples/sample-app/spec.ts")
specPath, err := filepath.Abs("testdata/integration_spec.ts")
if err != nil {
t.Fatal(err)
}
@@ -90,155 +40,98 @@ func bundleSampleAppSpec(t *testing.T) string {
return string(bundle.JavaScript)
}
func loginSnapshots() Snapshots {
func listSnapshots() Snapshots {
return Snapshots{
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"auth_status": json.RawMessage(`"logged-out"`),
"account_count": json.RawMessage(`0`),
"accounts": json.RawMessage(`[]`),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
"route": json.RawMessage(`"list"`),
"item_count": json.RawMessage(`0`),
"has_submitted": json.RawMessage(`false`),
}
}
// twoAccountsJSON is shared between home, ledger, and add-transaction
// snapshots so invariants that correlate accounts with ledger rows stay
// consistent across routes.
const twoAccountsJSON = `[` +
`{"id":"acc-1","name":"Checking","balance":0,"txnCount":0},` +
`{"id":"acc-2","name":"Savings","balance":0,"txnCount":0}` +
`]`
func homeSnapshots() Snapshots {
func formSnapshots() Snapshots {
return Snapshots{
"route": json.RawMessage(`"home"`),
"logged_in": json.RawMessage(`true`),
"auth_status": json.RawMessage(`"logged-in"`),
"account_count": json.RawMessage(`2`),
"accounts": json.RawMessage(twoAccountsJSON),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
"route": json.RawMessage(`"form"`),
"item_count": json.RawMessage(`0`),
"has_submitted": json.RawMessage(`false`),
}
}
func addAccountSnapshots() Snapshots {
s := homeSnapshots()
s["route"] = json.RawMessage(`"add-account"`)
return s
}
func ledgerSnapshots() Snapshots {
s := homeSnapshots()
s["route"] = json.RawMessage(`"ledger"`)
s["active_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
func addTxnSnapshots() Snapshots {
s := ledgerSnapshots()
s["route"] = json.RawMessage(`"add-transaction"`)
s["txn_form_type"] = json.RawMessage(`"credit"`)
s["txn_form_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// emits Tap actions targeting the login screen elements when they are present
// in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) {
// TestIntegrationSpecFiresInputActions verifies the bundled neutral spec
// emits an InputText action on the text field and a Tap action on the
// primary button when both are present in the hierarchy.
func TestIntegrationSpecFiresInputActions(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(sampleAppHierarchyXML)
tree, err := hierarchy.Parse(formHierarchyXML)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
if err := v.PushSnapshot(SnapshotInput{Snapshots: formSnapshots(), Tree: tree}); err != nil {
t.Fatal(err)
}
typeEmailHits := 0
tapSubmitHits := 0
typeFieldHits := 0
tapPrimaryHits := 0
for range 400 {
action, err := v.NextAction()
if err != nil {
continue
}
switch {
case action.Kind == ActionKindInputText && action.On == "desc:login_email":
typeEmailHits++
case action.Kind == ActionKindTap && action.On == "desc:login_submit":
tapSubmitHits++
case action.Kind == ActionKindInputText && action.On == "desc:text_field":
typeFieldHits++
case action.Kind == ActionKindTap && action.On == "desc:primary_action":
tapPrimaryHits++
}
}
if typeEmailHits == 0 {
t.Fatal("loginHelper never typed into desc:login_email on sample-app hierarchy")
if typeFieldHits == 0 {
t.Fatal("typeIntoField never typed into desc:text_field on form hierarchy")
}
if tapSubmitHits == 0 {
t.Fatal("adversarialLogin never tapped desc:login_submit on sample-app hierarchy")
if tapPrimaryHits == 0 {
t.Fatal("tapPrimary never tapped desc:primary_action on form hierarchy")
}
}
// TestSampleAppSpecPropertiesEvaluate checks the properties declared in the
// sample-app spec evaluate sensibly across a small snapshot sequence. The
// TestIntegrationSpecPropertiesEvaluate checks the properties declared in
// the neutral spec evaluate sensibly across a small snapshot sequence. The
// spec mixes safety and liveness properties; Pending verdicts are expected
// for liveness properties that haven't had time to resolve yet.
func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(sampleAppHierarchyXML)
tree, err := hierarchy.Parse(listHierarchyXML)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
if err := v.PushSnapshot(SnapshotInput{Snapshots: listSnapshots(), Tree: tree}); err != nil {
t.Fatal(err)
}
verdicts := v.EvaluateProperties()
if verdicts["accountCountNonNegative"] != ltl.VerdictHolds {
t.Errorf("accountCountNonNegative: got %v, want holds", verdicts["accountCountNonNegative"])
}
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
}
if verdicts["authStatusIsKnown"] != ltl.VerdictHolds {
t.Errorf("authStatusIsKnown: got %v, want holds", verdicts["authStatusIsKnown"])
if verdicts["itemCountNonNegative"] != ltl.VerdictHolds {
t.Errorf("itemCountNonNegative: got %v, want holds", verdicts["itemCountNonNegative"])
}
if verdicts["routeIsKnown"] != ltl.VerdictHolds {
t.Errorf("routeIsKnown: got %v, want holds", verdicts["routeIsKnown"])
}
// Liveness: loginReachable hasn't resolved yet.
if verdicts["loginReachable"] != ltl.VerdictPending {
t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"])
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
}
// Liveness: submitEventually hasn't resolved yet.
if verdicts["submitEventually"] != ltl.VerdictPending {
t.Errorf("submitEventually: got %v, want pending", verdicts["submitEventually"])
}
}
// TestSampleAppSpecActionsFireOnEachRoute pushes a hierarchy + snapshot pair
// representative of each sample-app route and verifies the expected action
// generators fire against that state. Guards against silent breakage of any
// one route's generators (a regression only e2e would otherwise catch).
func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
// TestIntegrationSpecActionsFireOnEachRoute pushes a hierarchy + snapshot
// pair representative of each route and verifies the expected action
// generators fire against that state.
func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) {
cases := []struct {
name string
xml string
@@ -247,47 +140,25 @@ func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
expectOns []string
}{
{
name: "home",
xml: homeHierarchyXML,
snapshots: homeSnapshots(),
name: "list",
xml: listHierarchyXML,
snapshots: listSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{
"desc:add_account_button",
"desc:logout_button",
"descPrefix:account_card:",
},
expectOns: []string{"desc:primary_action", "desc:secondary_action"},
},
{
name: "add-account",
xml: addAccountHierarchyXML,
snapshots: addAccountSnapshots(),
name: "form",
xml: formHierarchyXML,
snapshots: formSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:add_account_submit", "desc:Back"},
},
{
name: "ledger",
xml: ledgerHierarchyXML,
snapshots: ledgerSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:add_txn_button", "desc:Back"},
},
{
name: "add-transaction",
xml: addTxnHierarchyXML,
snapshots: addTxnSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{
"desc:txn_submit",
"desc:txn_debit",
"desc:Back",
},
expectOns: []string{"desc:primary_action", "desc:secondary_action"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(tc.xml)
@@ -316,16 +187,8 @@ func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn))
}
}
if tc.name == "add-account" && !sawInputText["desc:account_name_field"] {
t.Errorf("add-account: typeAccountName never fired; saw %v", keysOf(sawInputText))
}
if tc.name == "add-transaction" {
if !sawInputText["desc:txn_amount"] {
t.Errorf("add-transaction: typeAmount never fired; saw %v", keysOf(sawInputText))
}
if !sawInputText["desc:txn_note"] {
t.Errorf("add-transaction: typeNote never fired; saw %v", keysOf(sawInputText))
}
if tc.name == "form" && !sawInputText["desc:text_field"] {
t.Errorf("form: typeIntoField never fired; saw %v", keysOf(sawInputText))
}
})
}
+68
View File
@@ -0,0 +1,68 @@
import {
InputText,
Tap,
actions,
always,
eventually,
extract,
weighted,
} from "@uatu/spec";
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
const route = extract<string>(
(state) => (state.snapshots.route as string) ?? "",
);
const itemCount = extract<number>(
(state) => (state.snapshots.item_count as number) ?? 0,
);
const hasSubmitted = extract<boolean>(
(state) => (state.snapshots.has_submitted as boolean) ?? false,
);
const primaryAction = extract((state) => state.ax.find("desc:primary_action"));
const secondaryAction = extract((state) =>
state.ax.find("desc:secondary_action"),
);
const textField = extract((state) => state.ax.find("desc:text_field"));
const itemCountNonNegative = always(() => itemCount.current >= 0);
const routeIsKnown = always(
() => route.current === "list" || route.current === "form",
);
const submitEventually = eventually(() => hasSubmitted.current).within(
30,
"seconds",
);
const typeIntoField = actions(() => {
if (route.current !== "form") return [];
const field = textField.current;
if (!field) return [];
return [InputText({ into: field, text: "hello" })];
});
const tapPrimary = actions(() => {
const button = primaryAction.current;
return button ? [Tap({ on: button })] : [];
});
const tapSecondary = actions(() => {
const button = secondaryAction.current;
return button ? [Tap({ on: button })] : [];
});
export const properties = {
itemCountNonNegative,
routeIsKnown,
submitEventually,
noUncaughtExceptions,
};
export const actionsRoot = weighted(
[40, typeIntoField],
[30, tapPrimary],
[30, tapSecondary],
);
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
(globalThis as { properties?: unknown }).properties = properties;