feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

@@ -0,0 +1,66 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/**
* Instrumented (device) contract tests for [DataStoreHostStore] — the real
* Preferences-DataStore-backed store needs a Context, so these run on a device
* (no emulator in the build env → compiled here, executed in device QA, plan §7).
* They assert the same read-modify-write / order-preserving behaviour the JVM
* [InMemoryHostStoreTest] pins for the in-memory double.
*/
@RunWith(AndroidJUnit4::class)
class DataStoreHostStoreTest {
private fun newStore(): DataStoreHostStore {
val ctx = ApplicationProvider.getApplicationContext<Context>()
val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { ctx.preferencesDataStoreFile("host-registry-test-${UUID.randomUUID()}") },
)
return DataStoreHostStore(dataStore)
}
private fun host(id: String, name: String = "host-$id", baseUrl: String = "http://10.0.0.1:3000") =
Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl)))
@Test
fun upsert_persists_and_loadAll_reads_back() = runBlocking {
val store = newStore()
val a = host("1")
assertEquals(listOf(a), store.upsert(a))
assertEquals(listOf(a), store.loadAll())
}
@Test
fun upsert_same_id_replaces_in_place() = runBlocking {
val store = newStore()
store.upsert(host("1"))
val renamed = host("1", name = "renamed")
assertEquals(listOf(renamed), store.upsert(renamed))
assertEquals("renamed", store.loadAll().single().name)
}
@Test
fun remove_unknown_is_noop() = runBlocking {
val store = newStore()
store.upsert(host("1"))
assertEquals(1, store.remove("nope").size)
assertEquals(0, store.remove("1").size)
assertEquals(0, store.loadAll().size)
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID
/**
* Instrumented (device) contract tests for [DataStoreLastSessionStore]. Mirrors the
* JVM in-memory tests plus the iOS `garbageStoredValueReadsBackAsNil` defence: a
* value that is not a UUID reads back as null (untrusted at rest). Runs on a device
* (no emulator here → compiled, deferred to device QA, plan §7).
*/
@RunWith(AndroidJUnit4::class)
class DataStoreLastSessionStoreTest {
private fun newStore(): DataStoreLastSessionStore {
val ctx = ApplicationProvider.getApplicationContext<Context>()
val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { ctx.preferencesDataStoreFile("last-session-test-${UUID.randomUUID()}") },
)
return DataStoreLastSessionStore(dataStore)
}
private val validSession = UUID.randomUUID().toString()
@Test
fun set_then_get_returns_same_sessionId() = runBlocking {
val store = newStore()
store.setLastSessionId(validSession, hostId = "host-1")
assertEquals(validSession, store.lastSessionId("host-1"))
}
@Test
fun unset_host_returns_null() = runBlocking {
assertNull(newStore().lastSessionId("host-1"))
}
@Test
fun setting_null_clears() = runBlocking {
val store = newStore()
store.setLastSessionId(validSession, hostId = "host-1")
store.setLastSessionId(null, hostId = "host-1")
assertNull(store.lastSessionId("host-1"))
}
@Test
fun distinct_hosts_are_isolated() = runBlocking {
val store = newStore()
val a = UUID.randomUUID().toString()
val b = UUID.randomUUID().toString()
store.setLastSessionId(a, hostId = "host-a")
store.setLastSessionId(b, hostId = "host-b")
assertEquals(a, store.lastSessionId("host-a"))
assertEquals(b, store.lastSessionId("host-b"))
}
}

View File

@@ -0,0 +1,44 @@
package wang.yaojia.webterm.hostregistry
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
/**
* Preferences-DataStore-backed [HostStore]. The whole host list is ONE JSON string
* under [HOSTS_KEY], serialized by [HostCodec]. Reads decode + reconstruct (dropping
* records whose baseUrl no longer validates); writes are an atomic read-modify-write
* via [DataStore.edit], reusing the shared immutable list transforms.
*
* The [DataStore] is injected (constructed from a Context in :app DI) so this class
* has no Android-framework surface of its own; its behaviour is verified instrumented
* (androidTest) on a device.
*/
public class DataStoreHostStore(
private val dataStore: DataStore<Preferences>,
) : HostStore {
override suspend fun loadAll(): List<Host> =
HostCodec.decode(dataStore.data.first()[HOSTS_KEY])
override suspend fun upsert(host: Host): List<Host> =
writeTransform { it.upserting(host) }
override suspend fun remove(id: String): List<Host> =
writeTransform { it.removing(id) }
private suspend fun writeTransform(transform: (List<Host>) -> List<Host>): List<Host> {
lateinit var updated: List<Host>
dataStore.edit { prefs ->
updated = transform(HostCodec.decode(prefs[HOSTS_KEY]))
prefs[HOSTS_KEY] = HostCodec.encode(updated)
}
return updated
}
private companion object {
val HOSTS_KEY = stringPreferencesKey("hosts")
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.hostregistry
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import wang.yaojia.webterm.wire.Validation
/**
* Preferences-DataStore-backed [LastSessionStore]. Each host's last sessionId is
* ONE string entry under `"lastSessionId.<hostId>"` (mirrors the iOS UserDefaults
* key prefix), so distinct hosts never collide.
*
* The stored value crosses a storage boundary → validated on read with the frozen
* v4-specific [Validation.isValidSessionId] (the SAME guard the wire codec applies
* to server-issued ids), so a non-v4/garbage value reads back as null rather than
* being handed to the attach path (untrusted at rest). The [DataStore] is injected;
* behaviour is verified instrumented (androidTest) on a device.
*/
public class DataStoreLastSessionStore(
private val dataStore: DataStore<Preferences>,
) : LastSessionStore {
override suspend fun lastSessionId(hostId: String): String? {
val raw = dataStore.data.first()[keyFor(hostId)]
return raw?.takeIf(Validation::isValidSessionId)
}
override suspend fun setLastSessionId(sessionId: String?, hostId: String) {
val key = keyFor(hostId)
dataStore.edit { prefs ->
if (sessionId == null) prefs.remove(key) else prefs[key] = sessionId
}
}
private fun keyFor(hostId: String) = stringPreferencesKey(KEY_PREFIX + hostId)
private companion object {
const val KEY_PREFIX = "lastSessionId."
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.hostregistry
import wang.yaojia.webterm.wire.HostEndpoint
/**
* A paired web-terminal host (frozen contract, plan §3 :host-registry). Immutable
* snapshot mirroring iOS `HostRegistry.Host`: identity ([id]) and dial info
* ([endpoint]) are fixed at creation; "renaming" a host means upserting a NEW value
* with the same [id] (see the `upserting`/`removing` transforms in HostStore.kt).
*
* - [id] is a stable UUID string, allocated once when the host is first paired.
* - [endpoint] is the SINGLE point of Origin/WS derivation ([HostEndpoint], frozen
* in :wire-protocol) — never redeclared here. Persistence stores only the
* [HostEndpoint.baseUrl] string and reconstructs via [HostEndpoint.fromBaseUrl],
* re-validating on load (host records are untrusted at rest — see HostCodec).
* - [hasDeviceCert] is a UI flag (does this host have an imported mTLS client
* identity?) surfaced by the session-list host menu / cert screen.
*/
public data class Host(
val id: String,
val name: String,
val endpoint: HostEndpoint,
val hasDeviceCert: Boolean = false,
) {
public companion object {
/**
* Validating factory: reconstructs [endpoint] from an untrusted [baseUrl]
* (QR/manual entry, or a value read back from storage). Returns null when
* [baseUrl] is not a valid http(s) URL — the caller drops the record rather
* than trusting a malformed endpoint at rest.
*/
public fun create(
id: String,
name: String,
baseUrl: String,
hasDeviceCert: Boolean = false,
): Host? {
val endpoint = HostEndpoint.fromBaseUrl(baseUrl) ?: return null
return Host(id = id, name = name, endpoint = endpoint, hasDeviceCert = hasDeviceCert)
}
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* At-rest DTO for a [Host]. Only the raw dial string [baseUrl] is persisted — NOT
* the derived Origin/WS values — so a stored record can never smuggle a mismatched
* Origin past the CSWSH defence: [HostEndpoint][wang.yaojia.webterm.wire.HostEndpoint]
* is re-derived from [baseUrl] on load. `@Serializable` (the serialization compiler
* plugin generates the serializer; composes with AGP 9's built-in Kotlin).
*/
@Serializable
internal data class PersistedHost(
val id: String,
val name: String,
val baseUrl: String,
val hasDeviceCert: Boolean = false,
)
/**
* Pure JSON codec for the persisted host list (shared by [DataStoreHostStore],
* JVM-testable without a Context). Encode is total; decode is defensive — host
* records are untrusted at rest:
* - a corrupt/undecodable blob → empty list (start clean, never crash);
* - a record whose [PersistedHost.baseUrl] no longer validates → dropped
* (reconstructed via [Host.create]).
*/
internal object HostCodec {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
fun encode(hosts: List<Host>): String =
json.encodeToString(hosts.map { it.toPersisted() })
fun decode(raw: String?): List<Host> {
if (raw.isNullOrBlank()) return emptyList()
val persisted = try {
json.decodeFromString<List<PersistedHost>>(raw)
} catch (_: Exception) {
return emptyList() // corrupted blob at rest → start clean, never crash
}
// mapNotNull drops records whose baseUrl fails re-validation (untrusted at rest).
return persisted.mapNotNull { it.toHost() }
}
private fun Host.toPersisted(): PersistedHost =
PersistedHost(
id = id,
name = name,
baseUrl = endpoint.baseUrl,
hasDeviceCert = hasDeviceCert,
)
private fun PersistedHost.toHost(): Host? =
Host.create(id = id, name = name, baseUrl = baseUrl, hasDeviceCert = hasDeviceCert)
}

View File

@@ -0,0 +1,49 @@
package wang.yaojia.webterm.hostregistry
/**
* Frozen contract (plan §3 :host-registry). Implementations: [DataStoreHostStore]
* (real, Preferences-DataStore-backed) and [InMemoryHostStore] (in-Sources double
* for this module's contract tests AND the AW4 ViewModel tests).
*
* Immutable style throughout: mutations return the NEW collection instead of
* mutating shared state in place (mirrors the iOS `HostStore` protocol).
*/
public interface HostStore {
/** The full paired-host list, in stored (insertion) order. */
public suspend fun loadAll(): List<Host>
/**
* Insert, or replace the host with the same [Host.id] (position preserved).
* Returns the new collection.
*/
public suspend fun upsert(host: Host): List<Host>
/**
* Remove the host with [id]. Removing an unknown [id] is an explicit no-op:
* returns the unchanged collection, never throws for "not found".
*/
public suspend fun remove(id: String): List<Host>
}
// ── Pure collection transforms shared by all HostStore implementations (DRY) ─────
// Never mutate the receiver — always return a fresh list. Mirrors the iOS
// `extension [Host] { upserting / removing }`. `internal` so both stores and the
// same-module unit tests can use them without widening the public surface.
/**
* Insert [host], or replace the existing entry with the same [Host.id] IN PLACE
* (position preserved). Returns a new list; the receiver is untouched.
*/
internal fun List<Host>.upserting(host: Host): List<Host> =
if (any { it.id == host.id }) {
map { if (it.id == host.id) host else it }
} else {
this + host
}
/**
* Return a new list without the host whose id equals [id]. Unknown id → a copy
* with the same contents (no-op). The receiver is untouched.
*/
internal fun List<Host>.removing(id: String): List<Host> =
filter { it.id != id }

View File

@@ -0,0 +1,37 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [HostStore]. Lives in `main` (not `test`) on purpose — it doubles for
* the DataStore store in this module's contract tests AND in the AW4 ViewModel
* tests (mirrors the iOS `InMemoryHostStore` actor).
*
* A [Mutex] serializes the read-modify-write in [upsert]/[remove] so concurrent
* callers can't interleave; the state is a value snapshot replaced wholesale on
* every change (no in-place mutation of a shared reference).
*/
public class InMemoryHostStore(
initial: List<Host> = emptyList(),
) : HostStore {
private val mutex = Mutex()
// Defensive copy so an external mutable list handed to the ctor can't alias
// our state; replaced wholesale on every write.
private var hosts: List<Host> = initial.toList()
override suspend fun loadAll(): List<Host> = mutex.withLock { hosts }
override suspend fun upsert(host: Host): List<Host> = mutex.withLock {
val updated = hosts.upserting(host)
hosts = updated
updated
}
override suspend fun remove(id: String): List<Host> = mutex.withLock {
val updated = hosts.removing(id)
hosts = updated
updated
}
}

View File

@@ -0,0 +1,23 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [LastSessionStore] double (in `main` so the AW4 ViewModel/cold-start
* tests can inject it). A [Mutex] serializes writes; the per-host map is an
* immutable snapshot replaced wholesale on every change (never mutated in place).
*/
public class InMemoryLastSessionStore : LastSessionStore {
private val mutex = Mutex()
private var byHost: Map<String, String> = emptyMap()
override suspend fun lastSessionId(hostId: String): String? =
mutex.withLock { byHost[hostId] }
override suspend fun setLastSessionId(sessionId: String?, hostId: String) {
mutex.withLock {
byHost = if (sessionId == null) byHost - hostId else byHost + (hostId to sessionId)
}
}
}

View File

@@ -0,0 +1,28 @@
package wang.yaojia.webterm.hostregistry
/**
* Frozen contract (plan §3 / A29). Persists the last server-adopted sessionId PER
* HOST — NON-SECRET UI state only (anything sensitive belongs in the device-cert
* store). Mirrors the iOS `LastSessionStore` protocol, made `suspend` for DataStore.
*
* Lifecycle (driven by `SessionActivityBridge`, A29):
* - **set** on `.adopted` — [setLastSessionId] with the server-issued id;
* - **clear** on `.exited` — [setLastSessionId] with `null` (a dead session must
* not be offered as "continue last", else cold-start silently spawns a NEW one);
* - **get** for cold-start — [lastSessionId] feeds the "继续上次会话" banner
* (`ColdStartPolicy`).
*/
public interface LastSessionStore {
/**
* The last adopted sessionId for [hostId], or null (unknown host, cleared, or —
* for a persisted store — a value that no longer parses; untrusted at rest).
*/
public suspend fun lastSessionId(hostId: String): String?
/**
* Persist [sessionId] as the last adopted session for [hostId], or clear it when
* [sessionId] is null. Distinct hosts are keyed independently (no cross-host
* clobbering).
*/
public suspend fun setLastSessionId(sessionId: String?, hostId: String)
}

View File

@@ -0,0 +1,70 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* [HostCodec] is the pure at-rest (de)serializer the DataStore store delegates to.
* Host records are UNTRUSTED at rest: the codec must round-trip cleanly, re-derive
* the endpoint via [HostEndpoint.fromBaseUrl], drop records whose baseUrl no longer
* validates, and never crash on a corrupt blob.
*/
class HostCodecTest {
private fun host(id: String, name: String, baseUrl: String, hasCert: Boolean = false): Host =
Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl)), hasDeviceCert = hasCert)
@Test
fun `encode then decode round-trips id, name, endpoint and cert flag`() {
val hosts = listOf(
host("1", "mac", "http://192.168.1.5:3000", hasCert = true),
host("2", "tailnet", "https://mac.tailnet.ts.net"),
)
val restored = HostCodec.decode(HostCodec.encode(hosts))
assertEquals(hosts, restored)
// The endpoint is RE-DERIVED, not stored — verify the derivations survived.
assertEquals("wss://mac.tailnet.ts.net/term", restored[1].endpoint.wsUrl)
assertEquals(true, restored[0].hasDeviceCert)
}
@Test
fun `decode drops a record whose stored baseUrl no longer validates`() {
// A hand-written blob with one good and one malformed baseUrl (untrusted at rest).
val raw = """
[
{"id":"1","name":"good","baseUrl":"http://10.0.0.1:3000","hasDeviceCert":false},
{"id":"2","name":"bad","baseUrl":"not a url","hasDeviceCert":false}
]
""".trimIndent()
val restored = HostCodec.decode(raw)
assertEquals(1, restored.size)
assertEquals("1", restored.single().id)
}
@Test
fun `decode of a corrupt or blank blob yields an empty list, never throws`() {
assertTrue(HostCodec.decode(null).isEmpty())
assertTrue(HostCodec.decode("").isEmpty())
assertTrue(HostCodec.decode(" ").isEmpty())
assertTrue(HostCodec.decode("{not json").isEmpty())
}
@Test
fun `decode preserves stored order`() {
val hosts = listOf(
host("a", "first", "http://10.0.0.1:3000"),
host("b", "second", "http://10.0.0.2:3000"),
host("c", "third", "http://10.0.0.3:3000"),
)
val restored = HostCodec.decode(HostCodec.encode(hosts))
assertEquals(listOf("a", "b", "c"), restored.map { it.id })
}
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* The pure list transforms shared by every [HostStore] impl (DRY). They must be
* immutable: return a NEW list, never mutate the receiver — the invariant the whole
* store contract rests on. Mirrors the iOS `extension [Host]` behaviour.
*/
class HostStoreTransformsTest {
private fun host(id: String, name: String = "host-$id", baseUrl: String = "http://10.0.0.$id:3000"): Host =
Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl)))
@Test
fun `upserting a new id appends to the end`() {
// Arrange
val a = host("1")
val b = host("2")
// Act
val result = listOf(a).upserting(b)
// Assert
assertEquals(listOf(a, b), result)
}
@Test
fun `upserting an existing id replaces in place, preserving position`() {
// Arrange
val a = host("1")
val b = host("2")
val c = host("3")
val renamedB = host("2", name = "renamed")
// Act
val result = listOf(a, b, c).upserting(renamedB)
// Assert — same length, same order, middle element replaced
assertEquals(listOf(a, renamedB, c), result)
assertEquals("renamed", result[1].name)
}
@Test
fun `upserting does not mutate the original list`() {
// Arrange
val original = listOf(host("1"))
// Act
val result = original.upserting(host("2"))
// Assert — original untouched; a new instance was returned
assertEquals(1, original.size)
assertEquals(2, result.size)
assertNotSame(original, result)
}
@Test
fun `removing an existing id drops exactly that entry`() {
// Arrange
val a = host("1")
val b = host("2")
// Act
val result = listOf(a, b).removing("1")
// Assert
assertEquals(listOf(b), result)
}
@Test
fun `removing an unknown id is a no-op returning the same contents`() {
// Arrange
val original = listOf(host("1"), host("2"))
// Act
val result = original.removing("nope")
// Assert — contents unchanged, original list not mutated
assertEquals(original, result)
assertEquals(2, original.size)
}
}

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* Contract tests for the in-Sources [InMemoryHostStore] double (which the AW4 VM
* tests reuse). Same behaviour the DataStore store must exhibit; verified here at
* JVM speed under virtual time.
*/
class InMemoryHostStoreTest {
private fun host(id: String, name: String = "host-$id"): Host =
Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.1:3000")))
@Test
fun `loadAll returns the seeded hosts`() = runTest {
val a = host("1")
val store = InMemoryHostStore(listOf(a))
assertEquals(listOf(a), store.loadAll())
}
@Test
fun `upsert inserts then replaces same-id, and loadAll reflects it`() = runTest {
val store = InMemoryHostStore()
val a = host("1")
assertEquals(listOf(a), store.upsert(a))
// replace with same id
val renamed = host("1", name = "renamed")
assertEquals(listOf(renamed), store.upsert(renamed))
assertEquals(listOf(renamed), store.loadAll())
}
@Test
fun `remove drops the host, unknown id is a no-op`() = runTest {
val a = host("1")
val b = host("2")
val store = InMemoryHostStore(listOf(a, b))
assertEquals(listOf(a, b), store.remove("nope"))
assertEquals(listOf(a), store.remove("2"))
assertEquals(listOf(a), store.loadAll())
}
@Test
fun `a list passed to the ctor is defensively copied`() = runTest {
val seed = mutableListOf(host("1"))
val store = InMemoryHostStore(seed)
// Mutating the caller's list must not leak into the store.
seed.add(host("2"))
assertEquals(1, store.loadAll().size)
}
}

View File

@@ -0,0 +1,50 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* Contract tests for the in-Sources [InMemoryLastSessionStore] double (reused by the
* A29 cold-start / SessionActivityBridge tests). Mirrors the iOS
* `LastSessionStoreTests`: set→get, unknown→null, set(null)→clear, host isolation.
*/
class InMemoryLastSessionStoreTest {
@Test
fun `set then get returns the same sessionId`() = runTest {
val store = InMemoryLastSessionStore()
store.setLastSessionId("session-1", hostId = "host-1")
assertEquals("session-1", store.lastSessionId("host-1"))
}
@Test
fun `an unset host returns null`() = runTest {
val store = InMemoryLastSessionStore()
assertNull(store.lastSessionId("host-1"))
}
@Test
fun `setting null clears an existing sessionId`() = runTest {
val store = InMemoryLastSessionStore()
store.setLastSessionId("session-1", hostId = "host-1")
store.setLastSessionId(null, hostId = "host-1")
assertNull(store.lastSessionId("host-1"))
}
@Test
fun `distinct hosts are keyed independently`() = runTest {
val store = InMemoryLastSessionStore()
store.setLastSessionId("session-a", hostId = "host-a")
store.setLastSessionId("session-b", hostId = "host-b")
assertEquals("session-a", store.lastSessionId("host-a"))
assertEquals("session-b", store.lastSessionId("host-b"))
}
}