diff --git a/Sources/FoundationEssentials/Formatting/Date+ISO8601FormatStyle.swift b/Sources/FoundationEssentials/Formatting/Date+ISO8601FormatStyle.swift index b56023a4e7..da42d6c9e1 100644 --- a/Sources/FoundationEssentials/Formatting/Date+ISO8601FormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/Date+ISO8601FormatStyle.swift @@ -357,13 +357,35 @@ extension Date.ISO8601FormatStyle : FormatStyle { } } + // When fractional seconds are included, the output's finest unit is the millisecond, so + // the sub-millisecond part is the remainder we are about to drop. Round the date to the + // nearest millisecond here, at the Date entry point, before extracting components. This + // lets the calendar carry any overflow correctly across seconds, minutes, hours, days, and + // the time zone boundary. Coarser fields keep truncating, so date-only and whole-second + // formatting are unchanged. + var value = value + if includingFractionalSeconds { + let interval = value.timeIntervalSinceReferenceDate + value = Date(timeIntervalSinceReferenceDate: (interval * 1000.0).rounded() / 1000.0) + } + let secondsFromGMT: Int? - let components = componentsFormatStyle._calendar._dateComponents(whichComponents, from: value) + var components = componentsFormatStyle._calendar._dateComponents(whichComponents, from: value) if fields.contains(.timeZone) { secondsFromGMT = timeZone.secondsFromGMT(for: value) } else { secondsFromGMT = nil } + + // The date is already rounded to the nearest millisecond above, but the calendar extracts + // the nanosecond from a `Double` fraction, so it lands a hair off the exact millisecond + // (e.g. 122999906 for .123). Snap the nanosecond onto the nearest millisecond so the shared + // formatter can truncate it back to the right value. The cross-second carry already happened + // on the date, so this only cleans up the float error and never reaches a full second. + if includingFractionalSeconds, let ns = components.nanosecond { + components.nanosecond = Int((Double(ns) / 1_000_000.0).rounded()) * 1_000_000 + } + return format(components, appendingTimeZoneOffset: secondsFromGMT) } diff --git a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift index 7bf1d3dc04..0a17310d2d 100644 --- a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift @@ -346,6 +346,13 @@ extension DateComponents.ISO8601FormatStyle : FormatStyle { if includingFractionalSeconds { let ns = components.nanosecond ?? 0 + // Format the milliseconds field by truncating the nanosecond toward zero. The + // `Date` entry point already rounds to the nearest millisecond and carries any + // overflow across the second boundary through the calendar, then hands this layer + // a nanosecond that sits exactly on a millisecond, so truncation reads it back + // correctly and can never overflow the three-digit field. A bare `DateComponents` + // formatted directly has no anchoring `Date` to carry through, so it keeps the + // original truncating behavior here unchanged. let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero)) buffer.appendElement(asciiPeriod) buffer.append(ms, zeroPad: 3) diff --git a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift index d3b3bb725a..267285eab7 100644 --- a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift +++ b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift @@ -207,9 +207,73 @@ private struct ISO8601FormatStyleFormattingTests { } @Test func rounding() { - // Date is: "1970-01-01 15:35:45.9999" + // Date is: "1970-01-01 15:35:45.9999". Rounding the fractional seconds to the nearest + // millisecond rounds .9999 up to a full second, which carries through the calendar, so the + // output is the next whole second 15:35:46.000, not the truncated 15:35:45.999. let date = Date(timeIntervalSinceReferenceDate: -978251054.0 - 0.0001) let str = Date.ISO8601FormatStyle().timeZone(separator: .colon).time(includingFractionalSeconds: true).timeSeparator(.colon).format(date) - #expect(str == "15:35:45.999Z") + #expect(str == "15:35:46.000Z") + } + + @Test func fractionalSecondsRoundToNearestMillisecond() throws { + // A `Date` built from a fractional `TimeInterval` at a present-day magnitude cannot represent + // the value exactly, so the extracted nanosecond lands just below the intended value (e.g. + // 122999906 for .123). The milliseconds field must round to the nearest millisecond rather + // than truncating toward zero, otherwise it is reported one millisecond too low. + let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true) + + // 1674036251.123 -> "2023-01-18T10:04:11.123" (previously ".122") + #expect(style.format(Date(timeIntervalSince1970: 1_674_036_251.123)) == "2023-01-18T10:04:11.123") + + // Every millisecond from a present-day base must round-trip through the formatted string. + let base = 1_674_036_251.0 + for ms in 0..<1000 { + let formatted = style.format(Date(timeIntervalSince1970: base + Double(ms) / 1000.0)) + let padded = "00\(ms)".suffix(3) + let suffix = ".\(padded)" + #expect(formatted.hasSuffix(suffix), "ms=\(ms) formatted as \(formatted)") + } + + // A sub-millisecond remainder that rounds up to a full millisecond carries into the + // seconds field through the calendar, so .9996 at second 11 reads as the next whole second. + #expect(style.format(Date(timeIntervalSince1970: base + 0.9996)) == "2023-01-18T10:04:12.000") + } + + @Test func fractionalSecondsCarryAtSecondBoundary() throws { + // parkera's scenario from issue #963: a time like HH:MM:59.9996 formatted with three + // fractional digits and round-nearest must not read HH:MM:59.999, which keeps the wrong + // value at the boundary. Because the rounding now happens at the Date entry point, the + // sub-millisecond remainder rounds up and the calendar carries it across the second. + let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true) + + // 1674036299.0 is 2023-01-18T10:04:59. The .9996 remainder rounds up to a full second, so + // the output carries to the next whole second: 10:05:00.000, never 10:04:59.999. + #expect(style.format(Date(timeIntervalSince1970: 1_674_036_299.0 + 0.9996)) == "2023-01-18T10:05:00.000") + + // 0.9999s also carries to the next whole second. + #expect(style.format(Date(timeIntervalSince1970: 1_674_036_251.0 + 0.9999)) == "2023-01-18T10:04:12.000") + } + + @Test func fractionalSecondsCarryAcrossDSTBoundary() throws { + // Carrying a rounded-up millisecond across a second can also cross a daylight saving time + // transition. Use US Pacific, where 2023-03-12 01:59:59.9996 local rounds up into the + // 03:00 wall-clock jump (02:00 does not exist). The calendar must produce a consistent + // wall-clock time and time zone offset for the rounded Date. + guard let pacific = TimeZone(identifier: "America/Los_Angeles") else { return } + let style = Date.ISO8601FormatStyle(timeZone: pacific).year().month().day().time(includingFractionalSeconds: true).timeZone(separator: .colon) + + // 1678615199.0 is 2023-03-12T01:59:59 Pacific (PST, -08:00), the last second before the + // spring-forward gap. Rounding .9996 up carries the wall clock to 03:00:00 PDT (-07:00). + let formatted = style.format(Date(timeIntervalSince1970: 1_678_615_199.0 + 0.9996)) + #expect(formatted == "2023-03-12T03:00:00.000-07:00") + } + + @Test func fractionalSecondsRoundTripParseThenFormat() throws { + // Parsing a millisecond-precision string and formatting it back must be stable: the + // formatted value should match the parsed input, not drift by a millisecond. + let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true) + let input = "2023-01-18T10:04:11.123" + let date = try style.parse(input) + #expect(style.format(date) == input) } }