some backend fixes and add android app
This commit is contained in:
69 files changed
+7111
-7
No files matched your search
@@ -0,0 +1,256 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Test
|
||||
import tech.kadence.android.data.dto.AuthMeDto
|
||||
import tech.kadence.android.data.dto.BlockEventsDto
|
||||
import tech.kadence.android.data.dto.BlockerPolicyDto
|
||||
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
|
||||
import tech.kadence.android.data.dto.StatsSummaryDto
|
||||
import tech.kadence.android.data.dto.TaskListDto
|
||||
import tech.kadence.android.data.dto.TimerStateDto
|
||||
import tech.kadence.android.data.net.KadenceJson
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The contract test, against committed mock data.
|
||||
*
|
||||
* The backend declares no response models, so there is nothing to generate DTOs from
|
||||
* and nothing to validate them against — the only defence is fixtures plus deliberate
|
||||
* corruption. Each DTO is decoded from a realistic fixture, then from the same fixture
|
||||
* with every optional key stripped, with unknown keys added, and with nulls where a
|
||||
* value is declared. None of that may throw, because the alternative is a phone that
|
||||
* crashes when the server grows a field.
|
||||
*
|
||||
* These tests never touch a network or a running backend.
|
||||
*/
|
||||
class DtoToleranceTest {
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
|
||||
"missing fixture: $name"
|
||||
}.bufferedReader().readText()
|
||||
|
||||
private inline fun <reified T> decode(name: String): T =
|
||||
KadenceJson.decodeFromString<T>(fixture(name))
|
||||
|
||||
/** The same payload with three keys the client has never heard of. */
|
||||
private fun withUnknownKeys(raw: String): String {
|
||||
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
|
||||
val grown = buildJsonObject {
|
||||
obj.forEach { (k, v) -> put(k, v) }
|
||||
put("some_new_field", JsonPrimitive("surprise"))
|
||||
put("another_one", JsonPrimitive(42))
|
||||
put("nested_novelty", buildJsonObject { put("x", JsonPrimitive(1)) })
|
||||
}
|
||||
return grown.toString()
|
||||
}
|
||||
|
||||
/** Every top-level value replaced with null, as a sloppy serialiser might. */
|
||||
private fun allNulls(raw: String): String {
|
||||
val obj = KadenceJson.parseToJsonElement(raw).jsonObject
|
||||
val nulled = buildJsonObject {
|
||||
obj.keys.forEach { k -> put(k, kotlinx.serialization.json.JsonNull) }
|
||||
}
|
||||
return nulled.toString()
|
||||
}
|
||||
|
||||
// --- the happy path, asserting the values that matter ---------------------
|
||||
|
||||
@Test
|
||||
fun `a running interval decodes`() {
|
||||
val state = decode<TimerStateDto>("timer_state_running.json")
|
||||
val current = assertNotNull(state.current)
|
||||
assertEquals(412L, current.id)
|
||||
assertEquals("work", current.kind)
|
||||
assertEquals(1104, current.remainingSeconds)
|
||||
assertEquals(1500, current.plannedSeconds)
|
||||
assertEquals(1, current.interruptions)
|
||||
assertEquals("PLAT-184", current.task?.externalId)
|
||||
assertEquals(6, state.completedToday)
|
||||
assertEquals("short_break", state.nextKind)
|
||||
assertTrue(state.timerEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `durations are minutes, not seconds`() {
|
||||
// The trap: `pomodoro.state()` sends minutes. Treating them as seconds would
|
||||
// make every interval 25 seconds long.
|
||||
val state = decode<TimerStateDto>("timer_state_running.json")
|
||||
assertEquals(25, state.durations["work"])
|
||||
assertEquals(5, state.durations["short_break"])
|
||||
assertEquals(15, state.durations["long_break"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an idle state has no current interval`() {
|
||||
val state = decode<TimerStateDto>("timer_state_idle.json")
|
||||
assertNull(state.current)
|
||||
assertEquals("work", state.nextKind)
|
||||
assertEquals(45, state.durations["work"], "the Classic preset")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a complete reply carries finished, auto_started and the celebration`() {
|
||||
val state = decode<TimerStateDto>("timer_complete_reply.json")
|
||||
assertEquals(412L, state.finished?.id)
|
||||
assertEquals("completed", state.finished?.status)
|
||||
assertEquals(413L, state.autoStarted?.id)
|
||||
assertEquals("short_break", state.autoStarted?.kind)
|
||||
assertNotNull(state.celebration)
|
||||
assertEquals(413L, state.current?.id, "current is the follow-on interval")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an inactive policy hides its lists but keeps all_domains`() {
|
||||
// The single most important API detail for the focus guard: `domains` and `apps`
|
||||
// are empty whenever `active` is false, so a client that reads them would think
|
||||
// the block list was empty and enforce nothing.
|
||||
val policy = decode<BlockerPolicyDto>("blocker_policy_inactive.json")
|
||||
assertTrue(!policy.active)
|
||||
assertTrue(policy.domains.isEmpty())
|
||||
assertTrue(policy.apps.isEmpty())
|
||||
assertEquals(3, policy.allDomains.size)
|
||||
assertEquals(2, policy.allApps.size)
|
||||
assertNull(policy.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an active policy populates both`() {
|
||||
val policy = decode<BlockerPolicyDto>("blocker_policy_active.json")
|
||||
assertTrue(policy.active)
|
||||
assertEquals(policy.allDomains, policy.domains)
|
||||
assertEquals(listOf("*gram*"), policy.unexpandable)
|
||||
assertEquals(412L, policy.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tasks decode with their buckets and sources`() {
|
||||
val list = decode<TaskListDto>("tasks.json")
|
||||
assertEquals(3, list.tasks.size)
|
||||
assertEquals("overdue", list.tasks[0].bucket)
|
||||
assertEquals(3, list.tasks[0].daysOverdue)
|
||||
assertTrue(list.tasks[0].pinned)
|
||||
assertEquals("local", list.tasks[2].source)
|
||||
assertEquals(1, list.counts["overdue"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the daily report decodes whole`() {
|
||||
val envelope = decode<DailyReportEnvelopeDto>("daily_report.json")
|
||||
val report = envelope.report
|
||||
assertEquals(6, report.metrics.pomodoros)
|
||||
assertEquals(155, report.metrics.focusMinutes)
|
||||
assertEquals(80, report.metrics.longestStretchMinutes)
|
||||
assertEquals(2, report.achievements.size)
|
||||
assertEquals("deep_work", report.achievements[0].code)
|
||||
assertEquals(2, report.funFacts.size)
|
||||
assertEquals(72.4, report.score)
|
||||
assertEquals(4, report.streak.current)
|
||||
assertEquals(16, report.metrics.blockedAttempts)
|
||||
assertNull(envelope.sentAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stats summary decodes its ranges`() {
|
||||
val stats = decode<StatsSummaryDto>("stats_summary.json")
|
||||
assertEquals(3, stats.range.size)
|
||||
assertEquals(2, stats.heatmap.size)
|
||||
assertEquals(162.5, stats.lifetime.focusHours)
|
||||
assertEquals(5.4, stats.average7d.pomodoros)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `block events decode both kinds`() {
|
||||
val events = decode<BlockEventsDto>("block_events.json")
|
||||
assertEquals(16, events.total)
|
||||
assertEquals("domain", events.events[0].kind)
|
||||
assertEquals("killed", events.events[1].action)
|
||||
assertEquals("youtube.com", events.byTarget[0].target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auth me decodes`() {
|
||||
val me = decode<AuthMeDto>("auth_me.json")
|
||||
assertTrue(me.authenticated)
|
||||
assertTrue(me.loginRequired)
|
||||
assertEquals("timka", me.username)
|
||||
}
|
||||
|
||||
// --- the corruption passes ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an empty object decodes into every DTO`() {
|
||||
// A stripped-down or errored response must still produce usable defaults.
|
||||
assertNull(KadenceJson.decodeFromString<TimerStateDto>("{}").current)
|
||||
assertEquals(8, KadenceJson.decodeFromString<TimerStateDto>("{}").goal)
|
||||
assertTrue(KadenceJson.decodeFromString<TaskListDto>("{}").tasks.isEmpty())
|
||||
assertTrue(!KadenceJson.decodeFromString<BlockerPolicyDto>("{}").active)
|
||||
assertEquals(0.0, KadenceJson.decodeFromString<DailyReportEnvelopeDto>("{}").report.score)
|
||||
assertEquals(0, KadenceJson.decodeFromString<StatsSummaryDto>("{}").range.size)
|
||||
assertEquals(0, KadenceJson.decodeFromString<BlockEventsDto>("{}").total)
|
||||
assertTrue(!KadenceJson.decodeFromString<AuthMeDto>("{}").authenticated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys are ignored everywhere`() {
|
||||
val grown = withUnknownKeys(fixture("timer_state_running.json"))
|
||||
assertEquals(412L, KadenceJson.decodeFromString<TimerStateDto>(grown).current?.id)
|
||||
|
||||
val policy = withUnknownKeys(fixture("blocker_policy_active.json"))
|
||||
assertTrue(KadenceJson.decodeFromString<BlockerPolicyDto>(policy).active)
|
||||
|
||||
val tasks = withUnknownKeys(fixture("tasks.json"))
|
||||
assertEquals(3, KadenceJson.decodeFromString<TaskListDto>(tasks).tasks.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nulls where a value is declared fall back to defaults`() {
|
||||
// `coerceInputValues` is what saves us here; without it this throws.
|
||||
val nulled = allNulls(fixture("timer_state_running.json"))
|
||||
val state = KadenceJson.decodeFromString<TimerStateDto>(nulled)
|
||||
assertNull(state.current)
|
||||
assertEquals(8, state.goal, "the default survives a null")
|
||||
assertEquals("work", state.nextKind)
|
||||
assertTrue(state.timerEnabled)
|
||||
|
||||
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>(
|
||||
allNulls(fixture("blocker_policy_active.json")),
|
||||
)
|
||||
assertTrue(!policy.active)
|
||||
assertTrue(policy.allDomains.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nested object arriving as the wrong shape does not take the page down`() {
|
||||
// A task row missing almost everything still decodes, because every field has
|
||||
// a default — the list renders with blanks instead of crashing.
|
||||
val json = """{"date":"2026-10-19","tasks":[{"id":9}],"counts":{},"sources":[]}"""
|
||||
val list = KadenceJson.decodeFromString<TaskListDto>(json)
|
||||
assertEquals(1, list.tasks.size)
|
||||
assertEquals(9L, list.tasks[0].id)
|
||||
assertEquals("", list.tasks[0].title)
|
||||
assertEquals("someday", list.tasks[0].bucket)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every fixture round-trips through the parser`() {
|
||||
// Cheap guard that no fixture has drifted into invalid JSON.
|
||||
val names = listOf(
|
||||
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
|
||||
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
|
||||
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
|
||||
)
|
||||
for (name in names) {
|
||||
val parsed = KadenceJson.parseToJsonElement(fixture(name))
|
||||
assertTrue(parsed is JsonObject, "$name should be a JSON object")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import tech.kadence.android.data.dto.TimerStateDto
|
||||
import tech.kadence.android.data.net.ApiResult
|
||||
import tech.kadence.android.data.net.AuthConfig
|
||||
import tech.kadence.android.data.net.KadenceApi
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The HTTP layer, against a mock server.
|
||||
*
|
||||
* Nothing here needs the real backend running — the backend lives only on the dev
|
||||
* machine, so a suite that required it would be unrunnable anywhere else. MockWebServer
|
||||
* serves the committed fixtures instead.
|
||||
*/
|
||||
class KadenceApiTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private var token: String? = "s3cret-token"
|
||||
|
||||
private val api: KadenceApi by lazy {
|
||||
KadenceApi(config = {
|
||||
object : AuthConfig {
|
||||
override val baseUrl = server.url("/").toString().trimEnd('/')
|
||||
override val apiToken = token
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
|
||||
.bufferedReader().readText()
|
||||
|
||||
private fun json(code: Int, body: String) =
|
||||
MockResponse.Builder()
|
||||
.code(code)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.build()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the api token goes out as X-API-Token, like the desktop client`() = runBlocking {
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.Ok<TimerStateDto>>(result)
|
||||
assertEquals(412L, result.value.current?.id)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("s3cret-token", recorded.headers["X-API-Token"])
|
||||
assertEquals("application/json", recorded.headers["Accept"])
|
||||
assertEquals("/api/timer/state", recorded.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no token means no header, for a backend that wants none`() = runBlocking {
|
||||
token = null
|
||||
server.enqueue(json(200, fixture("timer_state_idle.json")))
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertNull(server.takeRequest().headers["X-API-Token"])
|
||||
token = "s3cret-token"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session cookie is kept and sent back`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse.Builder()
|
||||
.code(200)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Set-Cookie", "kadence_session=abc123; Path=/; HttpOnly; Max-Age=2592000")
|
||||
.body("""{"ok":true}""")
|
||||
.build(),
|
||||
)
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"timka","password":"x"}""")
|
||||
server.takeRequest()
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
val second = server.takeRequest()
|
||||
|
||||
assertTrue(
|
||||
second.headers["Cookie"]?.contains("kadence_session=abc123") == true,
|
||||
"the session cookie should ride along: ${second.headers["Cookie"]}",
|
||||
)
|
||||
assertEquals("abc123", api.cookieJar.named("kadence_session")?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on a normal path notifies so the UI can drop to login`() = runBlocking {
|
||||
var notified = 0
|
||||
api.unauthorized.subscribe { notified++ }
|
||||
|
||||
server.enqueue(json(401, """{"detail":"not authenticated"}"""))
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertIs<ApiResult.Unauthorized>(result)
|
||||
assertEquals("not authenticated", result.detail)
|
||||
assertEquals(1, notified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on the login path does not, or signing in would loop`() = runBlocking {
|
||||
var notified = 0
|
||||
api.unauthorized.subscribe { notified++ }
|
||||
|
||||
server.enqueue(json(401, """{"detail":"wrong password"}"""))
|
||||
val result = api.post<Map<String, Boolean>>("/api/auth/login", """{"username":"a"}""")
|
||||
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(401, result.status)
|
||||
assertEquals("wrong password", result.detail)
|
||||
assertEquals(0, notified, "a failed sign-in must not trigger the re-auth bus")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain detail string is surfaced as the message`() = runBlocking {
|
||||
// What `TimerError` produces, e.g. refusing to pause when pausing is off.
|
||||
server.enqueue(json(400, """{"detail":"Pausing is switched off in your settings."}"""))
|
||||
|
||||
val result = api.post<TimerStateDto>("/api/timer/pause")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(400, result.status)
|
||||
assertEquals("Pausing is switched off in your settings.", result.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 422 validation body renders as field colon message`() = runBlocking {
|
||||
server.enqueue(
|
||||
json(
|
||||
422,
|
||||
"""{"detail":[
|
||||
{"loc":["body","minutes"],"msg":"Input should be less than or equal to 240"},
|
||||
{"loc":["body","kind"],"msg":"Input should be 'work', 'short_break' or 'long_break'"}
|
||||
]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = api.post<TimerStateDto>("/api/timer/start", """{"minutes":9999}""")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(422, result.status)
|
||||
assertEquals(
|
||||
"minutes: Input should be less than or equal to 240; " +
|
||||
"kind: Input should be 'work', 'short_break' or 'long_break'",
|
||||
result.detail,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-JSON error body falls back to the status line`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse.Builder().code(502).body("<html>Bad Gateway</html>").build(),
|
||||
)
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertEquals(502, result.status)
|
||||
assertTrue(result.detail.startsWith("502"), "got: ${result.detail}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable backend is a NetworkError, never a crash`() = runBlocking {
|
||||
server.close() // nothing listening any more
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.NetworkError>(result)
|
||||
|
||||
// Restart so tearDown has something to close.
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful but unreadable body does not crash the caller`() = runBlocking {
|
||||
server.enqueue(json(200, """{"current": "this should be an object"}"""))
|
||||
|
||||
val result = api.get<TimerStateDto>("/api/timer/state")
|
||||
assertIs<ApiResult.HttpError>(result)
|
||||
assertTrue(result.detail.startsWith("unreadable response"), "got: ${result.detail}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a POST with no body still sends an empty object`() = runBlocking {
|
||||
// FastAPI's request models default every field, but an absent body on a POST
|
||||
// that declares one is a 422; the desktop client sends `{}` for the same reason.
|
||||
server.enqueue(json(200, fixture("timer_state_running.json")))
|
||||
|
||||
api.post<TimerStateDto>("/api/timer/resume")
|
||||
val recorded = server.takeRequest()
|
||||
|
||||
assertEquals("POST", recorded.method)
|
||||
assertEquals("{}", recorded.body?.utf8())
|
||||
assertTrue(recorded.headers["Content-Type"]?.startsWith("application/json") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing slashes in the configured URL do not produce a double slash`() = runBlocking {
|
||||
server.enqueue(json(200, fixture("timer_state_idle.json")))
|
||||
|
||||
api.get<TimerStateDto>("/api/timer/state")
|
||||
|
||||
assertEquals("/api/timer/state", server.takeRequest().target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import tech.kadence.android.ui.components.TabularFigures
|
||||
import tech.kadence.android.ui.theme.KadenceTheme
|
||||
import tech.kadence.core.KADENCE_DARK
|
||||
import tech.kadence.core.KADENCE_LIGHT
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The theme and the type, on the JVM.
|
||||
*
|
||||
* Robolectric means the Compose layer is checked here rather than on the phone: the
|
||||
* palette actually reaching a composable, and — the one that matters — that the serif
|
||||
* clock really is fixed-advance despite Fraunces having no `tnum`.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ThemeTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun argb(color: Color): Long = color.value.toLong() ushr 32 and 0xFFFFFFFFL
|
||||
|
||||
@Test
|
||||
fun `the light palette reaching a composable is the core palette`() {
|
||||
var paper: Color? = null
|
||||
var ink: Color? = null
|
||||
var rule: Color? = null
|
||||
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
paper = KadenceTheme.colors.paper
|
||||
ink = KadenceTheme.colors.ink
|
||||
rule = KadenceTheme.colors.rule
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(KADENCE_LIGHT.paper, argb(paper!!), "paper")
|
||||
assertEquals(KADENCE_LIGHT.ink, argb(ink!!), "ink")
|
||||
assertEquals(KADENCE_LIGHT.rule, argb(rule!!), "rule keeps its 12% alpha")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dark palette is the dark one, not a tinted light`() {
|
||||
var paper: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = true, dynamicAccent = false) {
|
||||
paper = KadenceTheme.colors.paper
|
||||
}
|
||||
}
|
||||
assertEquals(KADENCE_DARK.paper, argb(paper!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without dynamic colour the accent is the brand ember`() {
|
||||
var accent: Color? = null
|
||||
var onAccent: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
accent = KadenceTheme.colors.accent
|
||||
onAccent = KadenceTheme.colors.onAccent
|
||||
}
|
||||
}
|
||||
assertEquals(KADENCE_LIGHT.ember, argb(accent!!))
|
||||
assertEquals(0xFFFFFFFFL, argb(onAccent!!), "white sits on ember")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chart palette does not follow the accent`() {
|
||||
var work: Color? = null
|
||||
var accent: Color? = null
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = true) {
|
||||
work = KadenceTheme.viz.work
|
||||
accent = KadenceTheme.colors.accent
|
||||
}
|
||||
}
|
||||
assertEquals(0xFFEB6834L, argb(work!!), "work keeps its validated orange")
|
||||
assertTrue(work != accent || argb(accent!!) == 0xFFEB6834L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the serif clock is fixed-advance despite Fraunces having no tnum`() {
|
||||
// The whole point of TabularFigures. "11:11" is the narrowest possible set of
|
||||
// digits and "00:00" among the widest; with proportional figures these would
|
||||
// differ by roughly 30% and the dial would visibly jiggle every second.
|
||||
compose.setContent {
|
||||
KadenceTheme(dark = false, dynamicAccent = false) {
|
||||
Row {
|
||||
TabularFigures(
|
||||
text = "11:11",
|
||||
style = KadenceTheme.type.clock,
|
||||
color = KadenceTheme.colors.ink,
|
||||
modifier = Modifier.testTag("ones"),
|
||||
)
|
||||
TabularFigures(
|
||||
text = "00:00",
|
||||
style = KadenceTheme.type.clock,
|
||||
color = KadenceTheme.colors.ink,
|
||||
modifier = Modifier.testTag("zeros"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ones = compose.onNodeWithTag("ones").getUnclippedBoundsInRoot()
|
||||
val zeros = compose.onNodeWithTag("zeros").getUnclippedBoundsInRoot()
|
||||
val onesWidth = (ones.right - ones.left).value
|
||||
val zerosWidth = (zeros.right - zeros.left).value
|
||||
|
||||
// Guard against a stubbed text measurer: if both came back zero the comparison
|
||||
// below would pass without proving anything.
|
||||
assertTrue(onesWidth > 0f, "the figures did not actually measure")
|
||||
assertTrue(
|
||||
abs(onesWidth - zerosWidth) < 0.5f,
|
||||
"11:11 measured $onesWidth dp and 00:00 measured $zerosWidth dp — " +
|
||||
"the clock would jiggle every second",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"authenticated": true,
|
||||
"login_required": true,
|
||||
"username": "timka",
|
||||
"password_login_enabled": true,
|
||||
"telegram_login_enabled": false,
|
||||
"telegram_bot_username": ""
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"total": 16,
|
||||
"by_target": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
|
||||
"events": [
|
||||
{"id": 90, "kind": "domain", "target": "youtube.com", "action": "blocked", "at": "2026-10-19T10:04:00+00:00", "details": "chrome"},
|
||||
{"id": 91, "kind": "app", "target": "*discord*", "action": "killed", "at": "2026-10-19T10:06:00+00:00", "details": "Discord (com.discord)"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"active": true,
|
||||
"reason": "a work interval is running",
|
||||
"mode": "during_work",
|
||||
"enabled": true,
|
||||
"strict": false,
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||
"apps": ["steam*", "*discord*"],
|
||||
"unexpandable": ["*gram*"],
|
||||
"kill_apps": true,
|
||||
"notify_on_block": true,
|
||||
"poll_seconds": 5,
|
||||
"session_id": 412,
|
||||
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||
"all_apps": ["steam*", "*discord*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"active": false,
|
||||
"reason": "no work interval is running",
|
||||
"mode": "during_work",
|
||||
"enabled": true,
|
||||
"strict": false,
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": [],
|
||||
"apps": [],
|
||||
"unexpandable": ["*gram*"],
|
||||
"kill_apps": true,
|
||||
"notify_on_block": true,
|
||||
"poll_seconds": 5,
|
||||
"session_id": null,
|
||||
"all_domains": ["youtube.com", "www.youtube.com", "m.youtube.com"],
|
||||
"all_apps": ["steam*", "*discord*"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"report": {
|
||||
"date": "2026-10-19",
|
||||
"metrics": {
|
||||
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
|
||||
"focus_minutes": 155, "break_minutes": 35, "wasted_minutes": 12, "abandoned": 1,
|
||||
"breaks_taken": 4, "interruptions": 3, "completion_rate": 0.86,
|
||||
"longest_stretch_minutes": 80, "first_start": "2026-10-19T09:12:00+00:00",
|
||||
"last_end": "2026-10-19T18:40:00+00:00",
|
||||
"by_hour": [{"hour": 9, "count": 1}, {"hour": 10, "count": 2}, {"hour": 14, "count": 3}],
|
||||
"peak_hour": 10,
|
||||
"tasks_completed": [
|
||||
{"id": 1, "title": "Fix the flaky importer", "source": "jira", "project": "Platform", "url": "https://example.atlassian.net/browse/PLAT-184"}
|
||||
],
|
||||
"tasks_completed_count": 1,
|
||||
"time_by_task": [{"title": "Fix the flaky importer", "minutes": 75}],
|
||||
"blocked_attempts": 16,
|
||||
"blocked_top": [{"target": "youtube.com", "count": 12}, {"target": "reddit.com", "count": 4}],
|
||||
"sessions": [
|
||||
{"id": 412, "kind": "work", "status": "completed", "started_at": "2026-10-19T09:12:00+00:00", "minutes": 25, "task": "Fix the flaky importer", "interruptions": 1}
|
||||
]
|
||||
},
|
||||
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
|
||||
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
|
||||
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
|
||||
"comparison": {"yesterday": 7, "previous_best": 11},
|
||||
"achievements": [
|
||||
{"code": "deep_work", "emoji": "🧘", "title": "Deep work", "detail": "80 minutes without breaking the chain."},
|
||||
{"code": "gatekeeper", "emoji": "🛡️", "title": "Gatekeeper", "detail": "The blocker turned you away 16 times. It won."}
|
||||
],
|
||||
"fun_facts": [
|
||||
"155 minutes of focus — that is 7.0 sitcom episodes you did not watch.",
|
||||
"Your peak hour was 10:00. Defend it."
|
||||
],
|
||||
"verdict": "Target hit. Now protect the streak. 💪",
|
||||
"open_tasks": {"overdue": 1, "today": 1, "someday": 1},
|
||||
"score": 72.4
|
||||
},
|
||||
"sent_at": null,
|
||||
"telegram_preview": "🍅 Daily report — 2026-10-19"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"today": {
|
||||
"date": "2026-10-19", "pomodoros": 6, "goal": 8, "goal_pct": 75.0, "goal_met": false,
|
||||
"focus_minutes": 155, "break_minutes": 35, "interruptions": 3,
|
||||
"by_hour": [{"hour": 10, "count": 2}], "peak_hour": 10,
|
||||
"blocked_attempts": 16, "sessions": []
|
||||
},
|
||||
"streak": {"current": 4, "best": 11, "goal_streak": 2, "total_days": 63},
|
||||
"lifetime": {"pomodoros": 390, "focus_minutes": 9750, "focus_hours": 162.5, "tasks_completed": 88, "blocked_attempts": 512},
|
||||
"average_7d": {"pomodoros": 5.4, "focus_minutes": 142.3},
|
||||
"range": [
|
||||
{"date": "2026-10-17", "pomodoros": 5, "focus_minutes": 125, "goal_met": false},
|
||||
{"date": "2026-10-18", "pomodoros": 7, "focus_minutes": 175, "goal_met": false},
|
||||
{"date": "2026-10-19", "pomodoros": 6, "focus_minutes": 155, "goal_met": false}
|
||||
],
|
||||
"heatmap": [
|
||||
{"date": "2026-10-13", "pomodoros": 8, "focus_minutes": 200, "goal_met": true},
|
||||
{"date": "2026-10-14", "pomodoros": 0, "focus_minutes": 0, "goal_met": false}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1, "source": "jira", "source_label": "Jira", "external_id": "PLAT-184",
|
||||
"title": "Fix the flaky importer", "description": null,
|
||||
"url": "https://example.atlassian.net/browse/PLAT-184",
|
||||
"project": "Platform", "status": "In Progress", "priority": "High",
|
||||
"due_date": "2026-10-16", "start_date": null, "labels": ["backend"],
|
||||
"is_done": false, "pinned": true, "stale": false,
|
||||
"estimated_pomodoros": 3, "days_overdue": 3, "bucket": "overdue"
|
||||
},
|
||||
{
|
||||
"id": 2, "source": "todoist", "source_label": "Todoist", "external_id": "7788",
|
||||
"title": "Write the design doc", "description": null, "url": null,
|
||||
"project": "Work", "status": null, "priority": "p2",
|
||||
"due_date": "2026-10-19", "start_date": null, "labels": [],
|
||||
"is_done": false, "pinned": false, "stale": false,
|
||||
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "today"
|
||||
},
|
||||
{
|
||||
"id": 3, "source": "local", "source_label": "Local", "external_id": "local-3",
|
||||
"title": "And this too", "description": null, "url": null,
|
||||
"project": null, "status": null, "priority": null,
|
||||
"due_date": null, "start_date": null, "labels": [],
|
||||
"is_done": false, "pinned": false, "stale": false,
|
||||
"estimated_pomodoros": null, "days_overdue": 0, "bucket": "someday"
|
||||
}
|
||||
],
|
||||
"counts": {"overdue": 1, "today": 1, "active": 0, "upcoming": 0, "someday": 1},
|
||||
"sources": ["jira", "todoist", "local"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"current": {
|
||||
"id": 413,
|
||||
"kind": "short_break",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:37:04+00:00",
|
||||
"ended_at": null,
|
||||
"planned_seconds": 300,
|
||||
"elapsed_seconds": 0,
|
||||
"remaining_seconds": 300,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 0,
|
||||
"cycle_index": 3,
|
||||
"task": null,
|
||||
"note": null
|
||||
},
|
||||
"finished": {
|
||||
"id": 412,
|
||||
"kind": "work",
|
||||
"status": "completed",
|
||||
"started_at": "2026-10-19T09:12:04+00:00",
|
||||
"ended_at": "2026-10-19T09:37:04+00:00",
|
||||
"planned_seconds": 1500,
|
||||
"elapsed_seconds": 1500,
|
||||
"remaining_seconds": 0,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 1,
|
||||
"cycle_index": 2,
|
||||
"task": {"source": "jira", "external_id": "PLAT-184", "title": "Fix the flaky importer"},
|
||||
"note": null
|
||||
},
|
||||
"auto_started": {
|
||||
"id": 413,
|
||||
"kind": "short_break",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:37:04+00:00",
|
||||
"planned_seconds": 300,
|
||||
"remaining_seconds": 300,
|
||||
"interruptions": 0,
|
||||
"cycle_index": 3,
|
||||
"task": null
|
||||
},
|
||||
"celebration": "Seven down. The afternoon is yours to lose.",
|
||||
"next_kind": "work",
|
||||
"completed_today": 7,
|
||||
"goal": 8,
|
||||
"cycle_position": 3,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 25, "short_break": 5, "long_break": 15},
|
||||
"server_time": "2026-10-19T09:37:04+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"current": null,
|
||||
"next_kind": "work",
|
||||
"completed_today": 0,
|
||||
"goal": 8,
|
||||
"cycle_position": 0,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 45, "short_break": 15, "long_break": 30},
|
||||
"server_time": "2026-10-19T08:00:00+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"current": {
|
||||
"id": 412,
|
||||
"kind": "work",
|
||||
"status": "running",
|
||||
"started_at": "2026-10-19T09:12:04+00:00",
|
||||
"ended_at": null,
|
||||
"planned_seconds": 1500,
|
||||
"elapsed_seconds": 396,
|
||||
"remaining_seconds": 1104,
|
||||
"paused_seconds": 0,
|
||||
"interruptions": 1,
|
||||
"cycle_index": 2,
|
||||
"task": {
|
||||
"source": "jira",
|
||||
"external_id": "PLAT-184",
|
||||
"title": "Fix the flaky importer"
|
||||
},
|
||||
"note": null
|
||||
},
|
||||
"next_kind": "short_break",
|
||||
"completed_today": 6,
|
||||
"goal": 8,
|
||||
"cycle_position": 2,
|
||||
"cycles_before_long_break": 4,
|
||||
"durations": {"work": 25, "short_break": 5, "long_break": 15},
|
||||
"server_time": "2026-10-19T09:18:40+00:00",
|
||||
"timer_enabled": true
|
||||
}
|
||||
Reference in new issue
Block a user