From 0dfd9008b73e64208ccc2877f0404654770558e8 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 15:26:46 +0200 Subject: [PATCH 1/4] Prove WinForms two-way binding end-to-end (#94) New StrongTypes.WinForms.Tests suite mirroring the WPF one: two-way Binding resolves the on-type [TypeConverter]s through TypeDescriptor with zero registration for NonEmptyString, Email, and the numeric wrappers; invalid input keeps the last valid source value and surfaces via BindingComplete. The earlier failed experiment was indeed a harness lifecycle problem: bindings activate only on a shown host Form, which the suite pins explicitly. Also pins the WinForms culture default (FormatInfo ?? CurrentCulture - the opposite of WPF). Co-Authored-By: Claude Fable 5 --- .github/workflows/test-winforms.yml | 49 +++++ Skill/references/wpf.md | 28 ++- StrongTypes.slnx | 3 + readme.md | 2 +- .../BindingTests.cs | 196 ++++++++++++++++++ src/StrongTypes.WinForms.Tests/Bindings.cs | 12 ++ .../CultureBindingTests.cs | 96 +++++++++ src/StrongTypes.WinForms.Tests/HostedForm.cs | 31 +++ .../NullableBindingTests.cs | 122 +++++++++++ .../PersonViewModel.cs | 57 +++++ src/StrongTypes.WinForms.Tests/StaThread.cs | 26 +++ .../StrongTypes.WinForms.Tests.csproj | 27 +++ testing.md | 25 +++ 13 files changed, 667 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test-winforms.yml create mode 100644 src/StrongTypes.WinForms.Tests/BindingTests.cs create mode 100644 src/StrongTypes.WinForms.Tests/Bindings.cs create mode 100644 src/StrongTypes.WinForms.Tests/CultureBindingTests.cs create mode 100644 src/StrongTypes.WinForms.Tests/HostedForm.cs create mode 100644 src/StrongTypes.WinForms.Tests/NullableBindingTests.cs create mode 100644 src/StrongTypes.WinForms.Tests/PersonViewModel.cs create mode 100644 src/StrongTypes.WinForms.Tests/StaThread.cs create mode 100644 src/StrongTypes.WinForms.Tests/StrongTypes.WinForms.Tests.csproj 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/references/wpf.md b/Skill/references/wpf.md index b43a366d..93f37b93 100644 --- a/Skill/references/wpf.md +++ b/Skill/references/wpf.md @@ -65,14 +65,30 @@ 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. +## WinForms + +The same `TypeDescriptor` mechanism backs WinForms `Binding`, verified +end-to-end: two-way binding, invalid-input rejection, and nullable +properties all behave as in WPF with zero setup. Two WinForms-specific +notes: + +- A binding only activates once its control lives on a **shown** form — + always true in a real app, but a unit test (or a control bound before + the form is shown) sees a silently dormant binding. +- The binding culture is `FormatInfo ?? CultureInfo.CurrentCulture` — the + opposite default from WPF, which ignores the host culture. Set + `Binding.FormatInfo` explicitly when the input culture must not follow + the machine. +- Invalid input surfaces through `Binding.BindingComplete` (state + `Exception`), not a WPF-style validation error — the source keeps its + last valid value either way. + ## 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). +Since the converters live on the types themselves, nothing WPF-specific is +required for other `TypeDescriptor`-based frameworks either. If a user is +on Avalonia or MAUI and hits a "binding silently fails" symptom, point +them at issue [#94](https://github.com/KaliCZ/StrongTypes/issues/94). ## Decision rule 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..0e621048 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). +The same `TypeDescriptor` mechanism backs WinForms — verified end-to-end by the `StrongTypes.WinForms.Tests` binding suite (there the culture default is `Binding.FormatInfo ?? CultureInfo.CurrentCulture`, and invalid input surfaces via `Binding.BindingComplete` instead of a validation error) — 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/BindingTests.cs b/src/StrongTypes.WinForms.Tests/BindingTests.cs new file mode 100644 index 00000000..1e73bcdc --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/BindingTests.cs @@ -0,0 +1,196 @@ +#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); + }); + } + + /// Pins the activation requirement the whole suite relies on: no shown host, no binding. + [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); + BindingCompleteState? failure = null; + binding.BindingComplete += (_, e) => + { + if (e.BindingCompleteState != BindingCompleteState.Success) + { + failure = e.BindingCompleteState; + } + }; + textBox.DataBindings.Add(binding); + using var form = new HostedForm(textBox); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Alice"), vm.Name); + Assert.Equal(BindingCompleteState.Exception, failure); + }); + } +} + +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_DoesNotMutateSource() + { + 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); + + textBox.Text = "not an email"; + + Assert.Equal(Email.Create("alice@example.com"), vm.Email); + }); + } +} + +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(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Age), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = "0"; + Assert.Equal(Positive.Create(30), vm.Age); + + textBox.Text = "7"; + Assert.Equal(Positive.Create(7), vm.Age); + }); + } +} 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..279256da --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs @@ -0,0 +1,96 @@ +#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 run under every +/// host culture in turn to prove an explicit culture makes the host irrelevant; the +/// no-FormatInfo cases pin the host-culture default. +/// +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_GovernsDisplayRegardlessOfHost(string cultureName, string expectedText) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + RunUnderEveryHost(host => + { + var vm = new PersonViewModel { Salary = Positive.Create(1234.5m) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Salary), vm, culture)); + using var form = new HostedForm(textBox); + + Assert.Equal(expectedText, textBox.Text); + }); + } + + /// + /// 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")] + public void FormatInfo_GovernsWriteBackRegardlessOfHost(string cultureName, string text, string expected) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var expectedSalary = Positive.Create(decimal.Parse(expected, CultureInfo.InvariantCulture)); + RunUnderEveryHost(host => + { + var vm = new PersonViewModel { Salary = Positive.Create(1m) }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Salary), vm, culture)); + using var form = new HostedForm(textBox); + + textBox.Text = text; + + Assert.Equal(expectedSalary, 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..566176df --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/HostedForm.cs @@ -0,0 +1,31 @@ +#nullable enable + +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace StrongTypes.WinForms.Tests; + +/// +/// WinForms 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. Hosts controls on a Form shown off-screen so bindings run without a visible window. +/// +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..5e189742 --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs @@ -0,0 +1,122 @@ +#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 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(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = ""; + + Assert.Equal(Positive.Create(10), vm.Score); + }); + } + + /// Nullable does not mean unvalidated: a present value still has to satisfy the invariant. + [Fact] + public void TwoWay_InvalidInput_DoesNotMutateSource() + { + 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); + + textBox.Text = "-1"; + + Assert.Equal(Positive.Create(10), vm.Score); + }); + } +} + +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_DoesNotMutateSource() + { + StaThread.Run(() => + { + var vm = new PersonViewModel { Nickname = NonEmptyString.Create("Ally") }; + var textBox = new TextBox(); + textBox.DataBindings.Add(TwoWay(nameof(vm.Nickname), vm)); + using var form = new HostedForm(textBox); + + textBox.Text = " "; + + Assert.Equal(NonEmptyString.Create("Ally"), vm.Nickname); + }); + } +} diff --git a/src/StrongTypes.WinForms.Tests/PersonViewModel.cs b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs new file mode 100644 index 00000000..d59c38ad --- /dev/null +++ b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs @@ -0,0 +1,57 @@ +#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 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 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/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" From 29b7c6f2f67734e03c58e51efc4985024e11aecc Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 15:37:21 +0200 Subject: [PATCH 2/4] Trim comments that fail the comment test Co-Authored-By: Claude Fable 5 --- src/StrongTypes.WinForms.Tests/BindingTests.cs | 1 - src/StrongTypes.WinForms.Tests/CultureBindingTests.cs | 5 ++--- src/StrongTypes.WinForms.Tests/HostedForm.cs | 5 ++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/StrongTypes.WinForms.Tests/BindingTests.cs b/src/StrongTypes.WinForms.Tests/BindingTests.cs index 1e73bcdc..3a61d339 100644 --- a/src/StrongTypes.WinForms.Tests/BindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/BindingTests.cs @@ -22,7 +22,6 @@ public void Hosted_DisplaysCurrentValue() }); } - /// Pins the activation requirement the whole suite relies on: no shown host, no binding. [Fact] public void Unhosted_BindingStaysDormant() { diff --git a/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs index 279256da..87f188fe 100644 --- a/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs @@ -10,9 +10,8 @@ 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 run under every -/// host culture in turn to prove an explicit culture makes the host irrelevant; the -/// no-FormatInfo cases pin the host-culture default. +/// 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 { diff --git a/src/StrongTypes.WinForms.Tests/HostedForm.cs b/src/StrongTypes.WinForms.Tests/HostedForm.cs index 566176df..8b9d3673 100644 --- a/src/StrongTypes.WinForms.Tests/HostedForm.cs +++ b/src/StrongTypes.WinForms.Tests/HostedForm.cs @@ -7,9 +7,8 @@ namespace StrongTypes.WinForms.Tests; /// -/// WinForms 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. Hosts controls on a Form shown off-screen so bindings run without a visible window. +/// 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 { From 3408606f7bd96441093209d4c5d3351431233c47 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 15:51:10 +0200 Subject: [PATCH 3/4] Address review: desktop.md, plain readme wording, suite parity - Rename Skill/references/wpf.md to desktop.md and restructure it around the shared TypeDescriptor mechanism with WPF and WinForms sections; update the SKILL.md and configuration.md links. - Readme lists supported technologies plainly instead of test-suite internals. - Parity between the two desktop suites: WPF gains the recovery-after- invalid-input case; WinForms gains Digit coverage, non-numeric input, and nullable value display. - Correct the MAUI status: empirically (headless Microsoft.Maui.Controls 10.0.80) display binds work for every strong type, but MAUI's target-to-source conversion never consults TypeConverters, so typed input silently never reaches the source for any strong type - an explicit IValueConverter is required regardless of converter shape. Co-Authored-By: Claude Fable 5 --- Skill/SKILL.md | 4 +- Skill/references/configuration.md | 2 +- Skill/references/desktop.md | 105 ++++++++++++++++++ Skill/references/wpf.md | 97 ---------------- readme.md | 2 +- .../BindingTests.cs | 65 +++++++++++ .../NullableBindingTests.cs | 14 +++ .../PersonViewModel.cs | 7 ++ src/StrongTypes.Wpf.Tests/BindingTests.cs | 19 ++++ 9 files changed, 214 insertions(+), 101 deletions(-) create mode 100644 Skill/references/desktop.md delete mode 100644 Skill/references/wpf.md 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 93f37b93..00000000 --- a/Skill/references/wpf.md +++ /dev/null @@ -1,97 +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. - -## WinForms - -The same `TypeDescriptor` mechanism backs WinForms `Binding`, verified -end-to-end: two-way binding, invalid-input rejection, and nullable -properties all behave as in WPF with zero setup. Two WinForms-specific -notes: - -- A binding only activates once its control lives on a **shown** form — - always true in a real app, but a unit test (or a control bound before - the form is shown) sees a silently dormant binding. -- The binding culture is `FormatInfo ?? CultureInfo.CurrentCulture` — the - opposite default from WPF, which ignores the host culture. Set - `Binding.FormatInfo` explicitly when the input culture must not follow - the machine. -- Invalid input surfaces through `Binding.BindingComplete` (state - `Exception`), not a WPF-style validation error — the source keeps its - last valid value either way. - -## Other XAML/MVVM frameworks - -Since the converters live on the types themselves, nothing WPF-specific is -required for other `TypeDescriptor`-based frameworks either. If a user is -on Avalonia or MAUI 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/readme.md b/readme.md index 0e621048..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 — verified end-to-end by the `StrongTypes.WinForms.Tests` binding suite (there the culture default is `Binding.FormatInfo ?? CultureInfo.CurrentCulture`, and invalid input surfaces via `Binding.BindingComplete` instead of a validation error) — 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/BindingTests.cs b/src/StrongTypes.WinForms.Tests/BindingTests.cs index 3a61d339..8695f45f 100644 --- a/src/StrongTypes.WinForms.Tests/BindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/BindingTests.cs @@ -192,4 +192,69 @@ public void TwoWay_InvariantBreach_DoesNotMutateSourceAndRecovers() Assert.Equal(Positive.Create(7), vm.Age); }); } + + [Fact] + public void TwoWay_NonNumericInput_DoesNotMutateSource() + { + 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 = "abc"; + + Assert.Equal(Positive.Create(30), vm.Age); + }); + } +} + +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_DoesNotMutateSource() + { + 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 = "42"; + + Assert.Equal(Digit.Create('7'), vm.Tier); + }); + } } diff --git a/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs index 5e189742..87f2e809 100644 --- a/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs @@ -22,6 +22,20 @@ public void Hosted_Null_DisplaysEmpty() }); } + [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() { diff --git a/src/StrongTypes.WinForms.Tests/PersonViewModel.cs b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs index d59c38ad..205af240 100644 --- a/src/StrongTypes.WinForms.Tests/PersonViewModel.cs +++ b/src/StrongTypes.WinForms.Tests/PersonViewModel.cs @@ -10,6 +10,7 @@ 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; @@ -32,6 +33,12 @@ public Positive Age set { _age = value; Raise(); } } + public Digit Tier + { + get => _tier; + set { _tier = value; Raise(); } + } + public Positive Salary { get => _salary; 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 From 82269d5e4b2d3d8b1ee2e16ffcb341c55956f3a9 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Fri, 17 Jul 2026 16:00:20 +0200 Subject: [PATCH 4/4] Match the WPF suite's error-surface and culture rigor in WinForms Every invalid-input test now also asserts the failure is observable (BindingComplete with Exception state, WinForms' analogue of the WPF validation error), the culture write-back theory gains the invalid and negative rows the WPF suite pins, and the FormatInfo display theory round-trips the displayed text back to the source via WriteValue. Co-Authored-By: Claude Fable 5 --- .../BindingFailureRecorder.cs | 20 ++++++++++ .../BindingTests.cs | 37 ++++++++++-------- .../CultureBindingTests.cs | 39 +++++++++++++++---- .../NullableBindingTests.cs | 19 ++++++--- 4 files changed, 86 insertions(+), 29 deletions(-) create mode 100644 src/StrongTypes.WinForms.Tests/BindingFailureRecorder.cs 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 index 8695f45f..c9e13ef4 100644 --- a/src/StrongTypes.WinForms.Tests/BindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/BindingTests.cs @@ -75,21 +75,14 @@ public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() var vm = new PersonViewModel { Name = NonEmptyString.Create("Alice") }; var textBox = new TextBox(); var binding = TwoWay(nameof(vm.Name), vm); - BindingCompleteState? failure = null; - binding.BindingComplete += (_, e) => - { - if (e.BindingCompleteState != BindingCompleteState.Success) - { - failure = e.BindingCompleteState; - } - }; + 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); + Assert.Equal(BindingCompleteState.Exception, failure.State); }); } } @@ -127,18 +120,21 @@ public void TwoWay_ValidInput_UpdatesSource() } [Fact] - public void TwoWay_InvalidInput_DoesNotMutateSource() + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() { StaThread.Run(() => { var vm = new PersonViewModel { Email = Email.Create("alice@example.com") }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Email), vm)); + 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); }); } } @@ -182,11 +178,14 @@ public void TwoWay_InvariantBreach_DoesNotMutateSourceAndRecovers() { var vm = new PersonViewModel { Age = Positive.Create(30) }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Age), vm)); + 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); @@ -194,18 +193,21 @@ public void TwoWay_InvariantBreach_DoesNotMutateSourceAndRecovers() } [Fact] - public void TwoWay_NonNumericInput_DoesNotMutateSource() + public void TwoWay_NonNumericInput_DoesNotMutateSourceAndReportsBindingError() { StaThread.Run(() => { var vm = new PersonViewModel { Age = Positive.Create(30) }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Age), vm)); + 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); }); } } @@ -243,18 +245,21 @@ public void TwoWay_ValidInput_UpdatesSource() } [Fact] - public void TwoWay_InvalidInput_DoesNotMutateSource() + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() { StaThread.Run(() => { var vm = new PersonViewModel { Tier = Digit.Create('7') }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Tier), vm)); + 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/CultureBindingTests.cs b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs index 87f188fe..7a5c1f51 100644 --- a/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/CultureBindingTests.cs @@ -22,17 +22,25 @@ public class CultureBindingTests [InlineData("de-DE", "1234,5")] [InlineData("cs-CZ", "1234,5")] [InlineData("ja-JP", "1234.5")] - public void FormatInfo_GovernsDisplayRegardlessOfHost(string cultureName, string expectedText) + 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 = Positive.Create(1234.5m) }; + var vm = new PersonViewModel { Salary = salary }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Salary), vm, culture)); + 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); }); } @@ -46,20 +54,35 @@ public void FormatInfo_GovernsDisplayRegardlessOfHost(string cultureName, string [InlineData("cs-CZ", "9876,5", "9876.5")] [InlineData("en-US", "9876,5", "98765")] [InlineData("de-DE", "9876.5", "98765")] - public void FormatInfo_GovernsWriteBackRegardlessOfHost(string cultureName, string text, string expected) + [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); - var expectedSalary = Positive.Create(decimal.Parse(expected, CultureInfo.InvariantCulture)); RunUnderEveryHost(host => { - var vm = new PersonViewModel { Salary = Positive.Create(1m) }; + var original = Positive.Create(1m); + var vm = new PersonViewModel { Salary = original }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Salary), vm, culture)); + 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; - Assert.Equal(expectedSalary, vm.Salary); + 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); + } }); } diff --git a/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs index 87f2e809..95bb5189 100644 --- a/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs +++ b/src/StrongTypes.WinForms.Tests/NullableBindingTests.cs @@ -59,29 +59,35 @@ public void TwoWay_ClearedInput_KeepsTheLastValueRatherThanNulling() { var vm = new PersonViewModel { Score = Positive.Create(10) }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + 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_DoesNotMutateSource() + public void TwoWay_InvalidInput_DoesNotMutateSourceAndReportsBindingError() { StaThread.Run(() => { var vm = new PersonViewModel { Score = Positive.Create(10) }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Score), vm)); + 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); }); } } @@ -119,18 +125,21 @@ public void TwoWay_ValidInput_UpdatesSource() } [Fact] - public void TwoWay_WhitespaceInput_DoesNotMutateSource() + public void TwoWay_WhitespaceInput_DoesNotMutateSourceAndReportsBindingError() { StaThread.Run(() => { var vm = new PersonViewModel { Nickname = NonEmptyString.Create("Ally") }; var textBox = new TextBox(); - textBox.DataBindings.Add(TwoWay(nameof(vm.Nickname), vm)); + 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); }); } }