feat(worldclock): the catalog, the search, the comparison and the face's geometry

The pure-Kotlin half of the world clock, with no Android on it anywhere and no
ambient zone read — the device's zone arrives as an argument, and an
architecture rule now enforces that.

`ZoneCatalog` de-duplicates the device's own tzdata by canonical id and keeps
the region ids, with `UTC` as the one stated exception — and keeps an alias
whose canonical id the device does not have, because tzdata and CLDR ship as
separate modules and can disagree. `ZoneSearch` matches word prefixes over
diacritic-folded text, so "sao" finds São Paulo and "erl" does not find Berlin.
`ZoneComparison` reads the offset and the day difference **at the instant**, so
Berlin to Sydney is ten hours in January and eight in July. `AnalogFace` turns
an instant into hand angles and says whether it is day or night there.

`Zones.isValid` now tests a snapshot rather than allocating the platform's set
on every call; tzdata takes effect at reboot, so the answer cannot change under
a running process.
This commit is contained in:
2026-09-22 13:04:54 +02:00
parent 8dec91fd4c
commit e0d5f1f254
13 changed files with 1346 additions and 1 deletions
@@ -14,8 +14,20 @@ val WorldClock.isKnownZone: Boolean get() = Zones.isValid(zoneId)
/** IANA zone-id validation. Shared with M9's intent-extra validation. */
object Zones {
/**
* Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry
* `HashSet` on every call, and the world clock asks per row per 200 ms tick
* — twenty-four cities would be a hundred and twenty-five throwaway sets a
* second. The device's tzdata arrives as an APEX module that takes effect
* at reboot, so the answer cannot change under a running process.
*/
private val available: Set<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
ZoneId.getAvailableZoneIds()
}
/** True only for region-based IANA ids the device knows; rejects blanks and fixed offsets. */
fun isValid(zoneId: String): Boolean = zoneId in ZoneId.getAvailableZoneIds()
fun isValid(zoneId: String): Boolean = zoneId in available
/** Trimmed id if [isValid] after trimming, else null. Never throws. */
fun normalise(zoneId: String?): String? = zoneId?.trim()?.takeIf(::isValid)
@@ -28,6 +28,9 @@ interface ElapsedRealtimeClock {
*/
interface ZoneProvider {
fun current(): java.time.ZoneId
/** Every zone id the device's tzdata knows. Injected for the same reason [current] is. */
fun available(): Set<String>
}
/**
@@ -0,0 +1,62 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import java.time.LocalTime
/**
* The face's geometry, as plain numbers: every angle the `Canvas` draws comes
* from here, so the dial is asserted a hundred times over without a pixel.
*/
object AnalogFace {
private const val DEGREES_PER_HOUR = 30f
private const val DEGREES_PER_MINUTE = 6f
private const val TICKS = 12
/** Degrees clockwise from twelve, in `[0, 360)`. The hour hand creeps with the minute. */
fun hourAngle(time: LocalTime): Float =
(time.hour % TICKS) * DEGREES_PER_HOUR + time.minute * (DEGREES_PER_HOUR / 60f)
fun minuteAngle(time: LocalTime): Float =
time.minute * DEGREES_PER_MINUTE + time.second * (DEGREES_PER_MINUTE / 60f)
/** Discrete: the hand steps once a second, as a quartz movement does. */
fun secondAngle(time: LocalTime): Float = time.second * DEGREES_PER_MINUTE
/** Twelve hour marks: 0f, 30f … 330f. */
val tickAngles: List<Float> = (0 until TICKS).map { it * DEGREES_PER_HOUR }
/** True for the marks at twelve, three, six and nine — the ones drawn longer. */
fun isQuarterTick(index: Int): Boolean = index % 3 == 0
}
/**
* Whether it is day where the face is pointing, as a fraction — what the
* dial's `Circle`↔`Sunny` morph carries. A stated **convention, not an
* ephemeris**: it needs no latitude, and the app never claims it is sunrise.
*/
object DayNight {
val DAWN_START: LocalTime = LocalTime.of(5, 0)
val DAWN_END: LocalTime = LocalTime.of(7, 0)
val DUSK_START: LocalTime = LocalTime.of(17, 0)
val DUSK_END: LocalTime = LocalTime.of(19, 0)
/** 0f deep night … 1f full day, linear across the two ramps. */
fun fractionAt(time: LocalTime): Float {
val now = time.toSecondOfDay()
val dawnStart = DAWN_START.toSecondOfDay()
val dawnEnd = DAWN_END.toSecondOfDay()
val duskStart = DUSK_START.toSecondOfDay()
val duskEnd = DUSK_END.toSecondOfDay()
return when {
now <= dawnStart -> 0f
now < dawnEnd -> (now - dawnStart).toFloat() / (dawnEnd - dawnStart)
now <= duskStart -> 1f
now < duskEnd -> (duskEnd - now).toFloat() / (duskEnd - duskStart)
else -> 0f
}
}
/** `fractionAt(time) >= 0.5f`. */
fun isDay(time: LocalTime): Boolean = fractionAt(time) >= 0.5f
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import de.jeanlucmakiola.clockula.domain.Zones
import java.time.ZoneId
object WorldClocks {
/**
* One per hour of the day; the bound a non-lazy reorderable column needs.
* `ReorderableColumn` composes every row up front at a fixed pitch, so the
* list needs a ceiling — and twenty-four cities is a list a person can
* still take in at a glance.
*/
const val MAX: Int = 24
/** The order after moving [id] one place earlier. Unchanged when it is first or absent. */
fun moveUp(idsInOrder: List<Long>, id: Long): List<Long> =
swapped(idsInOrder, idsInOrder.indexOf(id), -1)
/** The order after moving [id] one place later. Unchanged when it is last or absent. */
fun moveDown(idsInOrder: List<Long>, id: Long): List<Long> =
swapped(idsInOrder, idsInOrder.indexOf(id), +1)
/** Always a permutation of its input: an out-of-range move is the input itself. */
private fun swapped(ids: List<Long>, from: Int, by: Int): List<Long> {
val to = from + by
if (from < 0 || to !in ids.indices) return ids
return ids.toMutableList().apply { add(to, removeAt(from)) }
}
}
object HomeZone {
/**
* [storedZoneId] when `Zones.normalise` accepts it, else [deviceZone].
* Never throws — a blank, an unknown id and a fixed offset all degrade to
* the device's own zone, because a home the tzdata dropped is not a home.
*/
fun resolve(storedZoneId: String?, deviceZone: ZoneId): ZoneId =
Zones.normalise(storedZoneId)
?.let { runCatching { ZoneId.of(it) }.getOrNull() }
?: deviceZone
}
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.clockula.domain.worldclock
/** One pickable zone, as the picker and the rows both see it. */
data class ZoneEntry(
val zoneId: String,
/** ICU's exemplar city, or [ZoneCatalog.cityFallbackFor] when ICU has none. */
val city: String,
/** The localised country name, or null when ICU has none. */
val country: String? = null,
)
object ZoneCatalog {
/** The ten IANA region prefixes a user-facing zone id may carry. */
val REGIONS: Set<String> = setOf(
"Africa",
"America",
"Antarctica",
"Arctic",
"Asia",
"Atlantic",
"Australia",
"Europe",
"Indian",
"Pacific",
)
/** Kept unconditionally when the device knows it: no region prefix, and ICU canonicalises it to `Etc/UTC`. */
const val UTC: String = "UTC"
/**
* The pickable ids: region-based ids that are their own canonical id, plus
* [UTC]. Sorted ascending by id. Pure — [canonicalOf] is the only thing
* that needs ICU, and it must be total (identity when ICU cannot say).
*
* The region rule drops `Etc/GMT+5`, `US/Pacific` and `EST5EDT`: a fixed
* offset is not a place, and it will be wrong in six months. The canonical
* rule drops the tzdata's own `backward` aliases, so the picker offers
* Asia/Kolkata and not also Asia/Calcutta — the answer comes from the
* device's tzdata rather than from a table of ours that would rot.
*
* An alias is only dropped when its canonical id is itself available: ICU
* (`com.android.i18n`) and tzdata (`com.android.tzdata`) are separately
* updatable APEX modules and can disagree, and a device whose tzdata still
* says `Europe/Kiev` while ICU already says `Europe/Kyiv` must not be left
* with no pickable zone for Ukraine at all.
*/
fun pickableIds(available: Set<String>, canonicalOf: (String) -> String): List<String> =
available
.filter { id ->
if (id == UTC) return@filter true
if (!isRegionId(id)) return@filter false
val canonical = canonicalOf(id)
canonical == id || canonical !in available
}
.sorted()
/**
* The last `/`-segment with underscores replaced by spaces, trimmed; the
* input trimmed when that is blank. Never throws.
*/
fun cityFallbackFor(zoneId: String): String {
val segment = zoneId.substringAfterLast('/').replace('_', ' ').trim()
return segment.ifBlank { zoneId.trim() }
}
/** By `ZoneSearch.fold(city)` ascending, then [ZoneEntry.zoneId] ascending. */
fun sorted(entries: List<ZoneEntry>): List<ZoneEntry> =
entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId }))
private fun isRegionId(zoneId: String): Boolean =
zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS
}
@@ -0,0 +1,111 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import kotlin.time.Instant
import kotlin.time.toJavaInstant
data class ZoneComparison(
/** The compared zone's local date-time at the instant asked about. */
val localDateTime: LocalDateTime,
/** Signed minutes the compared zone is ahead of home **at that instant**. */
val offsetMinutes: Int,
/** Signed whole days the compared zone's local date differs from home's. */
val dayDifference: Int,
)
object ZoneComparisons {
private const val SECONDS_PER_MINUTE = 60
/**
* Pure; wall clock. Both offsets are read at [at], so DST is the platform's
* answer rather than arithmetic on a standard offset: Berlin↔Sydney is ten
* hours apart in January and eight in July, and the two zones' summers do
* not overlap.
*/
fun compare(home: ZoneId, other: ZoneId, at: Instant): ZoneComparison {
val instant = at.toJavaInstant()
val homeLocal = instant.atZone(home)
val otherLocal = instant.atZone(other)
return ZoneComparison(
localDateTime = otherLocal.toLocalDateTime(),
offsetMinutes = offsetMinutes(otherLocal.offset.totalSeconds) -
offsetMinutes(homeLocal.offset.totalSeconds),
// The day comes from the two local dates, never from the offset:
// Berlin and Honolulu are eleven hours apart and still on the same
// calendar day at this instant.
dayDifference = ChronoUnit.DAYS
.between(homeLocal.toLocalDate(), otherLocal.toLocalDate())
.toInt(),
)
}
/** [zone]'s offset from UTC at [at], in signed minutes. */
fun utcOffsetMinutes(zone: ZoneId, at: Instant): Int =
offsetMinutes(zone.rules.getOffset(at.toJavaInstant()).totalSeconds)
private fun offsetMinutes(totalSeconds: Int): Int = totalSeconds / SECONDS_PER_MINUTE
}
sealed interface OffsetLabel {
data object Same : OffsetLabel
/** [hours] and [minutes] are non-negative magnitudes; the variant carries the sign. */
data class Ahead(val hours: Int, val minutes: Int) : OffsetLabel
data class Behind(val hours: Int, val minutes: Int) : OffsetLabel
}
enum class DayLabel { YESTERDAY, TODAY, TOMORROW }
/**
* Labels as **data**, not as strings — `NextFireFormat`'s precedent. The
* wording lives in `strings.xml`, and the magnitude is split here so a
* translator sees "4 hours 45 minutes ahead" rather than a signed 285 to
* interpret.
*/
object ZoneOffsetFormat {
private const val MINUTES_PER_HOUR = 60
fun labelFor(offsetMinutes: Int): OffsetLabel {
if (offsetMinutes == 0) return OffsetLabel.Same
val magnitude = abs(offsetMinutes)
val hours = magnitude / MINUTES_PER_HOUR
val minutes = magnitude % MINUTES_PER_HOUR
return if (offsetMinutes > 0) {
OffsetLabel.Ahead(hours, minutes)
} else {
OffsetLabel.Behind(hours, minutes)
}
}
/**
* Clamped: anything at or below −1 is YESTERDAY, at or above +1 TOMORROW.
* The extreme zones are 26 hours apart, so ±1 is the only outcome tzdata
* can reach; the clamp makes the function total rather than hiding a case.
*/
fun dayLabelFor(dayDifference: Int): DayLabel = when {
dayDifference <= -1 -> DayLabel.YESTERDAY
dayDifference >= 1 -> DayLabel.TOMORROW
else -> DayLabel.TODAY
}
/** `"GMT"`, `"GMT+1"`, `"GMT+5:45"`, `"GMT-3:30"`. ASCII digits through `Locale.ROOT`. */
fun gmtLabel(utcOffsetMinutes: Int): String {
if (utcOffsetMinutes == 0) return "GMT"
val sign = if (utcOffsetMinutes > 0) "+" else "-"
val magnitude = abs(utcOffsetMinutes)
val hours = magnitude / MINUTES_PER_HOUR
val minutes = magnitude % MINUTES_PER_HOUR
return if (minutes == 0) {
String.format(Locale.ROOT, "GMT%s%d", sign, hours)
} else {
String.format(Locale.ROOT, "GMT%s%d:%02d", sign, hours, minutes)
}
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import java.text.Normalizer
import java.util.Locale
object ZoneSearch {
/** Every run of characters that is neither a letter nor a digit — one word break. */
private val WORD_BREAK = Regex("[^\\p{L}\\p{N}]+")
/** The combining marks NFD leaves behind once the base letter is separated out. */
private val COMBINING_MARKS = Regex("\\p{Mn}+")
/** NFD, combining marks stripped, lowercased through `Locale.ROOT`, trimmed. */
fun fold(text: String): String =
COMBINING_MARKS
.replace(Normalizer.normalize(text, Normalizer.Form.NFD), "")
.lowercase(Locale.ROOT)
.trim()
/** [fold] split on every run of non-letter, non-digit characters; blanks dropped. */
fun words(text: String): List<String> =
fold(text).split(WORD_BREAK).filter(String::isNotEmpty)
/**
* True when **every** word of [query] is a prefix of **some** word of
* [entry]'s city, country or zone id. A blank query matches everything.
*
* A prefix on a word rather than a substring sweep: "york" and "new yor"
* both find New York, in either order, while "erl" does not find Berlin —
* which is what keeps a three-letter query from turning into forty hits.
*/
fun matches(entry: ZoneEntry, query: String): Boolean {
val wanted = words(query)
if (wanted.isEmpty()) return true
val haystack = words(entry.city) + words(entry.country.orEmpty()) + words(entry.zoneId)
return wanted.all { word -> haystack.any { it.startsWith(word) } }
}
/** [entries] filtered by [matches], input order preserved. */
fun filter(entries: List<ZoneEntry>, query: String): List<ZoneEntry> =
entries.filter { matches(it, query) }
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
/**
* IANA zone ids, not a bespoke city table. The same validation M9 will run over
@@ -50,4 +51,18 @@ class ZonesTest {
assertThat(Zones.normalise("Mars/Phobos")).isNull()
assertThat(Zones.normalise("+02:00")).isNull()
}
/**
* The id set is snapshotted once — `ZoneId.getAvailableZoneIds()` builds a
* fresh ~600-entry `HashSet` per call and the world clock asks per row per
* tick. The snapshot must still be the platform's own answer, not a table
* of ours.
*/
@Test
fun `the snapshot agrees with the platform for every id the device knows`() {
val deviceIds = ZoneId.getAvailableZoneIds()
assertThat(deviceIds.filterNot(Zones::isValid)).isEmpty()
assertThat(Zones.isValid("Mars/Phobos")).isFalse()
}
}
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalTime
/**
* §5.5 — 20 cases. Everything the face draws is angles over a `LocalTime` and a
* day fraction over the same, so the geometry is asserted without a pixel (D22)
* — and the morph the dial carries is information, not decoration (D5).
*/
class AnalogFaceTest {
private val tolerance = 0.001f
/** `[0f, 360f)` — the dial, half-open, spelled once. */
private val fullTurn: ClosedFloatingPointRange<Float> = 0f..360f
private infix fun Float.isOnThe(dial: ClosedFloatingPointRange<Float>): Boolean =
this >= dial.start && this < dial.endInclusive
private fun at(hour: Int, minute: Int = 0, second: Int = 0): LocalTime =
LocalTime.of(hour, minute, second)
private fun minutesOfDay(): List<LocalTime> =
(0 until 24 * 60).map { LocalTime.of(it / 60, it % 60) }
/** §5.5 #1 */
@Test
fun `twelve is the top of the dial, at both ends of the day`() {
assertThat(AnalogFace.hourAngle(at(0))).isWithin(tolerance).of(0f)
assertThat(AnalogFace.hourAngle(at(12))).isWithin(tolerance).of(0f)
}
/** §5.5 #2 */
@Test
fun `the quarters sit at ninety degrees apart`() {
assertThat(AnalogFace.hourAngle(at(3))).isWithin(tolerance).of(90f)
assertThat(AnalogFace.hourAngle(at(6))).isWithin(tolerance).of(180f)
assertThat(AnalogFace.hourAngle(at(9))).isWithin(tolerance).of(270f)
}
/** §5.5 #3 */
@Test
fun `the hour hand creeps half a degree a minute`() {
assertThat(AnalogFace.hourAngle(at(15, 30))).isWithin(tolerance).of(105f)
}
/** §5.5 #4 */
@Test
fun `the hour hand reaches the top only at the top of the hour`() {
assertThat(AnalogFace.hourAngle(at(23, 59))).isWithin(tolerance).of(359.5f)
}
/** §5.5 #5 */
@Test
fun `the hour angle never leaves the dial`() {
val offenders = minutesOfDay().filterNot { AnalogFace.hourAngle(it) isOnThe fullTurn }
assertThat(offenders).isEmpty()
}
/** §5.5 #6 */
@Test
fun `the hour angle rises steadily through each half of the day`() {
val halves = listOf(minutesOfDay().take(720), minutesOfDay().drop(720))
for (half in halves) {
val angles = half.map(AnalogFace::hourAngle)
assertThat(angles).isInStrictOrder()
}
}
/** §5.5 #7 */
@Test
fun `the minute hand is at the top on the hour and the bottom on the half`() {
assertThat(AnalogFace.minuteAngle(at(0, 0, 0))).isWithin(tolerance).of(0f)
assertThat(AnalogFace.minuteAngle(at(0, 30, 0))).isWithin(tolerance).of(180f)
}
/** §5.5 #8 */
@Test
fun `the minute hand creeps a tenth of a degree a second`() {
assertThat(AnalogFace.minuteAngle(at(0, 30, 30))).isWithin(tolerance).of(183f)
}
/** §5.5 #9 */
@Test
fun `the minute hand reaches the top only on the minute`() {
assertThat(AnalogFace.minuteAngle(at(0, 59, 59))).isWithin(tolerance).of(359.9f)
}
/** §5.5 #10 */
@Test
fun `the minute angle never leaves the dial`() {
val offenders = (0 until 3_600)
.map { LocalTime.of(0, it / 60, it % 60) }
.filterNot { AnalogFace.minuteAngle(it) isOnThe fullTurn }
assertThat(offenders).isEmpty()
}
/** §5.5 #11 */
@Test
fun `the second hand is three quarters round at forty-five seconds`() {
assertThat(AnalogFace.secondAngle(at(0, 0, 45))).isWithin(tolerance).of(270f)
}
/** §5.5 #12 */
@Test
fun `the second hand steps rather than sweeps`() {
val nearlyTheNextSecond = LocalTime.of(0, 0, 45, 999_999_999)
assertThat(AnalogFace.secondAngle(nearlyTheNextSecond)).isWithin(tolerance).of(270f)
}
/** §5.5 #13 */
@Test
fun `the second angle is six degrees a second, all the way round`() {
for (second in 0 until 60) {
val angle = AnalogFace.secondAngle(at(0, 0, second))
assertThat(angle).isWithin(tolerance).of(second * 6f)
assertThat(angle isOnThe fullTurn).isTrue()
}
}
/** §5.5 #14 */
@Test
fun `there are twelve hour marks, one every thirty degrees`() {
assertThat(AnalogFace.tickAngles)
.containsExactly(0f, 30f, 60f, 90f, 120f, 150f, 180f, 210f, 240f, 270f, 300f, 330f)
.inOrder()
}
/** §5.5 #15 */
@Test
fun `only the four quarters are quarter ticks`() {
val quarters = (0..11).filter(AnalogFace::isQuarterTick)
assertThat(quarters).containsExactly(0, 3, 6, 9).inOrder()
}
/** §5.5 #16 */
@Test
fun `the middle of the night is a flat zero`() {
assertThat(DayNight.fractionAt(at(0, 0))).isWithin(tolerance).of(0f)
assertThat(DayNight.fractionAt(at(4, 59))).isWithin(tolerance).of(0f)
assertThat(DayNight.fractionAt(at(23, 59))).isWithin(tolerance).of(0f)
}
/** §5.5 #17 */
@Test
fun `the two ramps cross their halfway points at six and eighteen`() {
assertThat(DayNight.fractionAt(at(6, 0))).isWithin(tolerance).of(0.5f)
assertThat(DayNight.fractionAt(at(7, 0))).isWithin(tolerance).of(1f)
assertThat(DayNight.fractionAt(at(12, 0))).isWithin(tolerance).of(1f)
assertThat(DayNight.fractionAt(at(17, 0))).isWithin(tolerance).of(1f)
assertThat(DayNight.fractionAt(at(18, 0))).isWithin(tolerance).of(0.5f)
assertThat(DayNight.fractionAt(at(19, 0))).isWithin(tolerance).of(0f)
}
/** §5.5 #18 */
@Test
fun `the day fraction is a fraction at every minute of the day`() {
val offenders = minutesOfDay().filterNot { DayNight.fractionAt(it) in 0f..1f }
assertThat(offenders).isEmpty()
}
/** §5.5 #19 */
@Test
fun `the ramps are monotonic`() {
val dawn = (5 * 60..7 * 60).map { DayNight.fractionAt(LocalTime.of(it / 60 % 24, it % 60)) }
val dusk = (17 * 60..19 * 60).map { DayNight.fractionAt(LocalTime.of(it / 60 % 24, it % 60)) }
assertThat(dawn).isInOrder()
assertThat(dusk).isInOrder(compareByDescending<Float> { it })
}
/** §5.5 #20 */
@Test
fun `it is day exactly once the fraction has reached a half`() {
assertThat(DayNight.isDay(at(5, 59))).isFalse()
assertThat(DayNight.isDay(at(6, 0))).isTrue()
assertThat(DayNight.isDay(at(18, 0))).isTrue()
assertThat(DayNight.isDay(at(18, 1))).isFalse()
}
}
@@ -0,0 +1,113 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.BERLIN
import org.junit.jupiter.api.Test
import java.time.ZoneId
/**
* §5.4 — 13 cases. The cap is a constant with a reason (D12), the two moves are
* always a permutation of their input (D13), and a home zone the device no
* longer knows degrades to the device's own rather than throwing (D6).
*/
class WorldClocksTest {
private val tokyo: ZoneId = ZoneId.of("Asia/Tokyo")
/** §5.4 #1 */
@Test
fun `the list is capped at one clock per hour of the day`() {
assertThat(WorldClocks.MAX).isEqualTo(24)
}
/** §5.4 #2 */
@Test
fun `moving up swaps a row with the one before it`() {
assertThat(WorldClocks.moveUp(listOf(1L, 2L, 3L), 2L))
.containsExactly(2L, 1L, 3L).inOrder()
}
/** §5.4 #3 */
@Test
fun `moving the first row up is a no-op`() {
assertThat(WorldClocks.moveUp(listOf(1L, 2L, 3L), 1L))
.containsExactly(1L, 2L, 3L).inOrder()
}
/** §5.4 #4 */
@Test
fun `moving an id the list does not hold is a no-op`() {
assertThat(WorldClocks.moveUp(listOf(1L, 2L, 3L), 9L))
.containsExactly(1L, 2L, 3L).inOrder()
}
/** §5.4 #5 */
@Test
fun `moving inside an empty list is an empty list`() {
assertThat(WorldClocks.moveUp(emptyList(), 1L)).isEmpty()
}
/** §5.4 #6 */
@Test
fun `a single row cannot move in either direction`() {
assertThat(WorldClocks.moveUp(listOf(1L), 1L)).containsExactly(1L)
assertThat(WorldClocks.moveDown(listOf(1L), 1L)).containsExactly(1L)
}
/** §5.4 #7 */
@Test
fun `moving down swaps a row with the one after it`() {
assertThat(WorldClocks.moveDown(listOf(1L, 2L, 3L), 2L))
.containsExactly(1L, 3L, 2L).inOrder()
}
/** §5.4 #8 */
@Test
fun `moving the last row down is a no-op`() {
assertThat(WorldClocks.moveDown(listOf(1L, 2L, 3L), 3L))
.containsExactly(1L, 2L, 3L).inOrder()
}
/** §5.4 #9 */
@Test
fun `a move is always a permutation, never a loss`() {
val ids = listOf(5L, 4L, 3L, 2L, 1L)
for (id in ids) {
assertThat(WorldClocks.moveUp(ids, id)).containsExactlyElementsIn(ids)
assertThat(WorldClocks.moveDown(ids, id)).containsExactlyElementsIn(ids)
}
}
/** §5.4 #10 */
@Test
fun `up and down are inverses in the middle of a list`() {
val ids = listOf(1L, 2L, 3L)
val there = WorldClocks.moveUp(ids, 2L)
val back = WorldClocks.moveDown(there, 2L)
assertThat(back).containsExactlyElementsIn(ids).inOrder()
}
/** §5.4 #11 */
@Test
fun `no stored home zone means the device's own`() {
assertThat(HomeZone.resolve(null, BERLIN)).isEqualTo(BERLIN)
}
/** §5.4 #12 */
@Test
fun `a stored home zone wins, trimmed`() {
assertThat(HomeZone.resolve("Asia/Tokyo", BERLIN)).isEqualTo(tokyo)
assertThat(HomeZone.resolve(" Asia/Tokyo ", BERLIN)).isEqualTo(tokyo)
}
/** §5.4 #13 */
@Test
fun `a blank, unknown or fixed-offset home zone degrades to the device's own`() {
assertThat(HomeZone.resolve("", BERLIN)).isEqualTo(BERLIN)
assertThat(HomeZone.resolve("Not/AZone", BERLIN)).isEqualTo(BERLIN)
assertThat(HomeZone.resolve("+02:00", BERLIN)).isEqualTo(BERLIN)
}
}
@@ -0,0 +1,227 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Zones
import org.junit.jupiter.api.Test
import java.time.ZoneId
/**
* §5.1 — 19 cases. The pickable-id filter is the whole of M8's "no bundled city
* database": the ids come from the device's own tzdata, the aliases are dropped
* by the tzdata's own canonicalisation, and `UTC` is the one id kept by name
* (D10).
*/
class ZoneCatalogTest {
private val mixed: Set<String> = setOf(
"Europe/Berlin",
"Asia/Calcutta",
"Asia/Kolkata",
"US/Pacific",
"Etc/GMT+5",
"EST5EDT",
"UTC",
)
private val canonicalOf: (String) -> String = { id ->
when (id) {
"Asia/Calcutta" -> "Asia/Kolkata"
"UTC" -> "Etc/UTC"
else -> id
}
}
private val identity: (String) -> String = { it }
private val deviceIds: Set<String> = ZoneId.getAvailableZoneIds()
private fun entry(zoneId: String, city: String) = ZoneEntry(zoneId = zoneId, city = city)
/** §5.1 #1 */
@Test
fun `the pickable ids are the canonical region ids and UTC, id-ascending`() {
val picked = ZoneCatalog.pickableIds(mixed, canonicalOf)
assertThat(picked).containsExactly("Asia/Kolkata", "Europe/Berlin", "UTC").inOrder()
}
/** §5.1 #2 */
@Test
fun `an alias is dropped in favour of its canonical id`() {
val picked = ZoneCatalog.pickableIds(mixed, canonicalOf)
assertThat(picked).doesNotContain("Asia/Calcutta")
}
/** §5.1 #3 */
@Test
fun `an id without an IANA region prefix is dropped`() {
val picked = ZoneCatalog.pickableIds(mixed, canonicalOf)
assertThat(picked).containsNoneOf("US/Pacific", "Etc/GMT+5", "EST5EDT")
}
/** §5.1 #4 */
@Test
fun `UTC survives its own canonicalisation to Etc-UTC`() {
val picked = ZoneCatalog.pickableIds(setOf("UTC", "Europe/Berlin"), canonicalOf)
assertThat(picked).contains("UTC")
}
/** §5.1 #5 */
@Test
fun `UTC is not invented when the device does not know it`() {
val picked = ZoneCatalog.pickableIds(setOf("Europe/Berlin"), identity)
assertThat(picked).doesNotContain("UTC")
}
/** §5.1 #6 */
@Test
fun `an empty tzdata yields an empty list rather than throwing`() {
val picked = ZoneCatalog.pickableIds(emptySet(), identity)
assertThat(picked).isEmpty()
}
/** §5.1 #7 */
@Test
fun `the thinly populated regions are regions too`() {
val ids = setOf("Antarctica/McMurdo", "Arctic/Longyearbyen", "Indian/Maldives")
val picked = ZoneCatalog.pickableIds(ids, identity)
assertThat(picked).containsExactlyElementsIn(ids.sorted()).inOrder()
}
/** §5.1 #8 */
@Test
fun `the real tzdata yields the cities a clock app must be able to show`() {
val picked = ZoneCatalog.pickableIds(deviceIds, identity)
assertThat(picked)
.containsAtLeast("Europe/Berlin", "America/New_York", "Asia/Tokyo", "UTC")
}
/** §5.1 #9 */
@Test
fun `the real tzdata yields no fixed-offset or legacy id`() {
val picked = ZoneCatalog.pickableIds(deviceIds, identity)
val offenders = picked.filter {
it.startsWith("Etc/") || it.startsWith("SystemV/") || it.startsWith("US/")
}
assertThat(offenders).isEmpty()
}
/** §5.1 #10 */
@Test
fun `every id from the real tzdata is UTC or carries an IANA region prefix`() {
val picked = ZoneCatalog.pickableIds(deviceIds, identity)
val offenders = picked.filterNot {
it == ZoneCatalog.UTC || it.substringBefore('/') in ZoneCatalog.REGIONS
}
assertThat(offenders).isEmpty()
}
/** §5.1 #11 */
@Test
fun `every id from the real tzdata is one the device can resolve`() {
val picked = ZoneCatalog.pickableIds(deviceIds, identity)
val offenders = picked.filterNot(Zones::isValid)
assertThat(offenders).isEmpty()
}
/** §5.1 #12 */
@Test
fun `the real tzdata yields a strictly ascending list with no duplicate`() {
val picked = ZoneCatalog.pickableIds(deviceIds, identity)
assertThat(picked).isInStrictOrder()
}
/** §5.1 #13 */
@Test
fun `a three-segment id falls back to its last segment`() {
val city = ZoneCatalog.cityFallbackFor("America/Argentina/Buenos_Aires")
assertThat(city).isEqualTo("Buenos Aires")
}
/** §5.1 #14 */
@Test
fun `the fallback city replaces underscores and keeps a bare id whole`() {
assertThat(ZoneCatalog.cityFallbackFor("Europe/Berlin")).isEqualTo("Berlin")
assertThat(ZoneCatalog.cityFallbackFor("Asia/Ho_Chi_Minh")).isEqualTo("Ho Chi Minh")
assertThat(ZoneCatalog.cityFallbackFor("UTC")).isEqualTo("UTC")
}
/** §5.1 #15 */
@Test
fun `a degenerate id comes back unchanged rather than throwing`() {
assertThat(ZoneCatalog.cityFallbackFor("")).isEqualTo("")
assertThat(ZoneCatalog.cityFallbackFor("/")).isEqualTo("/")
}
/** §5.1 #16 */
@Test
fun `entries sort by the folded city name, not the accented one`() {
val entries = listOf(
entry("Europe/Zurich", "Zürich"),
entry("America/Sao_Paulo", "São Paulo"),
entry("Europe/Berlin", "Berlin"),
)
val sorted = ZoneCatalog.sorted(entries)
assertThat(sorted.map { it.city }).containsExactly("Berlin", "São Paulo", "Zürich").inOrder()
}
/** §5.1 #17 */
@Test
fun `two cities with the same name are separated by their zone id`() {
val entries = listOf(
entry("B/Springfield", "Springfield"),
entry("A/Springfield", "Springfield"),
)
val sorted = ZoneCatalog.sorted(entries)
assertThat(sorted.map { it.zoneId })
.containsExactly("A/Springfield", "B/Springfield").inOrder()
}
/**
* §5.1 #18 — ICU (`com.android.i18n`) and tzdata (`com.android.tzdata`) are
* separately updatable APEX modules and can disagree. An alias whose
* canonical id the device does not have must keep its place, or the region
* loses its only pickable zone.
*/
@Test
fun `an alias whose canonical id the device does not have is kept`() {
val ids = setOf("Europe/Kiev", "Europe/Berlin")
val renaming: (String) -> String = { id ->
if (id == "Europe/Kiev") "Europe/Kyiv" else id
}
val picked = ZoneCatalog.pickableIds(ids, renaming)
assertThat(picked).containsExactly("Europe/Berlin", "Europe/Kiev").inOrder()
}
/** §5.1 #19 — and it is still dropped once the canonical id really is there. */
@Test
fun `an alias whose canonical id the device does have is still dropped`() {
val ids = setOf("Europe/Kiev", "Europe/Kyiv")
val renaming: (String) -> String = { id ->
if (id == "Europe/Kiev") "Europe/Kyiv" else id
}
val picked = ZoneCatalog.pickableIds(ids, renaming)
assertThat(picked).containsExactly("Europe/Kyiv")
}
}
@@ -0,0 +1,294 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.UTC_ZONE
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
/**
* §5.3 — 27 cases. Both offsets are read **at the instant**, which is the whole
* DST story: Berlin↔Sydney is ten hours apart in January and eight in July, and
* a subtraction of standard offsets would get both wrong (D7).
*/
class ZoneComparisonTest {
private val kolkata: ZoneId = ZoneId.of("Asia/Kolkata")
private val kathmandu: ZoneId = ZoneId.of("Asia/Kathmandu")
private val kiritimati: ZoneId = ZoneId.of("Pacific/Kiritimati")
private val honolulu: ZoneId = ZoneId.of("Pacific/Honolulu")
private val niue: ZoneId = ZoneId.of("Pacific/Niue")
private val sydney: ZoneId = ZoneId.of("Australia/Sydney")
/** 2024-01-15T12:00:00Z — southern summer, northern winter. */
private val january: Instant = Instant.fromEpochMilliseconds(1_705_320_000_000L)
/** 2024-07-15T12:00:00Z — the other way round. */
private val july: Instant = Instant.fromEpochMilliseconds(1_721_044_800_000L)
/** 2024-03-31T00:30:00Z — half an hour before Berlin springs forward. */
private val beforeSpringForward: Instant = Instant.fromEpochMilliseconds(1_711_845_000_000L)
/** 2024-03-31T01:30:00Z — half an hour after. */
private val afterSpringForward: Instant = Instant.fromEpochMilliseconds(1_711_848_600_000L)
// --- compare ---
/** §5.3 #1 */
@Test
fun `New York is six hours behind Berlin on the same calendar day`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = NEW_YORK, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(-360)
assertThat(comparison.dayDifference).isEqualTo(0)
assertThat(comparison.localDateTime).isEqualTo(LocalDateTime.parse("2023-11-14T17:13:20"))
}
/** §5.3 #2 */
@Test
fun `Kolkata is four and a half hours ahead of Berlin and already tomorrow`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = kolkata, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(270)
assertThat(comparison.dayDifference).isEqualTo(1)
assertThat(comparison.localDateTime).isEqualTo(LocalDateTime.parse("2023-11-15T03:43:20"))
}
/** §5.3 #3 */
@Test
fun `a forty-five-minute zone survives the arithmetic`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = kathmandu, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(285)
}
/** §5.3 #4 */
@Test
fun `UTC is one hour behind Berlin in November`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = UTC_ZONE, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(-60)
assertThat(comparison.dayDifference).isEqualTo(0)
}
/** §5.3 #5 */
@Test
fun `a zone compared with itself is the same time on the same day`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = BERLIN, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(0)
assertThat(comparison.dayDifference).isEqualTo(0)
}
/** §5.3 #6 */
@Test
fun `Kiritimati is thirteen hours ahead of Berlin and a day on`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = kiritimati, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(780)
assertThat(comparison.dayDifference).isEqualTo(1)
}
/** §5.3 #7 */
@Test
fun `the extreme pair is exactly one day apart`() {
val comparison = ZoneComparisons.compare(home = kiritimati, other = honolulu, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(-1440)
assertThat(comparison.dayDifference).isEqualTo(-1)
}
/** §5.3 #8 */
@Test
fun `twenty-five hours is still one calendar day`() {
val comparison = ZoneComparisons.compare(home = kiritimati, other = niue, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(-1500)
assertThat(comparison.dayDifference).isEqualTo(-1)
}
/** §5.3 #9 */
@Test
fun `Sydney is ten hours ahead of Berlin in January`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = sydney, at = january)
assertThat(comparison.offsetMinutes).isEqualTo(600)
}
/** §5.3 #10 */
@Test
fun `Sydney is eight hours ahead of Berlin in July`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = sydney, at = july)
assertThat(comparison.offsetMinutes).isEqualTo(480)
}
/** §5.3 #11 */
@Test
fun `the spring-forward transition moves the difference by an hour`() {
val before = ZoneComparisons.compare(home = UTC_ZONE, other = BERLIN, at = beforeSpringForward)
val after = ZoneComparisons.compare(home = UTC_ZONE, other = BERLIN, at = afterSpringForward)
assertThat(before.offsetMinutes).isEqualTo(60)
assertThat(after.offsetMinutes).isEqualTo(120)
}
/** §5.3 #12 */
@Test
fun `the local time across the gap is the platform's answer, not ours`() {
val comparison = ZoneComparisons.compare(home = UTC_ZONE, other = BERLIN, at = afterSpringForward)
assertThat(comparison.localDateTime).isEqualTo(LocalDateTime.parse("2024-03-31T03:30"))
}
/** §5.3 #13 */
@Test
fun `the comparison is antisymmetric`() {
val zones = listOf(BERLIN, NEW_YORK, kolkata, kiritimati)
for (a in zones) {
for (b in zones) {
assertThat(ZoneComparisons.compare(a, b, T0).offsetMinutes)
.isEqualTo(-ZoneComparisons.compare(b, a, T0).offsetMinutes)
}
}
}
/** §5.3 #14 */
@Test
fun `the day difference comes from the dates and not from the offset`() {
val comparison = ZoneComparisons.compare(home = BERLIN, other = honolulu, at = T0)
assertThat(comparison.offsetMinutes).isEqualTo(-660)
assertThat(comparison.dayDifference).isEqualTo(0)
}
// --- utcOffsetMinutes ---
/** §5.3 #15 */
@Test
fun `the UTC offset is read from the zone's own rules`() {
assertThat(ZoneComparisons.utcOffsetMinutes(BERLIN, T0)).isEqualTo(60)
assertThat(ZoneComparisons.utcOffsetMinutes(NEW_YORK, T0)).isEqualTo(-300)
assertThat(ZoneComparisons.utcOffsetMinutes(kolkata, T0)).isEqualTo(330)
assertThat(ZoneComparisons.utcOffsetMinutes(kathmandu, T0)).isEqualTo(345)
assertThat(ZoneComparisons.utcOffsetMinutes(kiritimati, T0)).isEqualTo(840)
assertThat(ZoneComparisons.utcOffsetMinutes(UTC_ZONE, T0)).isEqualTo(0)
}
/** §5.3 #16 */
@Test
fun `the UTC offset follows summer time`() {
assertThat(ZoneComparisons.utcOffsetMinutes(BERLIN, july)).isEqualTo(120)
}
// --- labelFor ---
/** §5.3 #17 */
@Test
fun `no difference is the Same label, not a zero-hour one`() {
assertThat(ZoneOffsetFormat.labelFor(0)).isEqualTo(OffsetLabel.Same)
}
/** §5.3 #18 */
@Test
fun `a positive difference is Ahead, with the magnitude split`() {
assertThat(ZoneOffsetFormat.labelFor(270)).isEqualTo(OffsetLabel.Ahead(4, 30))
assertThat(ZoneOffsetFormat.labelFor(285)).isEqualTo(OffsetLabel.Ahead(4, 45))
assertThat(ZoneOffsetFormat.labelFor(780)).isEqualTo(OffsetLabel.Ahead(13, 0))
}
/** §5.3 #19 */
@Test
fun `a negative difference is Behind, with a non-negative magnitude`() {
assertThat(ZoneOffsetFormat.labelFor(-360)).isEqualTo(OffsetLabel.Behind(6, 0))
assertThat(ZoneOffsetFormat.labelFor(-60)).isEqualTo(OffsetLabel.Behind(1, 0))
assertThat(ZoneOffsetFormat.labelFor(-1440)).isEqualTo(OffsetLabel.Behind(24, 0))
}
/** §5.3 #20 */
@Test
fun `a sub-hour difference carries zero hours rather than a missing variant`() {
assertThat(ZoneOffsetFormat.labelFor(45)).isEqualTo(OffsetLabel.Ahead(0, 45))
assertThat(ZoneOffsetFormat.labelFor(-30)).isEqualTo(OffsetLabel.Behind(0, 30))
}
/** §5.3 #21 */
@Test
fun `every reachable difference splits into a non-negative hours and minutes`() {
for (minutes in -1500..1500) {
when (val label = ZoneOffsetFormat.labelFor(minutes)) {
is OffsetLabel.Same -> assertThat(minutes).isEqualTo(0)
is OffsetLabel.Ahead -> {
assertThat(label.hours).isAtLeast(0)
assertThat(label.minutes).isIn(0..59)
assertThat(label.hours * 60 + label.minutes).isEqualTo(minutes)
}
is OffsetLabel.Behind -> {
assertThat(label.hours).isAtLeast(0)
assertThat(label.minutes).isIn(0..59)
assertThat(label.hours * 60 + label.minutes).isEqualTo(-minutes)
}
}
}
}
// --- dayLabelFor ---
/** §5.3 #22 */
@Test
fun `the three reachable day differences read as yesterday, today and tomorrow`() {
assertThat(ZoneOffsetFormat.dayLabelFor(-1)).isEqualTo(DayLabel.YESTERDAY)
assertThat(ZoneOffsetFormat.dayLabelFor(0)).isEqualTo(DayLabel.TODAY)
assertThat(ZoneOffsetFormat.dayLabelFor(1)).isEqualTo(DayLabel.TOMORROW)
}
/** §5.3 #23 */
@Test
fun `an unreachable day difference is clamped rather than unhandled`() {
assertThat(ZoneOffsetFormat.dayLabelFor(-7)).isEqualTo(DayLabel.YESTERDAY)
assertThat(ZoneOffsetFormat.dayLabelFor(7)).isEqualTo(DayLabel.TOMORROW)
}
// --- gmtLabel ---
/** §5.3 #24 */
@Test
fun `a whole-hour offset reads as GMT and its hours`() {
assertThat(ZoneOffsetFormat.gmtLabel(0)).isEqualTo("GMT")
assertThat(ZoneOffsetFormat.gmtLabel(60)).isEqualTo("GMT+1")
assertThat(ZoneOffsetFormat.gmtLabel(-300)).isEqualTo("GMT-5")
assertThat(ZoneOffsetFormat.gmtLabel(840)).isEqualTo("GMT+14")
}
/** §5.3 #25 */
@Test
fun `a part-hour offset carries its minutes`() {
assertThat(ZoneOffsetFormat.gmtLabel(330)).isEqualTo("GMT+5:30")
assertThat(ZoneOffsetFormat.gmtLabel(345)).isEqualTo("GMT+5:45")
assertThat(ZoneOffsetFormat.gmtLabel(-210)).isEqualTo("GMT-3:30")
}
/** §5.3 #26 */
@Test
fun `an hourless negative offset still carries its sign`() {
assertThat(ZoneOffsetFormat.gmtLabel(-30)).isEqualTo("GMT-0:30")
}
/** §5.3 #27 */
@Test
fun `every quarter-hour offset formats as ASCII in the GMT shape`() {
val shape = Regex("""^GMT([+-]\d{1,2}(:\d{2})?)?$""")
for (minutes in -900..900 step 15) {
val label = ZoneOffsetFormat.gmtLabel(minutes)
assertThat(label).matches(shape.pattern)
assertThat(label.all { it.code in 32..126 }).isTrue()
}
}
}
@@ -0,0 +1,162 @@
package de.jeanlucmakiola.clockula.domain.worldclock
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* §5.2 — 19 cases. Search is a **word prefix over folded text** (D9): a user
* typing ASCII finds a city spelled with diacritics, word order does not
* matter, and "erl" does not find Berlin.
*/
class ZoneSearchTest {
private val newYork = ZoneEntry(
zoneId = "America/New_York",
city = "New York",
country = "United States",
)
private val berlin = ZoneEntry(
zoneId = "Europe/Berlin",
city = "Berlin",
country = "Germany",
)
private val saoPaulo = ZoneEntry(
zoneId = "America/Sao_Paulo",
city = "São Paulo",
country = "Brazil",
)
private val all = listOf(berlin, newYork, saoPaulo)
/** §5.2 #1 */
@Test
fun `folding strips the diacritics a user will not type`() {
assertThat(ZoneSearch.fold("São Paulo")).isEqualTo("sao paulo")
}
/** §5.2 #2 */
@Test
fun `folding lowercases and trims`() {
assertThat(ZoneSearch.fold("Zürich")).isEqualTo("zurich")
assertThat(ZoneSearch.fold(" BERLIN ")).isEqualTo("berlin")
}
/** §5.2 #3 */
@Test
fun `an apostrophe is a word break like any other non-letter`() {
assertThat(ZoneSearch.words("Côte d'Ivoire")).containsExactly("cote", "d", "ivoire").inOrder()
}
/** §5.2 #4 */
@Test
fun `a zone id splits into its own searchable words`() {
assertThat(ZoneSearch.words("America/New_York"))
.containsExactly("america", "new", "york").inOrder()
}
/** §5.2 #5 */
@Test
fun `blank text has no words`() {
assertThat(ZoneSearch.words("")).isEmpty()
assertThat(ZoneSearch.words(" ")).isEmpty()
}
/** §5.2 #6 */
@Test
fun `a whole word of the city matches`() {
assertThat(ZoneSearch.matches(newYork, "york")).isTrue()
}
/** §5.2 #7 */
@Test
fun `a partial last word still matches as a prefix`() {
assertThat(ZoneSearch.matches(newYork, "new yor")).isTrue()
}
/** §5.2 #8 */
@Test
fun `the order of the query's words does not matter`() {
assertThat(ZoneSearch.matches(newYork, "yor new")).isTrue()
}
/** §5.2 #9 */
@Test
fun `a match is a prefix of a word, never a substring of one`() {
assertThat(ZoneSearch.matches(berlin, "erl")).isFalse()
}
/** §5.2 #10 */
@Test
fun `the country is searched`() {
assertThat(ZoneSearch.matches(berlin, "germany")).isTrue()
}
/** §5.2 #11 */
@Test
fun `a query matching nothing about the entry does not match`() {
assertThat(ZoneSearch.matches(berlin, "america")).isFalse()
}
/** §5.2 #12 */
@Test
fun `the zone id's own words are searched`() {
assertThat(ZoneSearch.matches(berlin, "europe")).isTrue()
}
/** §5.2 #13 */
@Test
fun `the query is folded as well as the entry`() {
assertThat(ZoneSearch.matches(saoPaulo, "sao")).isTrue()
assertThat(ZoneSearch.matches(saoPaulo, "SÃO")).isTrue()
}
/** §5.2 #14 */
@Test
fun `a blank query matches everything`() {
assertThat(ZoneSearch.matches(berlin, "")).isTrue()
assertThat(ZoneSearch.matches(berlin, " ")).isTrue()
}
/** §5.2 #15 */
@Test
fun `a null country matches nothing rather than throwing`() {
val countryless = berlin.copy(country = null)
assertThat(ZoneSearch.matches(countryless, "germany")).isFalse()
}
/** §5.2 #16 */
@Test
fun `a blank query filters nothing out`() {
val filtered = ZoneSearch.filter(all, " ")
assertThat(filtered).containsExactlyElementsIn(all).inOrder()
}
/** §5.2 #17 */
@Test
fun `a query keeps only the entries it matches`() {
val filtered = ZoneSearch.filter(all, "york")
assertThat(filtered).containsExactly(newYork)
}
/** §5.2 #18 */
@Test
fun `a query matching nothing yields an empty list`() {
val filtered = ZoneSearch.filter(all, "zzz")
assertThat(filtered).isEmpty()
}
/** §5.2 #19 */
@Test
fun `filtering preserves the catalog's order rather than ranking`() {
val filtered = ZoneSearch.filter(all, "a")
assertThat(filtered.map { it.zoneId })
.containsExactly("America/New_York", "America/Sao_Paulo").inOrder()
}
}