diff --git a/docs/type-to-string-mapping.md b/docs/type-to-string-mapping.md index a5f43b8c9..5e3176886 100644 --- a/docs/type-to-string-mapping.md +++ b/docs/type-to-string-mapping.md @@ -267,6 +267,10 @@ public static partial class DateFormatter return value.ToString("yyyy-MM-ddTHH-mm-ss.FFFFFFF", Culture.InvariantCulture); } + // 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 static string GetDateOffset(DateTimeOffset value) { var offset = value.Offset; @@ -275,27 +279,28 @@ public static partial class DateFormatter { if (offset.Minutes == 0) { - return $"+{offset.TotalHours:0}"; + return FormattableString.Invariant($"+{offset.TotalHours:0}"); } - return $"+{offset.Hours:0}-{offset.Minutes:00}"; + return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}"); } if (offset < TimeSpan.Zero) { if (offset.Minutes == 0) { - return $"{offset.Hours:0}"; + return FormattableString.Invariant($"{offset.Hours:0}"); } - return $"{offset.Hours:0}{offset.Minutes:00}"; + // Minutes is negative too, which is what renders the separator + return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}"); } return "+0"; } } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Verify.Tests/DateFormatterTests.cs b/src/Verify.Tests/DateFormatterTests.cs index 6b034affc..863cff4c6 100644 --- a/src/Verify.Tests/DateFormatterTests.cs +++ b/src/Verify.Tests/DateFormatterTests.cs @@ -28,6 +28,39 @@ public Task DateTimeOtherTimeZoneNegativeToParameterString() return Verify(DateFormatter.ToParameterString(date)); } + // NumberFormatInfo.NegativeSign is not "-" in every culture: sv-SE renders U+2212 and ar-SA + // prefixes U+061C to it. The offset is both snapshot content and part of a parameter file + // name, so a negative offset rendered on one machine has to equal the snapshot committed + // from another. The sign is set explicitly rather than by picking a real culture, so the + // assertion does not move with the ICU data the test happens to run against + [Theory] + [InlineData(-1.5, "2000-10-01 -1-30", "2000-10-01-1-30")] + [InlineData(-5, "2000-10-01 -5", "2000-10-01-5")] + [InlineData(1.5, "2000-10-01 +1-30", "2000-10-01+1-30")] + [InlineData(5, "2000-10-01 +5", "2000-10-01+5")] + [InlineData(0, "2000-10-01 +0", "2000-10-01+0")] + public void OffsetDoesNotTakeTheCurrentCultureNegativeSign(double hours, string expectedJson, string expectedParameter) + { + var date = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromHours(hours)); + + // U+2212 MINUS SIGN, as sv-SE uses + var culture = (CultureInfo) CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NegativeSign = "−"; + + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + + Assert.Equal(expectedJson, DateFormatter.Convert(date)); + Assert.Equal(expectedParameter, DateFormatter.ToParameterString(date)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + [Fact] public Task DateTimeLocalToJsonString() { diff --git a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs index cee516c93..279c7e61c 100644 --- a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs +++ b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs @@ -57,6 +57,10 @@ static string GetParameterDatePart(DateTimeOffset value) return value.ToString("yyyy-MM-ddTHH-mm-ss.FFFFFFF", Culture.InvariantCulture); } + // 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 static string GetDateOffset(DateTimeOffset value) { var offset = value.Offset; @@ -65,20 +69,21 @@ static string GetDateOffset(DateTimeOffset value) { if (offset.Minutes == 0) { - return $"+{offset.TotalHours:0}"; + return FormattableString.Invariant($"+{offset.TotalHours:0}"); } - return $"+{offset.Hours:0}-{offset.Minutes:00}"; + return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}"); } if (offset < TimeSpan.Zero) { if (offset.Minutes == 0) { - return $"{offset.Hours:0}"; + return FormattableString.Invariant($"{offset.Hours:0}"); } - return $"{offset.Hours:0}{offset.Minutes:00}"; + // Minutes is negative too, which is what renders the separator + return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}"); } return "+0"; diff --git a/src/todo.md b/src/todo.md index 03784f69b..a4e3a14bc 100644 --- a/src/todo.md +++ b/src/todo.md @@ -29,8 +29,8 @@ 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.** - `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. +- [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. Wider than first recorded: `sv-SE` renders U+2212 MINUS SIGN and `fa-IR` U+200E + U+2212, so the sign is visibly wrong, not just invisible. - [ ] **Sub-millisecond date parameters collide.** `Verify/Serialization/DateFormatter_DateTime.cs` (and the `DateTimeOffset` twin) use `Second == 0` / `Millisecond == 0` to omit the fraction, but sub-millisecond ticks leave those properties 0. `AddTicks(1)` and `AddTicks(2)` cases format identically → spurious "prefix has already been used" (or silent sharing of one verified file). Correct check is ticks-based.