diff --git a/Directory.Build.props b/Directory.Build.props index 2d3ee6a..3d69d97 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -25,7 +25,7 @@ git README.md autopoco;testdata;test-data;poco;generator;fluent;fake-data - 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. IbanSource generates the IBAN of any SEPA country with correct mod 97 check digits and the national check digits inside the BBAN (BE, EE, ES, FI, FR/MC, HU, IS, IT/SM, NO, PL, PT, SI), TestBlzIbanSource a German IBAN on a bank code outside the assigned range so the number is recognisable as test data, and VatIdSource a VAT ID with a valid check digit for AT, BE, DE, DK, EE, FI, GB, HR, HU, IE, IT, LT, LU, NL, PL, PT, SE, SI, SK and XI, calibrated against the official construction rules; countries that have not published their rules are left out on purpose. TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. + 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. IbanSource generates the IBAN of any SEPA country with correct mod 97 check digits and the national check digits inside the BBAN (BE, EE, ES, FI, FR/MC, HU, IS, IT/SM, NO, PL, PT, SI), TestBlzIbanSource a German IBAN on a bank code outside the assigned range so the number is recognisable as test data, and VatIdSource a VAT ID with a valid check digit for AT, BE, DE, DK, EE, FI, GB, HR, HU, IE, IT, LT, LU, NL, PL, PT, SE, SI, SK and XI, calibrated against the official construction rules; countries that have not published their rules are left out on purpose. More sources: VinSource (North American check digit, model year per 49 CFR 565, optional WMI prefix), MacAddressSource and TestMacAddressSource (universally vs. locally administered unicast, three formats), GuidV7Source (RFC 9562 version 7 UUIDs whose timestamp comes from a seeded range instead of the clock), PatternSource (# digit, @ letter, * both, backslash escapes) with SkuSource, SerialNumberSource and LotNumberSource (seeded year plus day-of-year date code) on top, and LatitudeSource/LongitudeSource with bounding box. EoriSource generates EORI customs numbers for BE, DE, DK, FR, GB, HR, IT, NL, PL and XI, the national part built from the underlying national identifier with its real check digits (enterprise number, CVR, SIRET, OIB, partita IVA, NIP, VAT registration number). TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. diff --git a/README.md b/README.md index 6d6758c..b857ae6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,22 @@ Each has a `Nullable...` variant. The structures come from the SWIFT IBAN regist One warning, stronger than for the credit cards: an IBAN needs no CVV and no second factor for a SEPA direct debit, so a generated IBAN that coincidentally names a real account is a sharper tool than a generated card number. `IbanSource` draws its bank codes, so use `TestBlzIbanSource` whenever the data leaves your test systems. A generated VAT ID is harmless in comparison, VIES simply reports it as not registered. +## Devices, codes and coordinates + +| Source | Produces | +| --- | --- | +| `VinSource` | A seventeen character vehicle identification number with the valid North American check digit at position nine, I, O and Q never appear, and the model year position follows 49 CFR 565. A prefix pins the world manufacturer identifier, e.g. `new VinSource("WVW")`. | +| `MacAddressSource` | A unicast MAC address as upper case hex, colons, hyphens or bare via `MacAddressFormat`. The plain source draws universally administered addresses; `TestMacAddressSource` sets the locally administered bit, the space IEEE 802 keeps free of manufacturer assignments, so those addresses provably name no vendor hardware. | +| `GuidV7Source` | An RFC 9562 version 7 UUID whose 48 timestamp bits are drawn from a seeded date range instead of the clock, so the same seed gives the same UUIDs on every run. Version and variant bits are correct and the encoded instant stays inside the range. | +| `PatternSource` | A string from a pattern: `#` digit, `@` capital letter, `*` both, backslash escapes, everything else literal. `"@@@-#####"` gives `KDX-83741`. The building block for every code that follows no public standard. | +| `SkuSource` | An SKU, `@@@-#####` unless another pattern is given. An SKU is merchant convention, so the pattern is the contract; for standardised article numbers use `GtinSource` and friends. | +| `SerialNumberSource` | A serial number, four blocks of four characters unless another pattern is given. | +| `LotNumberSource` | A lot or batch number with the widespread date code of two digit year plus day of the year, e.g. `L26265-0387`, the date drawn from a seeded range, never from the clock. | +| `LatitudeSource`, `LongitudeSource` | Decimal degrees, the whole world unless a bounding box is given, e.g. `new LatitudeSource(47, 55)` with `new LongitudeSource(5, 15)`. The two members are drawn independently; where the pair has to name one meaningful place, build it with `Impose`. | +| `EoriSource` | An EORI customs number (Annex 12-01 UCC IA) for BE, DE, DK, FR, GB, HR, IT, NL, PL and XI. The format itself has no check digit, but the national part is built from the underlying national identifier with its real checks: the Belgian enterprise number, the Danish CVR, a doubly Luhn-valid French SIRET, the Croatian OIB, the Italian partita IVA, the Polish NIP plus `00000`, the UK VAT registration number plus `000`. Austria publishes no structure and the Portuguese third country check digit is unpublished, so both stay out. | + +Each has a `Nullable...` variant. + ## holonsoft.AutoPoco.Faker, realistic values from Bogus A separate, optional package. The core package keeps its promise of having no dependency, and who wants believable names and addresses installs one more: diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Business/PatternBasedBusinessSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Business/PatternBasedBusinessSourceTests.cs new file mode 100644 index 0000000..675e4dd --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Business/PatternBasedBusinessSourceTests.cs @@ -0,0 +1,143 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Business; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources; + +public class SkuSourceTests : TestBase { + [Fact] + public void TheDefaultSkuIsThreeLettersAHyphenAndFiveDigits() { + var source = new SkuSource(); + + for (var i = 0; i < 200; i++) { + var value = source.Next(null); + Regex.IsMatch(value, @"^[A-Z]{3}-\d{5}$").ShouldBeTrue($"'{value}' does not look like the default SKU"); + } + } + + [Fact] + public void AnOwnPatternReplacesTheDefault() { + var source = new SkuSource("##-@@@@-#"); + + for (var i = 0; i < 100; i++) + Regex.IsMatch(source.Next(null), @"^\d{2}-[A-Z]{4}-\d$").ShouldBeTrue(); + } + + [Fact] + public void NextReturnsStableSkuListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new SkuSource(), new string[] { "PDU-31380", "XCC-61809", "GDO-64322", "IOW-50036", "FVK-06092", "CFM-52438", "ZBM-48329", "XGW-91743", "QGO-71000", "FBY-63657" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseSkus() { + var source = new NullableSkuSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + Regex.IsMatch(value!, @"^[A-Z]{3}-\d{5}$").ShouldBeTrue($"'{value}' does not look like the default SKU"); + } +} + +public class SerialNumberSourceTests : TestBase { + [Fact] + public void TheDefaultSerialNumberIsFourBlocksOfFourCharacters() { + var source = new SerialNumberSource(); + + for (var i = 0; i < 200; i++) { + var value = source.Next(null); + Regex.IsMatch(value, @"^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$").ShouldBeTrue($"'{value}' does not look like the default serial number"); + } + } + + [Fact] + public void AnOwnPatternReplacesTheDefault() { + var source = new SerialNumberSource(@"SN\#@@######"); + + for (var i = 0; i < 100; i++) + Regex.IsMatch(source.Next(null), @"^SN#[A-Z]{2}\d{6}$").ShouldBeTrue(); + } + + [Fact] + public void NextReturnsStableSerialNumberListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new SerialNumberSource(), new string[] { "S3KJ-WNS2-YH63-RKZQ", "QQI8-U5WG-3T65-LAWT", "MG9V-IY5L-4ZVP-1C4S", "3AP6-RM9H-DQR3-G6QN", "1WGX-VSM3-VCLQ-7WJ4", "X3RR-JFGC-EHI4-U3V3", "SJ4S-UGDG-WO0F-P1L8", "CXSW-BQ67-9NTJ-14YJ", "M7A2-W4VB-0CN5-WVJ5", "DM0N-N8LW-S3PN-KGGH" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseSerialNumbers() { + var source = new NullableSerialNumberSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + Regex.IsMatch(value!, @"^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$").ShouldBeTrue($"'{value}' does not look like the default serial number"); + } +} + +public class LotNumberSourceTests : TestBase { + [Fact] + public void TheDefaultLotNumberCarriesADateCodeInsideTheDefaultRange() { + var source = new LotNumberSource(); + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + Regex.IsMatch(value, @"^L\d{5}-\d{4}$").ShouldBeTrue($"'{value}' does not look like the default lot number"); + + var year = int.Parse(value.AsSpan(1, 2), CultureInfo.InvariantCulture); + var dayOfYear = int.Parse(value.AsSpan(3, 3), CultureInfo.InvariantCulture); + + year.ShouldBeInRange(20, 35); + dayOfYear.ShouldBeInRange(1, 366); + } + } + + [Fact] + public void APinnedDateIsEncodedExactly() { + var date = new DateOnly(2026, 9, 22); // the 265th day of 2026 + var source = new LotNumberSource(date, date); + + for (var i = 0; i < 50; i++) + source.Next(null).ShouldStartWith("L26265-"); + } + + [Fact] + public void AnOwnSuffixPatternReplacesTheDefault() { + var source = new LotNumberSource("@@##"); + + for (var i = 0; i < 100; i++) + Regex.IsMatch(source.Next(null), @"^L\d{5}-[A-Z]{2}\d{2}$").ShouldBeTrue(); + } + + [Fact] + public void ReversedDatesThrow() + => Should.Throw(() => new LotNumberSource(new DateOnly(2030, 1, 1), new DateOnly(2020, 1, 1))); + + [Fact] + public void NextReturnsStableLotNumberListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new LotNumberSource(), new string[] { "L30152-3431", "L34346-8072", "L26157-6180", "L29166-9636", "L24224-3228", "L33298-5003", "L30153-6550", "L30281-6092", "L30286-5524", "L33147-8914" }); + + [Fact] + public void NextReturnsStableLotNumberListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableLotNumberSource()!, new string?[] { "L30152-3431", "L34346-8072", "L26157-6180", "L29166-9636", "L24224-3228", "L33298-5003", "L30153-6550", "L30281-6092", "L30286-5524", "L33147-8914", "L25122-8329", "L31331-7669", "L21237-4306", "L31125-7100", "L26107-0518", "L29319-3657", "L20183-7034", "L33277-3803", "L26128-0171", "L21336-4383", "L21067-7311", "L28342-0302", "L22156-0080", "L27195-7915", null, "L28355-1590", "L32198-6797", null, "L23293-3127", "L26309-2386" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseLotNumbers() { + var source = new NullableLotNumberSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + Regex.IsMatch(value!, @"^L\d{5}-\d{4}$").ShouldBeTrue($"'{value}' does not look like the default lot number"); + } +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Geo/GeoCoordinateSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Geo/GeoCoordinateSourceTests.cs new file mode 100644 index 0000000..2f975e7 --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Geo/GeoCoordinateSourceTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Geo; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Geo; + +public class GeoCoordinateSourceTests : TestBase { + [Fact] + public void ALatitudeStaysOnTheGlobe() { + var source = new LatitudeSource(); + + for (var i = 0; i < 300; i++) + source.Next(null).ShouldBeInRange(-90, 90); + } + + [Fact] + public void ALongitudeStaysOnTheGlobe() { + var source = new LongitudeSource(); + + for (var i = 0; i < 300; i++) + source.Next(null).ShouldBeInRange(-180, 180); + } + + [Fact] + public void ABoundingBoxIsRespected() { + var latitudes = new LatitudeSource(47, 55); + var longitudes = new LongitudeSource(5, 15); + + for (var i = 0; i < 300; i++) { + latitudes.Next(null).ShouldBeInRange(47, 55); + longitudes.Next(null).ShouldBeInRange(5, 15); + } + } + + [Fact] + public void APinnedCoordinateIsExact() { + new LatitudeSource(48.1371, 48.1371).Next(null).ShouldBe(48.1371); + new LongitudeSource(11.5754, 11.5754).Next(null).ShouldBe(11.5754); + } + + [Theory] + [InlineData(-91, 0)] + [InlineData(0, 91)] + [InlineData(double.NaN, 0)] + [InlineData(0, double.PositiveInfinity)] + [InlineData(50, 40)] // reversed + public void ABrokenLatitudeRangeThrows(double min, double max) + => Should.Throw(() => new LatitudeSource(min, max)); + + [Theory] + [InlineData(-181, 0)] + [InlineData(0, 181)] + [InlineData(15, 5)] // reversed + public void ABrokenLongitudeRangeThrows(double min, double max) + => Should.Throw(() => new LongitudeSource(min, max)); + + [Fact] + public void NextReturnsStableLatitudeListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new LatitudeSource(), new double[] { 31.669814152620816D, 56.60525664808654D, 49.95233714608446D, 77.55660019739571D, -57.73951179844884D, 27.81041412257177D, -72.10171540745205D, -43.08707460818985D, -48.911967188936735D, 57.460836378945686D }); + + [Fact] + public void NextReturnsStableLongitudeListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new LongitudeSource(), new double[] { 63.33962830524163D, 113.21051329617308D, 99.90467429216892D, 155.11320039479142D, -115.47902359689768D, 55.62082824514354D, -144.2034308149041D, -86.1741492163797D, -97.82393437787347D, 114.92167275789137D }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseCoordinates() { + var source = new NullableLatitudeSource(); + source.SetNullCreationThreshold(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + value!.Value.ShouldBeInRange(-90, 90); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 200).Select(_ => new LongitudeSource().Next(null)).ShouldAllBe(v => v >= -180 && v <= 180); +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/EoriSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/EoriSourceTests.cs new file mode 100644 index 0000000..5b9025b --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/EoriSourceTests.cs @@ -0,0 +1,134 @@ +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Identifiers; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Identifiers; + +public class EoriSourceTests : TestBase { + /// + /// EORI numbers built from registered national identifiers of real operators (an EORI's national part + /// is the national identifier), plus the German shape the chambers of commerce publish, so the + /// validator is measured against the world before it judges the source. + /// + [Theory] + [InlineData("BE0776091951")] // the enterprise number from the BMF worked example + [InlineData("DE000000001234567")] // the zero padded shape from the IHK documentation + [InlineData("DK26259495")] // Coop Danmark's CVR number + [InlineData("GB434031494000")] // a checksummed VAT registration number plus 000 + [InlineData("XI434031494000")] // Northern Ireland runs on the same scheme + [InlineData("HR29524210204")] // a registered OIB + [InlineData("IT00000010215")] // a registered partita IVA + [InlineData("NL002065538")] // Philips' fiscal number + [InlineData("PL521220725700000")] // a registered NIP plus the five zeros + public void TheValidatorAcceptsAWellFormedEori(string value) + => IdentifierValidation.IsValidEori(value).ShouldBeTrue(); + + /// + /// The examples the authorities publish are format illustrations, not checksummed numbers: the HMRC + /// example fails the VAT registration check and the EC example fails the CVR check. The validator is + /// stricter than the format on purpose, because the source only generates checksummed national parts. + /// + [Theory] + [InlineData("GB012345678000")] // the HMRC form example + [InlineData("DK11223344")] // the EC national implementation example + public void TheOfficialFormatIllustrationsFailTheNationalChecks(string value) + => IdentifierValidation.IsValidEori(value).ShouldBeFalse(); + + [Theory] + [InlineData("BE0776091952")] // enterprise number check pair off by one + [InlineData("DE00000000123456")] // fourteen digits instead of fifteen + [InlineData("DE0000000012345678")] // sixteen digits instead of fifteen + [InlineData("DEA00000001234567")] // Germany uses digits only + [InlineData("DK26259496")] // CVR no longer divisible by eleven + [InlineData("FR00000000000001")] // the SIRET no longer passes the Luhn check + [InlineData("FR10000000000000")] // the SIREN no longer passes the Luhn check + [InlineData("GB434031494")] // the 000 tail is missing + [InlineData("GB434031494001")] // the tail is exactly three zeros + [InlineData("HR29524210205")] // OIB check digit off by one + [InlineData("IT00000010216")] // partita IVA check digit off by one + [InlineData("NL00206553")] // eight digits instead of nine + [InlineData("PL521220725712345")] // the tail behind the NIP is five zeros + [InlineData("PL521220725800000")] // NIP check digit off by one + [InlineData("AT1234567890")] // Austria publishes no structure, so it is not generated or accepted + [InlineData("")] + [InlineData(null)] + public void TheValidatorRejectsABrokenEori(string? value) + => IdentifierValidation.IsValidEori(value).ShouldBeFalse(); + + [Theory] + [InlineData("BE", 12)] + [InlineData("DE", 17)] + [InlineData("DK", 10)] + [InlineData("FR", 16)] + [InlineData("GB", 14)] + [InlineData("HR", 13)] + [InlineData("IT", 13)] + [InlineData("NL", 11)] + [InlineData("PL", 17)] + [InlineData("XI", 14)] + public void EveryNumberOfAFixedCountryIsValid(string countryCode, int expectedLength) { + var source = new EoriSource(countryCode); + + for (var i = 0; i < 200; i++) { + var value = source.Next(null); + value.Length.ShouldBe(expectedLength); + value.ShouldStartWith(countryCode); + IdentifierValidation.IsValidEori(value).ShouldBeTrue($"'{value}' does not validate"); + } + } + + [Fact] + public void TheRandomCountryProducesEveryCountry() { + var source = new EoriSource(); + + var countries = Enumerable.Range(0, 500) + .Select(_ => source.Next(null)[..2]) + .Distinct() + .OrderBy(c => c, StringComparer.Ordinal) + .ToList(); + + countries.ShouldBe(EoriSourceBase.SupportedCountries.OrderBy(c => c, StringComparer.Ordinal).ToList()); + } + + [Fact] + public void AFixedCountryStaysFixed() + => Enumerable.Range(0, 100) + .Select(_ => new EoriSource("FR").Next(null)) + .ShouldAllBe(v => v.StartsWith("FR")); + + [Theory] + [InlineData("AT")] // structure not published, deliberately unsupported + [InlineData("PT")] // third country check digit unpublished, deliberately unsupported + [InlineData("de")] + [InlineData("XX")] + public void AnUnsupportedCountryThrows(string countryCode) + => Should.Throw(() => new EoriSource(countryCode)) + .Message.ShouldContain(countryCode); + + [Fact] + public void NextReturnsStableEoriListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new EoriSource(), new string[] { "FR43138072426189", "BE1736432236", "PL750036550100000", "IT09225520692", "FR89148329976691", "DE743067100051863", "IT57670340348", "FR80301712843833", "BE0831140342", "BE1360080748" }); + + [Fact] + public void NextReturnsStableEoriListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableEoriSource()!, new string?[] { "FR43138072426189", "BE1736432236", "PL750036550100000", "IT09225520692", "FR89148329976691", "DE743067100051863", "IT57670340348", "FR80301712843833", "BE0831140342", "BE1360080748", "XI107930424000", "DE590467979312742", "FR86720480275700", "FR55609639476789", "HR00293897945", "BE0881761870", "BE1379796888", "IT09770190909", "FR01306283128676", "PL835854213600000", "PL691913124100000", "HR17873134413", "DE577786562532133", "DE299998707036770", null, "HR20002427320", "PL123030392800000", null, "GB706973062000", "HR13561360091" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidNumbers() { + var source = new NullableEoriSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + IdentifierValidation.IsValidEori(value).ShouldBeTrue($"'{value}' does not validate"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new EoriSource().Next(null)).ShouldAllBe(v => v != null); +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs index 28a9bfa..7a5e92b 100644 --- a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs @@ -741,6 +741,88 @@ private static bool RunsIso7064Mod1110ToOne(ReadOnlySpan digits) { return sum == 1; } + /// + /// True when an EORI number is well formed for its country and its national part carries the check + /// digits of the underlying national identifier. Only the countries the source generates are known. + /// + public static bool IsValidEori(string? value) { + if (value is null || value.Length < 4) + return false; + + var country = value[..2]; + var national = value[2..]; + + return country switch { + // the Belgian enterprise number is the Belgian VAT number without the prefix + "BE" => national.Length == 10 && IsValidVatId($"BE{national}"), + "DE" => national.Length == 15 && AllDigits(national), + // the CVR number is the Danish VAT number without the prefix + "DK" => national.Length == 8 && IsValidVatId($"DK{national}"), + "FR" => IsValidSiret(national), + // the VAT registration number with three zeros behind it + "GB" or "XI" => national.Length == 12 && national.EndsWith("000", StringComparison.Ordinal) + && IsValidVatId($"GB{national[..9]}"), + // the OIB is the Croatian VAT number without the prefix + "HR" => national.Length == 11 && IsValidVatId($"HR{national}"), + // the partita IVA is the Italian VAT number without the prefix + "IT" => national.Length == 11 && IsValidVatId($"IT{national}"), + "NL" => national.Length == 9 && AllDigits(national), + // the NIP with five zeros behind it + "PL" => national.Length == 15 && national.EndsWith("00000", StringComparison.Ordinal) + && IsValidVatId($"PL{national[..10]}"), + _ => false + }; + } + + /// + /// True when a fourteen digit SIRET is valid: the whole number passes the Luhn check and so does the + /// nine digit SIREN it starts with. + /// + private static bool IsValidSiret(string national) + => national.Length == 14 && AllDigits(national) && IsValidLuhn(national) && IsValidLuhn(national[..9]); + + /// + /// True when a seventeen character VIN carries the correct North American check digit at position nine. + /// Validated the other way round than the generator: every character is transliterated and weighted, + /// the check position with weight zero, and the sum mod 11 has to name the check character, a ten + /// written as an X. + /// + public static bool IsValidVin(string? value) { + const string alphabet = "0123456789ABCDEFGHJKLMNPRSTUVWXYZ"; + ReadOnlySpan weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; + + if (value is not { Length: 17 }) + return false; + + var sum = 0; + + for (var i = 0; i < 17; i++) { + if (!alphabet.Contains(value[i])) + return false; + + sum += VinTransliterationValue(value[i]) * weights[i]; + } + + var expected = sum % 11; + var actual = value[8] switch { + >= '0' and <= '9' => value[8] - '0', + 'X' => 10, + _ => -1 + }; + + return actual == expected; + } + + private static int VinTransliterationValue(char c) + => c switch { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'H' => c - 'A' + 1, + >= 'J' and <= 'N' => c - 'J' + 1, + 'P' => 7, + 'R' => 9, + _ => c - 'S' + 2 + }; + /// /// Every single character variation of the value at the given position, the original excluded. /// An ISBN-10 may carry an X in its last position, everything else is a digit. diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/MacAddressSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/MacAddressSourceTests.cs new file mode 100644 index 0000000..6b74bdf --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/MacAddressSourceTests.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Identifiers; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Identifiers; + +public partial class MacAddressSourceTests : TestBase { + [GeneratedRegex("^([0-9A-F]{2}:){5}[0-9A-F]{2}$")] + private static partial Regex ColonFormat(); + + [GeneratedRegex("^([0-9A-F]{2}-){5}[0-9A-F]{2}$")] + private static partial Regex HyphenFormat(); + + [GeneratedRegex("^[0-9A-F]{12}$")] + private static partial Regex BareFormat(); + + private static int FirstOctet(string address) + => int.Parse(address[..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + [Theory] + [InlineData(MacAddressFormat.Colons)] + [InlineData(MacAddressFormat.Hyphens)] + [InlineData(MacAddressFormat.Bare)] + public void EveryAddressMatchesItsFormat(MacAddressFormat format) { + var source = new MacAddressSource(format); + var pattern = format switch { + MacAddressFormat.Colons => ColonFormat(), + MacAddressFormat.Hyphens => HyphenFormat(), + _ => BareFormat() + }; + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + pattern.IsMatch(value).ShouldBeTrue($"'{value}' does not match the {format} format"); + } + } + + /// + /// The plain source produces universally administered unicast addresses, so the two low bits of the + /// first octet are clear, the way a burned-in address of a network card looks. + /// + [Fact] + public void EveryAddressIsAUniversallyAdministeredUnicast() { + var source = new MacAddressSource(); + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + (FirstOctet(value) & 0b0000_0011).ShouldBe(0, $"'{value}' is not a universally administered unicast"); + } + } + + /// + /// The test source sets the locally administered bit and keeps the unicast bit clear, so the second + /// hex digit is always a 2, 6, A or E and no manufacturer OUI can own the address. + /// + [Fact] + public void EveryTestAddressIsALocallyAdministeredUnicast() { + var source = new TestMacAddressSource(); + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + (FirstOctet(value) & 0b0000_0011).ShouldBe(0b0000_0010, $"'{value}' is not a locally administered unicast"); + "26AE".ShouldContain(value[1], $"'{value}' does not look locally administered"); + } + } + + [Fact] + public void TheTestAddressesDifferFromThePlainOnesOnTheSameSeed() { + var plain = new MacAddressSource(); + var test = new TestMacAddressSource(); + + var fromPlain = Enumerable.Range(0, 20).Select(_ => plain.Next(null)).ToList(); + var fromTest = Enumerable.Range(0, 20).Select(_ => test.Next(null)).ToList(); + + fromTest.ShouldNotBe(fromPlain); + } + + [Fact] + public void AnUnknownFormatThrows() + => Should.Throw(() => new MacAddressSource((MacAddressFormat) 99)); + + [Fact] + public void NextReturnsStableAddressListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new MacAddressSource(), new string[] { "DC:EF:43:94:73:6D", "B0:53:F8:60:17:DC", "C0:62:2C:E6:7A:11", "A8:30:7D:E9:46:43", "6C:1B:F6:94:A3:5A", "98:6D:B2:DA:92:88", "6C:B6:FB:6A:9E:05", "20:D0:C3:DD:2E:C6", "C4:95:0A:20:5D:56", "10:89:DF:52:62:C5" }); + + [Fact] + public void NextReturnsStableTestAddressListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new TestMacAddressSource(), new string[] { "DE:EF:43:94:73:6D", "B2:53:F8:60:17:DC", "C2:62:2C:E6:7A:11", "AA:30:7D:E9:46:43", "6E:1B:F6:94:A3:5A", "9A:6D:B2:DA:92:88", "6E:B6:FB:6A:9E:05", "22:D0:C3:DD:2E:C6", "C6:95:0A:20:5D:56", "12:89:DF:52:62:C5" }); + + [Fact] + public void NextReturnsStableAddressListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableMacAddressSource()!, new string?[] { "DC:EF:43:94:73:6D", "B0:53:F8:60:17:DC", "C0:62:2C:E6:7A:11", "A8:30:7D:E9:46:43", "6C:1B:F6:94:A3:5A", "98:6D:B2:DA:92:88", "6C:B6:FB:6A:9E:05", "20:D0:C3:DD:2E:C6", "C4:95:0A:20:5D:56", "10:89:DF:52:62:C5", "EC:95:F2:FB:44:ED", "E0:1F:B8:D9:81:CC", "84:9C:B8:03:8A:32", "D8:FC:37:3C:C6:5B", "14:09:D1:CD:B7:5A", "B4:1B:BA:83:BD:10", "C4:2E:5A:7E:57:AE", "FC:81:BC:30:EB:FD", "E0:FA:90:65:21:5F", "DC:F8:16:C3:7F:DF", "F4:0C:95:37:B6:FB", "BC:5A:7D:87:60:2A", "90:3C:7A:44:A1:43", "18:A8:B0:1B:FE:2C", null, "90:0F:2E:7A:50:CC", "8C:D1:E7:B1:D2:BD", null, "84:9E:03:1F:28:03", "B0:1C:67:D3:B1:71" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidAddresses() { + var source = new NullableTestMacAddressSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + ColonFormat().IsMatch(value!).ShouldBeTrue($"'{value}' does not match the colon format"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new MacAddressSource().Next(null)).ShouldAllBe(v => v != null); +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VinSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VinSourceTests.cs new file mode 100644 index 0000000..c76de7f --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VinSourceTests.cs @@ -0,0 +1,102 @@ +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Identifiers; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Identifiers; + +public class VinSourceTests : TestBase { + /// + /// Known valid numbers, so the validator is measured against the world before it judges the source: + /// the standard's own example whose check digit is the X for ten, and the all ones number whose + /// weighted sum is 89, which is 1 mod 11. + /// + [Theory] + [InlineData("1M8GDM9AXKP042788")] + [InlineData("11111111111111111")] + public void TheValidatorAcceptsAKnownValidVin(string value) + => IdentifierValidation.IsValidVin(value).ShouldBeTrue(); + + [Theory] + [InlineData("1M8GDM9A0KP042788")] // check digit 0 where the X belongs + [InlineData("1M8GDM9A1KP042788")] // check digit 1 where the X belongs + [InlineData("2M8GDM9AXKP042788")] // a data character changed, the X no longer fits + [InlineData("1M8GDM9AXKP04278")] // one character short + [InlineData("1M8GDM9AXKP0427888")] // one character too many + [InlineData("IM8GDM9AXKP042788")] // an I never appears in a VIN + [InlineData("1m8gdm9axkp042788")] // capital letters only + [InlineData("")] + [InlineData(null)] + public void TheValidatorRejectsABrokenVin(string? value) + => IdentifierValidation.IsValidVin(value).ShouldBeFalse(); + + [Fact] + public void EveryVinHasSeventeenCharactersAndAValidCheckDigit() { + var source = new VinSource(); + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + value.Length.ShouldBe(17); + IdentifierValidation.IsValidVin(value).ShouldBeTrue($"'{value}' has a broken check digit"); + value.ShouldAllBe(c => c != 'I' && c != 'O' && c != 'Q'); + "UZ0".ShouldNotContain(value[9], $"'{value}' carries a model year character 49 CFR 565 does not allow"); + } + } + + /// + /// The check digit ten is written as an X and comes up in roughly one of eleven numbers, so a sample + /// of a few hundred has to contain one. + /// + [Fact] + public void ACheckDigitOfTenIsWrittenAsAnX() { + var source = new VinSource(); + + Enumerable.Range(0, 500) + .Select(_ => source.Next(null)) + .ShouldContain(v => v[8] == 'X'); + } + + [Fact] + public void APrefixPinsTheWorldManufacturerIdentifier() { + var source = new VinSource("WVW"); + + for (var i = 0; i < 100; i++) { + var value = source.Next(null); + value.ShouldStartWith("WVW"); + IdentifierValidation.IsValidVin(value).ShouldBeTrue($"'{value}' has a broken check digit"); + } + } + + [Theory] + [InlineData("WVI")] // I is not a VIN character + [InlineData("wvw")] // capital letters only + [InlineData("WVWZZZ1JZ")] // nine characters reach into the check digit + public void ABrokenPrefixThrows(string prefix) + => Should.Throw(() => new VinSource(prefix)); + + [Fact] + public void NextReturnsStableVinListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new VinSource(), new string[] { "V3LKZPV203H63ULTT", "TJ8X5ZG32Y65MAZWN", "G9YJ5M4Y6SS1C4V3A", "S6UN9HDT5MU3G6TP1", "ZGYVN3YC0NT7ZK43U", "UKFGCEHJ5Y4X3Y3VK", "4VXGDGZR81FS1M8CV", "ZBT679PW2L14KN7A2", "Z4YB0CP548ZYK5DN0", "PP8MZV3S4DPLGGHPC" }); + + [Fact] + public void NextReturnsStableVinListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableVinSource()!, new string?[] { "V3LKZPV203H63ULTT", "TJ8X5ZG32Y65MAZWN", "G9YJ5M4Y6SS1C4V3A", "S6UN9HDT5MU3G6TP1", "ZGYVN3YC0NT7ZK43U", "UKFGCEHJ5Y4X3Y3VK", "4VXGDGZR81FS1M8CV", "ZBT679PW2L14KN7A2", "Z4YB0CP548ZYK5DN0", "PP8MZV3S4DPLGGHPC", "PAPSNX0B18YPHSC0V", "DKGDCB2V6YBF83M4F", "K5SYW1JL76V1WR7XV", "F3134YXE0M17F56JE", "VUEH2X9B980GK7VTG", "X5F0ZAVZ33L22VWZJ", "3X0KXLK154G0XSC23", "DGD7Y67P226VU8XV2", "9XRGP84K0NBH936CD", "HYRV41KL7XGJPM2Z8", "Y5HBR9Y2X5FSBWP6N", "U0LFM8KK7LZEJ6BHJ", "ZP1WHEVR39G3FFC63", "PR95PDLF4A1VKLVAH", null, "SHWPYK7L1WDNEWG09", "DJ44WK269XHF09RU5", null, "6PZHD30563YYBPV0E", "A5WWW5MX3MCTJ7SBS" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidNumbers() { + var source = new NullableVinSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + IdentifierValidation.IsValidVin(value).ShouldBeTrue($"'{value}' has a broken check digit"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new VinSource().Next(null)).ShouldAllBe(v => v != null); +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/GuidV7SourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/GuidV7SourceTests.cs new file mode 100644 index 0000000..82f6649 --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/GuidV7SourceTests.cs @@ -0,0 +1,100 @@ +using System.Globalization; +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Primitives; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Primitives; + +public class GuidV7SourceTests : TestBase { + /// + /// The instant a version 7 UUID encodes, read from its first 48 bits. + /// + private static DateTime EncodedTimestamp(Guid value) { + var hex = value.ToString("N"); + var milliseconds = long.Parse(hex[..12], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return DateTime.UnixEpoch.AddMilliseconds(milliseconds); + } + + [Fact] + public void EveryUuidCarriesTheVersionAndVariantBits() { + var source = new GuidV7Source(); + + for (var i = 0; i < 300; i++) { + var hex = source.Next(null).ToString("N"); + hex[12].ShouldBe('7', $"'{hex}' does not carry version 7"); + "89ab".ShouldContain(hex[16], $"'{hex}' does not carry the RFC variant"); + } + } + + [Fact] + public void TheTimestampStaysInsideTheDefaultRange() { + var source = new GuidV7Source(); + var min = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var max = new DateTime(2036, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + for (var i = 0; i < 300; i++) { + var timestamp = EncodedTimestamp(source.Next(null)); + timestamp.ShouldBeInRange(min, max); + } + } + + [Fact] + public void APinnedTimestampIsEncodedExactly() { + var instant = new DateTime(2026, 9, 22, 8, 30, 0, DateTimeKind.Utc); + var source = new GuidV7Source(instant, instant); + + for (var i = 0; i < 20; i++) + EncodedTimestamp(source.Next(null)).ShouldBe(instant); + } + + [Fact] + public void TheSameSeedGivesTheSameUuidsAndTheClockPlaysNoPart() { + var first = new GuidV7Source(); + var second = new GuidV7Source(); + + var fromFirst = Enumerable.Range(0, 20).Select(_ => first.Next(null)).ToList(); + var fromSecond = Enumerable.Range(0, 20).Select(_ => second.Next(null)).ToList(); + + fromFirst.ShouldBe(fromSecond); + } + + [Fact] + public void ReversedBoundsThrow() + => Should.Throw(() => new GuidV7Source( + new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + + [Fact] + public void ABoundBeforeTheUnixEpochThrows() + => Should.Throw(() => new GuidV7Source( + new DateTime(1969, 12, 31, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + + [Fact] + public void NextReturnsStableUuidListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new GuidV7Source(), new Guid[] { new Guid("017cba51-dadc-7f43-9473-6db153f86017"), new Guid("01842887-1fdc-7262-ace6-7a11a8307de9"), new Guid("0118f0ff-75f6-74a3-9a9a-6db2da92886e"), new Guid("013424f3-3fb6-7b6a-9e05-20d0c3dd2ec6"), new Guid("01db5c0b-34c5-750a-a05d-561089df5262"), new Guid("01d1e3b3-1e95-72fb-84ed-e31fb8d981cc"), new Guid("00fdaa34-db84-7cb8-838a-32d9fc373cc6"), new Guid("0170cc59-365b-7609-91cd-b75ab41bba83"), new Guid("01b66c1f-ad10-762e-9a7e-57aefd81bc30"), new Guid("0188ed8c-28eb-7de0-ba90-65215fdcf816") }); + + [Fact] + public void NextReturnsStableUuidListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableGuidV7Source()!, new Guid?[] { new Guid("017cba51-dadc-7f43-9473-6db153f86017"), new Guid("01842887-1fdc-7262-ace6-7a11a8307de9"), new Guid("0118f0ff-75f6-74a3-9a9a-6db2da92886e"), new Guid("013424f3-3fb6-7b6a-9e05-20d0c3dd2ec6"), new Guid("01db5c0b-34c5-750a-a05d-561089df5262"), new Guid("01d1e3b3-1e95-72fb-84ed-e31fb8d981cc"), new Guid("00fdaa34-db84-7cb8-838a-32d9fc373cc6"), new Guid("0170cc59-365b-7609-91cd-b75ab41bba83"), new Guid("01b66c1f-ad10-762e-9a7e-57aefd81bc30"), new Guid("0188ed8c-28eb-7de0-ba90-65215fdcf816"), new Guid("01237cd7-e47f-7ff6-8c95-37b6fbbf5a7d"), new Guid("0150f3b3-0687-702a-933c-7a44a1431ba8"), new Guid("010a0e07-34b0-7bfe-ac93-0f2e7a50cc8e"), new Guid("013352b5-4ae7-71d2-bd84-9e031f2803b0"), new Guid("015ee48a-6eb1-7144-bf5c-ac9e107330bf"), new Guid("01a7db01-ac10-7098-80ae-bf0f2e67d901"), new Guid("01187fbd-e93f-7508-8c61-7b9c6da569e0"), new Guid("01e0caae-0e0b-7a2f-8607-0997b96cdd93"), new Guid("00e93f75-2beb-7b81-b26b-27c46213683b"), new Guid("016804ba-5296-770a-8260-84285fcbc08c"), new Guid("01ac3674-6257-7567-a05f-5305254d9600"), new Guid("00fc2567-64f9-7673-b917-e617c855ebaa"), new Guid("01c7df66-6ef0-7fe0-acdc-f2798328192c"), new Guid("012debba-db79-7490-902d-2bf7285157e6"), null, new Guid("0108b771-927d-7cf1-af28-f0d7acf237a9"), new Guid("019d8e3e-900a-77d9-ba2e-16ee3df8661e"), null, new Guid("0116033b-2200-798b-a71f-d7f01129d9cc"), new Guid("0123ce09-ff7f-73be-8021-5c0d6f93504d") }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseVersionSevenUuids() { + var source = new NullableGuidV7Source(); + source.SetNullCreationThreshold(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + value!.Value.ToString("N")[12].ShouldBe('7'); + } + + [Fact] + public void ANonNullableSourceNeverReturnsEmpty() + => Enumerable.Range(0, 500).Select(_ => new GuidV7Source().Next(null)).ShouldAllBe(v => v != Guid.Empty); +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/PatternSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/PatternSourceTests.cs new file mode 100644 index 0000000..44f1ca9 --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Primitives/PatternSourceTests.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Primitives; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Primitives; + +public class PatternSourceTests : TestBase { + [Theory] + [InlineData("#####", @"^\d{5}$")] + [InlineData("@@@", "^[A-Z]{3}$")] + [InlineData("***", "^[0-9A-Z]{3}$")] + [InlineData("AB-##", @"^AB-\d{2}$")] // letters that are no placeholders stay literal + [InlineData("ORD/####/@@", @"^ORD/\d{4}/[A-Z]{2}$")] + [InlineData(@"\#-#", @"^#-\d$")] // an escaped placeholder stays literal + [InlineData(@"\\#", @"^\\\d$")] // an escaped backslash stays a backslash + public void EveryValueMatchesItsPattern(string pattern, string expected) { + var source = new PatternSource(pattern); + var regex = new Regex(expected); + + for (var i = 0; i < 200; i++) { + var value = source.Next(null); + regex.IsMatch(value).ShouldBeTrue($"'{value}' does not match pattern '{pattern}'"); + } + } + + [Fact] + public void EveryPlaceholderCharacterOccurs() { + var source = new PatternSource("#@*"); + var values = Enumerable.Range(0, 2000).Select(_ => source.Next(null)).ToList(); + + values.Select(v => v[0]).Distinct().OrderBy(c => c).ShouldBe("0123456789".ToCharArray()); + values.Select(v => v[1]).Distinct().Count().ShouldBe(26); + values.Select(v => v[2]).Distinct().Count().ShouldBe(36); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("ab\\")] // a lone trailing backslash escapes nothing + public void ABrokenPatternThrows(string? pattern) + => Should.Throw(() => new PatternSource(pattern!)); + + [Fact] + public void ThePatternIsPublished() + => new PatternSource("@@-##").Pattern.ShouldBe("@@-##"); + + [Fact] + public void NextReturnsStableValueListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new PatternSource("@@@-#####"), new string[] { "PDU-31380", "XCC-61809", "GDO-64322", "IOW-50036", "FVK-06092", "CFM-52438", "ZBM-48329", "XGW-91743", "QGO-71000", "FBY-63657" }); + + [Fact] + public void NextReturnsStableValueListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullablePatternSource("@@@-#####")!, new string?[] { "PDU-31380", "XCC-61809", "GDO-64322", "IOW-50036", "FVK-06092", "CFM-52438", "ZBM-48329", "XGW-91743", "QGO-71000", "FBY-63657", "WHA-34138", "QMT-01712", "EDI-30731", "REM-03026", "NQA-80791", "VIM-15904", "LPG-79793", "LLB-27423", "IWH-20480", "MXF-70355", "NWA-96397", "GXI-50029", "DIZ-79400", "NLX-81761", null, "PIQ-72797", "ZOW-86097", null, "XQR-99301", "NPT-06283" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseMatchingValues() { + var source = new NullablePatternSource("####", 50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + Regex.IsMatch(value!, @"^\d{4}$").ShouldBeTrue($"'{value}' does not match the pattern"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new PatternSource("##").Next(null)).ShouldAllBe(v => v != null); +} diff --git a/holonsoft.AutoPoco/DataSources/Business/LotNumberSource.cs b/holonsoft.AutoPoco/DataSources/Business/LotNumberSource.cs new file mode 100644 index 0000000..6ba74af --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Business/LotNumberSource.cs @@ -0,0 +1,86 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.DataSources.Primitives; +using holonsoft.AutoPoco.Engine.Interfaces; + +namespace holonsoft.AutoPoco.DataSources.Business; + +/// +/// Generates a lot or batch number the way manufacturing plants stamp them: an L, a date code of two +/// digit year and three digit day of the year, a hyphen and a drawn suffix, e.g. L26265-0387 for +/// the 265th day of 2026, batch 0387. +/// +/// +/// Lot numbers follow no standard, but the year plus day-of-year date code is the widespread convention, +/// so the default looks like the real thing. The date is drawn between the two bounds from the seeded +/// stream, never from the clock, so the same seed gives the same lots on every run. The suffix comes from +/// a pattern, see for the placeholders; the date code itself is not part +/// of the pattern and always present. Where your plant writes lots differently, use +/// directly. +/// +public abstract class LotNumberSourceBase : PatternSourceBase { + private static readonly DateOnly _defaultMinDate = new(2020, 1, 1); + private static readonly DateOnly _defaultMaxDate = new(2035, 12, 31); + + /// + /// The suffix pattern used when none is given: four digits. + /// + public const string DefaultSuffixPattern = "####"; + + private readonly DateOnly _minDate; + private readonly DateOnly _maxDate; + + /// + /// Creates the source. + /// + /// Lower bound of the date code, inclusive; the first of January 2020 when null. + /// Upper bound of the date code, inclusive; the last day of 2035 when null. + /// The pattern behind the hyphen, four digits when null. + /// Optional null creation threshold in percent. + /// lies before . + /// is empty or ends in a lone backslash. + protected LotNumberSourceBase(DateOnly? minDate = null, DateOnly? maxDate = null, string? suffixPattern = null, int? nullCreationThreshold = null) + : base(suffixPattern ?? DefaultSuffixPattern, nullCreationThreshold) { + _minDate = minDate ?? _defaultMinDate; + _maxDate = maxDate ?? _defaultMaxDate; + + if (_maxDate < _minDate) + throw new ArgumentOutOfRangeException(nameof(maxDate), _maxDate, $"The maximum date must not be before the minimum date ({_minDate:O})."); + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var days = _maxDate.DayNumber - _minDate.DayNumber; + var date = DateOnly.FromDayNumber(_minDate.DayNumber + Random.Next(0, days + 1)); + + return $"L{date.Year % 100:00}{date.DayOfYear:000}-{FillPattern(Pattern)}"; + } +} + +/// +/// A lot number with a seeded date code, e.g. L26265-0387. +/// +public class LotNumberSource(DateOnly? minDate, DateOnly? maxDate, string? suffixPattern) : LotNumberSourceBase(minDate, maxDate, suffixPattern) { + public LotNumberSource() : this(null, null, null) { } + + public LotNumberSource(string suffixPattern) : this(null, null, suffixPattern) { } + + public LotNumberSource(DateOnly minDate, DateOnly maxDate) : this(minDate, maxDate, null) { } +} + +/// +/// A lot number that returns null every now and then. +/// +public class NullableLotNumberSource(DateOnly? minDate, DateOnly? maxDate, string? suffixPattern, int nullCreationThreshold) + : LotNumberSourceBase(minDate, maxDate, suffixPattern, nullCreationThreshold) { + public NullableLotNumberSource() : this(null, null, null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableLotNumberSource(string suffixPattern) : this(null, null, suffixPattern, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableLotNumberSource(DateOnly minDate, DateOnly maxDate) : this(minDate, maxDate, null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableLotNumberSource(int nullCreationThreshold) : this(null, null, null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Business/SerialNumberSource.cs b/holonsoft.AutoPoco/DataSources/Business/SerialNumberSource.cs new file mode 100644 index 0000000..9db24e6 --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Business/SerialNumberSource.cs @@ -0,0 +1,41 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.DataSources.Primitives; + +namespace holonsoft.AutoPoco.DataSources.Business; + +/// +/// Generates a serial number from a pattern. The default ****-****-****-**** gives four blocks of +/// four characters, something like 8HK2-P0QF-1Z7A-M4CD. +/// +/// +/// A serial number follows no standard, every manufacturer invents their own scheme, so this source makes +/// no claim of validity beyond the pattern; give your own pattern when your devices look different, see +/// for the placeholders. The IMEI of a mobile device does follow a +/// standard, use ImeiSource for that. +/// +public abstract class SerialNumberSourceBase(string? pattern, int? nullCreationThreshold = null) + : PatternSourceBase(pattern ?? DefaultPattern, nullCreationThreshold) { + /// + /// The pattern used when none is given: four blocks of four characters. + /// + public const string DefaultPattern = "****-****-****-****"; +} + +/// +/// A serial number from a pattern, four blocks of four characters unless another pattern is given. +/// +public class SerialNumberSource(string? pattern) : SerialNumberSourceBase(pattern) { + public SerialNumberSource() : this(null) { } +} + +/// +/// A serial number that returns null every now and then. +/// +public class NullableSerialNumberSource(string? pattern, int nullCreationThreshold) + : SerialNumberSourceBase(pattern, nullCreationThreshold) { + public NullableSerialNumberSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableSerialNumberSource(string? pattern) : this(pattern, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableSerialNumberSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Business/SkuSource.cs b/holonsoft.AutoPoco/DataSources/Business/SkuSource.cs new file mode 100644 index 0000000..935a243 --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Business/SkuSource.cs @@ -0,0 +1,40 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.DataSources.Primitives; + +namespace holonsoft.AutoPoco.DataSources.Business; + +/// +/// Generates an SKU, the article code a merchant assigns, from a pattern. The default +/// @@@-##### gives something like KDX-83741. +/// +/// +/// An SKU follows no standard, every merchant invents their own scheme, so this source makes no claim of +/// validity beyond the pattern; give your own pattern when your system expects a different shape, see +/// for the placeholders. For the article numbers that do follow a +/// standard, GTIN, EAN or ASIN, use those sources instead. +/// +public abstract class SkuSourceBase(string? pattern, int? nullCreationThreshold = null) + : PatternSourceBase(pattern ?? DefaultPattern, nullCreationThreshold) { + /// + /// The pattern used when none is given: three letters, a hyphen and five digits. + /// + public const string DefaultPattern = "@@@-#####"; +} + +/// +/// An SKU from a pattern, three letters, a hyphen and five digits unless another pattern is given. +/// +public class SkuSource(string? pattern) : SkuSourceBase(pattern) { + public SkuSource() : this(null) { } +} + +/// +/// An SKU that returns null every now and then. +/// +public class NullableSkuSource(string? pattern, int nullCreationThreshold) : SkuSourceBase(pattern, nullCreationThreshold) { + public NullableSkuSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableSkuSource(string? pattern) : this(pattern, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableSkuSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Geo/GeoCoordinateSources.cs b/holonsoft.AutoPoco/DataSources/Geo/GeoCoordinateSources.cs new file mode 100644 index 0000000..32981aa --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Geo/GeoCoordinateSources.cs @@ -0,0 +1,88 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; +using holonsoft.AutoPoco.Util; + +namespace holonsoft.AutoPoco.DataSources.Geo; + +/// +/// The shared shape of the latitude and longitude sources: a double drawn uniformly between two bounds +/// that have to stay inside the valid range of the coordinate. +/// +/// +/// A latitude and a longitude on the same object are drawn independently, so together they name a point +/// inside the bounding box, but not a point with any further meaning; it can lie in the sea. Where the +/// two members have to describe one meaningful place, build them with Impose from prepared coordinates. +/// +public abstract class GeoCoordinateSourceBase : DataSourceBase { + private readonly double _min; + private readonly double _max; + + /// + /// Creates the source. + /// + /// Lower bound, inclusive. + /// Upper bound, inclusive. + /// The magnitude the coordinate can never exceed, 90 for a latitude, 180 for a longitude. + /// Optional null creation threshold in percent. + /// + /// A bound is not finite, lies outside the valid range, or the maximum lies below the minimum. + /// + private protected GeoCoordinateSourceBase(double min, double max, double limit, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (!double.IsFinite(min) || min < -limit || min > limit) + throw new ArgumentOutOfRangeException(nameof(min), min, $"The bound must lie between {-limit} and {limit}."); + + if (!double.IsFinite(max) || max < -limit || max > limit) + throw new ArgumentOutOfRangeException(nameof(max), max, $"The bound must lie between {-limit} and {limit}."); + + if (max < min) + throw new ArgumentOutOfRangeException(nameof(max), max, $"The maximum must not be below the minimum ({min})."); + + _min = min; + _max = max; + } + + /// + /// Lower bound, inclusive. + /// + public double Min => _min; + + /// + /// Upper bound, inclusive. + /// + public double Max => _max; + + protected override T GetNextValue(IGenerationContext? context) + => (T) (object) Random.NextBetween(_min, _max); +} + +/// +/// A latitude in decimal degrees, the whole world unless a bounding box is given. +/// +public class LatitudeSource(double min, double max) : GeoCoordinateSourceBase(min, max, 90) { + public LatitudeSource() : this(-90, 90) { } +} + +/// +/// A latitude that returns null every now and then. +/// +/// +public class NullableLatitudeSource(double min, double max) : GeoCoordinateSourceBase(min, max, 90) { + public NullableLatitudeSource() : this(-90, 90) { } +} + +/// +/// A longitude in decimal degrees, the whole world unless a bounding box is given. +/// +public class LongitudeSource(double min, double max) : GeoCoordinateSourceBase(min, max, 180) { + public LongitudeSource() : this(-180, 180) { } +} + +/// +/// A longitude that returns null every now and then. +/// +/// +public class NullableLongitudeSource(double min, double max) : GeoCoordinateSourceBase(min, max, 180) { + public NullableLongitudeSource() : this(-180, 180) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/EoriSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/EoriSource.cs new file mode 100644 index 0000000..82343ea --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Identifiers/EoriSource.cs @@ -0,0 +1,263 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; +using holonsoft.AutoPoco.Util; + +namespace holonsoft.AutoPoco.DataSources.Identifiers; + +/// +/// Generates an EORI number, the customs identifier of an economic operator in the EU: the country code +/// and up to fifteen alphanumeric characters per Annex 12-01 of the UCC Implementing Regulation. +/// +/// +/// The EORI format itself carries no check digit, but most countries build the national part from an +/// identifier that does, and this source builds it the same way: Belgium from an enterprise number with +/// its mod 97 tail, Denmark from a CVR number divisible by eleven, France from a SIRET whose SIREN and +/// whole number both satisfy Luhn, Croatia from an OIB, Italy from a partita IVA, Poland from a NIP with +/// five zeros behind it, the United Kingdom and Northern Ireland from a VAT registration number with 000 +/// behind it, the Netherlands from nine digits of fiscal number and Germany from fifteen digits, which is +/// all their formats require. Austria is left out because no official source publishes its structure, and +/// the Portuguese third country scheme because its check digit algorithm is unpublished; a country we +/// cannot make verifiable stays out. +/// The numbers are syntactically valid, they are not registered and do not identify a real operator, so +/// the EU validation service will report them as not registered, which is the correct answer for test data. +/// +public abstract class EoriSourceBase : DataSourceBase { + /// + /// The countries this source can generate, as the prefixes the numbers carry. + /// + public static IReadOnlyCollection SupportedCountries { get; } = + ["BE", "DE", "DK", "FR", "GB", "HR", "IT", "NL", "PL", "XI"]; + + private static readonly string[] _countries = [.. SupportedCountries]; + + private readonly string? _fixedCountry; + + /// + /// Creates the source. + /// + /// + /// Optional country prefix, e.g. DE. Drawn from when null. + /// + /// Optional null creation threshold in percent. + /// is not a supported country. + protected EoriSourceBase(string? countryCode = null, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (countryCode is not null) { + if (!SupportedCountries.Contains(countryCode)) + throw new ArgumentException( + $"'{countryCode}' is not a country this source can generate an EORI number for. Supported: {string.Join(", ", SupportedCountries)}.", + nameof(countryCode)); + + _fixedCountry = countryCode; + } + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var country = _fixedCountry ?? _countries[Random.Next(0, _countries.Length)]; + + return country switch { + "BE" => $"BE{NextBelgianEnterpriseNumber()}", + "DE" => $"DE{NextDigits(15)}", + "DK" => $"DK{NextDanishCvrNumber()}", + "FR" => $"FR{NextFrenchSiret()}", + "GB" or "XI" => $"{country}{NextUnitedKingdomVatRegistrationNumber()}000", + "HR" => $"HR{NextCroatianOib()}", + "IT" => $"IT{NextItalianPartitaIva()}", + "NL" => $"NL{NextDigits(9)}", + "PL" => $"PL{NextPolishNip()}00000", + _ => throw new InvalidOperationException($"Country '{country}' has no EORI generator.") + }; + } + + private string NextDigits(int count) { + Span digits = stackalloc char[count]; + + for (var i = 0; i < count; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + return new string(digits); + } + + /// + /// Ten digits: a leading zero or one, a second digit off zero, and the pair 97 minus the first eight + /// mod 97, the same arithmetic the Belgian VAT ID uses. + /// + private string NextBelgianEnterpriseNumber() { + Span digits = stackalloc char[10]; + digits[0] = (char) ('0' + Random.Next(0, 2)); + digits[1] = (char) ('1' + Random.Next(0, 9)); + + var body = digits[0] - '0'; + body = (body * 10) + (digits[1] - '0'); + + for (var i = 2; i < 8; i++) { + digits[i] = (char) ('0' + Random.Next(0, 10)); + body = (body * 10) + (digits[i] - '0'); + } + + var check = 97 - (body % 97); + digits[8] = (char) ('0' + (check / 10)); + digits[9] = (char) ('0' + (check % 10)); + + return new string(digits); + } + + /// + /// Eight digits, the first off zero, weighted 2, 7, 6, 5, 4, 3, 2, 1 divisible by eleven; drawn again + /// when no last digit achieves that. + /// + private string NextDanishCvrNumber() { + ReadOnlySpan weights = [2, 7, 6, 5, 4, 3, 2]; + Span digits = stackalloc char[8]; + int check; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + check = (11 - (sum % 11)) % 11; + } while (check == 10); + + digits[7] = (char) ('0' + check); + + return new string(digits); + } + + /// + /// Fourteen digits: eight digits and their Luhn check digit form the SIREN, four digits of + /// establishment number follow, and the last digit makes the whole SIRET pass the Luhn check too. + /// + private string NextFrenchSiret() { + Span digits = stackalloc char[14]; + + for (var i = 0; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[8] = (char) ('0' + CheckDigits.Luhn(digits, 8)); + + for (var i = 9; i < 13; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[13] = (char) ('0' + CheckDigits.Luhn(digits, 13)); + + return new string(digits); + } + + /// + /// Nine digits of VAT registration number: seven digits weighted 8 down to 2, closed by the two digit + /// remainder of the MOD 97 or the MOD 9755 scheme, each with its excluded leading ranges. + /// + private string NextUnitedKingdomVatRegistrationNumber() { + ReadOnlySpan weights = [8, 7, 6, 5, 4, 3, 2]; + Span digits = stackalloc char[9]; + var mod9755 = Random.Next(0, 2) == 1; + int body; + + do { + body = Random.Next(0, 10_000_000); + } while (mod9755 + ? body is >= 1 and <= 1_000_000 + : body is >= 100_000 and <= 999_999 or >= 9_490_001 and <= 9_700_000 or >= 9_990_001 and <= 9_999_999); + + for (var i = 6; i >= 0; i--) { + digits[i] = (char) ('0' + (body % 10)); + body /= 10; + } + + var sum = mod9755 ? 55 : 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + var check = (97 - (sum % 97)) % 97; + digits[7] = (char) ('0' + (check / 10)); + digits[8] = (char) ('0' + (check % 10)); + + return new string(digits); + } + + /// + /// The eleven digit OIB with its ISO 7064 MOD 11,10 check digit. + /// + private string NextCroatianOib() { + Span digits = stackalloc char[11]; + + for (var i = 0; i < 10; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[10] = (char) ('0' + CheckDigits.Iso7064Mod1110(digits, 10)); + + return new string(digits); + } + + /// + /// The eleven digit partita IVA: seven digits, an office code between 001 and 100 and the Luhn check digit. + /// + private string NextItalianPartitaIva() { + Span digits = stackalloc char[11]; + + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var office = 1 + Random.Next(0, 100); + digits[7] = (char) ('0' + (office / 100)); + digits[8] = (char) ('0' + (office / 10 % 10)); + digits[9] = (char) ('0' + (office % 10)); + + digits[10] = (char) ('0' + CheckDigits.Luhn(digits, 10)); + + return new string(digits); + } + + /// + /// The ten digit NIP, the first digit off zero, closed by its weighted mod 11 check digit; drawn again + /// when the weighted sum leaves remainder ten. + /// + private string NextPolishNip() { + ReadOnlySpan weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]; + Span digits = stackalloc char[10]; + int check; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 9; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + check = CheckDigits.WeightedMod11(digits, weights); + } while (check == 10); + + digits[9] = (char) ('0' + check); + + return new string(digits); + } +} + +/// +/// An EORI number whose national part carries the check digits of the underlying national identifier. +/// +public class EoriSource(string? countryCode) : EoriSourceBase(countryCode) { + public EoriSource() : this(null) { } +} + +/// +/// An EORI number that returns null every now and then. +/// +public class NullableEoriSource(string? countryCode, int nullCreationThreshold) : EoriSourceBase(countryCode, nullCreationThreshold) { + public NullableEoriSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableEoriSource(string? countryCode) : this(countryCode, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableEoriSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/MacAddressSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/MacAddressSource.cs new file mode 100644 index 0000000..77b154f --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Identifiers/MacAddressSource.cs @@ -0,0 +1,146 @@ +using System.Text; +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; + +namespace holonsoft.AutoPoco.DataSources.Identifiers; + +/// +/// How a MAC address is written. +/// +public enum MacAddressFormat { + /// + /// Six hex pairs separated by colons, e.g. 00:1A:2B:3C:4D:5E. The most common form. + /// + Colons = 0, + + /// + /// Six hex pairs separated by hyphens, e.g. 00-1A-2B-3C-4D-5E. The Windows form. + /// + Hyphens = 1, + + /// + /// Twelve hex digits without separators, e.g. 001A2B3C4D5E. + /// + Bare = 2 +} + +/// +/// Generates a MAC address as upper case hex, a unicast address in the universally administered space. +/// +/// +/// The two low bits of the first octet are cleared: bit zero says unicast, bit one says universally +/// administered, so the address looks like the burned-in address of a network card. The leading three +/// octets are drawn, so they can land on an OUI a real manufacturer owns; where that matters, use +/// , whose addresses sit in the locally administered space that the +/// IEEE keeps free of manufacturer assignments. +/// +public abstract class MacAddressSourceBase : DataSourceBase { + private readonly MacAddressFormat _format; + + /// + /// Creates the source. + /// + /// How the address is written, colons unless another form is given. + /// Optional null creation threshold in percent. + /// is not a known form. + protected MacAddressSourceBase(MacAddressFormat format = MacAddressFormat.Colons, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (!Enum.IsDefined(format)) + throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown MAC address format."); + + _format = format; + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + Span octets = stackalloc byte[6]; + + for (var i = 0; i < 6; i++) + octets[i] = (byte) Random.Next(0, 256); + + octets[0] = AdjustFirstOctet(octets[0]); + + var separator = _format switch { + MacAddressFormat.Colons => ":", + MacAddressFormat.Hyphens => "-", + _ => "" + }; + + var address = new StringBuilder(17); + + for (var i = 0; i < 6; i++) { + if (i > 0) + address.Append(separator); + + address.Append(octets[i].ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + + return address.ToString(); + } + + /// + /// Puts the drawn first octet into the address space of this source. The base class clears the + /// multicast and the locally administered bit, so the address is a universally administered unicast. + /// + private protected virtual byte AdjustFirstOctet(byte octet) + => (byte) (octet & 0b1111_1100); +} + +/// +/// A unicast MAC address in the universally administered space. +/// +public class MacAddressSource(MacAddressFormat format) : MacAddressSourceBase(format) { + public MacAddressSource() : this(MacAddressFormat.Colons) { } +} + +/// +/// A unicast MAC address that returns null every now and then. +/// +public class NullableMacAddressSource(MacAddressFormat format, int nullCreationThreshold) + : MacAddressSourceBase(format, nullCreationThreshold) { + public NullableMacAddressSource() : this(MacAddressFormat.Colons, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableMacAddressSource(MacAddressFormat format) : this(format, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableMacAddressSource(int nullCreationThreshold) : this(MacAddressFormat.Colons, nullCreationThreshold) { } +} + +/// +/// A unicast MAC address in the locally administered space, recognisable as not belonging to any +/// manufacturer's network card. +/// +/// +/// The second bit of the first octet is set, which IEEE 802 reserves for locally administered addresses: +/// no manufacturer OUI is ever assigned there, so the address provably names no vendor hardware. The +/// second hex digit of such an address is always a 2, 6, A or E. This is the same convention that +/// randomised Wi-Fi addresses and virtual machines use. +/// +public abstract class TestMacAddressSourceBase(MacAddressFormat format = MacAddressFormat.Colons, int? nullCreationThreshold = null) + : MacAddressSourceBase(format, nullCreationThreshold) { + private protected override byte AdjustFirstOctet(byte octet) + => (byte) ((octet & 0b1111_1100) | 0b0000_0010); +} + +/// +/// A locally administered unicast MAC address, recognisable as test data. +/// +public class TestMacAddressSource(MacAddressFormat format) : TestMacAddressSourceBase(format) { + public TestMacAddressSource() : this(MacAddressFormat.Colons) { } +} + +/// +/// A locally administered unicast MAC address that returns null every now and then. +/// +public class NullableTestMacAddressSource(MacAddressFormat format, int nullCreationThreshold) + : TestMacAddressSourceBase(format, nullCreationThreshold) { + public NullableTestMacAddressSource() : this(MacAddressFormat.Colons, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableTestMacAddressSource(MacAddressFormat format) : this(format, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableTestMacAddressSource(int nullCreationThreshold) : this(MacAddressFormat.Colons, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/VinSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/VinSource.cs new file mode 100644 index 0000000..ec72e75 --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Identifiers/VinSource.cs @@ -0,0 +1,125 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; + +namespace holonsoft.AutoPoco.DataSources.Identifiers; + +/// +/// Generates a VIN, the seventeen character vehicle identification number, with a valid North American +/// check digit at position nine. +/// +/// +/// The alphabet leaves out I, O and Q, which a VIN never contains, and position ten leaves out U, Z and +/// the zero on top, the model year convention of 49 CFR 565. The check digit transliterates every letter +/// to a value, weights the seventeen positions 8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2 and +/// takes the sum mod 11, a ten written as an X. Strictly this check digit is the North American scheme; +/// European VINs are not obliged to carry one, but a number that satisfies it is valid everywhere. +/// A prefix pins the leading characters, e.g. a real world manufacturer identifier such as WVW, so a fleet +/// looks like one manufacturer. The numbers are syntactically valid, they are not registered and do not +/// identify a real vehicle. +/// +public abstract class VinSourceBase : DataSourceBase { + private const int _length = 17; + private const int _checkDigitPosition = 8; + + /// + /// Every character a VIN may contain: digits and capital letters without I, O and Q. + /// + private const string _alphabet = "0123456789ABCDEFGHJKLMNPRSTUVWXYZ"; + + /// + /// The characters allowed at position ten, the model year: the VIN alphabet without U, Z and zero. + /// + private const string _modelYearAlphabet = "123456789ABCDEFGHJKLMNPRSTVWXY"; + + private static readonly int[] _weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; + + private readonly string _prefix; + + /// + /// Creates the source. + /// + /// + /// Optional leading characters, normally the three character world manufacturer identifier. At most + /// eight characters, everything up to the check digit; the check digit is always calculated. + /// + /// Optional null creation threshold in percent. + /// + /// contains a character a VIN never carries, or is longer than eight characters. + /// + protected VinSourceBase(string? prefix = null, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + _prefix = prefix ?? ""; + + foreach (var c in _prefix) + if (!_alphabet.Contains(c)) + throw new ArgumentException( + $"A VIN consists of digits and capital letters without I, O and Q, '{_prefix}' does not.", nameof(prefix)); + + if (_prefix.Length > _checkDigitPosition) + throw new ArgumentException( + $"A VIN prefix is at most {_checkDigitPosition} characters long, everything up to the check digit; '{_prefix}' has {_prefix.Length}.", + nameof(prefix)); + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var characters = new char[_length]; + _prefix.AsSpan().CopyTo(characters); + + for (var i = _prefix.Length; i < _length; i++) + characters[i] = i switch { + _checkDigitPosition => '0', // placeholder, calculated below + 9 => _modelYearAlphabet[Random.Next(0, _modelYearAlphabet.Length)], + _ => _alphabet[Random.Next(0, _alphabet.Length)] + }; + + var sum = 0; + for (var i = 0; i < _length; i++) + sum += TransliterationValue(characters[i]) * _weights[i]; + + var check = sum % 11; + characters[_checkDigitPosition] = check == 10 + ? 'X' + : (char) ('0' + check); + + return new string(characters); + } + + /// + /// The value a character counts in the check digit sum: a digit counts as itself, a letter by the VIN + /// transliteration table, where A to H are one to eight, J to N one to five, P is seven, R is nine and + /// S to Z are two to nine. + /// + private static int TransliterationValue(char c) + => c switch { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'H' => c - 'A' + 1, + >= 'J' and <= 'N' => c - 'J' + 1, + 'P' => 7, + 'R' => 9, + _ => c - 'S' + 2 + }; +} + +/// +/// A VIN with a valid check digit. +/// +public class VinSource(string? prefix) : VinSourceBase(prefix) { + public VinSource() : this(null) { } +} + +/// +/// A VIN that returns null every now and then. +/// +public class NullableVinSource(string? prefix, int nullCreationThreshold) : VinSourceBase(prefix, nullCreationThreshold) { + public NullableVinSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableVinSource(string? prefix) : this(prefix, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableVinSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Primitives/GuidV7Source.cs b/holonsoft.AutoPoco/DataSources/Primitives/GuidV7Source.cs new file mode 100644 index 0000000..732164a --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Primitives/GuidV7Source.cs @@ -0,0 +1,83 @@ +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; + +namespace holonsoft.AutoPoco.DataSources.Primitives; + +/// +/// Generates a version 7 UUID per RFC 9562: 48 bits of millisecond timestamp, the version and variant +/// bits, and 74 random bits, all drawn from the AutoPoco stream instead of the clock. +/// +/// +/// The timestamp is drawn between the two bounds, the last day of 2035 as the default upper one, so the +/// same seed gives the same UUIDs on every run and no value depends on when the test runs. That is a +/// deliberate difference from Guid.CreateVersion7(): these UUIDs decode to a plausible +/// instant and carry correct version and variant bits, but they are not monotonic and do not encode the +/// current time. Use when any random Guid does the job and the version bits do +/// not matter. +/// +public abstract class GuidV7SourceBase : DataSourceBase { + private static readonly DateTime _defaultMinTimestamp = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _defaultMaxTimestamp = new(2035, 12, 31, 23, 59, 59, DateTimeKind.Utc); + + private readonly long _minMilliseconds; + private readonly long _maxMilliseconds; + + /// + /// Creates the source. + /// + /// Lower bound of the encoded instant, inclusive, treated as UTC. + /// Upper bound of the encoded instant, inclusive, treated as UTC. + /// + /// The bounds are reversed, or lie outside what 48 bits of Unix milliseconds can hold. + /// + protected GuidV7SourceBase(DateTime? minTimestamp = null, DateTime? maxTimestamp = null) { + var min = minTimestamp ?? _defaultMinTimestamp; + var max = maxTimestamp ?? _defaultMaxTimestamp; + + if (min < DateTime.UnixEpoch) + throw new ArgumentOutOfRangeException(nameof(minTimestamp), min, "A version 7 UUID timestamp starts at the Unix epoch."); + + if (max < min) + throw new ArgumentOutOfRangeException(nameof(maxTimestamp), max, $"The maximum timestamp must not be before the minimum timestamp ({min:O})."); + + _minMilliseconds = (min.Ticks - DateTime.UnixEpoch.Ticks) / TimeSpan.TicksPerMillisecond; + _maxMilliseconds = (max.Ticks - DateTime.UnixEpoch.Ticks) / TimeSpan.TicksPerMillisecond; + + // 48 bits of milliseconds reach the year 10889, a DateTime cannot exceed them + } + + protected override T GetNextValue(IGenerationContext? context) { + var milliseconds = _minMilliseconds + Random.NextInt64(0, _maxMilliseconds - _minMilliseconds + 1); + + Span bytes = stackalloc byte[16]; + + for (var i = 5; i >= 0; i--) { + bytes[i] = (byte) (milliseconds & 0xFF); + milliseconds >>= 8; + } + + bytes[6] = (byte) (0x70 | Random.Next(0, 16)); + bytes[7] = (byte) Random.Next(0, 256); + bytes[8] = (byte) (0x80 | Random.Next(0, 64)); + + for (var i = 9; i < 16; i++) + bytes[i] = (byte) Random.Next(0, 256); + + return (T) (object) new Guid(bytes, bigEndian: true); + } +} + +/// +/// A version 7 UUID whose timestamp is drawn from the seeded range instead of the clock. +/// +public class GuidV7Source(DateTime? minTimestamp, DateTime? maxTimestamp) : GuidV7SourceBase(minTimestamp, maxTimestamp) { + public GuidV7Source() : this(null, null) { } +} + +/// +/// A version 7 UUID source that returns null every now and then. +/// +/// +public class NullableGuidV7Source(DateTime? minTimestamp, DateTime? maxTimestamp) : GuidV7SourceBase(minTimestamp, maxTimestamp) { + public NullableGuidV7Source() : this(null, null) { } +} diff --git a/holonsoft.AutoPoco/DataSources/Primitives/PatternSource.cs b/holonsoft.AutoPoco/DataSources/Primitives/PatternSource.cs new file mode 100644 index 0000000..ba7cec8 --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Primitives/PatternSource.cs @@ -0,0 +1,110 @@ +using System.Text; +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; + +namespace holonsoft.AutoPoco.DataSources.Primitives; + +/// +/// Generates a string from a pattern: a # becomes a digit, a @ a capital letter, a * +/// one of both, a backslash keeps the next character literal, and every other character stays as it is. +/// "@@@-#####" gives something like KDX-83741. +/// +/// +/// Meant for the identifiers that follow no public standard: an SKU, a serial number, an order code. +/// Because there is no standard, nothing here claims to be validatable; the pattern is the contract. +/// For numbers that do carry a published check digit, use the dedicated sources, they exist for exactly +/// that reason. +/// +public abstract class PatternSourceBase : DataSourceBase { + private const string _letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string _lettersAndDigits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private readonly string _pattern; + + /// + /// Creates the source. + /// + /// + /// The pattern: # digit, @ capital letter, * digit or capital letter, backslash + /// escapes, everything else literal. + /// + /// Optional null creation threshold in percent. + /// is null, empty or ends in a lone backslash. + protected PatternSourceBase(string pattern, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (string.IsNullOrEmpty(pattern)) + throw new ArgumentException("A pattern needs at least one character.", nameof(pattern)); + + var escaped = false; + foreach (var c in pattern) + escaped = !escaped && c == '\\'; + + if (escaped) + throw new ArgumentException($"The pattern '{pattern}' ends in a lone backslash, which escapes nothing.", nameof(pattern)); + + _pattern = pattern; + } + + /// + /// The pattern this source was created with. + /// + public string Pattern => _pattern; + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + return FillPattern(_pattern); + } + + /// + /// Replaces every placeholder of the pattern with a drawn character. + /// + protected string FillPattern(string pattern) { + var value = new StringBuilder(pattern.Length); + var escaped = false; + + foreach (var c in pattern) { + if (escaped) { + value.Append(c); + escaped = false; + continue; + } + + switch (c) { + case '\\': + escaped = true; + break; + case '#': + value.Append((char) ('0' + Random.Next(0, 10))); + break; + case '@': + value.Append(_letters[Random.Next(0, _letters.Length)]); + break; + case '*': + value.Append(_lettersAndDigits[Random.Next(0, _lettersAndDigits.Length)]); + break; + default: + value.Append(c); + break; + } + } + + return value.ToString(); + } +} + +/// +/// A string built from a pattern: # digit, @ capital letter, * both, backslash escapes. +/// +public class PatternSource(string pattern) : PatternSourceBase(pattern); + +/// +/// A string built from a pattern that returns null every now and then. +/// +public class NullablePatternSource(string pattern, int nullCreationThreshold) : PatternSourceBase(pattern, nullCreationThreshold) { + public NullablePatternSource(string pattern) : this(pattern, AutoPocoDefaults.NullCreationThreshold) { } +}