mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
3288364424
commit
7d3efdcc7a
1 file changed
+68
-64
@@ -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") {
|
||||||
|
|||||||
Reference in new issue
Block a user