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
38 changes: 24 additions & 14 deletions docs/type-to-string-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,20 @@ public static partial class DateFormatter

static string GetJsonDatePart(DateTime value)
{
if (value.TimeOfDay == TimeSpan.Zero)
// ticks, not the Second/Millisecond properties, since sub-millisecond ticks leave both of those zero
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-dd HH:mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", Culture.InvariantCulture);
}
Expand All @@ -178,17 +181,19 @@ public static partial class DateFormatter

static string GetParameterDatePart(DateTime value)
{
if (value.TimeOfDay == TimeSpan.Zero)
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm-ss", Culture.InvariantCulture);
}
Expand All @@ -197,7 +202,7 @@ public static partial class DateFormatter
}
}
```
<sup><a href='/src/Verify/Serialization/DateFormatter_DateTime.cs#L1-L68' title='Snippet source file'>snippet source</a> | <a href='#snippet-DateFormatter_DateTime.cs' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Verify/Serialization/DateFormatter_DateTime.cs#L1-L73' title='Snippet source file'>snippet source</a> | <a href='#snippet-DateFormatter_DateTime.cs' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -221,17 +226,20 @@ public static partial class DateFormatter

static string GetJsonDatePart(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
// ticks, not the Second/Millisecond properties, since sub-millisecond ticks leave both of those zero
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-dd HH:mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", Culture.InvariantCulture);
}
Expand All @@ -249,17 +257,19 @@ public static partial class DateFormatter

static string GetParameterDatePart(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm-ss", Culture.InvariantCulture);
}
Expand Down Expand Up @@ -295,7 +305,7 @@ public static partial class DateFormatter
}
}
```
<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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
0: {
dateJson: 2000-10-01 Utc,
dateParameter: 2000-10-01Utc,
offsetJson: 2000-10-01 +0,
offsetParameter: 2000-10-01+0
},
1: {
dateJson: 2000-10-01 00:00:00.0000001 Utc,
dateParameter: 2000-10-01T00-00-00.0000001Utc,
offsetJson: 2000-10-01 00:00:00.0000001 +0,
offsetParameter: 2000-10-01T00-00-00.0000001+0
},
10000: {
dateJson: 2000-10-01 00:00:00.001 Utc,
dateParameter: 2000-10-01T00-00-00.001Utc,
offsetJson: 2000-10-01 00:00:00.001 +0,
offsetParameter: 2000-10-01T00-00-00.001+0
},
10000000: {
dateJson: 2000-10-01 00:00:01 Utc,
dateParameter: 2000-10-01T00-00-01Utc,
offsetJson: 2000-10-01 00:00:01 +0,
offsetParameter: 2000-10-01T00-00-01+0
},
10000001: {
dateJson: 2000-10-01 00:00:01.0000001 Utc,
dateParameter: 2000-10-01T00-00-01.0000001Utc,
offsetJson: 2000-10-01 00:00:01.0000001 +0,
offsetParameter: 2000-10-01T00-00-01.0000001+0
},
2: {
dateJson: 2000-10-01 00:00:00.0000002 Utc,
dateParameter: 2000-10-01T00-00-00.0000002Utc,
offsetJson: 2000-10-01 00:00:00.0000002 +0,
offsetParameter: 2000-10-01T00-00-00.0000002+0
},
600000000: {
dateJson: 2000-10-01 00:01 Utc,
dateParameter: 2000-10-01T00-01Utc,
offsetJson: 2000-10-01 00:01 +0,
offsetParameter: 2000-10-01T00-01+0
},
600000001: {
dateJson: 2000-10-01 00:01:00.0000001 Utc,
dateParameter: 2000-10-01T00-01-00.0000001Utc,
offsetJson: 2000-10-01 00:01:00.0000001 +0,
offsetParameter: 2000-10-01T00-01-00.0000001+0
}
}
49 changes: 49 additions & 0 deletions src/Verify.Tests/DateFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,55 @@ await Verify(new
});
}

[Fact]
public void SubMillisecondTicksDoNotCollide()
{
var date = new DateTime(2000, 10, 1, 0, 0, 0, DateTimeKind.Utc);
Assert.NotEqual(
DateFormatter.ToParameterString(date.AddTicks(1)),
DateFormatter.ToParameterString(date.AddTicks(2)));

var offset = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.Zero);
Assert.NotEqual(
DateFormatter.ToParameterString(offset.AddTicks(1)),
DateFormatter.ToParameterString(offset.AddTicks(2)));
}

[Fact]
public Task SubMillisecondTicks()
{
var date = new DateTime(2000, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var offset = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.Zero);

var values = new Dictionary<string, object>();
foreach (var ticks in tickOffsets)
{
values.Add(
ticks.ToString(),
new
{
dateJson = DateFormatter.Convert(date.AddTicks(ticks)),
dateParameter = DateFormatter.ToParameterString(date.AddTicks(ticks)),
offsetJson = DateFormatter.Convert(offset.AddTicks(ticks)),
offsetParameter = DateFormatter.ToParameterString(offset.AddTicks(ticks))
});
}

return Verify(values);
}

static long[] tickOffsets =
[
0,
1,
2,
TimeSpan.TicksPerMillisecond,
TimeSpan.TicksPerSecond,
TimeSpan.TicksPerSecond + 1,
TimeSpan.TicksPerMinute,
TimeSpan.TicksPerMinute + 1
];

static bool[] bools =
[
true,
Expand Down
17 changes: 11 additions & 6 deletions src/Verify/Serialization/DateFormatter_DateTime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ public static string Convert(DateTime value)

static string GetJsonDatePart(DateTime value)
{
if (value.TimeOfDay == TimeSpan.Zero)
// ticks, not the Second/Millisecond properties, since sub-millisecond ticks leave both of those zero
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-dd HH:mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", Culture.InvariantCulture);
}
Expand All @@ -48,17 +51,19 @@ public static string ToParameterString(DateTime value)

static string GetParameterDatePart(DateTime value)
{
if (value.TimeOfDay == TimeSpan.Zero)
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm-ss", Culture.InvariantCulture);
}
Expand Down
17 changes: 11 additions & 6 deletions src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ public static string Convert(DateTimeOffset value)

static string GetJsonDatePart(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
// ticks, not the Second/Millisecond properties, since sub-millisecond ticks leave both of those zero
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-dd HH:mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-dd HH:mm:ss", Culture.InvariantCulture);
}
Expand All @@ -39,17 +42,19 @@ public static string ToParameterString(DateTimeOffset value)

static string GetParameterDatePart(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
var ticks = value.TimeOfDay.Ticks;

if (ticks == 0)
{
return value.ToString("yyyy-MM-dd", Culture.InvariantCulture);
}

if (value is {Second: 0, Millisecond: 0})
if (ticks % TimeSpan.TicksPerMinute == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm", Culture.InvariantCulture);
}

if (value.Millisecond == 0)
if (ticks % TimeSpan.TicksPerSecond == 0)
{
return value.ToString("yyyy-MM-ddTHH-mm-ss", Culture.InvariantCulture);
}
Expand Down
2 changes: 1 addition & 1 deletion src/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des
- [ ] **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.**
- [x] **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.

- [ ] **`#` in parameter values collides with the indexed-target namespace.**
Expand Down
Loading