From 7dca855dc5851c9af1277df2c09c6466f9d1e048 Mon Sep 17 00:00:00 2001 From: Ben Fortuna Date: Sat, 22 Jun 2019 15:08:03 +1000 Subject: [PATCH] Support for contextual zone definitions (local and global) --- .../ical4j/data/DefaultContentHandler.java | 75 +-- .../model/DefaultZoneRulesProvider.java | 80 +++ .../net/fortuna/ical4j/model/TimeZone.java | 3 +- .../fortuna/ical4j/model/TimeZoneLoader.java | 13 +- .../ical4j/model/TimeZoneRegistry.java | 28 ++ .../ical4j/model/TimeZoneRegistryImpl.java | 68 ++- .../ical4j/model/ZoneRulesBuilder.java | 75 +++ .../ical4j/model/ZoneRulesProviderImpl.java | 47 ++ .../services/java.time.zone.ZoneRulesProvider | 1 + .../net/fortuna/ical4j/model/tz.availableIds | 461 ++++++++++++++++++ .../model/DefaultZoneRulesProviderTest.groovy | 47 ++ .../ical4j/model/ZoneRulesBuilderTest.groovy | 29 ++ .../model/ZoneRulesProviderImplTest.groovy | 23 + .../ical4j/model/property/TzIdSpec.groovy | 45 ++ .../data/CalendarBuilderTimezoneTest.java | 13 +- .../model/property/DateListPropertyTest.java | 14 +- .../model/property/DatePropertyTest.java | 27 +- 17 files changed, 917 insertions(+), 132 deletions(-) create mode 100644 src/main/java/net/fortuna/ical4j/model/DefaultZoneRulesProvider.java create mode 100644 src/main/java/net/fortuna/ical4j/model/ZoneRulesBuilder.java create mode 100644 src/main/java/net/fortuna/ical4j/model/ZoneRulesProviderImpl.java create mode 100644 src/main/resources/META-INF/services/java.time.zone.ZoneRulesProvider create mode 100644 src/main/resources/net/fortuna/ical4j/model/tz.availableIds create mode 100644 src/test/groovy/net/fortuna/ical4j/model/DefaultZoneRulesProviderTest.groovy create mode 100644 src/test/groovy/net/fortuna/ical4j/model/ZoneRulesBuilderTest.groovy create mode 100644 src/test/groovy/net/fortuna/ical4j/model/ZoneRulesProviderImplTest.groovy create mode 100644 src/test/groovy/net/fortuna/ical4j/model/property/TzIdSpec.groovy diff --git a/src/main/java/net/fortuna/ical4j/data/DefaultContentHandler.java b/src/main/java/net/fortuna/ical4j/data/DefaultContentHandler.java index 1986b753d..891bc9712 100644 --- a/src/main/java/net/fortuna/ical4j/data/DefaultContentHandler.java +++ b/src/main/java/net/fortuna/ical4j/data/DefaultContentHandler.java @@ -1,16 +1,15 @@ package net.fortuna.ical4j.data; import net.fortuna.ical4j.model.*; -import net.fortuna.ical4j.model.component.*; +import net.fortuna.ical4j.model.component.CalendarComponent; +import net.fortuna.ical4j.model.component.VTimeZone; import net.fortuna.ical4j.model.parameter.TzId; -import net.fortuna.ical4j.model.property.DateListProperty; -import net.fortuna.ical4j.model.property.DateProperty; import net.fortuna.ical4j.util.Constants; import java.io.IOException; import java.net.URISyntaxException; import java.text.ParseException; -import java.util.ArrayList; +import java.time.zone.ZoneRulesProvider; import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; @@ -25,8 +24,6 @@ public class DefaultContentHandler implements ContentHandler { private final TimeZoneRegistry tzRegistry; - private List propertiesWithTzId; - private final Consumer consumer; private PropertyBuilder propertyBuilder; @@ -57,30 +54,11 @@ public DefaultContentHandler(Consumer consumer, TimeZoneRegistry tzReg @Override public void startCalendar() { calendar = new Calendar(); - propertiesWithTzId = new ArrayList<>(); } @Override - public void endCalendar() throws IOException { - if (propertiesWithTzId.size() > 0 && tzRegistry != null) { - for (CalendarComponent component : calendar.getComponents()) { - resolveTimezones(component.getProperties()); - - if (component instanceof VAvailability) { - for (Component available : ((VAvailability) component).getAvailable()) { - resolveTimezones(available.getProperties()); - } - } else if (component instanceof VEvent) { - for (Component alarm : ((VEvent) component).getAlarms()) { - resolveTimezones(alarm.getProperties()); - } - } else if (component instanceof VToDo) { - for (Component todo : ((VToDo) component).getAlarms()) { - resolveTimezones(todo.getProperties()); - } - } - } - } + public void endCalendar() { + ZoneRulesProvider.registerProvider(new ZoneRulesProviderImpl(tzRegistry)); consumer.accept(calendar); } @@ -142,8 +120,6 @@ public void endProperty(String name) throws URISyntaxException, ParseException, } else if (calendar != null) { calendar.getProperties().add(property); } - - property = null; } @Override @@ -157,7 +133,7 @@ public void parameter(String name, String value) throws URISyntaxException { // VTIMEZONE may be defined later, so so keep // track of dates until all components have been // parsed, and then try again later - propertiesWithTzId.add((TzId) parameter); + ((TzId) parameter).setTimeZoneRegistry(tzRegistry); } propertyBuilder.parameter(parameter); @@ -174,43 +150,4 @@ private void assertProperty(PropertyBuilder property) { throw new CalendarException("Expected property not initialised"); } } - - private void resolveTimezones(List properties) throws IOException { - - // Go through each property and try to resolve the TZID. - for (TzId tzParam : propertiesWithTzId) { - - //lookup timezone - final TimeZone timezone = tzRegistry.getTimeZone(tzParam.getValue()); - - // If timezone found, then update date property - if (timezone != null) { - for (Property property : properties) { - if (tzParam.equals(property.getParameter(Parameter.TZID))) { - - // Get the String representation of date(s) as - // we will need this after changing the timezone - final String strDate = property.getValue(); - - // Change the timezone - if (property instanceof DateProperty) { - ((DateProperty) property).setTimeZone(timezone); - } else if (property instanceof DateListProperty) { - ((DateListProperty) property).setTimeZone(timezone); - } else { - throw new CalendarException("Invalid parameter: " + tzParam.getName()); - } - - // Reset value - try { - property.setValue(strDate); - } catch (ParseException | URISyntaxException e) { - // shouldn't happen as its already been parsed - throw new CalendarException(e); - } - } - } - } - } - } } diff --git a/src/main/java/net/fortuna/ical4j/model/DefaultZoneRulesProvider.java b/src/main/java/net/fortuna/ical4j/model/DefaultZoneRulesProvider.java new file mode 100644 index 000000000..bc0719b73 --- /dev/null +++ b/src/main/java/net/fortuna/ical4j/model/DefaultZoneRulesProvider.java @@ -0,0 +1,80 @@ +package net.fortuna.ical4j.model; + +import net.fortuna.ical4j.data.ParserException; +import net.fortuna.ical4j.model.component.VTimeZone; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.text.ParseException; +import java.time.zone.ZoneRules; +import java.time.zone.ZoneRulesProvider; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A default {@link ZoneRulesProvider} implementation for included timezone definitions. To avoid conflicting with + * the standard Java zone rules this provider maintains an internal map of local zone ids to globally unique ids. + * + * NOTE: Globally unique zone identifiers are transient and will be regenerated for each instance of this class. They + * are only used to support registration and use of alternative definitions in the scope of this library. + */ +public class DefaultZoneRulesProvider extends ZoneRulesProvider { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultZoneRulesProvider.class); + + private static final String DEFAULT_RESOURCE_PREFIX = "zoneinfo/"; + + private final TimeZoneLoader zoneLoader; + + private final Map zoneRulesMap; + + public DefaultZoneRulesProvider() { + this(new TimeZoneLoader(DEFAULT_RESOURCE_PREFIX)); + } + + public DefaultZoneRulesProvider(TimeZoneLoader timeZoneLoader) { + this.zoneLoader = timeZoneLoader; + for (String id : zoneLoader.getAvailableIDs()) { + TimeZoneRegistry.ZONE_IDS.put("ical4j~" + UUID.randomUUID().toString(), id); + } + this.zoneRulesMap = new ConcurrentHashMap<>(); + } + + @Override + protected Set provideZoneIds() { + return TimeZoneRegistry.ZONE_IDS.keySet(); + } + + @Override + protected ZoneRules provideRules(String zoneId, boolean forCaching) { + ZoneRules retVal = null; + if (zoneRulesMap.containsKey(zoneId)) { + retVal = zoneRulesMap.get(zoneId); + } else { + try { + String localZoneId = TimeZoneRegistry.ZONE_IDS.get(zoneId); + VTimeZone vTimeZone = zoneLoader.loadVTimeZone(localZoneId); + retVal = new ZoneRulesBuilder().vTimeZone(vTimeZone).build(); + zoneRulesMap.put(zoneId, retVal); + } catch (IOException | ParserException | ParseException e) { + LOG.error("Error loading zone rules", e); + } + } + return retVal; + } + + @Override + protected NavigableMap provideVersions(String zoneId) { + NavigableMap retVal = new TreeMap<>(); + if (zoneRulesMap.containsKey(zoneId)) { + retVal.put(zoneId, zoneRulesMap.get(zoneId)); + } + return retVal; + } + + @Override + protected boolean provideRefresh() { + return super.provideRefresh(); + } +} diff --git a/src/main/java/net/fortuna/ical4j/model/TimeZone.java b/src/main/java/net/fortuna/ical4j/model/TimeZone.java index 47fb5c9e7..f4a7f6aa5 100644 --- a/src/main/java/net/fortuna/ical4j/model/TimeZone.java +++ b/src/main/java/net/fortuna/ical4j/model/TimeZone.java @@ -89,7 +89,8 @@ public final int getOffset(final int era, final int year, final int month, final final int second = ms / 1000; ms -= second * 1000; - OffsetDateTime date = OffsetDateTime.of(year, month, dayOfMonth, hour, minute, second, ms * 1000, ZoneOffset.UTC); + // convert zero-based month of old API to new API by adding 1.. + OffsetDateTime date = OffsetDateTime.of(year, month + 1, dayOfMonth, hour, minute, second, ms * 1000, ZoneOffset.ofTotalSeconds(getRawOffset() / 1000)); final Observance observance = vTimeZone.getApplicableObservance(date); if (observance != null) { final TzOffsetTo offset = observance.getProperty(Property.TZOFFSETTO); diff --git a/src/main/java/net/fortuna/ical4j/model/TimeZoneLoader.java b/src/main/java/net/fortuna/ical4j/model/TimeZoneLoader.java index 63947435b..0f8c114db 100644 --- a/src/main/java/net/fortuna/ical4j/model/TimeZoneLoader.java +++ b/src/main/java/net/fortuna/ical4j/model/TimeZoneLoader.java @@ -13,8 +13,10 @@ import org.apache.commons.lang3.Validate; import org.slf4j.LoggerFactory; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URL; @@ -46,14 +48,11 @@ public class TimeZoneLoader { private static final String MESSAGE_MISSING_DEFAULT_TZ_CACHE_IMPL = "Error loading default cache implementation. Please ensure the JCache API dependency is included in the classpath, or override the cache implementation (e.g. via configuration: net.fortuna.ical4j.timezone.cache.impl=net.fortuna.ical4j.util.MapTimeZoneCache)"; private static Proxy proxy = null; - private static final Set TIMEZONE_DEFINITIONS = new HashSet(); private static final String DATE_TIME_TPL = "yyyyMMdd'T'HHmmss"; private static final String RRULE_TPL = "FREQ=YEARLY;BYMONTH=%d;BYDAY=%d%s"; private static final Standard NO_TRANSITIONS; static { - TIMEZONE_DEFINITIONS.addAll(Arrays.asList(net.fortuna.ical4j.model.TimeZone.getAvailableIDs())); - NO_TRANSITIONS = new Standard(); TzOffsetFrom offsetFrom = new TzOffsetFrom(ZoneOffset.UTC); TzOffsetTo offsetTo = new TzOffsetTo(ZoneOffset.UTC); @@ -90,6 +89,12 @@ public TimeZoneLoader(String resourcePrefix, TimeZoneCache cache) { this.cache = cache; } + public String[] getAvailableIDs() { + return new BufferedReader(new InputStreamReader( + ResourceLoader.getResourceAsStream("net/fortuna/ical4j/model/tz.availableIds"))) + .lines().toArray(String[]::new); + } + /** * Loads an existing VTimeZone from the classpath corresponding to the specified Java timezone. * @@ -153,7 +158,7 @@ private VTimeZone updateDefinition(VTimeZone vTimeZone) throws IOException, Pars } private static VTimeZone generateTimezoneForId(String timezoneId) throws ParseException { - if (!TIMEZONE_DEFINITIONS.contains(timezoneId)) { + if (!ZoneId.getAvailableZoneIds().contains(timezoneId)) { return null; } TimeZone javaTz = TimeZone.getTimeZone(timezoneId); diff --git a/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistry.java b/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistry.java index 8911dc907..116785e76 100644 --- a/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistry.java +++ b/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistry.java @@ -31,6 +31,16 @@ */ package net.fortuna.ical4j.model; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.zone.ZoneRules; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static java.time.ZoneId.getAvailableZoneIds; +import static java.time.ZoneId.of; + /** * $Id$ * @@ -42,6 +52,18 @@ */ public interface TimeZoneRegistry { + Map ZONE_IDS = new ConcurrentHashMap<>(); + + Map ZONE_ALIASES = new ConcurrentHashMap<>(); + + static ZoneId getGlobalZoneId(String tzId) { + // Ensure zone rules are loaded.. + Set ids = getAvailableZoneIds(); + return of(ZONE_IDS.entrySet().stream().filter(entry -> entry.getValue().equals(tzId)) + .findFirst().orElseThrow(() -> new DateTimeException(String.format("Unknown timezone identifier [%s]", tzId))).getKey(), + ZONE_ALIASES); + } + /** * Registers a new timezone for use with iCalendar objects. If a timezone * with the same identifier is already registered this timezone will take @@ -74,4 +96,10 @@ public interface TimeZoneRegistry { * is registered with the specified identifier null is returned. */ TimeZone getTimeZone(final String id); + + Map getZoneRules(); + + ZoneId getZoneId(String tzId); + + String getTzId(String zoneId); } diff --git a/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistryImpl.java b/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistryImpl.java index ac593b5b6..48f32d05a 100644 --- a/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistryImpl.java +++ b/src/main/java/net/fortuna/ical4j/model/TimeZoneRegistryImpl.java @@ -42,8 +42,13 @@ import java.io.IOException; import java.io.InputStream; import java.text.ParseException; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.zone.ZoneRules; +import java.util.HashMap; import java.util.Map; import java.util.Properties; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -64,50 +69,38 @@ public class TimeZoneRegistryImpl implements TimeZoneRegistry { private static final Pattern TZ_ID_SUFFIX = Pattern.compile("(?<=/)[^/]*/[^/]*$"); - private static final Map DEFAULT_TIMEZONES = new ConcurrentHashMap(); + private static final Map DEFAULT_TIMEZONES = new ConcurrentHashMap<>(); private static final Properties ALIASES = new Properties(); static { - InputStream aliasInputStream = null; - try { - aliasInputStream = ResourceLoader.getResourceAsStream("net/fortuna/ical4j/model/tz.alias"); + try (InputStream aliasInputStream = ResourceLoader.getResourceAsStream("net/fortuna/ical4j/model/tz.alias")) { ALIASES.load(aliasInputStream); } catch (IOException ioe) { LoggerFactory.getLogger(TimeZoneRegistryImpl.class).warn( "Error loading timezone aliases: " + ioe.getMessage()); - } finally { - if (aliasInputStream != null) { - try { - aliasInputStream.close(); - } catch (IOException e) { - LoggerFactory.getLogger(TimeZoneRegistryImpl.class).warn( - "Error closing resource stream: " + e.getMessage()); - } - } } - try { - aliasInputStream = ResourceLoader.getResourceAsStream("tz.alias"); + try (InputStream aliasInputStream = ResourceLoader.getResourceAsStream("tz.alias")) { ALIASES.load(aliasInputStream); } catch (IOException | NullPointerException e) { LoggerFactory.getLogger(TimeZoneRegistryImpl.class).debug( "Error loading custom timezone aliases: " + e.getMessage()); - } finally { - if (aliasInputStream != null) { - try { - aliasInputStream.close(); - } catch (IOException e) { - LoggerFactory.getLogger(TimeZoneRegistryImpl.class).warn( - "Error closing resource stream: " + e.getMessage()); - } - } } + + for (String alias : ALIASES.stringPropertyNames()) { + TimeZoneRegistry.ZONE_ALIASES.put(alias, ALIASES.getProperty(alias)); + } + } private final TimeZoneLoader timeZoneLoader; - private Map timezones; + private final Map timezones; + + private final Map zoneRules; + + private final Map zoneIds; /** * Default constructor. @@ -123,7 +116,9 @@ public TimeZoneRegistryImpl() { */ public TimeZoneRegistryImpl(final String resourcePrefix) { this.timeZoneLoader = new TimeZoneLoader(resourcePrefix); - timezones = new ConcurrentHashMap(); + timezones = new ConcurrentHashMap<>(); + zoneRules = new ConcurrentHashMap<>(); + zoneIds = new HashMap<>(); } /** @@ -149,6 +144,10 @@ public final void register(final TimeZone timezone, boolean update) { } else { timezones.put(timezone.getID(), timezone); } + + String globalId = "ical4j~" + UUID.randomUUID().toString(); + zoneIds.put(globalId, timezone.getID()); + zoneRules.put(globalId, new ZoneRulesBuilder().vTimeZone(timezones.get(timezone.getID()).getVTimeZone()).build()); } /** @@ -202,4 +201,21 @@ public final TimeZone getTimeZone(final String id) { } return timezone; } + + @Override + public Map getZoneRules() { + return zoneRules; + } + + @Override + public ZoneId getZoneId(String tzId) { + return ZoneId.of(zoneIds.entrySet().stream().filter(entry -> entry.getValue().equals(tzId)) + .findFirst().orElseThrow(() -> new DateTimeException(String.format("Unknown timezone identifier [%s]", tzId))).getKey(), + TimeZoneRegistry.ZONE_ALIASES); + } + + @Override + public String getTzId(String zoneId) { + return zoneIds.get(zoneId); + } } diff --git a/src/main/java/net/fortuna/ical4j/model/ZoneRulesBuilder.java b/src/main/java/net/fortuna/ical4j/model/ZoneRulesBuilder.java new file mode 100644 index 000000000..df4159cf4 --- /dev/null +++ b/src/main/java/net/fortuna/ical4j/model/ZoneRulesBuilder.java @@ -0,0 +1,75 @@ +package net.fortuna.ical4j.model; + +import net.fortuna.ical4j.model.component.Observance; +import net.fortuna.ical4j.model.component.VTimeZone; +import net.fortuna.ical4j.model.property.RRule; + +import java.time.*; +import java.time.zone.ZoneOffsetTransition; +import java.time.zone.ZoneOffsetTransitionRule; +import java.time.zone.ZoneOffsetTransitionRule.TimeDefinition; +import java.time.zone.ZoneRules; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Construct a {@link java.time.zone.ZoneRules} instance from a {@link net.fortuna.ical4j.model.component.VTimeZone}. + */ +public class ZoneRulesBuilder { + + private VTimeZone vTimeZone; + + public ZoneRulesBuilder vTimeZone(VTimeZone vTimeZone) { + this.vTimeZone = vTimeZone; + return this; + } + + private List buildTransitions(List observances) { + List transitions = new ArrayList<>(); + for (Observance observance : observances) { + // ignore transitions that have no effect.. + if (!observance.getOffsetFrom().getOffset().equals(observance.getOffsetTo().getOffset())) { + transitions.add(ZoneOffsetTransition.of(observance.getStartDate().getDate(), + observance.getOffsetFrom().getOffset(), observance.getOffsetTo().getOffset())); + } + } + return transitions; + } + + private List buildTransitionRules(List observances, ZoneOffset standardOffset) { + List transitionRules = new ArrayList<>(); + for (Observance observance : observances) { + RRule rrule = observance.getProperty(Property.RRULE); + if (rrule != null) { + Month recurMonth = Month.of(rrule.getRecur().getMonthList().get(0)); + int dayOfMonth = rrule.getRecur().getDayList().get(0).getOffset(); + DayOfWeek dayOfWeek = WeekDay.getDayOfWeek(rrule.getRecur().getDayList().get(0)); + LocalTime time = LocalTime.from(observance.getStartDate().getDate()); + boolean endOfDay = false; + TimeDefinition timeDefinition = TimeDefinition.UTC; + transitionRules.add(ZoneOffsetTransitionRule.of(recurMonth, dayOfMonth, dayOfWeek, time, endOfDay, + timeDefinition, standardOffset, observance.getOffsetFrom().getOffset(), + observance.getOffsetTo().getOffset())); + } + } + return transitionRules; + } + + public ZoneRules build() { + Observance current = vTimeZone.getApplicableObservance(Instant.now(), + vTimeZone.getObservances().getComponents(Observance.STANDARD)); + ZoneOffset standardOffset = current.getOffsetTo().getOffset(); + ZoneOffset wallOffset = current.getOffsetFrom().getOffset(); + List standardOffsetTransitions = buildTransitions( + vTimeZone.getObservances().getComponents(Observance.STANDARD)); + Collections.sort(standardOffsetTransitions); + List offsetTransitions = buildTransitions( + vTimeZone.getObservances().getComponents(Observance.DAYLIGHT)); + Collections.sort(offsetTransitions); + List transitionRules = buildTransitionRules( + vTimeZone.getObservances(), standardOffset); + + return ZoneRules.of(standardOffset, wallOffset, standardOffsetTransitions, offsetTransitions, transitionRules); + } +} diff --git a/src/main/java/net/fortuna/ical4j/model/ZoneRulesProviderImpl.java b/src/main/java/net/fortuna/ical4j/model/ZoneRulesProviderImpl.java new file mode 100644 index 000000000..bdb51824f --- /dev/null +++ b/src/main/java/net/fortuna/ical4j/model/ZoneRulesProviderImpl.java @@ -0,0 +1,47 @@ +package net.fortuna.ical4j.model; + +import java.time.zone.ZoneRules; +import java.time.zone.ZoneRulesProvider; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; + +public class ZoneRulesProviderImpl extends ZoneRulesProvider { + + private final TimeZoneRegistry timeZoneRegistry; + + public ZoneRulesProviderImpl(TimeZoneRegistry timeZoneRegistry) { + Objects.requireNonNull(timeZoneRegistry, "timeZoneRegistry"); + this.timeZoneRegistry = timeZoneRegistry; + } + + @Override + protected Set provideZoneIds() { + return timeZoneRegistry.getZoneRules().keySet(); + } + + @Override + protected ZoneRules provideRules(String zoneId, boolean forCaching) { + ZoneRules retVal = null; + // don't allow caching of rules due to potential for dynamically loaded definitions.. + if (timeZoneRegistry.getZoneRules().containsKey(zoneId)) { + retVal = timeZoneRegistry.getZoneRules().get(zoneId); + } + return retVal; + } + + @Override + protected NavigableMap provideVersions(String zoneId) { + NavigableMap retVal = new TreeMap<>(); + if (timeZoneRegistry.getZoneRules().containsKey(zoneId)) { + retVal.put(zoneId, timeZoneRegistry.getZoneRules().get(zoneId)); + } + return retVal; + } + + @Override + protected boolean provideRefresh() { + return super.provideRefresh(); + } +} diff --git a/src/main/resources/META-INF/services/java.time.zone.ZoneRulesProvider b/src/main/resources/META-INF/services/java.time.zone.ZoneRulesProvider new file mode 100644 index 000000000..a25ffe11c --- /dev/null +++ b/src/main/resources/META-INF/services/java.time.zone.ZoneRulesProvider @@ -0,0 +1 @@ +net.fortuna.ical4j.model.DefaultZoneRulesProvider diff --git a/src/main/resources/net/fortuna/ical4j/model/tz.availableIds b/src/main/resources/net/fortuna/ical4j/model/tz.availableIds new file mode 100644 index 000000000..42bf7fef9 --- /dev/null +++ b/src/main/resources/net/fortuna/ical4j/model/tz.availableIds @@ -0,0 +1,461 @@ +Africa/Abidjan +Africa/Accra +Africa/Addis_Ababa +Africa/Algiers +Africa/Asmara +Africa/Bamako +Africa/Bangui +Africa/Banjul +Africa/Bissau +Africa/Blantyre +Africa/Brazzaville +Africa/Bujumbura +Africa/Cairo +Africa/Casablanca +Africa/Ceuta +Africa/Conakry +Africa/Dakar +Africa/Dar_es_Salaam +Africa/Djibouti +Africa/Douala +Africa/El_Aaiun +Africa/Freetown +Africa/Gaborone +Africa/Harare +Africa/Johannesburg +Africa/Juba +Africa/Kampala +Africa/Khartoum +Africa/Kigali +Africa/Kinshasa +Africa/Lagos +Africa/Libreville +Africa/Lome +Africa/Luanda +Africa/Lubumbashi +Africa/Lusaka +Africa/Malabo +Africa/Maputo +Africa/Maseru +Africa/Mbabane +Africa/Mogadishu +Africa/Monrovia +Africa/Nairobi +Africa/Ndjamena +Africa/Niamey +Africa/Nouakchott +Africa/Ouagadougou +Africa/Porto-Novo +Africa/Sao_Tome +Africa/Tripoli +Africa/Tunis +Africa/Windhoek +America/Adak +America/Anchorage +America/Anguilla +America/Antigua +America/Araguaina +America/Argentina/Buenos_Aires +America/Argentina/Catamarca +America/Argentina/Cordoba +America/Argentina/Jujuy +America/Argentina/La_Rioja +America/Argentina/Mendoza +America/Argentina/Rio_Gallegos +America/Argentina/Salta +America/Argentina/San_Juan +America/Argentina/San_Luis +America/Argentina/Tucuman +America/Argentina/Ushuaia +America/Aruba +America/Asuncion +America/Atikokan +America/Bahia +America/Bahia_Banderas +America/Barbados +America/Belem +America/Belize +America/Blanc-Sablon +America/Boa_Vista +America/Bogota +America/Boise +America/Cambridge_Bay +America/Campo_Grande +America/Cancun +America/Caracas +America/Cayenne +America/Cayman +America/Chicago +America/Chihuahua +America/Costa_Rica +America/Creston +America/Cuiaba +America/Curacao +America/Danmarkshavn +America/Dawson +America/Dawson_Creek +America/Denver +America/Detroit +America/Dominica +America/Edmonton +America/Eirunepe +America/El_Salvador +America/Fort_Nelson +America/Fortaleza +America/Glace_Bay +America/Godthab +America/Goose_Bay +America/Grand_Turk +America/Grenada +America/Guadeloupe +America/Guatemala +America/Guayaquil +America/Guyana +America/Halifax +America/Havana +America/Hermosillo +America/Indiana/Indianapolis +America/Indiana/Knox +America/Indiana/Marengo +America/Indiana/Petersburg +America/Indiana/Tell_City +America/Indiana/Vevay +America/Indiana/Vincennes +America/Indiana/Winamac +America/Inuvik +America/Iqaluit +America/Jamaica +America/Juneau +America/Kentucky/Louisville +America/Kentucky/Monticello +America/Kralendijk +America/La_Paz +America/Lima +America/Los_Angeles +America/Lower_Princes +America/Maceio +America/Managua +America/Manaus +America/Marigot +America/Martinique +America/Matamoros +America/Mazatlan +America/Menominee +America/Merida +America/Metlakatla +America/Mexico_City +America/Miquelon +America/Moncton +America/Monterrey +America/Montevideo +America/Montserrat +America/Nassau +America/New_York +America/Nipigon +America/Nome +America/Noronha +America/North_Dakota/Beulah +America/North_Dakota/Center +America/North_Dakota/New_Salem +America/Ojinaga +America/Panama +America/Pangnirtung +America/Paramaribo +America/Phoenix +America/Port-au-Prince +America/Port_of_Spain +America/Porto_Velho +America/Puerto_Rico +America/Punta_Arenas +America/Rainy_River +America/Rankin_Inlet +America/Recife +America/Regina +America/Resolute +America/Rio_Branco +America/Santarem +America/Santiago +America/Santo_Domingo +America/Sao_Paulo +America/Scoresbysund +America/Sitka +America/St_Barthelemy +America/St_Johns +America/St_Kitts +America/St_Lucia +America/St_Thomas +America/St_Vincent +America/Swift_Current +America/Tegucigalpa +America/Thule +America/Thunder_Bay +America/Tijuana +America/Toronto +America/Tortola +America/Vancouver +America/Whitehorse +America/Winnipeg +America/Yakutat +America/Yellowknife +Antarctica/Casey +Antarctica/Davis +Antarctica/DumontDUrville +Antarctica/Macquarie +Antarctica/Mawson +Antarctica/McMurdo +Antarctica/Palmer +Antarctica/Rothera +Antarctica/Syowa +Antarctica/Troll +Antarctica/Vostok +Arctic/Longyearbyen +Asia/Aden +Asia/Almaty +Asia/Amman +Asia/Anadyr +Asia/Aqtau +Asia/Aqtobe +Asia/Ashgabat +Asia/Atyrau +Asia/Baghdad +Asia/Bahrain +Asia/Baku +Asia/Bangkok +Asia/Barnaul +Asia/Beirut +Asia/Bishkek +Asia/Brunei +Asia/Chita +Asia/Choibalsan +Asia/Colombo +Asia/Damascus +Asia/Dhaka +Asia/Dili +Asia/Dubai +Asia/Dushanbe +Asia/Famagusta +Asia/Gaza +Asia/Hebron +Asia/Ho_Chi_Minh +Asia/Hong_Kong +Asia/Hovd +Asia/Irkutsk +Asia/Istanbul +Asia/Jakarta +Asia/Jayapura +Asia/Jerusalem +Asia/Kabul +Asia/Kamchatka +Asia/Karachi +Asia/Kathmandu +Asia/Khandyga +Asia/Kolkata +Asia/Krasnoyarsk +Asia/Kuala_Lumpur +Asia/Kuching +Asia/Kuwait +Asia/Macau +Asia/Magadan +Asia/Makassar +Asia/Manila +Asia/Muscat +Asia/Nicosia +Asia/Novokuznetsk +Asia/Novosibirsk +Asia/Omsk +Asia/Oral +Asia/Phnom_Penh +Asia/Pontianak +Asia/Pyongyang +Asia/Qatar +Asia/Qyzylorda +Asia/Riyadh +Asia/Sakhalin +Asia/Samarkand +Asia/Seoul +Asia/Shanghai +Asia/Singapore +Asia/Srednekolymsk +Asia/Taipei +Asia/Tashkent +Asia/Tbilisi +Asia/Tehran +Asia/Thimphu +Asia/Tokyo +Asia/Tomsk +Asia/Ulaanbaatar +Asia/Urumqi +Asia/Ust-Nera +Asia/Vientiane +Asia/Vladivostok +Asia/Yakutsk +Asia/Yangon +Asia/Yekaterinburg +Asia/Yerevan +Atlantic/Azores +Atlantic/Bermuda +Atlantic/Canary +Atlantic/Cape_Verde +Atlantic/Faroe +Atlantic/Madeira +Atlantic/Reykjavik +Atlantic/South_Georgia +Atlantic/St_Helena +Atlantic/Stanley +Australia/Adelaide +Australia/Brisbane +Australia/Broken_Hill +Australia/Currie +Australia/Darwin +Australia/Eucla +Australia/Hobart +Australia/Lindeman +Australia/Lord_Howe +Australia/Melbourne +Australia/Perth +Australia/Sydney +Etc/GMT+0 +Etc/GMT+1 +Etc/GMT+10 +Etc/GMT+11 +Etc/GMT+12 +Etc/GMT+2 +Etc/GMT+3 +Etc/GMT+4 +Etc/GMT+5 +Etc/GMT+6 +Etc/GMT+7 +Etc/GMT+8 +Etc/GMT+9 +Etc/GMT-0 +Etc/GMT-1 +Etc/GMT-10 +Etc/GMT-11 +Etc/GMT-12 +Etc/GMT-13 +Etc/GMT-14 +Etc/GMT-2 +Etc/GMT-3 +Etc/GMT-4 +Etc/GMT-5 +Etc/GMT-6 +Etc/GMT-7 +Etc/GMT-8 +Etc/GMT-9 +Etc/GMT +Etc/GMT0 +Etc/Greenwich +Etc/UCT +Etc/UTC +Etc/Universal +Etc/Zulu +Europe/Amsterdam +Europe/Andorra +Europe/Astrakhan +Europe/Athens +Europe/Belgrade +Europe/Berlin +Europe/Bratislava +Europe/Brussels +Europe/Bucharest +Europe/Budapest +Europe/Busingen +Europe/Chisinau +Europe/Copenhagen +Europe/Dublin +Europe/Gibraltar +Europe/Guernsey +Europe/Helsinki +Europe/Isle_of_Man +Europe/Istanbul +Europe/Jersey +Europe/Kaliningrad +Europe/Kiev +Europe/Kirov +Europe/Lisbon +Europe/Ljubljana +Europe/London +Europe/Luxembourg +Europe/Madrid +Europe/Malta +Europe/Mariehamn +Europe/Minsk +Europe/Monaco +Europe/Moscow +Europe/Nicosia +Europe/Oslo +Europe/Paris +Europe/Podgorica +Europe/Prague +Europe/Riga +Europe/Rome +Europe/Samara +Europe/San_Marino +Europe/Sarajevo +Europe/Saratov +Europe/Simferopol +Europe/Skopje +Europe/Sofia +Europe/Stockholm +Europe/Tallinn +Europe/Tirane +Europe/Ulyanovsk +Europe/Uzhgorod +Europe/Vaduz +Europe/Vatican +Europe/Vienna +Europe/Vilnius +Europe/Volgograd +Europe/Warsaw +Europe/Zagreb +Europe/Zaporozhye +Europe/Zurich +Indian/Antananarivo +Indian/Chagos +Indian/Christmas +Indian/Cocos +Indian/Comoro +Indian/Kerguelen +Indian/Mahe +Indian/Maldives +Indian/Mauritius +Indian/Mayotte +Indian/Reunion +Pacific/Apia +Pacific/Auckland +Pacific/Bougainville +Pacific/Chatham +Pacific/Chuuk +Pacific/Easter +Pacific/Efate +Pacific/Enderbury +Pacific/Fakaofo +Pacific/Fiji +Pacific/Funafuti +Pacific/Galapagos +Pacific/Gambier +Pacific/Guadalcanal +Pacific/Guam +Pacific/Honolulu +Pacific/Kiritimati +Pacific/Kosrae +Pacific/Kwajalein +Pacific/Majuro +Pacific/Marquesas +Pacific/Midway +Pacific/Nauru +Pacific/Niue +Pacific/Norfolk +Pacific/Noumea +Pacific/Pago_Pago +Pacific/Palau +Pacific/Pitcairn +Pacific/Pohnpei +Pacific/Port_Moresby +Pacific/Rarotonga +Pacific/Saipan +Pacific/Tahiti +Pacific/Tarawa +Pacific/Tongatapu +Pacific/Wake +Pacific/Wallis diff --git a/src/test/groovy/net/fortuna/ical4j/model/DefaultZoneRulesProviderTest.groovy b/src/test/groovy/net/fortuna/ical4j/model/DefaultZoneRulesProviderTest.groovy new file mode 100644 index 000000000..1ab4fc180 --- /dev/null +++ b/src/test/groovy/net/fortuna/ical4j/model/DefaultZoneRulesProviderTest.groovy @@ -0,0 +1,47 @@ +package net.fortuna.ical4j.model + +import spock.lang.Specification + +import java.time.ZoneId + +class DefaultZoneRulesProviderTest extends Specification { + + def 'asset new instance available ids'() { + given: 'a zone rules provider' + DefaultZoneRulesProvider provider = [] + + when: 'requesting available ids' + def availableIds = provider.provideZoneIds() + + then: 'a non empty set is returned' + !availableIds.isEmpty() + } + + def 'test get global zone id'() { + given: 'a zone rules provider' + ZoneId.availableZoneIds + + and: 'a local zone id' + def tzId = 'Australia/Melbourne' + + when: 'requesting the corresponding global id' + def globalId = TimeZoneRegistry.getGlobalZoneId(tzId) + + then: 'a valid id is returned' + TimeZoneRegistry.ZONE_IDS.get(globalId.id) == tzId + } + + def 'test get zone id'() { + given: 'a zone rules provider' + ZoneId.availableZoneIds + + and: 'a local zone id' + def tzId = 'Australia/Melbourne' + + when: 'requesting a zone id using the mapped global id' + def zoneId = TimeZoneRegistry.getGlobalZoneId(tzId) + + then: 'a valid zone id is returned' + zoneId != null + } +} diff --git a/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesBuilderTest.groovy b/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesBuilderTest.groovy new file mode 100644 index 000000000..75d5b113e --- /dev/null +++ b/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesBuilderTest.groovy @@ -0,0 +1,29 @@ +package net.fortuna.ical4j.model + +import spock.lang.Shared +import spock.lang.Specification + +import java.time.Instant +import java.time.ZoneOffset + +class ZoneRulesBuilderTest extends Specification { + + @Shared + TimeZoneRegistry timeZoneRegistry = TimeZoneRegistryFactory.instance.createRegistry() + + def 'test build rules'() { + expect: 'rule definitions match expected' + def zonerules = new ZoneRulesBuilder().vTimeZone(timeZoneRegistry.getTimeZone(tzId).getVTimeZone()).build() + zonerules.getOffset(Instant.now()) == expectedOffset + + Instant now = Instant.now(); + zonerules.isDaylightSavings(now) == java.util.TimeZone.getTimeZone(tzId).inDaylightTime(java.util.Date.from(now)); + + where: + tzId | expectedOffset + 'UTC' | ZoneOffset.UTC + 'Australia/Melbourne' | ZoneOffset.ofHours(10) + 'Europe/Lisbon' | ZoneOffset.ofHours(1) + 'America/Los_Angeles' | ZoneOffset.ofHours(-7) + } +} diff --git a/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesProviderImplTest.groovy b/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesProviderImplTest.groovy new file mode 100644 index 000000000..de9d5a83f --- /dev/null +++ b/src/test/groovy/net/fortuna/ical4j/model/ZoneRulesProviderImplTest.groovy @@ -0,0 +1,23 @@ +package net.fortuna.ical4j.model + + +import spock.lang.Specification + +import java.time.Instant +import java.time.ZoneOffset +import java.time.zone.ZoneRules + +class ZoneRulesProviderImplTest extends Specification { + + def 'verify zone rules'() { + given: 'a zone rules provider instance' + TimeZoneRegistry registry = TimeZoneRegistryFactory.instance.createRegistry() + ZoneRulesProviderImpl zoneRulesProvider = [registry] + + when: 'zone rules are requested' + ZoneRules zoneRules = zoneRulesProvider.provideRules('Australia/Melbourne', false) + + then: 'an appropriate rules instance is provided' + zoneRules.getStandardOffset(Instant.now()) == ZoneOffset.ofHours(10) + } +} diff --git a/src/test/groovy/net/fortuna/ical4j/model/property/TzIdSpec.groovy b/src/test/groovy/net/fortuna/ical4j/model/property/TzIdSpec.groovy new file mode 100644 index 000000000..f7905119f --- /dev/null +++ b/src/test/groovy/net/fortuna/ical4j/model/property/TzIdSpec.groovy @@ -0,0 +1,45 @@ +package net.fortuna.ical4j.model.property + +import net.fortuna.ical4j.model.ParameterList +import spock.lang.Specification + +import java.time.DateTimeException + +class TzIdSpec extends Specification { + + def 'test successfull instance creation'() { + expect: 'instance is created successfully' + new TzId.Factory().createProperty([] as ParameterList, tzIdString) as String == "TZID:$expectedString\r\n" + + where: + tzIdString | expectedString + 'Australia/Melbourne' | 'Australia/Melbourne' + 'UTC+10' | 'UTC+10:00' + } + + def 'test custom zone id instance creation'() { + given: 'custom zone ids are registered' + + expect: 'instance is created successfully' + new TzId.Factory().createProperty([] as ParameterList, tzIdString) as String == "TZID:$expectedString\r\n" + + where: + tzIdString | expectedString + '/tzurl.org/Australia/Melbourne' | '/tzurl.org/Australia/Melbourne' + 'Canberra, Melbourne, Sydney' | 'Canberra, Melbourne, Sydney' + } + + def 'test unsuccessfull instance creation'() { + when: 'attempt to create instance' + new TzId.Factory().createProperty([] as ParameterList, tzIdString) + + then: 'instance is not created successfully' + thrown(expectedException) + + where: + tzIdString | expectedException + 'Unknown' | DateTimeException + '/tzurl.org/Australia/Melbourne' | DateTimeException + 'Canberra, Melbourne, Sydney' | DateTimeException + } +} diff --git a/src/test/java/net/fortuna/ical4j/data/CalendarBuilderTimezoneTest.java b/src/test/java/net/fortuna/ical4j/data/CalendarBuilderTimezoneTest.java index 0a902fa85..33eb5a1d6 100755 --- a/src/test/java/net/fortuna/ical4j/data/CalendarBuilderTimezoneTest.java +++ b/src/test/java/net/fortuna/ical4j/data/CalendarBuilderTimezoneTest.java @@ -31,18 +31,18 @@ */ package net.fortuna.ical4j.data; -import java.io.InputStream; - import junit.framework.TestCase; import net.fortuna.ical4j.model.Calendar; import net.fortuna.ical4j.model.Component; import net.fortuna.ical4j.model.ComponentList; -import net.fortuna.ical4j.model.DateTime; import net.fortuna.ical4j.model.component.CalendarComponent; import net.fortuna.ical4j.model.component.VEvent; import net.fortuna.ical4j.model.property.DtStart; import net.fortuna.ical4j.util.CompatibilityHints; +import java.io.InputStream; +import java.time.ZonedDateTime; + /** * $Id: CalendarBuilderTimezoneTest.java [Jul 1, 2008] * @@ -96,14 +96,13 @@ public void testVTimeZoneAfterVEvent() throws Exception { VEvent vevent = (VEvent) comps.get(0); DtStart dtstart = vevent.getStartDate(); - DateTime dateTime = (DateTime) dtstart.getDate(); + ZonedDateTime dateTime = (ZonedDateTime) dtstart.getDate(); assertEquals("date value not correct", "20080624T130000", dtstart .getValue()); - assertNotNull("timezone not present", dateTime.getTimeZone()); + assertNotNull("timezone not present", dateTime.getZone()); assertEquals("timezone not correct", - "/softwarestudio.org/Tzfile/America/Chicago", dateTime - .getTimeZone().getID()); + "/softwarestudio.org/Tzfile/America/Chicago", builder.getRegistry().getTzId(dateTime.getZone().getId())); } } diff --git a/src/test/java/net/fortuna/ical4j/model/property/DateListPropertyTest.java b/src/test/java/net/fortuna/ical4j/model/property/DateListPropertyTest.java index 07680b7e6..0f70febb2 100644 --- a/src/test/java/net/fortuna/ical4j/model/property/DateListPropertyTest.java +++ b/src/test/java/net/fortuna/ical4j/model/property/DateListPropertyTest.java @@ -31,16 +31,15 @@ */ package net.fortuna.ical4j.model.property; -import java.io.IOException; -import java.net.URISyntaxException; -import java.text.ParseException; - import junit.framework.TestSuite; -import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory; import net.fortuna.ical4j.model.Property; import net.fortuna.ical4j.model.PropertyTest; import net.fortuna.ical4j.model.TimeZoneRegistry; +import java.io.IOException; +import java.net.URISyntaxException; +import java.text.ParseException; + /** * $Id$ * @@ -89,16 +88,13 @@ public void testCopy() throws IOException, URISyntaxException, * @return */ public static TestSuite suite() throws ParseException { - TimeZoneRegistry tzReg = DefaultTimeZoneRegistryFactory.getInstance() - .createRegistry(); - TestSuite suite = new TestSuite(); ExDate exZulu = new ExDate(); exZulu.setValue("20111212T000000Z"); suite.addTest(new DateListPropertyTest("testCopy", exZulu)); ExDate exMelbourne = new ExDate(); - exMelbourne.setTimeZone(tzReg.getTimeZone("Australia/Melbourne")); + exMelbourne.setTimeZone(TimeZoneRegistry.getGlobalZoneId("Australia/Melbourne")); exMelbourne.setValue("20111212T000000"); suite.addTest(new DateListPropertyTest("testCopy", exMelbourne)); diff --git a/src/test/java/net/fortuna/ical4j/model/property/DatePropertyTest.java b/src/test/java/net/fortuna/ical4j/model/property/DatePropertyTest.java index 709add1af..4cb448aa6 100644 --- a/src/test/java/net/fortuna/ical4j/model/property/DatePropertyTest.java +++ b/src/test/java/net/fortuna/ical4j/model/property/DatePropertyTest.java @@ -32,16 +32,18 @@ package net.fortuna.ical4j.model.property; import junit.framework.TestSuite; -import net.fortuna.ical4j.model.Date; -import net.fortuna.ical4j.model.DateTime; -import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory; +import net.fortuna.ical4j.model.ParameterList; import net.fortuna.ical4j.model.Property; import net.fortuna.ical4j.model.PropertyTest; import net.fortuna.ical4j.model.TimeZoneRegistry; +import net.fortuna.ical4j.model.parameter.TzId; import java.io.IOException; import java.net.URISyntaxException; import java.text.ParseException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; /** * $Id$ @@ -79,16 +81,10 @@ public void testCopy() throws IOException, URISyntaxException, ParseException { Property copy = property.copy(); assertEquals(property, copy); - if (property.getTimeZone() != null) { - assertEquals(property.getTimeZone(), ((DateProperty) copy).getTimeZone()); - } - else { - assertNull(((DateProperty) copy).getTimeZone()); - } } - public void testHashValue() throws Exception { - Date date = property.getDate(); + public void testHashValue() { + Temporal date = property.getDate(); if (date != null) { assertEquals(date.hashCode(), property.hashCode()); } else { @@ -101,9 +97,6 @@ public void testHashValue() throws Exception { * @return */ public static TestSuite suite() { - TimeZoneRegistry tzReg = DefaultTimeZoneRegistryFactory.getInstance() - .createRegistry(); - TestSuite suite = new TestSuite(); DtStamp dtStamp = new DtStamp(); // dtStamp.getParameters().add(new TzId("Australia/Melbourne")); @@ -111,9 +104,11 @@ public static TestSuite suite() { suite.addTest(new DatePropertyTest("testCopy", dtStamp)); suite.addTest(new DatePropertyTest("testHashValue", dtStamp)); - DtStart dtStart = new DtStart(new DateTime()); + ParameterList tzParams = new ParameterList(); + tzParams.add(new TzId(ZoneId.of("Australia/Melbourne").getId())); + DtStart dtStart = new DtStart<>(tzParams, + ZonedDateTime.now(TimeZoneRegistry.getGlobalZoneId("Australia/Melbourne"))); // dtStart.getParameters().add(new TzId("Australia/Melbourne")); - dtStart.setTimeZone(tzReg.getTimeZone("Australia/Melbourne")); suite.addTest(new DatePropertyTest("testCopy", dtStart)); suite.addTest(new DatePropertyTest("testHashValue", dtStart));