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.