diff --git a/.github/workflows/test-winforms.yml b/.github/workflows/test-winforms.yml new file mode 100644 index 00000000..f72e3395 --- /dev/null +++ b/.github/workflows/test-winforms.yml @@ -0,0 +1,49 @@ +name: test-winforms + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + + env: + config: 'Debug' + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj + + - name: Build + run: dotnet build src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj --configuration ${{ env.config }} --no-restore + + - name: Test + run: dotnet test src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj --no-restore --no-build --configuration ${{ env.config }} + + - name: Upload test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: winforms-test-logs + path: | + src/StrongTypes.WinForms.Tests/**/TestResults/**/*.log + src/StrongTypes.WinForms.Tests/**/TestResults/**/*.trx + retention-days: 5 diff --git a/Skill/SKILL.md b/Skill/SKILL.md index a5842d16..f7720367 100644 --- a/Skill/SKILL.md +++ b/Skill/SKILL.md @@ -35,7 +35,7 @@ Add packages only when the host project actually hits that stack: - **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 / 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`. +- **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/desktop.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 @@ -73,7 +73,7 @@ demand when about to write code against that surface. | 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 / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/wpf.md` | +| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/desktop.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 index cdd614ec..5fade08e 100644 --- a/Skill/references/configuration.md +++ b/Skill/references/configuration.md @@ -198,6 +198,6 @@ Three things in there surprise people: - **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, + WPF/WinForms two-way binding (`references/desktop.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/desktop.md b/Skill/references/desktop.md new file mode 100644 index 00000000..eb3a31c2 --- /dev/null +++ b/Skill/references/desktop.md @@ -0,0 +1,105 @@ +# Desktop MVVM binding (WPF, WinForms) + +**No package, no setup.** Two-way binding a text control to a strong-typed +view-model property works off the core `Kalicz.StrongTypes` package alone, +in both WPF and WinForms. + +> 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 + +Both frameworks route `string → T` through `TypeDescriptor.GetConverter(T)` +and never consult `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. + +## WPF + +```xml + +``` + +…where `Name` is a view-model property of type `NonEmptyString`. + +`ValidatesOnExceptions=True` is the load-bearing piece — strong types throw +`ArgumentException` from `Create` / `Parse` when the input violates the +invariant; `ValidatesOnExceptions=True` turns that into a `ValidationError` +on the binding, which drives WPF's standard "invalid input" red-border +template. Without it, the binding silently swallows the failure and the +view-model is left holding the previous valid value. + +### Culture + +The converter parses **and** formats in the binding's culture (`ConverterCulture`, +or the element's `Language`) — WPF never consults the host thread's culture. A +`Positive` 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. + +## WinForms + +```csharp +textBox.DataBindings.Add(new Binding( + nameof(TextBox.Text), viewModel, nameof(PersonViewModel.Name), + formattingEnabled: true, DataSourceUpdateMode.OnPropertyChanged)); +``` + +`formattingEnabled: true` is the load-bearing piece here — it routes the +binding through the `TypeConverter` pipeline. Differences from WPF: + +- The binding culture is `FormatInfo ?? CultureInfo.CurrentCulture` — the + host culture governs by default, the opposite of WPF. Set + `Binding.FormatInfo` explicitly when the input culture must not follow + the machine. +- Invalid input surfaces through `Binding.BindingComplete` (state + `Exception`), not a validation-error template — the source keeps its + last valid value either way. +- A binding only activates once its control lives on a **shown** form — + always true in a running app, but a control bound before its form is + shown (or a unit test without one) sees a silently dormant binding. + +## Nullable properties + +In both frameworks a nullable wrapper (`Positive?`, `NonEmptyString?`) +binds and validates normally, but **clearing the box does not set the +property to null** — the empty string reaches the converter and fails the +invariant. The BCL's `NullableConverter` (which maps empty to null, and does +exactly that for configuration binding) is never consulted. 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. + +## MAUI and Avalonia + +Not covered yet. MAUI's binding engine never consults `[TypeConverter]`s +when writing target → source: display bindings work for every strong type, +but typed input silently never reaches a strong-typed view-model property. +Until dedicated support exists, a MAUI user must put an explicit +`IValueConverter` (string ↔ strong type via `Parse` / `ToString`) on each +two-way binding. For status on both frameworks, point them at issue +[#94](https://github.com/KaliCZ/StrongTypes/issues/94). + +## Decision rule + +> **Nothing to add, nothing to call.** Reference `Kalicz.StrongTypes` and bind. +> The only thing you must remember is `ValidatesOnExceptions=True` on inbound +> WPF bindings and `formattingEnabled: true` on WinForms ones, or invalid +> input fails silently. diff --git a/Skill/references/wpf.md b/Skill/references/wpf.md deleted file mode 100644 index b43a366d..00000000 --- a/Skill/references/wpf.md +++ /dev/null @@ -1,81 +0,0 @@ -# WPF MVVM binding - -**No package, no setup.** Two-way binding a `TextBox` to a strong-typed -view-model property works off the core `Kalicz.StrongTypes` package alone. - -> 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. -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. - -## Bindings — what to write in XAML - -```xml - -``` - -…where `Name` is a view-model property of type `NonEmptyString`. - -`ValidatesOnExceptions=True` is the load-bearing piece — strong types throw -`ArgumentException` from `Create` / `Parse` when the input violates the -invariant; `ValidatesOnExceptions=True` turns that into a `ValidationError` -on the binding, which drives WPF's standard "invalid input" red-border -template. Without it, the binding silently swallows the failure and the -view-model is left holding the previous valid value. - -## Culture - -The converter parses **and** formats in the binding's culture (`ConverterCulture`, -or the element's `Language`), so a `Positive` 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` 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 - -> **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 5aeda2f2..e0e6df1c 100644 --- a/StrongTypes.slnx +++ b/StrongTypes.slnx @@ -37,6 +37,9 @@ + + + diff --git a/readme.md b/readme.md index 6e765d94..5ec8853a 100644 --- a/readme.md +++ b/readme.md @@ -205,7 +205,7 @@ Nothing to install, nothing to call. WPF resolves `string → T` through `TypeDe …where `Name` is a view-model property of type `NonEmptyString`. `ValidatesOnExceptions=True` is the load-bearing piece: it turns the `ArgumentException` a strong type throws on invalid input into a `ValidationError`, driving WPF's standard red-border template. Without it the binding swallows the failure silently. -The same `TypeDescriptor` mechanism backs WinForms and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94). +Supported the same way, with nothing to install or call: WPF, WinForms, and designers. MAUI and Avalonia aren't covered yet — see [issue #94](https://github.com/KaliCZ/StrongTypes/issues/94). [↑ Back to contents](#contents) diff --git a/src/StrongTypes.WinForms.Tests/BindingFailureRecorder.cs b/src/StrongTypes.WinForms.Tests/BindingFailureRecorder.cs new file mode 100644 index 00000000..2becb3fc --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/BindingFailureRecorder.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System.Windows.Forms; + +namespace StrongTypes.WinForms.Tests; + +/// Records the first failed state — WinForms' error surface, where WPF raises a validation error. +internal sealed class BindingFailureRecorder +{ + public BindingCompleteState? State { get; private set; } + + public BindingFailureRecorder(Binding binding) => + binding.BindingComplete += (_, e) => + { + if (e.BindingCompleteState != BindingCompleteState.Success) + { + State ??= e.BindingCompleteState; + } + }; +} diff --git a/src/StrongTypes.WinForms.Tests/BindingTests.cs b/src/StrongTypes.WinForms.Tests/BindingTests.cs new file mode 100644 index 00000000..c9e13ef4 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/BindingTests.cs @@ -0,0 +1,265 @@ +#nullable enable + +using System.Windows.Forms; +using Xunit; +using static StrongTypes.WinForms.Tests.Bindings; + +namespace StrongTypes.WinForms.Tests; + +public class NonEmptyStringBindingTests +{ + [Fact] + public void Hosted_DisplaysCurrentValue() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Name), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("Alice", textBox.Text); + }); + } + + [Fact] + public void Unhosted_BindingStaysDormant() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Name), vm)); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void SourceChange_ReflectsInControl() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Name), vm)); + using var form = new HostedForm(textBox); + + vm.Name = NonEmptyString.Create("Bob"); + + Assert.Equal("Bob", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Name), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "Bob"; + + Assert.Equal(NonEmptyString.Create("Bob"), vm.Name); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Name), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Alice"), vm.Name); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} + +public class EmailBindingTests +{ + [Fact] + public void Hosted_DisplaysAddress() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Email = Email.Create("alice@example.com") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Email), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("alice@example.com", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel(); + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Email), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "bob@example.com"; + + Assert.Equal(Email.Create("bob@example.com"), vm.Email); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Email = Email.Create("alice@example.com") }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Email), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = "not an email"; + + Assert.Equal(Email.Create("alice@example.com"), vm.Email); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} + +public class PositiveIntBindingTests +{ + [Fact] + public void Hosted_DisplaysCurrentValue() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Age = Positive.Create(30) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Age), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("30", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Age = Positive.Create(30) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Age), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "42"; + + Assert.Equal(Positive.Create(42), vm.Age); + }); + } + + [Fact] + public void TwoWay_InvariantBreach_DoesNotMutateSourceAndRecovers() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Age = Positive.Create(30) }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Age), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = "0"; + Assert.Equal(Positive.Create(30), vm.Age); + Assert.Equal(BindingCompleteState.Exception, failure.State); + + textBox.Text = "7"; + Assert.Equal(Positive.Create(7), vm.Age); + }); + } + + [Fact] + public void TwoWay_NonNumericInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Age = Positive.Create(30) }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Age), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = "abc"; + + Assert.Equal(Positive.Create(30), vm.Age); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} + +public class DigitBindingTests +{ + [Fact] + public void Hosted_DisplaysCurrentValue() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Tier), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("7", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Tier), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "3"; + + Assert.Equal(Digit.Create('3'), vm.Tier); + }); + } + + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Tier = Digit.Create('7') }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Tier), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = "42"; + + Assert.Equal(Digit.Create('7'), vm.Tier); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} diff --git a/src/StrongTypes.WinForms.Tests/Bindings.cs b/src/StrongTypes.WinForms.Tests/Bindings.cs new file mode 100644 index 00000000..035ae47b --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/Bindings.cs @@ -0,0 +1,12 @@ +#nullable enable + +using System.Globalization; +using System.Windows.Forms; + +namespace StrongTypes.WinForms.Tests; + +internal static class Bindings +{ + public static Binding TwoWay(string path, object source, CultureInfo? culture = null) => + new("Text", source, path, formattingEnabled: true, DataSourceUpdateMode.OnPropertyChanged) { FormatInfo = culture }; +} diff --git a/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs new file mode 100644 index 00000000..7a5c1f51 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs @@ -0,0 +1,118 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Windows.Forms; +using Xunit; +using static StrongTypes.WinForms.Tests.Bindings; + +namespace StrongTypes.WinForms.Tests; + +/// +/// WinForms resolves a binding's culture as FormatInfo ?? CultureInfo.CurrentCulture — +/// unlike WPF, the host culture governs by default. The FormatInfo cases therefore run +/// under every host culture in turn, asserting an explicit culture makes the host irrelevant. +/// +public class CultureBindingTests +{ + private static readonly string[] HostCultures = ["en-US", "de-DE", "cs-CZ", "ja-JP"]; + + [Theory] + [InlineData("en-US", "1234.5")] + [InlineData("de-DE", "1234,5")] + [InlineData("cs-CZ", "1234,5")] + [InlineData("ja-JP", "1234.5")] + public void FormatInfo_DisplaysAndRoundTripsRegardlessOfHost(string cultureName, string expectedText) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var salary = Positive.Create(1234.5m); + RunUnderEveryHost(host => + { + var vm = new PersonViewModel { Salary = salary }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Salary), vm, culture); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + Assert.Equal(expectedText, textBox.Text); + + binding.WriteValue(); + + Assert.True(failure.State is null, $"host {host}: committing the displayed text raised a binding error"); + Assert.Equal(salary, vm.Salary); + }); + } + + /// + /// 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. + /// + [Theory] + [InlineData("en-US", "9876.5", "9876.5")] + [InlineData("de-DE", "9876,5", "9876.5")] + [InlineData("cs-CZ", "9876,5", "9876.5")] + [InlineData("en-US", "9876,5", "98765")] + [InlineData("de-DE", "9876.5", "98765")] + [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 FormatInfo_GovernsWriteBackRegardlessOfHost(string cultureName, string text, string? expected) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + RunUnderEveryHost(host => + { + var original = Positive.Create(1m); + var vm = new PersonViewModel { Salary = original }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Salary), vm, culture); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = text; + + if (expected is { } number) + { + Assert.True(failure.State is null, $"host {host}: valid input '{text}' raised a binding error"); + Assert.Equal(Positive.Create(decimal.Parse(number, CultureInfo.InvariantCulture)), vm.Salary); + } + else + { + Assert.Equal(BindingCompleteState.Exception, failure.State); + Assert.Equal(original, vm.Salary); + } + }); + } + + [Theory] + [InlineData("en-US", "1234.5")] + [InlineData("de-DE", "1234,5")] + [InlineData("cs-CZ", "1234,5")] + public void NoFormatInfo_UsesTheHostCulture(string hostName, string expectedText) + { + StaThread.Run(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(hostName); + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Salary), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal(expectedText, textBox.Text); + }); + } + + private static void RunUnderEveryHost(Action body) + { + foreach (var host in HostCultures) + { + StaThread.Run(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(host); + body(host); + }); + } + } +} diff --git a/src/StrongTypes.WinForms.Tests/HostedForm.cs b/src/StrongTypes.WinForms.Tests/HostedForm.cs new file mode 100644 index 00000000..8b9d3673 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/HostedForm.cs @@ -0,0 +1,30 @@ +#nullable enable + +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace StrongTypes.WinForms.Tests; + +/// +/// WinForms bindings stay dormant until their control sits on a shown form — parenting alone +/// or a force-created handle is not enough — so this shows the host form off-screen. +/// +internal sealed class HostedForm : IDisposable +{ + private readonly Form _form; + + public HostedForm(params Control[] controls) + { + _form = new Form + { + StartPosition = FormStartPosition.Manual, + Location = new Point(-10000, -10000), + ShowInTaskbar = false, + }; + _form.Controls.AddRange(controls); + _form.Show(); + } + + public void Dispose() => _form.Dispose(); +} diff --git a/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs new file mode 100644 index 00000000..95bb5189 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs @@ -0,0 +1,145 @@ +#nullable enable + +using System.Windows.Forms; +using Xunit; +using static StrongTypes.WinForms.Tests.Bindings; + +namespace StrongTypes.WinForms.Tests; + +public class NullableStructBindingTests +{ + [Fact] + public void Hosted_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void Hosted_Value_DisplaysIt() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("10", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = null }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "10"; + + Assert.Equal(Positive.Create(10), vm.Score); + }); + } + + [Fact] + public void TwoWay_ClearedInput_KeepsTheLastValueRatherThanNulling() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Score), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = ""; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } + + /// Nullable does not mean unvalidated: a present value still has to satisfy the invariant. + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Score = Positive.Create(10) }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Score), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = "-1"; + + Assert.Equal(Positive.Create(10), vm.Score); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} + +public class NullableReferenceBindingTests +{ + [Fact] + public void Hosted_Null_DisplaysEmpty() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Nickname), vm)); + using var form = new HostedForm(textBox); + + Assert.Equal("", textBox.Text); + }); + } + + [Fact] + public void TwoWay_ValidInput_UpdatesSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = null }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Nickname), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "Ally"; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + }); + } + + [Fact] + public void TwoWay_WhitespaceInput_DoesNotMutateSourceAndReportsBindingError() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = NonEmptyString.Create("Ally") }; + var textBox = new TextBox(); + var binding = TwoWay(nameof(vm.Nickname), vm); + var failure = new BindingFailureRecorder(binding); + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + Assert.Equal(BindingCompleteState.Exception, failure.State); + }); + } +} diff --git a/src/StrongTypes.WinForms.Tests/PersonViewModel.cs b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs new file mode 100644 index 00000000..205af240 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs @@ -0,0 +1,64 @@ +#nullable enable + +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace StrongTypes.WinForms.Tests; + +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 + { + get => _name; + set { _name = value; Raise(); } + } + + public Email Email + { + get => _email; + set { _email = value; Raise(); } + } + + public Positive Age + { + get => _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) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/src/StrongTypes.WinForms.Tests/StaThread.cs b/src/StrongTypes.WinForms.Tests/StaThread.cs new file mode 100644 index 00000000..2d35a7ea --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/StaThread.cs @@ -0,0 +1,26 @@ +#nullable enable + +using System; +using System.Runtime.ExceptionServices; +using System.Threading; + +namespace StrongTypes.WinForms.Tests; + +/// WinForms controls require an STA thread; xUnit worker threads are MTA. +internal static class StaThread +{ + public static void Run(Action body) + { + ExceptionDispatchInfo? captured = null; + var thread = new Thread(() => + { + try { body(); } + catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + thread.Join(); + captured?.Throw(); + } +} diff --git a/src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj b/src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj new file mode 100644 index 00000000..01baf910 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0-windows + Exe + 14.0 + enable + enable + true + true + CS1591 + false + + true + true + true + + + + + + + + + diff --git a/src/StrongTypes.Wpf.Tests/BindingTests.cs b/src/StrongTypes.Wpf.Tests/BindingTests.cs index 0e873844..11b88e9d 100644 --- a/src/StrongTypes.Wpf.Tests/BindingTests.cs +++ b/src/StrongTypes.Wpf.Tests/BindingTests.cs @@ -178,6 +178,25 @@ public void TwoWay_NonNumericInput_DoesNotMutateSourceAndRaisesValidationError() Assert.True(Validation.GetHasError(textBox)); }); } + + [Fact] + public void TwoWay_InvalidInput_RecoversOnNextValidInput() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Age = Positive.Create(30) }; + var textBox = new TextBox(); + BindingOperations.SetBinding(textBox, TextBox.TextProperty, TwoWay(nameof(vm.Age), vm)); + + textBox.Text = "0"; + Assert.True(Validation.GetHasError(textBox)); + + textBox.Text = "42"; + + Assert.Equal(Positive.Create(42), vm.Age); + Assert.False(Validation.GetHasError(textBox)); + }); + } } public class DigitBindingTests diff --git a/testing.md b/testing.md index 3e3927ce..5f36e3fc 100644 --- a/testing.md +++ b/testing.md @@ -283,6 +283,31 @@ there is no WPF package to install. representative types are enough — don't mirror the per-type API matrix here. +## WinForms binding tests — `StrongTypes.WinForms.Tests` + +Proves two-way WinForms `Binding` works off the core package's +`[TypeConverter]`s alone — the project references `StrongTypes` and +nothing else, mirroring the WPF suite. + +- The project targets `net10.0-windows`. The Ubuntu `build` job compiles + it but skips it at `dotnet test`; the suite runs in the dedicated + `test-winforms` workflow on `windows-latest`. +- Run every test body through `StaThread.Run(...)` — WinForms requires an + STA thread and xUnit workers are MTA. +- Bindings stay dormant until their control lives on a created, **visible** + host — an unparented control, or a hidden `Form` with a force-created + handle, never activates them. Host controls via the `HostedForm` helper + (a `Form` shown off-screen); `Unhosted_BindingStaysDormant` pins the + requirement. +- A binding's culture is `FormatInfo ?? CultureInfo.CurrentCulture` — the + **opposite default from WPF**, which ignores the host culture. Anything + culture-sensitive follows `CultureBindingTests`: explicit-`FormatInfo` + cases run under several host cultures asserting the host is ignored, and + the no-`FormatInfo` cases pin the host-culture default. +- Binding resolves through `TypeDescriptor` and is type-agnostic, so + representative types are enough — don't mirror the per-type API matrix + here. + ## Analyzer tests — `StrongTypes.Analyzers.Tests` For every diagnostic and code fix, write both a "reports the diagnostic"