mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
fix(folio): stop abusing contentDescription as data carrier (#45)
* feat(hierarchy): full-attribute selector system
- Add Attributes map to Element (raw platform attrs + serialized booleans)
- Add Selector / AttrFilter types for multi-filter AND matching
- Add matchAttr with alias expansion and substring/boolean semantics
- Add matchSelector (AND of all filters)
- id: and desc: keep exact/suffix/prefix semantics for backward compat
- text: widens to substring via matchAttr
- default: case routes unknown kinds to matchAttr (NEW)
- Add Tree.FindNode / Tree.FindAllNodes returning *Node
- Add Node.Find / Node.FindAll for scoped subtree string search
- Add Node.FindBySelector / Node.FindAllBySelector for object AND search
- Add attributeAliases for cross-platform name expansion
* test(hierarchy): full-attribute selector coverage
- raw resource-id: substring match
- label:/content-desc: alias expansion to accessibilityText on iOS
- scrollable:true/false boolean exact match
- title: iOS-only attribute, graceful nil on Android
- text: substring widening
- Selector AND: both filters must match; single miss returns nil
- Node.Find scoped search: descendants only, not siblings
* feat(verifier): object-form selectors + attrs + element-level find
- ax.find/findAll accept string or {attr:value} JS objects
- Object form builds Selector with AND semantics
- Returned element objects expose attrs sub-object (raw platform attrs)
- Returned element objects expose .find() and .findAll() scoped to subtree
- Element-level .find/.findAll accept string or object selectors
* feat(spec): extend AccessibilityElement and AccessibilityTree types
- AccessibilityElement gains attrs, find(), findAll()
- find/findAll on both Tree and Element accept string | AttrSelector
- AttrSelector = Record<string, string> for object-form AND matching
* feat(folio): migrate to chained object-form selectors
- Replace path queries (desc:X > desc:Y) with chained API
- Screen root lookups use { accessibilityText: "ScreenName" }
- Element-scoped searches use find/findAll with string or object
- Keep string selectors for descPrefix: and desc:Back (shows both forms)
* fix(folio): use account_card:id desc, expose balance via text semantics
* fix(folio): embed accountId in LedgerScreen desc, expose values via text semantics
* fix(spec): replace desc-parsing with text-based parseDollarCents extraction
This commit is contained in:
7 files changed
+593
-107
No files matched your search
+7
-2
@@ -123,7 +123,7 @@ private fun AccountCard(
|
||||
.clip(RoundedCornerShape(RadiusLg))
|
||||
.background(t.surface)
|
||||
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||
.semantics(mergeDescendants = true) { contentDescription = "account:$id:$balance" }
|
||||
.semantics { contentDescription = "account_card:$id" }
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -148,6 +148,11 @@ private fun AccountCard(
|
||||
)
|
||||
Text(txnLabel, style = Type.caption, color = t.textMuted)
|
||||
}
|
||||
Text(formatCents(balance), style = Type.bodyStrong, color = t.text)
|
||||
Text(
|
||||
formatCents(balance),
|
||||
style = Type.bodyStrong,
|
||||
color = t.text,
|
||||
modifier = Modifier.semantics { contentDescription = "account_balance" },
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -75,7 +75,7 @@ fun LedgerScreen(accountId: String) {
|
||||
val balance = balanceOf(txns)
|
||||
|
||||
Screen(
|
||||
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen" },
|
||||
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" },
|
||||
header = {
|
||||
Header(
|
||||
title = account.name,
|
||||
@@ -92,11 +92,14 @@ fun LedgerScreen(accountId: String) {
|
||||
)
|
||||
},
|
||||
) {
|
||||
Box(Modifier.size(1.dp).semantics { contentDescription = "active_account:$accountId" })
|
||||
Box(Modifier.size(1.dp).semantics { contentDescription = "ledger_balance:$balance" })
|
||||
Card {
|
||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||
Text(formatCents(balance), style = Type.balance, color = t.text)
|
||||
Text(
|
||||
formatCents(balance),
|
||||
style = Type.balance,
|
||||
color = t.text,
|
||||
modifier = Modifier.semantics { contentDescription = "ledger_balance_display" },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"ACTIVITY",
|
||||
@@ -138,7 +141,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 14.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = "ledger_row:$id:$signed" },
|
||||
.semantics { contentDescription = "ledger_row:$id" },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -166,6 +169,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
||||
formatCents(signed, signed = true),
|
||||
style = Type.bodyStrong,
|
||||
color = t.text,
|
||||
modifier = Modifier.semantics { contentDescription = "txn_amount" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,57 +20,72 @@ interface LedgerRow {
|
||||
signed: number;
|
||||
}
|
||||
|
||||
function parseAccount(desc: string | undefined): Account {
|
||||
const parts = (desc ?? "").split(":");
|
||||
return { id: parts[1] ?? "", balance: Number(parts[2]) };
|
||||
}
|
||||
|
||||
function parseLedgerRow(desc: string | undefined): LedgerRow {
|
||||
const parts = (desc ?? "").split(":");
|
||||
return { id: parts[1] ?? "", signed: Number(parts[2]) };
|
||||
}
|
||||
|
||||
function parseCents(desc: string | null | undefined): number {
|
||||
if (!desc) return 0;
|
||||
const parts = desc.split(":");
|
||||
return Number(parts[1]) || 0;
|
||||
// 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 {
|
||||
if (!text) return 0;
|
||||
const sign = text.startsWith("-") ? -1 : 1;
|
||||
const digits = text.replace(/[^0-9]/g, "");
|
||||
return digits ? sign * parseInt(digits, 10) : 0;
|
||||
}
|
||||
|
||||
// Route and auth state derived from screen root nodes
|
||||
const loggedIn = extract(s => s.ax.find("desc:LoginScreen") == null);
|
||||
const loggedIn = extract(s => s.ax.find({ accessibilityText: "LoginScreen" }) == null);
|
||||
const route = extract<string | null>(s => {
|
||||
if (s.ax.find("desc:LoginScreen")) return "login";
|
||||
if (s.ax.find("desc:HomeScreen")) return "home";
|
||||
if (s.ax.find("desc:AddAccountScreen")) return "add-account";
|
||||
if (s.ax.find("desc:LedgerScreen")) return "ledger";
|
||||
if (s.ax.find("desc:AddTransactionScreen")) return "add-transaction";
|
||||
if (s.ax.find({ accessibilityText: "LoginScreen" })) return "login";
|
||||
if (s.ax.find({ accessibilityText: "HomeScreen" })) return "home";
|
||||
if (s.ax.find({ accessibilityText: "AddAccountScreen" })) return "add-account";
|
||||
if (s.ax.find({ accessibilityText: "LedgerScreen" })) return "ledger";
|
||||
if (s.ax.find({ accessibilityText: "AddTransactionScreen" })) return "add-transaction";
|
||||
return null;
|
||||
});
|
||||
|
||||
// All element lookups scoped through their screen root
|
||||
const accounts = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:")
|
||||
.map(el => parseAccount(el.desc)));
|
||||
const ledgerRows = extract(s => s.ax.findAll("desc:LedgerScreen > descPrefix:ledger_row:")
|
||||
.map(el => parseLedgerRow(el.desc)));
|
||||
const accounts = extract(s =>
|
||||
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:")
|
||||
.map(el => ({
|
||||
id: el.desc?.split(":")[1] ?? "",
|
||||
balance: parseDollarCents(el.find({ accessibilityText: "account_balance" })?.text),
|
||||
})) ?? []);
|
||||
const ledgerRows = extract(s =>
|
||||
s.ax.find({ accessibilityText: "LedgerScreen" })?.findAll("descPrefix:ledger_row:")
|
||||
.map(el => ({
|
||||
id: el.desc?.split(":")[1] ?? "",
|
||||
signed: parseDollarCents(el.find({ accessibilityText: "txn_amount" })?.text),
|
||||
})) ?? []);
|
||||
const ledgerBalance = extract(s =>
|
||||
parseCents(s.ax.find("desc:LedgerScreen > descPrefix:ledger_balance:")?.desc));
|
||||
const activeAccountId = extract(s =>
|
||||
s.ax.find("desc:LedgerScreen > descPrefix:active_account:")?.desc?.split(":")[1] ?? null);
|
||||
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
|
||||
const focusedInput = extract(s =>
|
||||
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
|
||||
|
||||
const loginEmailField = extract(s => s.ax.find("desc:LoginScreen > desc:login_email"));
|
||||
const loginPasswordField = extract(s => s.ax.find("desc:LoginScreen > desc:login_password"));
|
||||
const loginSubmit = extract(s => s.ax.find("desc:LoginScreen > desc:login_submit"));
|
||||
const addAccountButton = extract(s => s.ax.find("desc:HomeScreen > desc:add_account_button"));
|
||||
const accountNameField = extract(s => s.ax.find("desc:AddAccountScreen > desc:account_name_field"));
|
||||
const addAccountSubmit = extract(s => s.ax.find("desc:AddAccountScreen > desc:add_account_submit"));
|
||||
const addTxnButton = extract(s => s.ax.find("desc:LedgerScreen > desc:add_txn_button"));
|
||||
const txnAmountField = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_amount"));
|
||||
const txnSubmit = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_submit"));
|
||||
const accountCards = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:"));
|
||||
const loginEmailField = extract(s =>
|
||||
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_email" }));
|
||||
const loginPasswordField = extract(s =>
|
||||
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_password" }));
|
||||
const loginSubmit = extract(s =>
|
||||
s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_submit" }));
|
||||
const addAccountButton = extract(s =>
|
||||
s.ax.find({ accessibilityText: "HomeScreen" })?.find({ accessibilityText: "add_account_button" }));
|
||||
const accountNameField = extract(s =>
|
||||
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "account_name_field" }));
|
||||
const addAccountSubmit = extract(s =>
|
||||
s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "add_account_submit" }));
|
||||
const addTxnButton = extract(s =>
|
||||
s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "add_txn_button" }));
|
||||
const txnAmountField = extract(s =>
|
||||
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_amount" }));
|
||||
const txnSubmit = extract(s =>
|
||||
s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_submit" }));
|
||||
const accountCards = extract(s =>
|
||||
s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") ?? []);
|
||||
const backButton = extract(s => s.ax.find("desc:Back"));
|
||||
|
||||
// Property 1: every new account starts with balance === 0
|
||||
|
||||
Reference in new issue
Block a user