diff --git a/Dummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs b/Dummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs new file mode 100644 index 0000000..8d995fe --- /dev/null +++ b/Dummies.UnitTests/AnyDateTimeOffsetOffsetTests.cs @@ -0,0 +1,81 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// Behaviour of the offset dimension — WithOffset pins it, +/// WithOffsetBetween draws it bounded, the default stays UTC, and values stay valid at the domain edges +/// because the instant is tightened before the offset is drawn. +/// +public sealed class AnyDateTimeOffsetOffsetTests { + + private const int SampleCount = 200; + + [Fact(DisplayName = "Offset: unconstrained, generated values carry UTC (zero) offset.")] + public void DefaultOffsetIsZero() { + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.DateTimeOffset().Generate().Offset).IsEqualTo(TimeSpan.Zero); + } + } + + [Fact(DisplayName = "WithOffset: every generated value carries the pinned offset.")] + public void WithOffsetPins() { + TimeSpan offset = TimeSpan.FromHours(2); + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.DateTimeOffset().WithOffset(offset).Generate().Offset).IsEqualTo(offset); + } + } + + [Fact(DisplayName = "WithOffsetBetween: offsets stay within the range, in whole minutes, and vary.")] + public void WithOffsetBetweenBounds() { + TimeSpan min = TimeSpan.FromHours(-5); + TimeSpan max = TimeSpan.FromHours(5); + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { + DateTimeOffset value = Any.DateTimeOffset().WithOffsetBetween(min, max).Generate(); + Check.That(value.Offset >= min && value.Offset <= max).IsTrue(); + Check.That(value.Offset.Ticks % TimeSpan.TicksPerMinute).IsEqualTo(0L); + seen.Add(value.Offset); + } + + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + + [Fact(DisplayName = "WithOffset: stays valid and after the floor at the top of the domain.")] + public void WithOffsetValidNearMaxValue() { + DateTimeOffset floor = DateTimeOffset.MaxValue.AddDays(-1); + for (int i = 0; i < SampleCount; i++) { + DateTimeOffset value = Any.DateTimeOffset().After(floor).WithOffset(TimeSpan.FromHours(14)).Generate(); + Check.That(value.Offset).IsEqualTo(TimeSpan.FromHours(14)); + Check.That(value.UtcTicks > floor.UtcTicks).IsTrue(); + } + } + + [Fact(DisplayName = "WithOffset: an instant window with no room for the offset conflicts eagerly.")] + public void WithOffsetImpossibleWindowConflicts() { + // The last 12h of the domain cannot host a +14h offset: the local ticks would overflow. + Check.ThatCode(() => Any.DateTimeOffset().After(DateTimeOffset.MaxValue.AddHours(-12)).WithOffset(TimeSpan.FromHours(14))) + .Throws(); + } + + [Fact(DisplayName = "WithOffset: arguments are validated (whole minutes, ±14:00, ordered range).")] + public void WithOffsetArguments() { + Check.ThatCode(() => Any.DateTimeOffset().WithOffset(TimeSpan.FromSeconds(30))).Throws(); + Check.ThatCode(() => Any.DateTimeOffset().WithOffset(TimeSpan.FromHours(15))).Throws(); + Check.ThatCode(() => Any.DateTimeOffset().WithOffsetBetween(TimeSpan.FromHours(2), TimeSpan.FromHours(-2))).Throws(); + } + + [Fact(DisplayName = "WithOffset: a second, different offset is rejected as already declared.")] + public void WithOffsetDeclaredOnce() { + Check.ThatCode(() => Any.DateTimeOffset().WithOffset(TimeSpan.FromHours(2)).WithOffset(TimeSpan.FromHours(3))) + .Throws(); + // The same offset twice is idempotent, not a conflict. + Check.That(Any.DateTimeOffset().WithOffset(TimeSpan.FromHours(2)).WithOffset(TimeSpan.FromHours(2)).Generate().Offset) + .IsEqualTo(TimeSpan.FromHours(2)); + } + +} diff --git a/Dummies.UnitTests/SurfaceParityTests.cs b/Dummies.UnitTests/SurfaceParityTests.cs index a8b16bf..360bb3b 100644 --- a/Dummies.UnitTests/SurfaceParityTests.cs +++ b/Dummies.UnitTests/SurfaceParityTests.cs @@ -140,6 +140,13 @@ private static string Signature(MethodInfo method) { "Between", "OneOf", "Except", "DifferentFrom", "WithGranularity" ]; + // AnyDateTimeOffset additionally exposes the offset dimension (WithOffset/WithOffsetBetween) — the only instant + // type carrying a second, offset dimension on top of the instant. + private static readonly string[] InstantWithGranularityAndOffsetAlgebra = [ + "After", "AfterOrEqualTo", "Before", "BeforeOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom", "WithGranularity", "WithOffset", "WithOffsetBetween" + ]; + public static IEnumerable Builders() { // Signed integers carry MultipleOf; the binary floats do not; Decimal carries WithScale; TimeSpan (a signed // magnitude) carries WithGranularity — the lattice constraint is what forks the former shared signed family. @@ -158,7 +165,7 @@ public static IEnumerable Builders() { yield return [typeof(AnyUInt64), UnsignedIntegerAlgebra]; yield return [typeof(AnyDateTime), InstantWithGranularityAlgebra]; - yield return [typeof(AnyDateTimeOffset), InstantWithGranularityAlgebra]; + yield return [typeof(AnyDateTimeOffset), InstantWithGranularityAndOffsetAlgebra]; // The remaining scalar builders each carry their own deliberate set. yield return [typeof(AnyBoolean), new[] { "True", "False", "DifferentFrom" }]; diff --git a/Dummies/AnyDateTimeOffset.cs b/Dummies/AnyDateTimeOffset.cs index c7d396c..83bdce9 100644 --- a/Dummies/AnyDateTimeOffset.cs +++ b/Dummies/AnyDateTimeOffset.cs @@ -14,19 +14,23 @@ namespace Dummies; /// draw. /// /// -/// Generated values carry offset (UTC); constraints compare by -/// — the instant, not the local rendering — exactly as -/// 's own comparison operators do. Values supplied to are -/// returned as given, offset included. There is deliberately no clock-relative constraint (no "in the -/// past/future"): a reproducible test pins its reference instants explicitly with and -/// . +/// Constraints compare by — the instant, not the local rendering — exactly +/// as 's own comparison operators do. Unconstrained, generated values carry offset +/// (UTC); / opt the offset +/// dimension into a fixed or bounded whole-minute value so offset-sensitive code can be exercised. Values supplied +/// to are returned as given, offset included. There is deliberately no clock-relative +/// constraint (no "in the past/future"): a reproducible test pins its reference instants explicitly with +/// and . /// public sealed class AnyDateTimeOffset : IAny, IHasRandomSource, ICardinalityHint { + // DateTimeOffset admits an offset in whole minutes within ±14:00. + private const int MaxOffsetMinutes = 14 * 60; + #region Statics members declarations internal static AnyDateTimeOffset Create(RandomSource source) { - return new AnyDateTimeOffset(source, OrdinalIntervalSpec.Unconstrained("DateTimeOffset", ordinal => V(Val(ordinal)), Ord(DateTimeOffset.MinValue), Ord(DateTimeOffset.MaxValue)), null); + return new AnyDateTimeOffset(source, OrdinalIntervalSpec.Unconstrained("DateTimeOffset", ordinal => V(Val(ordinal)), Ord(DateTimeOffset.MinValue), Ord(DateTimeOffset.MaxValue)), null, false, 0, 0); } private static ulong Ord(DateTimeOffset value) { @@ -41,24 +45,45 @@ private static string V(DateTimeOffset value) { return value.ToString("O", CultureInfo.InvariantCulture); } + private static string Render(TimeSpan offset) { + return offset.ToString("c", CultureInfo.InvariantCulture); + } + private static string Join(DateTimeOffset[] values) { return string.Join(", ", values.Select(V)); } + /// Validates a supplied offset (whole minutes, within ±14:00) and returns it in whole minutes. + private static int ValidateOffset(TimeSpan offset, string parameterName) { + if (offset.Ticks % TimeSpan.TicksPerMinute != 0) { throw new ArgumentException("The offset must be a whole number of minutes.", parameterName); } + if (offset < TimeSpan.FromMinutes(-MaxOffsetMinutes) || offset > TimeSpan.FromMinutes(MaxOffsetMinutes)) { + throw new ArgumentOutOfRangeException(parameterName, offset, "The offset must be within ±14:00."); + } + + return (int)(offset.Ticks / TimeSpan.TicksPerMinute); + } + #endregion #region Fields declarations private readonly IReadOnlyDictionary? _allowedOriginals; + private readonly bool _offsetDeclared; + private readonly int _offsetMaxMinutes; + private readonly int _offsetMinMinutes; private readonly RandomSource _source; private readonly OrdinalIntervalSpec _spec; #endregion - private AnyDateTimeOffset(RandomSource source, OrdinalIntervalSpec spec, IReadOnlyDictionary? allowedOriginals) { + private AnyDateTimeOffset(RandomSource source, OrdinalIntervalSpec spec, IReadOnlyDictionary? allowedOriginals, + bool offsetDeclared, int offsetMinMinutes, int offsetMaxMinutes) { _source = source; _spec = spec; _allowedOriginals = allowedOriginals; + _offsetDeclared = offsetDeclared; + _offsetMinMinutes = offsetMinMinutes; + _offsetMaxMinutes = offsetMaxMinutes; } RandomSource? IHasRandomSource.Source => _source; @@ -72,7 +97,7 @@ private AnyDateTimeOffset(RandomSource source, OrdinalIntervalSpec spec, IReadOn /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyDateTimeOffset After(DateTimeOffset instant) { - return new AnyDateTimeOffset(_source, _spec.WithMinimumAbove(Ord(instant), $"After({V(instant)})"), _allowedOriginals); + return With(_spec.WithMinimumAbove(Ord(instant), $"After({V(instant)})")); } /// Requires an instant at or after . @@ -80,7 +105,7 @@ public AnyDateTimeOffset After(DateTimeOffset instant) { /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyDateTimeOffset AfterOrEqualTo(DateTimeOffset instant) { - return new AnyDateTimeOffset(_source, _spec.WithMinimum(Ord(instant), $"AfterOrEqualTo({V(instant)})"), _allowedOriginals); + return With(_spec.WithMinimum(Ord(instant), $"AfterOrEqualTo({V(instant)})")); } /// Requires an instant strictly before . @@ -88,7 +113,7 @@ public AnyDateTimeOffset AfterOrEqualTo(DateTimeOffset instant) { /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyDateTimeOffset Before(DateTimeOffset instant) { - return new AnyDateTimeOffset(_source, _spec.WithMaximumBelow(Ord(instant), $"Before({V(instant)})"), _allowedOriginals); + return With(_spec.WithMaximumBelow(Ord(instant), $"Before({V(instant)})")); } /// Requires an instant at or before . @@ -96,7 +121,7 @@ public AnyDateTimeOffset Before(DateTimeOffset instant) { /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyDateTimeOffset BeforeOrEqualTo(DateTimeOffset instant) { - return new AnyDateTimeOffset(_source, _spec.WithMaximum(Ord(instant), $"BeforeOrEqualTo({V(instant)})"), _allowedOriginals); + return With(_spec.WithMaximum(Ord(instant), $"BeforeOrEqualTo({V(instant)})")); } /// Requires an instant within the inclusive range [, ]. @@ -110,7 +135,7 @@ public AnyDateTimeOffset Between(DateTimeOffset start, DateTimeOffset end) { string constraint = $"Between({V(start)}, {V(end)})"; - return new AnyDateTimeOffset(_source, _spec.WithMinimum(Ord(start), constraint).WithMaximum(Ord(end), constraint), _allowedOriginals); + return With(_spec.WithMinimum(Ord(start), constraint).WithMaximum(Ord(end), constraint)); } /// @@ -128,7 +153,42 @@ public AnyDateTimeOffset WithGranularity(TimeSpan granularity) { string rendered = granularity.ToString("c", CultureInfo.InvariantCulture); - return new AnyDateTimeOffset(_source, _spec.WithStep((ulong)granularity.Ticks, Ord(DateTimeOffset.MinValue), $"WithGranularity({rendered})"), _allowedOriginals); + return With(_spec.WithStep((ulong)granularity.Ticks, Ord(DateTimeOffset.MinValue), $"WithGranularity({rendered})")); + } + + /// + /// Pins the offset dimension to — every generated value carries exactly that offset, + /// rather than the default . The instant is tightened so the value stays valid at + /// the domain edges. Declared once per generator. + /// + /// The offset to pin; a whole number of minutes, within ±14:00. + /// A new generator carrying the added constraint. + /// Thrown when is not a whole number of minutes. + /// Thrown when is outside ±14:00. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDateTimeOffset WithOffset(TimeSpan offset) { + int minutes = ValidateOffset(offset, nameof(offset)); + + return WithOffsetRange(minutes, minutes, $"WithOffset({Render(offset)})"); + } + + /// + /// Draws the offset dimension from the inclusive range [, ] — + /// a bounded, whole-minute offset — so a test can exercise offset-sensitive logic while staying valid. The + /// instant is tightened so every offset in the range stays valid. Declared once per generator. + /// + /// The inclusive lower offset; a whole number of minutes, within ±14:00. + /// The inclusive upper offset; a whole number of minutes, within ±14:00. + /// A new generator carrying the added constraint. + /// Thrown when an offset is not a whole number of minutes, or is after . + /// Thrown when an offset is outside ±14:00. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDateTimeOffset WithOffsetBetween(TimeSpan minimum, TimeSpan maximum) { + int min = ValidateOffset(minimum, nameof(minimum)); + int max = ValidateOffset(maximum, nameof(maximum)); + if (min > max) { throw new ArgumentException($"The minimum offset ({Render(minimum)}) must be at or before the maximum ({Render(maximum)}).", nameof(minimum)); } + + return WithOffsetRange(min, max, $"WithOffsetBetween({Render(minimum)}, {Render(maximum)})"); } /// @@ -151,7 +211,7 @@ public AnyDateTimeOffset OneOf(params DateTimeOffset[] values) { if (!originals.ContainsKey(Ord(value))) { originals.Add(Ord(value), value); } } - return new AnyDateTimeOffset(_source, _spec.WithAllowed(values.Select(Ord).ToArray(), $"OneOf({Join(values)})"), originals); + return new AnyDateTimeOffset(_source, _spec.WithAllowed(values.Select(Ord).ToArray(), $"OneOf({Join(values)})"), originals, _offsetDeclared, _offsetMinMinutes, _offsetMaxMinutes); } /// Requires the instant to be none of the supplied values (compared by instant). @@ -164,7 +224,7 @@ public AnyDateTimeOffset Except(params DateTimeOffset[] values) { if (values is null) { throw new ArgumentNullException(nameof(values)); } if (values.Length == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } - return new AnyDateTimeOffset(_source, _spec.WithExcluded(values.Select(Ord).ToArray(), $"Except({Join(values)})"), _allowedOriginals); + return With(_spec.WithExcluded(values.Select(Ord).ToArray(), $"Except({Join(values)})")); } /// @@ -176,15 +236,47 @@ public AnyDateTimeOffset Except(params DateTimeOffset[] values) { /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. public AnyDateTimeOffset DifferentFrom(DateTimeOffset value) { - return new AnyDateTimeOffset(_source, _spec.WithExcluded([Ord(value)], $"DifferentFrom({V(value)})"), _allowedOriginals); + return With(_spec.WithExcluded([Ord(value)], $"DifferentFrom({V(value)})")); } /// public DateTimeOffset Generate() { - ulong ordinal = _spec.GenerateOrdinal(_source.Current.Random); + Random random = _source.Current.Random; + ulong ordinal = _spec.GenerateOrdinal(random); if (_allowedOriginals is not null && _allowedOriginals.TryGetValue(ordinal, out DateTimeOffset original)) { return original; } + if (!_offsetDeclared) { return Val(ordinal); } + + int minutes = _offsetMinMinutes == _offsetMaxMinutes + ? _offsetMinMinutes + : _offsetMinMinutes + random.Next(_offsetMaxMinutes - _offsetMinMinutes + 1); + TimeSpan offset = TimeSpan.FromMinutes(minutes); + + // The instant domain was tightened when the offset was declared, so the local ticks stay valid here. + return new DateTimeOffset((long)ordinal + offset.Ticks, offset); + } + + /// Carries the offset state forward onto a new spec — every instant constraint routes through here. + private AnyDateTimeOffset With(OrdinalIntervalSpec spec) { + return new AnyDateTimeOffset(_source, spec, _allowedOriginals, _offsetDeclared, _offsetMinMinutes, _offsetMaxMinutes); + } + + private AnyDateTimeOffset WithOffsetRange(int minMinutes, int maxMinutes, string applying) { + if (_offsetDeclared) { + if (_offsetMinMinutes == minMinutes && _offsetMaxMinutes == maxMinutes) { return this; } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because an offset constraint is already defined."); + } + + // Tighten the instant so local ticks = UtcTicks + offset stay in [0, MaxTicks] for every offset in the range; + // the offset can then be drawn independently, never producing an out-of-range DateTimeOffset. + long minOffsetTicks = minMinutes * TimeSpan.TicksPerMinute; + long maxOffsetTicks = maxMinutes * TimeSpan.TicksPerMinute; + ulong lowerUtc = (ulong)Math.Max(0L, -minOffsetTicks); + ulong upperUtc = (ulong)(DateTimeOffset.MaxValue.UtcTicks - Math.Max(0L, maxOffsetTicks)); + + OrdinalIntervalSpec spec = _spec.WithMinimum(lowerUtc, applying).WithMaximum(upperUtc, applying); - return Val(ordinal); + return new AnyDateTimeOffset(_source, spec, _allowedOriginals, true, minMinutes, maxMinutes); } } diff --git a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 8615fd8..81c0407 100644 --- a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -105,6 +105,8 @@ Dummies.AnyDateTimeOffset.Except(params System.DateTimeOffset[]! values) -> Dumm Dummies.AnyDateTimeOffset.Generate() -> System.DateTimeOffset Dummies.AnyDateTimeOffset.OneOf(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithOffset(System.TimeSpan offset) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithOffsetBetween(System.TimeSpan minimum, System.TimeSpan maximum) -> Dummies.AnyDateTimeOffset! Dummies.AnyDecimal Dummies.AnyDecimal.Between(decimal minimum, decimal maximum) -> Dummies.AnyDecimal! Dummies.AnyDecimal.DifferentFrom(decimal value) -> Dummies.AnyDecimal! diff --git a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 22d72f5..c15a0d3 100644 --- a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -90,6 +90,8 @@ Dummies.AnyDateTimeOffset.Except(params System.DateTimeOffset[]! values) -> Dumm Dummies.AnyDateTimeOffset.Generate() -> System.DateTimeOffset Dummies.AnyDateTimeOffset.OneOf(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithOffset(System.TimeSpan offset) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithOffsetBetween(System.TimeSpan minimum, System.TimeSpan maximum) -> Dummies.AnyDateTimeOffset! Dummies.AnyDecimal Dummies.AnyDecimal.Between(decimal minimum, decimal maximum) -> Dummies.AnyDecimal! Dummies.AnyDecimal.DifferentFrom(decimal value) -> Dummies.AnyDecimal! diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 6e4848f..da5fe8f 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -81,6 +81,11 @@ matter — and that is the point. (`WithGranularity(TimeSpan.FromMinutes(15))`) — so tick-precision values never surprise a serialization round-trip. Each is built in one draw, composes with the bounds and exclusions, and conflicts eagerly when the range holds no grid point. +- **Offset-aware `DateTimeOffset`**: unconstrained, `Any.DateTimeOffset()` carries offset + `TimeSpan.Zero` (UTC); `WithOffset(TimeSpan)` pins a whole-minute offset (±14:00) and + `WithOffsetBetween(min, max)` draws a bounded one, so offset-sensitive code (local + rendering, offset arithmetic, "same instant, different offset") is actually exercised. The + instant is tightened first, so the value stays valid even at the edges of the range. - **Values built to satisfy the constraints** — a scalar is constructed directly, never generated-then-filtered. The one exception is excluding values from a string (`Any.String().DifferentFrom(...)`/`Except(...)`): a string has no ordinal mapping to diff --git a/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.fr.md b/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.fr.md new file mode 100644 index 0000000..3794702 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.fr.md @@ -0,0 +1,74 @@ +# ADR-0037 | Faire varier la dimension d'offset de DateTimeOffset + +🌍 🇬🇧 [English](0037-vary-the-datetimeoffset-offset-dimension.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-26 +**Décideurs :** Reefact + +## Contexte + +Un `DateTimeOffset` porte deux dimensions : l'instant (son `UtcTicks`) et le décalage (offset) par rapport à UTC. Le décalage est la raison d'être du type face à un simple `DateTime`. `AnyDateTimeOffset` ne fait varier que l'instant et fixe le décalage à `TimeSpan.Zero`, une limitation que ses propres remarks documentent. Le code dont le comportement dépend du décalage — rendu local, arithmétique de décalage, égalité « même instant, décalage différent » — ne peut donc pas obtenir de Dummies un décalage varié mais valide, et le bug latent courant « le code suppose un décalage nul » n'est jamais révélé par une valeur dummy. + +`DateTimeOffset` contraint son décalage à un nombre entier de minutes dans ±14:00, et exige que les ticks locaux (`UtcTicks + offset`) restent dans la plage `DateTime` ; aux extrêmes du domaine, tout décalage n'est pas valide pour un instant donné. Dummies construit une valeur de manière constructive pour satisfaire ses contraintes, détecte les contradictions au moment de la déclaration, et ne retente jamais. La comparaison se fait par instant, et `OneOf` renvoie déjà les valeurs fournies telles quelles, décalage compris, car reconstruire à partir du seul instant normaliserait le décalage. L'issue #226 recense un tirage de décalage borné comme un ajout piloté par la demande ; l'issue #297 en assure le suivi. + +## Décision + +`AnyDateTimeOffset` acquiert une dimension de décalage optionnelle — `WithOffset` épingle un décalage en minutes entières et `WithOffsetBetween` en tire un borné —, tandis que le défaut non contraint reste `TimeSpan.Zero`, et l'instant est resserré à la déclaration de sorte que tout décalage admis produise une valeur valide. + +## Justification + +Atteindre le décalage fait de `AnyDateTimeOffset` un générateur fidèle à son propre type et révèle la classe de bugs « suppose un décalage UTC » qu'un générateur épinglé à zéro masque. Le garder optionnel — le défaut reste `TimeSpan.Zero` — rend l'ajout non cassant : les tests qui s'appuient aujourd'hui sur un décalage nul, ou qui sérialisent en `+00:00`, continuent de fonctionner. + +Resserrer l'instant à la déclaration, plutôt que de caler ou rejeter le décalage à chaque tirage, est ce qui préserve le modèle constructif, en un seul tirage et sans nouvelle tentative : dès que la fenêtre d'instant admet tous les décalages de la plage demandée, le décalage devient un tirage indépendant qui ne peut jamais produire une valeur hors plage. Cela réutilise aussi le resserrement de bornes du moteur d'intervalle, si bien qu'une fenêtre d'instant sans place pour le décalage demandé entre en conflit par anticipation en nommant les deux côtés — exactement comme toute autre contrainte. Offrir un épinglage et un tirage borné reprend l'idiome pin/`Between` déjà présent dans la bibliothèque, et la règle des minutes entières dans ±14:00 reprend celle de `DateTimeOffset`. `OneOf` continue de renvoyer ses valeurs telles quelles car c'est une énumération terminale de valeurs exactes, de sorte que la dimension de décalage ne régit que le tirage construit. + +L'arithmétique du décalage, les bornes de resserrement de l'instant et le tirage relèvent de l'implémentation, documentée dans le code `AnyDateTimeOffset` et dans la documentation utilisateur de Dummies — pas ici. + +## Alternatives envisagées + +### Faire varier le décalage par défaut + +Envisagée parce que « n'importe quel `DateTimeOffset` valide » inclut sans doute n'importe quel décalage, faisant de l'épinglage actuel à zéro le choix le moins fidèle. Rejetée parce que c'est un changement de comportement cassant : les tests qui vérifient `Offset == TimeSpan.Zero`, ou qui sérialisent en `+00:00`, casseraient. L'option optionnelle livre la capacité de façon additive ; faire varier par défaut ne pourra être revu que dans une future version majeure. + +### Caler ou rejeter le décalage à chaque tirage près des bords + +Envisagée parce qu'elle laisse le domaine de l'instant intact. Rejetée parce qu'elle réintroduit soit un échec conditionnel à chaque tirage (contre le modèle sans nouvelle tentative), soit un rétrécissement silencieux du décalage difficile à raisonner. Resserrer l'instant une seule fois, en amont, est plus simple et toujours valide. + +### Ne livrer que `WithOffset` (épinglage), sans tirage borné + +Envisagée comme surface minimale. Rejetée parce que le cas d'usage moteur — exercer une logique sensible au décalage sur une plage de décalages — est précisément le tirage borné ; un épinglage seul ne le sert pas. + +### Laisser le manque + +Envisagée parce que la plupart du code traite un `DateTimeOffset` comme un instant. Rejetée parce qu'elle laisse `AnyDateTimeOffset` un générateur infidèle dont le décalage ne varie jamais, et pousse quiconque a besoin d'un décalage varié vers une construction faite à la main qui ignore généralement la graine. + +## Conséquences + +### Positives + +* Le code sensible au décalage devient exerçable, et le bug latent « suppose un décalage UTC » attrapable, avec une valeur qui reste valide par construction. +* L'ajout est non cassant : le défaut non contraint est inchangé. +* Une combinaison instant/décalage impossible est diagnostiquée par anticipation via le moteur existant, en nommant les deux contraintes. + +### Négatives + +* `AnyDateTimeOffset` porte désormais une seconde dimension et son propre état de décalage propagé à travers chaque transformation. +* La dimension de décalage est spécifique à `DateTimeOffset` — les autres générateurs temporels n'ont pas de décalage — une spécificité délibérée plutôt qu'une surface uniforme. + +### Risques + +* Un décalage épinglé près du bord du domaine resserre la fenêtre d'instant atteignable ; un utilisateur pourrait lire le conflit *eager* qui en résulte comme fallacieux. Atténuation : le conflit nomme les deux contraintes, et le comportement est documenté. +* `WithOffset` combiné à `OneOf` ne remplace pas le décalage propre d'une valeur `OneOf`. Atténuation : documenté, et cohérent avec la sémantique d'énumération terminale de `OneOf`. + +## Actions de suivi + +* Documenter `WithOffset`/`WithOffsetBetween` dans le readme de Dummies et la documentation des builders (fait dans la pull request d'implémentation). +* N'envisager un raccourci pour « n'importe quel décalage valide » que si `WithOffsetBetween(-14h, +14h)` s'avère une friction en pratique. +* Ne revisiter la variation du décalage par défaut que dans une future version majeure. + +## Références + +* Issue [#297](https://github.com/Reefact/first-class-errors/issues/297) — l'issue dédiée à cette fonctionnalité. +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — le backlog Nice-to-Have dont elle a été détachée. +* [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) — la sémantique d'énumération terminale que suit `OneOf`. +* `AnyDateTimeOffset` dans le projet `Dummies` ; le readme NuGet de Dummies. diff --git a/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.md b/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.md new file mode 100644 index 0000000..813e96a --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0037-vary-the-datetimeoffset-offset-dimension.md @@ -0,0 +1,74 @@ +# ADR-0037 | Vary the DateTimeOffset offset dimension + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0037-vary-the-datetimeoffset-offset-dimension.fr.md) + +**Status:** Proposed +**Date:** 2026-07-26 +**Decision Makers:** Reefact + +## Context + +A `DateTimeOffset` carries two dimensions: the instant (its `UtcTicks`) and the offset from UTC. The offset is the reason the type exists rather than a plain `DateTime`. `AnyDateTimeOffset` varies only the instant and pins the offset to `TimeSpan.Zero`, a limitation its own remarks document. Code whose behaviour depends on the offset — local rendering, offset arithmetic, "same instant, different offset" equality — therefore cannot obtain a varied-but-valid offset from Dummies, and the common latent bug "the code assumes the offset is zero" is never surfaced by a dummy value. + +`DateTimeOffset` constrains its offset to a whole number of minutes within ±14:00, and requires that the local ticks (`UtcTicks + offset`) stay inside the `DateTime` range; near the extremes of the domain, not every offset is valid for a given instant. Dummies builds a value constructively to satisfy its constraints, detects contradictions eagerly at declaration, and never retries. Comparison is by instant, and `OneOf` already returns the supplied values verbatim, offset included, because rebuilding from the instant alone would normalise the offset away. Issue #226 records a bounded offset draw as a demand-driven addition; issue #297 tracks it. + +## Decision + +`AnyDateTimeOffset` gains an opt-in offset dimension — `WithOffset` pins a whole-minute offset and `WithOffsetBetween` draws a bounded one — while the unconstrained default stays `TimeSpan.Zero`, and the instant is tightened at declaration so that every admitted offset yields a valid value. + +## Rationale + +Reaching the offset makes `AnyDateTimeOffset` a faithful generator of its own type and surfaces the "assumes UTC offset" bug class that a zero-pinned generator hides. Keeping it opt-in — the default stays `TimeSpan.Zero` — makes the addition non-breaking: tests that today rely on a zero offset, or serialise to `+00:00`, keep working. + +Tightening the instant at declaration, rather than clamping or rejecting the offset per draw, is what keeps the constructive, one-draw, no-retry model: once the instant window admits every offset in the requested range, the offset is an independent draw that can never produce an out-of-range value. It also reuses the interval engine's bound tightening, so an instant window with no room for the requested offset conflicts eagerly and names both sides — exactly as every other constraint does. Offering a pin and a bounded draw mirrors the library's existing pin/`Between` idiom, and the whole-minute ±14:00 rule mirrors `DateTimeOffset`'s own. `OneOf` keeps returning its values verbatim because it is a terminal enumeration of exact values, so the offset dimension governs only the constructed draw. + +The offset arithmetic, the instant-tightening bounds, and the draw are implementation, documented in the `AnyDateTimeOffset` code and the Dummies user documentation — not here. + +## Alternatives Considered + +### Vary the offset by default + +Considered because "any valid `DateTimeOffset`" arguably includes any offset, making the current zero-pin the less faithful choice. Rejected because it is a behavioural breaking change: tests asserting `Offset == TimeSpan.Zero`, or serialising to a `+00:00` rendering, would break. Opt-in delivers the capability additively; varying by default can be revisited only under a future major version. + +### Clamp or reject the offset per draw near the edges + +Considered because it leaves the instant domain untouched. Rejected because it either reintroduces a per-draw conditional failure (against the no-retry model) or silently narrows the offset in a way that is hard to reason about. Tightening the instant once, up front, is simpler and always valid. + +### Ship only `WithOffset` (pin), no bounded draw + +Considered as the minimal surface. Rejected because the motivating use case — exercising offset-sensitive logic across a range of offsets — is exactly the bounded draw; a pin alone does not serve it. + +### Leave the gap + +Considered because most code treats a `DateTimeOffset` as an instant. Rejected because it keeps `AnyDateTimeOffset` an unfaithful generator whose offset never varies, and pushes anyone who needs a varied offset to a hand-rolled construction that typically ignores the seed. + +## Consequences + +### Positive + +* Offset-sensitive code becomes exercisable, and the "assumes UTC offset" latent bug is catchable, with a value that stays valid by construction. +* The addition is non-breaking: the unconstrained default is unchanged. +* An impossible instant/offset combination is diagnosed eagerly through the existing engine, naming both constraints. + +### Negative + +* `AnyDateTimeOffset` now carries a second dimension and its own offset state threaded through every transform. +* The offset dimension is `DateTimeOffset`-specific — the other temporal generators have no offset — a deliberate specificity rather than a uniform surface. + +### Risks + +* A pinned offset near the domain edge tightens the reachable instant window; a user could read the resulting eager conflict as spurious. Mitigation: the conflict names both constraints, and the behaviour is documented. +* `WithOffset` combined with `OneOf` does not override a `OneOf` value's own offset. Mitigation: documented, and consistent with `OneOf`'s terminal-enumeration semantics. + +## Follow-up Actions + +* Document `WithOffset`/`WithOffsetBetween` in the Dummies readme and the builder documentation (done in the implementing pull request). +* Consider a shorthand for "any valid offset" only if `WithOffsetBetween(-14h, +14h)` proves a friction in practice. +* Revisit varying the offset by default only under a future major version. + +## References + +* Issue [#297](https://github.com/Reefact/first-class-errors/issues/297) — the dedicated issue for this feature. +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — the Nice-to-Have backlog it was split from. +* [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) — the terminal-enumeration semantics `OneOf` follows. +* `AnyDateTimeOffset` in the `Dummies` project; the Dummies NuGet readme. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index ac8c9bc..fa4072c 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -219,3 +219,4 @@ Optional supporting material: | [ADR-0034](0034-require-a-scope-on-the-version-driving-commit-types.md) | Require a scope on the version-driving commit types | Accepted | | [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.md) | Enforce structural Any conflicts at compile time, value-dependent ones at run time | Accepted | | [ADR-0036](0036-draw-lattice-constrained-scalars-on-the-grid.md) | Draw lattice-constrained scalars on the grid | Proposed | +| [ADR-0037](0037-vary-the-datetimeoffset-offset-dimension.md) | Vary the DateTimeOffset offset dimension | Proposed |