diff --git a/docs/type-to-string-mapping.md b/docs/type-to-string-mapping.md
index 3f5f3f968..f2f5e31bb 100644
--- a/docs/type-to-string-mapping.md
+++ b/docs/type-to-string-mapping.md
@@ -278,39 +278,32 @@ public static partial class DateFormatter
}
// 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
+ // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. The offset reaches both snapshot
+ // content and parameter file names, so the culture is pinned here the same way it is for every
+ // date part above, and the sign is written as a literal rather than taken from a negative number.
static string GetDateOffset(DateTimeOffset value)
{
var offset = value.Offset;
- if (offset > TimeSpan.Zero)
+ if (offset == TimeSpan.Zero)
{
- if (offset.Minutes == 0)
- {
- return FormattableString.Invariant($"+{offset.TotalHours:0}");
- }
-
- return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}");
+ return "+0";
}
- if (offset < TimeSpan.Zero)
- {
- if (offset.Minutes == 0)
- {
- return FormattableString.Invariant($"{offset.Hours:0}");
- }
+ // The sign belongs to the offset as a whole. Taking it from the hour component
+ // instead loses it for a sub hour offset, where that component is zero.
+ var sign = offset < TimeSpan.Zero ? '-' : '+';
- // Minutes is negative too, which is what renders the separator
- return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}");
+ if (offset.Minutes == 0)
+ {
+ return FormattableString.Invariant($"{sign}{Math.Abs(offset.TotalHours):0}");
}
- return "+0";
+ return FormattableString.Invariant($"{sign}{Math.Abs(offset.Hours):0}-{Math.Abs(offset.Minutes):00}");
}
}
```
-snippet source | anchor
+snippet source | anchor
diff --git a/src/Verify.Tests/DateFormatterTests.cs b/src/Verify.Tests/DateFormatterTests.cs
index a32962e26..4d32e2f2f 100644
--- a/src/Verify.Tests/DateFormatterTests.cs
+++ b/src/Verify.Tests/DateFormatterTests.cs
@@ -276,6 +276,24 @@ public Task SubMillisecondTicks()
TimeSpan.TicksPerMinute + 1
];
+ // A sub hour offset has a zero hour component, so the sign has to come from
+ // the offset itself
+ [Fact]
+ public void SubHourOffsets()
+ {
+ var negative = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromMinutes(-30));
+ Assert.Equal("2000-10-01 -0-30", DateFormatter.Convert(negative));
+ Assert.Equal("2000-10-01-0-30", DateFormatter.ToParameterString(negative));
+
+ var positive = new DateTimeOffset(2000, 10, 1, 0, 0, 0, TimeSpan.FromMinutes(30));
+ Assert.Equal("2000-10-01 +0-30", DateFormatter.Convert(positive));
+ Assert.Equal("2000-10-01+0-30", DateFormatter.ToParameterString(positive));
+
+ Assert.NotEqual(
+ DateFormatter.ToParameterString(negative),
+ DateFormatter.ToParameterString(positive));
+ }
+
static bool[] bools =
[
true,
diff --git a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
index 3ca4bc603..ce3e11744 100644
--- a/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
+++ b/src/Verify/Serialization/DateFormatter_DateTimeOffset.cs
@@ -63,34 +63,27 @@ static string GetParameterDatePart(DateTimeOffset value)
}
// 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
+ // everywhere: sv-SE renders U+2212 and ar-SA prefixes U+061C. The offset reaches both snapshot
+ // content and parameter file names, so the culture is pinned here the same way it is for every
+ // date part above, and the sign is written as a literal rather than taken from a negative number.
static string GetDateOffset(DateTimeOffset value)
{
var offset = value.Offset;
- if (offset > TimeSpan.Zero)
+ if (offset == TimeSpan.Zero)
{
- if (offset.Minutes == 0)
- {
- return FormattableString.Invariant($"+{offset.TotalHours:0}");
- }
-
- return FormattableString.Invariant($"+{offset.Hours:0}-{offset.Minutes:00}");
+ return "+0";
}
- if (offset < TimeSpan.Zero)
- {
- if (offset.Minutes == 0)
- {
- return FormattableString.Invariant($"{offset.Hours:0}");
- }
+ // The sign belongs to the offset as a whole. Taking it from the hour component
+ // instead loses it for a sub hour offset, where that component is zero.
+ var sign = offset < TimeSpan.Zero ? '-' : '+';
- // Minutes is negative too, which is what renders the separator
- return FormattableString.Invariant($"{offset.Hours:0}{offset.Minutes:00}");
+ if (offset.Minutes == 0)
+ {
+ return FormattableString.Invariant($"{sign}{Math.Abs(offset.TotalHours):0}");
}
- return "+0";
+ return FormattableString.Invariant($"{sign}{Math.Abs(offset.Hours):0}-{Math.Abs(offset.Minutes):00}");
}
}
\ No newline at end of file
diff --git a/src/todo.md b/src/todo.md
index 26b29acd2..e40e69c54 100644
--- a/src/todo.md
+++ b/src/todo.md
@@ -73,7 +73,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des
- [x] **`FlattenMessage` omits the joining space after a line ending in `.`.**
`Verify/Combinations/CombinationResultsConverter.cs:168-183` — two-line messages (net48 `ArgumentNullException`) render as `"Value cannot be null.Parameter name: p"`.
-- [ ] **Negative sub-hour offsets render unsigned.**
+- [x] **Negative sub-hour offsets render unsigned.**
`Verify/Serialization/DateFormatter_DateTimeOffset.cs:74-82` — `TimeSpan.FromMinutes(-30)` renders `0-30` (positive twin is `+0-30`). No real timezone in that range; constructed offsets only.
- [x] **Mismatch crash for handle-based `FileStream` received streams.**