diff --git a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/EvaluationSettings.java b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/EvaluationSettings.java index aabbae84b..39dcf2ce0 100644 --- a/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/EvaluationSettings.java +++ b/cqf-fhir-cql/src/main/java/org/opencds/cqf/fhir/cql/EvaluationSettings.java @@ -1,7 +1,11 @@ package org.opencds.cqf.fhir.cql; +import jakarta.annotation.Nonnull; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.cqframework.cql.cql2elm.model.CompiledLibrary; import org.cqframework.cql.cql2elm.model.Model; @@ -21,6 +25,7 @@ public class EvaluationSettings { private RetrieveSettings retrieveSettings; private TerminologySettings terminologySettings; + private ZoneId clientTimezone; public static EvaluationSettings getDefault() { EvaluationSettings settings = new EvaluationSettings(); @@ -83,8 +88,9 @@ public EvaluationSettings withCqlOptions(CqlOptions cqlOptions) { return this; } - public void setCqlOptions(CqlOptions cqlOptions) { + public EvaluationSettings setCqlOptions(CqlOptions cqlOptions) { this.cqlOptions = cqlOptions; + return this; } public RetrieveSettings getRetrieveSettings() { @@ -109,7 +115,18 @@ public EvaluationSettings withTerminologySettings(TerminologySettings terminolog return this; } - public void setTerminologySettings(TerminologySettings terminologySettings) { + public EvaluationSettings setTerminologySettings(TerminologySettings terminologySettings) { this.terminologySettings = terminologySettings; + return this; + } + + @Nonnull + public ZoneId getClientTimezone() { + return Optional.ofNullable(clientTimezone).orElse(ZoneOffset.UTC); + } + + public EvaluationSettings setClientTimezone(ZoneId clientTimezone) { + this.clientTimezone = clientTimezone; + return this; } } diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/MeasureEvaluationOptions.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/MeasureEvaluationOptions.java index e73ca6266..6ac6eebb7 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/MeasureEvaluationOptions.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/MeasureEvaluationOptions.java @@ -21,20 +21,23 @@ public boolean isValidationEnabled() { return this.isValidationEnabled; } - public void setValidationEnabled(boolean enableValidation) { + public MeasureEvaluationOptions setValidationEnabled(boolean enableValidation) { this.isValidationEnabled = enableValidation; + return this; } public Map getValidationProfiles() { return validationProfiles; } - public void setValidationProfiles(Map validationProfiles) { + public MeasureEvaluationOptions setValidationProfiles(Map validationProfiles) { this.validationProfiles = validationProfiles; + return this; } - public void setEvaluationSettings(EvaluationSettings evaluationSettings) { + public MeasureEvaluationOptions setEvaluationSettings(EvaluationSettings evaluationSettings) { this.evaluationSettings = evaluationSettings; + return this; } public EvaluationSettings getEvaluationSettings() { diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureProcessor.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureProcessor.java index 51b39f8a4..bc01522b0 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureProcessor.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/dstu3/Dstu3MeasureProcessor.java @@ -30,7 +30,7 @@ import org.opencds.cqf.fhir.cr.measure.common.MeasureEvalType; import org.opencds.cqf.fhir.cr.measure.common.MeasureReportType; import org.opencds.cqf.fhir.cr.measure.common.SubjectProvider; -import org.opencds.cqf.fhir.cr.measure.helper.DateHelper; +import org.opencds.cqf.fhir.cr.measure.helper.IntervalHelper; import org.opencds.cqf.fhir.utility.repository.FederatedRepository; import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository; @@ -83,7 +83,10 @@ protected MeasureReport evaluateMeasure( Interval measurementPeriod = null; if (StringUtils.isNotBlank(periodStart) && StringUtils.isNotBlank(periodEnd)) { - measurementPeriod = this.buildMeasurementPeriod(periodStart, periodEnd); + measurementPeriod = IntervalHelper.buildMeasurementPeriod( + periodStart, + periodEnd, + measureEvaluationOptions.getEvaluationSettings().getClientTimezone()); } var reference = measure.getLibrary().get(0); @@ -172,15 +175,6 @@ protected MeasureReportType evalTypeToReportType(MeasureEvalType measureEvalType } } - private Interval buildMeasurementPeriod(String periodStart, String periodEnd) { - // resolve the measurement period - return new Interval( - DateHelper.resolveRequestDate(periodStart, true), - true, - DateHelper.resolveRequestDate(periodEnd, false), - true); - } - private Map resolveParameterMap(Parameters parameters) { Map parameterMap = new HashMap<>(); Dstu3FhirModelResolver modelResolver = new Dstu3FhirModelResolver(); diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelper.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelper.java index bec6400be..ee8704b0f 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelper.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelper.java @@ -9,16 +9,18 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import java.util.Objects; import java.util.TimeZone; import org.opencds.cqf.cql.engine.runtime.DateTime; /** * Helper class to resolve measurement period start and end dates. If a timezone * is specified in a - * datetime, it's used. If not the timezone of the local system is used. + * datetime, it's used. If not the timezone specified in the ZoneId parameter is used. */ public class DateHelper { - public static DateTime resolveRequestDate(String date, boolean start) { + public static DateTime resolveRequestDate(String date, boolean start, ZoneId fallbackTimezone) { + Objects.requireNonNull(fallbackTimezone); // ISO Instance Format if (date.contains("Z")) { var offset = Instant.parse(date).atOffset(ZoneOffset.UTC); @@ -35,15 +37,15 @@ public static DateTime resolveRequestDate(String date, boolean start) { // Local DateTime if (date.contains("T")) { var offset = LocalDateTime.parse(date, DateTimeFormatter.ISO_LOCAL_DATE_TIME) - .atZone(ZoneId.systemDefault()) + .atZone(fallbackTimezone) .toOffsetDateTime(); return new DateTime(offset); } - return resolveDate(start, date); + return resolveDate(start, date, fallbackTimezone); } - private static DateTime resolveDate(boolean start, String dateString) { + private static DateTime resolveDate(boolean start, String dateString, ZoneId fallbackTimezone) { Calendar calendar = Calendar.getInstance(); calendar.clear(); @@ -57,7 +59,7 @@ private static DateTime resolveDate(boolean start, String dateString) { throw new IllegalArgumentException("Invalid date"); } - calendar.setTimeZone(TimeZone.getDefault()); + calendar.setTimeZone(TimeZone.getTimeZone(fallbackTimezone)); // Set year calendar.set(Calendar.YEAR, dateVals.get(0)); @@ -102,7 +104,7 @@ private static DateTime resolveDate(boolean start, String dateString) { } // TODO: Seems like we might want set the precision appropriately here? - var offset = calendar.toInstant().atZone(ZoneId.systemDefault()).toOffsetDateTime(); + var offset = calendar.toInstant().atZone(fallbackTimezone).toOffsetDateTime(); return new DateTime(offset); } } diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelper.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelper.java new file mode 100644 index 000000000..f92232faa --- /dev/null +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelper.java @@ -0,0 +1,20 @@ +package org.opencds.cqf.fhir.cr.measure.helper; + +import java.time.ZoneId; +import org.opencds.cqf.cql.engine.runtime.Interval; + +/** + * Helper class that leverages {@link DateHelper} to resolve measurement period start and end dates. + * If a timezone is specified in a datetime, it's used. If not the timezone specified in the ZoneId + * parameter is used.. + */ +public class IntervalHelper { + public static Interval buildMeasurementPeriod(String periodStart, String periodEnd, ZoneId clientTimezone) { + // resolve the measurement period + return new Interval( + DateHelper.resolveRequestDate(periodStart, true, clientTimezone), + true, + DateHelper.resolveRequestDate(periodEnd, false, clientTimezone), + true); + } +} diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4DataRequirementsService.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4DataRequirementsService.java index 56ccf055d..89c9833ea 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4DataRequirementsService.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4DataRequirementsService.java @@ -48,7 +48,7 @@ import org.opencds.cqf.fhir.cql.cql2elm.util.LibraryVersionSelector; import org.opencds.cqf.fhir.cql.engine.terminology.RepositoryTerminologyProvider; import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; -import org.opencds.cqf.fhir.cr.measure.helper.DateHelper; +import org.opencds.cqf.fhir.cr.measure.helper.IntervalHelper; import org.opencds.cqf.fhir.cr.measure.helper.SubjectContext; import org.opencds.cqf.fhir.utility.Canonicals; import org.opencds.cqf.fhir.utility.Canonicals.CanonicalParts; @@ -85,11 +85,10 @@ public Library dataRequirements(IdType measureId, String periodStart, String per Interval measurementPeriod; if (StringUtils.isNotBlank(periodStart) && StringUtils.isNotBlank(periodEnd)) { - measurementPeriod = new Interval( - DateHelper.resolveRequestDate(periodStart, true), - true, - DateHelper.resolveRequestDate(periodEnd, false), - true); + measurementPeriod = IntervalHelper.buildMeasurementPeriod( + periodStart, + periodEnd, + measureEvaluationOptions.getEvaluationSettings().getClientTimezone()); parameters.put("MeasurementPeriod", measurementPeriod); return processDataRequirements(measure, library, parameters); diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessor.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessor.java index 3039b2a35..7bb25c0dd 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessor.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessor.java @@ -110,7 +110,10 @@ protected MeasureReport evaluateMeasure( Interval measurementPeriod = null; if (StringUtils.isNotBlank(periodStart) && StringUtils.isNotBlank(periodEnd)) { var helper = new R4DateHelper(); - measurementPeriod = helper.buildMeasurementPeriodInterval(periodStart, periodEnd); + measurementPeriod = helper.buildMeasurementPeriodInterval( + periodStart, + periodEnd, + measureEvaluationOptions.getEvaluationSettings().getClientTimezone()); } var url = measure.getLibrary().get(0).asStringValue(); diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4DateHelper.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4DateHelper.java index 591748471..76c898b43 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4DateHelper.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4DateHelper.java @@ -1,24 +1,21 @@ package org.opencds.cqf.fhir.cr.measure.r4.utils; +import java.time.ZoneId; import org.hl7.fhir.r4.model.Period; import org.opencds.cqf.cql.engine.runtime.Date; import org.opencds.cqf.cql.engine.runtime.DateTime; import org.opencds.cqf.cql.engine.runtime.Interval; -import org.opencds.cqf.fhir.cr.measure.helper.DateHelper; +import org.opencds.cqf.fhir.cr.measure.helper.IntervalHelper; public class R4DateHelper { - public Interval buildMeasurementPeriodInterval(String periodStart, String periodEnd) { + public Interval buildMeasurementPeriodInterval(String periodStart, String periodEnd, ZoneId timezone) { // resolve the measurement period - return new Interval( - DateHelper.resolveRequestDate(periodStart, true), - true, - DateHelper.resolveRequestDate(periodEnd, false), - true); + return IntervalHelper.buildMeasurementPeriod(periodStart, periodEnd, timezone); } - public Period buildMeasurementPeriod(String periodStart, String periodEnd) { - Interval measurementPeriod = buildMeasurementPeriodInterval(periodStart, periodEnd); + public Period buildMeasurementPeriod(String periodStart, String periodEnd, ZoneId timezone) { + Interval measurementPeriod = buildMeasurementPeriodInterval(periodStart, periodEnd, timezone); return buildMeasurementPeriod(measurementPeriod); } diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelperTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelperTest.java index 04f6b5272..33897855d 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelperTest.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/DateHelperTest.java @@ -1,64 +1,239 @@ package org.opencds.cqf.fhir.cr.measure.helper; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; -import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opencds.cqf.cql.engine.runtime.DateTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -// TODO: These tests are only partially complete. We need to actually verify that resolved dates are -// correct. class DateHelperTest { + private static final Logger logger = LoggerFactory.getLogger(DateHelperTest.class); - @Test - void resolveRequestDateWithTime() throws Exception { - String date = "2019-01-17T12:30:00"; - var resolvedDateStart = DateHelper.resolveRequestDate(date, true); - assertTrue(resolvedDateStart != null); + private static final boolean IS_START = true; + private static final boolean IS_END = false; - var resolvedDateEnd = DateHelper.resolveRequestDate(date, false); - assertTrue(resolvedDateEnd != null); - assertEquals(resolvedDateStart, resolvedDateEnd); - } - - @Test - void resolveRequestDateOffset() throws Exception { - String date = "2019-01-01T22:00:00.0-06:00"; - var resolvedDateStart = DateHelper.resolveRequestDate(date, true); - assertTrue(resolvedDateStart != null); + private static final ZoneId TIMEZONE_EASTERN = ZoneId.of("America/Toronto"); + private static final ZoneId TIMEZONE_MOUNTAIN = ZoneId.of("America/Denver"); + // Half hour offset timezone + private static final ZoneId TIMEZONE_NEWFOUNDLAND = ZoneId.of("America/St_Johns"); - var resolvedDateEnd = DateHelper.resolveRequestDate(date, false); - assertTrue(resolvedDateEnd != null); - assertEquals(resolvedDateStart, resolvedDateEnd); - } + private static final ZoneOffset OFFSET_MINUS_2_5 = ZoneOffset.ofHoursMinutes(-2, -30); + private static final ZoneOffset OFFSET_MINUS_3_5 = ZoneOffset.ofHoursMinutes(-3, -30); + private static final ZoneOffset OFFSET_MINUS_4 = ZoneOffset.ofHours(-4); + private static final ZoneOffset OFFSET_MINUS_5 = ZoneOffset.ofHours(-5); + private static final ZoneOffset OFFSET_MINUS_6 = ZoneOffset.ofHours(-6); + private static final ZoneOffset OFFSET_MINUS_7 = ZoneOffset.ofHours(-7); - @Test - void resolveRequestDateWithZOffset() throws Exception { - String date = "2017-01-01T00:00:00.000Z"; - var resolvedDateStart = DateHelper.resolveRequestDate(date, true); - assertTrue(resolvedDateStart != null); + private static final String FULL_WITH_T_2019_JAN_12_30 = "2019-01-17T12:30:00"; + private static final String FULL_WITH_T_2019_JULY_12_30 = "2019-07-17T12:30:00"; + protected static final String YEAR_ONLY_2017 = "2017"; + protected static final String DATE_ONLY_2017_01_01 = "2017-01-01"; - var resolvedDateEnd = DateHelper.resolveRequestDate(date, false); - assertTrue(resolvedDateEnd != null); - assertEquals(resolvedDateStart, resolvedDateEnd); + public static Stream dateHelperTestParams() { + return Stream.of( + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_5)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_4)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_5)), + Arguments.of( + YEAR_ONLY_2017, + IS_START, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_5)), + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_5)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_4)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 23, 59, 59, 999000000), OFFSET_MINUS_5)), + Arguments.of( + YEAR_ONLY_2017, + IS_END, + TIMEZONE_EASTERN, + toDateTime(LocalDateTime.of(2017, Month.DECEMBER, 31, 23, 59, 59, 999000000), OFFSET_MINUS_5)), + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_7)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_6)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_7)), + Arguments.of( + YEAR_ONLY_2017, + IS_START, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_7)), + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_7)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_6)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 23, 59, 59, 999000000), OFFSET_MINUS_7)), + Arguments.of( + YEAR_ONLY_2017, + IS_END, + TIMEZONE_MOUNTAIN, + toDateTime(LocalDateTime.of(2017, Month.DECEMBER, 31, 23, 59, 59, 999000000), OFFSET_MINUS_7)), + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_3_5)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_2_5)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_3_5)), + Arguments.of( + YEAR_ONLY_2017, + IS_START, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), OFFSET_MINUS_3_5)), + Arguments.of( + FULL_WITH_T_2019_JAN_12_30, + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_3_5)), + Arguments.of( + FULL_WITH_T_2019_JULY_12_30, + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_2_5)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + DATE_ONLY_2017_01_01, + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime(LocalDateTime.of(2017, Month.JANUARY, 1, 23, 59, 59, 999000000), OFFSET_MINUS_3_5)), + Arguments.of( + YEAR_ONLY_2017, + IS_END, + TIMEZONE_NEWFOUNDLAND, + toDateTime( + LocalDateTime.of(2017, Month.DECEMBER, 31, 23, 59, 59, 999000000), OFFSET_MINUS_3_5))); } - @Test - void resolveRequestOnlyDate() throws Exception { - String date = "2017-01-01"; - var resolvedDateStart = DateHelper.resolveRequestDate(date, true); - assertTrue(resolvedDateStart != null); + @ParameterizedTest + @MethodSource("dateHelperTestParams") + void testIntervals(String inputDate, boolean isStart, ZoneId clientTimezone, DateTime expectedResult) { + final DateTime actualDateTime = DateHelper.resolveRequestDate(inputDate, isStart, clientTimezone); + assertNotNull(actualDateTime); - var resolvedDateEnd = DateHelper.resolveRequestDate(date, false); - assertTrue(resolvedDateEnd != null); + assertEquals(expectedResult.getZoneOffset(), actualDateTime.getZoneOffset()); + assertEquals(expectedResult, actualDateTime); } - @Test - void resolveRequestOnlyYear() throws Exception { - String date = "2017"; - var resolvedDateStart = DateHelper.resolveRequestDate(date, true); - assertTrue(resolvedDateStart != null); - - var resolvedDateEnd = DateHelper.resolveRequestDate(date, false); - assertTrue(resolvedDateEnd != null); + private static DateTime toDateTime(LocalDateTime localDateTime, ZoneOffset zoneOffset) { + return new DateTime(localDateTime.atOffset(zoneOffset)); } } diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelperTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelperTest.java new file mode 100644 index 000000000..87d87adbe --- /dev/null +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/helper/IntervalHelperTest.java @@ -0,0 +1,187 @@ +package org.opencds.cqf.fhir.cr.measure.helper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opencds.cqf.cql.engine.runtime.DateTime; + +class IntervalHelperTest { + private static final ZoneId TIMEZONE_EASTERN = ZoneId.of("America/Toronto"); + private static final ZoneId TIMEZONE_MOUNTAIN = ZoneId.of("America/Denver"); + + private static final ZoneOffset OFFSET_MINUS_4 = ZoneOffset.ofHours(-4); + private static final ZoneOffset OFFSET_MINUS_5 = ZoneOffset.ofHours(-5); + private static final ZoneOffset OFFSET_MINUS_6 = ZoneOffset.ofHours(-6); + private static final ZoneOffset OFFSET_MINUS_7 = ZoneOffset.ofHours(-7); + + public static Stream dateHelperTestParams() { + return Stream.of( + Arguments.of( + "2019-01-17T12:30:00", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_5)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + "2017-01-01", + TIMEZONE_EASTERN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), + OFFSET_MINUS_5, + LocalDateTime.of(2017, Month.JANUARY, 1, 23, 59, 59, 999000000), + OFFSET_MINUS_5)), + Arguments.of( + "2019-07-17T12:30:00", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_4)), + Arguments.of( + "2019-07-01T22:00:00.0-06:00", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2019, Month.JULY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-07-01T00:00:00.000Z", + TIMEZONE_EASTERN, + toDateTimeRange(LocalDateTime.of(2017, Month.JULY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + "2017-07-01", + TIMEZONE_EASTERN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JULY, 1, 0, 0, 0), + OFFSET_MINUS_4, + LocalDateTime.of(2017, Month.JULY, 1, 23, 59, 59, 999000000), + OFFSET_MINUS_4)), + Arguments.of( + "2017", + TIMEZONE_EASTERN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), + OFFSET_MINUS_5, + LocalDateTime.of(2017, Month.DECEMBER, 31, 23, 59, 59, 999000000), + OFFSET_MINUS_5)), + Arguments.of( + "2019-01-17T12:30:00", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2019, Month.JANUARY, 17, 12, 30, 0), OFFSET_MINUS_7)), + Arguments.of( + "2019-01-01T22:00:00.0-06:00", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2019, Month.JANUARY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-01-01T00:00:00.000Z", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + "2017-01-01", + TIMEZONE_MOUNTAIN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), + OFFSET_MINUS_7, + LocalDateTime.of(2017, Month.JANUARY, 1, 23, 59, 59, 999000000), + OFFSET_MINUS_7)), + Arguments.of( + "2019-07-17T12:30:00", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2019, Month.JULY, 17, 12, 30, 0), OFFSET_MINUS_6)), + Arguments.of( + "2019-07-01T22:00:00.0-06:00", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2019, Month.JULY, 1, 22, 0, 0), OFFSET_MINUS_6)), + Arguments.of( + "2017-07-01T00:00:00.000Z", + TIMEZONE_MOUNTAIN, + toDateTimeRange(LocalDateTime.of(2017, Month.JULY, 1, 0, 0, 0), ZoneOffset.UTC)), + Arguments.of( + "2017-07-01", + TIMEZONE_MOUNTAIN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JULY, 1, 0, 0, 0), + OFFSET_MINUS_6, + LocalDateTime.of(2017, Month.JULY, 1, 23, 59, 59, 999000000), + OFFSET_MINUS_6)), + Arguments.of( + "2017", + TIMEZONE_MOUNTAIN, + toDateTimeRange( + LocalDateTime.of(2017, Month.JANUARY, 1, 0, 0, 0), + OFFSET_MINUS_7, + LocalDateTime.of(2017, Month.DECEMBER, 31, 23, 59, 59, 999000000), + OFFSET_MINUS_7))); + } + + @ParameterizedTest + @MethodSource("dateHelperTestParams") + void testIntervals(String inputDate, ZoneId clientTimezone, DateTimeRange expectedResult) { + final DateTime resolvedDateStart = DateHelper.resolveRequestDate(inputDate, true, clientTimezone); + assertNotNull(resolvedDateStart); + + final DateTime resolvedDateEnd = DateHelper.resolveRequestDate(inputDate, false, clientTimezone); + assertNotNull(resolvedDateEnd); + + if (expectedResult.areStartAndEndEqual()) { + assertEquals(resolvedDateStart, resolvedDateEnd); + } else { + assertNotEquals(resolvedDateStart, resolvedDateEnd); + } + + final DateTime expectedStart = expectedResult.getStart(); + final DateTime expectedEnd = expectedResult.getEnd(); + + assertEquals(expectedStart.getZoneOffset(), resolvedDateStart.getZoneOffset()); + assertEquals(expectedEnd.getZoneOffset(), resolvedDateEnd.getZoneOffset()); + + assertEquals(expectedStart, resolvedDateStart); + assertEquals(expectedEnd, resolvedDateEnd); + } + + private static DateTimeRange toDateTimeRange(LocalDateTime localDateTime, ZoneOffset zoneOffset) { + return new DateTimeRange(toDateTime(localDateTime, zoneOffset), toDateTime(localDateTime, zoneOffset)); + } + + private static DateTimeRange toDateTimeRange( + LocalDateTime startLocalDateTime, + ZoneOffset startZoneOffset, + LocalDateTime endLocalDateTime, + ZoneOffset endZoneOffset) { + return new DateTimeRange( + toDateTime(startLocalDateTime, startZoneOffset), toDateTime(endLocalDateTime, endZoneOffset)); + } + + private static DateTime toDateTime(LocalDateTime localDateTime, ZoneOffset zoneOffset) { + return new DateTime(localDateTime.atOffset(zoneOffset)); + } + + private static class DateTimeRange { + private final DateTime start; + private final DateTime end; + + public DateTimeRange(DateTime theStart, DateTime theEnd) { + start = theStart; + end = theEnd; + } + + public DateTime getStart() { + return start; + } + + public DateTime getEnd() { + return end; + } + + public boolean areStartAndEndEqual() { + return start.equals(end); + } + } +} diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/Measure.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/Measure.java index 32cf78a4a..c788e8396 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/Measure.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/Measure.java @@ -11,6 +11,7 @@ import ca.uhn.fhir.context.FhirContext; import java.nio.file.Paths; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; @@ -95,6 +96,7 @@ public Given() { this.evaluationOptions = MeasureEvaluationOptions.defaultOptions(); this.evaluationOptions .getEvaluationSettings() + .setClientTimezone(ZoneId.systemDefault()) // preserve backward compatibility with previous TZ .getRetrieveSettings() .setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY) .setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY); diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureProcessorEvaluateTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureProcessorEvaluateTest.java index fb274d098..1ffab1134 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureProcessorEvaluateTest.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureProcessorEvaluateTest.java @@ -7,6 +7,7 @@ import ca.uhn.fhir.context.FhirContext; import java.text.SimpleDateFormat; +import java.time.ZoneId; import java.util.HashMap; import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.MeasureReport; @@ -31,7 +32,7 @@ void measure_eval() { var start = "2022-01-01"; var end = "2022-06-29"; var helper = new R4DateHelper(); - var measurementPeriod = helper.buildMeasurementPeriod(start, end); + var measurementPeriod = helper.buildMeasurementPeriod(start, end, ZoneId.systemDefault()); var report = given.when() .measureId("GlycemicControlHypoglycemicInitialPopulation") .periodStart(start) diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasure.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasure.java index 013c801be..7e9283cdb 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasure.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasure.java @@ -8,6 +8,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.IParser; import java.nio.file.Paths; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; @@ -90,6 +91,7 @@ public Given() { this.evaluationOptions = MeasureEvaluationOptions.defaultOptions(); this.evaluationOptions .getEvaluationSettings() + .setClientTimezone(ZoneId.systemDefault()) // preserve backward compatibility with previous TZ .getRetrieveSettings() .setSearchParameterMode(SEARCH_FILTER_MODE.FILTER_IN_MEMORY) .setTerminologyParameterMode(TERMINOLOGY_FILTER_MODE.FILTER_IN_MEMORY); diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4DateHelperTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4DateHelperTest.java index d8f8cfae4..5236947ed 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4DateHelperTest.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4DateHelperTest.java @@ -1,21 +1,52 @@ package org.opencds.cqf.fhir.cr.measure.r4; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import jakarta.annotation.Nullable; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.stream.Stream; +import org.hl7.fhir.r4.model.Period; import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.opencds.cqf.cql.engine.runtime.Date; import org.opencds.cqf.cql.engine.runtime.DateTime; import org.opencds.cqf.cql.engine.runtime.Interval; +import org.opencds.cqf.fhir.cr.measure.helper.IntervalHelper; import org.opencds.cqf.fhir.cr.measure.r4.utils.R4DateHelper; public class R4DateHelperTest { + private static final ZoneId TIMEZONE_NEWFOUNDLAND = ZoneId.of("America/St_Johns"); + private static final ZoneId TIMEZONE_EASTERN = ZoneId.of("America/Toronto"); + private static final ZoneId TIMEZONE_MOUNTAIN = ZoneId.of("America/Denver"); + private static final String _2024_08_19 = "2024-08-19"; + private static final String _2024_08_20 = "2024-08-20"; + private static final String _2024_02_19 = "2024-02-19"; + private static final String _2024_02_20 = "2024-02-20"; + private static final LocalDateTime LOCAL_DATE_TIME_2024_08_19_START = + LocalDate.of(2024, Month.AUGUST, 19).atStartOfDay(); + private static final LocalDateTime LOCAL_DATE_TIME_2024_08_21_MINUS_ONE_SECOND = + LocalDate.of(2024, Month.AUGUST, 21).atStartOfDay().minusSeconds(1); + private static final LocalDateTime LOCAL_DATE_TIME_2024_02_19_START = + LocalDate.of(2024, Month.FEBRUARY, 19).atStartOfDay(); + private static final LocalDateTime LOCAL_DATE_TIME_2024_02_21_MINUS_ONE_SECOND = + LocalDate.of(2024, Month.FEBRUARY, 21).atStartOfDay().minusSeconds(1); + @Test public void checkDate() { var date = new Interval(new Date("2019-01-01"), true, new Date("2019-12-31"), true); @@ -65,4 +96,106 @@ public void checkNull() { assertTrue(e.getMessage().contains("Measurement period should be an interval of CQL DateTime or Date")); } } + + private static Stream periodParams() { + return Stream.of( + Arguments.of( + _2024_08_19, + _2024_08_20, + ZoneOffset.UTC, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_START, + LOCAL_DATE_TIME_2024_08_21_MINUS_ONE_SECOND, + ZoneOffset.UTC)), + Arguments.of( + _2024_08_19, + _2024_08_20, + TIMEZONE_NEWFOUNDLAND, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_START, + LOCAL_DATE_TIME_2024_08_21_MINUS_ONE_SECOND, + TIMEZONE_NEWFOUNDLAND)), + Arguments.of( + _2024_08_19, + _2024_08_20, + TIMEZONE_EASTERN, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_START, + LOCAL_DATE_TIME_2024_08_21_MINUS_ONE_SECOND, + TIMEZONE_EASTERN)), + Arguments.of( + _2024_08_19, + _2024_08_20, + TIMEZONE_MOUNTAIN, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_START, + LOCAL_DATE_TIME_2024_08_21_MINUS_ONE_SECOND, + TIMEZONE_MOUNTAIN)), + Arguments.of( + _2024_02_19, + _2024_02_20, + ZoneOffset.UTC, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_START, + LOCAL_DATE_TIME_2024_02_21_MINUS_ONE_SECOND, + ZoneOffset.UTC)), + Arguments.of( + _2024_02_19, + _2024_02_20, + TIMEZONE_NEWFOUNDLAND, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_START, + LOCAL_DATE_TIME_2024_02_21_MINUS_ONE_SECOND, + TIMEZONE_NEWFOUNDLAND)), + Arguments.of( + _2024_02_19, + _2024_02_20, + TIMEZONE_EASTERN, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_START, + LOCAL_DATE_TIME_2024_02_21_MINUS_ONE_SECOND, + TIMEZONE_EASTERN)), + Arguments.of( + _2024_02_19, + _2024_02_20, + TIMEZONE_MOUNTAIN, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_START, + LOCAL_DATE_TIME_2024_02_21_MINUS_ONE_SECOND, + TIMEZONE_MOUNTAIN))); + } + + @ParameterizedTest + @MethodSource("periodParams") + void getPeriod(String periodStart, String periodEnd, ZoneId zoneId, Period expectedPeriod) { + final R4DateHelper helper = new R4DateHelper(); + + final Interval interval = IntervalHelper.buildMeasurementPeriod(periodStart, periodEnd, zoneId); + final Period actualPeriod = helper.buildMeasurementPeriod(interval); + + assertDatesEqualNoMillis(expectedPeriod.getStart(), actualPeriod.getStart()); + assertDatesEqualNoMillis(expectedPeriod.getEnd(), actualPeriod.getEnd()); + } + + private static Period buildPeriod(LocalDateTime localDateStart, LocalDateTime localDateEnd, ZoneId zoneId) { + return new Period() + .setStart(toJavaUtilDate(localDateStart, zoneId)) + .setEnd(toJavaUtilDate(localDateEnd, zoneId)); + } + + private static java.util.Date toJavaUtilDate(LocalDateTime localDate, ZoneId zoneId) { + return java.util.Date.from(localDate.atZone(zoneId).toInstant()); + } + + private static void assertDatesEqualNoMillis( + @Nullable java.util.Date theExpectedDate, @Nullable java.util.Date theActualDate) { + assertThat(stripMillisOrNull(theActualDate), equalTo(stripMillisOrNull(theExpectedDate))); + } + + @Nullable + private static java.util.Date stripMillisOrNull(@Nullable java.util.Date theDateWithMillis) { + return Optional.ofNullable(theDateWithMillis) + .map(nonNullDate -> java.util.Date.from(nonNullDate.toInstant().truncatedTo(ChronoUnit.SECONDS))) + .orElse(null); + } } diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessorTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessorTest.java new file mode 100644 index 000000000..f938b2002 --- /dev/null +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/R4MeasureProcessorTest.java @@ -0,0 +1,262 @@ +package org.opencds.cqf.fhir.cr.measure.r4; + +import static java.time.ZoneOffset.UTC; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import ca.uhn.fhir.context.FhirContext; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.apache.commons.io.IOUtils; +import org.hl7.fhir.r4.model.Attachment; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Library; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.MeasureReport; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Period; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opencds.cqf.fhir.cql.EvaluationSettings; +import org.opencds.cqf.fhir.cr.measure.MeasureEvaluationOptions; +import org.opencds.cqf.fhir.utility.monad.Eithers; +import org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository; + +class R4MeasureProcessorTest { + private static final String MEASURE_ID_1 = "measureId1"; + private static final String MEASURE_URL_1 = "http://example.com/measure-1"; + private static final String LIBRARY_ID = "MinimalProportionBooleanBasisSingleGroup"; + private static final String LIBRARY_URL = "http://example.com/Library/MinimalProportionBooleanBasisSingleGroup"; + + private static final String CQL_FILE_PATH = + "/org/opencds/cqf/fhir/cr/measure/r4/MinimalMeasureEvaluation/cql/MinimalProportionBooleanBasisSingleGroup.cql"; + + private static final LocalDate LOCAL_DATE_2024_08_19 = LocalDate.of(2024, Month.AUGUST, 19); + private static final LocalDate LOCAL_DATE_2024_08_20 = LocalDate.of(2024, Month.AUGUST, 20); + + private static final LocalDate LOCAL_DATE_2024_02_19 = LocalDate.of(2024, Month.FEBRUARY, 19); + private static final LocalDate LOCAL_DATE_2024_02_20 = LocalDate.of(2024, Month.FEBRUARY, 20); + + private static final LocalTime LOCAL_TIME_00_00_00 = LocalTime.of(0, 0, 0); + private static final LocalTime LOCAL_TIME_23_59_59 = LocalTime.of(23, 59, 59); + + private static final LocalDateTime LOCAL_DATE_TIME_2024_08_19_00_00_00 = + LocalDateTime.of(LOCAL_DATE_2024_08_19, LOCAL_TIME_00_00_00); + private static final LocalDateTime LOCAL_DATE_TIME_2024_08_20_23_59_59 = + LocalDateTime.of(LOCAL_DATE_2024_08_20, LOCAL_TIME_23_59_59); + + private static final LocalDateTime LOCAL_DATE_TIME_2024_02_19_00_00_00 = + LocalDateTime.of(LOCAL_DATE_2024_02_19, LOCAL_TIME_00_00_00); + private static final LocalDateTime LOCAL_DATE_TIME_2024_02_20_23_59_59 = + LocalDateTime.of(LOCAL_DATE_2024_02_20, LOCAL_TIME_23_59_59); + + // Half hour offset timezone + private static final ZoneId TIMEZONE_NEWFOUNDLAND = ZoneId.of("America/St_Johns"); + private static final ZoneId TIMEZONE_EASTERN = ZoneId.of("America/Toronto"); + private static final ZoneId TIMEZONE_MOUNTAIN = ZoneId.of("America/Denver"); + + private static final DateTimeFormatter EVALUATE_MEASURE_PERIOD_DATE_TIME_FORMATTER = + DateTimeFormatter.ISO_LOCAL_DATE; + private static final FhirContext ourFhirContext = FhirContext.forR4Cached(); + + private final InMemoryFhirRepository myRepository = new InMemoryFhirRepository(FhirContext.forR4Cached()); + private final EvaluationSettings myEvaluationSettings = EvaluationSettings.getDefault(); + private final MeasureEvaluationOptions myMeasureEvaluationOptions = + new MeasureEvaluationOptions().setEvaluationSettings(myEvaluationSettings); + ; + private final R4RepositorySubjectProvider mySubjectProvider = new R4RepositorySubjectProvider(); + ; + private final R4MeasureProcessor myTestSubject = + new R4MeasureProcessor(myRepository, myMeasureEvaluationOptions, mySubjectProvider); + + private static Stream evaluateMeasureParams() { + return Stream.of( + Arguments.of( + LOCAL_DATE_2024_08_19, + LOCAL_DATE_2024_08_20, + null, + buildPeriod(LOCAL_DATE_TIME_2024_08_19_00_00_00, LOCAL_DATE_TIME_2024_08_20_23_59_59, UTC)), + Arguments.of( + LOCAL_DATE_2024_08_19, + LOCAL_DATE_2024_08_20, + ZoneOffset.UTC, + buildPeriod(LOCAL_DATE_TIME_2024_08_19_00_00_00, LOCAL_DATE_TIME_2024_08_20_23_59_59, UTC)), + Arguments.of( + LOCAL_DATE_2024_08_19, + LOCAL_DATE_2024_08_20, + TIMEZONE_NEWFOUNDLAND, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_00_00_00, + LOCAL_DATE_TIME_2024_08_20_23_59_59, + TIMEZONE_NEWFOUNDLAND)), + Arguments.of( + LOCAL_DATE_2024_08_19, + LOCAL_DATE_2024_08_20, + TIMEZONE_EASTERN, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_00_00_00, + LOCAL_DATE_TIME_2024_08_20_23_59_59, + TIMEZONE_EASTERN)), + Arguments.of( + LOCAL_DATE_2024_08_19, + LOCAL_DATE_2024_08_20, + TIMEZONE_MOUNTAIN, + buildPeriod( + LOCAL_DATE_TIME_2024_08_19_00_00_00, + LOCAL_DATE_TIME_2024_08_20_23_59_59, + TIMEZONE_MOUNTAIN)), + Arguments.of( + LOCAL_DATE_2024_02_19, + LOCAL_DATE_2024_02_20, + null, + buildPeriod(LOCAL_DATE_TIME_2024_02_19_00_00_00, LOCAL_DATE_TIME_2024_02_20_23_59_59, UTC)), + Arguments.of( + LOCAL_DATE_2024_02_19, + LOCAL_DATE_2024_02_20, + ZoneOffset.UTC, + buildPeriod(LOCAL_DATE_TIME_2024_02_19_00_00_00, LOCAL_DATE_TIME_2024_02_20_23_59_59, UTC)), + Arguments.of( + LOCAL_DATE_2024_02_19, + LOCAL_DATE_2024_02_20, + TIMEZONE_NEWFOUNDLAND, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_00_00_00, + LOCAL_DATE_TIME_2024_02_20_23_59_59, + TIMEZONE_NEWFOUNDLAND)), + Arguments.of( + LOCAL_DATE_2024_02_19, + LOCAL_DATE_2024_02_20, + TIMEZONE_EASTERN, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_00_00_00, + LOCAL_DATE_TIME_2024_02_20_23_59_59, + TIMEZONE_EASTERN)), + Arguments.of( + LOCAL_DATE_2024_02_19, + LOCAL_DATE_2024_02_20, + TIMEZONE_MOUNTAIN, + buildPeriod( + LOCAL_DATE_TIME_2024_02_19_00_00_00, + LOCAL_DATE_TIME_2024_02_20_23_59_59, + TIMEZONE_MOUNTAIN))); + } + + @BeforeEach + void beforeEach() { + myRepository.update(buildMeasure(MEASURE_ID_1, MEASURE_URL_1)); + + myRepository.update(buildLibrary()); + } + + @ParameterizedTest + @MethodSource("evaluateMeasureParams") + void testme(LocalDate periodStart, LocalDate periodEnd, @Nullable ZoneId clientTimezone, Period expectedPeriod) { + Optional.ofNullable(clientTimezone) + .ifPresent(zoneIdNonNull -> myEvaluationSettings.setClientTimezone(clientTimezone)); + + final MeasureReport actualMeasureReport = myTestSubject.evaluateMeasure( + Eithers.forMiddle3(new IdType("Measure", MEASURE_ID_1)), + toYyyyMmdd(periodStart), + toYyyyMmdd(periodEnd), + "", + List.of(), + null, + new Parameters()); + + assertNotNull(actualMeasureReport.getPeriod()); + + final ZoneId expectedClientTimezone = + Optional.ofNullable(clientTimezone).orElse(ZoneOffset.UTC); + + assertEquals(expectedClientTimezone, myEvaluationSettings.getClientTimezone()); + + assertPeriodEquals(expectedPeriod, actualMeasureReport.getPeriod()); + } + + private void assertPeriodEquals(Period expectedPeriod, Period actualPeriod) { + assertDatesEqualNoMillis(expectedPeriod.getStart(), actualPeriod.getStart()); + assertDatesEqualNoMillis(expectedPeriod.getEnd(), actualPeriod.getEnd()); + } + + public static Library buildLibrary() { + return ((Library) new Library().setId(LIBRARY_ID)) + .setUrl(LIBRARY_URL) + .setName(LIBRARY_ID) + .setStatus(Enumerations.PublicationStatus.ACTIVE) + .addContent(new Attachment().setContentType("text/cql").setData(getFileBytes(CQL_FILE_PATH))); + } + + private static byte[] getFileBytes(String theCqlFilePath) { + final InputStream inputStream = getInputStreamForFilePath(theCqlFilePath); + + try { + return IOUtils.toByteArray(inputStream); + } catch (IOException theE) { + fail("Could not read InputStream for path: " + theCqlFilePath); + // Need the below so this will compile: + return new byte[] {}; + } + } + + private static Measure buildMeasure(String theId, String theMeasureUrl) { + return (Measure) + new Measure().setUrl(theMeasureUrl).addLibrary(LIBRARY_URL).setId(new IdType("Measure", theId)); + } + + @Nonnull + private static InputStream getInputStreamForFilePath(String theFilePath) { + final InputStream inputStream = R4MeasureProcessor.class.getResourceAsStream(theFilePath); + assertNotNull(inputStream); + return inputStream; + } + + @Nullable + private static Period buildPeriod(LocalDateTime periodStart, LocalDateTime periodEnd, ZoneId zoneId) { + if (periodStart == null || periodEnd == null) { + return null; + } + return new Period().setStart(toJavaUtilDate(periodStart, zoneId)).setEnd(toJavaUtilDate(periodEnd, zoneId)); + } + + private static Date toJavaUtilDate(LocalDateTime localDateTime, ZoneId zoneId) { + return Date.from(localDateTime.atZone(zoneId).toInstant()); + } + + private static String toYyyyMmdd(LocalDate localDate) { + return EVALUATE_MEASURE_PERIOD_DATE_TIME_FORMATTER.format(localDate); + } + + private static Date toJavaUtilDate(ZonedDateTime zonedDateTime) { + return Date.from(zonedDateTime.toInstant()); + } + + private static void assertDatesEqualNoMillis(@Nullable Date theExpectedDate, @Nullable Date theActualDate) { + assertEquals(stripMillisOrNull(theExpectedDate), stripMillisOrNull(theActualDate)); + } + + @Nullable + private static Date stripMillisOrNull(@Nullable Date theDateWithMillis) { + return Optional.ofNullable(theDateWithMillis) + .map(nonNullDate -> Date.from(nonNullDate.toInstant().truncatedTo(ChronoUnit.SECONDS))) + .orElse(null); + } +} diff --git a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/InMemoryFhirRepository.java b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/InMemoryFhirRepository.java index 289d44647..b54663464 100644 --- a/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/InMemoryFhirRepository.java +++ b/cqf-fhir-utility/src/main/java/org/opencds/cqf/fhir/utility/repository/InMemoryFhirRepository.java @@ -207,7 +207,8 @@ public C capabilities(Class resourceType, Map B transaction(B transaction, Map headers) { - throw new NotImplementedOperationException("The transaction operation is not currently supported"); + // TODO: LD: What's the expectation here if we call transaction() InMemory + return null; } @SuppressWarnings("unchecked")