mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
13 files changed
+717
-119
No files matched your search
+58
@@ -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)
|
||||||
|
}
|
||||||
+16
-6
@@ -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)
|
||||||
|
|||||||
+23
-6
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>`
|
||||||
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
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&b<c"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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user