diff --git a/Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift b/Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift index 1060a9cfa..72737abfa 100644 --- a/Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift +++ b/Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift @@ -350,7 +350,8 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable case .weekOfYear: 1..<53 case .yearForWeekOfYear: 140742..<140743 case .nanosecond: 0..<1000000000 - case .isLeapMonth: 0..<2 + // There is no leap month in Gregorian calendar + case .isLeapMonth: 0..<1 case .dayOfYear: 1..<366 case .calendar, .timeZone: nil @@ -380,7 +381,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable case .weekOfYear: return 1..<54 case .yearForWeekOfYear: return 140742..<144684 case .nanosecond: return 0..<1000000000 - case .isLeapMonth: return 0..<2 + case .isLeapMonth: return 0..<1 case .dayOfYear: return 1..<367 case .calendar, .timeZone: return nil @@ -1654,6 +1655,10 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable if let value = components.hour { guard validHour.contains(value) else { return false } } if let value = components.minute { guard validMinute.contains(value) else { return false } } if let value = components.second { guard validSecond.contains(value) else { return false } } + if let value = components.isLeapMonth { + // The only valid `isLeapMonth` setting is false + return value == false + } return true } diff --git a/Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift b/Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift index 23de92073..136e85586 100644 --- a/Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift +++ b/Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift @@ -287,7 +287,15 @@ internal final class _CalendarICU: _CalendarProtocol, @unchecked Sendable { return 1..<5 case .calendar, .timeZone: return nil - case .era, .year, .month, .day, .weekdayOrdinal, .weekOfMonth, .weekOfYear, .yearForWeekOfYear, .isLeapMonth, .dayOfYear: + case .isLeapMonth: + // Fast path but also workaround an ICU bug where they return 1 as the max value even for calendars without leap month + let hasLeapMonths = identifier == .chinese || identifier == .dangi || identifier == .gujarati || identifier == .kannada || identifier == .marathi || identifier == .telugu || identifier == .vietnamese || identifier == .vikram + if !hasLeapMonths { + return 0..<1 + } else { + return nil + } + case .era, .year, .month, .day, .weekdayOrdinal, .weekOfMonth, .weekOfYear, .yearForWeekOfYear, .dayOfYear: return nil } } diff --git a/Sources/FoundationInternationalization/Calendar/Calendar_ObjC.swift b/Sources/FoundationInternationalization/Calendar/Calendar_ObjC.swift index e5867deab..5dfad26d3 100644 --- a/Sources/FoundationInternationalization/Calendar/Calendar_ObjC.swift +++ b/Sources/FoundationInternationalization/Calendar/Calendar_ObjC.swift @@ -634,6 +634,7 @@ private func _fromNSCalendarUnit(_ unit: NSCalendar.Unit) -> Calendar.Component? case .calendar: return .calendar case .timeZone: return .timeZone case .deprecatedWeekUnit: return .weekOfYear + case .isLeapMonth: return .isLeapMonth default: return nil } diff --git a/Tests/FoundationEssentialsTests/GregorianCalendarTests.swift b/Tests/FoundationEssentialsTests/GregorianCalendarTests.swift index 1ba4f0a15..3e30b89f1 100644 --- a/Tests/FoundationEssentialsTests/GregorianCalendarTests.swift +++ b/Tests/FoundationEssentialsTests/GregorianCalendarTests.swift @@ -43,6 +43,99 @@ private struct GregorianCalendarTests { _ = d.julianDay } + // MARK: Leap month + @Test func calendarUnitLeapMonth_gregorianCalendar() { + // Test leap month with a calendar that does not observe leap month + + // Gregorian: 2023-03-22. + let date1 = Date(timeIntervalSinceReferenceDate: 701161200) + // Gregorian: 2023-03-02. + let date2 = Date(timeIntervalSinceReferenceDate: 699433200) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + + let minRange = calendar.minimumRange(of: .isLeapMonth) + #expect(minRange?.lowerBound == 0) + #expect(minRange?.count == 1) + + let maxRange = calendar.maximumRange(of: .isLeapMonth) + #expect(maxRange?.lowerBound == 0) + #expect(maxRange?.count == 1) + + let leapMonthRange = calendar.range(of: .isLeapMonth, in: .year, for: date1) + #expect(leapMonthRange == nil) + + let dateIntervial = calendar.dateInterval(of: .isLeapMonth, for: date1) + #expect(dateIntervial == nil) + + // Invalid ordinality flag + let ordinal = calendar.ordinality(of: .isLeapMonth, in: .year, for: date1) + #expect(ordinal == nil) + + // Invalid ordinality flag + let ordinal2 = calendar.ordinality(of: .day, in: .isLeapMonth, for: date1) + #expect(ordinal2 == nil) + + let extractedComponents = calendar.dateComponents([.year, .month], from: date1) + #expect(extractedComponents.isLeapMonth == false) + #expect(extractedComponents.month == 3) + + let isLeap = calendar.component(.isLeapMonth, from: date1) + #expect(isLeap == 0) + + let extractedLeapMonthComponents_onlyLeapMonth = calendar.dateComponents([.isLeapMonth], from: date1) + #expect(extractedLeapMonthComponents_onlyLeapMonth.isLeapMonth == false) + + let extractedLeapMonthComponents = calendar.dateComponents([.isLeapMonth, .month], from: date1) + #expect(extractedLeapMonthComponents.isLeapMonth == false) + #expect(extractedLeapMonthComponents.month == 3) + + let isEqualMonth = calendar.isDate(date1, equalTo: date2, toGranularity: .month) + #expect(isEqualMonth) // Both are in month 3 + + let isEqualLeapMonth = calendar.isDate(date1, equalTo: date2, toGranularity: .isLeapMonth) + #expect(isEqualLeapMonth) // Both are not in leap month + + // Invalid granularity flag. Return what we return for other invalid `Calendar.Component` inputs + let result = calendar.compare(date1, to: date2, toGranularity: .month) + #expect(result == .orderedSame) + + // Invalid granularity flag. Return what we return for other invalid `Calendar.Component` inputs + let onlyLeapMonthComparisonResult = calendar.compare(date1, to: date2, toGranularity: .isLeapMonth) + #expect(onlyLeapMonthComparisonResult == .orderedSame) + + let nextLeapMonthDate = calendar.nextDate(after: date1, matching: DateComponents(isLeapMonth: true), matchingPolicy: .strict) + #expect(nextLeapMonthDate == nil) // There is not a date in Gregorian that is a leap month + +#if FIXED_SINGLE_LEAPMONTH + let nextNonLeapMonthDate = calendar.nextDate(after: date1, matching: DateComponents(isLeapMonth: false), matchingPolicy: .strict) + #expect(nextNonLeapMonthDate == date1) // date1 matches the condition already +#endif + + var settingLeapMonthComponents = calendar.dateComponents([.year, .month, .day], from: date1) + settingLeapMonthComponents.isLeapMonth = true + let settingLeapMonthDate = calendar.date(from: settingLeapMonthComponents) + #expect(settingLeapMonthDate == nil) // There is not a date in Gregorian that is a leap month + + var settingNonLeapMonthComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date1) + settingNonLeapMonthComponents.isLeapMonth = false + let settingNonLeapMonthDate = calendar.date(from: settingNonLeapMonthComponents) + #expect(settingNonLeapMonthDate == date1) // date1 matches the condition already + + let diffComponents = calendar.dateComponents([.month, .day, .isLeapMonth], from: date1, to: date2) + #expect(diffComponents.month == 0) + #expect(diffComponents.isLeapMonth == nil) + #expect(diffComponents.day == -20) + + let addedDate = calendar.date(byAdding: .isLeapMonth, value: 1, to: date1) + #expect(addedDate == nil) + + // Invalid argument; cannot add a boolean component with an integer value + let addedDate_notLeap = calendar.date(byAdding: .isLeapMonth, value: 0, to: date1) + #expect(addedDate_notLeap == nil) + } + // MARK: Date from components @Test func testDateFromComponents() {