android: offline writes go to the outbox that was already written
`:core/Outbox.kt` is 245 lines and 29 tests of careful thinking — what coalesces with what, which post-condition proves an ambiguous send landed, which intervals cannot be filed honestly at all — and nothing in `app/` had ever called it. Three comments pointed at it as though it were wired up. A tap with no signal produced one banner and was gone, after a ten-second connect timeout taken *under the command mutex*, so a handful of taps was most of a minute of frozen UI and then a stack of identical banners. Now every command has a queued form. `Reachability` (ConnectivityManager, which the manifest has had permission for since the start and never used) is asked before the network is, so an offline tap is answered instantly; a request that fails as NetworkError anyway lands in the same place one timeout later. The optimistic state stays on screen, the local anchor keeps ticking, and the snapshot is cached — so an interval started with no signal survives the process being killed. `NET_CAPABILITY_VALIDATED` is deliberately not required: this backend is routinely a LAN address, and demanding validation would call the one setup the README recommends unreachable. Replay runs on the next successful poll and the moment a network appears. In order, stopping at the first thing that cannot go; at most once, with the state re-read and probed rather than resent, because none of these endpoints is idempotent; a 4xx dropped rather than retried eight times into a stuck queue. An interval that both began and ended offline is quarantined and *said out loud* — replaying it would stamp `started_at` as the reconnect moment and record a pomodoro of no duration, quietly corrupting the statistics the app exists to produce. The poll finally declares the queue as `PendingOps`, which is what that type was written for: a poll racing an unsent intent now keeps the dial still instead of bouncing it. The offline banner stops saying only that the countdown keeps running — the sentence that made the dead buttons look intentional. 7 tests: an offline pause that never touches the network, the queue going out on reconnect, two `+5`s coalescing into ten minutes, pause-then-resume annihilating, the unfilable interval being reported, and an offline interval surviving a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+756
-19
No files matched your search
@@ -0,0 +1,224 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.Dispatcher
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import mockwebserver3.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import tech.kadence.android.data.KadenceRepository
|
||||
import tech.kadence.android.data.net.AuthConfig
|
||||
import tech.kadence.android.data.net.KadenceApi
|
||||
import tech.kadence.android.data.store.Cached
|
||||
import tech.kadence.android.data.store.LocalStore
|
||||
import tech.kadence.android.data.store.OutboxFileDto
|
||||
import tech.kadence.android.data.store.OutboxStore
|
||||
import tech.kadence.android.timer.Connection
|
||||
import tech.kadence.android.timer.TimerEngine
|
||||
import tech.kadence.core.OutboxOp
|
||||
import tech.kadence.core.Status
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Taps with no signal.
|
||||
*
|
||||
* `:core`'s outbox — coalescing, post-condition probes, the quarantine for an interval
|
||||
* that both began and ended offline — was complete and 29-tests deep, and nothing in
|
||||
* the app had ever called it. A tap without a network produced one banner and was
|
||||
* gone. These tests are about the queue being real: the intent is kept, the UI moves
|
||||
* at once, and the server gets it when it comes back.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class OfflineWriteTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: LocalStore
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
/** Flipped by the tests: this is "the phone has no network". */
|
||||
private var online = true
|
||||
private val seen = mutableListOf<String>()
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
|
||||
.bufferedReader().readText()
|
||||
|
||||
private fun engine(): TimerEngine = TimerEngine(
|
||||
repo = KadenceRepository(
|
||||
KadenceApi(config = {
|
||||
object : AuthConfig {
|
||||
override val baseUrl = server.url("/").toString().trimEnd('/')
|
||||
override val apiToken = null
|
||||
}
|
||||
}),
|
||||
),
|
||||
scope = scope,
|
||||
configured = { true },
|
||||
store = store,
|
||||
reachable = { online },
|
||||
)
|
||||
|
||||
private fun outbox() = OutboxStore(store)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
File(context.filesDir, "cache").deleteRecursively()
|
||||
store = LocalStore(context)
|
||||
online = true
|
||||
seen.clear()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.url.encodedPath
|
||||
seen += path
|
||||
val body = when {
|
||||
path.endsWith("/api/timer/state") -> fixture("timer_state_running.json")
|
||||
path.endsWith("/api/timer/pause") ->
|
||||
fixture("timer_state_running.json")
|
||||
.replace("\"status\": \"running\"", "\"status\": \"paused\"")
|
||||
else -> fixture("timer_state_running.json")
|
||||
}
|
||||
return MockResponse.Builder()
|
||||
.code(200)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offline pause moves the dial, is kept, and never touches the network`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
|
||||
runBlocking { engine.pause().join() }
|
||||
|
||||
val ui = engine.state.value
|
||||
assertEquals(Status.PAUSED, ui.status, "the user asked, so the user can see it")
|
||||
assertEquals(Connection.Offline, ui.connection)
|
||||
assertTrue(ui.notice?.contains("kept") == true, "say it will be sent, got: ${ui.notice}")
|
||||
assertEquals(listOf(OutboxOp.PAUSE.name), outbox().entries.map { it.op })
|
||||
assertTrue(seen.none { it.endsWith("/api/timer/pause") }, "nothing was attempted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queue is sent when the server answers again`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
runBlocking { engine.pause().join() }
|
||||
|
||||
online = true
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
|
||||
assertTrue(seen.any { it.endsWith("/api/timer/pause") }, "the queued pause went out")
|
||||
assertTrue(outbox().isEmpty, "and left the queue")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two taps on plus five are one ten-minute extend`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
|
||||
runBlocking { engine.extend(5).join() }
|
||||
runBlocking { engine.extend(5).join() }
|
||||
|
||||
val queued = outbox().entries
|
||||
assertEquals(1, queued.size, "coalesced, as :core says it should be")
|
||||
assertEquals(10, queued.single().minutes)
|
||||
assertEquals(1104 + 600, engine.state.value.remaining, "and both were shown")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause then resume with no signal cancels out`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
|
||||
runBlocking { engine.pause().join() }
|
||||
runBlocking { engine.resume().join() }
|
||||
|
||||
assertTrue(outbox().isEmpty, "the user changed their mind before we had signal")
|
||||
assertEquals(Status.RUNNING, engine.state.value.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an interval that began and ended offline is not filed, and is said so`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
|
||||
// A start and a complete that the server never saw. Replaying them would stamp
|
||||
// `started_at` as the reconnect moment and record a pomodoro of no duration,
|
||||
// which is worse than losing it.
|
||||
runBlocking { engine.complete().join() }
|
||||
runBlocking { engine.startKind("work").join() }
|
||||
runBlocking { engine.complete().join() }
|
||||
|
||||
online = true
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
|
||||
val ui = engine.state.value
|
||||
assertTrue(
|
||||
ui.notice?.contains("could not be filed") == true,
|
||||
"the loss has to be visible, got: ${ui.notice}",
|
||||
)
|
||||
assertTrue(seen.none { it.endsWith("/api/timer/start") }, "and never sent")
|
||||
assertTrue(outbox().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an interval started offline survives the process being killed`() {
|
||||
val engine = engine()
|
||||
runBlocking { engine.refreshNow().join() }
|
||||
online = false
|
||||
runBlocking { engine.complete().join() }
|
||||
runBlocking { engine.startKind("short_break", minutes = 5).join() }
|
||||
|
||||
// A whole new engine, as after a process death: same disk, nothing in memory.
|
||||
val reborn = engine()
|
||||
reborn.start()
|
||||
reborn.stop()
|
||||
|
||||
val ui = reborn.state.value
|
||||
assertEquals("short_break", ui.kind)
|
||||
assertEquals(Status.RUNNING, ui.status)
|
||||
assertTrue(ui.remaining in 290..300, "five minutes, less the moment that passed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queue that survives a restart is still readable`() {
|
||||
val box = outbox()
|
||||
box.add(OutboxOp.EXTEND, anchorSessionId = 412, minutes = 5)
|
||||
box.add(OutboxOp.INTERRUPT, anchorSessionId = 412)
|
||||
box.add(OutboxOp.INTERRUPT, anchorSessionId = 412)
|
||||
|
||||
val back = store.read(Cached.OUTBOX, OutboxFileDto.serializer())
|
||||
|
||||
assertEquals(2, back?.entries?.size)
|
||||
assertEquals(2, back?.entries?.last()?.repeat, "three interruptions are three increments")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user