From 384031a6a292777c07f5f569d29da85dffb977bd Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Sun, 24 May 2026 20:03:18 -0700 Subject: [PATCH 1/3] Round ISO8601 fractional seconds to the nearest millisecond When formatting with includingFractionalSeconds, the milliseconds field was computed from the nanosecond component by truncating toward zero: let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero)) A Date created from a fractional TimeInterval at a present-day magnitude cannot represent the value exactly. For Date(timeIntervalSince1970: 1674036251.123) the nanosecond component the calendar extracts is 122999906 rather than 123000000, so truncation yields ".122" instead of ".123". The same off-by-one drops the last millisecond for many values, for example ".001" becomes ".000" and ".011" becomes ".010". Round to the nearest millisecond instead. The result is clamped to 999 so a value that rounds up to 1000 does not overflow the three-digit field, matching the existing behavior already covered by the rounding() test for ".9999". Fixes #963. --- .../DateComponents+ISO8601FormatStyle.swift | 8 ++++++- .../ISO8601FormatStyleFormattingTests.swift | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift index 7bf1d3dc04..1484e7871c 100644 --- a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift @@ -346,7 +346,13 @@ extension DateComponents.ISO8601FormatStyle : FormatStyle { if includingFractionalSeconds { let ns = components.nanosecond ?? 0 - let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero)) + // Round to the nearest millisecond rather than truncating. A `Date` created from a + // fractional `TimeInterval` such as `1674036251.123` cannot represent the value + // exactly, so the extracted nanosecond is often slightly below the intended value + // (e.g. 122999906 instead of 123000000). Truncating that toward zero produces an + // off-by-one in the milliseconds field (".122" instead of ".123"). Clamp to 999 so + // a value that rounds up to 1000 does not overflow the three-digit field. + let ms = min(Int((Double(ns) / 1_000_000.0).rounded()), 999) buffer.appendElement(asciiPeriod) buffer.append(ms, zeroPad: 3) } diff --git a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift index d3b3bb725a..d3caa4e782 100644 --- a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift +++ b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift @@ -212,4 +212,27 @@ private struct ISO8601FormatStyleFormattingTests { let str = Date.ISO8601FormatStyle().timeZone(separator: .colon).time(includingFractionalSeconds: true).timeSeparator(.colon).format(date) #expect(str == "15:35:45.999Z") } + + @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 1000 must clamp to .999, never overflow to ".1000". + #expect(style.format(Date(timeIntervalSince1970: base + 0.9996)) == "2023-01-18T10:04:11.999") + } } From 7208ae689de4f1bb3a61d0178b46931ccab9a8de Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 26 May 2026 14:30:10 -0700 Subject: [PATCH 2/3] Add a second-boundary test for the millisecond rounding clamp Cover the scenario from issue #963: a time at HH:MM:59 with a sub-millisecond remainder that rounds up to 1000 ms must clamp to .999 and keep the second at 59, never reading .000 or carrying into the seconds field. --- .../ISO8601FormatStyleFormattingTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift index d3caa4e782..350d0b9957 100644 --- a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift +++ b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift @@ -235,4 +235,16 @@ private struct ISO8601FormatStyleFormattingTests { // A sub-millisecond remainder that rounds up to 1000 must clamp to .999, never overflow to ".1000". #expect(style.format(Date(timeIntervalSince1970: base + 0.9996)) == "2023-01-18T10:04:11.999") } + + @Test func fractionalSecondsClampAtSecondBoundary() throws { + // parkera's scenario from issue #963: a time like HH:MM:59.9999 formatted with three + // fractional digits and round-nearest must not read HH:MM:59.000. The min(..., 999) + // clamp keeps it at .999 rather than wrapping the rounded-up millisecond to zero or + // silently carrying into the seconds field, which this format layer does not do. + let style = Date.ISO8601FormatStyle.iso8601.year().month().day().time(includingFractionalSeconds: true) + + // 1674036299.0 is 2023-01-18T10:04:59. The sub-millisecond remainder rounds up to 1000 + // and clamps to .999, so the second stays 59 and never reads .000. + #expect(style.format(Date(timeIntervalSince1970: 1_674_036_299.0 + 0.9996)) == "2023-01-18T10:04:59.999") + } } From 498594f2422984fd89e7d307e9cf2b56993a9310 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 11 Jun 2026 21:52:25 -0700 Subject: [PATCH 3/3] Move ISO8601 fractional-second rounding to the Date entry point The earlier fix rounded the milliseconds field inside the shared DateComponents formatter and clamped to 999. As parkera pointed out, the clamp preserves a wrong value at the second boundary: a time like 10:04:59.9996 formatted as 10:04:59.999 instead of carrying to 10:05:00.000. Round at the Date entry point instead. Before extracting components, when fractional seconds are included, round the date to the nearest millisecond. The calendar then carries any overflow correctly across seconds, minutes, hours, days, and the time zone boundary. The extracted nanosecond is snapped onto the nearest millisecond to undo the Double fraction error, so the shared formatter can go back to truncating. This scopes the rounding to the case it actually fixes. Rounding happens only when fractional seconds are in the output, at the finest included unit. Coarser fields keep truncating, so date-only and whole-second formatting are unchanged. The bare DateComponents path, which has no anchoring Date and cannot carry, keeps the original truncating behavior with no clamp. Tests cover the second-boundary carry, a DST spring-forward carry, a per-millisecond round-trip sweep, and a parse-then-format round-trip. The existing rounding test is updated to assert the correct carry. --- .../Formatting/Date+ISO8601FormatStyle.swift | 24 ++++++++- .../DateComponents+ISO8601FormatStyle.swift | 15 +++--- .../ISO8601FormatStyleFormattingTests.swift | 53 ++++++++++++++----- 3 files changed, 72 insertions(+), 20 deletions(-) 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 1484e7871c..0a17310d2d 100644 --- a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift @@ -346,13 +346,14 @@ extension DateComponents.ISO8601FormatStyle : FormatStyle { if includingFractionalSeconds { let ns = components.nanosecond ?? 0 - // Round to the nearest millisecond rather than truncating. A `Date` created from a - // fractional `TimeInterval` such as `1674036251.123` cannot represent the value - // exactly, so the extracted nanosecond is often slightly below the intended value - // (e.g. 122999906 instead of 123000000). Truncating that toward zero produces an - // off-by-one in the milliseconds field (".122" instead of ".123"). Clamp to 999 so - // a value that rounds up to 1000 does not overflow the three-digit field. - let ms = min(Int((Double(ns) / 1_000_000.0).rounded()), 999) + // 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 350d0b9957..267285eab7 100644 --- a/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift +++ b/Tests/FoundationEssentialsTests/Formatting/ISO8601FormatStyleFormattingTests.swift @@ -207,10 +207,12 @@ 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 { @@ -232,19 +234,46 @@ private struct ISO8601FormatStyleFormattingTests { #expect(formatted.hasSuffix(suffix), "ms=\(ms) formatted as \(formatted)") } - // A sub-millisecond remainder that rounds up to 1000 must clamp to .999, never overflow to ".1000". - #expect(style.format(Date(timeIntervalSince1970: base + 0.9996)) == "2023-01-18T10:04:11.999") + // 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 fractionalSecondsClampAtSecondBoundary() throws { - // parkera's scenario from issue #963: a time like HH:MM:59.9999 formatted with three - // fractional digits and round-nearest must not read HH:MM:59.000. The min(..., 999) - // clamp keeps it at .999 rather than wrapping the rounded-up millisecond to zero or - // silently carrying into the seconds field, which this format layer does not do. + @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 sub-millisecond remainder rounds up to 1000 - // and clamps to .999, so the second stays 59 and never reads .000. - #expect(style.format(Date(timeIntervalSince1970: 1_674_036_299.0 + 0.9996)) == "2023-01-18T10:04:59.999") + // 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) } }