mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
- WebLedgerStore.accountExistsByName case-insensitive (matches SQL COLLATE NOCASE) - drop SampleApplication.maybeInjectDebugError; sample spec now passes clean - drop noLogcatErrors from sample spec (default matches system-wide E logs) - openRandomAccount picks uniformly from findAll instead of first match - clear txnError on any add-transaction interaction, not only valid amount input - escapeForAdbInputText quotes shell metacharacters (quotes, backslash, etc.) - extract buildClearKeyevents helper with empty/normal/cap tests - broaden spec_integration_test.go to cover home, add-account, ledger, add-transaction action generators - document FocusTracker single-focus invariant
This commit is contained in:
8 files changed
+284
-26
No files matched your search
-12
@@ -1,7 +1,6 @@
|
|||||||
package dev.uatu.sample
|
package dev.uatu.sample
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import dev.uatu.sdk.Uatu
|
import dev.uatu.sdk.Uatu
|
||||||
|
|
||||||
class SampleApplication : Application() {
|
class SampleApplication : Application() {
|
||||||
@@ -80,16 +79,5 @@ class SampleApplication : Application() {
|
|||||||
Uatu.extract("login_error") { UiState.loginError.value }
|
Uatu.extract("login_error") { UiState.loginError.value }
|
||||||
Uatu.extract("add_account_error") { UiState.addAccountError.value }
|
Uatu.extract("add_account_error") { UiState.addAccountError.value }
|
||||||
Uatu.extract("txn_error") { UiState.txnError.value }
|
Uatu.extract("txn_error") { UiState.txnError.value }
|
||||||
maybeInjectDebugError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fires a synthetic Uatu.reportError in debug builds so the sample-app
|
|
||||||
// e2e run can verify noUncaughtExceptions surfaces SDK-captured errors
|
|
||||||
// in the trace. Production builds skip this.
|
|
||||||
private fun maybeInjectDebugError() {
|
|
||||||
val isDebuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
|
||||||
if (isDebuggable) {
|
|
||||||
Uatu.reportError(RuntimeException("synthetic"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
// Assumes single-focus: only one input can be focused at a time, so
|
||||||
|
// enter() overwriting is safe. leave() is id-gated so a stale dispose
|
||||||
|
// from a previously-focused field cannot clobber the active focus.
|
||||||
object FocusTracker {
|
object FocusTracker {
|
||||||
private val _current = MutableStateFlow<String?>(null)
|
private val _current = MutableStateFlow<String?>(null)
|
||||||
val current: StateFlow<String?> = _current.asStateFlow()
|
val current: StateFlow<String?> = _current.asStateFlow()
|
||||||
|
|||||||
+9
-3
@@ -103,7 +103,10 @@ fun AddTransactionPage(accountId: String) {
|
|||||||
Segmented(
|
Segmented(
|
||||||
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 = {
|
||||||
|
UiState.txnError.value = ""
|
||||||
|
type = if (it == 0) TxnType.credit else TxnType.debit
|
||||||
|
},
|
||||||
descriptions = listOf("txn_credit", "txn_debit"),
|
descriptions = listOf("txn_credit", "txn_debit"),
|
||||||
)
|
)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -111,9 +114,9 @@ fun AddTransactionPage(accountId: String) {
|
|||||||
TextInput(
|
TextInput(
|
||||||
value = amount,
|
value = amount,
|
||||||
onChange = {
|
onChange = {
|
||||||
|
UiState.txnError.value = ""
|
||||||
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
|
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
|
||||||
amount = it
|
amount = it
|
||||||
UiState.txnError.value = ""
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
placeholder = "0.00",
|
placeholder = "0.00",
|
||||||
@@ -129,7 +132,10 @@ fun AddTransactionPage(accountId: String) {
|
|||||||
FieldLabel("Note (optional)")
|
FieldLabel("Note (optional)")
|
||||||
TextInput(
|
TextInput(
|
||||||
value = note,
|
value = note,
|
||||||
onChange = { note = it.take(80) },
|
onChange = {
|
||||||
|
UiState.txnError.value = ""
|
||||||
|
note = it.take(80)
|
||||||
|
},
|
||||||
placeholder = "What's this for?",
|
placeholder = "What's this for?",
|
||||||
label = "Note",
|
label = "Note",
|
||||||
description = "txn_note",
|
description = "txn_note",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ class WebLedgerStore : LedgerStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun accountExistsByName(name: String): Boolean =
|
override fun accountExistsByName(name: String): Boolean =
|
||||||
_accounts.value.any { it.name == name }
|
_accounts.value.any { it.name.equals(name, ignoreCase = true) }
|
||||||
|
|
||||||
override fun insertAccount(id: String, name: String, createdAt: Long) {
|
override fun insertAccount(id: String, name: String, createdAt: Long) {
|
||||||
_accounts.value = _accounts.value + Account(id, name, createdAt)
|
_accounts.value = _accounts.value + Account(id, name, createdAt)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
waitOnce,
|
waitOnce,
|
||||||
weighted,
|
weighted,
|
||||||
} from "@uatu/spec";
|
} from "@uatu/spec";
|
||||||
import { noLogcatErrors, noUncaughtExceptions } from "@uatu/spec/defaults/properties";
|
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
|
||||||
|
|
||||||
interface AccountSnapshot {
|
interface AccountSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,7 +90,9 @@ const txnCredit = extract((state) => state.ax.find("desc:txn_credit"));
|
|||||||
const txnDebit = extract((state) => state.ax.find("desc:txn_debit"));
|
const txnDebit = extract((state) => state.ax.find("desc:txn_debit"));
|
||||||
const txnSubmit = extract((state) => state.ax.find("desc:txn_submit"));
|
const txnSubmit = extract((state) => state.ax.find("desc:txn_submit"));
|
||||||
const backButton = extract((state) => state.ax.find("desc:Back"));
|
const backButton = extract((state) => state.ax.find("desc:Back"));
|
||||||
const anyAccountCard = extract((state) => state.ax.find("descPrefix:account_card:"));
|
const allAccountCards = extract((state) =>
|
||||||
|
state.ax.findAll("descPrefix:account_card:"),
|
||||||
|
);
|
||||||
|
|
||||||
const accountCountNonNegative = always(() => accountCount.current >= 0);
|
const accountCountNonNegative = always(() => accountCount.current >= 0);
|
||||||
|
|
||||||
@@ -334,8 +336,10 @@ const openAddAccount = actions(() => {
|
|||||||
|
|
||||||
const openRandomAccount = actions(() => {
|
const openRandomAccount = actions(() => {
|
||||||
if (route.current !== "home") return [];
|
if (route.current !== "home") return [];
|
||||||
const card = anyAccountCard.current;
|
const cards = allAccountCards.current;
|
||||||
return card ? [Tap({ on: card })] : [];
|
if (cards.length === 0) return [];
|
||||||
|
const card = cards[Math.floor(Math.random() * cards.length)];
|
||||||
|
return [Tap({ on: card })];
|
||||||
});
|
});
|
||||||
|
|
||||||
const logoutAction = actions(() => {
|
const logoutAction = actions(() => {
|
||||||
@@ -411,7 +415,6 @@ export const properties = {
|
|||||||
...stateMachine,
|
...stateMachine,
|
||||||
...liveness,
|
...liveness,
|
||||||
noUncaughtExceptions,
|
noUncaughtExceptions,
|
||||||
noLogcatErrors,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionsRoot = weighted(
|
export const actionsRoot = weighted(
|
||||||
|
|||||||
@@ -22,6 +22,45 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
</node>
|
</node>
|
||||||
</hierarchy>`
|
</hierarchy>`
|
||||||
|
|
||||||
|
const homeHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
|
||||||
|
<node index="0" class="android.view.View" content-desc="logout_button" clickable="true" bounds="[980,80][1060,160]" />
|
||||||
|
<node index="1" class="android.view.View" content-desc="account_card:acc-1" clickable="true" bounds="[64,320][1016,440]" />
|
||||||
|
<node index="2" class="android.view.View" content-desc="account_card:acc-2" clickable="true" bounds="[64,460][1016,580]" />
|
||||||
|
<node index="3" class="android.view.View" content-desc="add_account_button" clickable="true" bounds="[64,2200][1016,2320]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>`
|
||||||
|
|
||||||
|
const addAccountHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
|
||||||
|
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
|
||||||
|
<node index="1" class="android.view.View" content-desc="account_name_field" clickable="true" bounds="[64,320][1016,440]" />
|
||||||
|
<node index="2" class="android.view.View" content-desc="add_account_submit" clickable="true" bounds="[64,2200][1016,2320]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>`
|
||||||
|
|
||||||
|
const ledgerHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
|
||||||
|
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
|
||||||
|
<node index="1" class="android.view.View" content-desc="add_txn_button" clickable="true" bounds="[64,2200][1016,2320]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>`
|
||||||
|
|
||||||
|
const addTxnHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
|
||||||
|
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
|
||||||
|
<node index="1" class="android.view.View" content-desc="txn_credit" clickable="true" bounds="[64,280][540,360]" />
|
||||||
|
<node index="2" class="android.view.View" content-desc="txn_debit" clickable="true" bounds="[540,280][1016,360]" />
|
||||||
|
<node index="3" class="android.view.View" content-desc="txn_amount" clickable="true" bounds="[64,440][1016,560]" />
|
||||||
|
<node index="4" class="android.view.View" content-desc="txn_note" clickable="true" bounds="[64,600][1016,720]" />
|
||||||
|
<node index="5" class="android.view.View" content-desc="txn_submit" clickable="true" bounds="[64,2200][1016,2320]" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>`
|
||||||
|
|
||||||
// bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real
|
// bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real
|
||||||
// @uatu/spec API so the integration test exercises the same path the CLI uses.
|
// @uatu/spec API so the integration test exercises the same path the CLI uses.
|
||||||
func bundleSampleAppSpec(t *testing.T) string {
|
func bundleSampleAppSpec(t *testing.T) string {
|
||||||
@@ -71,6 +110,55 @@ func loginSnapshots() Snapshots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// twoAccountsJSON is shared between home, ledger, and add-transaction
|
||||||
|
// snapshots so invariants that correlate accounts with ledger rows stay
|
||||||
|
// consistent across routes.
|
||||||
|
const twoAccountsJSON = `[` +
|
||||||
|
`{"id":"acc-1","name":"Checking","balance":0,"txnCount":0},` +
|
||||||
|
`{"id":"acc-2","name":"Savings","balance":0,"txnCount":0}` +
|
||||||
|
`]`
|
||||||
|
|
||||||
|
func homeSnapshots() Snapshots {
|
||||||
|
return Snapshots{
|
||||||
|
"route": json.RawMessage(`"home"`),
|
||||||
|
"logged_in": json.RawMessage(`true`),
|
||||||
|
"auth_status": json.RawMessage(`"logged-in"`),
|
||||||
|
"account_count": json.RawMessage(`2`),
|
||||||
|
"accounts": json.RawMessage(twoAccountsJSON),
|
||||||
|
"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(`""`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAccountSnapshots() Snapshots {
|
||||||
|
s := homeSnapshots()
|
||||||
|
s["route"] = json.RawMessage(`"add-account"`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerSnapshots() Snapshots {
|
||||||
|
s := homeSnapshots()
|
||||||
|
s["route"] = json.RawMessage(`"ledger"`)
|
||||||
|
s["active_account_id"] = json.RawMessage(`"acc-1"`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTxnSnapshots() Snapshots {
|
||||||
|
s := ledgerSnapshots()
|
||||||
|
s["route"] = json.RawMessage(`"add-transaction"`)
|
||||||
|
s["txn_form_type"] = json.RawMessage(`"credit"`)
|
||||||
|
s["txn_form_account_id"] = json.RawMessage(`"acc-1"`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
|
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
|
||||||
// emits Tap actions targeting the login screen elements when they are present
|
// emits Tap actions targeting the login screen elements when they are present
|
||||||
// in the hierarchy.
|
// in the hierarchy.
|
||||||
@@ -145,3 +233,108 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
|
|||||||
t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"])
|
t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSampleAppSpecActionsFireOnEachRoute pushes a hierarchy + snapshot pair
|
||||||
|
// representative of each sample-app route and verifies the expected action
|
||||||
|
// generators fire against that state. Guards against silent breakage of any
|
||||||
|
// one route's generators (a regression only e2e would otherwise catch).
|
||||||
|
func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
xml string
|
||||||
|
snapshots Snapshots
|
||||||
|
expectKind ActionKind
|
||||||
|
expectOns []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "home",
|
||||||
|
xml: homeHierarchyXML,
|
||||||
|
snapshots: homeSnapshots(),
|
||||||
|
expectKind: ActionKindTap,
|
||||||
|
expectOns: []string{
|
||||||
|
"desc:add_account_button",
|
||||||
|
"desc:logout_button",
|
||||||
|
"descPrefix:account_card:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add-account",
|
||||||
|
xml: addAccountHierarchyXML,
|
||||||
|
snapshots: addAccountSnapshots(),
|
||||||
|
expectKind: ActionKindTap,
|
||||||
|
expectOns: []string{"desc:add_account_submit", "desc:Back"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ledger",
|
||||||
|
xml: ledgerHierarchyXML,
|
||||||
|
snapshots: ledgerSnapshots(),
|
||||||
|
expectKind: ActionKindTap,
|
||||||
|
expectOns: []string{"desc:add_txn_button", "desc:Back"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add-transaction",
|
||||||
|
xml: addTxnHierarchyXML,
|
||||||
|
snapshots: addTxnSnapshots(),
|
||||||
|
expectKind: ActionKindTap,
|
||||||
|
expectOns: []string{
|
||||||
|
"desc:txn_submit",
|
||||||
|
"desc:txn_debit",
|
||||||
|
"desc:Back",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
v := newVerifier(t)
|
||||||
|
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(tc.xml)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := v.PushSnapshot(SnapshotInput{Snapshots: tc.snapshots, Tree: tree}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sawOn := map[string]bool{}
|
||||||
|
sawInputText := map[string]bool{}
|
||||||
|
for range 800 {
|
||||||
|
action, err := v.NextAction()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if action.Kind == tc.expectKind {
|
||||||
|
sawOn[action.On] = true
|
||||||
|
}
|
||||||
|
if action.Kind == ActionKindInputText {
|
||||||
|
sawInputText[action.On] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, on := range tc.expectOns {
|
||||||
|
if !sawOn[on] {
|
||||||
|
t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.name == "add-account" && !sawInputText["desc:account_name_field"] {
|
||||||
|
t.Errorf("add-account: typeAccountName never fired; saw %v", keysOf(sawInputText))
|
||||||
|
}
|
||||||
|
if tc.name == "add-transaction" {
|
||||||
|
if !sawInputText["desc:txn_amount"] {
|
||||||
|
t.Errorf("add-transaction: typeAmount never fired; saw %v", keysOf(sawInputText))
|
||||||
|
}
|
||||||
|
if !sawInputText["desc:txn_note"] {
|
||||||
|
t.Errorf("add-transaction: typeNote never fired; saw %v", keysOf(sawInputText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -93,6 +93,32 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
|||||||
// hierarchy that reports an enormous text length for the focused field.
|
// hierarchy that reports an enormous text length for the focused field.
|
||||||
internal const val MAX_CLEAR_DELETES: Int = 1024
|
internal const val MAX_CLEAR_DELETES: Int = 1024
|
||||||
|
|
||||||
|
internal fun buildClearKeyevents(textLength: Int): List<String> {
|
||||||
|
if (textLength <= 0) return emptyList()
|
||||||
|
val deletes = minOf(textLength, MAX_CLEAR_DELETES)
|
||||||
|
val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
|
||||||
|
repeat(deletes) { args.add("KEYCODE_DEL") }
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// `adb shell input text` runs through a remote sh, so shell metacharacters
|
||||||
|
// in the payload would be interpreted by the device shell. Substitute
|
||||||
|
// spaces with %s (input's escape) and backslash-escape characters sh
|
||||||
|
// would otherwise expand. Keep this list conservative; anything not
|
||||||
|
// listed passes through literally.
|
||||||
|
internal fun escapeForAdbInputText(text: String): String {
|
||||||
|
val sb = StringBuilder(text.length)
|
||||||
|
for (ch in text) {
|
||||||
|
when (ch) {
|
||||||
|
' ' -> sb.append("%s")
|
||||||
|
'\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?',
|
||||||
|
'$', '`', '[', ']', '{', '}', '~', '#', -> sb.append('\\').append(ch)
|
||||||
|
else -> sb.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
|
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
|
||||||
// present. Captures only the tag's attribute string so we can pull
|
// present. Captures only the tag's attribute string so we can pull
|
||||||
// `text="..."` out of it without building a full XML tree.
|
// `text="..."` out of it without building a full XML tree.
|
||||||
@@ -188,16 +214,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
|||||||
// pure replace: read the current value's length from the hierarchy,
|
// pure replace: read the current value's length from the hierarchy,
|
||||||
// then move-end + N backspaces before typing.
|
// then move-end + N backspaces before typing.
|
||||||
clearFocusedField()
|
clearFocusedField()
|
||||||
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s")))
|
runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearFocusedField() {
|
private fun clearFocusedField() {
|
||||||
val current = focusedFieldText() ?: return
|
val current = focusedFieldText() ?: return
|
||||||
if (current.isEmpty()) return
|
if (current.isEmpty()) return
|
||||||
val deletes = minOf(current.length, MAX_CLEAR_DELETES)
|
runAdb(buildClearKeyevents(current.length))
|
||||||
val keyevents = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
|
|
||||||
repeat(deletes) { keyevents.add("KEYCODE_DEL") }
|
|
||||||
runAdb(keyevents)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun focusedFieldText(): String? {
|
private fun focusedFieldText(): String? {
|
||||||
|
|||||||
@@ -3,9 +3,51 @@ package dev.uatu.sidecar
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class InputTextClearTest {
|
class InputTextClearTest {
|
||||||
|
|
||||||
|
@Test fun buildClearKeyeventsEmptyWhenNothingToDelete() {
|
||||||
|
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0))
|
||||||
|
assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() {
|
||||||
|
val args = StubDriverBackend.buildClearKeyevents(3)
|
||||||
|
assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END",
|
||||||
|
"KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun buildClearKeyeventsCapsAtMaxClearDeletes() {
|
||||||
|
val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10
|
||||||
|
val args = StubDriverBackend.buildClearKeyevents(huge)
|
||||||
|
val deletes = args.count { it == "KEYCODE_DEL" }
|
||||||
|
assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes)
|
||||||
|
assertEquals("KEYCODE_MOVE_END", args[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun escapeForAdbInputTextSubstitutesSpaces() {
|
||||||
|
assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun escapeForAdbInputTextEscapesShellMetacharacters() {
|
||||||
|
val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f")
|
||||||
|
assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() {
|
||||||
|
assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'"))
|
||||||
|
assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\""))
|
||||||
|
assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun escapeForAdbInputTextLeavesSimpleTextAlone() {
|
||||||
|
assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34"))
|
||||||
|
assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee"))
|
||||||
|
assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test fun parsesTextFromFocusedNode() {
|
@Test fun parsesTextFromFocusedNode() {
|
||||||
val xml = """
|
val xml = """
|
||||||
<hierarchy>
|
<hierarchy>
|
||||||
|
|||||||
Reference in new issue
Block a user