diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9092d604..8c4bb9ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,11 +49,11 @@ as part of the same PR. spec, move the long form into the type's XML docs and leave a tight example in the readme. - **Package readmes** — each package that ships its own readme - ([`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md), + ([`StrongTypes.Configuration`](src/StrongTypes.Configuration/readme.md), + [`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md), [`StrongTypes.FsCheck`](src/StrongTypes.FsCheck/readme.md), [`StrongTypes.OpenApi.Microsoft`](src/StrongTypes.OpenApi.Microsoft/readme.md), [`StrongTypes.OpenApi.Swashbuckle`](src/StrongTypes.OpenApi.Swashbuckle/readme.md), - [`StrongTypes.Wpf`](src/StrongTypes.Wpf/readme.md), [`StrongTypes.AspNetCore`](src/StrongTypes.AspNetCore/readme.md)) should reflect what the package actually does after your change. Update the one that matches. diff --git a/Skill/SKILL.md b/Skill/SKILL.md index fa8451a1..c2b4a5b7 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -9,7 +9,8 @@ Library of focused C# value wrappers (`NonEmptyString`, `Positive`, `NonEmptyEnumerable`, …) and algebraic types (`Maybe`, `Result`) that push invariants into the type system. Every wrapper ships a `System.Text.Json` converter, so invalid input fails at -deserialization before any endpoint code runs. +deserialization before any endpoint code runs, and a `TypeConverter`, so +the same invariant validates `appsettings.json` as it binds. Per-type detail lives in `references/*.md` — load the relevant file on demand when about to write code against that surface. @@ -19,19 +20,20 @@ demand when about to write code against that surface. | Package | What it gives you | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Kalicz.StrongTypes` | The core types (`NonEmptyString`, numeric wrappers, `NonEmptyEnumerable`, `Maybe`, `Result`, …). | +| `Kalicz.StrongTypes.Configuration` | `OptionsBuilder.BindStrongTypes()` — binds a section and fails when a property declared non-nullable is null once bound, which an absent key otherwise leaves silently. Reads intent from nullable annotations, so no `[Required]` per property. | | `Kalicz.StrongTypes.EfCore` | EF Core value converters, interval column mapping (two endpoint columns by default, JSON opt-in), and LINQ translators (`.Unwrap()`, interval `Start`/`End`) so strong types sit directly on entity properties. | | `Kalicz.StrongTypes.FsCheck` | FsCheck `Arbitrary` generators registered via `[Properties(Arbitrary = new[] { typeof(Generators) })]`. | | `Kalicz.StrongTypes.OpenApi.Microsoft` | Schema transformers for `Microsoft.AspNetCore.OpenApi` (`AddOpenApi()`) so wrappers render as the wire JSON shape, not the CLR shape. | | `Kalicz.StrongTypes.OpenApi.Swashbuckle` | The same idea for Swashbuckle's `AddSwaggerGen()` pipeline — schema filters that produce the wire JSON shape. | -| `Kalicz.StrongTypes.Wpf` | `TypeConverter` infrastructure for two-way MVVM binding to strong-typed view-model properties. One `this.UseStrongTypes()` call in `App.OnStartup`. | | `Kalicz.StrongTypes.AspNetCore` | MVC model binder for `NonEmptyEnumerable` from `[FromForm]` / `[FromQuery]` / `[FromHeader]` / `[FromRoute]`, **plus** opt-out normalization of JSON request-body validation error keys (`$.value` → `Value`) so they match data-annotation / model-binding keys. The binder is niche; `[FromBody]` round-trips wrappers via the core JSON converters without it. | Add packages only when the host project actually hits that stack: +- **Configuration** — when a non-nullable reference property (`NonEmptyString`, `Email`, or a plain `string`) sits on an options class. Binding works without it (every wrapper carries a `TypeConverter`); the package only stops an absent key leaving that property null. Skip it if every reference property is nullable or has a default, or if you already use `[Required]`. See `references/configuration.md`. - **EfCore** — only if EF Core is in use. - **FsCheck** — only for property-based test projects. - **OpenApi.Microsoft** vs **OpenApi.Swashbuckle** — pick **one**, matching the spec generator the app already wires up. They are not interchangeable. `references/openapi.md` covers both pipelines. -- **Wpf** — only for WPF apps that two-way bind to strong-typed VM properties. See `references/wpf.md`. +- **WPF / WinForms** — no package. Two-way binding works off the core package's `[TypeConverter]`s; there is nothing to install or call. A `Kalicz.StrongTypes.Wpf` package existed before v2 — if you find it referenced, remove it. See `references/wpf.md`. - **AspNetCore** — add it when a controller takes `NonEmptyEnumerable` from a non-body source (forms, repeated query params, header lists), **or** when you want JSON request-body validation errors keyed by the property name (`Value`) instead of the System.Text.Json path (`$.value`). The error-key normalization is on by default once `AddStrongTypes()` is called — opt out with `AddStrongTypes(o => o.NormalizeJsonErrorKeys = false)`, or set `o.JsonErrorKeyCasing`. The binder alone is niche — `[FromBody]` already round-trips `NonEmptyEnumerable` via the core JSON converters — but the error-key normalization applies to any JSON API. See `references/aspnetcore.md`. ## Type catalog — what's in the box @@ -62,12 +64,13 @@ demand when about to write code against that surface. | Topic | Reference | | ------------------------------------------------------------- | ------------------------------- | | Enum extensions (`Enum.Parse`, `AllValues`, `AllFlagValues`, `GetFlags`) and `string?` parsers (`AsInt`, `AsGuid`, `AsEnum`, …) | `references/parsing.md` | +| Configuration / `IOptions` binding — zero setup, invariant doubles as validation, `ValidateOnStart()`, and `BindStrongTypes()` (`Kalicz.StrongTypes.Configuration`) for the absent key | `references/configuration.md` | | `T?.Map`, `bool.MapTrue` / `MapFalse` | `references/map.md` | | `IEnumerable` extensions, `ReadOnlyList`, `Result` partition helpers | `references/collections.md` | | EF Core: `UseStrongTypes` value converters, interval column mapping, `.Unwrap()` LINQ marker | `references/efcore.md` | | FsCheck: shared `Generators` class, shipped arbitraries | `references/fscheck.md` | | OpenAPI: `AddStrongTypes()` for either `AddOpenApi()` (`Kalicz.StrongTypes.OpenApi.Microsoft`) or `AddSwaggerGen()` (`Kalicz.StrongTypes.OpenApi.Swashbuckle`) | `references/openapi.md` | -| WPF: `this.UseStrongTypes()` in `App.OnStartup` for two-way MVVM binding | `references/wpf.md` | +| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/wpf.md` | | ASP.NET Core MVC: `services.AddStrongTypes()` for `NonEmptyEnumerable` from `[FromForm]` & friends, plus JSON request-body validation error-key normalization | `references/aspnetcore.md` | ## Design philosophy — picking the right wrapper diff --git a/Skill/references/configuration.md b/Skill/references/configuration.md new file mode 100644 index 00000000..3b47704d --- /dev/null +++ b/Skill/references/configuration.md @@ -0,0 +1,203 @@ +# Configuration and options binding + +Strong types bind from `IConfiguration` / `IOptions` with **no setup** — +every wrapper carries a `TypeConverter`, which is what `ConfigurationBinder` +uses to turn a config string into a typed value. Put the wrapper straight on +the options class: + +```csharp +public sealed class RetryOptions +{ + public Positive MaxRetries { get; set; } + public NonNegative InitialDelaySeconds { get; set; } + public NonEmptyString? Name { get; set; } + public Email? Contact { get; set; } + public Positive? OptionalLimit { get; set; } // absent key stays null +} + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); // see "Fail at startup" below +``` + +```json +{ + "Retry": { + "MaxRetries": 5, + "InitialDelaySeconds": 0, + "Name": "checkout", + "Contact": "ops@example.com" + } +} +``` + +## The invariant is the validation rule + +This is the reason to use a wrapper here rather than a plain `int`. The type +already says "must be positive", so a bad value is rejected without a +`[Range]` attribute or a custom `IValidateOptions`: + +``` +InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries' + to type 'StrongTypes.Positive`1[System.Int32]'. + ---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value') +``` + +The outer frame names the **config path** and the **offending value**; the +inner one names the **broken invariant**. Don't add a `[Range(1, int.MaxValue)]` +on top of a `Positive` — it is the same rule stated twice, and only one +of the two is enforced by the type. + +## Fail at startup, not on the first request + +Options binding is **lazy**. Without `ValidateOnStart()`, a bad value does not +stop the app: it starts, serves traffic, and throws on the first request that +reads `IOptions.Value` — surfacing as a 500 rather than a failed deploy. + +```csharp +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateOnStart(); // conversion failures now abort startup +``` + +`ValidateOnStart()` forces eager binding, so it catches conversion failures +even with **no** `IValidateOptions` registered. Always add it when options +hold strong types — otherwise the invariant buys you a later crash, not an +earlier one. + +## The missing key — `BindStrongTypes()` + +Add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/) +and the declaration becomes the spec — no `[Required]`, no `ValidateDataAnnotations()`: + +```csharp +builder.Services.AddOptions() + .BindStrongTypes(builder.Configuration.GetSection("Client")) + .ValidateOnStart(); +``` + +It fails when a property **declared non-nullable is null after binding** — the one state an absent +key can leave that the declaration forbids. Your nullable annotations already say which properties +those are, so no `[Required]` has to repeat it: + +```csharp +public sealed class ClientOptions +{ + public NonEmptyString Name { get; set; } = null!; // fails unless configured + public NonEmptyString? Nickname { get; set; } // nullable — fine + public string ApiKey { get; set; } = null!; // fails unless configured + public string Endpoint { get; set; } = "https://x.test"; // has a default — never null + public Positive MaxRetries { get; set; } // never checked — see below +} +``` + +``` +OptionsValidationException: 'Client:Name' is null. Configure it, give ClientOptions.Name a default, + or declare it nullable. +``` + +**Every reference property is covered, not only the wrappers** — a non-nullable `string` is as +broken by a missing key as a `NonEmptyString`. A declared default needs nothing: it isn't null. + +**At every depth, not just the top level.** Nested options classes, collection elements and +dictionary values are all walked, and the failure names the full path (`'Client:Endpoints:1:Host'`). +The walk stops exactly where `ConfigurationBinder` stops — at a type it has a `TypeConverter` for — +so a wrapper is a leaf, never something to recurse into. + +**Value types are not checked, and don't need to be.** An unconfigured `Positive` is `1` and an +unconfigured `bool` is `false` — defaults, and values those types are happy to hold. There is no +contradiction to catch, so this will not tell you that you forgot `MaxRetries`. Declare it +`Positive?` if "not configured" must be distinguishable; that is the only way the CLR offers. + +An options class in an assembly with `disable` declares no intent, so nothing +on it is enforced. + +Analyzer **ST0004** flags a plain `Bind` / `Configure` that would leave a non-nullable wrapper null, +with a code fix that rewrites the call. It reports only this library's own wrappers, never a plain +`string`, so it sees less than the check it points you at. It also stays quiet for a property +already carrying `[Required]`, which genuinely covers that case. + +The rest of this section describes what happens **without** that package. + +## `ValidateOnStart()` does not catch a *missing* value + +It only surfaces failures that binding itself raises. A key that simply isn't +there raises nothing — binding succeeds, it just doesn't assign — so the +property keeps whatever the options class gave it. Since a real options class +has no initialisers, that means: + +| declaration | key not in config | is that a problem? | +| --------------------------- | ----------------- | ----------------------------------------- | +| `NonEmptyString Name` | **`null`** | **yes** — the type says it is never null | +| `Positive MaxRetries` | `1` (default) | no — a value the type is happy to hold | +| `Positive? MaxRetries` | `null` | no — it is nullable | + +**A non-nullable `NonEmptyString` can be null.** The invariant constrains every +value the type can hold; it cannot make the binder assign one. Against an +unconfigured key the wrapper is no better than `string`, and that is the only +place an absent key produces a state the declaration forbids. + +Guard it with `[Required]` and `ValidateDataAnnotations()` — or with +`BindStrongTypes()` above, which reads the same intent from the nullable +annotation instead of an attribute: + +```csharp +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Retry")) + .ValidateDataAnnotations() // [Required] → catches a null reference property + .ValidateOnStart(); // → catches an invalid value, at startup +``` + +Neither can help with a struct: `default(Positive)` is `1`, so nothing +distinguishes "configured as 1" from "never configured". Declare it +`Positive?` when that distinction matters — the CLR offers no other way +to say it. + +## `null` and `""` — the exact matrix + +Not uniform, and not guessable. Measured; `ConfigurationBinder.Get` and +`IOptions.Value` agree on **every** row: + +| in `appsettings.json` | `NonEmptyString` | `NonEmptyString?` | `Positive` | `Positive?` | +| --------------------- | ---------------- | ----------------- | --------------- | ---------------- | +| key absent | **`null`** † | `null` | `1` (`default`) | `null` | +| `null` | **`null`** | `null` | `1` (`default`) | `null` | +| `""` | **throws** | **throws** | **throws** | **`null`** | +| `" "` | **throws** | **throws** | throws (format) | throws (format) | +| valid | binds | binds | binds | binds | +| invariant breach | throws | throws | throws | throws | + +† unless the options class initialises the property, which is rare — an absent +key leaves whatever was already there, and for a reference type that is `null`. + +Three things in there surprise people: + +- **`""` is not uniform.** It throws for everything *except* a nullable **struct** + wrapper, where it binds to `null` — a nullable struct resolves to the BCL's + `NullableConverter`, which maps empty to null before our converter is consulted. + A nullable *reference* wrapper gets no such treatment (`NonEmptyString?` is the + same runtime type as `NonEmptyString`), so `""` reaches `Parse` and fails. + Don't reach for `""` to mean "unset" — omit the key. +- **An explicit `null` nulls even a non-nullable reference property.** Nullability + is erased by the time the binder runs, so `"Name": null` leaves a + `NonEmptyString Name` holding `null`, exactly as it would a `string` — which is + precisely what `BindStrongTypes` (above) catches, treating it the same as an + absent key. Without that package, omit the key rather than writing `null`. +- **An explicit `null` overwrites, an absent key does not.** `"Name": null` clears + a property initialised in the options class; leaving the key out keeps it. + +## Things worth knowing + +- **A missing key leaves the default**, it does not throw. `default(Positive)` + is `1` and `default(NonNegative)` is `0` — both satisfy their invariant, so + a typo'd key is a silently valid default. Use `Positive?` when "not + configured" must be distinguishable from a configured value. +- **`ConfigurationBinder` parses with the invariant culture**, so `"1234.5"` is + a `Positive` of 1234.5 regardless of the host's locale. Config files + are not localized; don't write `1234,5`. +- **Nullable wrappers still enforce the invariant** when a value is present: + `OptionalLimit` may be absent, but `-1` is rejected. +- The same `TypeConverter` powers anything that goes through `TypeDescriptor` — + WPF/WinForms two-way binding (`references/wpf.md`), designers, + `PropertyGrid`, and libraries doing generic string↔object conversion. It is + one mechanism on the type, so none of them need a registration call. diff --git a/Skill/references/numeric.md b/Skill/references/numeric.md index 4ba7fa88..15673ea6 100644 --- a/Skill/references/numeric.md +++ b/Skill/references/numeric.md @@ -49,8 +49,53 @@ All four types have `AsX` / `ToX` extension methods available on any and matching `==`, `!=`, `<`, `<=`, `>`, `>=` operators on both sides — so `4.ToPositive() > 2` is just a comparison, no unwrap. - `GetHashCode`, `Equals(object?)`, `ToString()` delegating to the value. +- `IFormattable` / `ISpanFormattable` — format specifiers and cultures reach + the underlying value (see "Formatting" below). +- `IParsable` / `ISpanParsable` — `Parse` / `TryParse` from a + `string` or a `ReadOnlySpan`, enforcing the invariant on the way in. + This is also what lets ASP.NET Core bind a wrapper from `[FromQuery]` / + `[FromRoute]` without any package. - `System.Text.Json` converter via `[JsonConverter]` — serialises as the underlying primitive. +- `TypeConverter` via `[TypeConverter]` — binds from `appsettings.json` / + `IOptions`. See `references/configuration.md`. + +## Formatting + +Format specifiers and format providers pass straight through to the +underlying value, so a wrapper formats exactly like the number it wraps: + +```csharp +var price = 1234.5m.ToPositive(); + +$"{price:N2}" // "1,234.50" +$"{price:C}" // "$1,234.50" +string.Format(germanCulture, "{0}", price) // "1234,5" +price.ToString("N2", CultureInfo.InvariantCulture); +``` + +Note `price.ToString()` with no provider uses the **current culture**, same +as `decimal.ToString()`. Pass an explicit provider anywhere the output is +machine-read (logs, URLs, files) rather than displayed. + +Parsing accepts a span, so a wrapper can be read out of a larger buffer +without slicing it into a string first: + +```csharp +Positive.Parse(line.AsSpan(6, 2), CultureInfo.InvariantCulture); +Positive.TryParse(span, CultureInfo.InvariantCulture, out var count); +``` + +Both interfaces also let a wrapper satisfy generic constraints, which no +amount of reaching for `.Value` at the call site can do for the caller: + +```csharp +static T Read(ReadOnlySpan s) where T : ISpanParsable => T.Parse(s, null); +static string Render(T v, string f) where T : IFormattable => v.ToString(f, null); + +Read>("42"); +Render(price, "C"); +``` ## Arithmetic diff --git a/Skill/references/wpf.md b/Skill/references/wpf.md index 109607e9..b43a366d 100644 --- a/Skill/references/wpf.md +++ b/Skill/references/wpf.md @@ -1,50 +1,34 @@ -# WPF MVVM binding — `Kalicz.StrongTypes.Wpf` +# WPF MVVM binding -Adds the `TypeConverter` infrastructure WPF needs to two-way bind a -`TextBox` (or any other input control) to a strong-typed view-model -property. +**No package, no setup.** Two-way binding a `TextBox` to a strong-typed +view-model property works off the core `Kalicz.StrongTypes` package alone. -## Why a separate package +> There was a `Kalicz.StrongTypes.Wpf` package requiring a +> `this.UseStrongTypes()` call in `App.OnStartup`. It is **gone as of v2** — +> the core types now carry `[TypeConverter]` themselves. Delete the package +> reference and the call; nothing replaces them. If you see +> `UseStrongTypes()` on an `Application` in old code or a blog post, that is +> the removed API. (The identically-named EF Core and OpenAPI +> `UseStrongTypes()` calls are unrelated and still current.) + +## Why it works WPF's binding pipeline routes `string → T` through -`TypeDescriptor.GetConverter(T)` and never consults `IParsable` -directly. Without a converter, typing into a `TextBox` bound to e.g. -a `NonEmptyString` view-model property silently fails to update the -source. The core `Kalicz.StrongTypes` package deliberately carries no -UI dependency, so the wiring lives here. - -## Wiring - -Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers -every strong type with a string round-trip — `NonEmptyString`, `Email`, -`Digit`, and every closed instantiation of the generic numeric wrappers -(`Positive`, `Negative`, …) — the package installs a -`TypeDescriptionProvider` that synthesises an `IParsable`-backed -converter on demand the first time WPF asks for it. - -Composite types have no single-`TextBox` string form and so get no -converter: bind their parts instead. An interval -(`FiniteInterval`, `Interval`, `IntervalFrom`, -`IntervalUntil`) binds field-by-field via `.Start` / `.End`; -`Maybe` and `NonEmptyEnumerable` likewise bind through their +`TypeDescriptor.GetConverter(T)` and never consults `IParsable` directly. +Every strong type with a string round-trip carries a `[TypeConverter]` that +bridges the two — `NonEmptyString`, `Email`, `Digit`, and every closed +instantiation of the generic numeric wrappers (`Positive`, +`Negative`, …). Nothing to register: the attribute travels with the +type. + +Composite types have no single-`TextBox` string form and so get no converter: +bind their parts instead. An interval (`FiniteInterval`, `Interval`, +`IntervalFrom`, `IntervalUntil`) binds field-by-field via `.Start` / +`.End`; `Maybe` and `NonEmptyEnumerable` likewise bind through their members, not as a whole. -```csharp -public partial class App : Application -{ - protected override void OnStartup(StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} -``` - ## Bindings — what to write in XAML -After registration, plain MVVM bindings to strong-typed properties -just work: - ```xml ` displays as `1234,5` on a +de-DE binding and reads back unchanged. Don't add an `IValueConverter` to +compensate — a pre-v2 `Kalicz.StrongTypes.Wpf` formatted with the ambient +culture while parsing in the binding's, which turned `1234.5` into `12345` on +round-trip; if a workaround exists in your code for that, delete it. + +## Nullable properties + +A nullable wrapper (`Positive?`, `NonEmptyString?`) binds and validates +normally, but **clearing the box does not set the property to null** — it +raises a validation error. WPF unwraps `Nullable` and asks for the +underlying type's converter, so the BCL's `NullableConverter` (which maps empty +to null, and does exactly that for configuration binding) is never consulted, +and `""` reaches `Positive.Parse`. This is long-standing behaviour, not a +v2 change. If "cleared means null" matters, bind through a `string?` view-model +property and convert in the view-model. ## Other XAML/MVVM frameworks -The same `TypeDescriptor`-based mechanism is used by WinForms and -some other frameworks; in practice this package is currently -documented and tested for WPF. If a user is on Avalonia, MAUI, or -WinForms and hits the same "binding silently fails" symptom, point -them at issue +The same `TypeDescriptor` mechanism is used by WinForms and some other +frameworks, and since the converters now live on the types themselves, nothing +WPF-specific is required for them either. In practice this is documented and +tested for WPF. If a user is on Avalonia, MAUI, or WinForms and hits a +"binding silently fails" symptom, point them at issue [#94](https://github.com/KaliCZ/StrongTypes/issues/94). ## Decision rule -> **Use it whenever a WPF view-model property is a strong type that -> the user can type into.** Display-only bindings (one-way out) work -> without it because WPF goes through `ToString()`. The package -> matters for the inbound (string → T) leg. +> **Nothing to add, nothing to call.** Reference `Kalicz.StrongTypes` and bind. +> The only thing you must remember is `ValidatesOnExceptions=True` on inbound +> (string → T) bindings, or invalid input fails silently. diff --git a/StrongTypes.slnx b/StrongTypes.slnx index 8e04e7ce..5aeda2f2 100644 --- a/StrongTypes.slnx +++ b/StrongTypes.slnx @@ -8,6 +8,11 @@ + + + + + @@ -33,7 +38,6 @@ - diff --git a/docs/diagrams/package-layout-dark.svg b/docs/diagrams/package-layout-dark.svg index ef8ef93c..b5d5c945 100644 --- a/docs/diagrams/package-layout-dark.svg +++ b/docs/diagrams/package-layout-dark.svg @@ -1,4 +1,4 @@ - + cannot catch a missing + /// non-nullable struct wrapper, whose default is an ordinary value and never null. + /// + [Fact] + public void Required_CatchesNullsOnly_NotAStructsDefault() + { + var exception = Assert.Throws( + () => Bind(OnlyTierConfigured, validate: true)); + + var failures = string.Join(" | ", exception.Failures); + + Assert.Contains($"'{nameof(RequiredRetryOptions.Name)}'", failures, System.StringComparison.Ordinal); + Assert.Contains($"'{nameof(RequiredRetryOptions.MaxRetriesOrUnset)}'", failures, System.StringComparison.Ordinal); + Assert.DoesNotContain($"'{nameof(RequiredRetryOptions.MaxRetries)}'", failures, System.StringComparison.Ordinal); + } + + /// Every key present and valid: validation passes, so the failures above are about absence rather than the annotations themselves. + [Fact] + public void Required_FullyConfigured_Passes() + { + var options = Bind( + "{\"Retry\":{\"Name\":\"checkout\",\"MaxRetries\":5,\"MaxRetriesOrUnset\":9,\"Tier\":7}}", + validate: true); + + Assert.Equal("checkout", options.Name.Value); + Assert.Equal(5, options.MaxRetries.Value); + Assert.Equal(9, options.MaxRetriesOrUnset?.Value); + } + + /// Get<T> on a section with no children at all returns null rather than a defaulted instance. + [Fact] + public void EmptySection_GetReturnsNull() => + Assert.Null(Section("{\"Retry\":{}}").Get()); +} diff --git a/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs new file mode 100644 index 00000000..def69c3c --- /dev/null +++ b/src/StrongTypes.Tests/Numbers/NumericFormattingTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Globalization; +using FsCheck.Xunit; +using Xunit; + +namespace StrongTypes.Tests; + +/// Composite formatting silently ignores a specifier on a type that is not $"{price:C}" compiles, never throws, and prints the unformatted number — so these pin the specifier and the culture reaching the underlying value. +public class NumericFormattingTests +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + private static readonly CultureInfo American = CultureInfo.GetCultureInfo("en-US"); + + // ── IFormattable ──────────────────────────────────────────────────── + + [Fact] + public void FormatSpecifier_IsHonouredInInterpolation() + { + var price = Positive.Create(1234.5m); + + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", price)); + Assert.Equal("$1,234.50", string.Format(American, "{0:C}", price)); + Assert.Equal("1.234,50", string.Format(German, "{0:N2}", price)); + } + + [Fact] + public void FormatProvider_IsHonouredWithoutASpecifier() => + Assert.Equal("1234,5", string.Format(German, "{0}", Positive.Create(1234.5m))); + + [Theory] + [InlineData("N2", "1,234.50")] + [InlineData("C", "$1,234.50")] + [InlineData("F0", "1235")] + [InlineData("E2", "1.23E+003")] + [InlineData(null, "1234.5")] + public void ToString_MatchesTheUnderlyingValue(string? format, string expected) + { + var price = Positive.Create(1234.5m); + + Assert.Equal(expected, price.ToString(format, American)); + Assert.Equal(price.Value.ToString(format, American), price.ToString(format, American)); + } + + /// The wrapper must never disagree with the value it wraps, for any format the underlying type accepts. + [Property] + public void ToString_NeverDivergesFromTheUnderlyingValue(int value) + { + if (value <= 0) return; + + var positive = Positive.Create(value); + foreach (var format in new[] { "D", "N0", "X", "G", null }) + { + Assert.Equal(value.ToString(format, American), positive.ToString(format, American)); + } + } + + [Fact] + public void EveryWrapper_IsFormattable() + { + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", Positive.Create(1234.5m))); + Assert.Equal("1,234.50", string.Format(American, "{0:N2}", NonNegative.Create(1234.5m))); + Assert.Equal("-1,234.50", string.Format(American, "{0:N2}", Negative.Create(-1234.5m))); + Assert.Equal("-1,234.50", string.Format(American, "{0:N2}", NonPositive.Create(-1234.5m))); + } + + // ── ISpanFormattable ──────────────────────────────────────────────── + + [Fact] + public void TryFormat_WritesIntoTheDestination() + { + Span destination = stackalloc char[32]; + + Assert.True(Positive.Create(1234.5m).TryFormat(destination, out var written, "N2", American)); + Assert.Equal("1,234.50", destination[..written].ToString()); + } + + [Fact] + public void TryFormat_DestinationTooShort_ReturnsFalse() + { + Span destination = stackalloc char[2]; + + Assert.False(Positive.Create(1234.5m).TryFormat(destination, out var written, "N2", American)); + Assert.Equal(0, written); + } + + // ── ISpanParsable ─────────────────────────────────────────────────── + + [Fact] + public void Parse_ReadsASliceWithoutMaterialisingIt() + { + const string line = "sku-1,42,widget"; + + Assert.Equal(42, Positive.Parse(line.AsSpan(6, 2), CultureInfo.InvariantCulture).Value); + } + + [Property] + public void TryParse_Span_MatchesTryParse_String(int value) + { + var text = value.ToString(CultureInfo.InvariantCulture); + + var fromString = Positive.TryParse(text, CultureInfo.InvariantCulture, out var parsedFromString); + var fromSpan = Positive.TryParse(text.AsSpan(), CultureInfo.InvariantCulture, out var parsedFromSpan); + + Assert.Equal(fromString, fromSpan); + Assert.Equal(parsedFromString, parsedFromSpan); + Assert.Equal(value > 0, fromSpan); + } + + [Fact] + public void Parse_Span_StillEnforcesTheInvariant() => + Assert.Throws(() => Positive.Parse("-5".AsSpan(), CultureInfo.InvariantCulture)); + + [Fact] + public void TryParse_Span_BreachedInvariant_ReturnsFalse() + { + Assert.False(Positive.TryParse("-5".AsSpan(), CultureInfo.InvariantCulture, out var result)); + Assert.Equal(default, result); + } + + // ── Generic constraints ───────────────────────────────────────────── + + private static string Render(T value, string format) where T : IFormattable => + value.ToString(format, American); + + private static T Read(ReadOnlySpan text) where T : ISpanParsable => + T.Parse(text, CultureInfo.InvariantCulture); + + private static string RenderIntoBuffer(T value, string format) where T : ISpanFormattable + { + Span destination = stackalloc char[64]; + return value.TryFormat(destination, out var written, format, American) ? destination[..written].ToString() : ""; + } + + [Fact] + public void SatisfiesTheIFormattableConstraint() => + Assert.Equal("$1,234.50", Render(Positive.Create(1234.5m), "C")); + + [Fact] + public void SatisfiesTheISpanFormattableConstraint() => + Assert.Equal("1,234.50", RenderIntoBuffer(Positive.Create(1234.5m), "N2")); + + [Fact] + public void SatisfiesTheISpanParsableConstraint() + { + Assert.Equal(42, Read>("42").Value); + Assert.Equal(0, Read>("0").Value); + Assert.Equal(-42, Read>("-42").Value); + Assert.Equal(-42, Read>("-42").Value); + } +} diff --git a/src/StrongTypes.Tests/StrongTypes.Tests.csproj b/src/StrongTypes.Tests/StrongTypes.Tests.csproj index d2d42542..7ea5f1b5 100644 --- a/src/StrongTypes.Tests/StrongTypes.Tests.csproj +++ b/src/StrongTypes.Tests/StrongTypes.Tests.csproj @@ -12,6 +12,11 @@ + + + + + diff --git a/src/StrongTypes.Wpf.TestApp/App.xaml.cs b/src/StrongTypes.Wpf.TestApp/App.xaml.cs index 563fd010..c589f96c 100644 --- a/src/StrongTypes.Wpf.TestApp/App.xaml.cs +++ b/src/StrongTypes.Wpf.TestApp/App.xaml.cs @@ -1,10 +1,3 @@ namespace StrongTypes.Wpf.TestApp; -public partial class App : System.Windows.Application -{ - protected override void OnStartup(System.Windows.StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} +public partial class App : System.Windows.Application; diff --git a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs index c70c62a0..7b0cdd46 100644 --- a/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs +++ b/src/StrongTypes.Wpf.TestApp/PersonViewModel.cs @@ -10,6 +10,10 @@ public sealed class PersonViewModel : INotifyPropertyChanged private NonEmptyString _name = NonEmptyString.Create("Alice"); private Email _email = Email.Create("alice@example.com"); private Positive _age = Positive.Create(30); + private Digit _tier = Digit.Create('7'); + private Positive _salary = Positive.Create(1234.5m); + private NonEmptyString? _nickname; + private Positive? _score; public NonEmptyString Name { @@ -29,6 +33,30 @@ public Positive Age set { _age = value; Raise(); } } + public Digit Tier + { + get => _tier; + set { _tier = value; Raise(); } + } + + public Positive Salary + { + get => _salary; + set { _salary = value; Raise(); } + } + + public NonEmptyString? Nickname + { + get => _nickname; + set { _nickname = value; Raise(); } + } + + public Positive? Score + { + get => _score; + set { _score = value; Raise(); } + } + public event PropertyChangedEventHandler? PropertyChanged; private void Raise([CallerMemberName] string? propertyName = null) => diff --git a/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj b/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj index 140e72e9..83121a6f 100644 --- a/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj +++ b/src/StrongTypes.Wpf.TestApp/StrongTypes.Wpf.TestApp.csproj @@ -13,6 +13,5 @@ - diff --git a/src/StrongTypes.Wpf.Tests/BindingTests.cs b/src/StrongTypes.Wpf.Tests/BindingTests.cs index 089a7fc0..0e873844 100644 --- a/src/StrongTypes.Wpf.Tests/BindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/BindingTests.cs @@ -4,6 +4,7 @@ using System.Windows.Data; using StrongTypes.Wpf.TestApp; using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; namespace StrongTypes.Wpf.Tests; @@ -64,24 +65,9 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = " "; Assert.Equal(NonEmptyString.Create("Alice"), vm.Name); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } - - private static Binding OneWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.OneWay, - }; - - private static Binding TwoWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; } public class EmailBindingTests @@ -126,24 +112,9 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "not-an-email"; Assert.Equal(Email.Create("alice@example.com"), vm.Email); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } - - private static Binding OneWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.OneWay, - }; - - private static Binding TwoWay(string path, object source) => new(path) - { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; } public class PositiveIntBindingTests @@ -188,7 +159,7 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "0"; Assert.Equal(Positive.Create(30), vm.Age); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } @@ -204,22 +175,54 @@ public void TwoWay_NonNumericInput_DoesNotMutateSourceAndRaisesValidationError() textBox.Text = "abc"; Assert.Equal(Positive.Create(30), vm.Age); - Assert.True(System.Windows.Controls.Validation.GetHasError(textBox)); + Assert.True(Validation.GetHasError(textBox)); }); } +} - private static Binding OneWay(string path, object source) => new(path) +public class DigitBindingTests +{ + [Fact] + public void OneWay_DisplaysCurrentValue() { - Source = source, - Mode = BindingMode.OneWay, - }; + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Tier), vm)); - private static Binding TwoWay(string path, object source) => new(path) + Assert.Equal("7", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() { - Source = source, - Mode = BindingMode.TwoWay, - UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, - ValidatesOnExceptions = true, - NotifyOnValidationError = true, - }; + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Tier), vm)); + + textBox.Text = "3"; + + Assert.Equal(Digit.Create('3'), vm.Tier); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Tier), vm)); + + textBox.Text = "42"; + + Assert.Equal(Digit.Create('7'), vm.Tier); + Assert.True(Validation.GetHasError(textBox)); + }); + } } diff --git a/src/StrongTypes.Wpf.Tests/Bindings.cs b/src/StrongTypes.Wpf.Tests/Bindings.cs new file mode 100644 index 00000000..70e3dcea --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/Bindings.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System.Globalization; +using System.Windows.Data; + +namespace StrongTypes.Wpf.Tests; + +internal static class Bindings +{ + public static Binding OneWay(string path, object source) => new(path) + { + Source = source, + Mode = BindingMode.OneWay, + }; + + public static Binding TwoWay(string path, object source, CultureInfo? culture = null) => new(path) + { + Source = source, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + ValidatesOnExceptions = true, + NotifyOnValidationError = true, + ConverterCulture = culture, + }; +} diff --git a/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs new file mode 100644 index 00000000..23b0959b --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/CultureBindingTests.cs @@ -0,0 +1,124 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Markup; +using StrongTypes.Wpf.TestApp; +using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; + +namespace StrongTypes.Wpf.Tests; + +/// +/// WPF resolves a binding's culture as ConverterCulture ?? element.Language and never consults +/// the host thread culture. Each case therefore runs under every host culture in turn, asserting the +/// host is ignored while the binding culture governs display, write-back, and round-trip. +/// +public class CultureBindingTests +{ + private static readonly string[] HostCultures = ["en-US", "de-DE", "cs-CZ", "ja-JP"]; + + [Theory] + [InlineData("en-US", 1234.5, "1234.5")] + [InlineData("de-DE", 1234.5, "1234,5")] + [InlineData("cs-CZ", 1234.5, "1234,5")] + [InlineData("ja-JP", 1234.5, "1234.5")] + public void DisplaysAndRoundTripsInTheBindingCulture(string cultureName, double value, string expectedText) + { + var binding = CultureInfo.GetCultureInfo(cultureName); + var salary = Positive.Create((decimal)value); + RunUnderEveryHost(host => + { + var vm = new PersonViewModel { Salary = salary }; + var textBox = BoundWithConverterCulture(vm, binding); + + Assert.Equal(expectedText, textBox.Text); + + textBox.Text = textBox.Text; + + Assert.False(Validation.GetHasError(textBox), $"host {host}: committing the displayed text raised an error"); + Assert.Equal(salary, vm.Salary); + }); + } + + /// + /// Write-back parses in the binding culture. A separator from the wrong culture is silently + /// swallowed as digit grouping — "9876,5" under en-US binds to 98765, a valid value + /// and no error — so the culture must match the input. Text that is no number, or that breaks the + /// invariant, raises a validation error and leaves the source unchanged (a null expected). + /// + [Theory] + [InlineData("en-US", "9876.5", 9876.5)] + [InlineData("de-DE", "9876,5", 9876.5)] + [InlineData("cs-CZ", "9876,5", 9876.5)] + [InlineData("ja-JP", "9876.5", 9876.5)] + [InlineData("en-US", "9876,5", 98765.0)] + [InlineData("de-DE", "9876.5", 98765.0)] + [InlineData("en-US", "not-a-number", null)] + [InlineData("de-DE", "not-a-number", null)] + [InlineData("en-US", "-5", null)] + [InlineData("de-DE", "-5", null)] + public void WriteBackParsesInTheBindingCulture(string cultureName, string text, double? expected) + { + var binding = CultureInfo.GetCultureInfo(cultureName); + RunUnderEveryHost(host => + { + var original = Positive.Create(1m); + var vm = new PersonViewModel { Salary = original }; + var textBox = BoundWithConverterCulture(vm, binding); + + textBox.Text = text; + + if (expected is { } number) + { + Assert.False(Validation.GetHasError(textBox), $"host {host}: valid input '{text}' raised an error"); + Assert.Equal(Positive.Create((decimal)number), vm.Salary); + } + else + { + Assert.True(Validation.GetHasError(textBox), $"host {host}: invalid input '{text}' was accepted"); + Assert.Equal(original, vm.Salary); + } + }); + } + + /// With no ConverterCulture the binding falls back to the element's Language, still never the host. + [Theory] + [InlineData("en-US")] + [InlineData("de-DE")] + [InlineData("cs-CZ")] + [InlineData("ja-JP")] + public void NoConverterCulture_usesTheElementLanguageNotTheHost(string languageName) + { + var language = CultureInfo.GetCultureInfo(languageName); + RunUnderEveryHost(host => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox { Language = XmlLanguage.GetLanguage(languageName) }; + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm)); + + Assert.Equal(1234.5m.ToString(language), textBox.Text); + }); + } + + private static void RunUnderEveryHost(Action body) + { + foreach (var host in HostCultures) + { + StaThread.Run(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(host); + body(host); + }); + } + } + + private static TextBox BoundWithConverterCulture(PersonViewModel vm, CultureInfo converterCulture) + { + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Salary), vm, converterCulture)); + return textBox; + } +} diff --git a/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs new file mode 100644 index 00000000..242adfae --- /dev/null +++ b/src/StrongTypes.Wpf.Tests/NullableBindingTests.cs @@ -0,0 +1,133 @@ +#nullable enable + +using System.Windows.Controls; +using System.Windows.Data; +using StrongTypes.Wpf.TestApp; +using Xunit; +using static StrongTypes.Wpf.Tests.Bindings; + +namespace StrongTypes.Wpf.Tests; + +public class NullableStructBindingTests +{ + [Fact] + public void OneWay_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Score), vm)); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void OneWay_Value_DisplaysIt() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Score), vm)); + + Assert.Equal("10", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = "10"; + + Assert.Equal(Positive.Create(10), vm.Score); + }); + } + + [Fact] + public void TwoWay_ClearedInput_RaisesValidationErrorRatherThanNulling() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = ""; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.True(Validation.GetHasError(textBox)); + }); + } + + /// Nullable does not mean unvalidated: a present value still has to satisfy the invariant. + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Score), vm)); + + textBox.Text = "-1"; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.True(Validation.GetHasError(textBox)); + }); + } +} + +public class NullableReferenceBindingTests +{ + [Fact] + public void OneWay_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, OneWay(nameof(vm.Nickname), vm)); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Nickname), vm)); + + textBox.Text = "Ally"; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndRaisesValidationError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = NonEmptyString.Create("Ally") }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Nickname), vm)); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + Assert.True(Validation.GetHasError(textBox)); + }); + } +} diff --git a/src/StrongTypes.Wpf.Tests/StaThread.cs b/src/StrongTypes.Wpf.Tests/StaThread.cs index 3a0dd258..d2dcf914 100644 --- a/src/StrongTypes.Wpf.Tests/StaThread.cs +++ b/src/StrongTypes.Wpf.Tests/StaThread.cs @@ -3,6 +3,7 @@ using System; using System.Runtime.ExceptionServices; using System.Threading; +using System.Windows.Threading; namespace StrongTypes.Wpf.Tests; @@ -19,6 +20,9 @@ public static T Run(Func body) { try { result = body(); } catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); } + // Constructing a WPF element spins up a Dispatcher for this thread; without shutdown it + // survives the thread and the test host force-exits over the leftover foreground threads. + finally { Dispatcher.FromThread(Thread.CurrentThread)?.InvokeShutdown(); } }); thread.SetApartmentState(ApartmentState.STA); thread.IsBackground = true; diff --git a/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj b/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj index d7df5d88..a07b607c 100644 --- a/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj +++ b/src/StrongTypes.Wpf.Tests/StrongTypes.Wpf.Tests.csproj @@ -23,7 +23,6 @@ - diff --git a/src/StrongTypes.Wpf.Tests/TestSetup.cs b/src/StrongTypes.Wpf.Tests/TestSetup.cs index 4da69a85..e0c2db6d 100644 --- a/src/StrongTypes.Wpf.Tests/TestSetup.cs +++ b/src/StrongTypes.Wpf.Tests/TestSetup.cs @@ -7,6 +7,7 @@ namespace StrongTypes.Wpf.Tests; internal static class TestSetup { + // An Application instance is required for WPF's binding engine to resolve resources. [ModuleInitializer] - internal static void Init() => StaThread.Run(() => new Application().UseStrongTypes()); + internal static void Init() => StaThread.Run(() => new Application()); } diff --git a/src/StrongTypes.Wpf/ParsableTypeConverter.cs b/src/StrongTypes.Wpf/ParsableTypeConverter.cs deleted file mode 100644 index 1f7ceda8..00000000 --- a/src/StrongTypes.Wpf/ParsableTypeConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; - -namespace StrongTypes.Wpf; - -/// A that converts between and via . Throws the same exception that T.Parse throws when the input cannot be parsed; pair the binding with ValidatesOnExceptions=True to surface that as a WPF ValidationError. -/// A strong type that implements . -public sealed class ParsableTypeConverter : TypeConverter where T : IParsable -{ - public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => - sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); - - public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => - destinationType == typeof(string) || base.CanConvertTo(context, destinationType); - - public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => - value is string s ? T.Parse(s, culture) : base.ConvertFrom(context, culture, value); - - public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => - destinationType == typeof(string) && value is T t - ? t.ToString() - : base.ConvertTo(context, culture, value, destinationType); -} diff --git a/src/StrongTypes.Wpf/Startup.cs b/src/StrongTypes.Wpf/Startup.cs deleted file mode 100644 index a5e2e597..00000000 --- a/src/StrongTypes.Wpf/Startup.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; -using System.Windows; - -namespace StrongTypes.Wpf; - -public static class ApplicationStartupExtensions -{ - private static readonly object _gate = new(); - private static bool _registered; - - /// Wires into for every strong type shipped in Kalicz.StrongTypes, enabling two-way MVVM binding from TextBox.Text to strong-typed view-model properties. Call once from App.OnStartup. Idempotent. - /// , for fluent chaining. - public static Application UseStrongTypes(this Application application) - { - if (_registered) - return application; - - lock (_gate) - { - if (_registered) - return application; - - var parent = TypeDescriptor.GetProvider(typeof(object)); - TypeDescriptor.AddProvider(new StrongTypesTypeDescriptionProvider(parent), typeof(object)); - _registered = true; - } - - return application; - } -} diff --git a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs b/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs deleted file mode 100644 index 5a77ef97..00000000 --- a/src/StrongTypes.Wpf/StrongTypesTypeDescriptionProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.ComponentModel; - -namespace StrongTypes.Wpf; - -internal sealed class StrongTypesTypeDescriptionProvider(TypeDescriptionProvider parent) : TypeDescriptionProvider(parent) -{ - public override ICustomTypeDescriptor? GetTypeDescriptor(Type objectType, object? instance) - { - var inner = base.GetTypeDescriptor(objectType, instance); - if (inner is null) - return null; - var converter = TryCreateConverter(objectType); - return converter is null ? inner : new StrongTypeDescriptor(inner, converter); - } - - private static TypeConverter? TryCreateConverter(Type type) - { - if (type == typeof(NonEmptyString)) - return new ParsableTypeConverter(); - if (type == typeof(Email)) - return new ParsableTypeConverter(); - if (type == typeof(Digit)) - return new ParsableTypeConverter(); - if (!type.IsGenericType) - return null; - var definition = type.GetGenericTypeDefinition(); - if (definition != typeof(Positive<>) - && definition != typeof(NonNegative<>) - && definition != typeof(Negative<>) - && definition != typeof(NonPositive<>)) - return null; - var converterType = typeof(ParsableTypeConverter<>).MakeGenericType(type); - return (TypeConverter)Activator.CreateInstance(converterType)!; - } -} - -internal sealed class StrongTypeDescriptor(ICustomTypeDescriptor parent, TypeConverter converter) : CustomTypeDescriptor(parent) -{ - public override TypeConverter GetConverter() => converter; -} diff --git a/src/StrongTypes.Wpf/readme.md b/src/StrongTypes.Wpf/readme.md deleted file mode 100644 index db2c6f42..00000000 --- a/src/StrongTypes.Wpf/readme.md +++ /dev/null @@ -1,35 +0,0 @@ -# Kalicz.StrongTypes.Wpf - -[![NuGet version](https://img.shields.io/nuget/v/Kalicz.StrongTypes.Wpf?label=nuget)](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) [![Downloads](https://img.shields.io/nuget/dt/Kalicz.StrongTypes.Wpf?label=downloads)](https://www.nuget.org/packages/Kalicz.StrongTypes.Wpf/) [![License](https://img.shields.io/github/license/KaliCZ/StrongTypes)](https://github.com/KaliCZ/StrongTypes/blob/main/license.txt) - -WPF MVVM binding support for [Kalicz.StrongTypes](https://www.nuget.org/packages/Kalicz.StrongTypes). With this package referenced and `UseStrongTypes()` called once in `App.OnStartup`, two-way binding from a `TextBox.Text` to a strong-typed view-model property just works. - -## Why a separate package - -WPF's binding pipeline routes `string → T` through `TypeDescriptor.GetConverter(T)` and never consults `IParsable` directly. Without a converter, typing into a `TextBox` bound to a strong-typed property silently fails to update the source. The core `Kalicz.StrongTypes` package deliberately avoids any UI dependency, so the wiring lives here. - -## Usage - -Call `this.UseStrongTypes()` once in `App.OnStartup`. One call covers every strong type that has a string round-trip — `NonEmptyString`, `Email`, `Digit`, and every closed instantiation of the generic numeric wrappers (`Positive`, `Negative`, …) — because the package installs a `TypeDescriptionProvider` that synthesizes an `IParsable`-backed converter on demand the first time WPF asks for it. Composite types with no single-`TextBox` form (the interval types, `Maybe`, `NonEmptyEnumerable`) get no converter — bind their parts (`.Start` / `.End`, etc.) instead. - -```csharp -public partial class App : Application -{ - protected override void OnStartup(StartupEventArgs e) - { - this.UseStrongTypes(); - base.OnStartup(e); - } -} -``` - -After registration, a plain MVVM binding works: - -```xml - -``` - -…where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` turns the strong type's `ArgumentException` (thrown by `Create` / `Parse` when validation fails) into a `ValidationError` on the binding, which in turn drives the standard WPF "invalid input" red border. diff --git a/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs new file mode 100644 index 00000000..47f112e0 --- /dev/null +++ b/src/StrongTypes/ComponentModel/ParsableTypeConverter.cs @@ -0,0 +1,34 @@ +#nullable enable + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace StrongTypes; + +/// Converts between and via , honouring the culture it is handed. +/// A strong type that implements . +/// Invalid input surfaces as whatever T.Parse throws — for a Kalicz.StrongTypes wrapper, the naming the broken invariant. +public sealed class ParsableTypeConverter : TypeConverter + where T : IParsable +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => + destinationType == typeof(string) || base.CanConvertTo(context, destinationType); + + /// is a string that breaks 's invariant. + /// is a string that is not in 's format. + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => + value is string s ? T.Parse(s, culture) : base.ConvertFrom(context, culture, value); + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) + { + if (destinationType != typeof(string) || value is not T parsed) + return base.ConvertTo(context, culture, value, destinationType); + + // Must format in the culture ConvertFrom parses in, or the pair does not round-trip. + return parsed is IFormattable formattable ? formattable.ToString(null, culture) : parsed.ToString(); + } +} diff --git a/src/StrongTypes/ComponentModel/StrongTypeConverter.cs b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs new file mode 100644 index 00000000..877c0e23 --- /dev/null +++ b/src/StrongTypes/ComponentModel/StrongTypeConverter.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace StrongTypes; + +/// Converts between and a strong type that implements , for generic types that cannot name a closed in an attribute argument. +public sealed class StrongTypeConverter : TypeConverter +{ + private readonly TypeConverter _inner; + + /// The closed strong type to convert. + /// does not implement . + public StrongTypeConverter(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (!typeof(IParsable<>).MakeGenericType(type).IsAssignableFrom(type)) + throw new ArgumentException($"{type} must implement IParsable<{type.Name}> to be converted from a string.", nameof(type)); + + _inner = (TypeConverter)Activator.CreateInstance(typeof(ParsableTypeConverter<>).MakeGenericType(type))!; + } + + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => _inner.CanConvertFrom(context, sourceType); + + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => _inner.CanConvertTo(context, destinationType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => _inner.ConvertFrom(context, culture, value); + + public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => _inner.ConvertTo(context, culture, value, destinationType); +} diff --git a/src/StrongTypes/Digits/Digit.cs b/src/StrongTypes/Digits/Digit.cs index e152f976..27ba923f 100644 --- a/src/StrongTypes/Digits/Digit.cs +++ b/src/StrongTypes/Digits/Digit.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; @@ -6,6 +7,7 @@ namespace StrongTypes; /// A decimal digit in the range 09, parsed from a single character. /// A character is accepted when returns true, which includes non-ASCII Unicode decimal digits whose numeric value folds into 0–9. default(Digit) represents 0. +[TypeConverter(typeof(ParsableTypeConverter))] public readonly struct Digit : IEquatable, IEquatable, diff --git a/src/StrongTypes/Emails/Email.cs b/src/StrongTypes/Emails/Email.cs index 88b240b7..f7500223 100644 --- a/src/StrongTypes/Emails/Email.cs +++ b/src/StrongTypes/Emails/Email.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Net.Mail; @@ -9,6 +10,7 @@ namespace StrongTypes; /// An email address validated by and capped at the RFC 5321 deliverable length of 254 characters. /// The property hands the caller a directly so it can be passed straight into APIs that take one (mailers, validators). The wire form on JSON, EF Core columns, and route arguments is the underlying address string. [JsonConverter(typeof(EmailJsonConverter))] +[TypeConverter(typeof(ParsableTypeConverter))] public sealed class Email : IEquatable, IEquatable, diff --git a/src/StrongTypes/Numbers/Negative.cs b/src/StrongTypes/Numbers/Negative.cs index d5266abd..bb428b0f 100644 --- a/src/StrongTypes/Numbers/Negative.cs +++ b/src/StrongTypes/Numbers/Negative.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(Negative<T>) wraps -T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct Negative where T : INumber { diff --git a/src/StrongTypes/Numbers/NonNegative.cs b/src/StrongTypes/Numbers/NonNegative.cs index 541392c9..ccc8f192 100644 --- a/src/StrongTypes/Numbers/NonNegative.cs +++ b/src/StrongTypes/Numbers/NonNegative.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(NonNegative<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-negative", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct NonNegative where T : INumber { diff --git a/src/StrongTypes/Numbers/NonPositive.cs b/src/StrongTypes/Numbers/NonPositive.cs index 4ca370d2..51a99e10 100644 --- a/src/StrongTypes/Numbers/NonPositive.cs +++ b/src/StrongTypes/Numbers/NonPositive.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(NonPositive<T>) wraps T.Zero and satisfies the invariant. [NumericWrapper(InvariantDescription = "non-positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct NonPositive where T : INumber { diff --git a/src/StrongTypes/Numbers/Positive.cs b/src/StrongTypes/Numbers/Positive.cs index db4839bb..4e1fad2b 100644 --- a/src/StrongTypes/Numbers/Positive.cs +++ b/src/StrongTypes/Numbers/Positive.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.Contracts; using System.Numerics; using System.Text.Json.Serialization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// Construct via or Create. default(Positive<T>) wraps T.One and satisfies the invariant. [NumericWrapper(InvariantDescription = "positive", GenerateSum = true)] [JsonConverter(typeof(NumericStrongTypeJsonConverterFactory))] +[TypeConverter(typeof(StrongTypeConverter))] public readonly partial struct Positive where T : INumber { diff --git a/src/StrongTypes/Strings/NonEmptyString.cs b/src/StrongTypes/Strings/NonEmptyString.cs index 5207b6b2..39fc29ec 100644 --- a/src/StrongTypes/Strings/NonEmptyString.cs +++ b/src/StrongTypes/Strings/NonEmptyString.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Globalization; @@ -9,6 +10,7 @@ namespace StrongTypes; /// A string guaranteed to be non-null, non-empty, and not consisting solely of whitespace. /// Comparison uses the current culture (it delegates to ). Exposes Count and a char indexer for parity with ; Count in particular makes the BCL [MaxLength] attribute work without a custom shim. [JsonConverter(typeof(NonEmptyStringJsonConverter))] +[TypeConverter(typeof(ParsableTypeConverter))] public sealed class NonEmptyString : IEquatable, IEquatable,