Merge remote-tracking branch 'origin/release/v2.16.0' into fix/agenda-multiday

This commit is contained in:
2026-07-19 11:50:29 +02:00
20 changed files with 1151 additions and 34 deletions

View File

@@ -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()

View File

@@ -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.

View File

@@ -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")
}
}