feat(watch): replace CaseStoreStub with disk-backed CaseStore
Per-case JSON markers under filesDir/recordings/cases/, atomic tmp+rename writes, kotlinx.coroutines Mutex serialising mutations. Wire format mirrors doctate-client-core::CaseMarker exactly (snake_case keys, RFC3339 strings, internally-tagged OnelinerState) — markers round-trip with the desktop client. mergeServerSnapshot adds/updates only; reconcileWithServerSnapshot removes synced markers inside the response window that the server stopped reporting. Pending markers are never touched. Local Manual oneliners stick against non-Manual server overrides. DoctateApp now exposes caseStore as a lazy property and wires a distinct-head flow collector to TileService.requestUpdate, decoupling the store from Android Tile APIs (testable on the JVM). JVM test infra: real org.json:json:20240303 plus testOptions.unitTests.isReturnDefaultValues=true to bypass android.jar 'not mocked' stubs for Log/JSONObject. 35 unit tests green incl. 13 new DiskCaseStoreTest cases (bootstrap, merge/reconcile semantics, mutex ordering).
This commit is contained in:
@@ -64,6 +64,12 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
testOptions {
|
||||
// Stub Android-framework methods (Log.i, etc.) return defaults instead of
|
||||
// throwing "not mocked" — required for JVM unit tests of code that uses
|
||||
// android.util.Log alongside core java APIs.
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -100,6 +106,8 @@ dependencies {
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.truth)
|
||||
// Real org.json so JVM unit tests don't hit the "not mocked" stub from android.jar.
|
||||
testImplementation("org.json:json:20240303")
|
||||
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package com.doctate.watch
|
||||
|
||||
import android.app.Application
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import android.util.Log
|
||||
import androidx.wear.tiles.TileService
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.DiskCaseStore
|
||||
import com.doctate.watch.net.HttpClientProvider
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.settings.BuildConfigSettings
|
||||
import com.doctate.watch.settings.Settings
|
||||
import com.doctate.watch.tile.DoctateTileService
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
@@ -20,6 +30,11 @@ class DoctateApp : Application() {
|
||||
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
|
||||
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
|
||||
|
||||
/** Persistent disk-backed marker store. Single source of truth for the case list. */
|
||||
val caseStore: CaseStore by lazy {
|
||||
DiskCaseStore(File(filesDir, "recordings/cases"))
|
||||
}
|
||||
|
||||
/** Process-scoped coroutine scope for work that must outlive any single ViewModel
|
||||
* (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
|
||||
val applicationScope: CoroutineScope by lazy {
|
||||
@@ -28,7 +43,20 @@ class DoctateApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CaseStoreStub.attach(this)
|
||||
CaseStoreStub.seedDemoData()
|
||||
// Trigger a Tile refresh whenever the rendered head of the case list
|
||||
// changes — Tile shows currentCase, only the head matters.
|
||||
caseStore.casesFlow
|
||||
.map { it.firstOrNull()?.let { e -> Triple(e.caseId, e.lastActivityAt, e.oneliner) } }
|
||||
.distinctUntilChanged()
|
||||
.drop(1) // initial emission is the bootstrap snapshot, no refresh needed
|
||||
.onEach {
|
||||
Log.i(TAG, "tile refresh requested: head changed")
|
||||
TileService.getUpdater(this).requestUpdate(DoctateTileService::class.java)
|
||||
}
|
||||
.launchIn(applicationScope)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DoctateApp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* On-disk representation of a single case. Wire-format identical to
|
||||
* `doctate-client-core::CaseMarker` (Rust) — same field names (snake_case),
|
||||
* same RFC3339 timestamps, same internally-tagged OnelinerState. A marker
|
||||
* file written by either client should round-trip through the other.
|
||||
*
|
||||
* Kept disjoint from [CaseEntry] so the in-memory UI representation can
|
||||
* use Long epoch-ms (faster to format) without dragging the wire format
|
||||
* along. Conversion happens in [DiskCaseStore] at the IO boundary.
|
||||
*/
|
||||
internal data class CaseMarker(
|
||||
val caseId: String,
|
||||
val createdAtRfc: String,
|
||||
val lastActivityAtRfc: String,
|
||||
val syncedToServer: Boolean,
|
||||
val oneliner: OnelinerState?,
|
||||
) {
|
||||
fun toJson(): String = JSONObject().apply {
|
||||
put("case_id", caseId)
|
||||
put("created_at", createdAtRfc)
|
||||
put("last_activity_at", lastActivityAtRfc)
|
||||
put("synced_to_server", syncedToServer)
|
||||
put("oneliner", oneliner?.let { onelinerToJson(it) } ?: JSONObject.NULL)
|
||||
}.toString(2)
|
||||
|
||||
fun toEntry(): CaseEntry = CaseEntry(
|
||||
caseId = caseId,
|
||||
createdAt = Instant.parse(createdAtRfc).toEpochMilli(),
|
||||
lastActivityAt = Instant.parse(lastActivityAtRfc).toEpochMilli(),
|
||||
oneliner = oneliner,
|
||||
syncedToServer = syncedToServer,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromJson(text: String): CaseMarker {
|
||||
val obj = JSONObject(text)
|
||||
return CaseMarker(
|
||||
caseId = obj.getString("case_id"),
|
||||
createdAtRfc = obj.getString("created_at"),
|
||||
lastActivityAtRfc = obj.getString("last_activity_at"),
|
||||
syncedToServer = obj.optBoolean("synced_to_server", false),
|
||||
oneliner = obj.optJSONObject("oneliner")?.let { onelinerFromJson(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode `OnelinerState` as the same internally-tagged JSON the server emits. */
|
||||
internal fun onelinerToJson(state: OnelinerState): JSONObject = when (state) {
|
||||
is OnelinerState.Ready -> JSONObject()
|
||||
.put("kind", "ready")
|
||||
.put("text", state.text)
|
||||
.put("generated_at", state.generatedAt)
|
||||
is OnelinerState.Empty -> JSONObject()
|
||||
.put("kind", "empty")
|
||||
.put("generated_at", state.generatedAt)
|
||||
is OnelinerState.Error -> JSONObject()
|
||||
.put("kind", "error")
|
||||
.put("generated_at", state.generatedAt)
|
||||
is OnelinerState.Manual -> JSONObject()
|
||||
.put("kind", "manual")
|
||||
.put("text", state.text)
|
||||
.put("set_at", state.setAt)
|
||||
}
|
||||
|
||||
/** Decode an internally-tagged `OnelinerState`. Unknown kinds raise IllegalArgumentException. */
|
||||
internal fun onelinerFromJson(obj: JSONObject): OnelinerState =
|
||||
when (val kind = obj.getString("kind")) {
|
||||
"ready" -> OnelinerState.Ready(
|
||||
text = obj.getString("text"),
|
||||
generatedAt = obj.getString("generated_at"),
|
||||
)
|
||||
"empty" -> OnelinerState.Empty(generatedAt = obj.getString("generated_at"))
|
||||
"error" -> OnelinerState.Error(generatedAt = obj.getString("generated_at"))
|
||||
"manual" -> OnelinerState.Manual(
|
||||
text = obj.getString("text"),
|
||||
setAt = obj.getString("set_at"),
|
||||
)
|
||||
else -> throw IllegalArgumentException("unknown OnelinerState kind: $kind")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Per-case marker store. Single source of truth for the case list shown in
|
||||
* Activity, Tile and Complication. Mirrors `doctate-client-core::CaseStore`
|
||||
* (Rust) — the asymmetric merge/reconcile design is the load-bearing piece:
|
||||
*
|
||||
* - [mergeServerSnapshot] only adds/updates. `/api/oneliners` is a sliding
|
||||
* window, so "missing" means nothing on its own.
|
||||
* - [reconcileWithServerSnapshot] removes synced markers the server no
|
||||
* longer reports, scoped to the window it covers. Pending markers are
|
||||
* never touched — losing one would silently erase a recording.
|
||||
*/
|
||||
interface CaseStore {
|
||||
/** Reactive snapshot, sorted by `lastActivityAt` desc. */
|
||||
val casesFlow: StateFlow<List<CaseEntry>>
|
||||
|
||||
/** Snapshot accessor for non-suspending contexts (Tile, Complication). */
|
||||
val currentCase: CaseEntry? get() = casesFlow.value.firstOrNull()
|
||||
|
||||
/** Append a new case the client itself just started. Returns the new id. */
|
||||
suspend fun createLocal(now: Long = System.currentTimeMillis()): String
|
||||
|
||||
/** Bump `lastActivityAt`. Creates the marker if missing. */
|
||||
suspend fun markActivity(caseId: String, now: Long = System.currentTimeMillis())
|
||||
|
||||
/** Flip `syncedToServer` to true after a successful upload ACK. No-op if absent. */
|
||||
suspend fun markSynced(caseId: String)
|
||||
|
||||
/** Apply a doctor's manual oneliner override locally (optimistic UI). */
|
||||
suspend fun setOnelinerLocal(caseId: String, state: OnelinerState)
|
||||
|
||||
/**
|
||||
* Apply a server `/api/oneliners` response: insert/update each entry,
|
||||
* never delete. Server's oneliner + timestamps win, except
|
||||
* `lastActivityAt` keeps whichever (local vs. server) is newer, and a
|
||||
* locally-set `Manual` oneliner sticks against non-Manual server states.
|
||||
*/
|
||||
suspend fun mergeServerSnapshot(response: OnelinersResponse)
|
||||
|
||||
/**
|
||||
* Remove synced markers inside the response's window that the server
|
||||
* no longer lists. Returns count removed. Pending markers and markers
|
||||
* outside the window are preserved.
|
||||
*/
|
||||
suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int
|
||||
|
||||
/** Drop synced markers older than `cutoffMs`. Returns count removed. */
|
||||
suspend fun cleanupStale(cutoffMs: Long): Int
|
||||
|
||||
/** Wipe everything (config change, account switch). */
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.wear.tiles.TileService
|
||||
import com.doctate.watch.tile.DoctateTileService
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* In-memory stand-in for a future Kotlin port of the Rust `CaseStore`.
|
||||
*
|
||||
* All three Wear OS surfaces (Activity list, Tile, Complication) read from
|
||||
* this singleton so they never diverge. The store is intentionally flat —
|
||||
* no disk persistence, no server merge, no sync flags. When the process
|
||||
* dies the state is gone; the PoC seeds a few fake cases on app start to
|
||||
* keep the UI populated.
|
||||
*
|
||||
* Invariant: `_cases.value` is always sorted by `lastActivityAt` desc, so
|
||||
* `value.firstOrNull()` is the "current case" for the Tile.
|
||||
*
|
||||
* The [appContext] must be set via [attach] from [com.doctate.watch.DoctateApp.onCreate]
|
||||
* so every mutation can trigger a Tile refresh. When context is null (e.g. JVM unit
|
||||
* tests), the refresh step is silently skipped.
|
||||
*/
|
||||
object CaseStoreStub {
|
||||
private const val TAG = "CaseStoreStub"
|
||||
|
||||
private val _cases = MutableStateFlow<List<CaseEntry>>(emptyList())
|
||||
val cases: StateFlow<List<CaseEntry>> = _cases.asStateFlow()
|
||||
|
||||
/** Snapshot accessor for non-suspending contexts (e.g. `TileService.onTileRequest`). */
|
||||
val currentCase: CaseEntry? get() = _cases.value.firstOrNull()
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun attach(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun createLocal(now: Long = System.currentTimeMillis()): String {
|
||||
val id = CaseId.new()
|
||||
val entry = CaseEntry(
|
||||
caseId = id,
|
||||
createdAt = now,
|
||||
lastActivityAt = now,
|
||||
oneliner = null,
|
||||
)
|
||||
_cases.update { listOf(entry) + it }
|
||||
requestTileRefresh("createLocal id=${id.take(8)}")
|
||||
return id
|
||||
}
|
||||
|
||||
fun markActivity(caseId: String, now: Long = System.currentTimeMillis()) {
|
||||
_cases.update { current ->
|
||||
val idx = current.indexOfFirst { it.caseId == caseId }
|
||||
if (idx < 0) return@update current
|
||||
val updated = current[idx].copy(lastActivityAt = now)
|
||||
(listOf(updated) + current.filterIndexed { i, _ -> i != idx })
|
||||
}
|
||||
requestTileRefresh("markActivity id=${caseId.take(8)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a case's oneliner. A previously-set Manual override sticks against
|
||||
* any non-Manual update — the doctor's text wins until they overwrite it
|
||||
* with another Manual.
|
||||
*/
|
||||
fun setOneliner(caseId: String, state: OnelinerState) {
|
||||
_cases.update { current ->
|
||||
current.map {
|
||||
when {
|
||||
it.caseId != caseId -> it
|
||||
it.oneliner is OnelinerState.Manual && state !is OnelinerState.Manual -> it
|
||||
else -> it.copy(oneliner = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
requestTileRefresh("setOneliner id=${caseId.take(8)} kind=${state::class.simpleName}")
|
||||
}
|
||||
|
||||
fun seedDemoData(now: Long = System.currentTimeMillis()) {
|
||||
if (_cases.value.isNotEmpty()) return
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = LocalDate.now(zone)
|
||||
fun at(date: LocalDate, hour: Int, minute: Int): Long =
|
||||
date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
|
||||
fun minutesAgo(m: Long): Long = now - m * 60_000L
|
||||
fun ready(text: String): OnelinerState = OnelinerState.Ready(text, Instant.now().toString())
|
||||
|
||||
// Covers all label variants: "Vor 1 Minute" / "Vor X Minuten" / HH:mm /
|
||||
// "Gestern - HH:mm" / "dd.MM.yy - HH:mm". `sortedByDescending` keeps the
|
||||
// store invariant (newest first) intact regardless of the current time of day.
|
||||
val entries = listOf(
|
||||
CaseEntry(CaseId.new(), minutesAgo(1), minutesAgo(1), ready("Anamnese Herr Weber")),
|
||||
CaseEntry(CaseId.new(), minutesAgo(32), minutesAgo(32), ready("Kniegelenksarthroskopie rechts")),
|
||||
CaseEntry(CaseId.new(), at(today, 8, 15), at(today, 8, 15), ready("Befund Thorax unauffällig")),
|
||||
CaseEntry(CaseId.new(), at(today.minusDays(1), 16, 40), at(today.minusDays(1), 16, 40), ready("Postoperative Visite Zimmer 12")),
|
||||
CaseEntry(CaseId.new(), at(today.minusDays(3), 11, 20), at(today.minusDays(3), 11, 20), ready("MRT Schulter links")),
|
||||
CaseEntry(CaseId.new(), at(today.minusDays(8), 9, 5), at(today.minusDays(8), 9, 5), ready("Laborkontrolle Diabetes")),
|
||||
)
|
||||
_cases.value = entries.sortedByDescending { it.lastActivityAt }
|
||||
requestTileRefresh("seedDemoData")
|
||||
}
|
||||
|
||||
private fun requestTileRefresh(reason: String) {
|
||||
val ctx = appContext ?: return
|
||||
Log.i(TAG, "requestTileRefresh: $reason")
|
||||
TileService.getUpdater(ctx).requestUpdate(DoctateTileService::class.java)
|
||||
}
|
||||
|
||||
/** Test-only: wipe state between tests. `object` singletons leak across tests otherwise. */
|
||||
internal fun resetForTest() {
|
||||
_cases.value = emptyList()
|
||||
appContext = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Disk-backed [CaseStore]. One JSON file per case under [dir]; in-memory
|
||||
* map serialised by [mutex] so concurrent merges from the sync worker and
|
||||
* the UI can't collide on a write.
|
||||
*
|
||||
* Atomic writes via temp-file + `Files.move(... ATOMIC_MOVE, REPLACE_EXISTING)`
|
||||
* — same trick as `case_store.rs::write_marker_file`. Readers either see
|
||||
* the previous version or the new one, never a partial blob.
|
||||
*
|
||||
* Bootstrap is synchronous (constructor body): the watch can render the
|
||||
* cached list before any coroutine has run. Empty/missing dir is fine —
|
||||
* the store starts empty and creates the dir lazily on first write.
|
||||
*/
|
||||
class DiskCaseStore(private val dir: File) : CaseStore {
|
||||
|
||||
private val state = LinkedHashMap<String, CaseMarker>()
|
||||
private val mutex = Mutex()
|
||||
private val _flow = MutableStateFlow<List<CaseEntry>>(emptyList())
|
||||
|
||||
override val casesFlow: StateFlow<List<CaseEntry>> = _flow.asStateFlow()
|
||||
|
||||
init {
|
||||
bootstrap()
|
||||
}
|
||||
|
||||
private fun bootstrap() {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
return
|
||||
}
|
||||
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
|
||||
for (f in files) {
|
||||
try {
|
||||
val marker = CaseMarker.fromJson(f.readText(Charsets.UTF_8))
|
||||
state[marker.caseId] = marker
|
||||
} catch (e: Exception) {
|
||||
// A single unreadable file shouldn't poison the store.
|
||||
Log.w(TAG, "skipping unreadable marker ${f.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
broadcast()
|
||||
}
|
||||
|
||||
override suspend fun createLocal(now: Long): String {
|
||||
val id = CaseId.new()
|
||||
val nowRfc = epochMsToRfc(now)
|
||||
val marker = CaseMarker(
|
||||
caseId = id,
|
||||
createdAtRfc = nowRfc,
|
||||
lastActivityAtRfc = nowRfc,
|
||||
syncedToServer = false,
|
||||
oneliner = null,
|
||||
)
|
||||
mutex.withLock {
|
||||
writeMarkerFile(marker)
|
||||
state[id] = marker
|
||||
broadcast()
|
||||
}
|
||||
Log.i(TAG, "createLocal id=${id.take(8)}")
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun markActivity(caseId: String, now: Long) {
|
||||
val nowRfc = epochMsToRfc(now)
|
||||
mutex.withLock {
|
||||
val existing = state[caseId]
|
||||
val updated = existing?.copy(lastActivityAtRfc = nowRfc)
|
||||
?: CaseMarker(
|
||||
caseId = caseId,
|
||||
createdAtRfc = nowRfc,
|
||||
lastActivityAtRfc = nowRfc,
|
||||
syncedToServer = false,
|
||||
oneliner = null,
|
||||
)
|
||||
writeMarkerFile(updated)
|
||||
state[caseId] = updated
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markSynced(caseId: String) {
|
||||
mutex.withLock {
|
||||
val existing = state[caseId] ?: return
|
||||
if (existing.syncedToServer) return
|
||||
val updated = existing.copy(syncedToServer = true)
|
||||
writeMarkerFile(updated)
|
||||
state[caseId] = updated
|
||||
broadcast()
|
||||
}
|
||||
Log.i(TAG, "markSynced id=${caseId.take(8)}")
|
||||
}
|
||||
|
||||
override suspend fun setOnelinerLocal(caseId: String, state: OnelinerState) {
|
||||
mutex.withLock {
|
||||
val existing = this.state[caseId] ?: return
|
||||
val updated = existing.copy(oneliner = state)
|
||||
writeMarkerFile(updated)
|
||||
this.state[caseId] = updated
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mergeServerSnapshot(response: OnelinersResponse) {
|
||||
mutex.withLock {
|
||||
for (entry in response.oneliners) {
|
||||
val serverActivityRfc = entry.lastRecordingAt
|
||||
?: entry.updatedAt
|
||||
?: entry.createdAt
|
||||
|
||||
val existing = state[entry.caseId]
|
||||
val updated = if (existing != null) {
|
||||
// A locally-set Manual oneliner sticks against non-Manual server overrides.
|
||||
val keepLocalManual = existing.oneliner is OnelinerState.Manual &&
|
||||
entry.oneliner !is OnelinerState.Manual
|
||||
existing.copy(
|
||||
syncedToServer = true,
|
||||
oneliner = if (keepLocalManual) existing.oneliner else entry.oneliner,
|
||||
lastActivityAtRfc = if (serverActivityRfc > existing.lastActivityAtRfc) {
|
||||
serverActivityRfc
|
||||
} else existing.lastActivityAtRfc,
|
||||
)
|
||||
} else {
|
||||
CaseMarker(
|
||||
caseId = entry.caseId,
|
||||
createdAtRfc = entry.createdAt,
|
||||
lastActivityAtRfc = serverActivityRfc,
|
||||
syncedToServer = true,
|
||||
oneliner = entry.oneliner,
|
||||
)
|
||||
}
|
||||
writeMarkerFile(updated)
|
||||
state[entry.caseId] = updated
|
||||
}
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int {
|
||||
// Window anchor: as_of - window_hours, lexicographically comparable to RFC3339.
|
||||
val asOfMs = Instant.parse(response.asOf).toEpochMilli()
|
||||
val windowStartMs = asOfMs - response.windowHours * 3_600_000L
|
||||
val windowStartRfc = epochMsToRfc(windowStartMs)
|
||||
val serverIds = response.oneliners.map { it.caseId }.toHashSet()
|
||||
|
||||
var removed = 0
|
||||
mutex.withLock {
|
||||
val toRemove = state.values.filter { m ->
|
||||
m.syncedToServer &&
|
||||
m.lastActivityAtRfc >= windowStartRfc &&
|
||||
m.caseId !in serverIds
|
||||
}.map { it.caseId }
|
||||
for (id in toRemove) {
|
||||
deleteMarkerFile(id)
|
||||
state.remove(id)
|
||||
Log.i(TAG, "marker removed (reconcile: missing from server snapshot) id=${id.take(8)}")
|
||||
removed++
|
||||
}
|
||||
if (removed > 0) broadcast()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
override suspend fun cleanupStale(cutoffMs: Long): Int {
|
||||
val cutoffRfc = epochMsToRfc(cutoffMs)
|
||||
var removed = 0
|
||||
mutex.withLock {
|
||||
val toRemove = state.values.filter { m ->
|
||||
m.syncedToServer && m.lastActivityAtRfc < cutoffRfc
|
||||
}.map { it.caseId }
|
||||
for (id in toRemove) {
|
||||
deleteMarkerFile(id)
|
||||
state.remove(id)
|
||||
Log.i(TAG, "stale marker removed id=${id.take(8)} cutoff=$cutoffRfc")
|
||||
removed++
|
||||
}
|
||||
if (removed > 0) broadcast()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
override suspend fun clearAll() {
|
||||
mutex.withLock {
|
||||
val files = dir.listFiles { f -> f.name.endsWith(".json") } ?: emptyArray()
|
||||
for (f in files) {
|
||||
if (!f.delete()) Log.w(TAG, "failed to delete marker ${f.name} during clearAll")
|
||||
}
|
||||
state.clear()
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcast() {
|
||||
// Sort newest-first so .firstOrNull() yields the "current case".
|
||||
val list = state.values
|
||||
.map { it.toEntry() }
|
||||
.sortedByDescending { it.lastActivityAt }
|
||||
_flow.value = list
|
||||
}
|
||||
|
||||
private fun writeMarkerFile(marker: CaseMarker) {
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val finalPath = File(dir, "${marker.caseId}.json")
|
||||
val tmpPath = File(dir, "${marker.caseId}.json.tmp")
|
||||
tmpPath.writeText(marker.toJson(), Charsets.UTF_8)
|
||||
Files.move(
|
||||
tmpPath.toPath(),
|
||||
finalPath.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteMarkerFile(caseId: String) {
|
||||
val f = File(dir, "$caseId.json")
|
||||
if (f.exists() && !f.delete()) {
|
||||
Log.w(TAG, "failed to delete marker $caseId.json")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DiskCaseStore"
|
||||
|
||||
/** Epoch-ms → RFC3339 UTC string with `Z` suffix (no offset). */
|
||||
fun epochMsToRfc(ms: Long): String = Instant.ofEpochMilli(ms).toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
/**
|
||||
* Wire-format DTOs for `GET /api/oneliners`. Mirror
|
||||
* `doctate-common/src/oneliners.rs::OnelinersResponse` and `OnelinerEntry`
|
||||
* exactly; the `OnelinersClient` (Phase 6) parses JSON into these.
|
||||
*
|
||||
* Timestamps stay RFC3339 strings — lexicographic comparison on the Zulu
|
||||
* format is correctly ordered, which is the same trick the Rust client uses
|
||||
* for window-anchored reconciliation.
|
||||
*/
|
||||
data class OnelinersResponse(
|
||||
val asOf: String,
|
||||
val windowHours: Int,
|
||||
val oneliners: List<OnelinerEntry>,
|
||||
)
|
||||
|
||||
data class OnelinerEntry(
|
||||
val caseId: String,
|
||||
val oneliner: OnelinerState?,
|
||||
val createdAt: String,
|
||||
val lastRecordingAt: String?,
|
||||
val updatedAt: String?,
|
||||
)
|
||||
@@ -3,6 +3,8 @@ package com.doctate.watch.presentation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
@@ -10,9 +12,10 @@ import androidx.wear.compose.material3.AppScaffold
|
||||
import androidx.wear.compose.navigation.SwipeDismissableNavHost
|
||||
import androidx.wear.compose.navigation.composable
|
||||
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.presentation.theme.DoctateWatchTheme
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object Routes {
|
||||
const val LIST = "list"
|
||||
@@ -26,6 +29,8 @@ object Routes {
|
||||
fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
|
||||
val navController = rememberSwipeDismissableNavController()
|
||||
val pending by pendingCommand.collectAsStateWithLifecycle()
|
||||
val app = LocalContext.current.applicationContext as DoctateApp
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// Key on seq so repeated identical commands still fire.
|
||||
LaunchedEffect(pending.seq) {
|
||||
@@ -36,7 +41,7 @@ fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
|
||||
navController.popBackStack(Routes.LIST, inclusive = false)
|
||||
}
|
||||
NavCommand.NewCase -> {
|
||||
val id = CaseStoreStub.createLocal()
|
||||
val id = app.caseStore.createLocal()
|
||||
navController.navigate(Routes.recording(id))
|
||||
}
|
||||
is NavCommand.ContinueCase -> {
|
||||
@@ -57,8 +62,10 @@ fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
|
||||
navController.navigate(Routes.detail(caseId))
|
||||
},
|
||||
onNewCase = {
|
||||
val id = CaseStoreStub.createLocal()
|
||||
navController.navigate(Routes.recording(id))
|
||||
coroutineScope.launch {
|
||||
val id = app.caseStore.createLocal()
|
||||
navController.navigate(Routes.recording(id))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -31,10 +33,11 @@ import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.Text
|
||||
import androidx.wear.input.RemoteInputIntentHelper
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.domain.OnelinerState
|
||||
import com.doctate.watch.domain.displayText
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -55,8 +58,10 @@ fun CaseDetailScreen(
|
||||
caseId: String,
|
||||
onRecord: () -> Unit,
|
||||
) {
|
||||
val cases by CaseStoreStub.cases.collectAsStateWithLifecycle()
|
||||
val app = LocalContext.current.applicationContext as DoctateApp
|
||||
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
|
||||
val case = cases.firstOrNull { it.caseId == caseId }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val editLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
@@ -69,10 +74,12 @@ fun CaseDetailScreen(
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
CaseStoreStub.setOneliner(
|
||||
caseId,
|
||||
OnelinerState.Manual(text, setAt = Instant.now().toString()),
|
||||
)
|
||||
coroutineScope.launch {
|
||||
app.caseStore.setOnelinerLocal(
|
||||
caseId,
|
||||
OnelinerState.Manual(text, setAt = Instant.now().toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,8 +26,8 @@ import androidx.wear.compose.material3.EdgeButton
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.domain.CaseEntry
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import com.doctate.watch.domain.displayText
|
||||
|
||||
@Composable
|
||||
@@ -34,7 +35,8 @@ fun CaseListScreen(
|
||||
onCaseTap: (caseId: String) -> Unit,
|
||||
onNewCase: () -> Unit,
|
||||
) {
|
||||
val cases by CaseStoreStub.cases.collectAsStateWithLifecycle()
|
||||
val app = LocalContext.current.applicationContext as DoctateApp
|
||||
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
|
||||
val listState = rememberScalingLazyListState()
|
||||
|
||||
// Display oldest-first so the newest entry sits next to the `Neu` EdgeButton.
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.audio.AudioRecorder
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.OnelinerState
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.net.UploadResult
|
||||
@@ -32,6 +32,7 @@ class RecordingViewModel(
|
||||
private val caseId: String,
|
||||
private val recorder: AudioRecorder,
|
||||
private val uploadClient: UploadClient,
|
||||
private val caseStore: CaseStore,
|
||||
private val applicationScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -128,7 +129,8 @@ class RecordingViewModel(
|
||||
try {
|
||||
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
|
||||
is UploadResult.Success -> {
|
||||
CaseStoreStub.markActivity(caseId)
|
||||
caseStore.markActivity(caseId)
|
||||
caseStore.markSynced(caseId)
|
||||
triggerFakeOnelinerBurst(caseId)
|
||||
dispatch(RecordingEvent.UploadSucceeded(caseId))
|
||||
}
|
||||
@@ -155,7 +157,7 @@ class RecordingViewModel(
|
||||
applicationScope.launch {
|
||||
delay(2_000)
|
||||
val label = fakeLabelFormat.format(Date())
|
||||
CaseStoreStub.setOneliner(
|
||||
caseStore.setOnelinerLocal(
|
||||
caseId,
|
||||
OnelinerState.Ready("[PoC] OneLiner $label", Instant.now().toString()),
|
||||
)
|
||||
@@ -178,6 +180,7 @@ class RecordingViewModel(
|
||||
caseId = caseId,
|
||||
recorder = AudioRecorder(app),
|
||||
uploadClient = app.uploadClient,
|
||||
caseStore = app.caseStore,
|
||||
applicationScope = app.applicationScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,22 +6,25 @@ import androidx.wear.protolayout.TimelineBuilders
|
||||
import androidx.wear.tiles.RequestBuilders
|
||||
import androidx.wear.tiles.TileBuilders
|
||||
import androidx.wear.tiles.TileService
|
||||
import com.doctate.watch.domain.CaseStoreStub
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
private const val RESOURCES_VERSION = "1"
|
||||
|
||||
/**
|
||||
* Serves the single Doctate Tile. Reads a non-blocking snapshot from [CaseStoreStub]
|
||||
* and renders the "glance at current case" view with three tap regions. Refreshes
|
||||
* are triggered externally via `TileService.getUpdater(ctx).requestUpdate(...)`.
|
||||
* Serves the single Doctate Tile. Reads a non-blocking snapshot from
|
||||
* [DoctateApp.caseStore] and renders the "glance at current case" view with
|
||||
* three tap regions. Refreshes are triggered externally via
|
||||
* `TileService.getUpdater(ctx).requestUpdate(...)` — the App wires that up
|
||||
* to the case-store flow.
|
||||
*/
|
||||
class DoctateTileService : TileService() {
|
||||
override fun onTileRequest(
|
||||
requestParams: RequestBuilders.TileRequest,
|
||||
): ListenableFuture<TileBuilders.Tile> {
|
||||
val current = CaseStoreStub.currentCase
|
||||
val app = applicationContext as DoctateApp
|
||||
val current = app.caseStore.currentCase
|
||||
val layout = LayoutElementBuilders.Layout.Builder()
|
||||
.setRoot(buildTileLayout(packageName, current))
|
||||
.build()
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.time.Instant
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CaseStoreStubTest {
|
||||
@Before fun setUp() {
|
||||
CaseStoreStub.resetForTest()
|
||||
}
|
||||
|
||||
@Test fun createLocal_inserts_entry_at_head() {
|
||||
val id = CaseStoreStub.createLocal(now = 1_000L)
|
||||
val snapshot = CaseStoreStub.cases.value
|
||||
assertThat(snapshot).hasSize(1)
|
||||
assertThat(snapshot[0].caseId).isEqualTo(id)
|
||||
assertThat(snapshot[0].oneliner).isNull()
|
||||
assertThat(snapshot[0].syncedToServer).isFalse()
|
||||
}
|
||||
|
||||
@Test fun createLocal_returns_new_case_at_head_of_list() {
|
||||
val first = CaseStoreStub.createLocal(now = 1_000L)
|
||||
val second = CaseStoreStub.createLocal(now = 2_000L)
|
||||
val snapshot = CaseStoreStub.cases.value
|
||||
assertThat(snapshot.map { it.caseId }).containsExactly(second, first).inOrder()
|
||||
}
|
||||
|
||||
@Test fun markActivity_brings_older_case_to_front() {
|
||||
val first = CaseStoreStub.createLocal(now = 1_000L)
|
||||
val second = CaseStoreStub.createLocal(now = 2_000L)
|
||||
CaseStoreStub.markActivity(first, now = 3_000L)
|
||||
val snapshot = CaseStoreStub.cases.value
|
||||
assertThat(snapshot.map { it.caseId }).containsExactly(first, second).inOrder()
|
||||
assertThat(snapshot[0].lastActivityAt).isEqualTo(3_000L)
|
||||
}
|
||||
|
||||
@Test fun markActivity_ignores_unknown_caseId() {
|
||||
val id = CaseStoreStub.createLocal(now = 1_000L)
|
||||
CaseStoreStub.markActivity("does-not-exist", now = 9_000L)
|
||||
val snapshot = CaseStoreStub.cases.value
|
||||
assertThat(snapshot).hasSize(1)
|
||||
assertThat(snapshot[0].caseId).isEqualTo(id)
|
||||
assertThat(snapshot[0].lastActivityAt).isEqualTo(1_000L)
|
||||
}
|
||||
|
||||
@Test fun setOneliner_mutates_target_case_only() {
|
||||
val a = CaseStoreStub.createLocal(now = 1_000L)
|
||||
val b = CaseStoreStub.createLocal(now = 2_000L)
|
||||
CaseStoreStub.setOneliner(a, ready("Test-Oneliner"))
|
||||
val snapshot = CaseStoreStub.cases.value
|
||||
val ready = snapshot.first { it.caseId == a }.oneliner as OnelinerState.Ready
|
||||
assertThat(ready.text).isEqualTo("Test-Oneliner")
|
||||
assertThat(snapshot.first { it.caseId == b }.oneliner).isNull()
|
||||
}
|
||||
|
||||
@Test fun setOneliner_manual_blocks_subsequent_auto_update() {
|
||||
val id = CaseStoreStub.createLocal(now = 1_000L)
|
||||
CaseStoreStub.setOneliner(id, OnelinerState.Manual("Doktor-Text", setAt = nowRfc3339()))
|
||||
CaseStoreStub.setOneliner(id, ready("[PoC] Auto-Text"))
|
||||
val entry = CaseStoreStub.cases.value.first { it.caseId == id }
|
||||
val manual = entry.oneliner as OnelinerState.Manual
|
||||
assertThat(manual.text).isEqualTo("Doktor-Text")
|
||||
}
|
||||
|
||||
@Test fun setOneliner_manual_overwrites_previous_manual() {
|
||||
val id = CaseStoreStub.createLocal(now = 1_000L)
|
||||
CaseStoreStub.setOneliner(id, OnelinerState.Manual("V1", setAt = nowRfc3339()))
|
||||
CaseStoreStub.setOneliner(id, OnelinerState.Manual("V2", setAt = nowRfc3339()))
|
||||
val entry = CaseStoreStub.cases.value.first { it.caseId == id }
|
||||
val manual = entry.oneliner as OnelinerState.Manual
|
||||
assertThat(manual.text).isEqualTo("V2")
|
||||
}
|
||||
|
||||
@Test fun currentCase_returns_head_of_list() {
|
||||
assertThat(CaseStoreStub.currentCase).isNull()
|
||||
val first = CaseStoreStub.createLocal(now = 1_000L)
|
||||
assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(first)
|
||||
val second = CaseStoreStub.createLocal(now = 2_000L)
|
||||
assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(second)
|
||||
}
|
||||
|
||||
private fun ready(text: String): OnelinerState =
|
||||
OnelinerState.Ready(text, generatedAt = nowRfc3339())
|
||||
|
||||
private fun nowRfc3339(): String = Instant.now().toString()
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class DiskCaseStoreTest {
|
||||
@get:Rule val tempDir = TemporaryFolder()
|
||||
|
||||
private fun newStore(subdir: String = "cases"): DiskCaseStore =
|
||||
DiskCaseStore(File(tempDir.root, subdir))
|
||||
|
||||
@Test fun createLocal_persists_marker_file_and_emits_snapshot() = runTest {
|
||||
val store = newStore()
|
||||
val id = store.createLocal(now = 1_700_000_000_000L)
|
||||
val list = store.casesFlow.value
|
||||
assertThat(list).hasSize(1)
|
||||
assertThat(list[0].caseId).isEqualTo(id)
|
||||
assertThat(list[0].syncedToServer).isFalse()
|
||||
|
||||
val markerFile = File(tempDir.root, "cases/$id.json")
|
||||
assertThat(markerFile.exists()).isTrue()
|
||||
val parsed = CaseMarker.fromJson(markerFile.readText())
|
||||
assertThat(parsed.caseId).isEqualTo(id)
|
||||
assertThat(parsed.syncedToServer).isFalse()
|
||||
}
|
||||
|
||||
@Test fun bootstrap_reloads_markers_from_disk() = runTest {
|
||||
val s1 = newStore()
|
||||
val a = s1.createLocal(now = 1_700_000_000_000L)
|
||||
val b = s1.createLocal(now = 1_700_000_001_000L)
|
||||
// Build a fresh store on the same dir → must repopulate from disk.
|
||||
val s2 = newStore()
|
||||
val ids = s2.casesFlow.value.map { it.caseId }.toSet()
|
||||
assertThat(ids).containsExactly(a, b)
|
||||
}
|
||||
|
||||
@Test fun markActivity_creates_marker_if_missing() = runTest {
|
||||
val store = newStore()
|
||||
store.markActivity("11111111-1111-1111-1111-111111111111", now = 1_700_000_000_000L)
|
||||
val list = store.casesFlow.value
|
||||
assertThat(list).hasSize(1)
|
||||
assertThat(list[0].caseId).isEqualTo("11111111-1111-1111-1111-111111111111")
|
||||
}
|
||||
|
||||
@Test fun markSynced_sets_flag_and_persists() = runTest {
|
||||
val store = newStore()
|
||||
val id = store.createLocal(now = 1_700_000_000_000L)
|
||||
store.markSynced(id)
|
||||
assertThat(store.casesFlow.value.first { it.caseId == id }.syncedToServer).isTrue()
|
||||
// Persisted?
|
||||
val parsed = CaseMarker.fromJson(File(tempDir.root, "cases/$id.json").readText())
|
||||
assertThat(parsed.syncedToServer).isTrue()
|
||||
}
|
||||
|
||||
@Test fun mergeServerSnapshot_inserts_unknown_case_with_synced_true() = runTest {
|
||||
val store = newStore()
|
||||
val response = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 72,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = "case-aaaa",
|
||||
oneliner = OnelinerState.Ready("hi", generatedAt = "2026-04-26T11:55:00Z"),
|
||||
createdAt = "2026-04-26T11:00:00Z",
|
||||
lastRecordingAt = "2026-04-26T11:30:00Z",
|
||||
updatedAt = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
store.mergeServerSnapshot(response)
|
||||
val entry = store.casesFlow.value.single()
|
||||
assertThat(entry.caseId).isEqualTo("case-aaaa")
|
||||
assertThat(entry.syncedToServer).isTrue()
|
||||
assertThat((entry.oneliner as OnelinerState.Ready).text).isEqualTo("hi")
|
||||
}
|
||||
|
||||
@Test fun mergeServerSnapshot_keeps_local_manual_against_non_manual_server() = runTest {
|
||||
val store = newStore()
|
||||
val id = store.createLocal(now = 1_700_000_000_000L)
|
||||
store.setOnelinerLocal(id, OnelinerState.Manual("doc-text", setAt = "2026-04-26T11:00:00Z"))
|
||||
val response = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 72,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = id,
|
||||
oneliner = OnelinerState.Ready("server-text", generatedAt = "2026-04-26T11:30:00Z"),
|
||||
createdAt = "2026-04-26T11:00:00Z",
|
||||
lastRecordingAt = null,
|
||||
updatedAt = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
store.mergeServerSnapshot(response)
|
||||
val entry = store.casesFlow.value.single()
|
||||
val manual = entry.oneliner as OnelinerState.Manual
|
||||
assertThat(manual.text).isEqualTo("doc-text")
|
||||
}
|
||||
|
||||
@Test fun mergeServerSnapshot_keeps_newer_local_lastActivity() = runTest {
|
||||
val store = newStore()
|
||||
// Local activity at T+10s; server reports T+5s — local wins.
|
||||
val laterMs = Instant.parse("2026-04-26T11:00:10Z").toEpochMilli()
|
||||
val id = "case-bbbb"
|
||||
store.markActivity(id, now = laterMs)
|
||||
store.markSynced(id)
|
||||
val response = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 72,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = id,
|
||||
oneliner = null,
|
||||
createdAt = "2026-04-26T11:00:00Z",
|
||||
lastRecordingAt = "2026-04-26T11:00:05Z",
|
||||
updatedAt = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
store.mergeServerSnapshot(response)
|
||||
val entry = store.casesFlow.value.single()
|
||||
assertThat(entry.lastActivityAt).isEqualTo(laterMs)
|
||||
}
|
||||
|
||||
@Test fun reconcile_removes_synced_marker_inside_window_missing_from_server() = runTest {
|
||||
val store = newStore()
|
||||
val asOf = "2026-04-26T12:00:00Z"
|
||||
val asOfMs = Instant.parse(asOf).toEpochMilli()
|
||||
val id = "case-cccc"
|
||||
store.markActivity(id, now = asOfMs - 3_600_000L) // 1h ago, well inside 72h window
|
||||
store.markSynced(id)
|
||||
|
||||
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
|
||||
val removed = store.reconcileWithServerSnapshot(response)
|
||||
assertThat(removed).isEqualTo(1)
|
||||
assertThat(store.casesFlow.value).isEmpty()
|
||||
// marker file gone
|
||||
assertThat(File(tempDir.root, "cases/$id.json").exists()).isFalse()
|
||||
}
|
||||
|
||||
@Test fun reconcile_preserves_pending_marker() = runTest {
|
||||
val store = newStore()
|
||||
val asOf = "2026-04-26T12:00:00Z"
|
||||
val asOfMs = Instant.parse(asOf).toEpochMilli()
|
||||
val id = store.createLocal(now = asOfMs - 3_600_000L) // pending: synced=false
|
||||
|
||||
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
|
||||
val removed = store.reconcileWithServerSnapshot(response)
|
||||
assertThat(removed).isEqualTo(0)
|
||||
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(id)
|
||||
}
|
||||
|
||||
@Test fun reconcile_preserves_marker_outside_window() = runTest {
|
||||
val store = newStore()
|
||||
val asOf = "2026-04-26T12:00:00Z"
|
||||
val asOfMs = Instant.parse(asOf).toEpochMilli()
|
||||
val id = "case-old"
|
||||
// 80h ago — outside 72h window: server silence not authoritative.
|
||||
store.markActivity(id, now = asOfMs - 80 * 3_600_000L)
|
||||
store.markSynced(id)
|
||||
|
||||
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
|
||||
val removed = store.reconcileWithServerSnapshot(response)
|
||||
assertThat(removed).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test fun cleanupStale_drops_only_synced() = runTest {
|
||||
val store = newStore()
|
||||
val cutoff = 2_000_000L
|
||||
val olderSynced = "case-old-synced"
|
||||
val olderPending = "case-old-pending"
|
||||
store.markActivity(olderSynced, now = 1_000_000L)
|
||||
store.markSynced(olderSynced)
|
||||
store.markActivity(olderPending, now = 1_000_000L)
|
||||
|
||||
val removed = store.cleanupStale(cutoffMs = cutoff)
|
||||
assertThat(removed).isEqualTo(1)
|
||||
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(olderPending)
|
||||
}
|
||||
|
||||
@Test fun clearAll_wipes_memory_and_disk() = runTest {
|
||||
val store = newStore()
|
||||
store.createLocal()
|
||||
store.createLocal()
|
||||
store.clearAll()
|
||||
assertThat(store.casesFlow.value).isEmpty()
|
||||
val files = File(tempDir.root, "cases").listFiles()?.toList().orEmpty()
|
||||
assertThat(files).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun concurrent_creates_serialize_via_mutex() = runTest {
|
||||
val store = newStore()
|
||||
val ids = (1..10).map {
|
||||
async { store.createLocal(now = 1_700_000_000_000L + it) }
|
||||
}.awaitAll()
|
||||
// Each create wrote one file; size matches.
|
||||
assertThat(store.casesFlow.value.map { it.caseId }.toSet()).hasSize(ids.size)
|
||||
val onDisk = File(tempDir.root, "cases").listFiles()?.filter { it.name.endsWith(".json") }
|
||||
?.map { it.nameWithoutExtension } ?: emptyList()
|
||||
assertThat(onDisk.toSet()).isEqualTo(ids.toSet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.doctate.watch.testfixtures
|
||||
|
||||
import com.doctate.watch.domain.CaseEntry
|
||||
import com.doctate.watch.domain.CaseId
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.OnelinerState
|
||||
import com.doctate.watch.domain.OnelinersResponse
|
||||
import com.doctate.watch.domain.isManual
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Thread-safe in-memory [CaseStore] for tests that need a store but not its
|
||||
* disk-IO. Mirrors the behaviour of `DiskCaseStore` (sort order, manual-stick
|
||||
* rule, reconcile window semantics) so contract tests can swap them.
|
||||
*/
|
||||
class InMemoryCaseStore : CaseStore {
|
||||
private data class Entry(
|
||||
val caseId: String,
|
||||
val createdAt: Long,
|
||||
val lastActivityAt: Long,
|
||||
val syncedToServer: Boolean,
|
||||
val oneliner: OnelinerState?,
|
||||
)
|
||||
|
||||
private val state = LinkedHashMap<String, Entry>()
|
||||
private val mutex = Mutex()
|
||||
private val _flow = MutableStateFlow<List<CaseEntry>>(emptyList())
|
||||
|
||||
override val casesFlow: StateFlow<List<CaseEntry>> = _flow.asStateFlow()
|
||||
|
||||
override suspend fun createLocal(now: Long): String {
|
||||
val id = CaseId.new()
|
||||
mutex.withLock {
|
||||
state[id] = Entry(id, now, now, syncedToServer = false, oneliner = null)
|
||||
broadcast()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun markActivity(caseId: String, now: Long) {
|
||||
mutex.withLock {
|
||||
val existing = state[caseId]
|
||||
state[caseId] = existing?.copy(lastActivityAt = now)
|
||||
?: Entry(caseId, now, now, syncedToServer = false, oneliner = null)
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markSynced(caseId: String) {
|
||||
mutex.withLock {
|
||||
val existing = state[caseId] ?: return
|
||||
if (existing.syncedToServer) return
|
||||
state[caseId] = existing.copy(syncedToServer = true)
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setOnelinerLocal(caseId: String, state: OnelinerState) {
|
||||
mutex.withLock {
|
||||
val existing = this.state[caseId] ?: return
|
||||
this.state[caseId] = existing.copy(oneliner = state)
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mergeServerSnapshot(response: OnelinersResponse) {
|
||||
mutex.withLock {
|
||||
for (entry in response.oneliners) {
|
||||
val serverActivityMs = (entry.lastRecordingAt ?: entry.updatedAt ?: entry.createdAt)
|
||||
.toEpochMs()
|
||||
val existing = state[entry.caseId]
|
||||
state[entry.caseId] = if (existing != null) {
|
||||
val keepLocalManual = existing.oneliner.isManual() &&
|
||||
entry.oneliner !is OnelinerState.Manual
|
||||
existing.copy(
|
||||
syncedToServer = true,
|
||||
oneliner = if (keepLocalManual) existing.oneliner else entry.oneliner,
|
||||
lastActivityAt = maxOf(existing.lastActivityAt, serverActivityMs),
|
||||
)
|
||||
} else {
|
||||
Entry(
|
||||
caseId = entry.caseId,
|
||||
createdAt = entry.createdAt.toEpochMs(),
|
||||
lastActivityAt = serverActivityMs,
|
||||
syncedToServer = true,
|
||||
oneliner = entry.oneliner,
|
||||
)
|
||||
}
|
||||
}
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int {
|
||||
val asOfMs = response.asOf.toEpochMs()
|
||||
val windowStartMs = asOfMs - response.windowHours * 3_600_000L
|
||||
val serverIds = response.oneliners.map { it.caseId }.toHashSet()
|
||||
var removed = 0
|
||||
mutex.withLock {
|
||||
val toRemove = state.values.filter { e ->
|
||||
e.syncedToServer &&
|
||||
e.lastActivityAt >= windowStartMs &&
|
||||
e.caseId !in serverIds
|
||||
}.map { it.caseId }
|
||||
for (id in toRemove) {
|
||||
state.remove(id)
|
||||
removed++
|
||||
}
|
||||
if (removed > 0) broadcast()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
override suspend fun cleanupStale(cutoffMs: Long): Int {
|
||||
var removed = 0
|
||||
mutex.withLock {
|
||||
val toRemove = state.values.filter { it.syncedToServer && it.lastActivityAt < cutoffMs }
|
||||
.map { it.caseId }
|
||||
for (id in toRemove) {
|
||||
state.remove(id)
|
||||
removed++
|
||||
}
|
||||
if (removed > 0) broadcast()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
override suspend fun clearAll() {
|
||||
mutex.withLock {
|
||||
state.clear()
|
||||
broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcast() {
|
||||
_flow.value = state.values
|
||||
.sortedByDescending { it.lastActivityAt }
|
||||
.map { CaseEntry(it.caseId, it.createdAt, it.lastActivityAt, it.oneliner, it.syncedToServer) }
|
||||
}
|
||||
|
||||
private fun String.toEpochMs(): Long = Instant.parse(this).toEpochMilli()
|
||||
}
|
||||
Reference in New Issue
Block a user