Skip to content

Commit 3ead71f

Browse files
authored
Work towards leap month bug fixes (#1399)
There is a bug in ICU that returns the wrong value for min/max range. Workaround that here. Also fix the same issue in Gregorian calendar, where the returned value isn't sensible. 153548677
1 parent 6d87788 commit 3ead71f

File tree

4 files changed

+110
-3
lines changed

4 files changed

+110
-3
lines changed

Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,8 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
350350
case .weekOfYear: 1..<53
351351
case .yearForWeekOfYear: 140742..<140743
352352
case .nanosecond: 0..<1000000000
353-
case .isLeapMonth: 0..<2
353+
// There is no leap month in Gregorian calendar
354+
case .isLeapMonth: 0..<1
354355
case .dayOfYear: 1..<366
355356
case .calendar, .timeZone:
356357
nil
@@ -380,7 +381,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
380381
case .weekOfYear: return 1..<54
381382
case .yearForWeekOfYear: return 140742..<144684
382383
case .nanosecond: return 0..<1000000000
383-
case .isLeapMonth: return 0..<2
384+
case .isLeapMonth: return 0..<1
384385
case .dayOfYear: return 1..<367
385386
case .calendar, .timeZone:
386387
return nil
@@ -1654,6 +1655,10 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
16541655
if let value = components.hour { guard validHour.contains(value) else { return false } }
16551656
if let value = components.minute { guard validMinute.contains(value) else { return false } }
16561657
if let value = components.second { guard validSecond.contains(value) else { return false } }
1658+
if let value = components.isLeapMonth {
1659+
// The only valid `isLeapMonth` setting is false
1660+
return value == false
1661+
}
16571662
return true
16581663
}
16591664

Sources/FoundationInternationalization/Calendar/Calendar_ICU.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,15 @@ internal final class _CalendarICU: _CalendarProtocol, @unchecked Sendable {
287287
return 1..<5
288288
case .calendar, .timeZone:
289289
return nil
290-
case .era, .year, .month, .day, .weekdayOrdinal, .weekOfMonth, .weekOfYear, .yearForWeekOfYear, .isLeapMonth, .dayOfYear:
290+
case .isLeapMonth:
291+
// Fast path but also workaround an ICU bug where they return 1 as the max value even for calendars without leap month
292+
let hasLeapMonths = identifier == .chinese || identifier == .dangi || identifier == .gujarati || identifier == .kannada || identifier == .marathi || identifier == .telugu || identifier == .vietnamese || identifier == .vikram
293+
if !hasLeapMonths {
294+
return 0..<1
295+
} else {
296+
return nil
297+
}
298+
case .era, .year, .month, .day, .weekdayOrdinal, .weekOfMonth, .weekOfYear, .yearForWeekOfYear, .dayOfYear:
291299
return nil
292300
}
293301
}

Sources/FoundationInternationalization/Calendar/Calendar_ObjC.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@ private func _fromNSCalendarUnit(_ unit: NSCalendar.Unit) -> Calendar.Component?
634634
case .calendar: return .calendar
635635
case .timeZone: return .timeZone
636636
case .deprecatedWeekUnit: return .weekOfYear
637+
case .isLeapMonth: return .isLeapMonth
637638
default:
638639
return nil
639640
}

Tests/FoundationEssentialsTests/GregorianCalendarTests.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,99 @@ private struct GregorianCalendarTests {
4343
_ = d.julianDay
4444
}
4545

46+
// MARK: Leap month
47+
@Test func calendarUnitLeapMonth_gregorianCalendar() {
48+
// Test leap month with a calendar that does not observe leap month
49+
50+
// Gregorian: 2023-03-22.
51+
let date1 = Date(timeIntervalSinceReferenceDate: 701161200)
52+
// Gregorian: 2023-03-02.
53+
let date2 = Date(timeIntervalSinceReferenceDate: 699433200)
54+
55+
var calendar = Calendar(identifier: .gregorian)
56+
calendar.timeZone = .gmt
57+
58+
let minRange = calendar.minimumRange(of: .isLeapMonth)
59+
#expect(minRange?.lowerBound == 0)
60+
#expect(minRange?.count == 1)
61+
62+
let maxRange = calendar.maximumRange(of: .isLeapMonth)
63+
#expect(maxRange?.lowerBound == 0)
64+
#expect(maxRange?.count == 1)
65+
66+
let leapMonthRange = calendar.range(of: .isLeapMonth, in: .year, for: date1)
67+
#expect(leapMonthRange == nil)
68+
69+
let dateIntervial = calendar.dateInterval(of: .isLeapMonth, for: date1)
70+
#expect(dateIntervial == nil)
71+
72+
// Invalid ordinality flag
73+
let ordinal = calendar.ordinality(of: .isLeapMonth, in: .year, for: date1)
74+
#expect(ordinal == nil)
75+
76+
// Invalid ordinality flag
77+
let ordinal2 = calendar.ordinality(of: .day, in: .isLeapMonth, for: date1)
78+
#expect(ordinal2 == nil)
79+
80+
let extractedComponents = calendar.dateComponents([.year, .month], from: date1)
81+
#expect(extractedComponents.isLeapMonth == false)
82+
#expect(extractedComponents.month == 3)
83+
84+
let isLeap = calendar.component(.isLeapMonth, from: date1)
85+
#expect(isLeap == 0)
86+
87+
let extractedLeapMonthComponents_onlyLeapMonth = calendar.dateComponents([.isLeapMonth], from: date1)
88+
#expect(extractedLeapMonthComponents_onlyLeapMonth.isLeapMonth == false)
89+
90+
let extractedLeapMonthComponents = calendar.dateComponents([.isLeapMonth, .month], from: date1)
91+
#expect(extractedLeapMonthComponents.isLeapMonth == false)
92+
#expect(extractedLeapMonthComponents.month == 3)
93+
94+
let isEqualMonth = calendar.isDate(date1, equalTo: date2, toGranularity: .month)
95+
#expect(isEqualMonth) // Both are in month 3
96+
97+
let isEqualLeapMonth = calendar.isDate(date1, equalTo: date2, toGranularity: .isLeapMonth)
98+
#expect(isEqualLeapMonth) // Both are not in leap month
99+
100+
// Invalid granularity flag. Return what we return for other invalid `Calendar.Component` inputs
101+
let result = calendar.compare(date1, to: date2, toGranularity: .month)
102+
#expect(result == .orderedSame)
103+
104+
// Invalid granularity flag. Return what we return for other invalid `Calendar.Component` inputs
105+
let onlyLeapMonthComparisonResult = calendar.compare(date1, to: date2, toGranularity: .isLeapMonth)
106+
#expect(onlyLeapMonthComparisonResult == .orderedSame)
107+
108+
let nextLeapMonthDate = calendar.nextDate(after: date1, matching: DateComponents(isLeapMonth: true), matchingPolicy: .strict)
109+
#expect(nextLeapMonthDate == nil) // There is not a date in Gregorian that is a leap month
110+
111+
#if FIXED_SINGLE_LEAPMONTH
112+
let nextNonLeapMonthDate = calendar.nextDate(after: date1, matching: DateComponents(isLeapMonth: false), matchingPolicy: .strict)
113+
#expect(nextNonLeapMonthDate == date1) // date1 matches the condition already
114+
#endif
115+
116+
var settingLeapMonthComponents = calendar.dateComponents([.year, .month, .day], from: date1)
117+
settingLeapMonthComponents.isLeapMonth = true
118+
let settingLeapMonthDate = calendar.date(from: settingLeapMonthComponents)
119+
#expect(settingLeapMonthDate == nil) // There is not a date in Gregorian that is a leap month
120+
121+
var settingNonLeapMonthComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date1)
122+
settingNonLeapMonthComponents.isLeapMonth = false
123+
let settingNonLeapMonthDate = calendar.date(from: settingNonLeapMonthComponents)
124+
#expect(settingNonLeapMonthDate == date1) // date1 matches the condition already
125+
126+
let diffComponents = calendar.dateComponents([.month, .day, .isLeapMonth], from: date1, to: date2)
127+
#expect(diffComponents.month == 0)
128+
#expect(diffComponents.isLeapMonth == nil)
129+
#expect(diffComponents.day == -20)
130+
131+
let addedDate = calendar.date(byAdding: .isLeapMonth, value: 1, to: date1)
132+
#expect(addedDate == nil)
133+
134+
// Invalid argument; cannot add a boolean component with an integer value
135+
let addedDate_notLeap = calendar.date(byAdding: .isLeapMonth, value: 0, to: date1)
136+
#expect(addedDate_notLeap == nil)
137+
}
138+
46139
// MARK: Date from components
47140

48141
@Test func testDateFromComponents() {

0 commit comments

Comments
 (0)