some backend fixes and add android app
This commit is contained in:
69 files changed
+7111
-7
No files matched your search
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.kotlin.test)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user