diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderBehaviorTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderBehaviorTest.kt index b060fea9..abf7240f 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderBehaviorTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderBehaviorTest.kt @@ -57,6 +57,43 @@ class AndroidCalendarProviderBehaviorTest { } + /** + * To make sure that's not a problem to insert an event with DTEND = DTSTART. + */ + @Test + fun testInsertEventWithDtEndEqualsDtStart() { + val values = contentValuesOf( + Events.CALENDAR_ID to calendar.id, + Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000 + Events.DTEND to 1759403653000, + Events.TITLE to "Event with DTSTART = DTEND" + ) + val id = calendar.addEvent(Entity(values)) + + // Google Calendar 2025.44.1-827414499-release correctly shows this event [2025/11/29] + + val event2 = calendar.getEventRow(id) + assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true) + } + + /** + * To make sure that's not a problem to insert an (invalid/useless) RRULE with UNTIL before the event's DTSTART. + */ + @Test + fun testInsertEventWithRRuleUntilBeforeDtStart() { + val values = contentValuesOf( + Events.CALENDAR_ID to calendar.id, + Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000 + Events.DURATION to "PT1H", + Events.TITLE to "Event with useless RRULE", + Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z" + ) + val id = calendar.addEvent(Entity(values)) + + val event2 = calendar.getEventRow(id) + assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true) + } + /** * To verify that it's a problem to insert a recurring all-day event with a duration of zero seconds. * See: @@ -114,24 +151,6 @@ class AndroidCalendarProviderBehaviorTest { assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true) } - /** - * To make sure that's not a problem to insert an (invalid/useless) RRULE with UNTIL before the event's DTSTART. - */ - @Test - fun testInsertEventWithRRuleUntilBeforeDtStart() { - val values = contentValuesOf( - Events.CALENDAR_ID to calendar.id, - Events.DTSTART to 1759403653000, // Thu Oct 02 2025 11:14:13 GMT+0000 - Events.DURATION to "PT1H", - Events.TITLE to "Event with useless RRULE", - Events.RRULE to "FREQ=DAILY;UNTIL=20251002T000000Z" - ) - val id = calendar.addEvent(Entity(values)) - - val event2 = calendar.getEventRow(id) - assertContentValuesEqual(values, event2!!, onlyFieldsInExpected = true) - } - /** * Reported as https://issuetracker.google.com/issues/446730408. diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt index 71dc8da2..a6f0dc9f 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt @@ -112,6 +112,21 @@ object TimeApiExtensions { /***** Durations *****/ + /** + * Returns the absolute (positive) temporal amount. + */ + fun TemporalAmount.abs(): TemporalAmount = + when (this) { + is Duration -> + this.abs() + is Period -> + if (this.isNegative) + this.negated() + else + this + else -> throw IllegalArgumentException("TemporalAmount must be Period or Duration") + } + fun TemporalAmount.toDuration(position: Instant): Duration = when (this) { is Duration -> this diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt index c160bcff..ff48cea4 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilder.kt @@ -10,6 +10,7 @@ import android.content.Entity import android.provider.CalendarContract.Events import androidx.annotation.VisibleForTesting import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.TimeApiExtensions.abs import at.bitfire.ical4android.util.TimeApiExtensions.toDuration import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration @@ -18,9 +19,9 @@ import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Duration import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule +import java.time.Duration import java.time.Period import java.time.temporal.TemporalAmount @@ -42,8 +43,13 @@ class DurationBuilder: AndroidEntityBuilder { } val dtStart = from.requireDtStart() - val duration = from.duration - ?: calculateFromDtEnd(dtStart, from.endDate) + + // calculate DURATION from DTEND - DTSTART, if necessary + val calculatedDuration = from.duration?.duration + ?: calculateFromDtEnd(dtStart, from.endDate) // ignores DTEND < DTSTART + + // use default duration, if necessary + val duration = calculatedDuration?.abs() // always use positive duration ?: defaultDuration(DateUtils.isDate(dtStart)) /* [RFC 5545 3.8.2.5] @@ -54,7 +60,7 @@ class DurationBuilder: AndroidEntityBuilder { so we wouldn't have to take care of that. However it expects seconds to be in "PS" format, whereas we provide an RFC 5545-compliant "PTS", which causes the provider to crash: https://github.com/bitfireAT/synctools/issues/144. So we must convert it ourselves to be on the safe side. */ - val alignedDuration = alignWithDtStart(duration.duration, dtStart) + val alignedDuration = alignWithDtStart(duration, dtStart) /* TemporalAmount can have months and years, but the RFC 5545 value must only contain weeks, days and time. So we have to recalculate the months/years to days according to their position in the calendar. @@ -74,13 +80,13 @@ class DurationBuilder: AndroidEntityBuilder { * @return Temporal amount that is * * - a [Period] (days/months/years that can't be represented by an exact number of seconds) when [dtStart] is a DATE, and - * - a [java.time.Duration] (exact time that can be represented by an exact number of seconds) when [dtStart] is a DATE-TIME. + * - a [Duration] (exact time that can be represented by an exact number of seconds) when [dtStart] is a DATE-TIME. */ @VisibleForTesting internal fun alignWithDtStart(amount: TemporalAmount, dtStart: DtStart): TemporalAmount { if (DateUtils.isDate(dtStart)) { // DTSTART is DATE - return if (amount is java.time.Duration) { + return if (amount is Duration) { // amount is Duration, change to Period of days instead Period.ofDays(amount.toDays().toInt()) } else { @@ -90,7 +96,7 @@ class DurationBuilder: AndroidEntityBuilder { } else { // DTSTART is DATE-TIME - return if (amount is java.time.Period) { + return if (amount is Period) { // amount is Period, change to Duration instead amount.toDuration(dtStart.date.toInstant()) } else { @@ -100,15 +106,23 @@ class DurationBuilder: AndroidEntityBuilder { } } + /** + * Calculates the DURATION from DTEND - DTSTART, if possible. + * + * @param dtStart start date/date-time + * @param dtEnd (optional) end date/date-time (ignored if not after [dtStart]) + * + * @return temporal amount ([Period] or [Duration]) or `null` if no valid end time was available + */ @VisibleForTesting - internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): Duration? { - if (dtEnd == null) + internal fun calculateFromDtEnd(dtStart: DtStart, dtEnd: DtEnd?): TemporalAmount? { + if (dtEnd == null || dtEnd.date.toInstant() <= dtStart.date.toInstant()) return null return if (DateUtils.isDateTime(dtStart) && DateUtils.isDateTime(dtEnd)) { // DTSTART and DTEND are DATE-TIME → calculate difference between timestamps val seconds = (dtEnd.date.time - dtStart.date.time) / 1000 - Duration(java.time.Duration.ofSeconds(seconds)) + Duration.ofSeconds(seconds) } else { // Either DTSTART or DTEND or both are DATE: // - DTSTART and DTEND are DATE → DURATION is exact number of days (no time part) @@ -116,16 +130,14 @@ class DurationBuilder: AndroidEntityBuilder { // - DTSTART is DATE-TIME, DTEND is DATE → amend DTEND with time of DTSTART → DURATION is exact number of days (no time part) val startDate = dtStart.date.toLocalDate() val endDate = dtEnd.date.toLocalDate() - Duration(Period.between(startDate, endDate)) + Period.between(startDate, endDate) } } - private fun defaultDuration(allDay: Boolean): Duration = - Duration( - if (allDay) - Period.ofDays(1) - else - java.time.Duration.ZERO - ) + private fun defaultDuration(allDay: Boolean): TemporalAmount = + if (allDay) + Period.ofDays(1) + else + Duration.ZERO } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt index 8ef1fe8a..8e071a87 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilder.kt @@ -10,6 +10,7 @@ import android.content.Entity import android.provider.CalendarContract.Events import androidx.annotation.VisibleForTesting import at.bitfire.ical4android.util.DateUtils +import at.bitfire.ical4android.util.TimeApiExtensions.abs import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate @@ -28,6 +29,7 @@ import java.time.LocalDate import java.time.Period import java.time.ZoneId import java.time.ZonedDateTime +import java.time.temporal.TemporalAmount class EndTimeBuilder: AndroidEntityBuilder { @@ -47,10 +49,22 @@ class EndTimeBuilder: AndroidEntityBuilder { } val dtStart = from.requireDtStart() - val dtEnd = from.endDate?.let { alignWithDtStart(it, dtStart = dtStart) } - ?: calculateFromDuration(dtStart, from.duration) + + // potentially calculate DTEND from DTSTART + DURATION, and always align with DTSTART value type + val calculatedDtEnd = from.getEndDate(/* don't let ical4j calculate DTEND from DURATION */ false) + ?.let { alignWithDtStart(it, dtStart = dtStart) } + ?: calculateFromDuration(dtStart, from.duration?.duration) + + // ignore DTEND when not after DTSTART and use default duration, if necessary + val dtEnd = calculatedDtEnd + ?.takeIf { it.date.toInstant() > dtStart.date.toInstant() } // only use DTEND if it's after DTSTART [1] ?: calculateFromDefault(dtStart) + /** + * [1] RFC 5545 3.8.2.2 Date-Time End: + * […] its value MUST be later in time than the value of the "DTSTART" property. + */ + // end time: UNIX timestamp values.put(Events.DTEND, dtEnd.date.time) @@ -122,12 +136,21 @@ class EndTimeBuilder: AndroidEntityBuilder { } } + /** + * Calculates the DTEND from DTSTART + DURATION, if possible. + * + * @param dtStart start date/date-time + * @param duration (optional) duration + * + * @return end date/date-time (same value type as [dtStart]) or `null` if [duration] was not given + */ @VisibleForTesting - internal fun calculateFromDuration(dtStart: DtStart, duration: net.fortuna.ical4j.model.property.Duration?): DtEnd? { + internal fun calculateFromDuration(dtStart: DtStart, duration: TemporalAmount?): DtEnd? { if (duration == null) return null - val dur = duration.duration + val dur = duration.abs() // always take positive temporal amount + return if (DateUtils.isDate(dtStart)) { // DTSTART is DATE if (dur is Period) { diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt index 4dc4e3a3..5574d9d2 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/util/TimeApiExtensionsTest.kt @@ -6,6 +6,7 @@ package at.bitfire.ical4android.util +import at.bitfire.ical4android.util.TimeApiExtensions.abs import at.bitfire.ical4android.util.TimeApiExtensions.requireTimeZone import at.bitfire.ical4android.util.TimeApiExtensions.toDuration import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate @@ -172,6 +173,39 @@ class TimeApiExtensionsTest { } + @Test + fun testTemporalAmount_abs_Duration_negative() { + assertEquals( + Duration.ofMinutes(1), + Duration.ofMinutes(-1).abs() + ) + } + + @Test + fun testTemporalAmount_abs_Duration_positive() { + assertEquals( + Duration.ofDays(1), + Duration.ofDays(1).abs() + ) + } + + @Test + fun testTemporalAmount_abs_Period_negative() { + assertEquals( + Period.ofWeeks(1), + Period.ofWeeks(-1).abs() + ) + } + + @Test + fun testTemporalAmount_abs_Period_positive() { + assertEquals( + Period.ofDays(1), + Period.ofDays(1).abs() + ) + } + + @Test fun testTemporalAmount_toDuration() { assertEquals(Duration.ofHours(1), Duration.ofHours(1).toDuration(Instant.EPOCH)) diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt index 301646bf..39005f7b 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/DurationBuilderTest.kt @@ -71,6 +71,18 @@ class DurationBuilderTest { assertEquals("P3D", result.entityValues.get(Events.DURATION)) } + @Test + fun `Recurring all-day event (with negative DURATION)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251010")), + Duration(Period.ofDays(-3)), // invalid negative DURATION will be treated as positive + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + assertEquals("P3D", result.entityValues.get(Events.DURATION)) + } + @Test fun `Recurring all-day event (with zero seconds DURATION)`() { val result = Entity(ContentValues()) @@ -95,6 +107,18 @@ class DurationBuilderTest { assertEquals("PT1H30M", result.entityValues.get(Events.DURATION)) } + @Test + fun `Recurring non-all-day event (with negative DURATION)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251010T010203", tzVienna)), + Duration(java.time.Duration.ofMinutes(-90)), // invalid negative DURATION will be treated as positive + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + assertEquals("PT1H30M", result.entityValues.get(Events.DURATION)) + } + @Test fun `Recurring all-day event (with DTEND)`() { val result = Entity(ContentValues()) @@ -107,6 +131,32 @@ class DurationBuilderTest { assertEquals("P1W", result.entityValues.get(Events.DURATION)) } + @Test + fun `Recurring all-day event (with DTEND before START)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251017")), + DtEnd(Date("20251010")), // DTEND before DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + // default duration for all-day events: one day + assertEquals("P1D", result.entityValues.get(Events.DURATION)) + } + + @Test + fun `Recurring all-day event (with DTEND equals START)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251017")), + DtEnd(Date("20251017")), // DTEND equals DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + // default duration for all-day events: one day + assertEquals("P1D", result.entityValues.get(Events.DURATION)) + } + @Test fun `Recurring non-all-day event (with DTEND)`() { val result = Entity(ContentValues()) @@ -119,6 +169,32 @@ class DurationBuilderTest { assertEquals("P1DT1H1M1S", result.entityValues.get(Events.DURATION)) } + @Test + fun `Recurring non-all-day event (with DTEND before DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251010T010203", tzVienna)), + DtEnd(DateTime("20251010T000203", tzVienna)), // DTEND before DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + // default duration for non-all-day events: zero seconds + assertEquals("PT0S", result.entityValues.get(Events.DURATION)) + } + + @Test + fun `Recurring non-all-day event (with DTEND equals DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251010T010203", tzVienna)), + DtEnd(DateTime("20251010T010203", tzVienna)), // DTEND equals DTSTART should be ignored + RRule("FREQ=DAILY;COUNT=5") + )) + builder.build(event, event, result) + // default duration for non-all-day events: zero seconds + assertEquals("PT0S", result.entityValues.get(Events.DURATION)) + } + @Test fun `Recurring all-day event (neither DURATION nor DTEND)`() { val result = Entity(ContentValues()) @@ -186,11 +262,20 @@ class DurationBuilderTest { DtEnd(Date("20240330")) ) assertEquals( - Duration(Period.ofDays(2)), + Period.ofDays(2), result ) } + @Test + fun `calculateFromDtEnd (dtStart=DATE, DtEnd before dtStart)`() { + val result = builder.calculateFromDtEnd( + DtStart(Date("20240328")), + DtEnd(Date("20240327")) + ) + assertNull(result) + } + @Test fun `calculateFromDtEnd (dtStart=DATE, DtEnd=DATE-TIME)`() { val result = builder.calculateFromDtEnd( @@ -198,11 +283,20 @@ class DurationBuilderTest { DtEnd(DateTime("20240330T123412", tzVienna)) ) assertEquals( - Duration(Period.ofDays(2)), + Period.ofDays(2), result ) } + @Test + fun `calculateFromDtEnd (dtStart=DATE-TIME, DtEnd before dtStart)`() { + val result = builder.calculateFromDtEnd( + DtStart(DateTime("20240328T010203", tzVienna)), + DtEnd(DateTime("20240328T000000", tzVienna)) + ) + assertNull(result) + } + @Test fun `calculateFromDtEnd (dtStart=DATE-TIME, DtEnd=DATE)`() { val result = builder.calculateFromDtEnd( @@ -210,7 +304,7 @@ class DurationBuilderTest { DtEnd(Date("20240330")) ) assertEquals( - Duration(Period.ofDays(2)), + Period.ofDays(2), result ) } @@ -222,7 +316,7 @@ class DurationBuilderTest { DtEnd(DateTime("20240728T010203Z")) // GMT+1 with DST → 2 hours difference ) assertEquals( - Duration(java.time.Duration.ofHours(2)), + java.time.Duration.ofHours(2), result ) } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt index 52239bee..2afb2310 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/calendar/builder/EndTimeBuilderTest.kt @@ -61,6 +61,30 @@ class EndTimeBuilderTest { assertEquals(1760140800000, result.entityValues.get(Events.DTEND)) } + @Test + fun `Non-recurring all-day event (with DTEND before DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251010")), + DtEnd(Date("20251001")) // before DTSTART, should be ignored + )) + builder.build(event, event, result) + // default duration: one day → 20251011 + assertEquals(1760140800000, result.entityValues.get(Events.DTEND)) + } + + @Test + fun `Non-recurring all-day event (with DTEND equals DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251010")), + DtEnd(Date("20251010")) // equals DTSTART, should be ignored + )) + builder.build(event, event, result) + // default duration: one day → 20251011 + assertEquals(1760140800000, result.entityValues.get(Events.DTEND)) + } + @Test fun `Non-recurring non-all-day event (with floating DTEND)`() { val result = Entity(ContentValues()) @@ -94,6 +118,30 @@ class EndTimeBuilderTest { assertEquals(1760148306000, result.entityValues.get(Events.DTEND)) } + @Test + fun `Non-recurring non-all-day event (with zoned DTEND before DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251011T040506", tzVienna)), + DtEnd(DateTime("20251010T040506", tzVienna)) // before DTSTART, should be ignored + )) + builder.build(event, event, result) + // default duration: 0 sec -> DTEND == DTSTART in calendar provider + assertEquals(1760148306000, result.entityValues.get(Events.DTEND)) + } + + @Test + fun `Non-recurring non-all-day event (with zoned DTEND equals DTSTART)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251011T040506", tzVienna)), + DtEnd(DateTime("20251011T040506", tzVienna)) // equals DTSTART, should be ignored + )) + builder.build(event, event, result) + // default duration: 0 sec -> DTEND == DTSTART in calendar provider + assertEquals(1760148306000, result.entityValues.get(Events.DTEND)) + } + @Test fun `Non-recurring all-day event (with DURATION)`() { val result = Entity(ContentValues()) @@ -105,6 +153,17 @@ class EndTimeBuilderTest { assertEquals(1760313600000, result.entityValues.get(Events.DTEND)) } + @Test + fun `Non-recurring all-day event (with negative DURATION)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(Date("20251010")), + Duration(Period.ofDays(-3)) // invalid negative DURATION will be treated as positive + )) + builder.build(event, event, result) + assertEquals(1760313600000, result.entityValues.get(Events.DTEND)) + } + @Test fun `Non-recurring non-all-day event (with DURATION)`() { val result = Entity(ContentValues()) @@ -116,6 +175,17 @@ class EndTimeBuilderTest { assertEquals(1760056323000, result.entityValues.get(Events.DTEND)) } + @Test + fun `Non-recurring non-all-day event (with negative DURATION)`() { + val result = Entity(ContentValues()) + val event = VEvent(propertyListOf( + DtStart(DateTime("20251010T010203", tzVienna)), + Duration(java.time.Duration.ofMinutes(-90)) // invalid negative DURATION will be treated as positive + )) + builder.build(event, event, result) + assertEquals(1760056323000, result.entityValues.get(Events.DTEND)) + } + @Test fun `Non-recurring all-day event (neither DTEND nor DURATION)`() { val result = Entity(ContentValues()) @@ -198,7 +268,7 @@ class EndTimeBuilderTest { fun `calculateFromDuration (dtStart=DATE, duration is date-based)`() { val result = builder.calculateFromDuration( DtStart(Date("20240228")), - Duration(null, "P1D") + java.time.Duration.ofDays(1) ) assertEquals( DtEnd(Date("20240229")), // leap day @@ -210,7 +280,7 @@ class EndTimeBuilderTest { fun `calculateFromDuration (dtStart=DATE, duration is time-based)`() { val result = builder.calculateFromDuration( DtStart(Date("20241231")), - Duration(null, "PT25H") + java.time.Duration.ofHours(25) ) assertEquals( DtEnd(Date("20250101")), @@ -222,7 +292,7 @@ class EndTimeBuilderTest { fun `calculateFromDuration (dtStart=DATE-TIME, duration is date-based)`() { val result = builder.calculateFromDuration( DtStart(DateTime("20250101T045623", tzVienna)), - Duration(null, "P1D") + java.time.Duration.ofDays(1) ) assertEquals( DtEnd(DateTime("20250102T045623", tzVienna)), @@ -234,7 +304,19 @@ class EndTimeBuilderTest { fun `calculateFromDuration (dtStart=DATE-TIME, duration is time-based)`() { val result = builder.calculateFromDuration( DtStart(DateTime("20250101T045623", tzVienna)), - Duration(null, "PT25H") + java.time.Duration.ofHours(25) + ) + assertEquals( + DtEnd(DateTime("20250102T055623", tzVienna)), + result + ) + } + + @Test + fun `calculateFromDuration (dtStart=DATE-TIME, duration is time-based and negative)`() { + val result = builder.calculateFromDuration( + DtStart(DateTime("20250101T045623", tzVienna)), + java.time.Duration.ofHours(-25) ) assertEquals( DtEnd(DateTime("20250102T055623", tzVienna)),