From f45f2a69710d51b4a058168570263f46ebd5df01 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 17 Aug 2026 22:00:58 +1000 Subject: [PATCH] Sign sub-hour UTC offsets GetDateOffset took the sign from the hour component, which is zero for an offset of less than an hour, so TimeSpan.FromMinutes(-30) rendered as 0-30 while its positive twin rendered as +0-30. The sign now comes from the offset itself and the components are rendered unsigned. Only reachable with a constructed offset: no real timezone sits in that range. --- docs/type-to-string-mapping.md | 33 ++++++++----------- src/Verify.Tests/DateFormatterTests.cs | 18 ++++++++++ .../DateFormatter_DateTimeOffset.cs | 31 +++++++---------- src/todo.md | 2 +- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/docs/type-to-string-mapping.md b/docs/type-to-string-mapping.md index 3f5f3f968..f2f5e31bb 100644 --- a/docs/type-to-string-mapping.md +++ b/docs/type-to-string-mapping.md @@ -278,39 +278,32 @@ public static partial class DateFormatter } // Interpolation formats with the current culture, and NumberFormatInfo.NegativeSign is not "-" - // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. A negative offset carries that - // sign into both snapshot content and parameter file names, so the culture is pinned here the - // same way it is for every date part above + // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. The offset reaches both snapshot + // content and parameter file names, so the culture is pinned here the same way it is for every + // date part above, and the sign is written as a literal rather than taken from a negative number. static string GetDateOffset(DateTimeOffset value) { var offset = value.Offset; - if (offset > TimeSpan.Zero) + if (offset == TimeSpan.Zero) { - if (offset.Minutes == 0) - { - return FormattableString.Invariant($"+{offset.TotalHours:0}"); - } - - return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}"); + return "+0"; } - if (offset < TimeSpan.Zero) - { - if (offset.Minutes == 0) - { - return FormattableString.Invariant($"{offset.Hours:0}"); - } + // The sign belongs to the offset as a whole. Taking it from the hour component + // instead loses it for a sub hour offset, where that component is zero. + var sign = offset < TimeSpan.Zero ? '-' : '+'; - // Minutes is negative too, which is what renders the separator - return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}"); + if (offset.Minutes == 0) + { + return FormattableString.Invariant($"{sign}{Math.Abs(offset.TotalHours):0}"); } - return "+0"; + return FormattableString.Invariant($"{sign}{Math.Abs(offset.Hours):0}-{Math.Abs(offset.Minutes):00}"); } } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Verify.Tests/DateFormatterTests.cs b/src/Verify.Tests/DateFormatterTests.cs index a32962e26..4d32e2f2f 100644 --- a/src/Verify.Tests/DateFormatterTests.cs +++ b/src/Verify.Tests/DateFormatterTests.cs @@ -276,6 +276,24 @@ public Task SubMillisecondTicks() TimeSpan.TicksPerMinute + 1 ]; + // A sub hour offset has a zero hour component, so the sign has to come from + // the offset itself + [Fact] + public void SubHourOffsets() + { + var negative = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromMinutes(-30)); + Assert.Equal("2000-10-01 -0-30", DateFormatter.Convert(negative)); + Assert.Equal("2000-10-01-0-30", DateFormatter.ToParameterString(negative)); + + var positive = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromMinutes(30)); + Assert.Equal("2000-10-01 +0-30", DateFormatter.Convert(positive)); + Assert.Equal("2000-10-01+0-30", DateFormatter.ToParameterString(positive)); + + Assert.NotEqual( + DateFormatter.ToParameterString(negative), + DateFormatter.ToParameterString(positive)); + } + static bool[] bools = [ true, diff --git a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs index 3ca4bc603..ce3e11744 100644 --- a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs +++ b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs @@ -63,34 +63,27 @@ static string GetParameterDatePart(DateTimeOffset value) } // Interpolation formats with the current culture, and NumberFormatInfo.NegativeSign is not "-" - // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. A negative offset carries that - // sign into both snapshot content and parameter file names, so the culture is pinned here the - // same way it is for every date part above + // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. The offset reaches both snapshot + // content and parameter file names, so the culture is pinned here the same way it is for every + // date part above, and the sign is written as a literal rather than taken from a negative number. static string GetDateOffset(DateTimeOffset value) { var offset = value.Offset; - if (offset > TimeSpan.Zero) + if (offset == TimeSpan.Zero) { - if (offset.Minutes == 0) - { - return FormattableString.Invariant($"+{offset.TotalHours:0}"); - } - - return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}"); + return "+0"; } - if (offset < TimeSpan.Zero) - { - if (offset.Minutes == 0) - { - return FormattableString.Invariant($"{offset.Hours:0}"); - } + // The sign belongs to the offset as a whole. Taking it from the hour component + // instead loses it for a sub hour offset, where that component is zero. + var sign = offset < TimeSpan.Zero ? '-' : '+'; - // Minutes is negative too, which is what renders the separator - return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}"); + if (offset.Minutes == 0) + { + return FormattableString.Invariant($"{sign}{Math.Abs(offset.TotalHours):0}"); } - return "+0"; + return FormattableString.Invariant($"{sign}{Math.Abs(offset.Hours):0}-{Math.Abs(offset.Minutes):00}"); } } \ No newline at end of file diff --git a/src/todo.md b/src/todo.md index 26b29acd2..e40e69c54 100644 --- a/src/todo.md +++ b/src/todo.md @@ -73,7 +73,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des - [x] **`FlattenMessage` omits the joining space after a line ending in `.`.** `Verify/Combinations/CombinationResultsConverter.cs:168-183` — two-line messages (net48 `ArgumentNullException`) render as `"Value cannot be null.Parameter name: p"`. -- [ ] **Negative sub-hour offsets render unsigned.** +- [x] **Negative sub-hour offsets render unsigned.** `Verify/Serialization/DateFormatter_DateTimeOffset.cs:74-82` — `TimeSpan.FromMinutes(-30)` renders `0-30` (positive twin is `+0-30`). No real timezone in that range; constructed offsets only. - [x] **Mismatch crash for handle-based `FileStream` received streams.**