diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java index 062d890ac..7ea687fbc 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java @@ -35,6 +35,7 @@ import java.time.OffsetDateTime; import java.time.Year; import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; import java.util.Calendar; import java.util.Date; import java.util.HashMap; @@ -464,12 +465,43 @@ private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Local } DateTimeFormatter formatter = formatCache.get(dateFormat); if (formatter == null) { - formatter = DateTimeFormatter.ofPattern(dateFormat, actualLocale); + formatter = DateTimeFormatter.ofPattern(toStrictPatternIfNecessary(dateFormat), actualLocale) + .withResolverStyle(ResolverStyle.STRICT); formatCache.put(dateFormat, formatter); } return formatter; } + /** + * Resolve years without an explicit era as proleptic years in strict mode. Preserve quoted literals and + * year-of-era semantics when the pattern explicitly includes an era. + */ + private static String toStrictPatternIfNecessary(String pattern) { + if (pattern == null || pattern.indexOf('y') == -1) { + return pattern; + } + StringBuilder builder = new StringBuilder(pattern); + boolean isQuoted = false; + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == '\'') { + if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '\'') { + i++; + } else { + isQuoted = !isQuoted; + } + } else if (!isQuoted) { + if (c == 'G') { + return pattern; + } + if (c == 'y') { + builder.setCharAt(i, 'u'); + } + } + } + return builder.toString(); + } + private static DateFormat getCacheDateFormat(String dateFormat) { Map dateFormatMap = DATE_FORMAT_THREAD_LOCAL.get(); if (dateFormatMap == null) { @@ -482,6 +514,7 @@ private static DateFormat getCacheDateFormat(String dateFormat) { } } SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat); + simpleDateFormat.setLenient(false); dateFormatMap.put(dateFormat, simpleDateFormat); return simpleDateFormat; } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java new file mode 100644 index 000000000..5b59eab7c --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.converters.date; + +import java.text.ParseException; +import java.util.Date; +import org.apache.fesod.sheet.metadata.GlobalConfiguration; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link DateStringConverter}. + */ +@Tag(Tags.UNIT) +class DateStringConverterTest { + + private static final GlobalConfiguration GLOBAL_CONFIGURATION = new GlobalConfiguration(); + private final DateStringConverter converter = new DateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-31", "2023-02-29", "2024-13-01"}) + void convertToJavaDataRejectsInvalidCalendarDates(String value) { + Assertions.assertThrows( + ParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>(value), null, GLOBAL_CONFIGURATION)); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-29", "2023-02-28"}) + void convertToJavaDataAcceptsValidCalendarDates(String value) throws ParseException { + Date parsed = converter.convertToJavaData(new ReadCellData<>(value), null, GLOBAL_CONFIGURATION); + + Assertions.assertEquals(value, DateUtils.format(parsed, DateUtils.DATE_FORMAT_10)); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java new file mode 100644 index 000000000..7c0a0ae08 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.converters.localdate; + +import java.text.ParseException; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import org.apache.fesod.sheet.metadata.GlobalConfiguration; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link LocalDateStringConverter}. + */ +@Tag(Tags.UNIT) +class LocalDateStringConverterTest { + + private static final GlobalConfiguration GLOBAL_CONFIGURATION = new GlobalConfiguration(); + private final LocalDateStringConverter converter = new LocalDateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-31", "2023-02-29", "2024-13-01"}) + void convertToJavaDataRejectsInvalidCalendarDates(String value) { + Assertions.assertThrows( + DateTimeParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>(value), null, GLOBAL_CONFIGURATION)); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-29", "2023-02-28"}) + void convertToJavaDataAcceptsValidCalendarDates(String value) throws ParseException { + LocalDate parsed = converter.convertToJavaData(new ReadCellData<>(value), null, GLOBAL_CONFIGURATION); + + Assertions.assertEquals(LocalDate.parse(value), parsed); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/DateUtilsTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/DateUtilsTest.java index 1d374bafa..c9b43c3e4 100644 --- a/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/DateUtilsTest.java +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/DateUtilsTest.java @@ -105,6 +105,20 @@ void test_parseDate() throws ParseException { Assertions.assertEquals(30, cal2.get(Calendar.MINUTE)); } + @ParameterizedTest + @ValueSource(strings = {"2024-02-31", "2023-02-29", "2024-13-01"}) + void test_parseDateRejectsInvalidCalendarDates(String value) { + Assertions.assertThrows(ParseException.class, () -> DateUtils.parseDate(value, DateUtils.DATE_FORMAT_10)); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-29", "2023-02-28"}) + void test_parseDateAcceptsValidCalendarDates(String value) throws ParseException { + Date parsed = DateUtils.parseDate(value, DateUtils.DATE_FORMAT_10); + + Assertions.assertEquals(value, DateUtils.format(parsed, DateUtils.DATE_FORMAT_10)); + } + @Test void test_parseDateAutoDetectsForwardSlashDateOnly() throws ParseException { Date date = DateUtils.parseDate("2026/10/01"); @@ -175,6 +189,44 @@ void test_parseLocalDate() { Assertions.assertEquals(LocalDate.of(2026, 10, 1), forwardSlashDate); } + @ParameterizedTest + @ValueSource(strings = {"2024-02-31", "2023-02-29", "2024-13-01"}) + void test_parseLocalDateRejectsInvalidCalendarDates(String value) { + Assertions.assertThrows( + DateTimeParseException.class, () -> DateUtils.parseLocalDate(value, DateUtils.DATE_FORMAT_10, null)); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-02-29", "2023-02-28"}) + void test_parseLocalDateAcceptsValidCalendarDates(String value) { + LocalDate parsed = DateUtils.parseLocalDate(value, DateUtils.DATE_FORMAT_10, null); + + Assertions.assertEquals(value, DateUtils.format(parsed, DateUtils.DATE_FORMAT_10)); + } + + @ParameterizedTest + @ValueSource(strings = {"uuuu-MM-dd", "yyyy-MM-dd"}) + void test_parseLocalDateAcceptsProlepticYears(String pattern) { + Assertions.assertEquals(LocalDate.of(-1, 1, 2), DateUtils.parseLocalDate("-0001-01-02", pattern, Locale.US)); + Assertions.assertEquals(LocalDate.of(0, 1, 2), DateUtils.parseLocalDate("0000-01-02", pattern, Locale.US)); + } + + @Test + void test_parseLocalDatePreservesQuotedPatternLiterals() { + LocalDate expected = LocalDate.of(2024, 2, 29); + Assertions.assertEquals(expected, DateUtils.parseLocalDate("2024-02-29 y", "yyyy-MM-dd 'y'", Locale.US)); + Assertions.assertEquals(expected, DateUtils.parseLocalDate("2024-02-29 'y", "yyyy-MM-dd '''y'", Locale.US)); + Assertions.assertEquals(expected, DateUtils.parseLocalDate("2024-02-29 G", "yyyy-MM-dd 'G'", Locale.US)); + } + + @Test + void test_parseLocalDatePreservesExplicitEra() { + Assertions.assertEquals( + LocalDate.of(0, 1, 2), DateUtils.parseLocalDate("0001-01-02 BC", "yyyy-MM-dd G", Locale.US)); + Assertions.assertEquals( + LocalDate.of(1, 1, 2), DateUtils.parseLocalDate("0001-01-02 AD", "yyyy-MM-dd G", Locale.US)); + } + @Test void test_parseLocalTime() { LocalTime usResult = DateUtils.parseLocalTime("12:30:45", DateUtils.TIME_FORMAT_8, Locale.US);