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:
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user