From 9c28d43f28f422de0b02d714e975572536afe09d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 00:37:09 +0000 Subject: [PATCH] refactor(justdummies): name the numbers that carry a domain fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A literal that stands for something — bool's two values, a Guid's sixteen bytes, a port's ceiling, decimal's widest scale — says nothing at its use site. Reading it means reconstructing the fact behind it, and the same fact spelled at two sites can drift with nothing to catch it. The clearest evidence was already in the repository: JD014's analyzer carries MinPort, MaxPort and MaxDecimalScale as named constants, while UriSpec and AnyDecimal spelled the very same limits as bare literals. One side of the repository had named these facts and the other had not. Named here, each with a one-line summary saying what it is: domain facts BooleanValueCount, GuidByteCount, PinnedCardinality, MinPort/MaxPort, MaxAsciiCodePoint, Ipv4OctetCount, MaxOctetDigits/MaxOctetValue, MinScale/MaxScale, MantissaByteCount, SignBit, AsciiCaseDistance, HexEscapeDigits, UnicodeEscapeDigits, HexBase, OctalBase, HexLetterOffset, MaxOctalTailDigits, FirstControlCode policy NullDrawOutcomes, SchemesPerFamily, CollisionsPerValue, ScalableCardinality, MinimumBudget, HashMultiplier generated shape the URI draw lengths and counts, gathered under one comment saying they are cosmetic and deliberate MaxScale lives on DecimalIntervalSpec and AnyDecimal reads it there, so the guard and the mantissa construction cannot disagree about what a decimal holds. Two byte counts became sizeof(ulong) and 3 * sizeof(int), which state the same fact without a constant to keep in step. What was deliberately left alone is the other half of the rule: the interval engines' +1/-1 bounds arithmetic, Count == 0 emptiness checks, midpoint math (lower/2 + upper/2), Pow10's *= 10m, character ranges written as characters, and 14 * 60 next to a comment reading "within ±14:00". Naming those adds a lookup where the line already reads. Behaviour is unchanged: every substitution is the same value, and the two guard messages interpolate to the text they spelled before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SuSBsGG7wMPMnSeTHKZAom --- JustDummies/AnyBoolean.cs | 10 ++- JustDummies/AnyDecimal.cs | 5 +- JustDummies/AnyGuid.cs | 10 ++- JustDummies/AnyInt128.cs | 12 +++- JustDummies/CollectionState.cs | 16 ++++- JustDummies/ConstraintClaim.cs | 10 ++- JustDummies/DecimalIntervalSpec.cs | 14 ++-- JustDummies/NullableExtensions.cs | 11 ++- JustDummies/RandomSource.cs | 2 +- JustDummies/RegexAlphabet.cs | 7 +- JustDummies/RegexParser.cs | 39 ++++++++--- JustDummies/Replay.cs | 8 ++- JustDummies/UriSpec.cs | 104 +++++++++++++++++++++++------ 13 files changed, 197 insertions(+), 51 deletions(-) diff --git a/JustDummies/AnyBoolean.cs b/JustDummies/AnyBoolean.cs index 93626d72..2205ad67 100644 --- a/JustDummies/AnyBoolean.cs +++ b/JustDummies/AnyBoolean.cs @@ -7,6 +7,12 @@ namespace JustDummies; /// public sealed class AnyBoolean : IAny, IHasRandomSource, ICardinalityHint { + /// How many values has: false and true, and nothing else. + private const int BooleanValueCount = 2; + + /// How many values a pin leaves producible — the one it fixed. + private const int PinnedCardinality = 1; + #region Statics members declarations internal static AnyBoolean Create(RandomSource source) { @@ -38,7 +44,7 @@ private AnyBoolean(RandomSource source, bool? pinned, ConstraintCall? pinnedCons RandomSource? IHasRandomSource.Source => _source; // Two distinct values unless a pin has already fixed one of them. - long? ICardinalityHint.DistinctCardinality => _pinned is null ? 2 : 1; + long? ICardinalityHint.DistinctCardinality => _pinned is null ? BooleanValueCount : PinnedCardinality; // A pin narrows the domain to that single value; unpinned, both booleans are producible. bool ICardinalityHint.Contains(bool value) => _pinned is not bool pinned || pinned == value; @@ -70,7 +76,7 @@ public AnyBoolean DifferentFrom(bool value) { /// public bool Generate() { - return _pinned ?? _source.Current.Next(2) == 0; + return _pinned ?? _source.Current.Next(BooleanValueCount) == 0; } private AnyBoolean Pin(bool value, ConstraintCall applying) { diff --git a/JustDummies/AnyDecimal.cs b/JustDummies/AnyDecimal.cs index 1ea3d0ec..8f4e367d 100644 --- a/JustDummies/AnyDecimal.cs +++ b/JustDummies/AnyDecimal.cs @@ -15,6 +15,9 @@ namespace JustDummies; /// public sealed class AnyDecimal : IAny, IHasRandomSource, ICardinalityHint { + /// The fewest decimal places accepts — a whole number, with no fractional part. + private const int MinScale = 0; + #region Statics members declarations internal static AnyDecimal Create(RandomSource source) { @@ -136,7 +139,7 @@ public AnyDecimal Between(decimal minimum, decimal maximum) { /// Thrown when is outside the range [0, 28]. /// Thrown when the constraint contradicts a constraint already declared. public AnyDecimal WithScale(int scale) { - if (scale < 0 || scale > 28) { throw new ArgumentOutOfRangeException(nameof(scale), scale, "The scale must be in the inclusive range [0, 28]."); } + if (scale < MinScale || scale > DecimalIntervalSpec.MaxScale) { throw new ArgumentOutOfRangeException(nameof(scale), scale, $"The scale must be in the inclusive range [{MinScale}, {DecimalIntervalSpec.MaxScale}]."); } return new AnyDecimal(_source, _spec.WithScale(scale, ConstraintCall.Of(nameof(WithScale), scale.ToString(CultureInfo.InvariantCulture)))); } diff --git a/JustDummies/AnyGuid.cs b/JustDummies/AnyGuid.cs index 857a0ee7..96ebebd5 100644 --- a/JustDummies/AnyGuid.cs +++ b/JustDummies/AnyGuid.cs @@ -10,6 +10,12 @@ namespace JustDummies; /// public sealed class AnyGuid : IAny, IHasRandomSource, ICardinalityHint { + /// How many bytes a is made of — its 128 bits, which a draw fills whole. + private const int GuidByteCount = 16; + + /// How many values a pin leaves producible — the one it fixed. + private const int PinnedCardinality = 1; + #region Statics members declarations internal static AnyGuid Create(RandomSource source) { @@ -68,7 +74,7 @@ private AnyGuid(RandomSource source, Guid? pinned, ConstraintCall? pinnedConstra RandomSource? IHasRandomSource.Source => _source; // Pinned to a single value, or bounded by an allow-list; otherwise the domain is effectively unbounded. - long? ICardinalityHint.DistinctCardinality => _pinned is not null ? 1 : _effectiveAllowed?.Count; + long? ICardinalityHint.DistinctCardinality => _pinned is not null ? PinnedCardinality : _effectiveAllowed?.Count; // Mirrors Generate: the pin, then the allow-list, then the full space minus the exclusions. bool ICardinalityHint.Contains(Guid value) { @@ -144,7 +150,7 @@ public Guid Generate() { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } - byte[] bytes = new byte[16]; + byte[] bytes = new byte[GuidByteCount]; random.NextBytes(bytes); Guid candidate = new(bytes); // Colliding with an excluded identifier has probability |excluded| / 2^128 per draw. On a collision, diff --git a/JustDummies/AnyInt128.cs b/JustDummies/AnyInt128.cs index 606b537f..0bf9b281 100644 --- a/JustDummies/AnyInt128.cs +++ b/JustDummies/AnyInt128.cs @@ -16,6 +16,14 @@ namespace JustDummies; /// public sealed class AnyInt128 : IAny, IHasRandomSource, ICardinalityHint { + /// + /// The bit that tells a negative from a non-negative one. Flipping it maps a signed + /// value onto its order-preserving ordinal and back — the 128-bit twin of 's + /// 64-bit mapping. It is static readonly rather than const because C# has no constant of a + /// user-defined type such as . + /// + private static readonly UInt128 SignBit = UInt128.One << 127; + #region Statics members declarations internal static AnyInt128 Create(RandomSource source) { @@ -25,11 +33,11 @@ internal static AnyInt128 Create(RandomSource source) { } private static UInt128 Ord(Int128 value) { - return unchecked((UInt128)value) ^ (UInt128.One << 127); + return unchecked((UInt128)value) ^ SignBit; } private static Int128 Val(UInt128 ordinal) { - return unchecked((Int128)(ordinal ^ (UInt128.One << 127))); + return unchecked((Int128)(ordinal ^ SignBit)); } private static string V(Int128 value) { diff --git a/JustDummies/CollectionState.cs b/JustDummies/CollectionState.cs index fb22ac3b..9380a807 100644 --- a/JustDummies/CollectionState.cs +++ b/JustDummies/CollectionState.cs @@ -26,6 +26,18 @@ namespace JustDummies; /// The element type. internal sealed class CollectionState { + // The three numbers the exhaustion budget is built from. They bound how long a dedup-draw may keep colliding + // before it reports a shortfall, and nothing outside ExhaustionBudget reads them. + + /// How many consecutive collisions each value of a known finite domain is allowed to cost. + private const long CollisionsPerValue = 64L; + + /// The cardinality up to which the budget scales with the domain rather than with the requested count. + private const long ScalableCardinality = 1_000_000L; + + /// The floor the budget never drops below, whatever the domain and the count work out to. + private const long MinimumBudget = 10_000L; + #region Statics members declarations internal static CollectionState Create(IAny item, bool distinct, IEqualityComparer? comparer) { @@ -268,9 +280,9 @@ private int ExhaustionBudget(int target) { // floor that collisions only reach if the domain is unexpectedly small (for example a comparer that // merges most values). Either way the fill is bounded — never an unbounded retry loop. long cardinality = _itemCardinality ?? long.MaxValue; - long bounded = cardinality <= 1_000_000L ? 64L * cardinality : 64L * target; + long bounded = cardinality <= ScalableCardinality ? CollisionsPerValue * cardinality : CollisionsPerValue * target; - return (int)Math.Min(Math.Max(bounded, 10_000L), int.MaxValue); + return (int)Math.Min(Math.Max(bounded, MinimumBudget), int.MaxValue); } private static AnyGenerationException Exhausted(RandomSource source, int reached, int target, string what, IAny culprit) { diff --git a/JustDummies/ConstraintClaim.cs b/JustDummies/ConstraintClaim.cs index 9dd43fe0..83b16990 100644 --- a/JustDummies/ConstraintClaim.cs +++ b/JustDummies/ConstraintClaim.cs @@ -42,6 +42,12 @@ namespace JustDummies; [ValueObject] internal sealed class ConstraintClaim : IEquatable { + /// + /// The odd prime each field's hash is multiplied by before the next is folded in, so that two fields swapping + /// values do not collide. Its exact value carries no meaning beyond being odd and prime. + /// + private const int HashMultiplier = 397; + #region Statics members declarations /// @@ -120,9 +126,9 @@ public override bool Equals(object? obj) { public override int GetHashCode() { unchecked { int hash = StringComparer.Ordinal.GetHashCode(Subject); - hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(Claims); + hash = (hash * HashMultiplier) ^ StringComparer.Ordinal.GetHashCode(Claims); - return (hash * 397) ^ (Constraint?.GetHashCode() ?? 0); + return (hash * HashMultiplier) ^ (Constraint?.GetHashCode() ?? 0); } } diff --git a/JustDummies/DecimalIntervalSpec.cs b/JustDummies/DecimalIntervalSpec.cs index bf219b53..fc41626f 100644 --- a/JustDummies/DecimalIntervalSpec.cs +++ b/JustDummies/DecimalIntervalSpec.cs @@ -14,6 +14,12 @@ internal sealed class DecimalIntervalSpec { private const int NoScale = -1; private const int NudgeBudget = 128; + /// The most decimal places a carries — the widest scale its 96-bit mantissa allows. + internal const int MaxScale = 28; + + /// How many bytes that mantissa spans: 96 bits, which reads back as three limbs. + private const int MantissaByteCount = 3 * sizeof(int); + private static readonly decimal SmallestStep = 0.0000000000000000000000000001m; private static readonly decimal MaxFraction = 7.9228162514264337593543950335m; @@ -236,13 +242,13 @@ internal decimal Generate(RandomSource source) { // A uniform fraction in [0, 1] over the full 96-bit mantissa scale. NextBytes fills all three // limbs — including each limb's top bit, which three non-negative Random.Next() draws would pin // to zero, capping the fraction near 0.5 and leaving the upper half of every range unreachable. - byte[] mantissa = new byte[12]; + byte[] mantissa = new byte[MantissaByteCount]; random.NextBytes(mantissa); decimal fraction = new decimal( BitConverter.ToInt32(mantissa, 0), - BitConverter.ToInt32(mantissa, 4), - BitConverter.ToInt32(mantissa, 8), - false, 28) / MaxFraction; + BitConverter.ToInt32(mantissa, sizeof(int)), + BitConverter.ToInt32(mantissa, 2 * sizeof(int)), + false, MaxScale) / MaxFraction; // Interpolate as a convex combination: min*(1 - fraction) + max*fraction stays within [min, max] for // fraction in [0, 1], and no intermediate ever leaves the decimal range. The earlier midpoint form // (mid ± half) overflowed on the full domain — it is symmetric, so max/2 rounds up and half = max/2 - min/2 diff --git a/JustDummies/NullableExtensions.cs b/JustDummies/NullableExtensions.cs index 1858f6e9..f3333b8f 100644 --- a/JustDummies/NullableExtensions.cs +++ b/JustDummies/NullableExtensions.cs @@ -8,6 +8,13 @@ namespace JustDummies; /// public static class NullableExtensions { + /// + /// How many equiprobable outcomes the null-versus-value draw picks between — two, which is what makes + /// null come up about half the time. Shared with + /// so the two siblings cannot drift to different rates. + /// + internal const int NullDrawOutcomes = 2; + /// /// Derives a generator that yields null about half the time and, otherwise, a value drawn from /// — so a test exercises both the present and the absent case without pinning @@ -39,7 +46,7 @@ public static class NullableExtensions { return new DerivedAny(source, reproducible, () => { RandomSource working = source ?? AmbientRandomSource.Instance; - return working.Current.Next(2) == 0 ? (T?)null : generator.Generate(); + return working.Current.Next(NullDrawOutcomes) == 0 ? (T?)null : generator.Generate(); }); } @@ -75,7 +82,7 @@ public static class NullableReferenceExtensions { return new DerivedAny(source, reproducible, () => { RandomSource working = source ?? AmbientRandomSource.Instance; - return working.Current.Next(2) == 0 ? (T?)null : generator.Generate(); + return working.Current.Next(NullableExtensions.NullDrawOutcomes) == 0 ? (T?)null : generator.Generate(); }); } diff --git a/JustDummies/RandomSource.cs b/JustDummies/RandomSource.cs index 402a3ac7..915d8fcc 100644 --- a/JustDummies/RandomSource.cs +++ b/JustDummies/RandomSource.cs @@ -352,7 +352,7 @@ internal static int NextInt32Inclusive(this SeededRandom random, int minInclusiv internal static ulong NextUInt64(this SeededRandom random) { if (random is null) { throw new ArgumentNullException(nameof(random)); } - byte[] bytes = new byte[8]; + byte[] bytes = new byte[sizeof(ulong)]; random.NextBytes(bytes); return BitConverter.ToUInt64(bytes, 0); diff --git a/JustDummies/RegexAlphabet.cs b/JustDummies/RegexAlphabet.cs index 56cecbb1..7b9dc3c6 100644 --- a/JustDummies/RegexAlphabet.cs +++ b/JustDummies/RegexAlphabet.cs @@ -17,6 +17,9 @@ internal static class RegexAlphabet { internal const char MinPrintable = ' '; // 0x20 internal const char MaxPrintable = '~'; // 0x7E + /// How far an ASCII letter's two cases sit apart: 'a' - 'A', the single bit that tells them apart. + private const int AsciiCaseDistance = 'a' - 'A'; + /// Every printable ASCII character — the universe negated classes and the dot draw from. internal static readonly char[] Printable = Range(MinPrintable, MaxPrintable); @@ -61,8 +64,8 @@ internal static char[] Negate(ISet excluded) { /// or class member matches either case. /// internal static IEnumerable WithBothCases(char character) { - if (character is >= 'A' and <= 'Z') { return new[] { character, (char)(character + 32) }; } - if (character is >= 'a' and <= 'z') { return new[] { character, (char)(character - 32) }; } + if (character is >= 'A' and <= 'Z') { return new[] { character, (char)(character + AsciiCaseDistance) }; } + if (character is >= 'a' and <= 'z') { return new[] { character, (char)(character - AsciiCaseDistance) }; } return new[] { character }; } diff --git a/JustDummies/RegexParser.cs b/JustDummies/RegexParser.cs index b5747a4f..811c06c7 100644 --- a/JustDummies/RegexParser.cs +++ b/JustDummies/RegexParser.cs @@ -32,6 +32,27 @@ internal sealed class RegexParser { private const int MaxGroupDepth = 256; + /// How many hexadecimal digits a \xHH escape spells. + private const int HexEscapeDigits = 2; + + /// How many hexadecimal digits a \uHHHH escape spells. + private const int UnicodeEscapeDigits = 4; + + /// How many octal digits may follow the first one in a \0nn escape. + private const int MaxOctalTailDigits = 2; + + /// The base a \x or \u escape's digits accumulate in. + private const int HexBase = 16; + + /// The base a \0 escape's digits accumulate in. + private const int OctalBase = 8; + + /// How many digits precede 'A' in the hexadecimal alphabet, so 'A' reads back as ten. + private const int HexLetterOffset = 10; + + /// The control code \cA names — the alphabet's first letter maps to the first control character, not to the null one. + private const int FirstControlCode = 1; + internal static RegexNode Parse(string pattern, bool ignoreCase) { if (pattern is null) { throw new ArgumentNullException(nameof(pattern)); } RegexParser parser = new(pattern, ignoreCase); @@ -74,7 +95,7 @@ private static bool IsHexDigit(char character) { } private static int HexValue(char character) { - return character <= '9' ? character - '0' : char.ToUpperInvariant(character) - 'A' + 10; + return character <= '9' ? character - '0' : char.ToUpperInvariant(character) - 'A' + HexLetterOffset; } #endregion @@ -406,8 +427,8 @@ private RegexNode ParseEscape() { case 'v': return Literal('\v'); case 'a': return Literal('\a'); case 'e': return Literal('\u001B'); - case 'x': return Literal(ReadHexEscape(2)); - case 'u': return Literal(ReadHexEscape(4)); + case 'x': return Literal(ReadHexEscape(HexEscapeDigits)); + case 'u': return Literal(ReadHexEscape(UnicodeEscapeDigits)); case 'c': return Literal(ReadControlEscape()); case '0': return Literal(ReadOctalTail(0)); case 'b': throw UnsupportedRegexException.OutsideRegularSubset(_pattern, "a word-boundary '\\b'", position); @@ -432,8 +453,8 @@ private RegexNode ParseEscape() { private char ReadHexEscape(int digits) { int value = 0; for (int i = 0; i < digits; i++) { - if (AtEnd || !IsHexDigit(Peek())) { throw Malformed($"a '\\{(digits == 2 ? 'x' : 'u')}' escape expects exactly {digits} hexadecimal digits"); } - value = value * 16 + HexValue(Next()); + if (AtEnd || !IsHexDigit(Peek())) { throw Malformed($"a '\\{(digits == HexEscapeDigits ? 'x' : 'u')}' escape expects exactly {digits} hexadecimal digits"); } + value = value * HexBase + HexValue(Next()); } return (char)value; @@ -442,12 +463,12 @@ private char ReadHexEscape(int digits) { private char ReadControlEscape() { if (AtEnd || Peek() is not ((>= 'A' and <= 'Z') or (>= 'a' and <= 'z'))) { throw Malformed("a '\\c' escape expects a letter (\\cA through \\cZ)"); } - return (char)(char.ToUpperInvariant(Next()) - 'A' + 1); + return (char)(char.ToUpperInvariant(Next()) - 'A' + FirstControlCode); } private char ReadOctalTail(int firstDigit) { int value = firstDigit; - for (int i = 0; i < 2 && !AtEnd && Peek() is >= '0' and <= '7'; i++) { value = value * 8 + (Next() - '0'); } + for (int i = 0; i < MaxOctalTailDigits && !AtEnd && Peek() is >= '0' and <= '7'; i++) { value = value * OctalBase + (Next() - '0'); } return (char)value; } @@ -542,8 +563,8 @@ private char ReadClassChar() { case 'a': return '\a'; case 'e': return '\u001B'; case 'b': return '\b'; // inside a class, \b is the backspace character, never a word boundary - case 'x': return ReadHexEscape(2); - case 'u': return ReadHexEscape(4); + case 'x': return ReadHexEscape(HexEscapeDigits); + case 'u': return ReadHexEscape(UnicodeEscapeDigits); case 'c': return ReadControlEscape(); case '0': return ReadOctalTail(0); default: diff --git a/JustDummies/Replay.cs b/JustDummies/Replay.cs index b72d3003..c2af816d 100644 --- a/JustDummies/Replay.cs +++ b/JustDummies/Replay.cs @@ -38,6 +38,12 @@ namespace JustDummies; [ValueObject] internal sealed class Replay : IEquatable { + /// + /// The odd prime the seed's hash is multiplied by before the guidance is folded in, so that the two fields + /// swapping values do not collide. Its exact value carries no meaning beyond being odd and prime. + /// + private const int HashMultiplier = 397; + #region Statics members declarations /// @@ -115,7 +121,7 @@ public override bool Equals(object? obj) { /// public override int GetHashCode() { unchecked { - return (Seed * 397) ^ StringComparer.Ordinal.GetHashCode(Guidance); + return (Seed * HashMultiplier) ^ StringComparer.Ordinal.GetHashCode(Guidance); } } diff --git a/JustDummies/UriSpec.cs b/JustDummies/UriSpec.cs index 7a61d403..ea1e55a1 100644 --- a/JustDummies/UriSpec.cs +++ b/JustDummies/UriSpec.cs @@ -47,6 +47,68 @@ internal sealed class UriSpec { private const string Unreserved = "abcdefghijklmnopqrstuvwxyz0123456789-._~"; private const int MinDynamicPort = 1025; // above every default we emit (http 80, https 443, ftp 21, ws 80, wss 443) + /// The lowest port an authority may carry; zero is reserved and never appears in a URI. + private const int MinPort = 1; + + /// The highest port an authority may carry — a port number is sixteen bits wide. + private const int MaxPort = 65535; + + /// The highest code point that is still ASCII; above it a host is internationalized (IDN). + private const int MaxAsciiCodePoint = 127; + + /// How many dot-separated octets a dotted-quad IPv4 literal is written with. + private const int Ipv4OctetCount = 4; + + /// The most digits a canonical IPv4 octet spells — the width of "255". + private const int MaxOctetDigits = 3; + + /// The highest value an IPv4 octet holds — an octet is one byte. + private const int MaxOctetValue = 255; + + /// How many schemes the Web and WebSocket families draw between: http/https, and ws/wss. + private const int SchemesPerFamily = 2; + + // The lengths and counts below decide how a generated URI LOOKS. Nothing in the grammar requires these particular + // numbers — they are chosen to read as a plausible URI in a failing test's output without burying it, and they are + // gathered here because each one is otherwise invisible at its call site, spelled as a bare pair of bounds passed + // to Draw. Changing one changes the look of every URI the library generates. + + /// The shortest user, password or mailto subject drawn. + private const int MinUserInfoLength = 3; + + /// The longest user, password or mailto subject drawn. + private const int MaxUserInfoLength = 8; + + /// The shortest path segment, fragment, or standalone relative reference drawn. + private const int MinTokenLength = 1; + + /// The longest path segment, fragment, or standalone relative reference drawn. + private const int MaxTokenLength = 8; + + /// The shortest key or value inside a generated query pair. + private const int MinQueryTokenLength = 1; + + /// The longest key or value inside a generated query pair. + private const int MaxQueryTokenLength = 6; + + /// The shortest top-level domain drawn. + private const int MinTldLength = 2; + + /// The longest top-level domain drawn. + private const int MaxTldLength = 4; + + /// The longest tail drawn after a DNS label's mandatory leading letter, so a label spans one to eight characters. + private const int MaxLabelTailLength = 7; + + /// The most segments an unconstrained () path draws; it may also draw none. + private const int MaxAutoPathSegments = 2; + + /// The fewest key/value pairs a generated query string carries. + private const int MinQueryPairs = 1; + + /// The most key/value pairs a generated query string carries. + private const int MaxQueryPairs = 2; + #endregion #region Statics members declarations @@ -257,27 +319,27 @@ private string BuildAbsolute(UriFamily family, SeededRandom random) { builder.Append(scheme).Append(':'); if (family == UriFamily.Mailto) { - builder.Append(_user ?? Draw(random, LowerAlphaNum, 3, 8)); + builder.Append(_user ?? Draw(random, LowerAlphaNum, MinUserInfoLength, MaxUserInfoLength)); builder.Append('@'); builder.Append(_host ?? Host(random)); - if (_hasQuery) { builder.Append("?subject=").Append(Draw(random, LowerAlphaNum, 3, 8)); } + if (_hasQuery) { builder.Append("?subject=").Append(Draw(random, LowerAlphaNum, MinUserInfoLength, MaxUserInfoLength)); } return builder.ToString(); } builder.Append("//"); if (AllowsUserInfo(family) && _hasUserInfo) { - builder.Append(_user ?? Draw(random, LowerAlphaNum, 3, 8)); - builder.Append(':').Append(_password ?? Draw(random, LowerAlphaNum, 3, 8)); + builder.Append(_user ?? Draw(random, LowerAlphaNum, MinUserInfoLength, MaxUserInfoLength)); + builder.Append(':').Append(_password ?? Draw(random, LowerAlphaNum, MinUserInfoLength, MaxUserInfoLength)); builder.Append('@'); } builder.Append(_host ?? Host(random)); - if (_hasPort) { builder.Append(':').Append(V(_port ?? random.Next(MinDynamicPort, 65536))); } + if (_hasPort) { builder.Append(':').Append(V(_port ?? random.Next(MinDynamicPort, MaxPort + 1))); } builder.Append(Path(random, leadingSlash: true)); if (AllowsQuery(family) && _hasQuery) { builder.Append(Query(random)); } - if (AllowsFragment(family) && _hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, 1, 8)); } + if (AllowsFragment(family) && _hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, MinTokenLength, MaxTokenLength)); } return builder.ToString(); } @@ -287,7 +349,7 @@ private string BuildRelative(RandomSource source) { StringBuilder builder = new(); builder.Append(Path(random, leadingSlash: _rooted)); if (_hasQuery) { builder.Append(Query(random)); } - if (_hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, 1, 8)); } + if (_hasFragment) { builder.Append('#').Append(Draw(random, LowerAlphaNum, MinTokenLength, MaxTokenLength)); } string result = builder.ToString(); if (result.Length > 0) { return result; } @@ -300,15 +362,15 @@ private string BuildRelative(RandomSource source) { throw AnyGenerationException.EmptyRelativeReference(Replay.Of(source)); } - return Draw(random, LowerAlphaNum, 1, 8); + return Draw(random, LowerAlphaNum, MinTokenLength, MaxTokenLength); } private string ResolveScheme(UriFamily family, SeededRandom random) { if (_scheme is not null) { return _scheme; } return family switch { - UriFamily.Web => random.Next(2) == 0 ? "http" : "https", - UriFamily.WebSocket => random.Next(2) == 0 ? "ws" : "wss", + UriFamily.Web => random.Next(SchemesPerFamily) == 0 ? "http" : "https", + UriFamily.WebSocket => random.Next(SchemesPerFamily) == 0 ? "ws" : "wss", UriFamily.Ftp => "ftp", UriFamily.Mailto => "mailto", _ => throw new InvalidOperationException("Relative URIs have no scheme.") @@ -319,7 +381,7 @@ private string Path(SeededRandom random, bool leadingSlash) { int count = _pathMode switch { UriPathMode.Root => 0, UriPathMode.Exact => _pathSegments, - _ => random.Next(3) // 0..2 + _ => random.Next(MaxAutoPathSegments + 1) }; if (count == 0) { return leadingSlash ? "/" : string.Empty; } @@ -327,30 +389,30 @@ private string Path(SeededRandom random, bool leadingSlash) { StringBuilder builder = new(); for (int i = 0; i < count; i++) { if (leadingSlash || i > 0) { builder.Append('/'); } - builder.Append(Draw(random, LowerAlphaNum, 1, 8)); + builder.Append(Draw(random, LowerAlphaNum, MinTokenLength, MaxTokenLength)); } return builder.ToString(); } private static string Query(SeededRandom random) { - int pairs = random.Next(1, 3); // 1..2 + int pairs = random.Next(MinQueryPairs, MaxQueryPairs + 1); StringBuilder builder = new("?"); for (int i = 0; i < pairs; i++) { if (i > 0) { builder.Append('&'); } - builder.Append(Draw(random, LowerAlphaNum, 1, 6)).Append('=').Append(Draw(random, LowerAlphaNum, 1, 6)); + builder.Append(Draw(random, LowerAlphaNum, MinQueryTokenLength, MaxQueryTokenLength)).Append('=').Append(Draw(random, LowerAlphaNum, MinQueryTokenLength, MaxQueryTokenLength)); } return builder.ToString(); } private static string Host(SeededRandom random) { - return Label(random) + "." + Draw(random, LowerLetters, 2, 4); + return Label(random) + "." + Draw(random, LowerLetters, MinTldLength, MaxTldLength); } private static string Label(SeededRandom random) { // A DNS-safe label: starts with a letter, then letters/digits — no leading digit, no hyphen edges. - return LowerLetters[random.Next(LowerLetters.Length)].ToString() + Draw(random, LowerAlphaNum, 0, 7); + return LowerLetters[random.Next(LowerLetters.Length)].ToString() + Draw(random, LowerAlphaNum, 0, MaxLabelTailLength); } private static string Draw(SeededRandom random, string pool, int min, int max) { @@ -381,7 +443,7 @@ internal static string RequireHost(string host, string parameterName) { if (host is null) { throw new ArgumentNullException(parameterName); } if (parameterName is null) { throw new ArgumentNullException(nameof(parameterName)); } if (host.Length == 0) { throw new ArgumentException("The host must not be empty.", parameterName); } - if (host.Any(character => character > 127)) { + if (host.Any(character => character > MaxAsciiCodePoint)) { throw new ArgumentException("The host must be ASCII: an internationalized (IDN) host would not round-trip identically across target frameworks. Pass the punycode form instead (e.g. \"xn--mnchen-3ya.de\").", parameterName); } if (Uri.CheckHostName(host) == UriHostNameType.Unknown) { @@ -401,11 +463,11 @@ internal static string RequireHost(string host, string parameterName) { private static bool IsCanonicalIpv4(string host) { string[] parts = host.Split('.'); - if (parts.Length != 4) { return false; } + if (parts.Length != Ipv4OctetCount) { return false; } foreach (string part in parts) { - if (part.Length is 0 or > 3) { return false; } + if (part.Length is 0 or > MaxOctetDigits) { return false; } if (part.Length > 1 && part[0] == '0') { return false; } // a leading zero is a non-canonical (octal-ish) octet - if (!int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out int octet) || octet > 255) { return false; } + if (!int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out int octet) || octet > MaxOctetValue) { return false; } } return true; @@ -429,7 +491,7 @@ internal static string RequireUserInfoPart(string value, string parameterName) { internal static int RequirePort(int port, string parameterName) { if (parameterName is null) { throw new ArgumentNullException(nameof(parameterName)); } - if (port is < 1 or > 65535) { throw new ArgumentOutOfRangeException(parameterName, port, "The port must be between 1 and 65535."); } + if (port is < MinPort or > MaxPort) { throw new ArgumentOutOfRangeException(parameterName, port, $"The port must be between {V(MinPort)} and {V(MaxPort)}."); } return port; }