feat(sample-app): ledger spec parity (#18)

* feat(sample-app): add FocusTracker

* feat(sample-app): support description on TextInput

* feat(sample-app): support description on AppButton and Segmented

* feat(sample-app): stable ids on login screen

* feat(sample-app): stable ids on add-account screen

* feat(sample-app): stable ids on add-transaction screen

* feat(sample-app): stable ids on home and ledger screens

* feat(sample-app): hoist error + form state into UiState

* feat(sample-app): register auth_status + accounts snapshots

* feat(sample-app): register ledger_rows + ledger_balance snapshots

* feat(sample-app): register error + focused_input snapshots

* refactor(sample-app): scaffold spec.ts extractors + safety

* feat(sample-app): spec accounting invariants

* feat(sample-app): spec state-machine monotonicity

* feat(sample-app): spec liveness properties

* feat(sample-app): spec auth + account action generators

* feat(sample-app): spec transaction action generators

* feat(sample-app): spec weighted workflow

* feat(sample-app): publish login form input snapshots

* feat(sample-app): publish account + txn input snapshots

* feat(sample-app): register form input snapshots

* fix(sample-app): sequence login + idempotent text inputs in spec

* fix(sample-app): clear UiState on page dispose

* fix(sample-app): tighten adversarial login, allow retype on error

* test(verifier): update sample-app spec integration for new selectors

* feat(sidecar): clear focused field before InputText types

* refactor(sample-app): simplify loginHelper to focus-driven sequencing

* refactor(sample-app): drop input-value UiState mirrors

* refactor(sample-app): drop input-value snapshot registrations

* test(verifier): adjust sample-app integration for replace-on-type
This commit is contained in:
pj authored and GitHub committed 2026-04-20 13:28:41 +07:00
1 parent 7493945251
commit 8381a98aaf
13 files changed
+718 -120

No files matched your search

@@ -22,6 +22,64 @@ class SampleApplication : Application() {
is Route.AddTransaction -> "add-transaction" is Route.AddTransaction -> "add-transaction"
} }
} }
Uatu.extract("auth_status") {
if (Repository.session.value != null) "logged-in" else "logged-out"
}
Uatu.extract("accounts") {
val txns = Repository.transactions.value
Repository.accounts.value.map { a ->
val rows = txns.filter { it.accountId == a.id }
mapOf(
"id" to a.id,
"name" to a.name,
"balance" to balanceOf(rows),
"txnCount" to rows.size,
)
}
}
Uatu.extract("active_account_id") {
when (val r = Navigator.current.value) {
is Route.Ledger -> r.accountId
is Route.AddTransaction -> r.accountId
else -> null
}
}
Uatu.extract("ledger_rows") {
val active = when (val r = Navigator.current.value) {
is Route.Ledger -> r.accountId
is Route.AddTransaction -> r.accountId
else -> null
}
if (active == null) emptyList()
else Repository.transactions.value
.filter { it.accountId == active }
.map {
mapOf(
"id" to it.id,
"accountId" to it.accountId,
"type" to if (it.type == TxnType.credit) "credit" else "debit",
"amount" to it.amount,
"signed" to signedAmount(it),
)
}
}
Uatu.extract("ledger_balance") {
val active = when (val r = Navigator.current.value) {
is Route.Ledger -> r.accountId
is Route.AddTransaction -> r.accountId
else -> null
}
if (active == null) 0L
else balanceOf(Repository.transactions.value.filter { it.accountId == active })
}
Uatu.extract("focused_input") { FocusTracker.current.value }
Uatu.extract("txn_form_type") { UiState.txnFormType.value }
Uatu.extract("txn_form_account_id") {
(Navigator.current.value as? Route.AddTransaction)?.accountId
}
Uatu.extract("login_error") { UiState.loginError.value }
Uatu.extract("add_account_error") { UiState.addAccountError.value }
Uatu.extract("txn_error") { UiState.txnError.value }
maybeInjectDebugError() maybeInjectDebugError()
} }
@@ -0,0 +1,18 @@
package dev.uatu.sample
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object FocusTracker {
private val _current = MutableStateFlow<String?>(null)
val current: StateFlow<String?> = _current.asStateFlow()
fun enter(id: String) {
_current.value = id
}
fun leave(id: String) {
if (_current.value == id) _current.value = null
}
}
@@ -0,0 +1,10 @@
package dev.uatu.sample
import kotlinx.coroutines.flow.MutableStateFlow
object UiState {
val loginError = MutableStateFlow("")
val addAccountError = MutableStateFlow("")
val txnError = MutableStateFlow("")
val txnFormType = MutableStateFlow<String?>(null)
}
@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,28 +17,34 @@ import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator import dev.uatu.sample.Navigator
import dev.uatu.sample.Repository import dev.uatu.sample.Repository
import dev.uatu.sample.Route import dev.uatu.sample.Route
import dev.uatu.sample.UiState
@Composable @Composable
fun AddAccountPage() { fun AddAccountPage() {
val t = LocalTokens.current val t = LocalTokens.current
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) } val err by UiState.addAccountError.collectAsState()
BackHandler { Navigator.back(Route.Home) } BackHandler { Navigator.back(Route.Home) }
DisposableEffect(Unit) {
onDispose { UiState.addAccountError.value = "" }
}
fun submit() { fun submit() {
val trimmed = name.trim() val trimmed = name.trim()
if (trimmed.isEmpty()) { if (trimmed.isEmpty()) {
err = "Account name is required"; return UiState.addAccountError.value = "Account name is required"; return
} }
if (trimmed.length > 40) { if (trimmed.length > 40) {
err = "Name is too long (max 40 characters)"; return UiState.addAccountError.value = "Name is too long (max 40 characters)"; return
} }
try { try {
Repository.createAccount(trimmed) Repository.createAccount(trimmed)
UiState.addAccountError.value = ""
Navigator.replace(Route.Home) Navigator.replace(Route.Home)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
err = e.message ?: "Could not create account" UiState.addAccountError.value = e.message ?: "Could not create account"
} }
} }
@@ -50,6 +58,7 @@ fun AddAccountPage() {
onClick = ::submit, onClick = ::submit,
style = ButtonStyle.Primary, style = ButtonStyle.Primary,
enabled = name.trim().isNotEmpty(), enabled = name.trim().isNotEmpty(),
description = "add_account_submit",
) )
}, },
) { ) {
@@ -61,10 +70,11 @@ fun AddAccountPage() {
FieldLabel("Account name") FieldLabel("Account name")
TextInput( TextInput(
value = name, value = name,
onChange = { name = it; err = null }, onChange = { name = it; UiState.addAccountError.value = "" },
placeholder = "e.g. Checking", placeholder = "e.g. Checking",
invalid = err != null, invalid = err.isNotEmpty(),
label = "Account name", label = "Account name",
description = "account_name_field",
) )
} }
ErrorText(err) ErrorText(err)
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -18,6 +19,7 @@ import dev.uatu.sample.Navigator
import dev.uatu.sample.Repository import dev.uatu.sample.Repository
import dev.uatu.sample.Route import dev.uatu.sample.Route
import dev.uatu.sample.TxnType import dev.uatu.sample.TxnType
import dev.uatu.sample.UiState
import dev.uatu.sample.parseCents import dev.uatu.sample.parseCents
private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
@@ -48,21 +50,31 @@ fun AddTransactionPage(accountId: String) {
var type by remember { mutableStateOf(TxnType.credit) } var type by remember { mutableStateOf(TxnType.credit) }
var amount by remember { mutableStateOf("") } var amount by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") } var note by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) } val err by UiState.txnError.collectAsState()
DisposableEffect(type) {
UiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit"
onDispose { UiState.txnFormType.value = null }
}
DisposableEffect(Unit) {
onDispose { UiState.txnError.value = "" }
}
fun submit() { fun submit() {
val cents = parseCents(amount) val cents = parseCents(amount)
if (cents == null) { if (cents == null) {
err = "Enter a valid amount (e.g. 12.34)"; return UiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return
} }
if (cents <= 0) { if (cents <= 0) {
err = "Amount must be greater than zero"; return UiState.txnError.value = "Amount must be greater than zero"; return
} }
try { try {
Repository.createTransaction(accountId, type, cents, note) Repository.createTransaction(accountId, type, cents, note)
UiState.txnError.value = ""
Navigator.back(Route.Home) Navigator.back(Route.Home)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
err = e.message ?: "Could not save transaction" UiState.txnError.value = e.message ?: "Could not save transaction"
} }
} }
@@ -80,6 +92,7 @@ fun AddTransactionPage(accountId: String) {
onClick = ::submit, onClick = ::submit,
style = ButtonStyle.Primary, style = ButtonStyle.Primary,
enabled = amount.isNotBlank(), enabled = amount.isNotBlank(),
description = "txn_submit",
) )
}, },
) { ) {
@@ -91,6 +104,7 @@ fun AddTransactionPage(accountId: String) {
selected = if (type == TxnType.credit) 0 else 1, selected = if (type == TxnType.credit) 0 else 1,
labels = listOf("Credit", "Debit"), labels = listOf("Credit", "Debit"),
onSelect = { type = if (it == 0) TxnType.credit else TxnType.debit }, onSelect = { type = if (it == 0) TxnType.credit else TxnType.debit },
descriptions = listOf("txn_credit", "txn_debit"),
) )
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Amount") FieldLabel("Amount")
@@ -98,15 +112,17 @@ fun AddTransactionPage(accountId: String) {
value = amount, value = amount,
onChange = { onChange = {
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
amount = it; err = null amount = it
UiState.txnError.value = ""
} }
}, },
placeholder = "0.00", placeholder = "0.00",
invalid = err != null, invalid = err.isNotEmpty(),
keyboardType = KeyboardType.Decimal, keyboardType = KeyboardType.Decimal,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
textStyle = Type.amountInput, textStyle = Type.amountInput,
label = "Amount", label = "Amount",
description = "txn_amount",
) )
} }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -116,6 +132,7 @@ fun AddTransactionPage(accountId: String) {
onChange = { note = it.take(80) }, onChange = { note = it.take(80) },
placeholder = "What's this for?", placeholder = "What's this for?",
label = "Note", label = "Note",
description = "txn_note",
) )
} }
ErrorText(err) ErrorText(err)
@@ -44,7 +44,7 @@ fun HomePage(user: String, onLogout: () -> Unit) {
title = "Accounts", title = "Accounts",
subtitle = user, subtitle = user,
right = { right = {
IconButton(onClick = onLogout, description = "Sign out", icon = Icons.Logout) IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout)
}, },
) )
}, },
@@ -65,6 +65,7 @@ fun HomePage(user: String, onLogout: () -> Unit) {
text = "+ Add account", text = "+ Add account",
onClick = { Navigator.push(Route.AddAccount) }, onClick = { Navigator.push(Route.AddAccount) },
style = ButtonStyle.Primary, style = ButtonStyle.Primary,
description = "add_account_button",
) )
}, },
) { ) {
@@ -80,6 +81,7 @@ fun HomePage(user: String, onLogout: () -> Unit) {
val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) } val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
val count = txns.count { it.accountId == a.id } val count = txns.count { it.accountId == a.id }
AccountCard( AccountCard(
id = a.id,
name = a.name, name = a.name,
initials = initialsOf(a.name), initials = initialsOf(a.name),
count = count, count = count,
@@ -94,6 +96,7 @@ fun HomePage(user: String, onLogout: () -> Unit) {
@Composable @Composable
private fun AccountCard( private fun AccountCard(
id: String,
name: String, name: String,
initials: String, initials: String,
count: Int, count: Int,
@@ -102,14 +105,13 @@ private fun AccountCard(
) { ) {
val t = LocalTokens.current val t = LocalTokens.current
val txnLabel = if (count == 1) "1 transaction" else "$count transactions" val txnLabel = if (count == 1) "1 transaction" else "$count transactions"
val a11y = "$name account, balance ${formatCents(balance)}, $txnLabel"
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(RadiusLg)) .clip(RoundedCornerShape(RadiusLg))
.background(t.surface) .background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg)) .border(1.dp, t.border, RoundedCornerShape(RadiusLg))
.semantics(mergeDescendants = true) { contentDescription = a11y } .semantics(mergeDescendants = true) { contentDescription = "account_card:$id" }
.clickable(role = Role.Button, onClick = onClick) .clickable(role = Role.Button, onClick = onClick)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -74,6 +74,7 @@ fun LedgerPage(accountId: String) {
text = "+ Add transaction", text = "+ Add transaction",
onClick = { Navigator.push(Route.AddTransaction(accountId)) }, onClick = { Navigator.push(Route.AddTransaction(accountId)) },
style = ButtonStyle.Primary, style = ButtonStyle.Primary,
description = "add_txn_button",
) )
}, },
) { ) {
@@ -103,7 +104,7 @@ fun LedgerPage(accountId: String) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
txns.forEachIndexed { i, txn -> txns.forEachIndexed { i, txn ->
TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
if (i != txns.lastIndex) { if (i != txns.lastIndex) {
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border)) Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
} }
@@ -114,16 +115,14 @@ fun LedgerPage(accountId: String) {
} }
@Composable @Composable
private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) { private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: String) {
val t = LocalTokens.current val t = LocalTokens.current
val signed = if (type == TxnType.credit) amount else -amount val signed = if (type == TxnType.credit) amount else -amount
val label = "${if (type == TxnType.credit) "Credit" else "Debit"} ${formatCents(signed, signed = true)}" +
(if (note.isNotEmpty()) ", $note" else "") + ", $date"
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 14.dp) .padding(vertical = 14.dp)
.semantics(mergeDescendants = true) { contentDescription = label }, .semantics(mergeDescendants = true) { contentDescription = "txn_row:$id" },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -18,6 +20,7 @@ import androidx.compose.ui.unit.dp
import dev.uatu.sample.DEMO_EMAIL import dev.uatu.sample.DEMO_EMAIL
import dev.uatu.sample.DEMO_PASSWORD import dev.uatu.sample.DEMO_PASSWORD
import dev.uatu.sample.Repository import dev.uatu.sample.Repository
import dev.uatu.sample.UiState
import dev.uatu.sample.checkCredentials import dev.uatu.sample.checkCredentials
@Composable @Composable
@@ -25,15 +28,20 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
val t = LocalTokens.current val t = LocalTokens.current
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) } val err by UiState.loginError.collectAsState()
DisposableEffect(Unit) {
onDispose { UiState.loginError.value = "" }
}
fun submit() { fun submit() {
if (email.isBlank() || password.isEmpty()) { if (email.isBlank() || password.isEmpty()) {
err = "Enter email and password"; return UiState.loginError.value = "Enter email and password"; return
} }
if (!checkCredentials(email, password)) { if (!checkCredentials(email, password)) {
err = "Invalid email or password"; return UiState.loginError.value = "Invalid email or password"; return
} }
UiState.loginError.value = ""
val user = email.trim().lowercase() val user = email.trim().lowercase()
Repository.setSession(user) Repository.setSession(user)
onLoggedIn(user) onLoggedIn(user)
@@ -49,23 +57,25 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
FieldLabel("Email") FieldLabel("Email")
TextInput( TextInput(
value = email, value = email,
onChange = { email = it; err = null }, onChange = { email = it; UiState.loginError.value = "" },
placeholder = DEMO_EMAIL, placeholder = DEMO_EMAIL,
invalid = err != null, invalid = err.isNotEmpty(),
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
label = "Email", label = "Email",
description = "login_email",
) )
} }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Password") FieldLabel("Password")
TextInput( TextInput(
value = password, value = password,
onChange = { password = it; err = null }, onChange = { password = it; UiState.loginError.value = "" },
placeholder = "••••••••", placeholder = "••••••••",
password = true, password = true,
invalid = err != null, invalid = err.isNotEmpty(),
keyboardType = KeyboardType.Password, keyboardType = KeyboardType.Password,
label = "Password", label = "Password",
description = "login_password",
) )
} }
ErrorText(err) ErrorText(err)
@@ -73,6 +83,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
text = "Sign in", text = "Sign in",
onClick = ::submit, onClick = ::submit,
style = ButtonStyle.Primary, style = ButtonStyle.Primary,
description = "login_submit",
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Card(dashed = true) { Card(dashed = true) {
@@ -36,6 +36,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.uatu.sample.FocusTracker
enum class ButtonStyle { Primary, Secondary, Ghost } enum class ButtonStyle { Primary, Secondary, Ghost }
@@ -46,6 +47,7 @@ fun AppButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: ButtonStyle = ButtonStyle.Secondary, style: ButtonStyle = ButtonStyle.Secondary,
enabled: Boolean = true, enabled: Boolean = true,
description: String? = null,
) { ) {
val t = LocalTokens.current val t = LocalTokens.current
val (bg, fg, border) = when (style) { val (bg, fg, border) = when (style) {
@@ -59,6 +61,10 @@ fun AppButton(
.clip(RoundedCornerShape(RadiusMd)) .clip(RoundedCornerShape(RadiusMd))
.background(if (enabled) bg else t.surface3) .background(if (enabled) bg else t.surface3)
.border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd)) .border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd))
.then(
if (description != null) Modifier.semantics { contentDescription = description }
else Modifier
)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick) .clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(vertical = 14.dp, horizontal = 16.dp), .padding(vertical = 14.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -84,6 +90,7 @@ fun TextInput(
textAlign: TextAlign = TextAlign.Start, textAlign: TextAlign = TextAlign.Start,
textStyle: TextStyle = Type.body, textStyle: TextStyle = Type.body,
label: String? = null, label: String? = null,
description: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val t = LocalTokens.current val t = LocalTokens.current
@@ -116,9 +123,16 @@ fun TextInput(
cursorBrush = SolidColor(t.text), cursorBrush = SolidColor(t.text),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { focused = it.isFocused } .onFocusChanged {
focused = it.isFocused
if (description != null) {
if (it.isFocused) FocusTracker.enter(description)
else FocusTracker.leave(description)
}
}
.semantics { .semantics {
if (label != null) contentDescription = label val desc = description ?: label
if (desc != null) contentDescription = desc
if (invalid) stateDescription = "Invalid" if (invalid) stateDescription = "Invalid"
}, },
) )
@@ -155,7 +169,12 @@ fun ErrorText(err: String?) {
} }
@Composable @Composable
fun Segmented(selected: Int, labels: List<String>, onSelect: (Int) -> Unit) { fun Segmented(
selected: Int,
labels: List<String>,
onSelect: (Int) -> Unit,
descriptions: List<String>? = null,
) {
val t = LocalTokens.current val t = LocalTokens.current
Row( Row(
modifier = Modifier modifier = Modifier
@@ -168,12 +187,16 @@ fun Segmented(selected: Int, labels: List<String>, onSelect: (Int) -> Unit) {
) { ) {
labels.forEachIndexed { i, label -> labels.forEachIndexed { i, label ->
val active = i == selected val active = i == selected
val desc = descriptions?.getOrNull(i)
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(RadiusSm)) .clip(RoundedCornerShape(RadiusSm))
.background(if (active) t.surface3 else t.surface) .background(if (active) t.surface3 else t.surface)
.semantics { this.selected = active } .semantics {
this.selected = active
if (desc != null) contentDescription = desc
}
.clickable(role = Role.Tab) { onSelect(i) } .clickable(role = Role.Tab) { onSelect(i) }
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
+394 -59
View File
@@ -14,89 +14,424 @@ import {
waitOnce, waitOnce,
weighted, weighted,
} from "@uatu/spec"; } from "@uatu/spec";
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties"; import { noLogcatErrors, noUncaughtExceptions } from "@uatu/spec/defaults/properties";
interface AccountSnapshot {
id: string;
name: string;
balance: number;
txnCount: number;
}
interface LedgerRow {
id: string;
accountId: string;
type: "credit" | "debit";
amount: number;
signed: number;
}
// ── Snapshot extractors (fed by SampleApplication.kt) ──────────
const loggedIn = extract<boolean>( const loggedIn = extract<boolean>(
(state) => (state.snapshots.logged_in as boolean) ?? false, (state) => (state.snapshots.logged_in as boolean) ?? false,
); );
const authStatus = extract<string>(
(state) => (state.snapshots.auth_status as string) ?? "",
);
const route = extract<string>( const route = extract<string>(
(state) => (state.snapshots.route as string) ?? "", (state) => (state.snapshots.route as string) ?? "",
); );
const accounts = extract<AccountSnapshot[]>(
(state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [],
);
const totalBalance = extract<number>(
(state) => (state.snapshots.total_balance as number) ?? 0,
);
const accountCount = extract<number>( const accountCount = extract<number>(
(state) => (state.snapshots.account_count as number) ?? 0, (state) => (state.snapshots.account_count as number) ?? 0,
); );
const activeAccountId = extract<string | null>(
(state) => (state.snapshots.active_account_id as string | null) ?? null,
);
const ledgerRows = extract<LedgerRow[]>(
(state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [],
);
const ledgerBalance = extract<number>(
(state) => (state.snapshots.ledger_balance as number) ?? 0,
);
const focusedInput = extract<string | null>(
(state) => (state.snapshots.focused_input as string | null) ?? null,
);
const txnFormType = extract<string | null>(
(state) => (state.snapshots.txn_form_type as string | null) ?? null,
);
const txnFormAccountId = extract<string | null>(
(state) => (state.snapshots.txn_form_account_id as string | null) ?? null,
);
const loginError = extract<string>(
(state) => (state.snapshots.login_error as string) ?? "",
);
const addAccountError = extract<string>(
(state) => (state.snapshots.add_account_error as string) ?? "",
);
const txnError = extract<string>(
(state) => (state.snapshots.txn_error as string) ?? "",
);
const loginEmailField = extract((state) => state.ax.find("desc:login_email"));
const loginPasswordField = extract((state) => state.ax.find("desc:login_password"));
const loginSubmitButton = extract((state) => state.ax.find("desc:login_submit"));
const addAccountButton = extract((state) => state.ax.find("desc:add_account_button"));
const logoutButton = extract((state) => state.ax.find("desc:logout_button"));
const accountNameField = extract((state) => state.ax.find("desc:account_name_field"));
const addAccountSubmit = extract((state) => state.ax.find("desc:add_account_submit"));
const addTxnButton = extract((state) => state.ax.find("desc:add_txn_button"));
const txnAmountField = extract((state) => state.ax.find("desc:txn_amount"));
const txnNoteField = extract((state) => state.ax.find("desc:txn_note"));
const txnCredit = extract((state) => state.ax.find("desc:txn_credit"));
const txnDebit = extract((state) => state.ax.find("desc:txn_debit"));
const txnSubmit = extract((state) => state.ax.find("desc:txn_submit"));
const backButton = extract((state) => state.ax.find("desc:Back"));
const anyAccountCard = extract((state) => state.ax.find("descPrefix:account_card:"));
// ── UI elements ────────────────────────────────────────────────
const phoneField = extract((state) => state.ax.find("desc:phone_field"));
const continueButton = extract((state) => state.ax.find("text:Continue"));
const addAccountButton = extract((state) => state.ax.find("text:Add account"));
const nameField = extract((state) => state.ax.find("desc:account_name"));
const createButton = extract((state) => state.ax.find("text:Create"));
// ── Properties ─────────────────────────────────────────────────
// accountCountNonNegative: the trivial safety property.
const accountCountNonNegative = always(() => accountCount.current >= 0); const accountCountNonNegative = always(() => accountCount.current >= 0);
// addAccountAdvances: once we land on add-account, the next step must be on const onHome = () => route.current === "home";
// a different screen. Exercises now(x).implies(next(y)). const onLedger = () =>
const addAccountAdvances = always( route.current === "ledger" || route.current === "add-transaction";
now(() => route.current === "add-account").implies( const isInteger = (n: number) => Number.isFinite(n) && Math.floor(n) === n;
next(() => route.current !== "add-account"),
const totalBalanceMatchesAccounts = always(
now(onHome).implies(
now(() => {
const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0);
return sum === totalBalance.current;
}),
), ),
); );
// eventuallyLoggedIn: within 30 seconds of the run starting, we expect to const ledgerBalanceMatchesRows = always(
// reach home. Exercises eventually(p).within(n, unit). now(onLedger).implies(
const eventuallyLoggedIn = eventually(() => loggedIn.current).within( now(() => {
30, const sum = ledgerRows.current.reduce((acc, r) => acc + r.signed, 0);
"seconds", return sum === ledgerBalance.current;
}),
),
); );
const ledgerRowsWellFormed = always(() => {
for (const row of ledgerRows.current) {
if (row.type !== "credit" && row.type !== "debit") return false;
if (!(row.amount > 0)) return false;
const expected = row.type === "credit" ? row.amount : -row.amount;
if (row.signed !== expected) return false;
}
return true;
});
const balancesAreIntegerCents = always(() => {
if (!isInteger(totalBalance.current)) return false;
if (!isInteger(ledgerBalance.current)) return false;
for (const a of accounts.current) if (!isInteger(a.balance)) return false;
for (const r of ledgerRows.current) {
if (!isInteger(r.amount) || !isInteger(r.signed)) return false;
}
return true;
});
const accountCountMatchesList = always(
() => accountCount.current === accounts.current.length,
);
const ledgerCountMatchesRows = always(
now(onLedger).implies(
now(() => {
const active = activeAccountId.current;
if (active === null) return true;
const fromAccounts = accounts.current.find((a) => a.id === active);
if (!fromAccounts) return true;
return fromAccounts.txnCount === ledgerRows.current.length;
}),
),
);
const zeroTxnsMeansZeroBalance = always(() => {
for (const a of accounts.current) {
if (a.txnCount === 0 && a.balance !== 0) return false;
}
return true;
});
const noOrphanTransactions = always(() => {
const active = activeAccountId.current;
if (active === null) return ledgerRows.current.length === 0;
return ledgerRows.current.every((r) => r.accountId === active);
});
const uniqueAccountNames = always(() => {
const seen = new Set<string>();
for (const a of accounts.current) {
const key = a.name.trim().toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
}
return true;
});
const accountingInvariants = {
totalBalanceMatchesAccounts,
ledgerBalanceMatchesRows,
ledgerRowsWellFormed,
balancesAreIntegerCents,
accountCountMatchesList,
ledgerCountMatchesRows,
zeroTxnsMeansZeroBalance,
noOrphanTransactions,
uniqueAccountNames,
};
const accountsOnlyGrow = always(
now(() => true).implies(
next(() => accounts.current.length >= (accounts.previous?.length ?? 0)),
),
);
const ledgerOnlyGrowsPerAccount = always(
now(() => activeAccountId.current !== null).implies(
next(() => {
if (activeAccountId.current !== activeAccountId.previous) return true;
return ledgerRows.current.length >= (ledgerRows.previous?.length ?? 0);
}),
),
);
const authStatusIsKnown = always(
() => authStatus.current === "logged-in" || authStatus.current === "logged-out",
);
const routeIsKnown = always(() => {
const r = route.current;
return (
r === "login" ||
r === "home" ||
r === "add-account" ||
r === "ledger" ||
r === "add-transaction"
);
});
const loggedInLeavesLogin = always(
now(() => loggedIn.current).implies(
eventually(() => route.current !== "login").within(3, "seconds"),
),
);
const loggedOutReachesLogin = always(
now(() => !loggedIn.current).implies(
eventually(() => route.current === "login").within(3, "seconds"),
),
);
const stateMachine = {
accountsOnlyGrow,
ledgerOnlyGrowsPerAccount,
authStatusIsKnown,
routeIsKnown,
loggedInLeavesLogin,
loggedOutReachesLogin,
};
const loginReachable = eventually(() => loggedIn.current).within(90, "seconds");
const accountCreationReachable = eventually(
() => accounts.current.length > 0,
).within(180, "seconds");
const someTransactionExists = eventually(() =>
accounts.current.some((a) => a.txnCount > 0),
).within(300, "seconds");
const loginErrorClears = always(
now(() => loginError.current !== "").implies(
eventually(() => loginError.current === "").within(30, "seconds"),
),
);
const addAccountErrorClears = always(
now(() => addAccountError.current !== "").implies(
eventually(() => addAccountError.current === "").within(30, "seconds"),
),
);
const txnErrorClears = always(
now(() => txnError.current !== "").implies(
eventually(() => txnError.current === "").within(30, "seconds"),
),
);
const liveness = {
loginReachable,
accountCreationReachable,
someTransactionExists,
loginErrorClears,
addAccountErrorClears,
txnErrorClears,
};
const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123";
const loginHelper = actions(() => {
if (loggedIn.current) return [];
const focus = focusedInput.current;
const email = loginEmailField.current;
const password = loginPasswordField.current;
const submit = loginSubmitButton.current;
if (focus === "login_password") {
return submit ? [Tap({ on: submit })] : [];
}
if (focus === "login_email") {
return password ? [InputText({ into: password, text: DEMO_PASSWORD })] : [];
}
return email ? [InputText({ into: email, text: DEMO_EMAIL })] : [];
});
const adversarialLogin = actions(() => {
if (loggedIn.current) return [];
if (focusedInput.current !== null) return [];
const submit = loginSubmitButton.current;
if (!submit) return [];
return [Tap({ on: submit })];
});
const accountNameSampler = from([
"Checking",
"Savings",
"Travel",
"Rent",
"Emergency Fund",
"Investments",
"Groceries",
" ",
"Checking",
"A".repeat(41),
"Petty Cash",
]);
const typeAccountName = actions(() => {
if (route.current !== "add-account") return [];
const field = accountNameField.current;
if (!field) return [];
return [InputText({ into: field, text: accountNameSampler.generate() })];
});
const submitAddAccount = actions(() => {
if (route.current !== "add-account") return [];
const submit = addAccountSubmit.current;
return submit ? [Tap({ on: submit })] : [];
});
const openAddAccount = actions(() => {
if (route.current !== "home") return [];
const button = addAccountButton.current;
return button ? [Tap({ on: button })] : [];
});
const openRandomAccount = actions(() => {
if (route.current !== "home") return [];
const card = anyAccountCard.current;
return card ? [Tap({ on: card })] : [];
});
const logoutAction = actions(() => {
if (route.current !== "home") return [];
const button = logoutButton.current;
return button ? [Tap({ on: button })] : [];
});
const goBack = actions(() => {
const button = backButton.current;
return button ? [Tap({ on: button })] : [];
});
const amountSampler = from([
"12.34",
"100",
"0.01",
"999.99",
"5.5",
"42",
"0",
"",
"1e4",
"0.001",
"-5",
]);
const typeAmount = actions(() => {
if (route.current !== "add-transaction") return [];
const field = txnAmountField.current;
if (!field) return [];
return [InputText({ into: field, text: amountSampler.generate() })];
});
const noteSampler = from([
"Coffee",
"Paycheck",
"Gas",
"Refund",
"",
"Groceries for the week",
]);
const typeNote = actions(() => {
if (route.current !== "add-transaction") return [];
const field = txnNoteField.current;
if (!field) return [];
return [InputText({ into: field, text: noteSampler.generate() })];
});
const toggleTxnType = actions(() => {
if (route.current !== "add-transaction") return [];
const current = txnFormType.current;
const target = current === "credit" ? txnDebit.current : txnCredit.current;
return target ? [Tap({ on: target })] : [];
});
const submitTxn = actions(() => {
if (route.current !== "add-transaction") return [];
const submit = txnSubmit.current;
return submit ? [Tap({ on: submit })] : [];
});
const openAddTxn = actions(() => {
if (route.current !== "ledger") return [];
const button = addTxnButton.current;
return button ? [Tap({ on: button })] : [];
});
export const properties = { export const properties = {
accountCountNonNegative, accountCountNonNegative,
addAccountAdvances, ...accountingInvariants,
eventuallyLoggedIn, ...stateMachine,
...liveness,
noUncaughtExceptions, noUncaughtExceptions,
noLogcatErrors,
}; };
// ── Actions ────────────────────────────────────────────────────
// Sampling: random phone numbers for the login screen.
const phoneSampler = from(["+919876543210", "+15555550100", "+442071234567"]);
const typePhone = actions(() => {
const field = phoneField.current;
if (!field) return [];
return [InputText({ into: field, text: phoneSampler.generate() })];
});
const tapContinue = actions(() =>
continueButton.current ? [Tap({ on: continueButton.current })] : [],
);
const tapAddAccount = actions(() =>
addAccountButton.current ? [Tap({ on: addAccountButton.current })] : [],
);
const nameSampler = from(["Alice", "Bob", "Charlie", "Dana"]);
const fillName = actions(() => {
const field = nameField.current;
if (!field) return [];
return [InputText({ into: field, text: nameSampler.generate() })];
});
const tapCreate = actions(() =>
createButton.current ? [Tap({ on: createButton.current })] : [],
);
export const actionsRoot = weighted( export const actionsRoot = weighted(
[30, typePhone], [30, loginHelper],
[30, tapContinue], [2, adversarialLogin],
[20, tapAddAccount], [18, typeAccountName],
[20, fillName], [14, submitAddAccount],
[20, tapCreate], [18, typeAmount],
[10, taps], [8, typeNote],
[5, swipes], [6, toggleTxnType],
[5, waitOnce], [16, submitTxn],
[5, pressKey], [14, openAddAccount],
[14, openRandomAccount],
[12, openAddTxn],
[6, goBack],
[1, logoutAction],
[4, taps],
[2, swipes],
[2, waitOnce],
[2, pressKey],
); );
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; (globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
+47 -30
View File
@@ -15,8 +15,9 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]"> <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.LinearLayout" bounds="[64,96][1016,2336]">
<node index="0" class="android.widget.TextView" text="Sign in" bounds="[100,200][900,300]" /> <node index="0" class="android.widget.TextView" text="Sign in" bounds="[100,200][900,300]" />
<node index="1" class="android.widget.EditText" content-desc="phone_field" clickable="true" enabled="true" bounds="[100,400][900,520]" /> <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.widget.Button" text="Continue" clickable="true" enabled="true" bounds="[400,800][680,920]" /> <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>
</node> </node>
</hierarchy>` </hierarchy>`
@@ -40,7 +41,7 @@ func bundleSampleAppSpec(t *testing.T) string {
bundle, err := bundler.Bundle(bundler.Options{ bundle, err := bundler.Bundle(bundler.Options{
EntryFile: specPath, EntryFile: specPath,
Aliases: map[string]string{ Aliases: map[string]string{
"@uatu/spec": apiPath, "@uatu/spec": apiPath,
"@uatu/spec/defaults/properties": defaultsPath, "@uatu/spec/defaults/properties": defaultsPath,
}, },
}) })
@@ -50,9 +51,29 @@ func bundleSampleAppSpec(t *testing.T) string {
return string(bundle.JavaScript) return string(bundle.JavaScript)
} }
func loginSnapshots() 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(`""`),
}
}
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec // TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// emits Tap/InputText actions targeting the login screen elements when they // emits Tap actions targeting the login screen elements when they are present
// are present in the hierarchy. // in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) { func TestSampleAppSpecFiresLoginActions(t *testing.T) {
v := newVerifier(t) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleSampleAppSpec(t)); err != nil {
@@ -63,34 +84,29 @@ func TestSampleAppSpecFiresLoginActions(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
snapshots := Snapshots{ if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"account_count": json.RawMessage(`0`),
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
tapContinueHits := 0 typeEmailHits := 0
typePhoneHits := 0 tapSubmitHits := 0
for range 400 { for range 400 {
action, err := v.NextAction() action, err := v.NextAction()
if err != nil { if err != nil {
continue continue
} }
switch { switch {
case action.Kind == ActionKindTap && action.On == "text:Continue": case action.Kind == ActionKindInputText && action.On == "desc:login_email":
tapContinueHits++ typeEmailHits++
case action.Kind == ActionKindInputText && action.On == "desc:phone_field": case action.Kind == ActionKindTap && action.On == "desc:login_submit":
typePhoneHits++ tapSubmitHits++
} }
} }
if tapContinueHits == 0 { if typeEmailHits == 0 {
t.Fatal("tapContinue never fired on sample-app hierarchy") t.Fatal("loginHelper never typed into desc:login_email on sample-app hierarchy")
} }
if typePhoneHits == 0 { if tapSubmitHits == 0 {
t.Fatal("typePhone never fired on sample-app hierarchy") t.Fatal("adversarialLogin never tapped desc:login_submit on sample-app hierarchy")
} }
} }
@@ -108,12 +124,7 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
snapshots := Snapshots{ if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"account_count": json.RawMessage(`0`),
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
verdicts := v.EvaluateProperties() verdicts := v.EvaluateProperties()
@@ -123,8 +134,14 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds { if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"]) t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
} }
// Liveness: eventuallyLoggedIn hasn't resolved yet. if verdicts["authStatusIsKnown"] != ltl.VerdictHolds {
if verdicts["eventuallyLoggedIn"] != ltl.VerdictPending { t.Errorf("authStatusIsKnown: got %v, want holds", verdicts["authStatusIsKnown"])
t.Errorf("eventuallyLoggedIn: got %v, want pending", verdicts["eventuallyLoggedIn"]) }
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"])
} }
} }
@@ -89,6 +89,31 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null return null
} }
// Hard cap on KEYCODE_DEL events per clear. Guards against a pathological
// hierarchy that reports an enormous text length for the focused field.
internal const val MAX_CLEAR_DELETES: Int = 1024
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
// present. Captures only the tag's attribute string so we can pull
// `text="..."` out of it without building a full XML tree.
private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
)
private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"")
internal fun parseFocusedText(xml: String): String? {
val node = FOCUSED_NODE.find(xml) ?: return null
val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return ""
return decodeXmlAttribute(match.groupValues[1])
}
private fun decodeXmlAttribute(value: String): String = value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
internal val KEY_MAP: Map<String, String> = mapOf( internal val KEY_MAP: Map<String, String> = mapOf(
"back" to "KEYCODE_BACK", "back" to "KEYCODE_BACK",
"home" to "KEYCODE_HOME", "home" to "KEYCODE_HOME",
@@ -158,9 +183,34 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun inputText(text: String) { override fun inputText(text: String) {
lastInputText = text lastInputText = text
// `adb shell input text` types keystrokes at the caret, so repeated
// calls append. Clear the focused field first so the caller sees a
// pure replace: read the current value's length from the hierarchy,
// then move-end + N backspaces before typing.
clearFocusedField()
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s"))) runAdb(listOf("shell", "input", "text", text.replace(" ", "%s")))
} }
private fun clearFocusedField() {
val current = focusedFieldText() ?: return
if (current.isEmpty()) return
val deletes = minOf(current.length, MAX_CLEAR_DELETES)
val keyevents = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
repeat(deletes) { keyevents.add("KEYCODE_DEL") }
runAdb(keyevents)
}
private fun focusedFieldText(): String? {
val xml = try {
hierarchy()
} catch (cause: Exception) {
println("inputText: hierarchy dump failed: $cause")
return null
}
if (xml.isBlank() || xml == "<hierarchy/>") return null
return parseFocusedText(xml)
}
@Volatile var lastSwipe: SwipeRecord? = null @Volatile var lastSwipe: SwipeRecord? = null
private set private set
@Volatile var lastKey: String? = null @Volatile var lastKey: String? = null
@@ -0,0 +1,48 @@
package dev.uatu.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class InputTextClearTest {
@Test fun parsesTextFromFocusedNode() {
val xml = """
<hierarchy>
<node text="ignored" focused="false"/>
<node resource-id="app:id/email" text="old@value" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("old@value", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() {
val xml = """<hierarchy><node focused="true"/></hierarchy>"""
assertEquals("", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsNullWhenNoFocusedNode() {
val xml = """<hierarchy><node text="x" focused="false"/></hierarchy>"""
assertNull(StubDriverBackend.parseFocusedText(xml))
}
@Test fun decodesXmlEntitiesInAttribute() {
val xml = """<hierarchy><node text="a&amp;b&lt;c&quot;d" focused="true"/></hierarchy>"""
assertEquals("a&b<c\"d", StubDriverBackend.parseFocusedText(xml))
}
@Test fun picksFirstFocusedNodeWhenMultiplePresent() {
val xml = """
<hierarchy>
<node text="first" focused="true"/>
<node text="second" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("first", StubDriverBackend.parseFocusedText(xml))
}
}