Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/type-to-string-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
}
}
```
<sup><a href='/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs#L1-L86' title='Snippet source file'>snippet source</a> | <a href='#snippet-DateFormatter_DateTimeOffset.cs' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs#L1-L91' title='Snippet source file'>snippet source</a> | <a href='#snippet-DateFormatter_DateTimeOffset.cs' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down
33 changes: 33 additions & 0 deletions src/Verify.Tests/DateFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
13 changes: 9 additions & 4 deletions src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions src/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading