refactor(folio-spec): query testTag and identify items by visible text

Replaces every accessibilityText / descPrefix data-carrier read with
testTag selectors that resolve to resource-id (Android) or
accessibilityIdentifier (iOS) via the SDK's alias table.

- Routes detected via testTag (LoginScreen, HomeScreen, etc.)
- Account identity = visible account name (no synthetic id encoded
  in semantics).
- Ledger row identity = joined text content of the row.
- Active account derived from route alone (not parsed from
  contentDescription).
- Focused input read from native focused="true" attribute, not from
  a custom focused_input data carrier.
This commit is contained in:
pj committed 2026-04-26 14:32:59 +07:00
1 parent 3288364424
commit 7d3efdcc7a
1 file changed
+68 -64
+68 -64
View File
@@ -11,17 +11,16 @@ import {
} from "@sanderling/spec"; } from "@sanderling/spec";
interface Account { interface Account {
id: string; name: string;
balance: number; balance: number;
} }
interface LedgerRow { interface LedgerRow {
id: string; key: string;
signed: number; signed: number;
} }
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
// formatCents always uses $ prefix, . as decimal, , as thousands separator.
function parseDollarCents(text: string | undefined): number { function parseDollarCents(text: string | undefined): number {
if (!text) return 0; if (!text) return 0;
const sign = text.startsWith("-") ? -1 : 1; const sign = text.startsWith("-") ? -1 : 1;
@@ -29,89 +28,96 @@ function parseDollarCents(text: string | undefined): number {
return digits ? sign * parseInt(digits, 10) : 0; return digits ? sign * parseInt(digits, 10) : 0;
} }
// Route and auth state derived from screen root nodes // Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS)
const loggedIn = extract(s => s.ax.find({ accessibilityText: "LoginScreen" }) == null); const loggedIn = extract(s => s.ax.find({ testTag: "LoginScreen" }) == null);
const route = extract<string | null>(s => { const route = extract<string | null>(s => {
if (s.ax.find({ accessibilityText: "LoginScreen" })) return "login"; if (s.ax.find({ testTag: "LoginScreen" })) return "login";
if (s.ax.find({ accessibilityText: "HomeScreen" })) return "home"; if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account";
if (s.ax.find({ accessibilityText: "AddAccountScreen" })) return "add-account"; if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction";
if (s.ax.find({ accessibilityText: "LedgerScreen" })) return "ledger"; if (s.ax.find({ testTag: "LedgerScreen" })) return "ledger";
if (s.ax.find({ accessibilityText: "AddTransactionScreen" })) return "add-transaction"; if (s.ax.find({ testTag: "HomeScreen" })) return "home";
return null; return null;
}); });
// All element lookups scoped through their screen root // Account cards on Home: identified by visible name (the first Text node inside).
const accounts = extract(s => // Each card carries an AccountBalance Text with the formatted dollar value.
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") const accounts = extract<Account[]>(s => {
.map(el => ({ const home = s.ax.find({ testTag: "HomeScreen" });
id: el.desc?.split(":")[1] ?? "", if (!home) return [];
balance: parseDollarCents(el.find({ accessibilityText: "account_balance" })?.text), return home.findAll({ testTag: "AccountCard" }).map(card => {
})) ?? []); const texts = card.findAll({}).map(c => c.text).filter((t): t is string => !!t);
const ledgerRows = extract(s => const balance = parseDollarCents(card.find({ testTag: "AccountBalance" })?.text);
s.ax.find({ accessibilityText: "LedgerScreen" })?.findAll("descPrefix:ledger_row:") const name = texts.find(t => !t.startsWith("$") && !/^\d/.test(t) && t !== "transaction" && t !== "transactions") ?? "";
.map(el => ({ return { name, balance };
id: el.desc?.split(":")[1] ?? "", });
signed: parseDollarCents(el.find({ accessibilityText: "txn_amount" })?.text),
})) ?? []);
const ledgerBalance = extract(s =>
parseDollarCents(
s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "ledger_balance_display" })?.text
));
const activeAccountId = extract(s => {
const desc = s.ax.find("descPrefix:LedgerScreen:")?.desc;
return desc ? (desc.split(":")[1] ?? null) : null;
}); });
// focusedInput lives in the app root (not inside any screen), so unscoped // Ledger rows: identified by the row's text contents joined together.
const focusedInput = extract(s => const ledgerRows = extract<LedgerRow[]>(s => {
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null); const ledger = s.ax.find({ testTag: "LedgerScreen" });
if (!ledger) return [];
return ledger.findAll({ testTag: "LedgerRow" }).map(row => {
const texts = row.findAll({}).map(c => c.text).filter((t): t is string => !!t);
const signed = parseDollarCents(row.find({ testTag: "TxnAmount" })?.text);
return { key: texts.join("|"), signed };
});
});
const ledgerBalance = extract(s =>
parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text));
// Focus uses the native focused="true" attribute. We surface whichever
// stable identifier the focused element carries (testTag, label, etc).
const focusedFieldTag = extract(s => {
const f = s.ax.find({ focused: "true" });
if (!f) return null;
return f.attrs?.["resource-id"] ?? f.attrs?.["accessibilityIdentifier"] ?? f.attrs?.["identifier"] ?? null;
});
const loginEmailField = extract(s => const loginEmailField = extract(s =>
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_email" })); s.ax.find({ testTag: "LoginScreen" })?.find({ testTag: "LoginEmail" }));
const loginPasswordField = extract(s => const loginPasswordField = extract(s =>
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_password" })); s.ax.find({ testTag: "LoginScreen" })?.find({ testTag: "LoginPassword" }));
const loginSubmit = extract(s => const loginSubmit = extract(s =>
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_submit" })); s.ax.find({ testTag: "LoginScreen" })?.find({ testTag: "LoginSubmit" }));
const addAccountButton = extract(s => const addAccountButton = extract(s =>
s.ax.find({ accessibilityText: "HomeScreen" })?.find({ accessibilityText: "add_account_button" })); s.ax.find({ testTag: "HomeScreen" })?.find({ testTag: "AddAccountButton" }));
const accountNameField = extract(s => const accountNameField = extract(s =>
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "account_name_field" })); s.ax.find({ testTag: "AddAccountScreen" })?.find({ testTag: "AccountNameField" }));
const addAccountSubmit = extract(s => const addAccountSubmit = extract(s =>
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "add_account_submit" })); s.ax.find({ testTag: "AddAccountScreen" })?.find({ testTag: "AddAccountSubmit" }));
const addTxnButton = extract(s => const addTxnButton = extract(s =>
s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "add_txn_button" })); s.ax.find({ testTag: "LedgerScreen" })?.find({ testTag: "AddTransactionButton" }));
const txnAmountField = extract(s => const txnAmountField = extract(s =>
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_amount" })); s.ax.find({ testTag: "AddTransactionScreen" })?.find({ testTag: "TxnAmountField" }));
const txnSubmit = extract(s => const txnSubmit = extract(s =>
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_submit" })); s.ax.find({ testTag: "AddTransactionScreen" })?.find({ testTag: "TxnSubmit" }));
const accountCards = extract(s => const accountCards = extract(s =>
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") ?? []); s.ax.find({ testTag: "HomeScreen" })?.findAll({ testTag: "AccountCard" }) ?? []);
const backButton = extract(s => s.ax.find("desc:Back")); const backButton = extract(s => s.ax.find({ testTag: "BackButton" }));
// Property 1: every new account starts with balance === 0 // Property 1: every newly-appearing account starts with balance === 0.
// Guard: only check when accounts were visible in the previous step too. // Identity is by visible name. Guard against navigation transitions where
// Without this, navigating away from HomeScreen (accounts=[]) then back // accounts vanish from the visible tree.
// makes every account look "new", causing false positives on pre-existing balances.
const newAccountBalanceIsZero = always( const newAccountBalanceIsZero = always(
next(() => { next(() => {
const prev = accounts.previous ?? []; const prev = accounts.previous ?? [];
const curr = accounts.current; const curr = accounts.current;
if (prev.length === 0 || curr.length === 0) return true; if (prev.length === 0 || curr.length === 0) return true;
const prevIds = new Set(prev.map(a => a.id)); const prevNames = new Set(prev.map(a => a.name));
const newAccounts = curr.filter(a => !prevIds.has(a.id)); return curr.filter(a => !prevNames.has(a.name)).every(a => a.balance === 0);
return newAccounts.every(a => a.balance === 0);
}) })
); );
// Property 2: every new transaction changes the account ledger balance by exactly its signed amount // Property 2: a newly-added ledger row changes the ledger balance by exactly its signed amount.
const newTxnChangesBalance = always( const newTxnChangesBalance = always(
now(() => activeAccountId.current !== null).implies( now(() => route.current === "ledger").implies(
next(() => { next(() => {
const prevRows = ledgerRows.previous ?? []; const prev = ledgerRows.previous ?? [];
const curRows = ledgerRows.current; const curr = ledgerRows.current;
if (curRows.length !== prevRows.length + 1) return true; if (curr.length !== prev.length + 1) return true;
const prevIds = new Set(prevRows.map(r => r.id)); const prevKeys = new Set(prev.map(r => r.key));
const added = curRows.find(r => !prevIds.has(r.id)); const added = curr.find(r => !prevKeys.has(r.key));
if (!added) return true; if (!added) return true;
const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0);
return delta === added.signed && delta !== 0; return delta === added.signed && delta !== 0;
@@ -122,15 +128,15 @@ const newTxnChangesBalance = always(
const DEMO_EMAIL = "[email protected]"; const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123"; const DEMO_PASSWORD = "ledger123";
// Login if not already in - step by step based on which field has focus // Login: drive the form via focus state read from the native focused="true" attr.
const login = actions(() => { const login = actions(() => {
if (loggedIn.current) return []; if (loggedIn.current) return [];
const focus = focusedInput.current; const focus = focusedFieldTag.current;
if (focus === "login_password") { if (focus === "LoginPassword") {
const submit = loginSubmit.current; const submit = loginSubmit.current;
return submit ? [Tap({ on: submit })] : []; return submit ? [Tap({ on: submit })] : [];
} }
if (focus === "login_email") { if (focus === "LoginEmail") {
const pwd = loginPasswordField.current; const pwd = loginPasswordField.current;
return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : []; return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : [];
} }
@@ -140,7 +146,6 @@ const login = actions(() => {
const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]); const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]);
// Add an account: home -> tap add -> type name -> submit
const addAccount = actions(() => { const addAccount = actions(() => {
if (!loggedIn.current) return []; if (!loggedIn.current) return [];
if (route.current === "home") { if (route.current === "home") {
@@ -160,7 +165,6 @@ const addAccount = actions(() => {
const amounts = from(["10", "50", "25", "100", "5"]); const amounts = from(["10", "50", "25", "100", "5"]);
// Add a transaction: home -> tap account card -> tap add txn -> type amount -> submit
const addTxn = actions(() => { const addTxn = actions(() => {
if (!loggedIn.current) return []; if (!loggedIn.current) return [];
if (route.current === "home") { if (route.current === "home") {