From e0d5f1f254b47d86eb27df5372e46855a5bea3f9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 13:04:54 +0200 Subject: [PATCH] feat(worldclock): the catalog, the search, the comparison and the face's geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../clockula/domain/WorldClock.kt | 14 +- .../clockula/domain/time/Clocks.kt | 3 + .../clockula/domain/worldclock/AnalogFace.kt | 62 ++++ .../clockula/domain/worldclock/WorldClocks.kt | 41 +++ .../clockula/domain/worldclock/ZoneCatalog.kt | 73 +++++ .../domain/worldclock/ZoneComparison.kt | 111 +++++++ .../clockula/domain/worldclock/ZoneSearch.kt | 43 +++ .../clockula/domain/ZonesTest.kt | 15 + .../domain/worldclock/AnalogFaceTest.kt | 189 +++++++++++ .../domain/worldclock/WorldClocksTest.kt | 113 +++++++ .../domain/worldclock/ZoneCatalogTest.kt | 227 ++++++++++++++ .../domain/worldclock/ZoneComparisonTest.kt | 294 ++++++++++++++++++ .../domain/worldclock/ZoneSearchTest.kt | 162 ++++++++++ 13 files changed, 1346 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFace.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparison.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFaceTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocksTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparisonTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearchTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt index de6f7f4..557791c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt @@ -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 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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index 1ac664c..e943df4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFace.kt new file mode 100644 index 0000000..f2a2082 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFace.kt @@ -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 = (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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt new file mode 100644 index 0000000..fc38f28 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt @@ -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, id: Long): List = + 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, id: Long): List = + 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, from: Int, by: Int): List { + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt new file mode 100644 index 0000000..5b23db1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -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 = 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, canonicalOf: (String) -> String): List = + 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): List = + entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId })) + + private fun isRegionId(zoneId: String): Boolean = + zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparison.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparison.kt new file mode 100644 index 0000000..464d35e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparison.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt new file mode 100644 index 0000000..2137e70 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearch.kt @@ -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 = + 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, query: String): List = + entries.filter { matches(it, query) } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt index 2872253..2706e26 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFaceTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFaceTest.kt new file mode 100644 index 0000000..1ad6af0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/AnalogFaceTest.kt @@ -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 = 0f..360f + + private infix fun Float.isOnThe(dial: ClosedFloatingPointRange): 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 = + (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 { 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocksTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocksTest.kt new file mode 100644 index 0000000..bbe1303 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocksTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt new file mode 100644 index 0000000..4529be9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt @@ -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 = 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 = 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") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparisonTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparisonTest.kt new file mode 100644 index 0000000..0cc0c87 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneComparisonTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearchTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearchTest.kt new file mode 100644 index 0000000..9fbdea3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneSearchTest.kt @@ -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() + } +}