Merge remote-tracking branch 'origin/release/v2.16.0' into fix/agenda-multiday
This commit is contained in:
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
||||
isAllDay: Boolean = false,
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||
timezone: String? = null,
|
||||
): EventForm = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event resolves wall clock in the given zone`() {
|
||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned zone wins over the device zone`() {
|
||||
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
|
||||
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
|
||||
assertThat(times.timezone).isEqualTo("America/New_York")
|
||||
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable pinned zone falls back to the device zone`() {
|
||||
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day ignores a pinned zone and stays UTC`() {
|
||||
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
|
||||
assertThat(update(original, original.copy())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the time of a pinned event keeps its zone`() {
|
||||
// The regression this guards: the update used to stamp the device zone
|
||||
// over the event's own, silently un-anchoring a foreign-zone event so it
|
||||
// stopped tracking that zone across DST.
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(title = "Standup", start = original.start))
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
)
|
||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||
.isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing only the zone still moves the event`() {
|
||||
// Same wall-clock, different zone: the instant moves, so DTSTART must be
|
||||
// rewritten even though start/end compare equal.
|
||||
val original = form()
|
||||
val values = update(original, original.copy(timezone = "America/New_York"))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpinning back to the device zone rewrites the times`() {
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(timezone = null))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text-only edit writes just the changed columns`() {
|
||||
val original = form()
|
||||
|
||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
||||
attendees: List<Attendee> = emptyList(),
|
||||
eventColor: Int? = null,
|
||||
eventColorKey: String? = null,
|
||||
eventTimezone: String? = null,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 1L,
|
||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
||||
accessLevel = accessLevel,
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
eventTimezone = eventTimezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
||||
assertThat(prefilled.description).isEqualTo("Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm leaves an event in the device zone unpinned`() {
|
||||
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// Same zone as the device: pinning it would only make the picker appear
|
||||
// on every ordinary event.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm pins a foreign zone and shows the times in it`() {
|
||||
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L, // 08:00Z
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||
// event's own wall-clock, which is what a later save re-anchors to.
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm never pins a zone on an all-day event`() {
|
||||
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
|
||||
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
|
||||
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm ignores an unparseable stored zone`() {
|
||||
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// A malformed sync row must not be honoured, nor fail the open.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn converts a pinned event into the target zone`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, end) = form.timesIn(berlin)!!
|
||||
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
||||
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn crosses the date line when the offset pushes past midnight`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, _) = form.timesIn(berlin)!!
|
||||
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
|
||||
// with it, not just the clock.
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
|
||||
// In January both are on standard time: 08:00 EST is 14:00 CET — the
|
||||
// same six hours as July, but only because both shifted. Late March,
|
||||
// when the US has sprung forward and Europe hasn't, the gap is five.
|
||||
val march = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
assertThat(march.timesIn(berlin)!!.first)
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn returns null when there is nothing to disambiguate`() {
|
||||
val base = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
)
|
||||
// Unpinned: the form's times already are the local times.
|
||||
assertThat(base.timesIn(berlin)).isNull()
|
||||
// Pinned to the target itself: same thing.
|
||||
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
||||
// All-day: date-anchored, so there's no zone conversion to show.
|
||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
||||
.isNull()
|
||||
// Unparseable: can't convert, mustn't throw.
|
||||
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
class TimeZoneCatalogTest {
|
||||
|
||||
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
|
||||
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
|
||||
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
|
||||
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
|
||||
|
||||
private fun filter(query: String) = filterTimeZones(zones, query)
|
||||
|
||||
@Test
|
||||
fun `catalogue holds the real zones and drops the legacy aliases`() {
|
||||
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
|
||||
// Bare aliases the tz database keeps for compatibility would double up
|
||||
// the real zones in the picker.
|
||||
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
|
||||
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option exposes city and region split from the id`() {
|
||||
val ny = zones.first { it.id == "America/New_York" }
|
||||
assertThat(ny.city).isEqualTo("New York")
|
||||
assertThat(ny.region).isEqualTo("America")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label is the id with underscores undone`() {
|
||||
assertThat(zones.first { it.id == "America/New_York" }.label)
|
||||
.isEqualTo("America/New York")
|
||||
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolved abbreviation is compact, not the long name`() {
|
||||
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
|
||||
// short and space-free — that's the whole point of showing it instead of
|
||||
// "Central European Time".
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(berlin.shortName).doesNotContain(" ")
|
||||
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor pairs a named abbreviation with the offset`() {
|
||||
val option = TimeZoneOption(
|
||||
id = "Europe/Berlin",
|
||||
displayName = "Central European Time",
|
||||
shortName = "CET",
|
||||
offsetMinutes = 60,
|
||||
)
|
||||
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor drops the offset when the abbreviation already is one`() {
|
||||
// Repeating the offset after an offset-shaped abbreviation would just say
|
||||
// the same thing twice.
|
||||
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
|
||||
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
|
||||
)
|
||||
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
|
||||
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset is resolved at the given instant, not the current one`() {
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
|
||||
assertThat(berlin.offsetMinutes).isEqualTo(120)
|
||||
|
||||
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
|
||||
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query returns everything unchanged`() {
|
||||
assertThat(filter("")).isEqualTo(zones)
|
||||
assertThat(filter(" ")).isEqualTo(zones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the city, ignoring case and underscores`() {
|
||||
assertThat(filter("new york").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches accented cities typed plainly`() {
|
||||
// Sao_Paulo has no accent in the id, but its localized name does — the
|
||||
// point is that a user typing plain ASCII still finds it.
|
||||
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
|
||||
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the full IANA id`() {
|
||||
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the abbreviation, case-insensitively`() {
|
||||
// "cest" isn't a city, id, or substring of "Central European Summer
|
||||
// Time", so the only way these match is via the abbreviation.
|
||||
val hits = filter("cest")
|
||||
assertThat(hits).isNotEmpty()
|
||||
assertThat(hits.map { it.id }).contains("Europe/Berlin")
|
||||
// Every hit genuinely carries that abbreviation — nothing bled in.
|
||||
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
|
||||
// Case doesn't matter.
|
||||
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a city starting with the query outranks one merely containing it`() {
|
||||
val ids = filter("york").map { it.id }
|
||||
// "New York" contains "york"; nothing starts with it, so it should still
|
||||
// surface rather than being buried.
|
||||
assertThat(ids).contains("America/New_York")
|
||||
|
||||
// "col" starts Colombo but only appears mid-string elsewhere.
|
||||
val col = filter("col").map { it.id }
|
||||
assertThat(col.first()).isEqualTo("Asia/Colombo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match yields an empty list rather than everything`() {
|
||||
assertThat(filter("zzzznotazone")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single zone resolves the same way the catalogue does`() {
|
||||
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
|
||||
.isEqualTo(fromCatalogue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone id resolves to null`() {
|
||||
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gmt offsets format with a sign and padding`() {
|
||||
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
|
||||
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
|
||||
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
|
||||
// India is +05:30 — a whole-hour assumption would render this wrong.
|
||||
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
|
||||
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user