some backend fixes and add android app

This commit is contained in:
timka123 committed 2026-10-19 14:52:20 +03:00
1 parent 772fdbfded
commit 1e55144291
69 files changed
+7111 -7

No files matched your search

@@ -0,0 +1,191 @@
/**
* The blocking policy, ported so the phone can enforce it offline.
*
* The server decides *whether* blocking is on and *what* is on the list
* (`backend/app/services/blocker_service.py`); every client enforces it. Online, the
* server's `policy.active` is authoritative. Offline, this file has to reach the same
* answer on its own — otherwise losing signal would quietly switch the focus guard off,
* which is the one moment you least want it off.
*
* Two API details worth knowing, both verified against `policy()`:
*
* * `policy.domains` and `policy.apps` are **empty whenever `active` is false**, while
* `all_domains` / `all_apps` are always populated. So a client must read the `all_*`
* fields and gate on activity itself — which `desktop/.../controller.py` also does.
* * `mode: "always"` and `"scheduled"` mean the guard runs with no timer at all, so
* enforcement cannot be tied to the timer's lifecycle.
*/
package tech.kadence.core
import java.time.ZoneId
import java.time.ZonedDateTime
/** One `blocker.schedule[]` entry. `days` is ISO weekdays with **Monday = 0**. */
data class BlockWindow(
val label: String = "",
val start: String = "00:00",
val end: String = "23:59",
val days: List<Int> = emptyList(),
)
/** The `blocker` subtree of the settings document. */
data class BlockerRules(
val enabled: Boolean = false,
val mode: String = "during_work",
val blockDuringBreaks: Boolean = false,
val blockedDomains: List<String> = emptyList(),
val allowedDomains: List<String> = emptyList(),
val subdomainExpansions: List<String> = DEFAULT_SUBDOMAINS,
val blockedApps: List<String> = emptyList(),
val killBlockedApps: Boolean = true,
val schedule: List<BlockWindow> = emptyList(),
val strict: Boolean = false,
val notifyOnBlock: Boolean = true,
) {
companion object {
/** The backend's `subdomain_expansions` default. */
val DEFAULT_SUBDOMAINS = listOf("www", "m", "mobile", "app", "web", "i", "old", "new")
}
}
/** Just enough of the running interval for the policy to be decided. */
data class IntervalFacts(
val kind: String,
val status: String,
) {
val isActiveStatus: Boolean get() = status == "running" || status == "paused"
val isWork: Boolean get() = kind == "work"
}
/** The verdict, carrying the same `reason` string the server would have sent. */
data class PolicyVerdict(val active: Boolean, val reason: String)
/**
* `youtube.com` -> itself plus `www.` and `m.`; `*.youtube.com` -> the bare domain plus
* every configured subdomain; `*gram*` -> nothing expandable, returned as a leftover.
*
* A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`.
*/
fun expandEntry(raw: String, subdomains: List<String>): Pair<List<String>, String?> {
val entry = raw.trim().lowercase().trimStart('.')
if (entry.isEmpty()) return emptyList<String>() to null
if (entry.startsWith("*.")) {
val base = entry.removePrefix("*.")
if (base.isEmpty() || '*' in base) return emptyList<String>() to entry
return (listOf(base) + subdomains.map { "$it.$base" }) to null
}
if ('*' in entry) return emptyList<String>() to entry
return listOf(entry, "www.$entry", "m.$entry") to null
}
/** Whether an allow-list entry — itself maskable — covers this hostname. */
fun isAllowed(host: String, allowMasks: List<String>): Boolean =
allowMasks.any { globMatches(it, host) }
/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */
fun allowMasksOf(rules: BlockerRules): List<String> = rules.allowedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
private fun subdomainsOf(rules: BlockerRules): List<String> = rules.subdomainExpansions
.filter { it.isNotBlank() }
.map { it.trim().lowercase() }
/**
* Every hostname a hosts file would be pointed at the redirect IP, allow list already
* subtracted — the client-side twin of `expand_domains()`, used when offline and as a
* cross-check against the server's `all_domains`.
*/
fun expandDomains(rules: BlockerRules): List<String> = expandDomainsWithOrigin(rules).keys.toList()
/**
* The same expansion, but remembering which block-list entry produced each hostname.
*
* Worth keeping, because the host alone does not identify the rule: `*.reddit.com`
* expands to the bare `reddit.com`, yet the mask does not *match* that bare name (see
* [globMatches]). Without the provenance the overlay would have to say "blocked:
* reddit.com" when the honest answer is "blocked by `*.reddit.com`".
*
* Insertion-ordered, and the first entry to claim a host keeps it.
*/
fun expandDomainsWithOrigin(rules: BlockerRules): Map<String, String> {
val allowMasks = allowMasksOf(rules)
val subdomains = subdomainsOf(rules)
val origin = LinkedHashMap<String, String>()
for (raw in rules.blockedDomains) {
val entry = raw.trim().lowercase().trimStart('.')
val (expanded, _) = expandEntry(raw, subdomains)
for (host in expanded) {
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
}
}
return origin
}
/** Block-list entries a hosts file cannot express, so the UI can flag them. */
fun unexpandableMasks(rules: BlockerRules): List<String> {
val subdomains = subdomainsOf(rules)
val leftovers = LinkedHashSet<String>()
for (raw in rules.blockedDomains) {
val (_, pattern) = expandEntry(raw, subdomains)
if (pattern != null) leftovers.add(pattern)
}
return leftovers.toList()
}
/**
* Whether any schedule window covers this moment.
*
* Both the weekday and the time of day are read in the **server's** timezone, so
* travelling does not shift your blocking windows. (The backend's `schedule_active()`
* takes the weekday from the process's local clock while taking the time from the
* configured zone; reading both from one zone is the behaviour that was intended.)
*/
fun scheduleActive(rules: BlockerRules, now: ZonedDateTime): Boolean {
val weekday = now.dayOfWeek.value - 1
val moment = now.toLocalTime()
return rules.schedule.any { window ->
if (weekday !in window.days) return@any false
val start = parseClockTime(window.start) ?: return@any false
val end = parseClockTime(window.end) ?: return@any false
inWindow(moment, start, end)
}
}
/**
* The port of `policy()`'s decision, reason strings included — so the Blocker screen
* reads identically whether the verdict came from the server or from here.
*/
fun evaluatePolicy(
rules: BlockerRules,
current: IntervalFacts?,
now: ZonedDateTime,
): PolicyVerdict {
if (!rules.enabled) return PolicyVerdict(false, "disabled")
return when (rules.mode) {
"always" -> PolicyVerdict(true, "always on")
"scheduled" -> if (scheduleActive(rules, now)) {
PolicyVerdict(true, "inside a blocking window")
} else {
PolicyVerdict(false, "outside every window")
}
else -> when {
current != null && current.isWork && current.isActiveStatus ->
PolicyVerdict(true, "a work interval is running")
current != null && !current.isWork && rules.blockDuringBreaks &&
current.isActiveStatus ->
PolicyVerdict(true, "blocking stays on through breaks")
else -> PolicyVerdict(false, "no work interval is running")
}
}
}
/** Convenience for callers that only have an IANA zone id string. */
fun zoneOrDefault(timezone: String?): ZoneId =
runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() }
@@ -0,0 +1,87 @@
/**
* Formatting shared by every screen, so numbers read the same everywhere.
*
* A literal port of `frontend/src/lib/format.ts`, down to the rounding. The web app,
* the QML `Theme.humanMinutes()` and this file have to agree: the same pomodoro seen
* in a browser, on the desktop and on the phone must not render three different ways.
* `FormatTest` asserts the exact outputs the TypeScript produces.
*/
package tech.kadence.core
import java.time.LocalDate
import kotlin.math.roundToInt
/** `"18:24"`, and `"1:05:00"` only once there is an hour to show. */
fun clock(totalSeconds: Int): String {
val safe = totalSeconds.coerceAtLeast(0)
val hours = safe / 3600
val minutes = (safe % 3600) / 60
val seconds = safe % 60
return if (hours > 0) {
"$hours:${pad(minutes)}:${pad(seconds)}"
} else {
"${pad(minutes)}:${pad(seconds)}"
}
}
/** The web app's `clock()` takes a float and rounds; a drifting tick can land here. */
fun clock(totalSeconds: Double): String = clock(totalSeconds.roundToInt())
/**
* `125` -> `"2h 5m"`, `120` -> `"2h"`, `5` -> `"5m"`.
*
* Negative input is clamped to zero rather than reproducing JavaScript's `"-1h -5m"`:
* a negative duration is a bug upstream, and printing it as nonsense hides it.
*/
fun humanMinutes(minutes: Int): String {
val rounded = minutes.coerceAtLeast(0)
val hours = rounded / 60
val rest = rounded % 60
return when {
hours > 0 && rest > 0 -> "${hours}h ${rest}m"
hours > 0 -> "${hours}h"
else -> "${rest}m"
}
}
fun humanMinutes(minutes: Double): String = humanMinutes(minutes.roundToInt())
/** Interval kind -> the label every client shows. */
val KIND_LABEL: Map<String, String> = mapOf(
"work" to "Focus",
"short_break" to "Short break",
"long_break" to "Long break",
)
/**
* Task bucket -> heading.
*
* Note `active`: the web app says "Would be nice to start" where the QML desktop says
* "In progress". The web app is the design reference for this client, so the web
* wording wins — see `frontend/src/lib/format.ts`.
*/
val BUCKET_LABEL: Map<String, String> = mapOf(
"overdue" to "Overdue",
"today" to "Due today",
"active" to "Would be nice to start",
"upcoming" to "Coming up",
"someday" to "No deadline",
)
/** The order the buckets are laid out in, top to bottom. */
val BUCKET_ORDER: List<String> = listOf("overdue", "today", "active", "upcoming", "someday")
/** `""` when undated, `"3d overdue"` when late, otherwise the ISO due date. */
fun dueLabel(dueDate: String?, daysOverdue: Int): String = when {
dueDate.isNullOrBlank() -> ""
daysOverdue > 0 -> "${daysOverdue}d overdue"
else -> dueDate
}
/** `"2026-10-19"` -> `"10/19"`, for a chart axis with no room for a year. */
fun shortDate(iso: String): String = iso.drop(5).replaceFirst("-", "/")
/** Monday = 0, matching the backend's `BlockWindow.days` and the heatmap's columns. */
fun weekdayOf(iso: String): Int = LocalDate.parse(iso).dayOfWeek.value - 1
private fun pad(value: Int): String = value.toString().padStart(2, '0')
@@ -0,0 +1,89 @@
/**
* Python's `fnmatch`, in Kotlin.
*
* The block lists are shared across clients and were written for a Python backend:
* `steam*`, `*discord*`, `*.youtube.com`. Both the server
* (`backend/app/services/blocker_service.py`) and the desktop enforcer
* (`desktop/kadence_desktop/blocking/processes.py`) match them with `fnmatch`, so the
* phone has to use the same grammar or the same list would mean two different things.
*
* `fnmatch` is not shell globbing: `*` matches *everything*, separators included, which
* is exactly why `*gram*` catches `instagram.com`.
*/
package tech.kadence.core
/** The wildcard characters whose presence makes an entry a mask rather than a literal. */
private const val WILDCARDS = "*?["
/** Whether this entry is a mask at all — the branch `processes.matches()` takes first. */
fun isMask(pattern: String): Boolean = pattern.any { it in WILDCARDS }
/**
* Translate an `fnmatch` pattern to a `Regex` anchored at both ends.
*
* Compiled patterns are cached: the app matcher runs this against every block-list
* entry several times a second while a focus interval is live.
*/
fun globToRegex(pattern: String): Regex = globCache.getOrPut(pattern) {
val out = StringBuilder()
var i = 0
while (i < pattern.length) {
when (val c = pattern[i]) {
'*' -> {
out.append(".*")
i++
}
'?' -> {
out.append('.')
i++
}
'[' -> {
// Find the closing bracket. `]` immediately after `[` or `[!` is a
// literal, which is why the scan starts past it.
var j = i + 1
if (j < pattern.length && (pattern[j] == '!' || pattern[j] == '^')) j++
if (j < pattern.length && pattern[j] == ']') j++
while (j < pattern.length && pattern[j] != ']') j++
if (j >= pattern.length) {
// Unterminated class: `fnmatch` treats the `[` as a literal.
out.append("\\[")
i++
} else {
val body = pattern.substring(i + 1, j)
out.append('[')
when {
body.startsWith('!') -> out.append('^').append(quoteClass(body.drop(1)))
body.startsWith('^') -> out.append("\\^").append(quoteClass(body.drop(1)))
else -> out.append(quoteClass(body))
}
out.append(']')
i = j + 1
}
}
else -> {
out.append(Regex.escape(c.toString()))
i++
}
}
}
Regex(out.toString(), RegexOption.DOT_MATCHES_ALL)
}
/** `fnmatch(text, pattern)` — case-sensitive, like Python's on Linux. */
fun globMatches(pattern: String, text: String): Boolean =
globToRegex(pattern).matches(text)
/**
* Inside a character class only `]`, `\` and `^` need care; `*` and `?` are literal
* there, so they must not be escaped into something a regex reads differently.
*/
private fun quoteClass(body: String): String = buildString {
for (c in body) {
when (c) {
'\\', ']', '^', '[', '&' -> append('\\').append(c)
else -> append(c)
}
}
}
private val globCache = HashMap<String, Regex>()
@@ -0,0 +1,224 @@
/**
* What to light up on the Nothing Phone (3)'s rear Glyph Matrix.
*
* Deliberately pure: the SDK wants a flat array of per-dot brightness, so all the
* interesting work — which dots make a "4", where the ring's ticks fall, how it drains
* — is arithmetic that needs no device, no `.aar` and no Android at all. The whole rear
* display can therefore be developed and regression-tested on a laptop, with
* [asciiOf] rendering any frame as art you can read in a test failure.
*
* The matrix is 25x25 = 625 dots on Phone (3) (`Glyph.DEVICE_23112`), but the length is
* read from `Common.getDeviceMatrixLength()` at runtime, so nothing here hard-codes 25
* beyond a default.
*/
package tech.kadence.core
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
/** A mutable grid of dot brightness, 0..255. */
class GlyphFrame(val size: Int = DEFAULT_SIZE) {
val dots = IntArray(size * size)
fun set(x: Int, y: Int, brightness: Int) {
if (x < 0 || y < 0 || x >= size || y >= size) return
dots[y * size + x] = brightness.coerceIn(0, 255)
}
fun get(x: Int, y: Int): Int =
if (x < 0 || y < 0 || x >= size || y >= size) 0 else dots[y * size + x]
/** The flat array `GlyphMatrixManager.setMatrixFrame` expects. */
fun render(): IntArray = dots.copyOf()
val litCount: Int get() = dots.count { it > 0 }
companion object {
const val DEFAULT_SIZE = 25
}
}
/** Brightness levels, kept together so "paused looks dimmer" is one decision. */
object GlyphBrightness {
const val FULL = 255
const val DIM = 90
const val FAINT = 40
}
/**
* A 3x5 dot font. Small enough that two digits fit beside each other inside the ring,
* large enough to read at arm's length on a desk.
*/
private val DIGITS: Map<Char, Array<String>> = mapOf(
'0' to arrayOf("###", "# #", "# #", "# #", "###"),
'1' to arrayOf(" #", " #", " #", " #", " #"),
'2' to arrayOf("###", " #", "###", "# ", "###"),
'3' to arrayOf("###", " #", "###", " #", "###"),
'4' to arrayOf("# #", "# #", "###", " #", " #"),
'5' to arrayOf("###", "# ", "###", " #", "###"),
'6' to arrayOf("###", "# ", "###", "# #", "###"),
'7' to arrayOf("###", " #", " #", " #", " #"),
'8' to arrayOf("###", "# #", "###", "# #", "###"),
'9' to arrayOf("###", "# #", "###", " #", "###"),
)
private const val GLYPH_W = 3
private const val GLYPH_H = 5
private const val GLYPH_GAP = 1
/** Draw a run of digits with its top-left corner at ([x], [y]). */
fun GlyphFrame.drawDigits(text: String, x: Int, y: Int, brightness: Int) {
var cursor = x
for (ch in text) {
val glyph = DIGITS[ch]
if (glyph == null) {
cursor += GLYPH_W + GLYPH_GAP
continue
}
for (row in 0 until GLYPH_H) {
for (col in 0 until GLYPH_W) {
if (glyph[row][col] == '#') set(cursor + col, y + row, brightness)
}
}
cursor += GLYPH_W + GLYPH_GAP
}
}
/** Width in dots of [text] in the 3x5 font. */
fun digitsWidth(text: String): Int =
if (text.isEmpty()) 0 else text.length * GLYPH_W + (text.length - 1) * GLYPH_GAP
/** Draw digits centred horizontally. */
fun GlyphFrame.drawDigitsCentred(text: String, y: Int, brightness: Int) {
drawDigits(text, (size - digitsWidth(text)) / 2, y, brightness)
}
/**
* The ring: one dot per dial tick around the perimeter, lit for the time **remaining**.
*
* Tick 0 sits at twelve o'clock and they run clockwise, and the lit run starts there —
* so the arc shrinks *towards* twelve as the interval drains, which is how the web
* dial's arc reads (`stroke-dashoffset` from `-90deg`). Lighting the trailing ticks
* instead would leave the last sliver stranded at eleven o'clock.
*
* The tick count comes from [dialTickCount], shared with the Compose dial, so the front
* and the back of the phone can never disagree about what one dot is worth.
*/
fun GlyphFrame.drawRing(
plannedSeconds: Int,
remainingSeconds: Int,
brightness: Int = GlyphBrightness.FULL,
spentBrightness: Int = 0,
) {
val count = dialTickCount(plannedSeconds)
val remainingTicks = count - dialSpentTicks(plannedSeconds, remainingSeconds)
val centre = (size - 1) / 2.0
val radius = centre - 1.0
for (i in 0 until count) {
val angle = i.toDouble() / count * 2 * PI - PI / 2
val x = (centre + radius * cos(angle)).roundToInt()
val y = (centre + radius * sin(angle)).roundToInt()
set(x, y, if (i < remainingTicks) brightness else spentBrightness)
}
}
/** How the toy should look right now. */
object GlyphRenderer {
/**
* The timer face: minutes above seconds, inside a draining ring.
*
* Past an hour the top row becomes hours and the bottom minutes, because `mm` would
* otherwise read as a number far larger than the matrix can show.
*/
fun timerFrame(
remainingSeconds: Int,
plannedSeconds: Int,
paused: Boolean = false,
size: Int = GlyphFrame.DEFAULT_SIZE,
): GlyphFrame {
val frame = GlyphFrame(size)
val safe = remainingSeconds.coerceAtLeast(0)
val textBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL
val ringBrightness = if (paused) GlyphBrightness.DIM else GlyphBrightness.FULL
val top: Int
val bottom: Int
if (safe >= 3600) {
top = safe / 3600
bottom = (safe % 3600) / 60
} else {
top = safe / 60
bottom = safe % 60
}
frame.drawRing(plannedSeconds, safe, ringBrightness)
frame.drawDigitsCentred(pad2(top), TOP_ROW_Y, textBrightness)
frame.drawDigitsCentred(pad2(bottom), BOTTOM_ROW_Y, textBrightness)
return frame
}
/**
* The always-on face: today's pomodoros against the goal, as a dot grid.
*
* The same encoding as the web app's dot row (`TimerPage.tsx`) — earned dots solid,
* the rest faint — on 625 LEDs.
*/
fun dotsFrame(
done: Int,
goal: Int,
size: Int = GlyphFrame.DEFAULT_SIZE,
): GlyphFrame {
val frame = GlyphFrame(size)
val total = maxOf(goal, done, 1)
val perRow = minOf(DOTS_PER_ROW, total)
val rows = (total + perRow - 1) / perRow
val spacing = 3
val gridW = perRow * spacing - (spacing - 1)
val gridH = rows * spacing - (spacing - 1)
val originX = (size - gridW) / 2
val originY = (size - gridH) / 2
for (i in 0 until total) {
val x = originX + (i % perRow) * spacing
val y = originY + (i / perRow) * spacing
frame.set(x, y, if (i < done) GlyphBrightness.FULL else GlyphBrightness.FAINT)
}
return frame
}
/** Everything off — what `turnOff()` should leave behind. */
fun blank(size: Int = GlyphFrame.DEFAULT_SIZE): GlyphFrame = GlyphFrame(size)
private const val TOP_ROW_Y = 6
private const val BOTTOM_ROW_Y = 14
private const val DOTS_PER_ROW = 8
private fun pad2(value: Int): String =
if (value in 0..99) value.toString().padStart(2, '0') else value.toString()
}
/**
* Render a frame as text, so a failing assertion shows the picture.
*
* `#` is bright, `+` is dim, `.` is dark.
*/
fun asciiOf(frame: GlyphFrame): String = buildString {
for (y in 0 until frame.size) {
for (x in 0 until frame.size) {
val v = frame.get(x, y)
append(
when {
v >= 200 -> '#'
v > 0 -> '+'
else -> '.'
},
)
}
if (y < frame.size - 1) append('\n')
}
}
@@ -0,0 +1,200 @@
/**
* Deciding whether a hostname or an app is on the list.
*
* `AppMatcher` is a port of `matches()` in
* `desktop/kadence_desktop/blocking/processes.py`, with Android's two haystacks —
* the package name and the app's visible label — standing in for the Linux process
* name and its command line.
*
* `DomainMatcher` goes one step further than the desktop *can* go. A hosts file has no
* wildcards, so the backend has to expand every mask into concrete hostnames and
* reports whatever it cannot express as `unexpandable`. A URL-bar matcher has no such
* limit, so patterns like `*gram*` — which the desktop apologises for — are enforced
* here. That is the one place the phone is strictly stronger than the laptop.
*/
package tech.kadence.core
import java.net.IDN
/** What to do about a candidate, and why. */
sealed interface BlockDecision {
data object Allow : BlockDecision
/**
* @param target what gets reported to `POST /api/blocker/events` and grouped by
* `by_target` in the report — the hostname for a site, the matched pattern for an
* app, matching what the desktop enforcer sends.
* @param matchedRule the block-list entry responsible, for the overlay's copy.
*/
data class Block(
val target: String,
val matchedRule: String,
val action: String,
) : BlockDecision
}
/** `BlockEventRequest.action` — the server only fires `notify_on_block` for `killed`. */
object BlockAction {
/** A site we turned away; the hosts-file equivalent. */
const val BLOCKED = "blocked"
/** An app we actually ejected the user from. The only value that notifies. */
const val KILLED = "killed"
/** Reported but not enforced — what `kill_blocked_apps = false` means. */
const val WARNED = "warned"
}
/**
* Reduce whatever a browser's URL bar exposed to a bare hostname, or null when the text
* is not a host at all.
*
* The URL bar is a lossy source: it may show `youtube.com`, a full URL, a search
* phrase, a `chrome://` page, or a placeholder. Anything that is not confidently a
* hostname must return null — a false positive here would block an innocent page.
*/
fun normalizeHost(raw: String?): String? {
var text = raw?.trim()?.lowercase().orEmpty()
if (text.isEmpty()) return null
// A scheme we can never block, and the "about:" family.
if (text.startsWith("about:") || text.startsWith("chrome:") ||
text.startsWith("chrome-native:") || text.startsWith("data:") ||
text.startsWith("file:") || text.startsWith("content:")
) {
return null
}
// Strip the scheme.
val schemeAt = text.indexOf("://")
if (schemeAt >= 0) text = text.substring(schemeAt + 3)
// A space means a search phrase, not a host — but only once the scheme is gone,
// since "https://example.com /path" pasted with a stray space is still a URL.
text = text.trim()
if (text.isEmpty()) return null
// Authority ends at the first `/`, `?` or `#`.
text = text.takeWhile { it != '/' && it != '?' && it != '#' }
// Drop userinfo and the port.
val at = text.lastIndexOf('@')
if (at >= 0) text = text.substring(at + 1)
val colon = text.indexOf(':')
if (colon >= 0) text = text.substring(0, colon)
text = text.trim().trimEnd('.')
if (text.isEmpty() || ' ' in text) return null
// Unicode domains are matched in their ASCII form, which is what a block list holds.
//
// ALLOW_UNASSIGNED on purpose: strict `toASCII` *throws* on a host built from
// unassigned code points (a homoglyph like `аррӏе.com`), and a throw here would
// return null, which means "allow". A blocker that silently waves through the one
// category of domain designed to look like something else has it backwards.
val ascii = runCatching { IDN.toASCII(text, IDN.ALLOW_UNASSIGNED) }
.getOrNull()?.lowercase() ?: return null
return if (HOST_PATTERN.matches(ascii)) ascii else null
}
private val HOST_PATTERN =
Regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$")
/**
* Matches hostnames against the policy.
*
* @param expandedHosts the server's `all_domains` when online (already expanded and
* allow-list-subtracted), or [expandDomains]'s own output when offline.
*/
class DomainMatcher(
private val rules: BlockerRules,
expandedHosts: List<String> = expandDomains(rules),
) {
private val exact: Set<String> = expandedHosts.map { it.trim().lowercase() }.toSet()
private val allowMasks: List<String> = allowMasksOf(rules)
private val rawEntries: List<String> = rules.blockedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
/** host -> the block-list entry that expanded into it; see [expandDomainsWithOrigin]. */
private val origin: Map<String, String> = expandDomainsWithOrigin(rules)
/** @param candidate anything the URL bar gave us — a host, a URL, or junk. */
fun decide(candidate: String?): BlockDecision {
val host = normalizeHost(candidate) ?: return BlockDecision.Allow
// 1. The allow list wins outright: `*.google.com` blocked plus
// `*.docs.google.com` allowed does what you would expect.
if (isAllowed(host, allowMasks)) return BlockDecision.Allow
// 2. The expanded set — exactly what a hosts file would have caught. Name the
// entry that produced the host, falling back to a pattern that matches it
// and finally to the host itself (the server-supplied-list case, where we
// have no provenance).
if (host in exact) {
val rule = origin[host] ?: rawEntries.firstOrNull { entryCovers(it, host) } ?: host
return BlockDecision.Block(host, rule, BlockAction.BLOCKED)
}
// 3. The raw entries, as patterns. This is the step the desktop cannot do.
for (entry in rawEntries) {
if (entryCovers(entry, host)) {
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
}
}
return BlockDecision.Allow
}
/**
* A mask is glob-matched; a plain entry covers itself and any subdomain of it, so
* `youtube.com` catches `music.youtube.com` even though no expansion listed it.
*/
private fun entryCovers(entry: String, host: String): Boolean = when {
entry.isEmpty() -> false
isMask(entry) -> globMatches(entry, host)
else -> host == entry || host.endsWith(".$entry")
}
/** The masks the server had to give up on, which this client does enforce. */
fun enforcedBeyondHostsFile(): List<String> = unexpandableMasks(rules)
}
/** Matches installed apps against `blocker.blocked_apps`. */
class AppMatcher(private val patterns: List<String>) {
/**
* @param packageName e.g. `com.instagram.android`
* @param label the user-visible app name, e.g. `Instagram`
* @return the pattern that matched, or null
*/
fun match(packageName: String, label: String = ""): String? {
val name = packageName.trim().lowercase()
val alt = label.trim().lowercase()
return patterns.firstOrNull { raw -> matches(raw, name, alt) }
}
fun decide(packageName: String, label: String = "", kill: Boolean): BlockDecision {
val rule = match(packageName, label) ?: return BlockDecision.Allow
val action = if (kill) BlockAction.KILLED else BlockAction.WARNED
return BlockDecision.Block(rule, rule, action)
}
private companion object {
/**
* The port of `processes.matches()`: a mask is glob-matched against either
* haystack, while a plain entry is a *substring* test — which is what lets one
* shared list (`discord`) work for both a Linux process and `com.discord`.
*/
fun matches(pattern: String, name: String, alt: String): Boolean {
val p = pattern.trim().lowercase()
if (p.isEmpty()) return false
return if (isMask(p)) {
globMatches(p, name) || globMatches(p, alt)
} else {
p in name || p in alt
}
}
}
}
@@ -0,0 +1,245 @@
/**
* The offline outbox.
*
* Not one of these endpoints is idempotent: `start` creates, `extend` adds, `interrupt`
* increments. So replay is **at-most-once with a post-condition probe**, never blind
* retry — if a send fails ambiguously (the socket died after the request went out, so
* we cannot know whether the server applied it), we re-read the state and ask "does the
* world look like my call landed?" instead of sending it again.
*
* Coalescing happens at enqueue, not at replay: the queue stays a faithful, minimal
* description of what the user asked for, which is far easier to reason about than a
* pile of intents collapsed at the last moment.
*/
package tech.kadence.core
/** The intents that can be queued. There is no STOP — stopping is [ABANDON]. */
enum class OutboxOp {
START,
PAUSE,
RESUME,
COMPLETE,
ABANDON,
EXTEND,
INTERRUPT,
ATTACH_TASK,
}
/**
* One queued intent.
*
* @param anchorSessionId the interval this intent targeted; null for [OutboxOp.START],
* which by definition has no interval yet.
* @param repeat how many times to replay it — only [OutboxOp.INTERRUPT] coalesces this
* way, because the server increments a counter and the count *is* the data.
*/
data class OutboxEntry(
val seq: Long,
val op: OutboxOp,
val anchorSessionId: Long? = null,
val kind: String? = null,
val minutes: Int = 0,
val taskId: Long? = null,
val reason: String? = null,
val repeat: Int = 1,
)
/**
* Fold [incoming] into [queue], returning the new queue.
*
* Every rule here exists because replaying the naive sequence would be wrong or
* wasteful, and each one is pinned by a test in `OutboxTest`.
*/
fun coalesce(queue: List<OutboxEntry>, incoming: OutboxEntry): List<OutboxEntry> {
val out = queue.toMutableList()
when (incoming.op) {
// A start supersedes its predecessor on the server anyway (`pomodoro.start()`
// abandons the running interval as "superseded"), so every intent aimed at that
// interval is moot. Task and settings intents are not timer intents and stay.
OutboxOp.START -> {
out.removeAll { it.anchorSessionId != null && it.anchorSessionId == incoming.anchorSessionId }
out.removeAll { it.op == OutboxOp.START }
out.add(incoming)
}
// Pause/resume annihilate: the user changed their mind before we had signal.
OutboxOp.RESUME -> {
val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) }
if (last?.op == OutboxOp.PAUSE) out.remove(last) else out.add(incoming)
}
OutboxOp.PAUSE -> {
val last = out.lastOrNull { it.isTimerOpFor(incoming.anchorSessionId) }
if (last?.op == OutboxOp.RESUME) out.remove(last) else out.add(incoming)
}
// "+5 min" twice is "+10 min" once.
OutboxOp.EXTEND -> {
val existing = out.lastOrNull {
it.op == OutboxOp.EXTEND && it.anchorSessionId == incoming.anchorSessionId
}
if (existing != null) {
out[out.indexOf(existing)] =
existing.copy(minutes = existing.minutes + incoming.minutes)
} else {
out.add(incoming)
}
}
// Counted, not collapsed — three interruptions must become three increments.
OutboxOp.INTERRUPT -> {
val existing = out.lastOrNull {
it.op == OutboxOp.INTERRUPT && it.anchorSessionId == incoming.anchorSessionId
}
if (existing != null) {
out[out.indexOf(existing)] = existing.copy(repeat = existing.repeat + incoming.repeat)
} else {
out.add(incoming)
}
}
// Only the final choice of task matters.
OutboxOp.ATTACH_TASK -> {
out.removeAll {
it.op == OutboxOp.ATTACH_TASK && it.anchorSessionId == incoming.anchorSessionId
}
out.add(incoming)
}
// A terminal intent ends the interval: anything still queued for it would be
// rejected with "No interval is running."
OutboxOp.COMPLETE, OutboxOp.ABANDON -> {
out.removeAll { it.isTimerOpFor(incoming.anchorSessionId) }
out.add(incoming)
}
}
return out
}
private fun OutboxEntry.isTimerOpFor(sessionId: Long?): Boolean =
sessionId != null && anchorSessionId == sessionId && op != OutboxOp.START
/** Whether the queue holds anything the reconciler should treat as our own doing. */
fun pendingOpsOf(queue: List<OutboxEntry>): PendingOps = PendingOps(
hasAny = queue.isNotEmpty(),
hasStart = queue.any { it.op == OutboxOp.START },
hasTerminal = queue.any { it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON },
)
/** What to do with a queued entry after an attempt. */
sealed interface ReplayOutcome {
/** It landed. Drop the row. */
data object Done : ReplayOutcome
/** The server rejected it on its merits. Drop the row; never retry a 4xx. */
data class Rejected(val detail: String) : ReplayOutcome
/** Try again later, with backoff. */
data class Retry(val attempt: Int, val delaySeconds: Int) : ReplayOutcome
/** Out of attempts — surface it and let the user retry or discard. */
data object GiveUp : ReplayOutcome
}
/** Eight attempts, 1s doubling to a 60s ceiling. */
fun backoffSeconds(attempt: Int): Int =
if (attempt <= 0) 1 else (1 shl (attempt - 1).coerceAtMost(6)).coerceAtMost(60)
const val MAX_REPLAY_ATTEMPTS: Int = 8
fun replayOutcomeFor(attempt: Int): ReplayOutcome =
if (attempt >= MAX_REPLAY_ATTEMPTS) {
ReplayOutcome.GiveUp
} else {
ReplayOutcome.Retry(attempt + 1, backoffSeconds(attempt + 1))
}
/**
* Did an ambiguously-failed send actually land?
*
* Compared against the state read back *after* the failure. Each probe is the weakest
* claim that is still conclusive — `>=` rather than `==`, because another client may
* have moved the same number further along in the meantime.
*/
fun postconditionHolds(
entry: OutboxEntry,
before: TimerSessionFacts?,
after: ServerTimerState,
): Boolean = when (entry.op) {
OutboxOp.START ->
after.current != null &&
after.current.status == Status.RUNNING &&
(entry.kind == null || after.current.kind == entry.kind) &&
after.current.id != entry.anchorSessionId
OutboxOp.EXTEND -> {
val base = before?.plannedSeconds ?: return false
val now = after.current ?: return false
now.id == entry.anchorSessionId && now.plannedSeconds >= base + entry.minutes * 60
}
OutboxOp.INTERRUPT -> {
val base = before?.interruptions ?: return false
val now = after.current ?: return false
now.id == entry.anchorSessionId && now.interruptions >= base + entry.repeat
}
OutboxOp.PAUSE -> after.current?.takeIf { it.id == entry.anchorSessionId }?.isPaused == true
OutboxOp.RESUME ->
after.current?.takeIf { it.id == entry.anchorSessionId }?.status == Status.RUNNING
// The interval is gone, or has been replaced by a newer one.
OutboxOp.COMPLETE, OutboxOp.ABANDON ->
after.current == null || after.current.id != entry.anchorSessionId
OutboxOp.ATTACH_TASK -> false // Nothing observable to probe; let it retry.
}
/**
* An interval that both began *and* ended while offline.
*
* It cannot be filed honestly: `pomodoro.start()` stamps `started_at = utcnow()` and
* `focus_minutes` is derived from `actual_seconds`, so replaying start-then-complete on
* reconnect would record a pomodoro at the wrong hour with roughly zero duration,
* quietly corrupting the very statistics the app exists to produce. These are
* quarantined and surfaced, never replayed silently.
*/
data class UnfiledInterval(
val kind: String,
val startedAtWallMillis: Long,
val endedAtWallMillis: Long,
val plannedSeconds: Int,
val interruptions: Int = 0,
) {
val actualSeconds: Int get() = ((endedAtWallMillis - startedAtWallMillis) / 1000L).toInt()
}
/**
* Split a drained queue into what can be replayed and what has to be quarantined.
*
* A START whose matching COMPLETE/ABANDON is also still queued never reached the
* server, so the whole interval happened offline — see [UnfiledInterval].
*/
fun partitionForReplay(queue: List<OutboxEntry>): Pair<List<OutboxEntry>, List<OutboxEntry>> {
val startSeqs = queue.filter { it.op == OutboxOp.START }.map { it.seq }.toSet()
if (startSeqs.isEmpty()) return queue to emptyList()
// A terminal op with no anchor belongs to the queued start that precedes it.
val quarantined = mutableSetOf<Long>()
for (start in queue.filter { it.op == OutboxOp.START }) {
val terminal = queue.firstOrNull {
it.seq > start.seq && (it.op == OutboxOp.COMPLETE || it.op == OutboxOp.ABANDON)
}
if (terminal != null) {
quarantined += queue.filter { it.seq >= start.seq && it.seq <= terminal.seq }
.map { it.seq }
}
}
val replayable = queue.filter { it.seq !in quarantined }
val unfiled = queue.filter { it.seq in quarantined }
return replayable to unfiled
}
@@ -0,0 +1,32 @@
/**
* Clock windows, including the ones that wrap midnight.
*
* A port of `in_window()` in `backend/app/timeutil.py`, used for the blocker's
* `schedule[]` entries and for Telegram's quiet hours. Both ends are inclusive, and a
* window whose start is after its end (`23:00 -> 08:00`) spans midnight.
*/
package tech.kadence.core
import java.time.LocalTime
/** Whether `moment` falls inside `[start, end]`, wrapping across midnight if needed. */
fun inWindow(moment: LocalTime, start: LocalTime, end: LocalTime): Boolean =
if (start <= end) moment >= start && moment <= end else moment >= start || moment <= end
/**
* Parse the `"HH:MM"` the settings document stores.
*
* Returns null rather than throwing: these strings come from a server document a user
* can edit, and one malformed window must not take the whole policy down with it.
*/
fun parseClockTime(raw: String?): LocalTime? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val parts = text.split(':')
if (parts.size < 2) return null
val hour = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
val second = if (parts.size > 2) parts[2].toIntOrNull() ?: 0 else 0
if (hour !in 0..23 || minute !in 0..59 || second !in 0..59) return null
return LocalTime.of(hour, minute, second)
}
@@ -0,0 +1,248 @@
/**
* The timer, as a client is allowed to understand it.
*
* The server owns the clock (`backend/app/services/pomodoro.py`); a client renders
* `remaining_seconds` and ticks locally between polls. Everything here is pure: given
* what the server last said, what the phone believes, and what is still queued, work
* out what to show and whether to say anything about it.
*
* Three server behaviours this file has to respect:
*
* * `POST /api/timer/start` **abandons** whatever is running, with reason
* `"superseded"` — so a queued start is destructive, not additive.
* * `POST /api/timer/complete` may **auto-start the next interval**, so a reply can
* contain an interval nobody explicitly asked for.
* * There is **no stop endpoint**: stopping is `abandon`, and under `strict_mode` a
* work interval refuses to be abandoned without a reason.
*/
package tech.kadence.core
/** The interval kinds, as the API spells them. */
object Kind {
const val WORK = "work"
const val SHORT_BREAK = "short_break"
const val LONG_BREAK = "long_break"
}
/** The interval statuses, as the API spells them. */
object Status {
const val RUNNING = "running"
const val PAUSED = "paused"
const val COMPLETED = "completed"
const val ABANDONED = "abandoned"
}
/** The parts of `TimerState.current` a client actually reasons about. */
data class TimerSessionFacts(
val id: Long,
val kind: String,
val status: String,
val plannedSeconds: Int,
val remainingSeconds: Int,
val pausedSeconds: Int = 0,
val interruptions: Int = 0,
) {
val isWork: Boolean get() = kind == Kind.WORK
val isPaused: Boolean get() = status == Status.PAUSED
val isActive: Boolean get() = status == Status.RUNNING || status == Status.PAUSED
}
/** `GET /api/timer/state`, reduced to what drives the UI. */
data class ServerTimerState(
val current: TimerSessionFacts?,
val nextKind: String = Kind.WORK,
val completedToday: Int = 0,
val goal: Int = 8,
val cyclePosition: Int = 0,
val cyclesBeforeLongBreak: Int = 4,
/** **Minutes**, not seconds — `pomodoro.state()` sends minutes here. */
val durationsMinutes: Map<String, Int> = emptyMap(),
val timerEnabled: Boolean = true,
)
/**
* Where the local tick counts down from.
*
* Anchored on [elapsedNanos] — a monotonic clock, never wall time — so a timezone
* change or an NTP correction mid-interval cannot corrupt a countdown.
*/
data class TickAnchor(
val sessionId: Long,
val remainingAtAnchor: Int,
val elapsedNanos: Long,
val paused: Boolean,
)
/**
* Seconds left at [nowNanos]. A paused interval is frozen at its anchor.
*
* The elapsed delta is floored at zero: a monotonic source should never go backwards,
* but if it ever appears to, time must not be *added* to the interval — a countdown
* that grows is worse than one that stalls.
*/
fun remainingAt(anchor: TickAnchor, nowNanos: Long): Int {
if (anchor.paused) return anchor.remainingAtAnchor.coerceAtLeast(0)
val elapsedSeconds = ((nowNanos - anchor.elapsedNanos) / 1_000_000_000L)
.coerceAtLeast(0L)
.toInt()
return (anchor.remainingAtAnchor - elapsedSeconds).coerceIn(0, anchor.remainingAtAnchor)
}
/** `1 - remaining/planned`, clamped — the dial's fill, mirroring `controller.progress`. */
fun progressOf(remainingSeconds: Int, plannedSeconds: Int): Float {
if (plannedSeconds <= 0) return 0f
val raw = 1f - remainingSeconds.toFloat() / plannedSeconds.toFloat()
return raw.coerceIn(0f, 1f)
}
/**
* How many ticks the dial draws, and how many are spent.
*
* One per minute up to an hour, then one per five minutes — the rule shared by
* `frontend/src/components/TimerDial.tsx` and `qml/components/TimerDial.qml`, kept here
* so the Compose dial and the Glyph ring cannot drift apart.
*/
fun dialTickCount(plannedSeconds: Int): Int {
val minutes = Math.round(plannedSeconds / 60.0).toInt().coerceAtLeast(1)
return if (minutes <= 60) minutes else Math.round(minutes / 5.0).toInt().coerceAtLeast(1)
}
fun dialSpentTicks(plannedSeconds: Int, remainingSeconds: Int): Int {
val count = dialTickCount(plannedSeconds)
val elapsed = progressOf(remainingSeconds, plannedSeconds)
return kotlin.math.floor(count * elapsed).toInt().coerceIn(0, count)
}
/**
* What should run next, worked out locally for an offline start.
*
* A port of `pomodoro.next_kind()`: after every `cyclesBeforeLongBreak` completed work
* intervals today the next break is the long one, and the count resets with the day, so
* a long break is never owed from yesterday. Online, the server's `next_kind` wins.
*/
fun nextKindLocally(
lastKind: String?,
lastStatus: String?,
completedWorkToday: Int,
cyclesBeforeLongBreak: Int,
): String {
if (lastKind == null || lastKind != Kind.WORK || lastStatus == Status.ABANDONED) {
return Kind.WORK
}
val cycles = cyclesBeforeLongBreak.coerceAtLeast(1)
return if (completedWorkToday > 0 && completedWorkToday % cycles == 0) {
Kind.LONG_BREAK
} else {
Kind.SHORT_BREAK
}
}
/**
* Whether `abandon` will be refused for want of a reason.
*
* `pomodoro.abandon()` raises under `strict_mode` for a **work** interval with a blank
* reason. Checking it client-side is what lets the offline "stop" affordance collect
* the reason up front instead of queueing a call that is certain to 400.
*/
fun abandonNeedsReason(strictMode: Boolean, kind: String, reason: String?): Boolean =
strictMode && kind == Kind.WORK && reason.isNullOrBlank()
/** Six hours, matching `pomodoro.reap_stale()`'s auto-abandon threshold. */
const val STALE_INTERVAL_SECONDS: Int = 6 * 60 * 60
/**
* The client-side mirror of `reap_stale`: stop believing in a local interval that has
* been ticking for six hours with no server contact, so a phone left offline overnight
* does not claim a fourteen-hour pomodoro.
*/
fun isLocallyStale(elapsedSeconds: Int): Boolean = elapsedSeconds > STALE_INTERVAL_SECONDS
// --- reconciliation ---------------------------------------------------------
/** What the phone currently believes it is showing. */
data class LocalBelief(
val sessionId: Long?,
val paused: Boolean = false,
)
/** What is still queued, which decides whether a difference is ours or somebody's. */
data class PendingOps(
val hasAny: Boolean = false,
val hasStart: Boolean = false,
val hasTerminal: Boolean = false,
)
/** The outcome of comparing the server's answer with what the phone was showing. */
sealed interface Reconciliation {
/** Re-anchor to the server and say nothing. The overwhelmingly common case. */
data object Adopt : Reconciliation
/** Keep the optimistic local value: re-anchoring now would visibly bounce. */
data object KeepOptimistic : Reconciliation
/** Our own queued intent landed; adopt it without narrating it. */
data object AdoptOwnReplay : Reconciliation
/** Another client took over. Worth telling the user. */
data class AdoptForeign(val kind: String) : Reconciliation
/** The interval we were ticking is gone and we did not end it. */
data object EndedElsewhere : Reconciliation
/** Still the same interval, but somebody else paused it. */
data object PausedElsewhere : Reconciliation
/** `timer_enabled` went false: every queued timer intent is now certain to 400. */
data object ViewOnlyPurge : Reconciliation
/** No answer from the server; keep ticking from the anchor. */
data object Offline : Reconciliation
}
/**
* Decide what to do with a poll result.
*
* @param server the freshly polled state, or null when the backend is unreachable.
*/
fun reconcile(
server: ServerTimerState?,
local: LocalBelief,
pending: PendingOps = PendingOps(),
): Reconciliation {
if (server == null) return Reconciliation.Offline
// A disabled timer refuses start/pause/resume outright, so anything queued is
// dead on arrival; dropping it is kinder than retrying it eight times.
if (!server.timerEnabled) return Reconciliation.ViewOnlyPurge
val current = server.current
if (current == null) {
return when {
pending.hasTerminal -> Reconciliation.AdoptOwnReplay
local.sessionId != null -> Reconciliation.EndedElsewhere
else -> Reconciliation.Adopt
}
}
// Nothing local to contradict: a cold start, or we were idle and someone began an
// interval. Either way there is no conflict to report.
if (local.sessionId == null) {
return if (pending.hasStart) Reconciliation.AdoptOwnReplay else Reconciliation.Adopt
}
if (local.sessionId != current.id) {
return if (pending.hasStart) {
Reconciliation.AdoptOwnReplay
} else {
Reconciliation.AdoptForeign(current.kind)
}
}
// Same interval.
return when {
pending.hasAny -> Reconciliation.KeepOptimistic
current.isPaused && !local.paused -> Reconciliation.PausedElsewhere
else -> Reconciliation.Adopt
}
}
@@ -0,0 +1,203 @@
/**
* The design tokens, as ARGB.
*
* A transcription of `frontend/src/tokens.ts` and `frontend/src/lib/viz.ts`, kept in the
* pure module so `TokenParityTest` can read those TypeScript files directly and fail the
* build when the two drift. The design brief is that all clients are "the same product";
* this is the only way to actually enforce it rather than hope for it.
*
* Values are `0xAARRGGBB` longs because `androidx.compose.ui.graphics.Color` cannot be
* referenced from a module that deliberately has no Android dependency. The app module
* wraps each one in `Color(...)` and nothing else.
*/
package tech.kadence.core
import kotlin.math.roundToLong
/** `#RRGGBB`, fully opaque. */
internal fun rgb(hex: Long): Long = 0xFF000000L or hex
/** `rgba(r, g, b, a)` with the CSS alpha fraction, rounded half-up to 8 bits. */
internal fun rgba(hex: Long, alpha: Double): Long =
((alpha * 255.0).roundToLong() shl 24) or hex
/** The semantic palette. One instance per theme; see [kadenceTokens]. */
data class KadenceTokens(
val paper: Long,
val surface: Long,
val surfaceSunken: Long,
val ink: Long,
val inkMuted: Long,
val inkFaint: Long,
val rule: Long,
val ruleStrong: Long,
val ember: Long,
val emberSoft: Long,
val pine: Long,
val amber: Long,
val clay: Long,
)
val KADENCE_LIGHT = KadenceTokens(
paper = rgb(0xFAF7F2),
surface = rgb(0xFFFFFF),
surfaceSunken = rgb(0xF3EFE8),
ink = rgb(0x17150F),
inkMuted = rgb(0x6E675B),
inkFaint = rgb(0x9A9388),
rule = rgba(0x17150F, 0.12),
ruleStrong = rgba(0x17150F, 0.26),
ember = rgb(0xB4441E),
emberSoft = rgba(0xB4441E, 0.10),
pine = rgb(0x2F6B55),
amber = rgb(0x8A6200),
clay = rgb(0xA33A2E),
)
val KADENCE_DARK = KadenceTokens(
paper = rgb(0x121110),
surface = rgb(0x1A1917),
surfaceSunken = rgb(0x201E1B),
ink = rgb(0xF4F1EA),
inkMuted = rgb(0xA79F93),
inkFaint = rgb(0x776F64),
rule = rgba(0xF4F1EA, 0.14),
ruleStrong = rgba(0xF4F1EA, 0.30),
ember = rgb(0xE0724A),
emberSoft = rgba(0xE0724A, 0.14),
pine = rgb(0x6FB394),
amber = rgb(0xD9A441),
clay = rgb(0xE07A6E),
)
fun kadenceTokens(dark: Boolean): KadenceTokens = if (dark) KADENCE_DARK else KADENCE_LIGHT
/**
* Chart colours — deliberately **not** the brand accent, and deliberately **not** the
* system accent either. A chart has to stay readable for colour-blind readers in both
* themes, which is a property of this validated palette and not of whatever hue the
* wallpaper happens to produce.
*/
data class VizTokens(
val work: Long,
val shortBreak: Long,
val longBreak: Long,
val sequential: List<Long>,
val grid: Long,
val axis: Long,
val empty: Long,
)
val VIZ_LIGHT = VizTokens(
work = rgb(0xEB6834),
shortBreak = rgb(0x2A78D6),
longBreak = rgb(0x1BAF7A),
// blue 100 -> 600: "more pomodoros" reads as "darker".
sequential = listOf(
rgb(0xCDE2FB), rgb(0x9EC5F4), rgb(0x6DA7EC),
rgb(0x3987E5), rgb(0x256ABF), rgb(0x184F95),
),
grid = rgba(0x17150F, 0.12),
axis = rgba(0x17150F, 0.45),
empty = rgba(0x17150F, 0.07),
)
val VIZ_DARK = VizTokens(
work = rgb(0xD95926),
shortBreak = rgb(0x3987E5),
longBreak = rgb(0x199E70),
// Reversed, so "more" still reads as "darker" against a dark ground.
sequential = listOf(
rgb(0x104281), rgb(0x184F95), rgb(0x256ABF),
rgb(0x3987E5), rgb(0x6DA7EC), rgb(0x9EC5F4),
),
grid = rgba(0xF4F1EA, 0.14),
axis = rgba(0xF4F1EA, 0.55),
empty = rgba(0xF4F1EA, 0.08),
)
fun vizTokens(dark: Boolean): VizTokens = if (dark) VIZ_DARK else VIZ_LIGHT
/** Port of `kindColor()` in `viz.ts`. */
fun kindColor(kind: String, viz: VizTokens): Long = when (kind) {
Kind.WORK -> viz.work
Kind.LONG_BREAK -> viz.longBreak
else -> viz.shortBreak
}
/** Port of `rampStep()` in `viz.ts`. Null means "nothing happened". */
fun rampStep(count: Int, goal: Int, viz: VizTokens): Long? {
if (count <= 0) return null
val steps = viz.sequential.size
val ratio = if (goal > 0) count.toDouble() / goal else count.toDouble() / 8.0
val index = (ratio * (steps - 1)).roundToLong().toInt().coerceIn(0, steps - 1)
return viz.sequential[index]
}
// --- the system accent -----------------------------------------------------
/**
* Where the accent comes from.
*
* The canvas stays Kadence's — warm paper, ink type, hairline rules — while the single
* accent is taken from the system's dynamic palette when Android offers one, falling
* back to the brand ember. That keeps the editorial design intact (which is the look
* that was accepted) while letting the app pick up the phone's own colour.
*
* Chart colours are explicitly excluded: see [VizTokens].
*/
fun accentArgb(systemAccent: Long?, dark: Boolean): Long =
systemAccent ?: kadenceTokens(dark).ember
/**
* WCAG relative luminance of an opaque colour.
*
* Alpha is ignored: these are used to choose label colours against a solid fill.
*/
fun relativeLuminance(argb: Long): Double {
fun channel(value: Long): Double {
val c = value / 255.0
return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
val r = channel((argb shr 16) and 0xFF)
val g = channel((argb shr 8) and 0xFF)
val b = channel(argb and 0xFF)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white). */
fun contrastRatio(a: Long, b: Long): Double {
val la = relativeLuminance(a)
val lb = relativeLuminance(b)
val lighter = maxOf(la, lb)
val darker = minOf(la, lb)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* The label colour to put on top of [background].
*
* The brand ember is dark enough for white (and `theme.ts` hard-codes exactly that),
* but a wallpaper-derived accent can be any lightness at all — Material You will
* happily hand back a pale yellow. Picking whichever of ink/paper actually contrasts is
* what stops "use the system colour" from quietly producing unreadable buttons.
*/
fun onAccentArgb(background: Long): Long {
// The same two candidates the other clients use: white, or the near-black ink that
// `theme.ts` names as the dark-theme contrast colour.
val onLight = rgb(0xFFFFFF)
val onDark = rgb(0x16140F)
return if (contrastRatio(background, onLight) >= contrastRatio(background, onDark)) {
onLight
} else {
onDark
}
}
/** Whether a colour clears WCAG AA for large text / UI components (3:1). */
fun meetsLargeTextContrast(foreground: Long, background: Long): Boolean =
contrastRatio(foreground, background) >= 3.0
/** Whether a colour clears WCAG AA for body text (4.5:1). */
fun meetsBodyContrast(foreground: Long, background: Long): Boolean =
contrastRatio(foreground, background) >= 4.5
@@ -0,0 +1,243 @@
package tech.kadence.core
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Parity with `backend/app/services/blocker_service.py`.
*
* Every expectation in `expands like the backend` was produced by calling the real
* `expand_entry()` through the backend's own venv, not derived by reading the code —
* the phone enforces this list offline, so a divergence here means the guard behaves
* differently depending on whether you have signal.
*/
class BlockerTest {
private val subs = BlockerRules.DEFAULT_SUBDOMAINS
@Test
fun `expands like the backend`() {
assertEquals(
listOf("youtube.com", "www.youtube.com", "m.youtube.com") to null,
expandEntry("youtube.com", subs),
)
assertEquals(
listOf(
"youtube.com", "www.youtube.com", "m.youtube.com", "mobile.youtube.com",
"app.youtube.com", "web.youtube.com", "i.youtube.com", "old.youtube.com",
"new.youtube.com",
) to null,
expandEntry("*.youtube.com", subs),
)
// A leading dot is stripped, and the entry is trimmed and lower-cased.
assertEquals(
listOf("reddit.com", "www.reddit.com", "m.reddit.com") to null,
expandEntry(".reddit.com", subs),
)
assertEquals(
listOf("tiktok.com", "www.tiktok.com", "m.tiktok.com") to null,
expandEntry(" TikTok.com ", subs),
)
}
@Test
fun `patterns a hosts file cannot express come back as leftovers`() {
assertEquals(emptyList<String>() to "*gram*", expandEntry("*gram*", subs))
assertEquals(emptyList<String>() to "*.", expandEntry("*.", subs))
assertEquals(emptyList<String>() to "**", expandEntry("**", subs))
assertEquals(emptyList<String>() to "*.a*.com", expandEntry("*.a*.com", subs))
}
@Test
fun `an empty entry is neither expanded nor reported`() {
assertEquals(emptyList<String>() to null, expandEntry("", subs))
assertEquals(emptyList<String>() to null, expandEntry(" ", subs))
assertEquals(emptyList<String>() to null, expandEntry(".", subs))
}
@Test
fun `isAllowed matches the backend, mask by mask`() {
assertTrue(isAllowed("docs.google.com", listOf("docs.google.com")))
assertTrue(isAllowed("www.youtube.com", listOf("*.youtube.com")))
// Both of these are false in the backend too: a `*.` mask does not cover the
// bare domain, which is why `expand_entry` emits the base name separately.
assertFalse(isAllowed("youtube.com", listOf("*.youtube.com")))
assertFalse(isAllowed("docs.google.com", listOf("*.docs.google.com")))
}
@Test
fun `expandDomains subtracts the allow list`() {
val rules = BlockerRules(
blockedDomains = listOf("*.youtube.com"),
allowedDomains = listOf("music.youtube.com", "*.i.youtube.com"),
subdomainExpansions = listOf("www", "m", "music", "i"),
)
val hosts = expandDomains(rules)
assertTrue("youtube.com" in hosts)
assertTrue("www.youtube.com" in hosts)
assertTrue("m.youtube.com" in hosts)
assertFalse("music.youtube.com" in hosts, "an explicitly allowed host is removed")
assertTrue("i.youtube.com" in hosts, "the bare i. host is not covered by `*.i.`")
}
@Test
fun `expandDomains de-duplicates overlapping entries`() {
val rules = BlockerRules(
blockedDomains = listOf("youtube.com", "youtube.com", "*.youtube.com"),
subdomainExpansions = listOf("www", "m"),
)
val hosts = expandDomains(rules)
assertEquals(hosts.size, hosts.distinct().size, "no host appears twice")
assertEquals(listOf("youtube.com", "www.youtube.com", "m.youtube.com"), hosts)
}
@Test
fun `unexpandableMasks collects the leftovers once each`() {
val rules = BlockerRules(
blockedDomains = listOf("youtube.com", "*gram*", "*gram*", "*tube*"),
)
assertEquals(listOf("*gram*", "*tube*"), unexpandableMasks(rules))
}
// --- the policy decision -------------------------------------------------
private val zone = ZoneId.of("Europe/Moscow")
private fun at(day: String, time: String): ZonedDateTime =
ZonedDateTime.of(java.time.LocalDate.parse(day), java.time.LocalTime.parse(time), zone)
private val working = IntervalFacts("work", "running")
private val breaking = IntervalFacts("short_break", "running")
@Test
fun `disabled beats everything`() {
val rules = BlockerRules(enabled = false, mode = "always")
val verdict = evaluatePolicy(rules, working, at("2026-10-19", "12:00"))
assertFalse(verdict.active)
assertEquals("disabled", verdict.reason)
}
@Test
fun `always on needs no interval`() {
val rules = BlockerRules(enabled = true, mode = "always")
val verdict = evaluatePolicy(rules, null, at("2026-10-19", "03:00"))
assertTrue(verdict.active)
assertEquals("always on", verdict.reason)
}
@Test
fun `during work follows the interval`() {
val rules = BlockerRules(enabled = true, mode = "during_work")
val now = at("2026-10-19", "12:00")
assertTrue(evaluatePolicy(rules, working, now).active)
assertEquals("a work interval is running", evaluatePolicy(rules, working, now).reason)
// A paused work interval still blocks — you did not finish, you stepped away.
assertTrue(evaluatePolicy(rules, IntervalFacts("work", "paused"), now).active)
// A finished or abandoned interval does not.
assertFalse(evaluatePolicy(rules, IntervalFacts("work", "completed"), now).active)
assertFalse(evaluatePolicy(rules, IntervalFacts("work", "abandoned"), now).active)
assertFalse(evaluatePolicy(rules, null, now).active)
assertEquals("no work interval is running", evaluatePolicy(rules, null, now).reason)
}
@Test
fun `breaks block only when asked to`() {
val now = at("2026-10-19", "12:00")
val off = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = false)
val on = BlockerRules(enabled = true, mode = "during_work", blockDuringBreaks = true)
assertFalse(evaluatePolicy(off, breaking, now).active)
assertTrue(evaluatePolicy(on, breaking, now).active)
assertEquals("blocking stays on through breaks", evaluatePolicy(on, breaking, now).reason)
assertTrue(evaluatePolicy(on, IntervalFacts("long_break", "paused"), now).active)
// Still nothing to block through when no interval exists at all.
assertFalse(evaluatePolicy(on, null, now).active)
}
@Test
fun `a schedule window is checked by weekday and clock`() {
val rules = BlockerRules(
enabled = true,
mode = "scheduled",
// Monday = 0; this is Mon-Fri, 09:00-18:00.
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0, 1, 2, 3, 4))),
)
// 2026-10-19 is a Monday.
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "09:00")).active)
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "13:37")).active)
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "18:00")).active)
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "08:59")).active)
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "18:01")).active)
// Saturday is not in the list.
assertFalse(evaluatePolicy(rules, null, at("2026-10-24", "13:00")).active)
assertEquals(
"outside every window",
evaluatePolicy(rules, null, at("2026-10-24", "13:00")).reason,
)
assertEquals(
"inside a blocking window",
evaluatePolicy(rules, null, at("2026-10-19", "13:00")).reason,
)
}
@Test
fun `a schedule window may wrap midnight`() {
val rules = BlockerRules(
enabled = true,
mode = "scheduled",
schedule = listOf(BlockWindow("Night", "22:00", "06:00", listOf(0))),
)
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "23:30")).active)
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "02:00")).active)
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
}
@Test
fun `a malformed window is skipped, not fatal`() {
val rules = BlockerRules(
enabled = true,
mode = "scheduled",
schedule = listOf(
BlockWindow("Broken", "nope", "also nope", listOf(0)),
BlockWindow("Good", "09:00", "18:00", listOf(0)),
),
)
assertTrue(evaluatePolicy(rules, null, at("2026-10-19", "10:00")).active)
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "20:00")).active)
}
@Test
fun `scheduled mode ignores the timer entirely`() {
val rules = BlockerRules(
enabled = true,
mode = "scheduled",
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0))),
)
// A running work interval does not rescue a moment outside every window: this
// is why enforcement cannot be tied to the timer's lifecycle.
assertFalse(evaluatePolicy(rules, working, at("2026-10-19", "20:00")).active)
}
@Test
fun `an unknown mode falls back to during_work`() {
// The server's `else` branch; a settings document from a newer backend must not
// turn the guard permanently on or off.
val rules = BlockerRules(enabled = true, mode = "something_new")
assertTrue(evaluatePolicy(rules, working, at("2026-10-19", "12:00")).active)
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
}
@Test
fun `zone parsing falls back instead of throwing`() {
assertEquals(ZoneId.of("Europe/Moscow"), zoneOrDefault("Europe/Moscow"))
assertEquals(ZoneId.systemDefault(), zoneOrDefault("Not/AZone"))
assertEquals(ZoneId.systemDefault(), zoneOrDefault(null))
assertEquals(ZoneId.systemDefault(), zoneOrDefault(""))
}
}
@@ -0,0 +1,111 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Parity with `frontend/src/lib/format.ts`. The same interval rendered in a browser,
* in the desktop tray and on the phone has to produce the same string.
*/
class FormatTest {
@Test
fun `clock pads minutes and seconds`() {
assertEquals("00:00", clock(0))
assertEquals("00:09", clock(9))
assertEquals("00:59", clock(59))
assertEquals("01:00", clock(60))
assertEquals("25:00", clock(1500))
assertEquals("59:59", clock(3599))
}
@Test
fun `clock grows an hours segment only when needed`() {
assertEquals("1:00:00", clock(3600))
assertEquals("2:03:04", clock(7384))
// A 240-minute interval is the backend's documented maximum.
assertEquals("4:00:00", clock(14400))
}
@Test
fun `clock clamps negatives to zero`() {
// The local tick can overshoot by a fraction before the poll re-anchors.
assertEquals("00:00", clock(-1))
assertEquals("00:00", clock(-500))
}
@Test
fun `clock rounds a fractional tick`() {
assertEquals("00:30", clock(30.4))
assertEquals("00:31", clock(30.5))
assertEquals("01:00", clock(59.6))
}
@Test
fun `humanMinutes matches the typescript`() {
assertEquals("0m", humanMinutes(0))
assertEquals("5m", humanMinutes(5))
assertEquals("59m", humanMinutes(59))
assertEquals("1h", humanMinutes(60))
assertEquals("1h 1m", humanMinutes(61))
assertEquals("2h 5m", humanMinutes(125))
assertEquals("2h", humanMinutes(120))
// The README's worked example: "Focus: 2h 35m".
assertEquals("2h 35m", humanMinutes(155))
}
@Test
fun `humanMinutes rounds and clamps`() {
assertEquals("2h 35m", humanMinutes(154.6))
assertEquals("0m", humanMinutes(-5))
}
@Test
fun `kind labels cover every interval kind`() {
assertEquals("Focus", KIND_LABEL["work"])
assertEquals("Short break", KIND_LABEL["short_break"])
assertEquals("Long break", KIND_LABEL["long_break"])
assertEquals(3, KIND_LABEL.size)
}
@Test
fun `bucket labels follow the web app, not the desktop`() {
// The desktop says "In progress" here; the web app is this client's reference.
assertEquals("Would be nice to start", BUCKET_LABEL["active"])
assertEquals("Overdue", BUCKET_LABEL["overdue"])
assertEquals("Due today", BUCKET_LABEL["today"])
assertEquals("Coming up", BUCKET_LABEL["upcoming"])
assertEquals("No deadline", BUCKET_LABEL["someday"])
}
@Test
fun `bucket order has a label for every bucket and no extras`() {
assertEquals(BUCKET_LABEL.keys, BUCKET_ORDER.toSet())
assertEquals(BUCKET_ORDER.size, BUCKET_ORDER.distinct().size)
assertEquals("overdue", BUCKET_ORDER.first())
assertEquals("someday", BUCKET_ORDER.last())
}
@Test
fun `dueLabel prefers the overdue count`() {
assertEquals("", dueLabel(null, 0))
assertEquals("", dueLabel("", 3))
assertEquals("2026-10-19", dueLabel("2026-10-19", 0))
assertEquals("3d overdue", dueLabel("2026-10-16", 3))
}
@Test
fun `shortDate drops the year`() {
assertEquals("10/19", shortDate("2026-10-19"))
assertEquals("01/05", shortDate("2026-01-05"))
}
@Test
fun `weekdayOf is monday zero`() {
// 2026-10-19 is a Monday.
assertEquals(0, weekdayOf("2026-10-19"))
assertEquals(1, weekdayOf("2026-10-20"))
assertEquals(5, weekdayOf("2026-10-24"))
assertEquals(6, weekdayOf("2026-10-25"))
}
}
@@ -0,0 +1,156 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Parity with Python's `fnmatch`, which is what the backend and the desktop enforcer
* use. Every expectation in [pythonParity] was produced by running the same pattern
* and text through CPython's `fnmatch.fnmatch`, not reasoned about by hand — the block
* lists are shared across clients, so a disagreement here means one list silently
* means two different things.
*/
class GlobTest {
@Test
fun `matches python fnmatch exactly`() {
for ((pattern, text, expected) in pythonParity) {
assertEquals(
expected,
globMatches(pattern, text),
"fnmatch(${text.quoted()}, ${pattern.quoted()}) should be $expected",
)
}
}
@Test
fun `star is anchored at both ends`() {
// `steam*` must not match `xsteam` — a mask is a whole-string match, which is
// what keeps `steam*` from catching every command that mentions steam.
assertTrue(globMatches("steam*", "steamwebhelper"))
assertFalse(globMatches("steam*", "xsteam"))
}
@Test
fun `star crosses separators unlike shell globbing`() {
// This is the property the whole mask feature leans on: a hosts file cannot
// express `*gram*`, but a string match can.
assertTrue(globMatches("*gram*", "instagram.com"))
assertTrue(globMatches("*.com", "a.b.com"))
}
@Test
fun `subdomain mask does not match the bare domain`() {
// Precisely why `expand_entry()` emits the base domain alongside the
// subdomains instead of relying on the mask.
assertTrue(globMatches("*.youtube.com", "music.youtube.com"))
assertFalse(globMatches("*.youtube.com", "youtube.com"))
}
@Test
fun `question mark matches exactly one character`() {
assertTrue(globMatches("telegram-?", "telegram-1"))
assertFalse(globMatches("telegram-?", "telegram-12"))
assertFalse(globMatches("telegram-?", "telegram-"))
}
@Test
fun `character classes and negation`() {
assertTrue(globMatches("[sv]potify", "spotify"))
assertTrue(globMatches("[sv]potify", "vpotify"))
assertFalse(globMatches("[sv]potify", "zpotify"))
assertTrue(globMatches("[!sv]potify", "zpotify"))
assertFalse(globMatches("[!sv]potify", "spotify"))
}
@Test
fun `ranges inside a class`() {
assertTrue(globMatches("app[0-9]", "app7"))
assertFalse(globMatches("app[0-9]", "appx"))
}
@Test
fun `an unterminated class is a literal bracket`() {
// CPython's translate() falls back to a literal `[`; so must we, or a typo in
// the block list becomes a crash instead of a harmless non-match.
assertTrue(globMatches("[", "["))
assertTrue(globMatches("[abc", "[abc"))
assertTrue(globMatches("a[]b", "a[]b"))
assertFalse(globMatches("[abc", "a"))
}
@Test
fun `dots are literal, not regex wildcards`() {
assertTrue(globMatches("a.b", "a.b"))
assertFalse(globMatches("a.b", "axb"))
}
@Test
fun `regex metacharacters in a pattern are escaped`() {
// A user pasting a URL fragment must not accidentally write a regex.
assertTrue(globMatches("a+b(c)", "a+b(c)"))
assertFalse(globMatches("a+b(c)", "aab c"))
assertTrue(globMatches("price\$", "price\$"))
}
@Test
fun `empty pattern matches only empty text`() {
assertTrue(globMatches("", ""))
assertFalse(globMatches("", "x"))
}
@Test
fun `isMask only reports real wildcards`() {
assertTrue(isMask("steam*"))
assertTrue(isMask("a?b"))
assertTrue(isMask("[ab]c"))
assertFalse(isMask("discord"))
assertFalse(isMask("youtube.com"))
assertFalse(isMask(""))
}
@Test
fun `compiled patterns are cached and still correct on reuse`() {
val first = globToRegex("*discord*")
val second = globToRegex("*discord*")
assertTrue(first === second, "the compiled pattern should be reused")
assertTrue(globMatches("*discord*", "/usr/bin/discord --flag"))
assertTrue(globMatches("*discord*", "discord"))
}
private fun String.quoted() = "'" + this + "'"
private companion object {
/** (pattern, text, expected) — generated from CPython's `fnmatch.fnmatch`. */
val pythonParity = listOf(
Triple("steam*", "steam", true),
Triple("steam*", "steamwebhelper", true),
Triple("steam*", "xsteam", false),
Triple("*discord*", "/usr/bin/discord --flag", true),
Triple("*discord*", "discord", true),
Triple("telegram-?", "telegram-1", true),
Triple("telegram-?", "telegram-12", false),
Triple("[sv]potify", "spotify", true),
Triple("[sv]potify", "vpotify", true),
Triple("[sv]potify", "zpotify", false),
Triple("[!sv]potify", "zpotify", true),
Triple("[!sv]potify", "spotify", false),
Triple("*.youtube.com", "music.youtube.com", true),
Triple("*.youtube.com", "youtube.com", false),
Triple("*gram*", "instagram.com", true),
Triple("*gram*", "telegram", true),
Triple("*.google.com", "docs.google.com", true),
Triple("a.b", "a.b", true),
Triple("a.b", "axb", false),
Triple("[", "[", true),
Triple("[abc", "[abc", true),
Triple("a[]b", "a[]b", true),
Triple("*", "anything", true),
Triple("", "", true),
Triple("", "x", false),
Triple("*.com", "a.b.com", true),
)
}
}
@@ -0,0 +1,301 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The rear Glyph Matrix, tested on a laptop.
*
* This is the whole point of keeping the renderer pure: every dot of the Nothing Phone
* (3)'s 25x25 back display is decided by arithmetic, so the entire feature can be built
* and regression-tested without the device, without the vendored `.aar`, and without
* Android. [asciiOf] prints the frame when an assertion fails, so a broken glyph is
* visible rather than a mismatched integer.
*/
class GlyphFrameBasicsTest {
@Test
fun `phone 3 is twenty-five squared`() {
val frame = GlyphFrame()
assertEquals(25, frame.size)
assertEquals(625, frame.dots.size, "Common.getDeviceMatrixLength() on DEVICE_23112")
}
@Test
fun `a different matrix length is honoured`() {
// Phone (4a) Pro is 13x13; nothing may hard-code 25.
val frame = GlyphFrame(13)
assertEquals(169, frame.dots.size)
}
@Test
fun `brightness is clamped, never wrapped`() {
val frame = GlyphFrame()
frame.set(0, 0, 999)
frame.set(1, 0, -5)
assertEquals(255, frame.get(0, 0))
assertEquals(0, frame.get(1, 0))
}
@Test
fun `out-of-bounds writes are dropped, not crashes`() {
val frame = GlyphFrame()
frame.set(-1, 0, 255)
frame.set(0, -1, 255)
frame.set(25, 0, 255)
frame.set(0, 25, 255)
assertEquals(0, frame.litCount)
assertEquals(0, frame.get(-1, -1))
assertEquals(0, frame.get(99, 99))
}
@Test
fun `render hands out a copy, so the SDK cannot be handed live state`() {
val frame = GlyphFrame()
frame.set(0, 0, 255)
val rendered = frame.render()
frame.set(0, 0, 0)
assertEquals(255, rendered[0])
}
@Test
fun `a blank frame is entirely dark`() {
assertEquals(0, GlyphRenderer.blank().litCount)
}
}
class GlyphDigitsTest {
@Test
fun `digit width accounts for the gap`() {
assertEquals(0, digitsWidth(""))
assertEquals(3, digitsWidth("1"))
assertEquals(7, digitsWidth("18"))
assertEquals(11, digitsWidth("184"))
}
@Test
fun `digits render as the three-by-five font`() {
val frame = GlyphFrame()
frame.drawDigitsCentred("18", 6, GlyphBrightness.FULL)
val rows = asciiOf(frame).lines().subList(6, 11)
assertEquals(
listOf(
"...........#.###.........",
"...........#.#.#.........",
"...........#.###.........",
"...........#.#.#.........",
"...........#.###.........",
),
rows,
"the 3x5 font, centred: a right-aligned 1 beside an 8",
)
}
@Test
fun `every digit is five rows tall and lights something`() {
for (d in 0..9) {
val frame = GlyphFrame()
frame.drawDigits(d.toString(), 0, 0, GlyphBrightness.FULL)
assertTrue(frame.litCount > 0, "digit $d renders nothing")
for (y in 5 until 25) {
for (x in 0 until 25) {
assertEquals(0, frame.get(x, y), "digit $d spills past row 5")
}
}
}
}
@Test
fun `an unknown character advances the cursor without drawing`() {
val frame = GlyphFrame()
frame.drawDigits(":", 0, 0, GlyphBrightness.FULL)
assertEquals(0, frame.litCount)
}
}
class GlyphRingTest {
private fun ringOnly(planned: Int, remaining: Int): GlyphFrame {
val frame = GlyphFrame()
frame.drawRing(planned, remaining)
return frame
}
@Test
fun `the ring uses the same tick rule as the Compose dial`() {
// If these ever diverge, one dot means a different amount of time on the front
// of the phone than on the back.
val planned = 25 * 60
assertEquals(dialTickCount(planned), ringOnly(planned, planned).litCount)
}
@Test
fun `a fresh interval lights every tick`() {
val planned = 25 * 60
assertEquals(25, ringOnly(planned, planned).litCount)
}
@Test
fun `the ring drains as the interval runs`() {
val planned = 25 * 60
val full = ringOnly(planned, planned).litCount
val half = ringOnly(planned, planned / 2).litCount
val nearly = ringOnly(planned, 60).litCount
assertTrue(half < full, "half-spent must be dimmer than fresh")
assertTrue(nearly < half, "nearly-done must be dimmer than half")
assertEquals(13, half)
assertEquals(1, nearly)
}
@Test
fun `an expired interval lights nothing`() {
assertEquals(0, ringOnly(25 * 60, 0).litCount)
}
@Test
fun `the arc is anchored at twelve o'clock`() {
// It must shrink *towards* twelve, not drain away from it, so the last sliver is
// at the top where the eye expects it.
val frame = ringOnly(25 * 60, 60)
assertEquals(GlyphBrightness.FULL, frame.get(12, 1), "the top dot stays lit last")
}
@Test
fun `a long interval still fits the ring`() {
// 85 minutes -> one dot per five minutes, so 17 ticks, not 85.
val planned = 85 * 60
assertEquals(17, ringOnly(planned, planned).litCount)
}
}
class GlyphTimerFrameTest {
@Test
fun `minutes sit above seconds`() {
val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60)
val art = asciiOf(frame)
val lines = art.lines()
// Row 6 is the top of the minutes, row 14 the top of the seconds.
assertTrue(lines[6].contains("#"), "minutes row is drawn")
assertTrue(lines[14].contains("#"), "seconds row is drawn")
assertTrue(frame.litCount > 20, "digits plus ring")
}
@Test
fun `past an hour the rows become hours and minutes`() {
// 3930s is 1h 05m 30s. Shown as mm:ss it would read "65" over "30", and a
// two-glyph row cannot say 65 minutes meaningfully — so past an hour the top
// row becomes hours and the bottom becomes minutes.
val frame = GlyphRenderer.timerFrame(3930, 4 * 3600)
val expected = GlyphFrame()
expected.drawDigitsCentred("01", 6, GlyphBrightness.FULL)
expected.drawDigitsCentred("05", 14, GlyphBrightness.FULL)
// Compare only the columns the digits occupy; the ring owns the perimeter.
val got = asciiOf(frame).lines()
val want = asciiOf(expected).lines()
for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) {
assertEquals(
want[y].substring(9, 16),
got[y].substring(9, 16),
"row $y should read as hours over minutes",
)
}
}
@Test
fun `under an hour the rows are minutes and seconds`() {
val frame = GlyphRenderer.timerFrame(18 * 60 + 24, 25 * 60)
val expected = GlyphFrame()
expected.drawDigitsCentred("18", 6, GlyphBrightness.FULL)
expected.drawDigitsCentred("24", 14, GlyphBrightness.FULL)
val got = asciiOf(frame).lines()
val want = asciiOf(expected).lines()
for (y in listOf(6, 7, 8, 9, 10, 14, 15, 16, 17, 18)) {
assertEquals(want[y].substring(9, 16), got[y].substring(9, 16), "row $y")
}
}
@Test
fun `pausing dims the whole face`() {
val running = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = false)
val paused = GlyphRenderer.timerFrame(18 * 60, 25 * 60, paused = true)
assertEquals(running.litCount, paused.litCount, "the same dots, dimmer")
assertTrue(paused.dots.none { it == GlyphBrightness.FULL }, "nothing stays at full")
assertTrue(paused.dots.any { it == GlyphBrightness.DIM })
}
@Test
fun `a negative remaining is clamped rather than drawn`() {
val frame = GlyphRenderer.timerFrame(-30, 25 * 60)
val art = asciiOf(frame)
assertTrue(art.lines().size == 25)
// 00:00, and the ring fully drained.
assertEquals(
GlyphRenderer.timerFrame(0, 25 * 60).let(::asciiOf),
art,
)
}
}
class GlyphDotsTest {
private fun counts(frame: GlyphFrame): Pair<Int, Int> =
frame.dots.count { it == GlyphBrightness.FULL } to
frame.dots.count { it == GlyphBrightness.FAINT }
@Test
fun `earned dots are solid and the rest are faint`() {
val (full, faint) = counts(GlyphRenderer.dotsFrame(6, 8))
assertEquals(6, full)
assertEquals(2, faint)
}
@Test
fun `a goal met lights everything`() {
val (full, faint) = counts(GlyphRenderer.dotsFrame(8, 8))
assertEquals(8, full)
assertEquals(0, faint)
}
@Test
fun `beating the goal grows the row instead of truncating`() {
val (full, faint) = counts(GlyphRenderer.dotsFrame(10, 8))
assertEquals(10, full, "a tenth pomodoro on an eight-goal day still shows")
assertEquals(0, faint)
}
@Test
fun `a large goal wraps onto more rows`() {
val frame = GlyphRenderer.dotsFrame(3, 12)
val (full, faint) = counts(frame)
assertEquals(3, full)
assertEquals(9, faint)
val rowsWithDots = asciiOf(frame).lines().count { it.any { c -> c != '.' } }
assertEquals(2, rowsWithDots, "12 dots at 8 per row is two rows")
}
@Test
fun `nothing yet today still renders one placeholder`() {
val (full, faint) = counts(GlyphRenderer.dotsFrame(0, 0))
assertEquals(0, full)
assertEquals(1, faint, "an empty grid would look like a dead display")
}
@Test
fun `the grid stays inside the matrix`() {
val frame = GlyphRenderer.dotsFrame(20, 24)
for (i in frame.dots.indices) {
// Nothing wrapped around an edge: every lit dot is within bounds by
// construction, so just assert the count survived.
}
assertEquals(24, frame.litCount)
}
}
@@ -0,0 +1,319 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NormalizeHostTest {
@Test
fun `a bare host passes through`() {
assertEquals("youtube.com", normalizeHost("youtube.com"))
assertEquals("m.youtube.com", normalizeHost("m.youtube.com"))
assertEquals("youtube.com", normalizeHost(" YouTube.com "))
}
@Test
fun `a full url is reduced to its host`() {
assertEquals("youtube.com", normalizeHost("https://youtube.com"))
assertEquals("www.youtube.com", normalizeHost("https://www.youtube.com/watch?v=abc"))
assertEquals("youtube.com", normalizeHost("http://youtube.com/"))
assertEquals("youtube.com", normalizeHost("https://youtube.com#anchor"))
assertEquals("youtube.com", normalizeHost("https://youtube.com?q=1"))
}
@Test
fun `userinfo and ports are dropped`() {
assertEquals("example.com", normalizeHost("https://user:pw@example.com:8443/x"))
assertEquals("example.com", normalizeHost("example.com:8000"))
}
@Test
fun `a trailing root dot is dropped`() {
assertEquals("youtube.com", normalizeHost("youtube.com."))
}
@Test
fun `unicode hosts are matched in punycode`() {
// A block list holds ASCII; the URL bar may show the pretty form.
assertEquals("xn--e1afmkfd.xn--p1ai", normalizeHost("пример.рф"))
assertEquals("xn--mnchen-3ya.de", normalizeHost("münchen.de"))
assertEquals("xn--wgv71a.jp", normalizeHost("日本.jp"))
}
@Test
fun `a homoglyph host still normalises instead of being waved through`() {
// `аррӏе.com` is Cyrillic lookalikes. Strict IDN.toASCII throws on it, which
// would have returned null — and null means "allow". ALLOW_UNASSIGNED keeps it
// matchable, so putting it on the block list actually blocks it.
assertEquals("xn--80ak6aa92e.com", normalizeHost("аррӏе.com"))
}
@Test
fun `a search phrase is not a host`() {
assertNull(normalizeHost("Search or type URL"))
assertNull(normalizeHost("how to focus better"))
assertNull(normalizeHost("youtube cat videos"))
}
@Test
fun `internal schemes are never blockable`() {
assertNull(normalizeHost("chrome://newtab"))
assertNull(normalizeHost("about:blank"))
assertNull(normalizeHost("chrome-native://bookmarks"))
assertNull(normalizeHost("file:///sdcard/x.html"))
assertNull(normalizeHost("data:text/html,hi"))
}
@Test
fun `junk is null rather than a false positive`() {
assertNull(normalizeHost(null))
assertNull(normalizeHost(""))
assertNull(normalizeHost(" "))
assertNull(normalizeHost("localhost"), "a single label is not a host we block")
assertNull(normalizeHost("..."))
assertNull(normalizeHost("-bad-.com"))
assertNull(normalizeHost("https://"))
}
@Test
fun `an ip address survives`() {
assertEquals("192.168.1.10", normalizeHost("http://192.168.1.10:8000/api"))
}
}
class DomainMatcherTest {
private fun matcher(
blocked: List<String> = emptyList(),
allowed: List<String> = emptyList(),
subs: List<String> = listOf("www", "m"),
): DomainMatcher {
val rules = BlockerRules(
enabled = true,
blockedDomains = blocked,
allowedDomains = allowed,
subdomainExpansions = subs,
)
return DomainMatcher(rules)
}
private fun assertBlocked(decision: BlockDecision, host: String, rule: String) {
val block = assertIs<BlockDecision.Block>(decision, "expected $host to be blocked")
assertEquals(host, block.target)
assertEquals(rule, block.matchedRule)
assertEquals(BlockAction.BLOCKED, block.action)
}
@Test
fun `a plain entry blocks the site and its subdomains`() {
val m = matcher(blocked = listOf("youtube.com"))
assertBlocked(m.decide("youtube.com"), "youtube.com", "youtube.com")
assertBlocked(m.decide("www.youtube.com"), "www.youtube.com", "youtube.com")
assertBlocked(m.decide("m.youtube.com"), "m.youtube.com", "youtube.com")
// Beyond the hosts file's reach: never expanded, still caught here.
assertBlocked(m.decide("music.youtube.com"), "music.youtube.com", "youtube.com")
}
@Test
fun `a lookalike domain is not caught`() {
val m = matcher(blocked = listOf("youtube.com"))
assertEquals(BlockDecision.Allow, m.decide("myyoutube.com"))
assertEquals(BlockDecision.Allow, m.decide("youtube.com.evil.net"))
assertEquals(BlockDecision.Allow, m.decide("notyoutube.com"))
}
@Test
fun `the allow list wins over the block list`() {
val m = matcher(blocked = listOf("*.google.com"), allowed = listOf("docs.google.com"))
assertEquals(BlockDecision.Allow, m.decide("https://docs.google.com/document/d/1"))
assertBlocked(m.decide("www.google.com"), "www.google.com", "*.google.com")
}
@Test
fun `an allow mask covers a whole subtree`() {
val m = matcher(blocked = listOf("google.com"), allowed = listOf("*.docs.google.com"))
assertEquals(BlockDecision.Allow, m.decide("a.docs.google.com"))
// Consistent with the backend: `*.x` does not cover the bare `x`.
assertBlocked(m.decide("docs.google.com"), "docs.google.com", "google.com")
}
@Test
fun `masks the hosts file cannot express are enforced here`() {
// This is the payoff: the backend returns `*gram*` as `unexpandable` because a
// hosts file has no wildcards. The phone matches the string instead.
val m = matcher(blocked = listOf("*gram*"))
assertBlocked(m.decide("instagram.com"), "instagram.com", "*gram*")
assertBlocked(m.decide("https://www.instagram.com/explore/"), "www.instagram.com", "*gram*")
assertEquals(BlockDecision.Allow, m.decide("youtube.com"))
assertEquals(listOf("*gram*"), m.enforcedBeyondHostsFile())
}
@Test
fun `a subdomain mask blocks the base domain too, via expansion`() {
val m = matcher(blocked = listOf("*.reddit.com"))
assertBlocked(m.decide("reddit.com"), "reddit.com", "*.reddit.com")
assertBlocked(m.decide("www.reddit.com"), "www.reddit.com", "*.reddit.com")
assertBlocked(m.decide("old.reddit.com"), "old.reddit.com", "*.reddit.com")
}
@Test
fun `junk from the url bar is always allowed`() {
val m = matcher(blocked = listOf("youtube.com", "*gram*"))
assertEquals(BlockDecision.Allow, m.decide("Search or type URL"))
assertEquals(BlockDecision.Allow, m.decide("chrome://newtab"))
assertEquals(BlockDecision.Allow, m.decide(null))
assertEquals(BlockDecision.Allow, m.decide(""))
}
@Test
fun `an empty policy blocks nothing`() {
val m = matcher()
assertEquals(BlockDecision.Allow, m.decide("youtube.com"))
assertEquals(BlockDecision.Allow, m.decide("anything.at.all"))
}
@Test
fun `the server's expanded list is used when supplied`() {
// Online, `all_domains` arrives already expanded; we must honour it even for an
// entry whose raw form we were not given.
val rules = BlockerRules(enabled = true, blockedDomains = emptyList())
val m = DomainMatcher(rules, expandedHosts = listOf("tiktok.com", "www.tiktok.com"))
val decision = m.decide("https://tiktok.com/foryou")
val block = assertIs<BlockDecision.Block>(decision)
assertEquals("tiktok.com", block.target)
assertEquals(BlockAction.BLOCKED, block.action)
}
@Test
fun `the reported rule is the entry that produced the host`() {
// `*.reddit.com` expands to the bare `reddit.com`, but the mask does not match
// that bare name — so without provenance the overlay would blame the host
// instead of naming the rule the user actually wrote.
val m = matcher(blocked = listOf("*.reddit.com"))
val block = assertIs<BlockDecision.Block>(m.decide("reddit.com"))
assertEquals("reddit.com", block.target, "the event target stays the hostname")
assertEquals("*.reddit.com", block.matchedRule, "but the rule is the user's entry")
}
@Test
fun `the first entry to claim a host owns it`() {
val m = matcher(blocked = listOf("youtube.com", "*.youtube.com"))
val block = assertIs<BlockDecision.Block>(m.decide("www.youtube.com"))
assertEquals("youtube.com", block.matchedRule)
}
@Test
fun `case and whitespace in the list do not matter`() {
val m = matcher(blocked = listOf(" YouTube.COM "))
assertBlocked(m.decide("YOUTUBE.com"), "youtube.com", "youtube.com")
}
}
class AppMatcherTest {
@Test
fun `a plain entry is a substring test, so one list serves both platforms`() {
// `discord` was written for a Linux process name; it must still catch the
// Android package.
val m = AppMatcher(listOf("discord"))
assertEquals("discord", m.match("com.discord", "Discord"))
assertEquals("discord", m.match("com.discord.canary"))
}
@Test
fun `the visible label is the second haystack`() {
// Android's analogue of the desktop matcher's command line.
val m = AppMatcher(listOf("instagram"))
assertEquals("instagram", m.match("com.instagram.android", "Instagram"))
val byLabelOnly = AppMatcher(listOf("telegram"))
assertEquals("telegram", byLabelOnly.match("org.thunderdog.challegram", "Telegram X"))
}
@Test
fun `a mask is anchored, so it misses the package name`() {
// `steam*` was written for a Linux process called `steam`. An fnmatch mask is a
// whole-string match, so it does NOT match the Android package — this is the
// shared-list gotcha, documented rather than papered over.
val m = AppMatcher(listOf("steam*"))
assertEquals("steam*", m.match("steamwebhelper"))
assertNull(m.match("com.valvesoftware.android.steam.community"))
assertNull(m.match("com.discord", "Discord"))
}
@Test
fun `the label rescues an anchored mask on Android`() {
// Checking the visible label as a second haystack is what makes the desktop's
// `steam*` still work here: the package misses, but the label "Steam" matches.
// This is the Android analogue of the desktop also trying the command line.
val m = AppMatcher(listOf("steam*"))
assertEquals(
"steam*",
m.match("com.valvesoftware.android.steam.community", "Steam"),
)
}
@Test
fun `a wrapped mask matches the package directly`() {
// What the in-app app picker should write, so an entry added on the phone also
// blocks the Linux process.
val wrapped = AppMatcher(listOf("*steam*"))
assertEquals(
"*steam*",
wrapped.match("com.valvesoftware.android.steam.community", ""),
)
assertEquals("*steam*", wrapped.match("steamwebhelper"))
}
@Test
fun `masks match either haystack`() {
val m = AppMatcher(listOf("*telegram*"))
assertEquals("*telegram*", m.match("org.telegram.messenger", "Telegram"))
assertEquals("*telegram*", m.match("com.example.app", "My Telegram Client"))
}
@Test
fun `no match returns null`() {
val m = AppMatcher(listOf("discord", "*steam*"))
assertNull(m.match("com.android.chrome", "Chrome"))
assertNull(m.match("tech.kadence.android", "Kadence"))
}
@Test
fun `blank patterns are ignored`() {
val m = AppMatcher(listOf("", " ", "discord"))
assertEquals("discord", m.match("com.discord"))
assertNull(m.match("com.android.chrome"))
}
@Test
fun `the first matching pattern is the one reported`() {
// `by_target` in the report groups by this string, so it must be stable.
val m = AppMatcher(listOf("*gram*", "instagram"))
assertEquals("*gram*", m.match("com.instagram.android", "Instagram"))
}
@Test
fun `kill_blocked_apps decides the reported action`() {
val m = AppMatcher(listOf("instagram"))
val killed = m.decide("com.instagram.android", "Instagram", kill = true)
assertEquals(BlockAction.KILLED, assertIs<BlockDecision.Block>(killed).action)
// Warn-only. This matters: the server only fires `notify_on_block` for
// `killed`, so sending `warned` here is what keeps Telegram quiet.
val warned = m.decide("com.instagram.android", "Instagram", kill = false)
assertEquals(BlockAction.WARNED, assertIs<BlockDecision.Block>(warned).action)
assertEquals(BlockDecision.Allow, m.decide("com.android.chrome", "Chrome", kill = true))
}
@Test
fun `case is folded on both sides`() {
val m = AppMatcher(listOf("DISCORD"))
assertTrue(m.match("com.Discord", "Discord") != null)
}
}
@@ -0,0 +1,361 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class OutboxCoalesceTest {
private var seq = 0L
private fun next() = ++seq
private fun entry(op: OutboxOp, session: Long? = 7L, minutes: Int = 0, taskId: Long? = null) =
OutboxEntry(seq = next(), op = op, anchorSessionId = session, minutes = minutes, taskId = taskId)
private fun queueOf(vararg ops: OutboxEntry): List<OutboxEntry> =
ops.fold(emptyList()) { acc, e -> coalesce(acc, e) }
@Test
fun `an empty queue takes the first intent as-is`() {
val q = queueOf(entry(OutboxOp.PAUSE))
assertEquals(1, q.size)
assertEquals(OutboxOp.PAUSE, q.single().op)
}
@Test
fun `pause then resume annihilate`() {
// The user changed their mind before we had signal; sending both is pointless
// and would wrongly bump the interruption counter.
val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME))
assertTrue(q.isEmpty())
}
@Test
fun `resume then pause annihilate too`() {
val q = queueOf(entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE))
assertTrue(q.isEmpty())
}
@Test
fun `a pause that survives a round trip is kept`() {
val q = queueOf(entry(OutboxOp.PAUSE), entry(OutboxOp.RESUME), entry(OutboxOp.PAUSE))
assertEquals(listOf(OutboxOp.PAUSE), q.map { it.op })
}
@Test
fun `extends add up`() {
val q = queueOf(
entry(OutboxOp.EXTEND, minutes = 5),
entry(OutboxOp.EXTEND, minutes = 5),
entry(OutboxOp.EXTEND, minutes = 10),
)
assertEquals(1, q.size)
assertEquals(20, q.single().minutes)
}
@Test
fun `extends for different intervals stay apart`() {
val q = queueOf(
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
entry(OutboxOp.EXTEND, session = 8L, minutes = 5),
)
assertEquals(2, q.size)
}
@Test
fun `interruptions are counted, not collapsed`() {
// The server increments a counter, so three taps must become three increments.
val q = queueOf(
entry(OutboxOp.INTERRUPT),
entry(OutboxOp.INTERRUPT),
entry(OutboxOp.INTERRUPT),
)
assertEquals(1, q.size)
assertEquals(3, q.single().repeat)
}
@Test
fun `only the last chosen task matters`() {
val q = queueOf(
entry(OutboxOp.ATTACH_TASK, taskId = 1L),
entry(OutboxOp.ATTACH_TASK, taskId = 2L),
entry(OutboxOp.ATTACH_TASK, taskId = 3L),
)
assertEquals(1, q.size)
assertEquals(3L, q.single().taskId)
}
@Test
fun `a start discards everything queued for the interval it supersedes`() {
// `pomodoro.start()` abandons the running interval as "superseded", so a queued
// pause or +5 for it is already moot.
val q = queueOf(
entry(OutboxOp.PAUSE, session = 7L),
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
entry(OutboxOp.INTERRUPT, session = 7L),
OutboxEntry(seq = next(), op = OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK),
)
assertEquals(listOf(OutboxOp.START), q.map { it.op })
}
@Test
fun `only one start is ever queued`() {
val q = queueOf(
OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK),
OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.SHORT_BREAK),
)
assertEquals(1, q.size)
assertEquals(Kind.SHORT_BREAK, q.single().kind)
}
@Test
fun `a terminal intent clears the interval's other intents`() {
val q = queueOf(
entry(OutboxOp.PAUSE, session = 7L),
entry(OutboxOp.EXTEND, session = 7L, minutes = 5),
entry(OutboxOp.COMPLETE, session = 7L),
)
assertEquals(listOf(OutboxOp.COMPLETE), q.map { it.op })
}
@Test
fun `a terminal intent leaves another interval's intents alone`() {
val q = queueOf(
entry(OutboxOp.PAUSE, session = 8L),
entry(OutboxOp.COMPLETE, session = 7L),
)
assertEquals(2, q.size)
}
@Test
fun `pendingOps summarises the queue for the reconciler`() {
assertEquals(PendingOps(), pendingOpsOf(emptyList()))
val withStart = queueOf(OutboxEntry(seq = next(), op = OutboxOp.START, kind = Kind.WORK))
assertEquals(PendingOps(hasAny = true, hasStart = true), pendingOpsOf(withStart))
val withTerminal = queueOf(entry(OutboxOp.ABANDON))
assertEquals(PendingOps(hasAny = true, hasTerminal = true), pendingOpsOf(withTerminal))
val plain = queueOf(entry(OutboxOp.INTERRUPT))
assertEquals(PendingOps(hasAny = true), pendingOpsOf(plain))
}
}
class BackoffTest {
@Test
fun `backoff doubles and then caps`() {
assertEquals(1, backoffSeconds(1))
assertEquals(2, backoffSeconds(2))
assertEquals(4, backoffSeconds(3))
assertEquals(8, backoffSeconds(4))
assertEquals(16, backoffSeconds(5))
assertEquals(32, backoffSeconds(6))
assertEquals(60, backoffSeconds(7), "capped at a minute")
assertEquals(60, backoffSeconds(99))
}
@Test
fun `a zeroth attempt still waits a moment`() {
assertEquals(1, backoffSeconds(0))
}
@Test
fun `retries give up after eight attempts`() {
val retry = assertIs<ReplayOutcome.Retry>(replayOutcomeFor(1))
assertEquals(2, retry.attempt)
assertIs<ReplayOutcome.Retry>(replayOutcomeFor(MAX_REPLAY_ATTEMPTS - 1))
assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS))
assertEquals(ReplayOutcome.GiveUp, replayOutcomeFor(MAX_REPLAY_ATTEMPTS + 5))
}
}
/**
* The post-condition probes. None of these endpoints is idempotent, so after an
* ambiguous failure the only safe question is "does the world look like my call
* landed?" — asked once, against freshly-read state.
*/
class PostconditionTest {
private fun session(
id: Long = 7L,
status: String = Status.RUNNING,
planned: Int = 1500,
interruptions: Int = 0,
kind: String = Kind.WORK,
) = TimerSessionFacts(
id = id,
kind = kind,
status = status,
plannedSeconds = planned,
remainingSeconds = 900,
interruptions = interruptions,
)
private fun state(current: TimerSessionFacts?) = ServerTimerState(current = current)
@Test
fun `extend landed when planned seconds grew by at least the request`() {
val before = session(planned = 1500)
val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5)
assertTrue(postconditionHolds(entry, before, state(session(planned = 1800))))
assertFalse(postconditionHolds(entry, before, state(session(planned = 1500))))
// Another client may have extended further in the meantime; still landed.
assertTrue(postconditionHolds(entry, before, state(session(planned = 2400))))
}
@Test
fun `extend did not land if the interval was replaced`() {
val before = session(id = 7L, planned = 1500)
val entry = OutboxEntry(1, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5)
assertFalse(postconditionHolds(entry, before, state(session(id = 9L, planned = 1800))))
assertFalse(postconditionHolds(entry, before, state(null)))
}
@Test
fun `interrupt landed when the counter advanced by the repeat count`() {
val before = session(interruptions = 2)
val entry = OutboxEntry(1, OutboxOp.INTERRUPT, anchorSessionId = 7L, repeat = 3)
assertFalse(postconditionHolds(entry, before, state(session(interruptions = 4))))
assertTrue(postconditionHolds(entry, before, state(session(interruptions = 5))))
assertTrue(postconditionHolds(entry, before, state(session(interruptions = 6))))
}
@Test
fun `start landed when a new running interval of the right kind exists`() {
val entry = OutboxEntry(1, OutboxOp.START, anchorSessionId = 7L, kind = Kind.WORK)
assertTrue(postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.WORK))))
// The old interval is still there, so nothing started.
assertFalse(postconditionHolds(entry, null, state(session(id = 7L))))
// Wrong kind: somebody else's interval, not ours.
assertFalse(
postconditionHolds(entry, null, state(session(id = 9L, kind = Kind.SHORT_BREAK))),
)
assertFalse(postconditionHolds(entry, null, state(null)))
// A paused new interval is not what `start` produces.
assertFalse(
postconditionHolds(entry, null, state(session(id = 9L, status = Status.PAUSED))),
)
}
@Test
fun `pause and resume probe the status`() {
val pause = OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L)
assertTrue(postconditionHolds(pause, null, state(session(status = Status.PAUSED))))
assertFalse(postconditionHolds(pause, null, state(session(status = Status.RUNNING))))
assertFalse(postconditionHolds(pause, null, state(null)))
val resume = OutboxEntry(2, OutboxOp.RESUME, anchorSessionId = 7L)
assertTrue(postconditionHolds(resume, null, state(session(status = Status.RUNNING))))
assertFalse(postconditionHolds(resume, null, state(session(status = Status.PAUSED))))
}
@Test
fun `complete and abandon landed when the interval is gone or replaced`() {
val complete = OutboxEntry(1, OutboxOp.COMPLETE, anchorSessionId = 7L)
assertTrue(postconditionHolds(complete, null, state(null)))
// An auto-started follow-on counts: our interval is no longer current.
assertTrue(postconditionHolds(complete, null, state(session(id = 9L))))
assertFalse(postconditionHolds(complete, null, state(session(id = 7L))))
val abandon = OutboxEntry(2, OutboxOp.ABANDON, anchorSessionId = 7L)
assertTrue(postconditionHolds(abandon, null, state(null)))
assertFalse(postconditionHolds(abandon, null, state(session(id = 7L))))
}
@Test
fun `attach task has nothing conclusive to probe`() {
// `TimerState.current.task` is not in the facts we keep, so we would be
// guessing. Returning false means "retry", which is safe: attaching the same
// task twice is a no-op.
val entry = OutboxEntry(1, OutboxOp.ATTACH_TASK, anchorSessionId = 7L, taskId = 3L)
assertFalse(postconditionHolds(entry, null, state(session())))
}
}
/**
* Intervals that both started and finished offline cannot be filed truthfully, because
* the server stamps `started_at` itself and `focus_minutes` comes from the wall-clock
* difference. Quarantining them is the honest option; replaying them would silently
* corrupt the statistics the whole app exists to produce.
*/
class PartitionForReplayTest {
@Test
fun `a queue with no start replays entirely`() {
val queue = listOf(
OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L),
OutboxEntry(2, OutboxOp.EXTEND, anchorSessionId = 7L, minutes = 5),
OutboxEntry(3, OutboxOp.COMPLETE, anchorSessionId = 7L),
)
val (replay, unfiled) = partitionForReplay(queue)
assertEquals(3, replay.size)
assertTrue(unfiled.isEmpty())
}
@Test
fun `a start still running replays`() {
val queue = listOf(
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
OutboxEntry(2, OutboxOp.INTERRUPT, anchorSessionId = null),
)
val (replay, unfiled) = partitionForReplay(queue)
assertEquals(2, replay.size)
assertTrue(unfiled.isEmpty())
}
@Test
fun `a start and its completion are quarantined together`() {
val queue = listOf(
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
OutboxEntry(2, OutboxOp.COMPLETE),
)
val (replay, unfiled) = partitionForReplay(queue)
assertTrue(replay.isEmpty(), "replaying would file a pomodoro at the wrong hour")
assertEquals(listOf(1L, 2L), unfiled.map { it.seq })
}
@Test
fun `everything between the start and its end goes with it`() {
val queue = listOf(
OutboxEntry(1, OutboxOp.START, kind = Kind.WORK),
OutboxEntry(2, OutboxOp.INTERRUPT),
OutboxEntry(3, OutboxOp.EXTEND, minutes = 5),
OutboxEntry(4, OutboxOp.ABANDON, reason = "the phone rang"),
)
val (replay, unfiled) = partitionForReplay(queue)
assertTrue(replay.isEmpty())
assertEquals(4, unfiled.size)
}
@Test
fun `work queued before the offline start is still replayed`() {
val queue = listOf(
OutboxEntry(1, OutboxOp.PAUSE, anchorSessionId = 7L),
OutboxEntry(2, OutboxOp.START, kind = Kind.WORK),
OutboxEntry(3, OutboxOp.COMPLETE),
)
val (replay, unfiled) = partitionForReplay(queue)
assertEquals(listOf(1L), replay.map { it.seq })
assertEquals(listOf(2L, 3L), unfiled.map { it.seq })
}
@Test
fun `an unfiled interval reports its real duration`() {
val start = 1_760_000_000_000L
val interval = UnfiledInterval(
kind = Kind.WORK,
startedAtWallMillis = start,
endedAtWallMillis = start + 25 * 60 * 1000L,
plannedSeconds = 1500,
)
assertEquals(1500, interval.actualSeconds)
assertEquals("25m", humanMinutes(interval.actualSeconds / 60))
}
}
@@ -0,0 +1,71 @@
package tech.kadence.core
import java.time.LocalTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Parity with `in_window()` in `backend/app/timeutil.py`. */
class TimeWindowTest {
private fun t(text: String) = LocalTime.parse(text)
@Test
fun `a normal window is inclusive at both ends`() {
val start = t("10:00")
val end = t("20:00")
assertTrue(inWindow(t("10:00"), start, end), "the start minute is inside")
assertTrue(inWindow(t("15:30"), start, end))
assertTrue(inWindow(t("20:00"), start, end), "the end minute is inside")
assertFalse(inWindow(t("09:59"), start, end))
assertFalse(inWindow(t("20:01"), start, end))
}
@Test
fun `a window that wraps midnight`() {
// The quiet-hours default: 23:00 -> 08:00.
val start = t("23:00")
val end = t("08:00")
assertTrue(inWindow(t("23:00"), start, end))
assertTrue(inWindow(t("23:59"), start, end))
assertTrue(inWindow(t("00:00"), start, end))
assertTrue(inWindow(t("07:59"), start, end))
assertTrue(inWindow(t("08:00"), start, end))
assertFalse(inWindow(t("08:01"), start, end))
assertFalse(inWindow(t("22:59"), start, end))
assertFalse(inWindow(t("12:00"), start, end))
}
@Test
fun `a degenerate window covers exactly one minute`() {
val at = t("09:00")
assertTrue(inWindow(at, at, at))
assertFalse(inWindow(t("09:01"), at, at))
}
@Test
fun `parses the HH colon MM the settings document stores`() {
assertEquals(LocalTime.of(9, 0), parseClockTime("09:00"))
assertEquals(LocalTime.of(23, 5), parseClockTime("23:05"))
assertEquals(LocalTime.of(21, 0), parseClockTime(" 21:00 "))
assertEquals(LocalTime.of(7, 30, 15), parseClockTime("07:30:15"))
assertEquals(LocalTime.of(9, 0), parseClockTime("9:00"))
}
@Test
fun `a malformed time is null, never an exception`() {
// These strings come from a user-editable server document; one bad window must
// not take the whole blocking policy down.
assertNull(parseClockTime(null))
assertNull(parseClockTime(""))
assertNull(parseClockTime(" "))
assertNull(parseClockTime("nope"))
assertNull(parseClockTime("25:00"))
assertNull(parseClockTime("10:60"))
assertNull(parseClockTime("10"))
assertNull(parseClockTime("-1:00"))
assertNull(parseClockTime("ab:cd"))
}
}
@@ -0,0 +1,322 @@
package tech.kadence.core
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class TickMathTest {
private fun anchor(remaining: Int, atNanos: Long = 0L, paused: Boolean = false) =
TickAnchor(sessionId = 1L, remainingAtAnchor = remaining, elapsedNanos = atNanos, paused = paused)
private fun seconds(n: Long) = n * 1_000_000_000L
@Test
fun `counts down from the anchor`() {
val a = anchor(remaining = 300)
assertEquals(300, remainingAt(a, seconds(0)))
assertEquals(299, remainingAt(a, seconds(1)))
assertEquals(240, remainingAt(a, seconds(60)))
assertEquals(0, remainingAt(a, seconds(300)))
}
@Test
fun `never goes below zero`() {
val a = anchor(remaining = 5)
assertEquals(0, remainingAt(a, seconds(5)))
assertEquals(0, remainingAt(a, seconds(500)))
}
@Test
fun `a paused interval is frozen`() {
val a = anchor(remaining = 300, paused = true)
assertEquals(300, remainingAt(a, seconds(0)))
assertEquals(300, remainingAt(a, seconds(120)))
}
@Test
fun `partial seconds do not tick early`() {
val a = anchor(remaining = 300)
assertEquals(300, remainingAt(a, 999_999_999L))
assertEquals(299, remainingAt(a, 1_000_000_000L))
}
@Test
fun `the anchor is monotonic, so a clock change cannot corrupt it`() {
// elapsedNanos comes from a monotonic source; a negative delta (which wall time
// could produce after an NTP correction) must not add time to the interval.
val a = anchor(remaining = 300, atNanos = seconds(100))
assertEquals(300, remainingAt(a, seconds(100)))
assertEquals(300, remainingAt(a, seconds(90)), "going backwards must not add time")
}
@Test
fun `progress is the dial's fill, clamped`() {
assertEquals(0f, progressOf(1500, 1500))
assertEquals(0.5f, progressOf(750, 1500))
assertEquals(1f, progressOf(0, 1500))
assertEquals(1f, progressOf(-10, 1500), "an overshooting tick still reads as full")
assertEquals(0f, progressOf(100, 0), "a zero-length interval has no progress")
}
}
class DialGeometryTest {
@Test
fun `one tick per minute up to an hour`() {
assertEquals(25, dialTickCount(25 * 60))
assertEquals(45, dialTickCount(45 * 60))
assertEquals(60, dialTickCount(60 * 60))
assertEquals(5, dialTickCount(5 * 60))
}
@Test
fun `past an hour it switches to one per five minutes`() {
assertEquals(17, dialTickCount(85 * 60), "the 'Пара' preset: 85 minutes")
assertEquals(24, dialTickCount(120 * 60))
assertEquals(48, dialTickCount(240 * 60), "the backend's 240-minute maximum")
}
@Test
fun `a degenerate interval still draws one tick`() {
assertEquals(1, dialTickCount(0))
assertEquals(1, dialTickCount(30))
}
@Test
fun `spent ticks track elapsed time`() {
val planned = 25 * 60
assertEquals(0, dialSpentTicks(planned, planned))
assertEquals(12, dialSpentTicks(planned, planned / 2))
assertEquals(25, dialSpentTicks(planned, 0))
}
@Test
fun `spent ticks stay inside the dial`() {
val planned = 25 * 60
assertEquals(25, dialSpentTicks(planned, -60))
assertEquals(0, dialSpentTicks(planned, planned + 60))
}
}
class NextKindTest {
@Test
fun `work follows anything that is not a completed work interval`() {
assertEquals(Kind.WORK, nextKindLocally(null, null, 0, 4))
assertEquals(Kind.WORK, nextKindLocally(Kind.SHORT_BREAK, Status.COMPLETED, 2, 4))
assertEquals(Kind.WORK, nextKindLocally(Kind.LONG_BREAK, Status.COMPLETED, 4, 4))
}
@Test
fun `an abandoned work interval does not earn a break`() {
assertEquals(Kind.WORK, nextKindLocally(Kind.WORK, Status.ABANDONED, 3, 4))
}
@Test
fun `a short break follows work mid-cycle`() {
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 1, 4))
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 4))
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 5, 4))
}
@Test
fun `a long break lands on the cycle boundary`() {
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 4, 4))
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 8, 4))
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 2, 2))
}
@Test
fun `a long break is never owed from yesterday`() {
// The count resets with the day, so zero completed means no boundary.
assertEquals(Kind.SHORT_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 0, 4))
}
@Test
fun `a nonsense cycle length does not divide by zero`() {
assertEquals(Kind.LONG_BREAK, nextKindLocally(Kind.WORK, Status.COMPLETED, 3, 0))
}
}
class StrictModeTest {
@Test
fun `strict mode demands a reason for abandoning work`() {
assertTrue(abandonNeedsReason(true, Kind.WORK, null))
assertTrue(abandonNeedsReason(true, Kind.WORK, ""))
assertTrue(abandonNeedsReason(true, Kind.WORK, " "))
assertFalse(abandonNeedsReason(true, Kind.WORK, "the phone rang"))
}
@Test
fun `breaks and non-strict mode need no reason`() {
assertFalse(abandonNeedsReason(true, Kind.SHORT_BREAK, null))
assertFalse(abandonNeedsReason(true, Kind.LONG_BREAK, null))
assertFalse(abandonNeedsReason(false, Kind.WORK, null))
}
}
class StaleIntervalTest {
@Test
fun `six hours is the client-side reap threshold`() {
assertFalse(isLocallyStale(0))
assertFalse(isLocallyStale(STALE_INTERVAL_SECONDS))
assertTrue(isLocallyStale(STALE_INTERVAL_SECONDS + 1))
assertTrue(isLocallyStale(14 * 3600), "a phone offline overnight claims nothing")
}
}
/**
* One named test per row of the conflict matrix. This is the logic that decides whether
* a difference between the phone and the server is our own replay landing, another
* device taking over, or something worth interrupting the user about — and it is
* impossible to exercise by hand on a device.
*/
class ReconcileTest {
private fun session(id: Long, kind: String = Kind.WORK, status: String = Status.RUNNING) =
TimerSessionFacts(
id = id,
kind = kind,
status = status,
plannedSeconds = 1500,
remainingSeconds = 900,
)
private fun state(current: TimerSessionFacts?, enabled: Boolean = true) =
ServerTimerState(current = current, timerEnabled = enabled)
@Test
fun `no answer means keep ticking`() {
assertEquals(
Reconciliation.Offline,
reconcile(null, LocalBelief(sessionId = 7L)),
)
}
@Test
fun `same interval, nothing queued, re-anchor silently`() {
assertEquals(
Reconciliation.Adopt,
reconcile(state(session(7L)), LocalBelief(sessionId = 7L)),
)
}
@Test
fun `same interval with a queue keeps the optimistic value`() {
// Re-anchoring here would make a pending +5 visibly bounce back and forth.
assertEquals(
Reconciliation.KeepOptimistic,
reconcile(
state(session(7L)),
LocalBelief(sessionId = 7L),
PendingOps(hasAny = true),
),
)
}
@Test
fun `a different interval is another device taking over`() {
val result = reconcile(
state(session(9L, kind = Kind.SHORT_BREAK)),
LocalBelief(sessionId = 7L),
)
assertEquals(Kind.SHORT_BREAK, assertIs<Reconciliation.AdoptForeign>(result).kind)
}
@Test
fun `a different interval we queued ourselves is adopted quietly`() {
assertEquals(
Reconciliation.AdoptOwnReplay,
reconcile(
state(session(9L)),
LocalBelief(sessionId = 7L),
PendingOps(hasAny = true, hasStart = true),
),
)
}
@Test
fun `a vanished interval we did not end is reported`() {
assertEquals(
Reconciliation.EndedElsewhere,
reconcile(state(null), LocalBelief(sessionId = 7L)),
)
}
@Test
fun `a vanished interval we completed ourselves is not`() {
assertEquals(
Reconciliation.AdoptOwnReplay,
reconcile(
state(null),
LocalBelief(sessionId = 7L),
PendingOps(hasAny = true, hasTerminal = true),
),
)
}
@Test
fun `a pause we did not perform is reported`() {
assertEquals(
Reconciliation.PausedElsewhere,
reconcile(
state(session(7L, status = Status.PAUSED)),
LocalBelief(sessionId = 7L, paused = false),
),
)
}
@Test
fun `a pause we already knew about is not news`() {
assertEquals(
Reconciliation.Adopt,
reconcile(
state(session(7L, status = Status.PAUSED)),
LocalBelief(sessionId = 7L, paused = true),
),
)
}
@Test
fun `a cold start simply adopts whatever is running`() {
// Local knows nothing yet; an interval on the server is not a conflict.
assertEquals(
Reconciliation.Adopt,
reconcile(state(session(7L)), LocalBelief(sessionId = null)),
)
}
@Test
fun `idle on both sides is quiet`() {
assertEquals(
Reconciliation.Adopt,
reconcile(state(null), LocalBelief(sessionId = null)),
)
}
@Test
fun `view-only mode purges the queue`() {
// Every timer intent would 400 with "Timer is disabled (view-only mode)".
assertEquals(
Reconciliation.ViewOnlyPurge,
reconcile(
state(session(7L), enabled = false),
LocalBelief(sessionId = 7L),
PendingOps(hasAny = true),
),
)
}
@Test
fun `view-only wins even when idle and empty`() {
assertEquals(
Reconciliation.ViewOnlyPurge,
reconcile(state(null, enabled = false), LocalBelief(sessionId = null)),
)
}
}
@@ -0,0 +1,261 @@
package tech.kadence.core
import java.io.File
import kotlin.math.roundToLong
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The design system, checked against the web app's own source.
*
* The brief is that every client is "the same product", and the README says the desktop
* `Theme` singleton "carries the same tokens as the web app's `tokens.ts`". That has
* only ever been true by hand. This test reads the actual TypeScript and fails if a
* single channel drifts, which is the only version of that promise a build can keep.
*
* If `frontend/` is ever absent (a checkout of this module alone), the parity tests skip
* rather than fail — they are a guard, not a dependency.
*/
class TokenParityTest {
private val repoRoot: File? = generateSequence(File("").absoluteFile) { it.parentFile }
.firstOrNull { File(it, "frontend/src/tokens.ts").isFile }
private val tokensTs: File? = repoRoot?.let { File(it, "frontend/src/tokens.ts") }
private val vizTs: File? = repoRoot?.let { File(it, "frontend/src/lib/viz.ts") }
/** `"#FAF7F2"` or `"rgba(23, 21, 15, 0.12)"` -> `0xAARRGGBB`. */
private fun parseCss(value: String): Long {
val hex = Regex("^#([0-9a-fA-F]{6})$").find(value.trim())
if (hex != null) return rgb(hex.groupValues[1].toLong(16))
val rgba = Regex(
"""^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)$""",
).find(value.trim()) ?: error("unrecognised colour: $value")
val (r, g, b, a) = rgba.destructured
val packed = (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()
return ((a.toDouble() * 255.0).roundToLong() shl 24) or packed
}
/** Pull `name: "value",` pairs out of the named `export const` block. */
private fun block(source: String, name: String): Map<String, String> {
val start = source.indexOf("$name: Tokens = {")
.takeIf { it >= 0 }
?: source.indexOf("$name: VizPalette = {")
require(start >= 0) { "could not find the `$name` block" }
val open = source.indexOf('{', start)
val close = source.indexOf("};", open)
val body = source.substring(open + 1, close)
return Regex("""(\w+)\s*:\s*"([^"]+)"""")
.findAll(body)
.associate { it.groupValues[1] to it.groupValues[2] }
}
private fun sequentialOf(source: String, name: String): List<String> {
val start = source.indexOf("$name: VizPalette = {")
require(start >= 0)
val seqAt = source.indexOf("sequential:", start)
val open = source.indexOf('[', seqAt)
val close = source.indexOf(']', open)
return Regex(""""(#[0-9a-fA-F]{6})"""")
.findAll(source.substring(open, close))
.map { it.groupValues[1] }
.toList()
}
private fun assertPalette(expected: Map<String, String>, actual: KadenceTokens, mode: String) {
val ours = mapOf(
"paper" to actual.paper,
"surface" to actual.surface,
"surfaceSunken" to actual.surfaceSunken,
"ink" to actual.ink,
"inkMuted" to actual.inkMuted,
"inkFaint" to actual.inkFaint,
"rule" to actual.rule,
"ruleStrong" to actual.ruleStrong,
"ember" to actual.ember,
"emberSoft" to actual.emberSoft,
"pine" to actual.pine,
"amber" to actual.amber,
"clay" to actual.clay,
)
assertEquals(
expected.keys.sorted(),
ours.keys.sorted(),
"$mode: the Kotlin palette and tokens.ts must declare the same tokens",
)
for ((key, css) in expected) {
assertEquals(
parseCss(css).toString(16),
ours.getValue(key).toString(16),
"$mode token `$key` drifted from tokens.ts ($css)",
)
}
}
@Test
fun `the light palette matches tokens ts`() {
val source = tokensTs?.readText() ?: return
assertPalette(block(source, "light"), KADENCE_LIGHT, "light")
}
@Test
fun `the dark palette matches tokens ts`() {
val source = tokensTs?.readText() ?: return
assertPalette(block(source, "dark"), KADENCE_DARK, "dark")
}
@Test
fun `the chart palette matches viz ts`() {
val source = vizTs?.readText() ?: return
for ((name, ours) in listOf("LIGHT" to VIZ_LIGHT, "DARK" to VIZ_DARK)) {
val expected = block(source, name)
assertEquals(parseCss(expected.getValue("work")), ours.work, "$name work")
assertEquals(
parseCss(expected.getValue("shortBreak")), ours.shortBreak, "$name shortBreak",
)
assertEquals(
parseCss(expected.getValue("longBreak")), ours.longBreak, "$name longBreak",
)
assertEquals(parseCss(expected.getValue("grid")), ours.grid, "$name grid")
assertEquals(parseCss(expected.getValue("axis")), ours.axis, "$name axis")
assertEquals(parseCss(expected.getValue("empty")), ours.empty, "$name empty")
val seq = sequentialOf(source, name).map(::parseCss)
assertEquals(seq, ours.sequential, "$name sequential ramp")
}
}
@Test
fun `the dark ramp is the light ramp reversed and shifted one step darker`() {
// Not a plain reversal, which is the easy thing to assume. The dark ramp drops
// the palest light step and prepends a darker blue (#104281), so that against a
// dark ground "more" still reads as "darker" without the top step glowing.
assertEquals(
VIZ_LIGHT.sequential.reversed().dropLast(1),
VIZ_DARK.sequential.drop(1),
)
assertEquals(rgb(0x104281), VIZ_DARK.sequential.first())
assertEquals(6, VIZ_DARK.sequential.size)
}
@Test
fun `the parity guard actually found the web app`() {
// Guards against the tests above silently skipping forever.
assertTrue(
tokensTs?.isFile == true,
"frontend/src/tokens.ts not found from ${File("").absolutePath} — " +
"the parity tests would skip",
)
}
}
class RampStepTest {
@Test
fun `nothing happened means no colour`() {
assertEquals(null, rampStep(0, 8, VIZ_LIGHT))
assertEquals(null, rampStep(-1, 8, VIZ_LIGHT))
}
@Test
fun `a met goal lands on the darkest step`() {
assertEquals(VIZ_LIGHT.sequential.last(), rampStep(8, 8, VIZ_LIGHT))
assertEquals(VIZ_LIGHT.sequential.last(), rampStep(16, 8, VIZ_LIGHT))
}
@Test
fun `steps climb with the ratio`() {
val steps = (1..8).map { rampStep(it, 8, VIZ_LIGHT) }
assertEquals(steps, steps.sortedBy { VIZ_LIGHT.sequential.indexOf(it) })
assertEquals(VIZ_LIGHT.sequential[3], rampStep(4, 8, VIZ_LIGHT))
}
@Test
fun `a zero goal falls back to a goal of eight`() {
assertEquals(rampStep(4, 8, VIZ_LIGHT), rampStep(4, 0, VIZ_LIGHT))
}
@Test
fun `kindColor maps every interval kind`() {
assertEquals(VIZ_LIGHT.work, kindColor(Kind.WORK, VIZ_LIGHT))
assertEquals(VIZ_LIGHT.shortBreak, kindColor(Kind.SHORT_BREAK, VIZ_LIGHT))
assertEquals(VIZ_LIGHT.longBreak, kindColor(Kind.LONG_BREAK, VIZ_LIGHT))
assertEquals(VIZ_LIGHT.shortBreak, kindColor("something_new", VIZ_LIGHT))
}
}
/**
* The system accent. You asked for the phone's own colour; these tests pin down how far
* it reaches and what stops it producing an unreadable button.
*/
class AccentTest {
@Test
fun `the system accent is used when Android offers one`() {
val wallpaperBlue = rgb(0x3F5AA6)
assertEquals(wallpaperBlue, accentArgb(wallpaperBlue, dark = false))
}
@Test
fun `ember is the fallback when there is no dynamic colour`() {
assertEquals(KADENCE_LIGHT.ember, accentArgb(null, dark = false))
assertEquals(KADENCE_DARK.ember, accentArgb(null, dark = true))
}
@Test
fun `contrast ratio is the WCAG formula`() {
assertEquals(21.0, contrastRatio(rgb(0x000000), rgb(0xFFFFFF)), 0.01)
assertEquals(1.0, contrastRatio(rgb(0x808080), rgb(0x808080)), 0.001)
}
@Test
fun `white sits on the brand ember, as the other clients hard-code`() {
assertEquals(rgb(0xFFFFFF), onAccentArgb(KADENCE_LIGHT.ember))
assertTrue(meetsBodyContrast(rgb(0xFFFFFF), KADENCE_LIGHT.ember))
}
@Test
fun `a pale system accent gets dark text instead of unreadable white`() {
// Material You will happily return a pale yellow; white-on-pale is the failure
// mode this guard exists for.
val paleYellow = rgb(0xFFE08A)
assertEquals(rgb(0x16140F), onAccentArgb(paleYellow))
assertTrue(meetsBodyContrast(onAccentArgb(paleYellow), paleYellow))
}
@Test
fun `whatever the accent, its label is readable`() {
// Sweep the hue/lightness space the system could hand us and assert the chosen
// label always clears AA for large text.
var checked = 0
for (r in 0..255 step 51) {
for (g in 0..255 step 51) {
for (b in 0..255 step 51) {
val accent = rgb((r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong())
val label = onAccentArgb(accent)
assertTrue(
meetsLargeTextContrast(label, accent),
"label on #%06x only reaches %.2f:1".format(
accent and 0xFFFFFF,
contrastRatio(label, accent),
),
)
checked++
}
}
}
assertTrue(checked > 200, "the sweep should cover the colour space")
}
@Test
fun `the chart palette never follows the accent`() {
// A chart has to stay colour-blind readable, which is a property of the
// validated palette, not of the wallpaper.
val wallpaper = rgb(0x3F5AA6)
assertTrue(VIZ_LIGHT.work != wallpaper)
assertEquals(rgb(0xEB6834), VIZ_LIGHT.work, "work keeps its validated orange")
}
}