diff --git a/docs/type-to-string-mapping.md b/docs/type-to-string-mapping.md
index a5f43b8c9..1c2b5a329 100644
--- a/docs/type-to-string-mapping.md
+++ b/docs/type-to-string-mapping.md
@@ -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);
}
@@ -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);
}
@@ -197,7 +202,7 @@ public static partial class DateFormatter
}
}
```
-snippet source | anchor
+snippet source | anchor
@@ -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);
}
@@ -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);
}
@@ -295,7 +305,7 @@ public static partial class DateFormatter
}
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/src/Verify.Tests/DateFormatterTests.SubMillisecondTicks.verified.txt b/src/Verify.Tests/DateFormatterTests.SubMillisecondTicks.verified.txt
new file mode 100644
index 000000000..9639c658f
--- /dev/null
+++ b/src/Verify.Tests/DateFormatterTests.SubMillisecondTicks.verified.txt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/src/Verify.Tests/DateFormatterTests.cs b/src/Verify.Tests/DateFormatterTests.cs
index 6b034affc..898985002 100644
--- a/src/Verify.Tests/DateFormatterTests.cs
+++ b/src/Verify.Tests/DateFormatterTests.cs
@@ -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();
+ 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,
diff --git a/src/Verify/Serialization/DateFormatter_DateTime.cs b/src/Verify/Serialization/DateFormatter_DateTime.cs
index dbc7212b7..3808f5b4c 100644
--- a/src/Verify/Serialization/DateFormatter_DateTime.cs
+++ b/src/Verify/Serialization/DateFormatter_DateTime.cs
@@ -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);
}
@@ -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);
}
diff --git a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
index cee516c93..689d3db86 100644
--- a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
+++ b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
@@ -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);
}
@@ -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);
}
diff --git a/src/todo.md b/src/todo.md
index 03784f69b..13a395117 100644
--- a/src/todo.md
+++ b/src/todo.md
@@ -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.**