Add comprehensive tests for outbox operations, time windows, timers, token parity, and accent colors
- Implement `OutboxCoalesceTest` to validate coalescing behavior of outbox operations. - Create `TimeWindowTest` to ensure correct handling of time windows, including edge cases. - Add `TimerTest` to verify timer functionality, including countdowns, pauses, and progress calculations. - Introduce `TokenParityTest` to ensure design tokens match between Kotlin and TypeScript sources. - Implement `RampStepTest` to validate color ramp calculations and mappings for interval kinds. - Add `AccentTest` to verify system accent behavior and contrast ratios for accessibility compliance.
This commit is contained in:
18 files changed
+3835
No files matched your search
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* 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)`; with
|
||||||
|
* `wildcard` set, `*.youtube.com` returns both at once — the concrete
|
||||||
|
* names block on their own, and the pattern says "every other subdomain too",
|
||||||
|
* which the URL-bar matcher (here) and the desktop's resolver stub take from there.
|
||||||
|
*/
|
||||||
|
fun expandEntry(raw: String, labels: List<String>, wildcard: Boolean = false): 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
|
||||||
|
val hosts = listOf(base) + labels.map { "$it.$base" }
|
||||||
|
return hosts to (entry.takeIf { wildcard })
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* A plain entry covers itself and its subdomains, matching what a plain entry does on
|
||||||
|
* the block side (`youtube.com` blocked catches `music.youtube.com`). An exception used
|
||||||
|
* to be glob-matched whatever it looked like, so the two halves of one list read
|
||||||
|
* identically and behaved differently. A masked entry is glob-matched as before, which
|
||||||
|
* is still how you allow something narrower than a whole subtree.
|
||||||
|
*
|
||||||
|
* Parity with `blocker_service.is_allowed`; `BlockerTest` pins it.
|
||||||
|
*/
|
||||||
|
fun isAllowed(host: String, allowMasks: List<String>): Boolean = allowMasks.any { entry ->
|
||||||
|
if (isMask(entry)) globMatches(entry, host) else host == entry || host.endsWith(".$entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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('.') }
|
||||||
|
|
||||||
|
/** One label of a hostname: what may legally sit in front of `.example.com`. */
|
||||||
|
private val LABEL_RE = Regex("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||||
|
|
||||||
|
/** What `subdomainExpansions` asked for, split three ways. See [parseExpansions]. */
|
||||||
|
data class Expansion(
|
||||||
|
val labels: List<String> = emptyList(),
|
||||||
|
val wildcard: Boolean = false,
|
||||||
|
val rejected: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `subdomainExpansions` split into labels, the `*` flag, and rejects — the port of
|
||||||
|
* `parse_expansions()`.
|
||||||
|
*
|
||||||
|
* A bare `*` is the user saying *every* subdomain, and it is supported rather than
|
||||||
|
* refused: the labels still expand into hostnames (which block without help), while
|
||||||
|
* [Expansion.wildcard] tells the matcher to cover the rest by mask. Before that, `*`
|
||||||
|
* was treated as a label, so `*.telegram.org` expanded to the literal string
|
||||||
|
* `*.telegram.org` — not a hostname, which the desktop app dropped without a word,
|
||||||
|
* `unexpandable` stayed empty, and `web.telegram.org` was never blocked. Anything
|
||||||
|
* that is neither a label nor `*` is still refused, and reported as a reject.
|
||||||
|
*/
|
||||||
|
fun parseExpansions(raw: List<String>): Expansion {
|
||||||
|
val labels = LinkedHashSet<String>()
|
||||||
|
val rejected = LinkedHashSet<String>()
|
||||||
|
var wildcard = false
|
||||||
|
for (entry in raw) {
|
||||||
|
val label = entry.trim().lowercase()
|
||||||
|
if (label.isEmpty()) continue
|
||||||
|
when {
|
||||||
|
label == "*" -> wildcard = true
|
||||||
|
LABEL_RE.matches(label) -> labels.add(label)
|
||||||
|
else -> rejected.add(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Expansion(labels.toList(), wildcard, rejected.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expansion entries neither a label nor `*`, so the UI can flag them. */
|
||||||
|
fun invalidExpansions(rules: BlockerRules): List<String> =
|
||||||
|
parseExpansions(rules.subdomainExpansions).rejected
|
||||||
|
|
||||||
|
private fun expansionOf(rules: BlockerRules): Expansion =
|
||||||
|
parseExpansions(rules.subdomainExpansions)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 expansion = expansionOf(rules)
|
||||||
|
val origin = LinkedHashMap<String, String>()
|
||||||
|
for (raw in rules.blockedDomains) {
|
||||||
|
val entry = raw.trim().lowercase().trimStart('.')
|
||||||
|
val (expanded, _) = expandEntry(raw, expansion.labels, expansion.wildcard)
|
||||||
|
for (host in expanded) {
|
||||||
|
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block-list entries a hosts file cannot express — the patterns this matcher takes
|
||||||
|
* over in the URL bar, and that the desktop app answers at DNS level when it can
|
||||||
|
* route the zone. The UI shows them as "enforced beyond the hosts file".
|
||||||
|
*/
|
||||||
|
fun unexpandableMasks(rules: BlockerRules): List<String> {
|
||||||
|
val expansion = expansionOf(rules)
|
||||||
|
val leftovers = LinkedHashSet<String>()
|
||||||
|
for (raw in rules.blockedDomains) {
|
||||||
|
val (_, pattern) = expandEntry(raw, expansion.labels, expansion.wildcard)
|
||||||
|
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,220 @@
|
|||||||
|
/**
|
||||||
|
* 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 a hosts file can. A hosts file has no
|
||||||
|
* wildcards, so the backend expands every mask into concrete hostnames and reports
|
||||||
|
* whatever it cannot express as `unexpandable`. A URL-bar matcher has no such limit:
|
||||||
|
* `*gram*` is matched here, and `*.youtube.com` covers every subdomain as soon as
|
||||||
|
* `*` is in `subdomain_expansions` — the same two things the desktop app now does
|
||||||
|
* through its local resolver stub.
|
||||||
|
*/
|
||||||
|
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 expansion: Expansion = parseExpansions(rules.subdomainExpansions)
|
||||||
|
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 a hosts file cannot do —
|
||||||
|
// a `*gram*` mask, or every subdomain once `*` says so.
|
||||||
|
for (entry in rawEntries) {
|
||||||
|
if (entryCovers(entry, host)) {
|
||||||
|
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlockDecision.Allow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a hostname one block-list entry covers.
|
||||||
|
*
|
||||||
|
* * a plain entry covers itself and any subdomain of it, so `youtube.com` catches
|
||||||
|
* `music.youtube.com` even though no expansion listed it;
|
||||||
|
* * `*.youtube.com` is a wildcard only when `*` is in `subdomain_expansions` —
|
||||||
|
* otherwise it means exactly the configured labels, which is what the desktop
|
||||||
|
* app expands into a hosts file, and what the settings screens say;
|
||||||
|
* * any other mask (`*gram*`, `*gram.com`) is glob-matched, as it always was —
|
||||||
|
* the one place the phone stays stronger than a hosts file.
|
||||||
|
*/
|
||||||
|
private fun entryCovers(entry: String, host: String): Boolean {
|
||||||
|
if (entry.isEmpty()) return false
|
||||||
|
if (entry.startsWith("*.")) {
|
||||||
|
val base = entry.removePrefix("*.")
|
||||||
|
if (base.isNotEmpty() && '*' !in base) {
|
||||||
|
if (expansion.wildcard) return globMatches(entry, host)
|
||||||
|
return host == base || expansion.labels.any { host == "$it.$base" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (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,306 @@
|
|||||||
|
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 `a wildcard is the user asking for every subdomain`() {
|
||||||
|
// `*` used to be treated as a subdomain label, so `*.telegram.org` expanded to
|
||||||
|
// the literal string `*.telegram.org` — which every client then dropped without
|
||||||
|
// a word. It is the wildcard now: the labels still expand into real hostnames,
|
||||||
|
// and the pattern comes back for the matcher to take the rest.
|
||||||
|
val rules = BlockerRules(
|
||||||
|
blockedDomains = listOf("*.telegram.org"),
|
||||||
|
subdomainExpansions = listOf("*", "web", "", "not a label"),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("web"), parseExpansions(rules.subdomainExpansions).labels)
|
||||||
|
assertTrue(parseExpansions(rules.subdomainExpansions).wildcard)
|
||||||
|
assertEquals(listOf("not a label"), invalidExpansions(rules))
|
||||||
|
assertEquals(listOf("telegram.org", "web.telegram.org"), expandDomains(rules))
|
||||||
|
assertTrue(expandDomains(rules).none { '*' in it })
|
||||||
|
assertEquals(listOf("*.telegram.org"), unexpandableMasks(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without a wildcard the mask stays inside the expansion list`() {
|
||||||
|
// The documented meaning of `*.google.com` before `*` existed: the bare domain
|
||||||
|
// plus the labels the user configured, nothing more, and nothing for a hosts
|
||||||
|
// file to refuse.
|
||||||
|
val rules = BlockerRules(
|
||||||
|
blockedDomains = listOf("*.google.com"),
|
||||||
|
subdomainExpansions = listOf("www", "mail"),
|
||||||
|
)
|
||||||
|
assertFalse(parseExpansions(rules.subdomainExpansions).wildcard)
|
||||||
|
assertEquals(emptyList(), unexpandableMasks(rules))
|
||||||
|
assertEquals(
|
||||||
|
listOf("google.com", "www.google.com", "mail.google.com"),
|
||||||
|
expandDomains(rules),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain exception covers its subdomains, like a plain block does`() {
|
||||||
|
// Parity with `blocker_service.is_allowed`. `youtube.com` blocked catches
|
||||||
|
// `music.youtube.com`; `youtube.com` allowed now spares it, instead of sparing
|
||||||
|
// only the literal string while reading exactly like the block entry.
|
||||||
|
assertTrue(isAllowed("music.youtube.com", listOf("youtube.com")))
|
||||||
|
assertTrue(isAllowed("youtube.com", listOf("youtube.com")))
|
||||||
|
assertFalse(isAllowed("notyoutube.com", listOf("youtube.com")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a masked exception is still a glob`() {
|
||||||
|
assertTrue(isAllowed("docs.google.com", listOf("docs.*")))
|
||||||
|
assertTrue(isAllowed("a.docs.google.com", listOf("*.docs.google.com")))
|
||||||
|
assertFalse(isAllowed("sheets.google.com", listOf("docs.*")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exception subtracts its subtree from the expanded list`() {
|
||||||
|
val rules = BlockerRules(
|
||||||
|
blockedDomains = listOf("*.youtube.com", "reddit.com"),
|
||||||
|
allowedDomains = listOf("youtube.com"),
|
||||||
|
subdomainExpansions = listOf("www", "m", "music"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,347 @@
|
|||||||
|
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"), subs = listOf("www", "old"))
|
||||||
|
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")
|
||||||
|
// …and only those: without `*` in the expansion list, an unlisted label is out.
|
||||||
|
assertEquals(BlockDecision.Allow, m.decide("cdn.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 `without a wildcard a subdomain mask covers only the configured labels`() {
|
||||||
|
// What the settings screens promise for `*.google.com`: the bare domain plus
|
||||||
|
// the labels listed in `subdomain_expansions`, and nothing else.
|
||||||
|
val m = matcher(blocked = listOf("*.google.com"), subs = listOf("www", "mail"))
|
||||||
|
assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
|
||||||
|
assertBlocked(m.decide("mail.google.com"), "mail.google.com", "*.google.com")
|
||||||
|
assertEquals(BlockDecision.Allow, m.decide("cdn.google.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wildcard in the expansion list makes the mask cover every label`() {
|
||||||
|
val m = matcher(blocked = listOf("*.google.com"), subs = listOf("*", "www"))
|
||||||
|
assertBlocked(m.decide("cdn.google.com"), "cdn.google.com", "*.google.com")
|
||||||
|
assertBlocked(m.decide("a.b.google.com"), "a.b.google.com", "*.google.com")
|
||||||
|
// The bare domain still belongs to the mask, via its expansion.
|
||||||
|
assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
|
||||||
|
// And the allow list still wins over the wildcard.
|
||||||
|
val allowed = matcher(
|
||||||
|
blocked = listOf("*.google.com"),
|
||||||
|
allowed = listOf("docs.google.com"),
|
||||||
|
subs = listOf("*"),
|
||||||
|
)
|
||||||
|
assertEquals(BlockDecision.Allow, allowed.decide("docs.google.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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