[icalendar] Add configuration for the behavior of the time-based event filter (#16105)

* Extract time-based event search strategies into a separate class

This allows extensions without having to adapt the logic in
BiweeklyPresentableCalendar.

Signed-off-by: Christian Heinemann <ch@chlab.net>
Co-authored-by: Leo Siepel <leosiepel@gmail.com>
This commit is contained in:
Christian Heinemann 2024-11-06 16:52:08 +01:00 committed by GitHub
parent 41c8c45e18
commit f37f39c03d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 923 additions and 41 deletions

View File

@ -10,7 +10,8 @@ The primary thing type is the calendar.
It is based on a single iCalendar file and implemented as bridge.
There can be multiple things having different properties representing different calendars.
Each calendar can have event filters which allow to get multiple events, maybe filtered by additional criteria. Time based filtering is done by each event's start.
Each calendar can have event filters which allow to get multiple events, maybe filtered by additional criteria.
Standard time-based filtering is done by each event's start, but it can also be configured to match other aspects.
## Thing Configuration
@ -40,6 +41,7 @@ Each `eventfilter` thing requires a bridge of type `calendar` and has following
| `datetimeStart` | The start of the time frame where to search for events relative to current time. Combined with `datetimeUnit`. | optional |
| `datetimeEnd` | The end of the time frame where to search for events relative to current time. Combined with `datetimeUnit`. The value must be greater than `datetimeStart` to get results. | optional |
| `datetimeRound` | Whether to round the datetimes of start and end down to the earlier time unit. Example if set: current time is 13:00, timeunit is set to `DAY`. Resulting search will start and end at 0:00. | optional |
| `datetimeMode` | Defines which part of an event must fall within the search period between start and end. Valid values: `START`, `ACTIVE` and `END`. | optional (default is `START`) |
| `textEventField` | A field to filter the events text-based. Valid values: `SUMMARY`, `DESCRIPTION`, `COMMENT`, `CONTACT` and `LOCATION` (as described in RFC 5545). | optional/required for text-based filtering |
| `textEventValue` | The text to filter events with. | optional |
| `textValueType` | The type of the text to filter with. Valid values: `TEXT` (field must contain value, case insensitive), `REGEX` (field must match value, completely, dot matches all, usually case sensitive). | optional/required for text-based filtering |

View File

@ -21,6 +21,7 @@ import org.eclipse.jdt.annotation.Nullable;
* The EventFilterConfiguration holds configuration for the Event Filter Item Type.
*
* @author Michael Wodniok - Initial contribution
* @author Christian Heinemann - Introduction of 'datetimeMode'
*/
@NonNullByDefault
public class EventFilterConfiguration {
@ -37,6 +38,8 @@ public class EventFilterConfiguration {
@Nullable
public Boolean datetimeRound;
@Nullable
public String datetimeMode;
@Nullable
public String textEventField;
@Nullable
public String textEventValue;

View File

@ -30,6 +30,7 @@ import org.openhab.binding.icalendar.internal.handler.PullJob.CalendarUpdateList
import org.openhab.binding.icalendar.internal.logic.AbstractPresentableCalendar;
import org.openhab.binding.icalendar.internal.logic.Event;
import org.openhab.binding.icalendar.internal.logic.EventTextFilter;
import org.openhab.binding.icalendar.internal.logic.EventTimeFilter;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.StringType;
@ -57,6 +58,7 @@ import org.slf4j.LoggerFactory;
*
* @author Michael Wodniok - Initial Contribution
* @author Michael Wodniok - Fixed subsecond search if rounding to unit
* @author Christian Heinemann - Introduction of configuration 'datetimeMode'
*/
@NonNullByDefault
public class EventFilterHandler extends BaseThingHandler implements CalendarUpdateListener {
@ -278,10 +280,11 @@ public class EventFilterHandler extends BaseThingHandler implements CalendarUpda
Instant reference = Instant.now();
TimeMultiplicator multiplicator = null;
EventTextFilter filter = null;
EventTextFilter eventTextFilter = null;
int maxEvents;
Instant begin = Instant.EPOCH;
Instant end = Instant.ofEpochMilli(Long.MAX_VALUE);
final EventTimeFilter eventTimeFilter;
try {
String textFilterValue = config.textEventValue;
@ -295,7 +298,7 @@ public class EventFilterHandler extends BaseThingHandler implements CalendarUpda
EventTextFilter.Field textFilterField = EventTextFilter.Field.valueOf(textEventField);
EventTextFilter.Type textFilterType = EventTextFilter.Type.valueOf(textValueType);
filter = new EventTextFilter(textFilterField, textFilterValue, textFilterType);
eventTextFilter = new EventTextFilter(textFilterField, textFilterValue, textFilterType);
} catch (IllegalArgumentException e2) {
throw new ConfigBrokenException("textEventField or textValueType are not set properly.");
}
@ -352,13 +355,16 @@ public class EventFilterHandler extends BaseThingHandler implements CalendarUpda
}
end = reference.plusSeconds(datetimeEnd.longValue() * multiplicator.getMultiplier());
}
eventTimeFilter = selectEventTimeFilterByConfigValue(config.datetimeMode);
} catch (ConfigBrokenException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
return;
}
synchronized (resultChannels) {
List<Event> results = cal.getFilteredEventsBetween(begin, end, filter, maxEvents);
List<Event> results = cal.getFilteredEventsBetween(begin, end, eventTimeFilter, eventTextFilter,
maxEvents);
for (int position = 0; position < resultChannels.size(); position++) {
ResultChannelSet channels = resultChannels.get(position);
if (position < results.size()) {
@ -393,4 +399,18 @@ public class EventFilterHandler extends BaseThingHandler implements CalendarUpda
}
updateFuture = scheduler.scheduleWithFixedDelay(this::updateStates, refreshTime, refreshTime, TimeUnit.MINUTES);
}
private EventTimeFilter selectEventTimeFilterByConfigValue(@Nullable String datetimeMode)
throws ConfigBrokenException {
if (datetimeMode == null) {
return EventTimeFilter.searchByStart();
}
return switch (datetimeMode) {
case "START" -> EventTimeFilter.searchByStart();
case "END" -> EventTimeFilter.searchByEnd();
case "ACTIVE" -> EventTimeFilter.searchByActive();
default -> throw new ConfigBrokenException("datetimeMode is not set properly.");
};
}
}

View File

@ -27,6 +27,7 @@ import org.eclipse.jdt.annotation.Nullable;
* @author Michael Wodniok - Initial contribution
* @author Andrew Fiddian-Green - Methods getJustBegunEvents() and getJustEndedEvents()
* @author Michael Wodniok - Added getFilteredEventsBetween()
* @author Christian Heinemann - Extension for the time-based filtering strategy
*/
@NonNullByDefault
public abstract class AbstractPresentableCalendar {
@ -91,12 +92,28 @@ public abstract class AbstractPresentableCalendar {
/**
* Return a filtered List of events with a maximum count, ordered by start.
*
* @param begin The begin of the time range where to search for events
* @param end The end of the time range where to search for events
* @param filter A filter for contents, if set to null, all events will be returned
* @param begin The begin of the time range where to search for events.
* @param end The end of the time range where to search for events.
* @param eventTimeFilter A filter for deciding whether an event falls into the time range.
* @param eventTextFilter A filter for contents, if set to null, all events will be returned.
* @param maximumCount The maximum of events returned here.
* @return A list with the filtered results.
*/
public abstract List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
int maximumCount);
public abstract List<Event> getFilteredEventsBetween(Instant begin, Instant end, EventTimeFilter eventTimeFilter,
@Nullable EventTextFilter eventTextFilter, int maximumCount);
/**
* Return a filtered List of events with a maximum count, ordered by start. Time based filtering is done by each
* event's start.
*
* @param begin The begin of the time range where to search for events.
* @param end The end of the time range where to search for events.
* @param eventTextFilter A filter for contents, if set to null, all events will be returned.
* @param maximumCount The maximum of events returned here.
* @return A list with the filtered results.
*/
public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter eventTextFilter,
int maximumCount) {
return getFilteredEventsBetween(begin, end, EventTimeFilter.searchByStart(), eventTextFilter, maximumCount);
}
}

View File

@ -61,6 +61,7 @@ import biweekly.util.com.google.ical.compat.javautil.DateIterator;
* @author Michael Wodniok - Added logic for events moved with "RECURRENCE-ID" (issue 9647)
* @author Michael Wodniok - Extended logic for defined behavior with parallel current events
* (issue 10808)
* @author Christian Heinemann - Extension for the time-based filtering strategy
*/
@NonNullByDefault
class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
@ -89,14 +90,14 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
@Override
public List<Event> getJustBegunEvents(Instant frameBegin, Instant frameEnd) {
return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0).stream().map(e -> e.toEvent())
.collect(Collectors.toList());
return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, EventTimeFilter.searchByStart()).stream()
.map(VEventWPeriod::toEvent).collect(Collectors.toList());
}
@Override
public List<Event> getJustEndedEvents(Instant frameBegin, Instant frameEnd) {
return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, true).stream().map(e -> e.toEvent())
.collect(Collectors.toList());
return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, EventTimeFilter.searchByJustEnded()).stream()
.map(VEventWPeriod::toEvent).collect(Collectors.toList());
}
@Override
@ -142,22 +143,22 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
}
@Override
public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
int maximumCount) {
List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
public List<Event> getFilteredEventsBetween(Instant begin, Instant end, EventTimeFilter eventTimeFilter,
@Nullable EventTextFilter eventTextFilter, int maximumCount) {
List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount, eventTimeFilter);
final List<Event> results = new ArrayList<>(candidates.size());
if (filter != null) {
if (eventTextFilter != null) {
Pattern filterPattern;
if (filter.type == Type.TEXT) {
filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
if (eventTextFilter.type == Type.TEXT) {
filterPattern = Pattern.compile(".*" + Pattern.quote(eventTextFilter.value) + ".*",
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
} else {
filterPattern = Pattern.compile(filter.value);
filterPattern = Pattern.compile(eventTextFilter.value);
}
Class<? extends TextProperty> propertyClass;
switch (filter.field) {
switch (eventTextFilter.field) {
case SUMMARY:
propertyClass = Summary.class;
break;
@ -199,28 +200,16 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
}
/**
* Finds events which begin in the given frame.
* Finds events which begin in the given frame by end time and date
*
* @param frameBegin Begin of the frame where to search events.
* @param frameEnd End of the time frame where to search events.
* @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
* @return All events which begin in the time frame.
*/
private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
return this.getVEventWPeriodsBetween(frameBegin, frameEnd, maximumPerSeries, false);
}
/**
* Finds events which begin in the given frame by end time and date
*
* @param frameBegin Begin of the frame where to search events.
* @param frameEnd End of the time frame where to search events. The Instant is inclusive when searchByEnd is true.
* @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
* @param searchByEnd Whether to search by begin of the event or by end.
* @param eventTimeFilter Strategy that decides which events should be considered in the time frame.
* @return All events which begin in the time frame.
*/
private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries,
boolean searchByEnd) {
EventTimeFilter eventTimeFilter) {
final List<VEvent> positiveEvents = new ArrayList<>();
final List<VEvent> negativeEvents = new ArrayList<>();
classifyEvents(positiveEvents, negativeEvents);
@ -232,17 +221,15 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
if (duration == null) {
duration = Duration.ZERO;
}
positiveBeginDates.advanceTo(Date.from(frameBegin.minus(searchByEnd ? duration : Duration.ZERO)));
positiveBeginDates.advanceTo(Date.from(eventTimeFilter.searchFrom(frameBegin, duration)));
int foundInSeries = 0;
while (positiveBeginDates.hasNext()) {
final Instant begInst = positiveBeginDates.next().toInstant();
if ((!searchByEnd && (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)))
|| (searchByEnd && begInst.plus(duration).isAfter(frameEnd))) {
if (eventTimeFilter.eventAfterFrame(frameEnd, begInst, duration)) {
break;
}
// biweekly is not as precise as java.time. An exact check is required.
if ((!searchByEnd && begInst.isBefore(frameBegin))
|| (searchByEnd && begInst.plus(duration).isBefore(frameBegin))) {
if (eventTimeFilter.eventBeforeFrame(frameBegin, begInst, duration)) {
continue;
}

View File

@ -0,0 +1,189 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.logic;
import java.time.Duration;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Strategy for time based filtering.
*
* @author Christian Heinemann - Initial contribution
*/
@NonNullByDefault
public abstract class EventTimeFilter {
/**
* Creates the strategy to search for events that start in a specific time frame. The exact end of the time frame is
* exclusive.
*
* @return The search strategy.
*/
public static EventTimeFilter searchByStart() {
return new SearchByStart();
}
/**
* Creates the strategy to search for events that end in a specific time frame. The exact end of the time frame is
* inclusive.
*
* @return The search strategy.
*/
public static EventTimeFilter searchByEnd() {
return new SearchByEnd();
}
/**
* Creates the strategy to search for events that are active in a specific time frame.
* It finds the same events as {@link #searchByStart()} and {@link #searchByEnd()}, but additionally also events
* that start before the time frame or end after.
*
* @return The search strategy.
*/
public static EventTimeFilter searchByActive() {
return new SearchByActive();
}
/**
* Creates the strategy to search for events that end in a specific time frame. The exact end of the time frame is
* inclusive.
* <p>
* This is the strategy applied by {@link BiweeklyPresentableCalendar#getJustEndedEvents(Instant, Instant)}.
* It is used here for backwards compatibility.
* There are problems when an event ends exactly at the end of the search period.
* Then the result is found for both this search period and one that begins immediately after it.
* However, the usual behavior should be that if there are several non-overlapping search periods, an event will
* only be found at most once.
* That's why it is only offered here as non-public for internal use.
*
* @return The search strategy.
*/
static EventTimeFilter searchByJustEnded() {
return new SearchByJustEnded();
}
/**
* Gives a time to start searching for occurrences of a particular (recurring) event.
*
* @param frameStart Start of the frame where to search events.
* @param eventDuration Duration of the event.
* @return The time to start searching.
*/
public abstract Instant searchFrom(Instant frameStart, Duration eventDuration);
/**
* Decides whether the relevant characteristic of an event occurrence is after the time frame. With the first hit,
* no further occurrences of a recurring event are searched for.
*
* @param frameEnd End of the frame where to search events.
* @param eventStart Start of the event occurrence.
* @param eventDuration Duration of the event.
* @return {@code true} if an occurrence of the event was found after the time frame, otherwise {@code false}.
*/
public abstract boolean eventAfterFrame(Instant frameEnd, Instant eventStart, Duration eventDuration);
/**
* Decides whether the relevant characteristic of an event occurrence is before the time frame. Such occurrences are
* ignored.
*
* @param frameStart Start of the frame where to search events.
* @param eventStart Start of the event occurrence.
* @param eventDuration Duration of the event.
* @return {@code true} if an occurrence of the event was found before the time frame, otherwise {@code false}.
*/
public abstract boolean eventBeforeFrame(Instant frameStart, Instant eventStart, Duration eventDuration);
@Override
public boolean equals(@Nullable Object other) {
if (other == null) {
return false;
}
return getClass().equals(other.getClass());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
private static class SearchByStart extends EventTimeFilter {
@Override
public Instant searchFrom(Instant frameStart, Duration eventDuration) {
return frameStart;
}
@Override
public boolean eventAfterFrame(Instant frameEnd, Instant eventStart, Duration eventDuration) {
return !eventStart.isBefore(frameEnd);
}
@Override
public boolean eventBeforeFrame(Instant frameStart, Instant eventStart, Duration eventDuration) {
return eventStart.isBefore(frameStart);
}
}
private static class SearchByEnd extends EventTimeFilter {
@Override
public Instant searchFrom(Instant frameStart, Duration eventDuration) {
return frameStart.minus(eventDuration);
}
@Override
public boolean eventAfterFrame(Instant frameEnd, Instant eventStart, Duration eventDuration) {
return eventStart.plus(eventDuration).isAfter(frameEnd);
}
@Override
public boolean eventBeforeFrame(Instant frameStart, Instant eventStart, Duration eventDuration) {
return !eventStart.plus(eventDuration).isAfter(frameStart);
}
}
private static class SearchByActive extends EventTimeFilter {
@Override
public Instant searchFrom(Instant frameStart, Duration eventDuration) {
return frameStart.minus(eventDuration);
}
@Override
public boolean eventAfterFrame(Instant frameEnd, Instant eventStart, Duration eventDuration) {
return !eventStart.isBefore(frameEnd);
}
@Override
public boolean eventBeforeFrame(Instant frameStart, Instant eventStart, Duration eventDuration) {
return !eventStart.plus(eventDuration).isAfter(frameStart);
}
}
private static class SearchByJustEnded extends EventTimeFilter {
@Override
public Instant searchFrom(Instant frameStart, Duration eventDuration) {
return frameStart.minus(eventDuration);
}
@Override
public boolean eventAfterFrame(Instant frameEnd, Instant eventStart, Duration eventDuration) {
return eventStart.plus(eventDuration).isAfter(frameEnd);
}
@Override
public boolean eventBeforeFrame(Instant frameStart, Instant eventStart, Duration eventDuration) {
return eventStart.plus(eventDuration).isBefore(frameStart);
}
}
}

View File

@ -30,6 +30,11 @@ thing-type.config.icalendar.calendar.username.label = User Name
thing-type.config.icalendar.calendar.username.description = User name for fetching the calendar (usable in combination with password in HTTP basic auth)
thing-type.config.icalendar.eventfilter.datetimeEnd.label = End
thing-type.config.icalendar.eventfilter.datetimeEnd.description = End date/time amount to find events relative to "now" (exclusive)
thing-type.config.icalendar.eventfilter.datetimeMode.label = Search mode
thing-type.config.icalendar.eventfilter.datetimeMode.description = Defines which part of an event must fall within the search period between start and end
thing-type.config.icalendar.eventfilter.datetimeMode.option.START = Events that begin in the period
thing-type.config.icalendar.eventfilter.datetimeMode.option.ACTIVE = Events that are active at any phase in the period
thing-type.config.icalendar.eventfilter.datetimeMode.option.END = Events that end in the period
thing-type.config.icalendar.eventfilter.datetimeRound.label = Round to Date/Time unit
thing-type.config.icalendar.eventfilter.datetimeRound.description = Setting this will round start and end date/time to the unit down (e.g. if unit is day: start and end will be rounded to 0:00 day time)
thing-type.config.icalendar.eventfilter.datetimeStart.label = Start

View File

@ -193,6 +193,17 @@
<description>Setting this will round start and end date/time to the unit down (e.g. if unit is day: start and end
will be rounded to 0:00 day time)</description>
</parameter>
<parameter name="datetimeMode" type="text" groupName="datetime_based">
<limitToOptions>true</limitToOptions>
<options>
<option value="START">Events that begin in the period</option>
<option value="ACTIVE">Events that are active at any phase in the period</option>
<option value="END">Events that end in the period</option>
</options>
<default>START</default>
<label>Search Mode</label>
<description>Defines which part of an event must fall within the search period between start and end</description>
</parameter>
<parameter name="textEventField" type="text" groupName="text_based">
<label>Event Field</label>
<description>iCal field to match</description>

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.handler;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.isNull;
import static org.mockito.Mockito.verify;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.icalendar.internal.logic.AbstractPresentableCalendar;
import org.openhab.binding.icalendar.internal.logic.EventTimeFilter;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.builder.BridgeBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.thing.binding.builder.ThingStatusInfoBuilder;
/**
* Tests for {@link EventFilterHandler}.
*
* @author Christian Heinemann - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
public class EventFilterHandlerTest {
private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProvider;
private @Mock @NonNullByDefault({}) ThingHandlerCallback thingHandlerCallback;
private @Mock @NonNullByDefault({}) ICalendarHandler iCalendarHandler;
private @Mock @NonNullByDefault({}) AbstractPresentableCalendar calendar;
private @NonNullByDefault({}) EventFilterHandler eventFilterHandler;
@BeforeEach
public void setUp() {
Configuration configuration = new Configuration();
configuration.put("maxEvents", "1");
configuration.put("datetimeStart", "0");
configuration.put("datetimeEnd", "1");
configuration.put("datetimeRound", "true");
configuration.put("datetimeUnit", "DAY");
doReturn(ZoneId.of("UTC")).when(timeZoneProvider).getTimeZone();
doReturn(calendar).when(iCalendarHandler).getRuntimeCalendar();
Bridge iCalendarBridge = BridgeBuilder.create(new ThingTypeUID("icalendar", "calendar"), "test").build();
iCalendarBridge.setStatusInfo(ThingStatusInfoBuilder.create(ThingStatus.ONLINE).build());
iCalendarBridge.setHandler(iCalendarHandler);
Thing eventFilterThing = ThingBuilder.create(new ThingTypeUID("icalendar", "eventfilter"), "test")
.withBridge(iCalendarBridge.getUID()).withConfiguration(configuration).build();
eventFilterHandler = new EventFilterHandler(eventFilterThing, timeZoneProvider);
eventFilterHandler.setCallback(thingHandlerCallback);
doReturn(iCalendarBridge).when(thingHandlerCallback).getBridge(iCalendarBridge.getUID());
}
@Test
public void testSearchWithDefaultMode() {
eventFilterHandler.getThing().getConfiguration().remove("datetimeMode");
doCalendarUpdate();
verify(calendar).getFilteredEventsBetween(any(), any(), eq(EventTimeFilter.searchByStart()), isNull(), eq(1));
}
@Test
public void testSearchByStart() {
eventFilterHandler.getThing().getConfiguration().put("datetimeMode", "START");
doCalendarUpdate();
verify(calendar).getFilteredEventsBetween(any(), any(), eq(EventTimeFilter.searchByStart()), isNull(), eq(1));
}
@Test
public void testSearchByEnd() {
eventFilterHandler.getThing().getConfiguration().put("datetimeMode", "END");
doCalendarUpdate();
verify(calendar).getFilteredEventsBetween(any(), any(), eq(EventTimeFilter.searchByEnd()), isNull(), eq(1));
}
@Test
public void testSearchByActive() {
eventFilterHandler.getThing().getConfiguration().put("datetimeMode", "ACTIVE");
doCalendarUpdate();
verify(calendar).getFilteredEventsBetween(any(), any(), eq(EventTimeFilter.searchByActive()), isNull(), eq(1));
}
@Test
public void testInvalidDatetimeModeConfigValue() {
eventFilterHandler.getThing().getConfiguration().put("datetimeMode", "DUMMY");
doCalendarUpdate();
ThingStatusInfo expectedThingStatusInfo = ThingStatusInfoBuilder.create(ThingStatus.OFFLINE)
.withStatusDetail(ThingStatusDetail.CONFIGURATION_ERROR)
.withDescription("datetimeMode is not set properly.").build();
verify(thingHandlerCallback).statusUpdated(eventFilterHandler.getThing(), expectedThingStatusInfo);
}
private void doCalendarUpdate() {
eventFilterHandler.initialize();
eventFilterHandler.getThing().setStatusInfo(ThingStatusInfoBuilder.create(ThingStatus.ONLINE).build());
eventFilterHandler.onCalendarUpdated();
}
}

View File

@ -0,0 +1,133 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.logic;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyCollectionOf;
import static org.hamcrest.Matchers.is;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests of time-based filtering when using {@link EventTimeFilter#searchByActive()} in {@link
* BiweeklyPresentableCalendar#getFilteredEventsBetween(Instant, Instant, EventTimeFilter, EventTextFilter, int)}
* with multi-day events.
*
* @author Christian Heinemann - Initial contribution
*/
@NonNullByDefault
public class MultiDayEventSearchByActiveTest {
private @NonNullByDefault({}) AbstractPresentableCalendar calendar;
private final EventTimeFilter eventTimeFilter = EventTimeFilter.searchByActive();
@BeforeEach
public void setUp() throws IOException, CalendarException {
calendar = new BiweeklyPresentableCalendar(new FileInputStream("src/test/resources/test-multiday.ics"));
}
@Test
public void eventWithTime() {
Event expectedFilteredEvent = new Event("Multi-day test event with time", Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), "");
assertThat("Day before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-04T00:00:00Z"),
Instant.parse("2023-12-05T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Hour before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T08:00:00Z"),
Instant.parse("2023-12-05T09:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Hour when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-05T10:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 1 when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T00:00:00Z"),
Instant.parse("2023-12-06T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 2 when event is still active",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-06T00:00:00Z"),
Instant.parse("2023-12-07T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 3 when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T00:00:00Z"),
Instant.parse("2023-12-08T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T14:00:00Z"),
Instant.parse("2023-12-05T15:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T15:00:00Z"),
Instant.parse("2023-12-05T16:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-08T00:00:00Z"),
Instant.parse("2023-12-09T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
@Test
public void eventWithoutTime() {
Event expectedFilteredEvent = new Event("Multi-day test event without time", localDateAsInstant("2023-12-12"),
localDateAsInstant("2023-12-15"), "");
assertThat("Day before event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-11"), localDateAsInstant("2023-12-12"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-12"), localDateAsInstant("2023-12-13"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 2 when event is still active", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-13"), localDateAsInstant("2023-12-14"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 3 when event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-14"), localDateAsInstant("2023-12-15"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day after event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-15"), localDateAsInstant("2023-12-16"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
private Instant localDateAsInstant(CharSequence text) {
return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
}

View File

@ -0,0 +1,123 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.logic;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyCollectionOf;
import static org.hamcrest.Matchers.is;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests of time-based filtering when using {@link EventTimeFilter#searchByEnd()} in {@link
* BiweeklyPresentableCalendar#getFilteredEventsBetween(Instant, Instant, EventTimeFilter, EventTextFilter, int)}
* with multi-day events.
*
* @author Christian Heinemann - Initial contribution
*/
@NonNullByDefault
public class MultiDayEventSearchByEndTest {
private @NonNullByDefault({}) AbstractPresentableCalendar calendar;
private final EventTimeFilter eventTimeFilter = EventTimeFilter.searchByEnd();
@BeforeEach
public void setUp() throws IOException, CalendarException {
calendar = new BiweeklyPresentableCalendar(new FileInputStream("src/test/resources/test-multiday.ics"));
}
@Test
public void eventWithTime() {
Event expectedFilteredEvent = new Event("Multi-day test event with time", Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), "");
assertThat("Day before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-04T00:00:00Z"),
Instant.parse("2023-12-05T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T00:00:00Z"),
Instant.parse("2023-12-06T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 2 when event is still active",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-06T00:00:00Z"),
Instant.parse("2023-12-07T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T00:00:00Z"),
Instant.parse("2023-12-08T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T14:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T15:00:00Z"),
Instant.parse("2023-12-07T16:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-08T00:00:00Z"),
Instant.parse("2023-12-09T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
@Test
public void eventWithoutTime() {
Event expectedFilteredEvent = new Event("Multi-day test event without time", localDateAsInstant("2023-12-12"),
localDateAsInstant("2023-12-15"), "");
assertThat("Day before event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-11"), localDateAsInstant("2023-12-12"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-12"), localDateAsInstant("2023-12-13"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 2 when event is still active", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-13"), localDateAsInstant("2023-12-14"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-14"), localDateAsInstant("2023-12-15"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day after event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-15"), localDateAsInstant("2023-12-16"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
private Instant localDateAsInstant(CharSequence text) {
return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
}

View File

@ -0,0 +1,123 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.logic;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyCollectionOf;
import static org.hamcrest.Matchers.is;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests of time-based filtering when using {@link EventTimeFilter#searchByJustEnded()} in {@link
* BiweeklyPresentableCalendar#getFilteredEventsBetween(Instant, Instant, EventTimeFilter, EventTextFilter, int)}
* with multi-day events.
*
* @author Christian Heinemann - Initial contribution
*/
@NonNullByDefault
public class MultiDayEventSearchByJustEndedTest {
private @NonNullByDefault({}) AbstractPresentableCalendar calendar;
private final EventTimeFilter eventTimeFilter = EventTimeFilter.searchByJustEnded();
@BeforeEach
public void setUp() throws IOException, CalendarException {
calendar = new BiweeklyPresentableCalendar(new FileInputStream("src/test/resources/test-multiday.ics"));
}
@Test
public void eventWithTime() {
Event expectedFilteredEvent = new Event("Multi-day test event with time", Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), "");
assertThat("Day before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-04T00:00:00Z"),
Instant.parse("2023-12-05T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T00:00:00Z"),
Instant.parse("2023-12-06T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 2 when event is still active",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-06T00:00:00Z"),
Instant.parse("2023-12-07T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T00:00:00Z"),
Instant.parse("2023-12-08T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T14:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Hour after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T15:00:00Z"),
Instant.parse("2023-12-07T16:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent)); // event found again!!
assertThat("Day after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-08T00:00:00Z"),
Instant.parse("2023-12-09T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
@Test
public void eventWithoutTime() {
Event expectedFilteredEvent = new Event("Multi-day test event without time", localDateAsInstant("2023-12-12"),
localDateAsInstant("2023-12-15"), "");
assertThat("Day before event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-11"), localDateAsInstant("2023-12-12"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-12"), localDateAsInstant("2023-12-13"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 2 when event is still active", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-13"), localDateAsInstant("2023-12-14"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-14"), localDateAsInstant("2023-12-15"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day after event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-15"), localDateAsInstant("2023-12-16"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent)); // event found again!!
}
private Instant localDateAsInstant(CharSequence text) {
return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
}

View File

@ -0,0 +1,123 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icalendar.internal.logic;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.emptyCollectionOf;
import static org.hamcrest.Matchers.is;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests of time-based filtering when using {@link EventTimeFilter#searchByStart()} in {@link
* BiweeklyPresentableCalendar#getFilteredEventsBetween(Instant, Instant, EventTimeFilter, EventTextFilter, int)}
* with multi-day events.
*
* @author Christian Heinemann - Initial contribution
*/
@NonNullByDefault
public class MultiDayEventSearchByStartTest {
private @NonNullByDefault({}) AbstractPresentableCalendar calendar;
private final EventTimeFilter eventTimeFilter = EventTimeFilter.searchByStart();
@BeforeEach
public void setUp() throws IOException, CalendarException {
calendar = new BiweeklyPresentableCalendar(new FileInputStream("src/test/resources/test-multiday.ics"));
}
@Test
public void eventWithTime() {
Event expectedFilteredEvent = new Event("Multi-day test event with time", Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-07T15:00:00Z"), "");
assertThat("Day before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-04T00:00:00Z"),
Instant.parse("2023-12-05T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Hour before event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T08:00:00Z"),
Instant.parse("2023-12-05T09:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Hour when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T09:00:00Z"),
Instant.parse("2023-12-05T10:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 1 when event starts",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-05T00:00:00Z"),
Instant.parse("2023-12-06T00:00:00Z"), eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 2 when event is still active",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-06T00:00:00Z"),
Instant.parse("2023-12-07T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-07T00:00:00Z"),
Instant.parse("2023-12-08T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day after event ends",
calendar.getFilteredEventsBetween(Instant.parse("2023-12-08T00:00:00Z"),
Instant.parse("2023-12-09T00:00:00Z"), eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
@Test
public void eventWithoutTime() {
Event expectedFilteredEvent = new Event("Multi-day test event without time", localDateAsInstant("2023-12-12"),
localDateAsInstant("2023-12-15"), "");
assertThat("Day before event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-11"), localDateAsInstant("2023-12-12"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 1 when event starts", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-12"), localDateAsInstant("2023-12-13"),
eventTimeFilter, null, 1),
contains(expectedFilteredEvent));
assertThat("Day 2 when event is still active", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-13"), localDateAsInstant("2023-12-14"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day 3 when event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-14"), localDateAsInstant("2023-12-15"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
assertThat("Day after event ends", //
calendar.getFilteredEventsBetween(localDateAsInstant("2023-12-15"), localDateAsInstant("2023-12-16"),
eventTimeFilter, null, 1),
is(emptyCollectionOf(Event.class)));
}
private Instant localDateAsInstant(CharSequence text) {
return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant();
}
}

View File

@ -0,0 +1,17 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:UTC
BEGIN:VEVENT
DTSTART;VALUE=DATE:20231212
DTEND;VALUE=DATE:20231215
SUMMARY:Multi-day test event without time
END:VEVENT
BEGIN:VEVENT
DTSTART:20231205T090000Z
DTEND:20231207T150000Z
SUMMARY:Multi-day test event with time
END:VEVENT
END:VCALENDAR