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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user