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.**