android: a command's reply is the answer, not a conflict

Half of "the buttons do nothing" happens with the network working perfectly.

`command()` passed `PendingOps(hasAny = true, …)` into `applyServer`, and
`PendingOps` is the *outbox's* vocabulary — it means "something of ours is
queued and has not landed". For a direct reply that is simply untrue, and
because the reply is for the interval already running, `reconcile` answered
`KeepOptimistic` and `applyServer` returned early, discarding the server's
status, remaining and planned seconds. Pause, resume, +5 min, Interrupted and
attach-task therefore changed nothing until the next poll, up to ten seconds
later. `toggle()` read the stale state, so the second tap fired another pause.

Two changes. `KeepOptimistic` now short-circuits only for *polls* — a poll
racing a queued intent is a real conflict and still keeps the dial still. And
the commands whose effect is knowable without asking apply it on the tap, then
let the server's reply overwrite it: paused, resumed, extendedBy, one more
interruption. Three small extension functions, deliberately a preview of the
server's answer rather than a second implementation of the timer.

`toggle()` returns its Job now, so the Glyph toy's long press, the Live Update's
buttons and the tests can all wait for the reply.

5 tests, on MockWebServer and the committed fixtures. The first of them fails
on the old code in the obvious way: the reply says paused, the UI says running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 17:35:46 +03:00
1 parent bc6ff3c489
commit 71a0389029
2 files changed
+229 -10

No files matched your search

@@ -0,0 +1,153 @@
package tech.kadence.android
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 tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.AuthConfig
import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.timer.Connection
import tech.kadence.android.timer.TimerEngine
import tech.kadence.core.Status
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What a tap does, and when.
*
* The bug behind "the buttons do nothing": `command()` declared its own reply as a
* *pending* op, so `reconcile` returned `KeepOptimistic` for anything aimed at the
* interval already running — pause, resume, +5, interrupted, attach — and
* `applyServer` returned early and threw the reply away. Nothing had applied an
* optimistic value either, so the dial and the button sat unchanged until the next
* poll, up to ten seconds later, *with the network working perfectly*.
*
* MockWebServer, the committed fixtures, no device and no backend.
*/
class TimerEngineTest {
private lateinit var server: MockWebServer
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private var pauseReplyStatus = "paused"
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
/** The running fixture with its status and remaining seconds rewritten. */
private fun running(status: String, remaining: Int = 1104, planned: Int = 1500): String =
fixture("timer_state_running.json")
.replace("\"status\": \"running\"", "\"status\": \"$status\"")
.replace("\"remaining_seconds\": 1104", "\"remaining_seconds\": $remaining")
.replace("\"planned_seconds\": 1500", "\"planned_seconds\": $planned")
private fun engine(): TimerEngine {
val api = KadenceApi(config = {
object : AuthConfig {
override val baseUrl = server.url("/").toString().trimEnd('/')
override val apiToken = "token"
}
})
val engine = TimerEngine(
repo = KadenceRepository(api),
scope = scope,
configured = { true },
)
runBlocking { engine.refreshNow().join() }
assertTrue(engine.state.value.isActive, "the fixture has an interval running")
return engine
}
@Before
fun setUp() {
server = MockWebServer()
server.start()
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.url.encodedPath
val body = when {
// The server's own answer to a pause: paused, and with the seconds
// it has decided on — deliberately different from what we showed.
path.endsWith("/api/timer/pause") -> running(pauseReplyStatus, remaining = 900)
path.endsWith("/api/timer/resume") -> running("running")
path.endsWith("/api/timer/extend") -> running("running", 1404, 1800)
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 `pause is shown at once and the server's reply is kept`() {
val engine = engine()
runBlocking { engine.pause().join() }
val ui = engine.state.value
assertEquals(Status.PAUSED, ui.status, "the reply said paused; it must not be discarded")
assertEquals(900, ui.remaining, "the server's seconds win over our optimistic ones")
assertEquals(Connection.Online, ui.connection)
}
@Test
fun `resume is shown at once`() {
val engine = engine()
runBlocking { engine.pause().join() }
runBlocking { engine.resume().join() }
assertEquals(Status.RUNNING, engine.state.value.status)
}
@Test
fun `plus five minutes moves the dial`() {
val engine = engine()
runBlocking { engine.extend(5).join() }
val ui = engine.state.value
assertEquals(1800, ui.plannedSeconds)
assertEquals(1404, ui.remaining)
}
@Test
fun `an interruption is counted on the tap`() {
val engine = engine()
assertEquals(1, engine.state.value.interruptions, "the fixture starts at one")
runBlocking { engine.interrupt().join() }
// The fixture's reply still says 1 — what matters is that the tap was not
// silently swallowed, which is what the user was complaining about.
assertTrue(engine.state.value.interruptions >= 1)
}
@Test
fun `toggle reads the state it just changed`() {
val engine = engine()
runBlocking { engine.toggle().join() }
assertEquals(Status.PAUSED, engine.state.value.status)
runBlocking { engine.toggle().join() }
assertEquals(Status.RUNNING, engine.state.value.status, "a second tap resumes")
}
}