From 5e8f75eae52d51eef81f8a64752963a27f447151 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 17 Aug 2026 20:09:51 +1000 Subject: [PATCH] Format UTC offsets with invariant culture GetDateOffset used plain interpolation, so the numbers were rendered with CurrentCulture while the rest of DateFormatter uses InvariantCulture. Under a culture with a non-ascii negative sign (for example ar-SA, which uses U+061C followed by "-") a DateTimeOffset with a negative offset produced a parameter string, and hence a file name, that never matched a snapshot committed from an en-US machine. The same value also leaked into snapshot content via Convert when date scrubbing is off. --- docs/type-to-string-mapping.md | 8 ++--- src/Verify.Tests/DateFormatterTests.cs | 30 +++++++++++++++++++ .../DateFormatter_DateTimeOffset.cs | 8 ++--- src/todo.md | 2 +- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/docs/type-to-string-mapping.md b/docs/type-to-string-mapping.md index a5f43b8c9b..5dea3e453e 100644 --- a/docs/type-to-string-mapping.md +++ b/docs/type-to-string-mapping.md @@ -275,20 +275,20 @@ public static partial class DateFormatter { if (offset.Minutes == 0) { - return $"+{offset.TotalHours:0}"; + return $"+{offset.TotalHours.ToString("0", Culture.InvariantCulture)}"; } - return $"+{offset.Hours:0}-{offset.Minutes:00}"; + return $"+{offset.Hours.ToString("0", Culture.InvariantCulture)}-{offset.Minutes.ToString("00", Culture.InvariantCulture)}"; } if (offset < TimeSpan.Zero) { if (offset.Minutes == 0) { - return $"{offset.Hours:0}"; + return offset.Hours.ToString("0", Culture.InvariantCulture); } - return $"{offset.Hours:0}{offset.Minutes:00}"; + return $"{offset.Hours.ToString("0", Culture.InvariantCulture)}{offset.Minutes.ToString("00", Culture.InvariantCulture)}"; } return "+0"; diff --git a/src/Verify.Tests/DateFormatterTests.cs b/src/Verify.Tests/DateFormatterTests.cs index 6b034affc6..55e8576a4b 100644 --- a/src/Verify.Tests/DateFormatterTests.cs +++ b/src/Verify.Tests/DateFormatterTests.cs @@ -194,6 +194,36 @@ await Verify(new }); } + [Fact] + public void OffsetIsNotAffectedByCurrentCulture() + { + // some cultures (for example ar-SA) use a negative sign that is not "-" + var culture = (CultureInfo) CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NegativeSign = "!"; + + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + + var negativeHalfHour = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromHours(-1.5)); + Assert.Equal("2000-10-01 -1-30", DateFormatter.Convert(negativeHalfHour)); + Assert.Equal("2000-10-01-1-30", DateFormatter.ToParameterString(negativeHalfHour)); + + var negativeWholeHour = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromHours(-5)); + Assert.Equal("2000-10-01 -5", DateFormatter.Convert(negativeWholeHour)); + Assert.Equal("2000-10-01-5", DateFormatter.ToParameterString(negativeWholeHour)); + + var positiveHalfHour = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromHours(1.5)); + Assert.Equal("2000-10-01 +1-30", DateFormatter.Convert(positiveHalfHour)); + Assert.Equal("2000-10-01+1-30", DateFormatter.ToParameterString(positiveHalfHour)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + static bool[] bools = [ true, diff --git a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs index cee516c933..36cbd9c6cd 100644 --- a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs +++ b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs @@ -65,20 +65,20 @@ static string GetDateOffset(DateTimeOffset value) { if (offset.Minutes == 0) { - return $"+{offset.TotalHours:0}"; + return $"+{offset.TotalHours.ToString("0", Culture.InvariantCulture)}"; } - return $"+{offset.Hours:0}-{offset.Minutes:00}"; + return $"+{offset.Hours.ToString("0", Culture.InvariantCulture)}-{offset.Minutes.ToString("00", Culture.InvariantCulture)}"; } if (offset < TimeSpan.Zero) { if (offset.Minutes == 0) { - return $"{offset.Hours:0}"; + return offset.Hours.ToString("0", Culture.InvariantCulture); } - return $"{offset.Hours:0}{offset.Minutes:00}"; + return $"{offset.Hours.ToString("0", Culture.InvariantCulture)}{offset.Minutes.ToString("00", Culture.InvariantCulture)}"; } return "+0"; diff --git a/src/todo.md b/src/todo.md index 03784f69b0..41ada29097 100644 --- a/src/todo.md +++ b/src/todo.md @@ -29,7 +29,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des ## Correctness, narrower triggers -- [ ] **Negative UTC offsets formatted with current culture.** +- [x] **Negative UTC offsets formatted with current culture.** `Verify/Serialization/DateFormatter_DateTimeOffset.cs:78,81` — plain interpolation (`$"{offset.Hours:0}"`) uses `CurrentCulture` while every other call in the file passes `Culture.InvariantCulture`. Under `ar-SA` the negative sign renders as invisible U+061C + `-`, so a `DateTimeOffset` parameter with offset `-05:00` produces a filename that never matches a snapshot committed from an en-US machine. Also leaks into snapshot content via `Convert` when date scrubbing is off. - [ ] **Sub-millisecond date parameters collide.**