A couple of unit tests for an application failed on a iOS 17.2.1 device and I could break down the problem to some strange behaviour when applying a dictionary + DateComponents
keys. The tests had been running fine with iOS 16.x
Here is some simplified code to reproduce the behaviour in Playground:
let date1 = try! Date("2023-12-31T00:00:00Z", strategy: .iso8601)
let date2 = try! Date("2024-01-31T00:00:00Z", strategy: .iso8601)
let date3 = try! Date("2024-02-28T00:00:00Z", strategy: .iso8601)
let date1dc = Calendar.current.dateComponents([.year, .month], from: date1)
let date2dc = Calendar.current.dateComponents([.year, .month], from: date2)
let date3dc = Calendar.current.dateComponents([.year, .month], from: date3)
let dc1 = DateComponents(year: 2023, month: 12)
let dc2 = DateComponents(year: 2024, month: 01)
let dc3 = DateComponents(year: 2024, month: 02)
let data: [DateComponents: String] = [
dc1: "One", dc2: "Two", dc3: "Three"
]
print(date1dc == dc1)
print(date2dc == dc2)
print(date3dc == dc3)
print("--------------------------------")
print(data[dc1])
print(data[dc2])
print(data[dc3])
print("--------------------------------")
print(data[date1dc])
print(data[date2dc])
print(data[date3dc])
The output for date1dc,
date2dc
and date3dc
now is random:
true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
Optional("One")
nil
Optional("Three")
or
true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
nil
nil
nil
or
true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
nil
nil
Optional("Three")
For me it looks like a serious foundation bug, but maybe I'm missing something.
Yep it appears that DateComponents
no longer implements Hashable
correctly. In the Swift REPL on Sonoma I get this:
1> import Foundation
2> var dc1 = Calendar.current.dateComponents([.year, .month], from: Date())
3> var dc2 = DateComponents(year: 2024, month: 1)
4> print(dc1 == dc2)
true
5> print(dc1.hashValue == dc2.hashValue)
false
The rule is: if two values are equal (according to their Equatable
implementation) then their hashValue
must also be equal. As seen here, this doesn’t hold. Here’s a clue to the cause:
6> print(dc1.isLeapMonth)
Optional(false)
7> print(dc2.isLeapMonth)
nil
Looking at the source code here shows that hash(into:)
does include isLeapMonth
in the hash calculation. And now we can test that theory:
8> dc2.isLeapMonth = dc1.isLeapMonth
9> print(dc1 == dc2 && dc1.hashValue == dc2.hashValue)
true