Skip to content

Work towards leap month bug fixes #1399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
93 changes: 93 additions & 0 deletions Tests/FoundationEssentialsTests/GregorianCalendarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down