mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Remove in-app SDK (#43)
* chore: delete internal/agent package
* chore(build): remove sdk-android from gradle settings
* chore(makefile): remove sdk-android targets
* chore(ci): remove release-android job from release workflow
* chore(folio): remove sdk-android dependency
* chore(folio): remove SDK initialization from FolioApplication
* chore(folio): delete snapshot extractor files
* feat(folio): add balance to account card content description
* feat(folio): add hierarchy content descriptions to LedgerScreen
* refactor(folio): rewrite spec.ts to use ax extractors
* docs: remove in-app SDK from README
* feat(folio): add focused_input indicator to App
* docs: remove in-app SDK from index
* refactor(runner): remove agent SDK connection and snapshot step
* test(runner): update tests for SDK removal
* docs: remove Android SDK section from getting-started
* refactor(testrun): remove agent SDK connection setup
* docs: remove snapshots from writing-specs
* docs: remove in-app SDK from architecture doc
* docs(folio): update README for SDK removal
* docs: update per-step cycle diagram in architecture doc
* fix(folio): detect screens from unique element presence, not id: selectors
testTag() in Compose is not exposed as resource-id without testTagsAsResourceId.
Use desc: selectors for elements unique to each screen instead of id: path queries.
* feat(folio): add screen root contentDescription for scoped ax selection
Each screen root gets semantics { contentDescription = "ScreenName" } so
sanderling specs can scope element lookups through the screen: desc:LoginScreen > desc:login_submit.
* fix(folio): scope all ax selectors through screen root nodes
Use desc:ScreenName > desc:element path queries so every selector is
rooted at the screen level. focusedInput stays unscoped since it lives
in the app root, outside any screen.
* fix(folio): guard newAccountBalanceIsZero against navigation false positives
Scoped selectors return [] when not on HomeScreen so accounts vanish and
reappear as apparently-new on each visit. Skip the check when prev was empty.
* chore(folio): link @sanderling/spec to local pkg/spec for IDE type checking
* feat(spec): add desc, class, clickable, enabled, checked, focused, selected to AccessibilityElement
Runtime fields set by the verifier were missing from the TypeScript type,
causing linting errors on el.desc and related accesses in specs.
* chore(folio): switch to bun, add tsconfig.json for IDE type checking
- Remove package-lock.json, add bun.lock
- Add tsconfig.json so VSCode resolves @sanderling/spec types
- Fix parseAccount/parseLedgerRow to accept string | undefined
This commit is contained in:
60 files changed
+298
-3354
No files matched your search
@@ -10,7 +10,6 @@ sanderling runs its property-based specs against.
|
||||
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
||||
- kotlinx.serialization for file-backed persistence
|
||||
- kotlinx.coroutines for state flows
|
||||
- sanderling `sdk-android` for harness integration on Android
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -70,9 +69,9 @@ Traces land in `./sanderling/runs/<timestamp>/`.
|
||||
|
||||
## How it connects to sanderling
|
||||
|
||||
- `composeApp/src/androidMain/.../FolioApplication.kt` calls `Sanderling.start(this)`
|
||||
and registers snapshot extractors (`logged_in`, `account_count`,
|
||||
`total_balance`, `route`)
|
||||
- `sanderling/spec.ts` imports `@sanderling/spec`, reads those snapshots, asserts
|
||||
properties, and weights the actions the fuzzer picks from
|
||||
- UI elements expose state via content descriptions (`account:$id:$balance`,
|
||||
`ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`,
|
||||
`focused_input:$field`)
|
||||
- `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`,
|
||||
asserts properties, and weights the actions the fuzzer picks from
|
||||
- `just test` invokes `sanderling test` against the installed APK
|
||||
@@ -83,7 +83,6 @@ kotlin {
|
||||
androidMain.dependencies {
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
||||
implementation("io.github.priyanshujain.sanderling:sdk-android:$sanderlingVersion")
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
|
||||
@@ -3,21 +3,11 @@ package app.folio
|
||||
import android.app.Application
|
||||
import app.folio.data.AndroidLedgerContext
|
||||
import app.folio.data.Repository
|
||||
import app.folio.sanderling.AccountSnapshots
|
||||
import app.folio.sanderling.AuthSnapshots
|
||||
import app.folio.sanderling.LedgerSnapshots
|
||||
import app.folio.sanderling.NavigationSnapshots
|
||||
import dev.sanderling.sdk.Sanderling
|
||||
|
||||
class FolioApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AndroidLedgerContext.context = applicationContext
|
||||
Repository.init()
|
||||
Sanderling.start(this)
|
||||
AuthSnapshots
|
||||
AccountSnapshots
|
||||
LedgerSnapshots
|
||||
NavigationSnapshots
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.account.AddAccountUiState
|
||||
import app.folio.platform.balanceOf
|
||||
import dev.sanderling.sdk.Sanderling
|
||||
|
||||
object AccountSnapshots {
|
||||
val accountCount by Sanderling.snapshot { Repository.accounts.value.size }
|
||||
val totalBalance by Sanderling.snapshot { balanceOf(Repository.transactions.value) }
|
||||
val accounts by Sanderling.snapshot {
|
||||
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)
|
||||
}
|
||||
}
|
||||
val addAccountError by Sanderling.snapshot { AddAccountUiState.addAccountError.value }
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.auth.LoginUiState
|
||||
import dev.sanderling.sdk.Sanderling
|
||||
|
||||
object AuthSnapshots {
|
||||
val loggedIn by Sanderling.snapshot { Repository.session.value != null }
|
||||
val authStatus by Sanderling.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
|
||||
val loginError by Sanderling.snapshot { LoginUiState.loginError.value }
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.FocusTracker
|
||||
import app.folio.data.Repository
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.feature.ledger.AddTransactionUiState
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.signedAmount
|
||||
import dev.sanderling.sdk.Sanderling
|
||||
|
||||
object LedgerSnapshots {
|
||||
private val activeId
|
||||
get() = when (val r = Navigator.current.value) {
|
||||
is Route.Ledger -> r.accountId
|
||||
is Route.AddTransaction -> r.accountId
|
||||
else -> null
|
||||
}
|
||||
|
||||
val activeAccountId by Sanderling.snapshot { activeId }
|
||||
val ledgerRows by Sanderling.snapshot {
|
||||
val active = activeId ?: return@snapshot emptyList<Any>()
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
val ledgerBalance by Sanderling.snapshot {
|
||||
val active = activeId ?: return@snapshot 0L
|
||||
balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
||||
}
|
||||
val focusedInput by Sanderling.snapshot { FocusTracker.current.value }
|
||||
val txnFormType by Sanderling.snapshot { AddTransactionUiState.txnFormType.value }
|
||||
val txnFormAccountId by Sanderling.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
|
||||
val txnError by Sanderling.snapshot { AddTransactionUiState.txnError.value }
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import dev.sanderling.sdk.Sanderling
|
||||
|
||||
object NavigationSnapshots {
|
||||
val screen by Sanderling.snapshot {
|
||||
when (Navigator.current.value) {
|
||||
Route.Login -> "login"
|
||||
Route.Home -> "home"
|
||||
Route.AddAccount -> "add-account"
|
||||
is Route.Ledger -> "ledger"
|
||||
is Route.AddTransaction -> "add-transaction"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -13,6 +14,9 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.account.AddAccountScreen
|
||||
import app.folio.feature.auth.LoginScreen
|
||||
@@ -21,6 +25,7 @@ import app.folio.feature.ledger.AddTransactionScreen
|
||||
import app.folio.feature.ledger.LedgerScreen
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.FocusTracker
|
||||
import app.folio.ui.theme.LedgerTheme
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Tokens
|
||||
@@ -29,6 +34,7 @@ import app.folio.ui.theme.Tokens
|
||||
fun App() {
|
||||
val session by Repository.session.collectAsState()
|
||||
val route by Navigator.current.collectAsState()
|
||||
val focusedInput by FocusTracker.current.collectAsState()
|
||||
|
||||
LaunchedEffect(session, route) {
|
||||
if (session == null && route !is Route.Login) {
|
||||
@@ -47,6 +53,9 @@ fun App() {
|
||||
.background(t.bg)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
if (focusedInput != null) {
|
||||
Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" })
|
||||
}
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (val r = route) {
|
||||
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
|
||||
|
||||
+3
-1
@@ -14,6 +14,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.data.Repository
|
||||
import app.folio.navigation.Navigator
|
||||
@@ -60,7 +62,7 @@ fun AddAccountScreen() {
|
||||
}
|
||||
|
||||
Screen(
|
||||
modifier = Modifier.testTag("AddAccountScreen"),
|
||||
modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" },
|
||||
header = {
|
||||
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
|
||||
},
|
||||
|
||||
+3
-1
@@ -16,6 +16,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.data.Repository
|
||||
@@ -60,7 +62,7 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) {
|
||||
onLoggedIn(user)
|
||||
}
|
||||
|
||||
Screen(modifier = Modifier.testTag("LoginScreen")) {
|
||||
Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ fun HomeScreen(user: String, onLogout: () -> Unit) {
|
||||
val total = balanceOf(txns)
|
||||
|
||||
Screen(
|
||||
modifier = Modifier.testTag("HomeScreen"),
|
||||
modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" },
|
||||
header = {
|
||||
Header(
|
||||
title = "Accounts",
|
||||
@@ -123,7 +123,7 @@ private fun AccountCard(
|
||||
.clip(RoundedCornerShape(RadiusLg))
|
||||
.background(t.surface)
|
||||
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||
.semantics(mergeDescendants = true) { contentDescription = "account_card:$id" }
|
||||
.semantics(mergeDescendants = true) { contentDescription = "account:$id:$balance" }
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
+3
-1
@@ -13,6 +13,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -91,7 +93,7 @@ fun AddTransactionScreen(accountId: String) {
|
||||
}
|
||||
|
||||
Screen(
|
||||
modifier = Modifier.testTag("AddTransactionScreen"),
|
||||
modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" },
|
||||
header = {
|
||||
Header(
|
||||
title = "Add transaction",
|
||||
|
||||
+4
-2
@@ -75,7 +75,7 @@ fun LedgerScreen(accountId: String) {
|
||||
val balance = balanceOf(txns)
|
||||
|
||||
Screen(
|
||||
modifier = Modifier.testTag("LedgerScreen"),
|
||||
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen" },
|
||||
header = {
|
||||
Header(
|
||||
title = account.name,
|
||||
@@ -92,6 +92,8 @@ fun LedgerScreen(accountId: String) {
|
||||
)
|
||||
},
|
||||
) {
|
||||
Box(Modifier.size(1.dp).semantics { contentDescription = "active_account:$accountId" })
|
||||
Box(Modifier.size(1.dp).semantics { contentDescription = "ledger_balance:$balance" })
|
||||
Card {
|
||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||
Text(formatCents(balance), style = Type.balance, color = t.text)
|
||||
@@ -136,7 +138,7 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date:
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 14.dp)
|
||||
.semantics(mergeDescendants = true) { contentDescription = "txn_row:$id" },
|
||||
.semantics(mergeDescendants = true) { contentDescription = "ledger_row:$id:$signed" },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.account.AddAccountUiState
|
||||
import app.folio.platform.balanceOf
|
||||
|
||||
object AccountSnapshots {
|
||||
val accountCount by SanderlingIos.snapshot { Repository.accounts.value.size }
|
||||
val totalBalance by SanderlingIos.snapshot { balanceOf(Repository.transactions.value) }
|
||||
val accounts by SanderlingIos.snapshot {
|
||||
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)
|
||||
}
|
||||
}
|
||||
val addAccountError by SanderlingIos.snapshot { AddAccountUiState.addAccountError.value }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.auth.LoginUiState
|
||||
|
||||
object AuthSnapshots {
|
||||
val loggedIn by SanderlingIos.snapshot { Repository.session.value != null }
|
||||
val authStatus by SanderlingIos.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" }
|
||||
val loginError by SanderlingIos.snapshot { LoginUiState.loginError.value }
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSBundle
|
||||
import platform.Foundation.NSThread
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal object IosAgent {
|
||||
private const val VERSION = "0.0.1"
|
||||
private const val PROTOCOL_VERSION = 1
|
||||
|
||||
fun start(host: String, port: Int) {
|
||||
val thread = NSThread { runLoop(host, port) }
|
||||
thread.name = "sanderling-agent"
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private fun runLoop(host: String, port: Int) {
|
||||
var delayMs = 0L
|
||||
while (true) {
|
||||
if (delayMs > 0) NSThread.sleepForTimeInterval(delayMs / 1000.0)
|
||||
try {
|
||||
val conn = TcpConnection.connect(host, port)
|
||||
delayMs = 0L
|
||||
try { serve(conn) } finally { conn.close() }
|
||||
} catch (_: Exception) {
|
||||
delayMs = if (delayMs <= 0L) 500L else minOf(delayMs * 2, 10_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serve(conn: TcpConnection) {
|
||||
val appPackage = (NSBundle.mainBundle.infoDictionary?.get("CFBundleIdentifier") as? String) ?: "unknown"
|
||||
conn.writeFrame(
|
||||
"""{"type":"HELLO","protocol_version":$PROTOCOL_VERSION,"version":${jsonString(VERSION)},"platform":"ios","app_package":${jsonString(appPackage)}}""".encodeToByteArray()
|
||||
)
|
||||
while (true) {
|
||||
val frame = conn.readFrame()
|
||||
val text = frame.decodeToString()
|
||||
val type = extractJsonField(text, "type") ?: break
|
||||
val id = extractJsonLong(text, "id") ?: 0L
|
||||
when (type) {
|
||||
"PAUSE" -> handlePause(conn, id)
|
||||
"RESUME" -> IosPauser.release()
|
||||
"GOODBYE" -> return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePause(conn: TcpConnection, id: Long) {
|
||||
val snapshots = IosPauser.pauseAndSnapshot {
|
||||
val snap = SanderlingIos.extractors.toMap()
|
||||
buildMap { for ((name, extractor) in snap) put(name, runCatching { extractor() }.getOrNull()) }
|
||||
}
|
||||
val snapshotsJson = snapshots.entries.joinToString(",") { (k, v) -> "${jsonString(k)}:${jsonValue(v)}" }
|
||||
conn.writeFrame("""{"type":"STATE","id":$id,"snapshots":{$snapshotsJson}}""".encodeToByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
internal fun extractJsonField(json: String, key: String): String? =
|
||||
Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").find(json)?.groupValues?.get(1)
|
||||
|
||||
internal fun extractJsonLong(json: String, key: String): Long? =
|
||||
Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull()
|
||||
|
||||
internal fun jsonString(s: String): String = buildString {
|
||||
append('"')
|
||||
for (c in s) when (c) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (c.code < 0x20) append("\\u${c.code.toString(16).padStart(4, '0')}") else append(c)
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
|
||||
internal fun jsonValue(value: Any?): String = when (value) {
|
||||
null -> "null"
|
||||
is Boolean -> if (value) "true" else "false"
|
||||
is Int -> value.toString()
|
||||
is Long -> value.toString()
|
||||
is Float -> value.toString()
|
||||
is Double -> value.toString()
|
||||
is String -> jsonString(value)
|
||||
is Map<*, *> -> "{${value.entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}"
|
||||
is List<*> -> "[${value.joinToString(",") { jsonValue(it) }}]"
|
||||
else -> jsonString(value.toString())
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.darwin.*
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal object IosPauser {
|
||||
private val snapshotReady = dispatch_semaphore_create(0)
|
||||
private val resumeGate = dispatch_semaphore_create(0)
|
||||
private var capturedSnapshot: Map<String, Any?> = emptyMap()
|
||||
|
||||
fun pauseAndSnapshot(extractors: () -> Map<String, Any?>): Map<String, Any?> {
|
||||
dispatch_async(dispatch_get_main_queue()) {
|
||||
capturedSnapshot = runCatching { extractors() }.getOrElse { emptyMap() }
|
||||
dispatch_semaphore_signal(snapshotReady)
|
||||
dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
|
||||
}
|
||||
val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L))
|
||||
check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" }
|
||||
return capturedSnapshot
|
||||
}
|
||||
|
||||
fun release() {
|
||||
dispatch_semaphore_signal(resumeGate)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.FocusTracker
|
||||
import app.folio.data.Repository
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.feature.ledger.AddTransactionUiState
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.signedAmount
|
||||
|
||||
object LedgerSnapshots {
|
||||
private val activeId
|
||||
get() = when (val r = Navigator.current.value) {
|
||||
is Route.Ledger -> r.accountId
|
||||
is Route.AddTransaction -> r.accountId
|
||||
else -> null
|
||||
}
|
||||
|
||||
val activeAccountId by SanderlingIos.snapshot { activeId }
|
||||
val ledgerRows by SanderlingIos.snapshot {
|
||||
val active = activeId ?: return@snapshot emptyList<Any>()
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
val ledgerBalance by SanderlingIos.snapshot {
|
||||
val active = activeId ?: return@snapshot 0L
|
||||
balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
||||
}
|
||||
val focusedInput by SanderlingIos.snapshot { FocusTracker.current.value }
|
||||
val txnFormType by SanderlingIos.snapshot { AddTransactionUiState.txnFormType.value }
|
||||
val txnFormAccountId by SanderlingIos.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId }
|
||||
val txnError by SanderlingIos.snapshot { AddTransactionUiState.txnError.value }
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
|
||||
object NavigationSnapshots {
|
||||
val screen by SanderlingIos.snapshot {
|
||||
when (Navigator.current.value) {
|
||||
Route.Login -> "login"
|
||||
Route.Home -> "home"
|
||||
Route.AddAccount -> "add-account"
|
||||
is Route.Ledger -> "ledger"
|
||||
is Route.AddTransaction -> "add-transaction"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import platform.Foundation.NSProcessInfo
|
||||
|
||||
private fun String.camelToSnakeCase(): String = buildString {
|
||||
for ((i, c) in this@camelToSnakeCase.withIndex()) {
|
||||
if (c.isUpperCase() && i > 0) append('_')
|
||||
append(c.lowercaseChar())
|
||||
}
|
||||
}
|
||||
|
||||
object SanderlingIos {
|
||||
internal val extractors = mutableMapOf<String, () -> Any?>()
|
||||
|
||||
fun start() {
|
||||
val port = resolvePort() ?: return
|
||||
IosAgent.start("127.0.0.1", port)
|
||||
}
|
||||
|
||||
private fun resolvePort(): Int? {
|
||||
// Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch).
|
||||
(NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String)
|
||||
?.toIntOrNull()?.let { return it }
|
||||
// Launch argument -SANDERLING_PORT <value> (Maestro simctl launch).
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val args = NSProcessInfo.processInfo.arguments as? List<String> ?: return null
|
||||
val idx = args.indexOfFirst { it == "-SANDERLING_PORT" }
|
||||
if (idx >= 0 && idx + 1 < args.size) {
|
||||
return args[idx + 1].toIntOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun extract(name: String, block: () -> Any?) {
|
||||
extractors[name] = block
|
||||
}
|
||||
|
||||
fun <T> snapshot(block: () -> T): SnapshotDelegate<T> = SnapshotDelegate(block)
|
||||
}
|
||||
|
||||
class SnapshotDelegate<T>(private val block: () -> T) {
|
||||
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
|
||||
SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?)
|
||||
return ReadOnlyProperty { _, _ -> block() }
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package app.folio.sanderling
|
||||
|
||||
import kotlinx.cinterop.*
|
||||
import platform.posix.*
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal class TcpConnection private constructor(private val fd: Int) {
|
||||
companion object {
|
||||
fun connect(host: String, port: Int): TcpConnection {
|
||||
val sock = socket(AF_INET, SOCK_STREAM, 0)
|
||||
check(sock >= 0) { "socket() failed" }
|
||||
memScoped {
|
||||
val addr = alloc<sockaddr_in>()
|
||||
addr.sin_family = AF_INET.convert()
|
||||
addr.sin_port = networkShort(port)
|
||||
addr.sin_addr.s_addr = networkAddress(host)
|
||||
val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf<sockaddr_in>().convert())
|
||||
if (result < 0) {
|
||||
close(sock)
|
||||
error("connect() to $host:$port failed: errno=$errno")
|
||||
}
|
||||
}
|
||||
return TcpConnection(sock)
|
||||
}
|
||||
|
||||
// Convert port to network byte order (big-endian bytes in memory on little-endian iOS).
|
||||
private fun networkShort(value: Int): UShort =
|
||||
(((value ushr 8) and 0xFF) or ((value and 0xFF) shl 8)).toUShort()
|
||||
|
||||
// Parse "a.b.c.d" → UInt with bytes [a,b,c,d] in memory (little-endian storage).
|
||||
private fun networkAddress(host: String): UInt {
|
||||
val parts = host.split(".").map { it.toInt() }
|
||||
check(parts.size == 4) { "expected IPv4 address, got: $host" }
|
||||
return (parts[0] or (parts[1] shl 8) or (parts[2] shl 16) or (parts[3] shl 24)).toUInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFrame(data: ByteArray) {
|
||||
val len = data.size
|
||||
writeAll(byteArrayOf((len ushr 24).toByte(), (len ushr 16).toByte(), (len ushr 8).toByte(), len.toByte()))
|
||||
writeAll(data)
|
||||
}
|
||||
|
||||
fun readFrame(): ByteArray {
|
||||
val header = readAll(4)
|
||||
val len = ((header[0].toInt() and 0xFF) shl 24) or
|
||||
((header[1].toInt() and 0xFF) shl 16) or
|
||||
((header[2].toInt() and 0xFF) shl 8) or
|
||||
(header[3].toInt() and 0xFF)
|
||||
check(len in 0..16_777_216) { "bad frame length: $len" }
|
||||
return readAll(len)
|
||||
}
|
||||
|
||||
private fun writeAll(data: ByteArray) {
|
||||
data.usePinned { pinned ->
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val n = send(fd, pinned.addressOf(offset), (data.size - offset).convert(), 0).toInt()
|
||||
check(n > 0) { "send() failed: errno=$errno" }
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAll(count: Int): ByteArray {
|
||||
val buf = ByteArray(count)
|
||||
buf.usePinned { pinned ->
|
||||
var offset = 0
|
||||
while (offset < count) {
|
||||
val n = recv(fd, pinned.addressOf(offset), (count - offset).convert(), 0).toInt()
|
||||
check(n > 0) { "recv() returned $n" }
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
fun close() {
|
||||
platform.posix.close(fd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "folio",
|
||||
"devDependencies": {
|
||||
"@sanderling/spec": "file:../../../pkg/spec",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@sanderling/spec": ["@sanderling/spec@file:../../../pkg/spec", { "devDependencies": { "@types/node": "^22.7.5", "tsx": "^4.21.0", "typescript": "^5.9.3" } }],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"tsx": ["[email protected]", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "folio",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "folio",
|
||||
"devDependencies": {
|
||||
"@sanderling/spec": "^0.0.1-rc4"
|
||||
}
|
||||
},
|
||||
"node_modules/@sanderling/spec": {
|
||||
"version": "0.0.1-rc4",
|
||||
"resolved": "https://registry.npmjs.org/@sanderling/spec/-/spec-0.0.1-rc4.tgz",
|
||||
"integrity": "sha512-iv08d2duxQMowqEnDtOeWRGLmiv7188D68g/5ei+cU1BL33cVXwGscLYznfsUv4PgK7XI5Vihwbnp7ME4+Lh1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"description": "Minimal sanderling integration example.",
|
||||
"devDependencies": {
|
||||
"@sanderling/spec": "^0.0.1-rc4"
|
||||
"@sanderling/spec": "file:../../../pkg/spec"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
@@ -21,31 +20,70 @@ interface LedgerRow {
|
||||
signed: number;
|
||||
}
|
||||
|
||||
const loggedIn = extract<boolean>(s => (s.snapshots.logged_in as boolean) ?? false);
|
||||
const route = extract<string>(s => (s.snapshots.screen as string) ?? "");
|
||||
const accounts = extract<Account[]>(s => (s.snapshots.accounts as Account[]) ?? []);
|
||||
const ledgerRows = extract<LedgerRow[]>(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
|
||||
const ledgerBalance = extract<number>(s => (s.snapshots.ledger_balance as number) ?? 0);
|
||||
const activeAccountId = extract<string | null>(s => (s.snapshots.active_account_id as string | null) ?? null);
|
||||
const focusedInput = extract<string | null>(s => (s.snapshots.focused_input as string | null) ?? null);
|
||||
function parseAccount(desc: string | undefined): Account {
|
||||
const parts = (desc ?? "").split(":");
|
||||
return { id: parts[1] ?? "", balance: Number(parts[2]) };
|
||||
}
|
||||
|
||||
const loginEmailField = extract(s => s.ax.find("id:LoginScreen > desc:login_email"));
|
||||
const loginPasswordField = extract(s => s.ax.find("id:LoginScreen > desc:login_password"));
|
||||
const loginSubmit = extract(s => s.ax.find("id:LoginScreen > desc:login_submit"));
|
||||
const addAccountButton = extract(s => s.ax.find("id:HomeScreen > desc:add_account_button"));
|
||||
const accountNameField = extract(s => s.ax.find("id:AddAccountScreen > desc:account_name_field"));
|
||||
const addAccountSubmit = extract(s => s.ax.find("id:AddAccountScreen > desc:add_account_submit"));
|
||||
const addTxnButton = extract(s => s.ax.find("id:LedgerScreen > desc:add_txn_button"));
|
||||
const txnAmountField = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_amount"));
|
||||
const txnSubmit = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_submit"));
|
||||
const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:"));
|
||||
function parseLedgerRow(desc: string | undefined): LedgerRow {
|
||||
const parts = (desc ?? "").split(":");
|
||||
return { id: parts[1] ?? "", signed: Number(parts[2]) };
|
||||
}
|
||||
|
||||
function parseCents(desc: string | null | undefined): number {
|
||||
if (!desc) return 0;
|
||||
const parts = desc.split(":");
|
||||
return Number(parts[1]) || 0;
|
||||
}
|
||||
|
||||
// Route and auth state derived from screen root nodes
|
||||
const loggedIn = extract(s => s.ax.find("desc:LoginScreen") == null);
|
||||
const route = extract<string | null>(s => {
|
||||
if (s.ax.find("desc:LoginScreen")) return "login";
|
||||
if (s.ax.find("desc:HomeScreen")) return "home";
|
||||
if (s.ax.find("desc:AddAccountScreen")) return "add-account";
|
||||
if (s.ax.find("desc:LedgerScreen")) return "ledger";
|
||||
if (s.ax.find("desc:AddTransactionScreen")) return "add-transaction";
|
||||
return null;
|
||||
});
|
||||
|
||||
// All element lookups scoped through their screen root
|
||||
const accounts = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:")
|
||||
.map(el => parseAccount(el.desc)));
|
||||
const ledgerRows = extract(s => s.ax.findAll("desc:LedgerScreen > descPrefix:ledger_row:")
|
||||
.map(el => parseLedgerRow(el.desc)));
|
||||
const ledgerBalance = extract(s =>
|
||||
parseCents(s.ax.find("desc:LedgerScreen > descPrefix:ledger_balance:")?.desc));
|
||||
const activeAccountId = extract(s =>
|
||||
s.ax.find("desc:LedgerScreen > descPrefix:active_account:")?.desc?.split(":")[1] ?? null);
|
||||
|
||||
// focusedInput lives in the app root (not inside any screen), so unscoped
|
||||
const focusedInput = extract(s =>
|
||||
s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null);
|
||||
|
||||
const loginEmailField = extract(s => s.ax.find("desc:LoginScreen > desc:login_email"));
|
||||
const loginPasswordField = extract(s => s.ax.find("desc:LoginScreen > desc:login_password"));
|
||||
const loginSubmit = extract(s => s.ax.find("desc:LoginScreen > desc:login_submit"));
|
||||
const addAccountButton = extract(s => s.ax.find("desc:HomeScreen > desc:add_account_button"));
|
||||
const accountNameField = extract(s => s.ax.find("desc:AddAccountScreen > desc:account_name_field"));
|
||||
const addAccountSubmit = extract(s => s.ax.find("desc:AddAccountScreen > desc:add_account_submit"));
|
||||
const addTxnButton = extract(s => s.ax.find("desc:LedgerScreen > desc:add_txn_button"));
|
||||
const txnAmountField = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_amount"));
|
||||
const txnSubmit = extract(s => s.ax.find("desc:AddTransactionScreen > desc:txn_submit"));
|
||||
const accountCards = extract(s => s.ax.findAll("desc:HomeScreen > descPrefix:account:"));
|
||||
const backButton = extract(s => s.ax.find("desc:Back"));
|
||||
|
||||
// Property 1: every new account starts with balance === 0
|
||||
// Guard: only check when accounts were visible in the previous step too.
|
||||
// Without this, navigating away from HomeScreen (accounts=[]) then back
|
||||
// makes every account look "new", causing false positives on pre-existing balances.
|
||||
const newAccountBalanceIsZero = always(
|
||||
next(() => {
|
||||
const prevIds = new Set((accounts.previous ?? []).map(a => a.id));
|
||||
const newAccounts = accounts.current.filter(a => !prevIds.has(a.id));
|
||||
const prev = accounts.previous ?? [];
|
||||
const curr = accounts.current;
|
||||
if (prev.length === 0 || curr.length === 0) return true;
|
||||
const prevIds = new Set(prev.map(a => a.id));
|
||||
const newAccounts = curr.filter(a => !prevIds.has(a.id));
|
||||
return newAccounts.every(a => a.balance === 0);
|
||||
})
|
||||
);
|
||||
@@ -69,7 +107,7 @@ const newTxnChangesBalance = always(
|
||||
const DEMO_EMAIL = "[email protected]";
|
||||
const DEMO_PASSWORD = "ledger123";
|
||||
|
||||
// Login if not already in — step by step based on which field has focus
|
||||
// Login if not already in - step by step based on which field has focus
|
||||
const login = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
const focus = focusedInput.current;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["spec.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user