From 51d7f3c6a638539f64df91ae5f64818143e49538 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:01:47 +1000 Subject: [PATCH 1/2] fix(generator): declare observation helpers for every dispatch file - Move the KVO and WinUI helper class declarations out of the WhenChanged dispatch file into ObservationHelpers.g.cs, so binding dispatch files that reference them compile (fixes #48) - Key the declarations to the detected types rather than the call sites, which keeps them a superset of the references and stops a second observation file declaring them twice - Ship the generator and analyzer in ReactiveUI.Binding and ReactiveUI.Binding.Reactive, matching the documented install story - Stop packing the same assemblies in ReactiveUI.Binding.SourceGenerators: a second copy loads as a second generator and emits every dispatch file twice - Cover NSObject sources and targets through BindOneWay and BindTwoWay --- CLAUDE.md | 21 ++- README.md | 2 +- .../ReactiveUI.Binding.Reactive.csproj | 17 +++ .../BindingGenerator.cs | 27 ++++ .../ObservationCodeGenerator.cs | 40 +----- .../Generators/ObservationHelperGenerator.cs | 101 +++++++++++++ ...ReactiveUI.Binding.SourceGenerators.csproj | 18 +-- .../ReactiveUI.Binding.csproj | 17 +++ ...ct_Source#BindOneWayDispatch.g.verified.cs | 55 ++++++++ ...#GeneratedBinderRegistration.g.verified.cs | 26 ++++ ...#GeneratedBindingsAttributes.g.verified.cs | 12 ++ ...ct_Source#ObservationHelpers.g.verified.cs | 133 ++++++++++++++++++ ...ct_Target#BindTwoWayDispatch.g.verified.cs | 68 +++++++++ ...#GeneratedBinderRegistration.g.verified.cs | 26 ++++ ...#GeneratedBindingsAttributes.g.verified.cs | 12 ++ ...ct_Target#ObservationHelpers.g.verified.cs | 133 ++++++++++++++++++ .../BindOneWayGeneratorTests.cs | 17 +++ .../BindTwoWayGeneratorTests.cs | 17 +++ .../Helpers/ApplePlatformSource.cs | 104 ++++++++++++++ ..._Detected#ObservationHelpers.g.verified.cs | 132 +++++++++++++++++ .../PlatformDetectionSnapshotTests.cs | 25 ++-- ..._Property#ObservationHelpers.g.verified.cs | 133 ++++++++++++++++++ ...Property#WhenChangedDispatch.g.verified.cs | 121 ---------------- ..._Property#ObservationHelpers.g.verified.cs | 97 +++++++++++++ ...Property#WhenChangedDispatch.g.verified.cs | 85 ----------- 25 files changed, 1164 insertions(+), 275 deletions(-) create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#BindOneWayDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBinderRegistration.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#ObservationHelpers.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#BindTwoWayDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBinderRegistration.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#ObservationHelpers.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ApplePlatformSource.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PDS.NSObject_Detected#ObservationHelpers.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#ObservationHelpers.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#ObservationHelpers.g.verified.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7b64766..db12170 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,7 @@ src/ │ │ ├── WinFormsBindingGenerator.cs # WinForms Component (affinity 23) │ │ ├── AndroidBindingGenerator.cs # Android View (affinity 19) │ │ ├── RegistrationGenerator.cs # Consolidates all → [ModuleInitializer] +│ │ ├── ObservationHelperGenerator.cs # Declares the KVO/WinUI helper classes, once per compilation │ │ └── ViewLocatorDispatchGenerator.cs # IViewFor → AOT view dispatch (Pipeline C) │ ├── Invocations/ # Per-invocation generators (Pipeline B) │ │ ├── WhenChangedInvocationGenerator.cs # After-change observation @@ -418,6 +419,20 @@ All pipeline models are `sealed record` types with value equality. NEVER include - `#pragma warning disable` at top of generated files - All generated types use `[Microsoft.CodeAnalysis.Embedded]` attribute +### Where the Observation Helper Classes Are Declared + +Some plugins (`KVOObservationPlugin`, `WinUIObservationPlugin`) emit observation code that instantiates helper +classes by bare name — `__KVOObservable`, `__KVOObserver`, `__WinUIDPObservable`. Every dispatch file is +another part of the same `__ReactiveUIGeneratedBindings` class, so one part declaring them is enough for all of +them, and two parts declaring them is a duplicate-member error. + +`ObservationHelperGenerator` therefore owns the declarations outright, in `ObservationHelpers.g.cs`. Emitters +only ever reference the helpers; none of them declare any. Which helpers to declare is decided from the +**detected types**, not from the call sites — a reference can only be emitted for a type +`CodeGeneratorHelpers.FindClassInfo` matched, so the declarations are a superset of the references whichever +binding API reaches for them. Deciding it from the call sites is what left `BindOneWay`, `BindTwoWay`, `Bind`, +`OneWayBind`, `WhenAny` and `WhenAnyObservable` emitting references to types nobody declared. + ### Two-Layer Language Version Constraint There are **two distinct C# language contexts** in this project: @@ -576,6 +591,10 @@ build keeps working right up until Wine starts. Each copy chains to the reposito - **Generator + Analyzer targets:** netstandard2.0 (Roslyn requirement) - **Runtime library targets:** net8.0;net9.0;net10.0;net462;net472;net481 - **No shallow clones:** Repository requires full clone for Nerdbank.GitVersioning -- **PackBuildOutputs target:** Generator .csproj packages both generator and analyzer DLLs into `analyzers/dotnet/cs` +- **Where the analyzers ship:** `ReactiveUI.Binding` and `ReactiveUI.Binding.Reactive` each pack the generator + and analyzer DLLs into `analyzers/dotnet/cs`, so referencing a runtime package is all a consumer needs. + `ReactiveUI.Binding.SourceGenerators` is a compatibility package that ships only the MSBuild props: a second + copy of the same assemblies under a different package root loads as a second generator and emits every + dispatch file twice, which fails the consumer's build **Philosophy:** Generate zero-reflection, AOT-compatible property observation and binding code at compile-time. Support all ReactiveUI platform notification mechanisms. Fall back to runtime expression analysis only when compile-time analysis is not possible. diff --git a/README.md b/README.md index aea961f..78d3fc2 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ Higher affinity values take priority when a type implements multiple mechanisms. | Package | Description | NuGet | |---------------------------------------|------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| | `ReactiveUI.Binding` | Runtime library with lightweight observables. No System.Reactive dependency. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.svg)](https://www.nuget.org/packages/ReactiveUI.Binding) | -| `ReactiveUI.Binding.SourceGenerators` | Source generator (auto-referenced by the Binding package). | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.SourceGenerators.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.SourceGenerators) | +| `ReactiveUI.Binding.SourceGenerators` | Compatibility package; the generator ships inside the runtime packages. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.SourceGenerators.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.SourceGenerators) | | `ReactiveUI.Binding.Reactive` | System.Reactive adapter for IScheduler overloads. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.Reactive.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.Reactive) | | `ReactiveUI.Binding.Wpf` | WPF DependencyProperty support. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.Wpf.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.Wpf) | | `ReactiveUI.Binding.WinForms` | WinForms Component support. | [![NuGet](https://img.shields.io/nuget/v/ReactiveUI.Binding.WinForms.svg)](https://www.nuget.org/packages/ReactiveUI.Binding.WinForms) | diff --git a/src/ReactiveUI.Binding.Reactive/ReactiveUI.Binding.Reactive.csproj b/src/ReactiveUI.Binding.Reactive/ReactiveUI.Binding.Reactive.csproj index 186bdf0..b226cb5 100644 --- a/src/ReactiveUI.Binding.Reactive/ReactiveUI.Binding.Reactive.csproj +++ b/src/ReactiveUI.Binding.Reactive/ReactiveUI.Binding.Reactive.csproj @@ -37,6 +37,23 @@ + + + + + + + + + + + + diff --git a/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs index ded20cd..fddb9d2 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs @@ -68,6 +68,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) consolidated.Combine(languageFeatures), static (ctx, data) => RegistrationGenerator.Generate(ctx, data.Left, data.Right)); + RegisterObservationHelperOutput(in context, allObservableTypes, languageFeatures); + // Pipeline C: View locator dispatch (IViewFor scanning) ViewLocatorDispatchGenerator.Register(context, languageFeatures); @@ -102,6 +104,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context) BindToInvocationGenerator.Register(context, bindTo, allClasses, languageFeatures); } + /// + /// Declares the observation helper classes that generated observation code instantiates by name, once + /// for the whole compilation. + /// + /// The generator initialization context. + /// Every detected type that has an observation plugin. + /// The consumer's language-feature snapshot, which names the namespace. + /// + /// Keyed to the detected types rather than to the call sites, which keeps the declarations a superset of + /// the references: observation code can only name a helper for a detected type, whichever binding API + /// reaches for it. Collapsing the per-type kinds to a distinct set first means adding another type of an + /// already-seen kind leaves this output cached. + /// + private static void RegisterObservationHelperOutput( + in IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider observableTypes, + IncrementalValueProvider languageFeatures) => + context.RegisterSourceOutput( + observableTypes + .Select(static (typeInfo, _) => typeInfo.ObservationKind) + .Collect() + .Select(static (kinds, _) => ObservationHelperGenerator.SelectHelperKinds(kinds)) + .Combine(languageFeatures), + static (ctx, data) => ObservationHelperGenerator.Generate(ctx, data.Left, data.Right)); + /// Runs one syntax scan and keeps the call sites it could extract. /// The extracted call-site model. /// The generator initialization context. diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs index 0e12a3c..3beb6bc 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/ObservationCodeGenerator.cs @@ -78,19 +78,14 @@ internal static bool IsINPChanging(ClassBindingInfo? classInfo) => CodeGeneratorHelpers.AppendExtensionClassHeader(sb, features); _ = sb.AppendLine(); - // Track which plugins with helper classes are used, so we emit them once - var usedPluginKinds = new HashSet(); - // Group invocations by their method signature var groups = GroupByTypeSignature(invocations); for (var g = 0; g < groups.Count; g++) { - GenerateGroup(sb, groups[g], allClasses, supportsCallerArgExpr, features.StubHasExpressionParameters, methodPrefix, usedPluginKinds); + GenerateGroup(sb, groups[g], allClasses, supportsCallerArgExpr, features.StubHasExpressionParameters, methodPrefix); } - EmitUsedHelperClasses(sb, usedPluginKinds); - CodeGeneratorHelpers.AppendExtensionClassFooter(sb); _ = sb.AppendLine(); @@ -905,25 +900,20 @@ private static string MethodSuffix(InvocationInfo inv) => 0, string.Join("|", inv.ExpressionTexts)); - /// - /// Generates the concrete overload and per-invocation observation methods for a single type group, - /// tracking which plugins require helper-class emission. - /// + /// Generates the concrete overload and per-invocation observation methods for a single type group. /// The string builder to append to. /// The type group to generate code for. /// All detected class binding info for type mechanism lookup. /// Whether the target language version supports CallerArgumentExpression. /// Whether the runtime stub declares the expression parameters this overload has to match. /// The method name prefix. - /// Accumulates the observation kinds of plugins that require helper classes. private static void GenerateGroup( StringBuilder sb, TypeGroup group, ImmutableArray allClasses, bool supportsCallerArgExpr, bool stubHasExpressionParameters, - string methodPrefix, - HashSet usedPluginKinds) + string methodPrefix) { // Resolve the plugin affinity for the source type to emit the runtime override check var groupClassInfo = CodeGeneratorHelpers.FindClassInfo(allClasses, group.SourceTypeFullName); @@ -951,34 +941,10 @@ private static void GenerateGroup( var classInfo = CodeGeneratorHelpers.FindClassInfo(allClasses, inv.SourceTypeFullName); - // Track plugin usage for helper class emission - if (classInfo is not null) - { - var plugin = ObservationPluginRegistry.GetBestPlugin(classInfo); - if (plugin?.RequiresHelperClasses == true) - { - _ = usedPluginKinds.Add(plugin.ObservationKind); - } - } - GenerateObservationMethod(sb, inv, classInfo, suffix, inv.IsBeforeChange, methodPrefix); } } - /// Emits helper classes for all used plugins that require them, sorted for deterministic output order. - /// The string builder to append to. - /// The observation kinds of plugins requiring helper classes. - private static void EmitUsedHelperClasses(StringBuilder sb, HashSet usedPluginKinds) - { - var sortedKinds = new List(usedPluginKinds); - sortedKinds.Sort(StringComparer.Ordinal); - for (var k = 0; k < sortedKinds.Count; k++) - { - var plugin = ObservationPluginRegistry.GetPluginByKind(sortedKinds[k]); - plugin?.EmitHelperClasses(sb); - } - } - /// Emits the trailing named-tuple projection lambda for a selector-less CombineLatest call. /// The string builder to append to. /// The number of property path observables being combined. diff --git a/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs new file mode 100644 index 0000000..935af69 --- /dev/null +++ b/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs @@ -0,0 +1,101 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using ReactiveUI.Binding.SourceGenerators.CodeGeneration; +using ReactiveUI.Binding.SourceGenerators.Models; +using ReactiveUI.Binding.SourceGenerators.Plugins; + +namespace ReactiveUI.Binding.SourceGenerators.Generators; + +/// +/// Declares the platform observation helper classes - the fused observables and observer shims that +/// generated observation code instantiates by name, such as the Apple KVO and WinUI dependency-property +/// observables. +/// +/// +/// +/// The helpers are declared once for the whole compilation, in a file of their own, because every dispatch +/// file is another part of the same __ReactiveUIGeneratedBindings class: one part declares them and +/// all the others reach them. Letting each file declare the helpers it happens to use would collide as soon +/// as two files used the same one, and letting one dispatch file own them - which is what used to happen - +/// left every other file referencing types that were never declared. +/// +/// +/// Which helpers to declare is decided from the detected types rather than from the call sites, so the +/// declarations are a superset of the references: observation code can only name a helper for a type this +/// pipeline detected, whichever API the call site used. A future binding API therefore cannot reintroduce +/// the undeclared-helper failure by forgetting to register itself here. +/// +/// +internal static class ObservationHelperGenerator +{ + /// The generated file the helper classes are declared in. + private const string HintName = "ObservationHelpers.g.cs"; + + /// Buffer capacity to reserve per helper-requiring observation kind. + private const int PerKindBufferCapacity = 4_096; + + /// + /// Reduces the per-type observation kinds to the distinct, ordered set of kinds that need helper + /// declarations, so adding another type of an already-seen kind leaves the generated file untouched. + /// + /// The observation kind of every detected type, with repeats. + /// The kinds requiring helper declarations, ordered for deterministic output. + internal static EquatableArray SelectHelperKinds(ImmutableArray observationKinds) + { + if (observationKinds.IsDefaultOrEmpty) + { + return default; + } + + var kinds = new SortedSet(StringComparer.Ordinal); + for (var i = 0; i < observationKinds.Length; i++) + { + var plugin = ObservationPluginRegistry.GetPluginByKind(observationKinds[i]); + if (plugin?.RequiresHelperClasses == true) + { + _ = kinds.Add(plugin.ObservationKind); + } + } + + if (kinds.Count == 0) + { + return default; + } + + var ordered = new string[kinds.Count]; + kinds.CopyTo(ordered); + return new(ordered); + } + + /// Declares the helper classes for the given observation kinds. + /// The source production context. + /// The observation kinds requiring helper declarations, in output order. + /// The consumer compilation's language-feature and generation-option snapshot. + internal static void Generate( + in SourceProductionContext context, + EquatableArray helperKinds, + in LanguageFeatures features) + { + if (helperKinds.Length == 0) + { + return; + } + + var sb = PooledBuilder.Rent(helperKinds.Length * PerKindBufferCapacity); + CodeGeneratorHelpers.AppendExtensionClassHeader(sb, features); + + for (var i = 0; i < helperKinds.Length; i++) + { + ObservationPluginRegistry.GetPluginByKind(helperKinds[i])?.EmitHelperClasses(sb); + } + + CodeGeneratorHelpers.AppendExtensionClassFooter(sb); + _ = sb.AppendLine(); + + CodeGeneratorHelpers.AddGeneratedSource(context, HintName, PooledBuilder.ToStringAndReturn(sb), features); + } +} diff --git a/src/ReactiveUI.Binding.SourceGenerators/ReactiveUI.Binding.SourceGenerators.csproj b/src/ReactiveUI.Binding.SourceGenerators/ReactiveUI.Binding.SourceGenerators.csproj index 1e22b85..0bb51b4 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/ReactiveUI.Binding.SourceGenerators.csproj +++ b/src/ReactiveUI.Binding.SourceGenerators/ReactiveUI.Binding.SourceGenerators.csproj @@ -5,8 +5,7 @@ false true - Source generator for ReactiveUI property observation and binding. Generates compile-time WhenChanged, WhenChanging, BindOneWay, and BindTwoWay implementations. - $(TargetsForTfmSpecificContentInPackage);PackBuildOutputs + Compatibility package for ReactiveUI.Binding consumers that reference the source generator by name. The generator and its analyzer ship inside ReactiveUI.Binding and ReactiveUI.Binding.Reactive; this package exists so existing references keep resolving. $(NoWarn);AD0001 full true @@ -21,7 +20,10 @@ - + - - - - - - - - - - diff --git a/src/ReactiveUI.Binding/ReactiveUI.Binding.csproj b/src/ReactiveUI.Binding/ReactiveUI.Binding.csproj index 26580f2..8d7014f 100644 --- a/src/ReactiveUI.Binding/ReactiveUI.Binding.csproj +++ b/src/ReactiveUI.Binding/ReactiveUI.Binding.csproj @@ -46,6 +46,23 @@ + + + + + + + + + + + + diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#BindOneWayDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#BindOneWayDispatch.g.verified.cs new file mode 100644 index 0000000..e1f57c6 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#BindOneWayDispatch.g.verified.cs @@ -0,0 +1,55 @@ +//HintName: BindOneWayDispatch.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Concrete typed overload for BindOneWay from global::TestApp.MyAppleView to global::TestApp.MyViewModel. + /// Uses CallerArgumentExpression for dispatch. + /// + public static global::System.IDisposable BindOneWay( + this global::TestApp.MyAppleView source, + global::TestApp.MyViewModel target, + global::System.Linq.Expressions.Expression> sourceProperty, + global::System.Linq.Expressions.Expression> targetProperty, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("sourceProperty")] string sourcePropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerArgumentExpression("targetProperty")] string targetPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + { + sourcePropertyExpression = sourcePropertyExpression.StartsWith("static ") ? sourcePropertyExpression.Substring(7) : sourcePropertyExpression; + targetPropertyExpression = targetPropertyExpression.StartsWith("static ") ? targetPropertyExpression.Substring(7) : targetPropertyExpression; + + if (sourcePropertyExpression == "x => x.Text" + && targetPropertyExpression == "x => x.Name") + { + return __BindOneWay_000018C063784945(source, target); + } + throw new global::System.InvalidOperationException( + "No generated binding found. Ensure the expression is an inline lambda for compile-time optimization."); + } + + private static global::System.IDisposable __BindOneWay_000018C063784945(global::TestApp.MyAppleView source, global::TestApp.MyViewModel target) + { + // BindOneWay: Text -> Name + var sourceObs = new __KVOObservable( + (global::Foundation.NSObject)source, + "text", + (global::Foundation.NSObject __o) => ((global::TestApp.MyAppleView)__o).Text, + true, + false); + + return global::ReactiveUI.Binding.Observables.RxBindingExtensions.Subscribe(sourceObs, value => + { + target.Name = value; + }); + } + + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBinderRegistration.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBinderRegistration.g.verified.cs new file mode 100644 index 0000000..e934459 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBinderRegistration.g.verified.cs @@ -0,0 +1,26 @@ +//HintName: GeneratedBinderRegistration.g.cs +// +#pragma warning disable +#nullable enable + +namespace ReactiveUI.Binding.Generated +{ + /// + /// Auto-generated binder registration. Registers high-affinity + /// ICreatesObservableForProperty implementations detected at compile time. + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class __GeneratedBinderRegistration + { + /// + /// Registers all generated binders with the Splat service locator. + /// + internal static void Initialize() + { + // Generated binder registrations will be added here in future phases. + // Each per-kind binder provides high-affinity observation for detected types. + // Detected types for kind: KVO + // Detected types for kind: INPC + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 0000000..283c2ec --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#GeneratedBindingsAttributes.g.verified.cs @@ -0,0 +1,12 @@ +//HintName: GeneratedBindingsAttributes.g.cs +// +#pragma warning disable +global using global::ReactiveUI.Binding.Generated.TestAssembly; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static partial class __ReactiveUIGeneratedBindings + { + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#ObservationHelpers.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#ObservationHelpers.g.verified.cs new file mode 100644 index 0000000..5455f05 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BOG.KVO_NSObject_Source#ObservationHelpers.g.verified.cs @@ -0,0 +1,133 @@ +//HintName: ObservationHelpers.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// NSObject subclass that receives KVO ObserveValue callbacks and forwards + /// them to a delegate. Mirrors ReactiveUI's BlockObserveValueDelegate pattern. + /// + private sealed class __KVOObserver : global::Foundation.NSObject + { + private readonly global::System.Action _callback; + + internal __KVOObserver(global::System.Action callback) + { + _callback = callback; + } + + public override void ObserveValue( + global::Foundation.NSString keyPath, + global::Foundation.NSObject ofObject, + global::Foundation.NSDictionary change, + global::System.IntPtr context) + { + _callback(); + } + } + + /// + /// Fused observable for Apple KVO property observation. + /// Uses NSObject.AddObserver / NSObject.RemoveObserver + /// with a compile-time resolved KVO key path. + /// + private sealed class __KVOObservable : global::System.IObservable + { + private readonly global::Foundation.NSObject _source; + private readonly global::Foundation.NSString _keyPath; + private readonly global::System.Func _getter; + private readonly bool _distinctUntilChanged; + private readonly global::Foundation.NSKeyValueObservingOptions _options; + + internal __KVOObservable( + global::Foundation.NSObject source, + string keyPath, + global::System.Func getter, + bool distinctUntilChanged, + bool beforeChange) + { + _source = source; + _keyPath = (global::Foundation.NSString)keyPath; + _getter = getter; + _distinctUntilChanged = distinctUntilChanged; + _options = beforeChange + ? global::Foundation.NSKeyValueObservingOptions.Old + : global::Foundation.NSKeyValueObservingOptions.New; + } + + public global::System.IDisposable Subscribe(global::System.IObserver observer) + { + return new Subscription(this, observer); + } + + private sealed class Subscription : global::System.IDisposable + { + private readonly __KVOObservable _parent; + private readonly __KVOObserver _kvoObserver; + private readonly global::System.Runtime.InteropServices.GCHandle _handle; + private readonly global::System.Collections.Generic.IEqualityComparer _comparer; + private global::System.IObserver _observer; + private T _lastValue; + private bool _hasValue; + + internal Subscription(__KVOObservable parent, global::System.IObserver observer) + { + _parent = parent; + _observer = observer; + _comparer = global::System.Collections.Generic.EqualityComparer.Default; + + _kvoObserver = new __KVOObserver(OnValueChanged); + _handle = global::System.Runtime.InteropServices.GCHandle.Alloc(_kvoObserver); + + parent._source.AddObserver( + _kvoObserver, + parent._keyPath, + parent._options, + global::System.IntPtr.Zero); + + // Emit initial value + var initial = parent._getter(parent._source); + _lastValue = initial; + _hasValue = true; + observer.OnNext(initial); + } + + private void OnValueChanged() + { + var obs = System.Threading.Volatile.Read(ref _observer); + if (obs == null) + { + return; + } + + var value = _parent._getter(_parent._source); + + if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) + { + return; + } + + _lastValue = value; + _hasValue = true; + obs.OnNext(value); + } + + public void Dispose() + { + var obs = System.Threading.Interlocked.Exchange(ref _observer, null); + if (obs != null) + { + _parent._source.RemoveObserver(_kvoObserver, _parent._keyPath); + _handle.Free(); + } + } + } + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#BindTwoWayDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#BindTwoWayDispatch.g.verified.cs new file mode 100644 index 0000000..12e4393 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#BindTwoWayDispatch.g.verified.cs @@ -0,0 +1,68 @@ +//HintName: BindTwoWayDispatch.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Concrete typed overload for BindTwoWay from global::TestApp.MyViewModel to global::TestApp.MyAppleView. + /// Uses CallerArgumentExpression for dispatch. + /// + public static global::System.IDisposable BindTwoWay( + this global::TestApp.MyViewModel source, + global::TestApp.MyAppleView target, + global::System.Linq.Expressions.Expression> sourceProperty, + global::System.Linq.Expressions.Expression> targetProperty, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("sourceProperty")] string sourcePropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerArgumentExpression("targetProperty")] string targetPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + { + sourcePropertyExpression = sourcePropertyExpression.StartsWith("static ") ? sourcePropertyExpression.Substring(7) : sourcePropertyExpression; + targetPropertyExpression = targetPropertyExpression.StartsWith("static ") ? targetPropertyExpression.Substring(7) : targetPropertyExpression; + + if (sourcePropertyExpression == "x => x.Name" + && targetPropertyExpression == "x => x.Text") + { + return __BindTwoWay_7FFFD5B3E84DC0B6(source, target); + } + throw new global::System.InvalidOperationException( + "No generated binding found. Ensure the expression is an inline lambda for compile-time optimization."); + } + + private static global::System.IDisposable __BindTwoWay_7FFFD5B3E84DC0B6(global::TestApp.MyViewModel source, global::TestApp.MyAppleView target) + { + // BindTwoWay: Name <-> Text + var sourceObs = new global::ReactiveUI.Binding.Observables.PropertyObservable( + source, + "Name", + (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::TestApp.MyViewModel)__o).Name, + true); + var targetObs = new __KVOObservable( + (global::Foundation.NSObject)target, + "text", + (global::Foundation.NSObject __o) => ((global::TestApp.MyAppleView)__o).Text, + true, + false); + + var d1 = global::ReactiveUI.Binding.Observables.RxBindingExtensions.Subscribe(sourceObs, value => + { + target.Text = value; + }); + + var __targetSkipped = global::ReactiveUI.Binding.Observables.RxBindingExtensions.Skip(targetObs, 1); + var d2 = global::ReactiveUI.Binding.Observables.RxBindingExtensions.Subscribe(__targetSkipped, value => + { + source.Name = value; + }); + + return new global::ReactiveUI.Binding.Observables.CompositeDisposable2(d1, d2); + } + + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBinderRegistration.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBinderRegistration.g.verified.cs new file mode 100644 index 0000000..e934459 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBinderRegistration.g.verified.cs @@ -0,0 +1,26 @@ +//HintName: GeneratedBinderRegistration.g.cs +// +#pragma warning disable +#nullable enable + +namespace ReactiveUI.Binding.Generated +{ + /// + /// Auto-generated binder registration. Registers high-affinity + /// ICreatesObservableForProperty implementations detected at compile time. + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class __GeneratedBinderRegistration + { + /// + /// Registers all generated binders with the Splat service locator. + /// + internal static void Initialize() + { + // Generated binder registrations will be added here in future phases. + // Each per-kind binder provides high-affinity observation for detected types. + // Detected types for kind: KVO + // Detected types for kind: INPC + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 0000000..283c2ec --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#GeneratedBindingsAttributes.g.verified.cs @@ -0,0 +1,12 @@ +//HintName: GeneratedBindingsAttributes.g.cs +// +#pragma warning disable +global using global::ReactiveUI.Binding.Generated.TestAssembly; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static partial class __ReactiveUIGeneratedBindings + { + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#ObservationHelpers.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#ObservationHelpers.g.verified.cs new file mode 100644 index 0000000..5455f05 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BTG.KVO_NSObject_Target#ObservationHelpers.g.verified.cs @@ -0,0 +1,133 @@ +//HintName: ObservationHelpers.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// NSObject subclass that receives KVO ObserveValue callbacks and forwards + /// them to a delegate. Mirrors ReactiveUI's BlockObserveValueDelegate pattern. + /// + private sealed class __KVOObserver : global::Foundation.NSObject + { + private readonly global::System.Action _callback; + + internal __KVOObserver(global::System.Action callback) + { + _callback = callback; + } + + public override void ObserveValue( + global::Foundation.NSString keyPath, + global::Foundation.NSObject ofObject, + global::Foundation.NSDictionary change, + global::System.IntPtr context) + { + _callback(); + } + } + + /// + /// Fused observable for Apple KVO property observation. + /// Uses NSObject.AddObserver / NSObject.RemoveObserver + /// with a compile-time resolved KVO key path. + /// + private sealed class __KVOObservable : global::System.IObservable + { + private readonly global::Foundation.NSObject _source; + private readonly global::Foundation.NSString _keyPath; + private readonly global::System.Func _getter; + private readonly bool _distinctUntilChanged; + private readonly global::Foundation.NSKeyValueObservingOptions _options; + + internal __KVOObservable( + global::Foundation.NSObject source, + string keyPath, + global::System.Func getter, + bool distinctUntilChanged, + bool beforeChange) + { + _source = source; + _keyPath = (global::Foundation.NSString)keyPath; + _getter = getter; + _distinctUntilChanged = distinctUntilChanged; + _options = beforeChange + ? global::Foundation.NSKeyValueObservingOptions.Old + : global::Foundation.NSKeyValueObservingOptions.New; + } + + public global::System.IDisposable Subscribe(global::System.IObserver observer) + { + return new Subscription(this, observer); + } + + private sealed class Subscription : global::System.IDisposable + { + private readonly __KVOObservable _parent; + private readonly __KVOObserver _kvoObserver; + private readonly global::System.Runtime.InteropServices.GCHandle _handle; + private readonly global::System.Collections.Generic.IEqualityComparer _comparer; + private global::System.IObserver _observer; + private T _lastValue; + private bool _hasValue; + + internal Subscription(__KVOObservable parent, global::System.IObserver observer) + { + _parent = parent; + _observer = observer; + _comparer = global::System.Collections.Generic.EqualityComparer.Default; + + _kvoObserver = new __KVOObserver(OnValueChanged); + _handle = global::System.Runtime.InteropServices.GCHandle.Alloc(_kvoObserver); + + parent._source.AddObserver( + _kvoObserver, + parent._keyPath, + parent._options, + global::System.IntPtr.Zero); + + // Emit initial value + var initial = parent._getter(parent._source); + _lastValue = initial; + _hasValue = true; + observer.OnNext(initial); + } + + private void OnValueChanged() + { + var obs = System.Threading.Volatile.Read(ref _observer); + if (obs == null) + { + return; + } + + var value = _parent._getter(_parent._source); + + if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) + { + return; + } + + _lastValue = value; + _hasValue = true; + obs.OnNext(value); + } + + public void Dispose() + { + var obs = System.Threading.Interlocked.Exchange(ref _observer, null); + if (obs != null) + { + _parent._source.RemoveObserver(_kvoObserver, _parent._keyPath); + _handle.Free(); + } + } + } + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindOneWayGeneratorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindOneWayGeneratorTests.cs index 1b4a71c..59730f7 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindOneWayGeneratorTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindOneWayGeneratorTests.cs @@ -332,4 +332,21 @@ public static void Execute(MyViewModel vm, MyView view) await result.HasNoGeneratorDiagnostics(); await result.DoesNotHaveGeneratedSource(BindOneWayDispatchgcsName); } + + /// + /// Verifies BindOneWay from an NSObject source, whose observation instantiates the KVO helper + /// classes. Those helpers are declared in a file of their own, so a compilation that reaches them + /// only through a binding - never through WhenChanged - still compiles. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task KVO_NSObject_Source() + { + var source = ApplePlatformSource.BindingScenario("view.BindOneWay(vm, x => x.Text, x => x.Name)"); + var result = + await TestHelper.TestPassWithResult(source, typeof(BindOneWayGeneratorTests), LanguageVersion.CSharp10); + await result.CompilationSucceeds(); + await result.HasNoGeneratorDiagnostics(); + await result.GeneratedSourceContains(ApplePlatformSource.HelperHintName, "__KVOObservable"); + } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindTwoWayGeneratorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindTwoWayGeneratorTests.cs index cd95f34..3ab1080 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindTwoWayGeneratorTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/BindTwoWayGeneratorTests.cs @@ -147,4 +147,21 @@ public async Task MultipleSameTypeBindings_CallerFilePath() TestHelper.FallbackLanguageVersion(nullableEnabled: true)); await result.HasNoGeneratorDiagnostics(); } + + /// + /// Verifies BindTwoWay against an NSObject target, whose observation instantiates the KVO helper + /// classes. Those helpers are declared in a file of their own, so a compilation that reaches them + /// only through a binding - never through WhenChanged - still compiles. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task KVO_NSObject_Target() + { + var source = ApplePlatformSource.BindingScenario("vm.BindTwoWay(view, x => x.Name, x => x.Text)"); + var result = + await TestHelper.TestPassWithResult(source, typeof(BindTwoWayGeneratorTests), LanguageVersion.CSharp10); + await result.CompilationSucceeds(); + await result.HasNoGeneratorDiagnostics(); + await result.GeneratedSourceContains(ApplePlatformSource.HelperHintName, "__KVOObservable"); + } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ApplePlatformSource.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ApplePlatformSource.cs new file mode 100644 index 0000000..68a4aeb --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/ApplePlatformSource.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +/// +/// Builds compilation sources for the Apple key-value-observing path. The KVO observation code the +/// generator emits instantiates helper classes that are themselves generated, so a scenario only proves +/// anything if it compiles - which needs enough of Foundation present for those helpers to bind. +/// A stub stands in for the real framework so the scenarios run on every target, not just Apple ones. +/// +internal static class ApplePlatformSource +{ + /// The generated file that declares the observation helper classes. + internal const string HelperHintName = "ObservationHelpers.g.cs"; + + /// + /// The members of Foundation the generated KVO helpers bind against: the observer callback they + /// override and the add/remove observer pair they subscribe through. + /// + private const string FoundationStub = """ + namespace Foundation + { + public class NSString + { + private readonly string _value; + public NSString(string value) { _value = value; } + public static explicit operator NSString(string value) => new NSString(value); + } + public class NSDictionary {} + public enum NSKeyValueObservingOptions { New = 1, Old = 2 } + public class NSObject + { + public virtual void ObserveValue(NSString keyPath, NSObject ofObject, NSDictionary change, IntPtr context) {} + public void AddObserver(NSObject observer, NSString keyPath, NSKeyValueObservingOptions options, IntPtr context) {} + public void RemoveObserver(NSObject observer, NSString keyPath) {} + } + } + """; + + /// Builds a source that only declares an NSObject-derived view, with no binding call. + /// The compilation source. + internal static string TypeDetectionScenario() => $$""" + using System; + + {{FoundationStub}} + + namespace TestApp + { + public class MyAppleView : Foundation.NSObject + { + public string Text { get; set; } + } + } + """; + + /// Builds a source with an NSObject-derived view and a plain view model, bound by the given call. + /// + /// The binding expression, with vm and view in scope - for example + /// view.BindOneWay(vm, x => x.Text, x => x.Name). + /// + /// The compilation source. + internal static string BindingScenario(string bindingCall) => $$""" + using System; + using System.ComponentModel; + + using ReactiveUI.Binding; + + {{FoundationStub}} + + namespace TestApp + { + public class MyAppleView : Foundation.NSObject + { + public string Text { get; set; } + } + + public class MyViewModel : INotifyPropertyChanged + { + private string _name = string.Empty; + public event PropertyChangedEventHandler PropertyChanged; + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); + } + } + } + } + + public static class Scenario + { + public static IDisposable Execute(MyViewModel vm, MyAppleView view) + => {{bindingCall}}; + } + } + """; +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PDS.NSObject_Detected#ObservationHelpers.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PDS.NSObject_Detected#ObservationHelpers.g.verified.cs new file mode 100644 index 0000000..1711583 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PDS.NSObject_Detected#ObservationHelpers.g.verified.cs @@ -0,0 +1,132 @@ +//HintName: ObservationHelpers.g.cs +// +#pragma warning disable + +using System; + +namespace ReactiveUI.Binding +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// NSObject subclass that receives KVO ObserveValue callbacks and forwards + /// them to a delegate. Mirrors ReactiveUI's BlockObserveValueDelegate pattern. + /// + private sealed class __KVOObserver : global::Foundation.NSObject + { + private readonly global::System.Action _callback; + + internal __KVOObserver(global::System.Action callback) + { + _callback = callback; + } + + public override void ObserveValue( + global::Foundation.NSString keyPath, + global::Foundation.NSObject ofObject, + global::Foundation.NSDictionary change, + global::System.IntPtr context) + { + _callback(); + } + } + + /// + /// Fused observable for Apple KVO property observation. + /// Uses NSObject.AddObserver / NSObject.RemoveObserver + /// with a compile-time resolved KVO key path. + /// + private sealed class __KVOObservable : global::System.IObservable + { + private readonly global::Foundation.NSObject _source; + private readonly global::Foundation.NSString _keyPath; + private readonly global::System.Func _getter; + private readonly bool _distinctUntilChanged; + private readonly global::Foundation.NSKeyValueObservingOptions _options; + + internal __KVOObservable( + global::Foundation.NSObject source, + string keyPath, + global::System.Func getter, + bool distinctUntilChanged, + bool beforeChange) + { + _source = source; + _keyPath = (global::Foundation.NSString)keyPath; + _getter = getter; + _distinctUntilChanged = distinctUntilChanged; + _options = beforeChange + ? global::Foundation.NSKeyValueObservingOptions.Old + : global::Foundation.NSKeyValueObservingOptions.New; + } + + public global::System.IDisposable Subscribe(global::System.IObserver observer) + { + return new Subscription(this, observer); + } + + private sealed class Subscription : global::System.IDisposable + { + private readonly __KVOObservable _parent; + private readonly __KVOObserver _kvoObserver; + private readonly global::System.Runtime.InteropServices.GCHandle _handle; + private readonly global::System.Collections.Generic.IEqualityComparer _comparer; + private global::System.IObserver _observer; + private T _lastValue; + private bool _hasValue; + + internal Subscription(__KVOObservable parent, global::System.IObserver observer) + { + _parent = parent; + _observer = observer; + _comparer = global::System.Collections.Generic.EqualityComparer.Default; + + _kvoObserver = new __KVOObserver(OnValueChanged); + _handle = global::System.Runtime.InteropServices.GCHandle.Alloc(_kvoObserver); + + parent._source.AddObserver( + _kvoObserver, + parent._keyPath, + parent._options, + global::System.IntPtr.Zero); + + // Emit initial value + var initial = parent._getter(parent._source); + _lastValue = initial; + _hasValue = true; + observer.OnNext(initial); + } + + private void OnValueChanged() + { + var obs = System.Threading.Volatile.Read(ref _observer); + if (obs == null) + { + return; + } + + var value = _parent._getter(_parent._source); + + if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) + { + return; + } + + _lastValue = value; + _hasValue = true; + obs.OnNext(value); + } + + public void Dispose() + { + var obs = System.Threading.Interlocked.Exchange(ref _observer, null); + if (obs != null) + { + _parent._source.RemoveObserver(_kvoObserver, _parent._keyPath); + _handle.Free(); + } + } + } + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PlatformDetectionSnapshotTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PlatformDetectionSnapshotTests.cs index 41b3da7..bf02059 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PlatformDetectionSnapshotTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/PlatformDetectionSnapshotTests.cs @@ -83,26 +83,17 @@ public class MyAndroidView : Android.Views.View return TestHelper.TestPass(source, typeof(PlatformDetectionSnapshotTests)); } - /// Verifies detection of Apple NSObject (KVO). + /// + /// Verifies detection of Apple NSObject (KVO), and that the KVO observation helpers it brings with it + /// compile on the minimum supported language version even with no binding call to use them. + /// /// A task representing the asynchronous test operation. [Test] - public Task NSObject_Detected() + public async Task NSObject_Detected() { - const string source = """ - namespace Foundation - { - public class NSObject {} - } - - namespace TestApp - { - public class MyAppleView : Foundation.NSObject - { - public string Text { get; set; } - } - } - """; + var source = ApplePlatformSource.TypeDetectionScenario(); - return TestHelper.TestPass(source, typeof(PlatformDetectionSnapshotTests)); + var result = await TestHelper.TestPassWithResult(source, typeof(PlatformDetectionSnapshotTests)); + await result.CompilationSucceeds(); } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#ObservationHelpers.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#ObservationHelpers.g.verified.cs new file mode 100644 index 0000000..5455f05 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#ObservationHelpers.g.verified.cs @@ -0,0 +1,133 @@ +//HintName: ObservationHelpers.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// NSObject subclass that receives KVO ObserveValue callbacks and forwards + /// them to a delegate. Mirrors ReactiveUI's BlockObserveValueDelegate pattern. + /// + private sealed class __KVOObserver : global::Foundation.NSObject + { + private readonly global::System.Action _callback; + + internal __KVOObserver(global::System.Action callback) + { + _callback = callback; + } + + public override void ObserveValue( + global::Foundation.NSString keyPath, + global::Foundation.NSObject ofObject, + global::Foundation.NSDictionary change, + global::System.IntPtr context) + { + _callback(); + } + } + + /// + /// Fused observable for Apple KVO property observation. + /// Uses NSObject.AddObserver / NSObject.RemoveObserver + /// with a compile-time resolved KVO key path. + /// + private sealed class __KVOObservable : global::System.IObservable + { + private readonly global::Foundation.NSObject _source; + private readonly global::Foundation.NSString _keyPath; + private readonly global::System.Func _getter; + private readonly bool _distinctUntilChanged; + private readonly global::Foundation.NSKeyValueObservingOptions _options; + + internal __KVOObservable( + global::Foundation.NSObject source, + string keyPath, + global::System.Func getter, + bool distinctUntilChanged, + bool beforeChange) + { + _source = source; + _keyPath = (global::Foundation.NSString)keyPath; + _getter = getter; + _distinctUntilChanged = distinctUntilChanged; + _options = beforeChange + ? global::Foundation.NSKeyValueObservingOptions.Old + : global::Foundation.NSKeyValueObservingOptions.New; + } + + public global::System.IDisposable Subscribe(global::System.IObserver observer) + { + return new Subscription(this, observer); + } + + private sealed class Subscription : global::System.IDisposable + { + private readonly __KVOObservable _parent; + private readonly __KVOObserver _kvoObserver; + private readonly global::System.Runtime.InteropServices.GCHandle _handle; + private readonly global::System.Collections.Generic.IEqualityComparer _comparer; + private global::System.IObserver _observer; + private T _lastValue; + private bool _hasValue; + + internal Subscription(__KVOObservable parent, global::System.IObserver observer) + { + _parent = parent; + _observer = observer; + _comparer = global::System.Collections.Generic.EqualityComparer.Default; + + _kvoObserver = new __KVOObserver(OnValueChanged); + _handle = global::System.Runtime.InteropServices.GCHandle.Alloc(_kvoObserver); + + parent._source.AddObserver( + _kvoObserver, + parent._keyPath, + parent._options, + global::System.IntPtr.Zero); + + // Emit initial value + var initial = parent._getter(parent._source); + _lastValue = initial; + _hasValue = true; + observer.OnNext(initial); + } + + private void OnValueChanged() + { + var obs = System.Threading.Volatile.Read(ref _observer); + if (obs == null) + { + return; + } + + var value = _parent._getter(_parent._source); + + if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) + { + return; + } + + _lastValue = value; + _hasValue = true; + obs.OnNext(value); + } + + public void Dispose() + { + var obs = System.Threading.Interlocked.Exchange(ref _observer, null); + if (obs != null) + { + _parent._source.RemoveObserver(_kvoObserver, _parent._keyPath); + _handle.Free(); + } + } + } + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#WhenChangedDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#WhenChangedDispatch.g.verified.cs index 5828d34..1b98b8f 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#WhenChangedDispatch.g.verified.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.KVO_NSObject_Property#WhenChangedDispatch.g.verified.cs @@ -39,126 +39,5 @@ internal static partial class __ReactiveUIGeneratedBindings return new __KVOObservable((global::Foundation.NSObject)obj, "text", (global::Foundation.NSObject __o) => ((global::TestApp.MyAppleView)__o).Text, true, false); } - - /// - /// NSObject subclass that receives KVO ObserveValue callbacks and forwards - /// them to a delegate. Mirrors ReactiveUI's BlockObserveValueDelegate pattern. - /// - private sealed class __KVOObserver : global::Foundation.NSObject - { - private readonly global::System.Action _callback; - - internal __KVOObserver(global::System.Action callback) - { - _callback = callback; - } - - public override void ObserveValue( - global::Foundation.NSString keyPath, - global::Foundation.NSObject ofObject, - global::Foundation.NSDictionary change, - global::System.IntPtr context) - { - _callback(); - } - } - - /// - /// Fused observable for Apple KVO property observation. - /// Uses NSObject.AddObserver / NSObject.RemoveObserver - /// with a compile-time resolved KVO key path. - /// - private sealed class __KVOObservable : global::System.IObservable - { - private readonly global::Foundation.NSObject _source; - private readonly global::Foundation.NSString _keyPath; - private readonly global::System.Func _getter; - private readonly bool _distinctUntilChanged; - private readonly global::Foundation.NSKeyValueObservingOptions _options; - - internal __KVOObservable( - global::Foundation.NSObject source, - string keyPath, - global::System.Func getter, - bool distinctUntilChanged, - bool beforeChange) - { - _source = source; - _keyPath = (global::Foundation.NSString)keyPath; - _getter = getter; - _distinctUntilChanged = distinctUntilChanged; - _options = beforeChange - ? global::Foundation.NSKeyValueObservingOptions.Old - : global::Foundation.NSKeyValueObservingOptions.New; - } - - public global::System.IDisposable Subscribe(global::System.IObserver observer) - { - return new Subscription(this, observer); - } - - private sealed class Subscription : global::System.IDisposable - { - private readonly __KVOObservable _parent; - private readonly __KVOObserver _kvoObserver; - private readonly global::System.Runtime.InteropServices.GCHandle _handle; - private readonly global::System.Collections.Generic.IEqualityComparer _comparer; - private global::System.IObserver _observer; - private T _lastValue; - private bool _hasValue; - - internal Subscription(__KVOObservable parent, global::System.IObserver observer) - { - _parent = parent; - _observer = observer; - _comparer = global::System.Collections.Generic.EqualityComparer.Default; - - _kvoObserver = new __KVOObserver(OnValueChanged); - _handle = global::System.Runtime.InteropServices.GCHandle.Alloc(_kvoObserver); - - parent._source.AddObserver( - _kvoObserver, - parent._keyPath, - parent._options, - global::System.IntPtr.Zero); - - // Emit initial value - var initial = parent._getter(parent._source); - _lastValue = initial; - _hasValue = true; - observer.OnNext(initial); - } - - private void OnValueChanged() - { - var obs = System.Threading.Volatile.Read(ref _observer); - if (obs == null) - { - return; - } - - var value = _parent._getter(_parent._source); - - if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) - { - return; - } - - _lastValue = value; - _hasValue = true; - obs.OnNext(value); - } - - public void Dispose() - { - var obs = System.Threading.Interlocked.Exchange(ref _observer, null); - if (obs != null) - { - _parent._source.RemoveObserver(_kvoObserver, _parent._keyPath); - _handle.Free(); - } - } - } - } } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#ObservationHelpers.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#ObservationHelpers.g.verified.cs new file mode 100644 index 0000000..8fdc265 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#ObservationHelpers.g.verified.cs @@ -0,0 +1,97 @@ +//HintName: ObservationHelpers.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Fused observable for WinUI DependencyProperty observation. + /// Uses RegisterPropertyChangedCallback / UnregisterPropertyChangedCallback + /// for token-based subscription management. + /// + private sealed class __WinUIDPObservable : global::System.IObservable + { + private readonly global::Microsoft.UI.Xaml.DependencyObject _source; + private readonly global::Microsoft.UI.Xaml.DependencyProperty _dp; + private readonly global::System.Func _getter; + private readonly bool _distinctUntilChanged; + + internal __WinUIDPObservable( + global::Microsoft.UI.Xaml.DependencyObject source, + global::Microsoft.UI.Xaml.DependencyProperty dp, + global::System.Func getter, + bool distinctUntilChanged) + { + _source = source; + _dp = dp; + _getter = getter; + _distinctUntilChanged = distinctUntilChanged; + } + + public global::System.IDisposable Subscribe(global::System.IObserver observer) + { + return new Subscription(this, observer); + } + + private sealed class Subscription : global::System.IDisposable + { + private readonly __WinUIDPObservable _parent; + private readonly long _token; + private readonly global::System.Collections.Generic.IEqualityComparer _comparer; + private global::System.IObserver _observer; + private T _lastValue; + private bool _hasValue; + + internal Subscription(__WinUIDPObservable parent, global::System.IObserver observer) + { + _parent = parent; + _observer = observer; + _comparer = global::System.Collections.Generic.EqualityComparer.Default; + _token = parent._source.RegisterPropertyChangedCallback(parent._dp, OnPropertyChanged); + + // Emit initial value + var initial = parent._getter(parent._source); + _lastValue = initial; + _hasValue = true; + observer.OnNext(initial); + } + + private void OnPropertyChanged( + global::Microsoft.UI.Xaml.DependencyObject sender, + global::Microsoft.UI.Xaml.DependencyProperty dp) + { + var obs = System.Threading.Volatile.Read(ref _observer); + if (obs == null) + { + return; + } + + var value = _parent._getter(sender); + + if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) + { + return; + } + + _lastValue = value; + _hasValue = true; + obs.OnNext(value); + } + + public void Dispose() + { + var obs = System.Threading.Interlocked.Exchange(ref _observer, null); + if (obs != null) + { + _parent._source.UnregisterPropertyChangedCallback(_parent._dp, _token); + } + } + } + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#WhenChangedDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#WhenChangedDispatch.g.verified.cs index 60742c7..db7cfdb 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#WhenChangedDispatch.g.verified.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/WCG.WinUIDependencyObject_Property#WhenChangedDispatch.g.verified.cs @@ -39,90 +39,5 @@ internal static partial class __ReactiveUIGeneratedBindings return new __WinUIDPObservable((global::Microsoft.UI.Xaml.DependencyObject)obj, global::TestApp.MyWinUIControl.TextProperty, (global::Microsoft.UI.Xaml.DependencyObject __o) => ((global::TestApp.MyWinUIControl)__o).Text, true); } - - /// - /// Fused observable for WinUI DependencyProperty observation. - /// Uses RegisterPropertyChangedCallback / UnregisterPropertyChangedCallback - /// for token-based subscription management. - /// - private sealed class __WinUIDPObservable : global::System.IObservable - { - private readonly global::Microsoft.UI.Xaml.DependencyObject _source; - private readonly global::Microsoft.UI.Xaml.DependencyProperty _dp; - private readonly global::System.Func _getter; - private readonly bool _distinctUntilChanged; - - internal __WinUIDPObservable( - global::Microsoft.UI.Xaml.DependencyObject source, - global::Microsoft.UI.Xaml.DependencyProperty dp, - global::System.Func getter, - bool distinctUntilChanged) - { - _source = source; - _dp = dp; - _getter = getter; - _distinctUntilChanged = distinctUntilChanged; - } - - public global::System.IDisposable Subscribe(global::System.IObserver observer) - { - return new Subscription(this, observer); - } - - private sealed class Subscription : global::System.IDisposable - { - private readonly __WinUIDPObservable _parent; - private readonly long _token; - private readonly global::System.Collections.Generic.IEqualityComparer _comparer; - private global::System.IObserver _observer; - private T _lastValue; - private bool _hasValue; - - internal Subscription(__WinUIDPObservable parent, global::System.IObserver observer) - { - _parent = parent; - _observer = observer; - _comparer = global::System.Collections.Generic.EqualityComparer.Default; - _token = parent._source.RegisterPropertyChangedCallback(parent._dp, OnPropertyChanged); - - // Emit initial value - var initial = parent._getter(parent._source); - _lastValue = initial; - _hasValue = true; - observer.OnNext(initial); - } - - private void OnPropertyChanged( - global::Microsoft.UI.Xaml.DependencyObject sender, - global::Microsoft.UI.Xaml.DependencyProperty dp) - { - var obs = System.Threading.Volatile.Read(ref _observer); - if (obs == null) - { - return; - } - - var value = _parent._getter(sender); - - if (_parent._distinctUntilChanged && _hasValue && _comparer.Equals(value, _lastValue)) - { - return; - } - - _lastValue = value; - _hasValue = true; - obs.OnNext(value); - } - - public void Dispose() - { - var obs = System.Threading.Interlocked.Exchange(ref _observer, null); - if (obs != null) - { - _parent._source.UnregisterPropertyChangedCallback(_parent._dp, _token); - } - } - } - } } } From f727861ad82c0eb81d8f6d94a555aa9a282aee77 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:26:54 +1000 Subject: [PATCH 2/2] test(generator): cover helper-kind selection and declaration - Extract the declaration loop into a static AppendHelperDeclarations so it can be exercised without a source production context. - Cover both paths, including a kind no plugin answers to: the pipeline cannot produce one, but a generator that threw on an unexpected kind would fail the consumer's build rather than merely generate less. - Pin the invariant that makes that case impossible - every kind the selection yields resolves back to a plugin, so a new plugin whose kind did not round-trip would be caught rather than silently declaring nothing. --- .../Generators/ObservationHelperGenerator.cs | 22 +++- .../ObservationHelperGeneratorTests.cs | 109 ++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Generators/ObservationHelperGeneratorTests.cs diff --git a/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs index 935af69..26d8e85 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Generators/ObservationHelperGenerator.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.Immutable; +using System.Text; using Microsoft.CodeAnalysis; using ReactiveUI.Binding.SourceGenerators.CodeGeneration; using ReactiveUI.Binding.SourceGenerators.Models; @@ -87,15 +88,26 @@ internal static void Generate( var sb = PooledBuilder.Rent(helperKinds.Length * PerKindBufferCapacity); CodeGeneratorHelpers.AppendExtensionClassHeader(sb, features); + AppendHelperDeclarations(sb, helperKinds); + CodeGeneratorHelpers.AppendExtensionClassFooter(sb); + _ = sb.AppendLine(); + + CodeGeneratorHelpers.AddGeneratedSource(context, HintName, PooledBuilder.ToStringAndReturn(sb), features); + } + /// Appends the declarations for each of the given observation kinds, in the order given. + /// The string builder to append to. + /// The observation kinds requiring helper declarations. + /// + /// A kind no plugin answers to contributes nothing. only ever yields kinds + /// it read off a plugin, so the pipeline cannot produce one - but a generator that threw on an unexpected + /// kind would fail the consumer's build rather than merely generate less, which is the worse of the two. + /// + internal static void AppendHelperDeclarations(StringBuilder sb, EquatableArray helperKinds) + { for (var i = 0; i < helperKinds.Length; i++) { ObservationPluginRegistry.GetPluginByKind(helperKinds[i])?.EmitHelperClasses(sb); } - - CodeGeneratorHelpers.AppendExtensionClassFooter(sb); - _ = sb.AppendLine(); - - CodeGeneratorHelpers.AddGeneratedSource(context, HintName, PooledBuilder.ToStringAndReturn(sb), features); } } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Generators/ObservationHelperGeneratorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Generators/ObservationHelperGeneratorTests.cs new file mode 100644 index 0000000..336be7d --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Generators/ObservationHelperGeneratorTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Text; +using ReactiveUI.Binding.SourceGenerators.Generators; +using ReactiveUI.Binding.SourceGenerators.Plugins; + +namespace ReactiveUI.Binding.SourceGenerators.Tests.Generators; + +/// Tests for how observation helper declarations are selected and emitted. +public class ObservationHelperGeneratorTests +{ + /// An observation kind no plugin answers to. + private const string UnknownKind = "NotAnObservationKind"; + + /// Verifies that no detected types yield no helper kinds. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectHelperKinds_NoTypes_YieldsNothing() + { + var kinds = ObservationHelperGenerator.SelectHelperKinds([]); + + await Assert.That(kinds.Length).IsEqualTo(0); + } + + /// Verifies that a kind whose plugin declares no helpers yields nothing. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectHelperKinds_KindWithoutHelpers_YieldsNothing() + { + var kinds = ObservationHelperGenerator.SelectHelperKinds(["INPC"]); + + await Assert.That(kinds.Length).IsEqualTo(0); + } + + /// Verifies that an unrecognised kind is ignored rather than selected. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectHelperKinds_UnknownKind_YieldsNothing() + { + var kinds = ObservationHelperGenerator.SelectHelperKinds([UnknownKind]); + + await Assert.That(kinds.Length).IsEqualTo(0); + } + + /// Verifies that repeats of one kind collapse, so another type of a seen kind changes nothing. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectHelperKinds_RepeatedKind_CollapsesToOne() + { + var kinds = ObservationHelperGenerator.SelectHelperKinds(["KVO", "KVO", "KVO"]); + + await Assert.That(kinds.Length).IsEqualTo(1); + await Assert.That(kinds[0]).IsEqualTo("KVO"); + } + + /// + /// Verifies every kind the selection can yield resolves back to a plugin. This is the invariant that lets + /// emission treat an unresolvable kind as impossible; a new plugin whose kind did not round-trip would + /// silently declare nothing. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectHelperKinds_EveryYieldedKind_ResolvesToAPlugin() + { + var everyKind = ImmutableArray.CreateBuilder(); + for (var i = 0; i < ObservationPluginRegistry.Count; i++) + { + everyKind.Add(ObservationPluginRegistry.GetPlugin(i).ObservationKind); + } + + var kinds = ObservationHelperGenerator.SelectHelperKinds(everyKind.ToImmutable()); + + await Assert.That(kinds.Length).IsGreaterThan(0); + for (var i = 0; i < kinds.Length; i++) + { + await Assert.That(ObservationPluginRegistry.GetPluginByKind(kinds[i])).IsNotNull(); + } + } + + /// Verifies that a selected kind's declarations are appended. + /// A task representing the asynchronous test operation. + [Test] + public async Task AppendHelperDeclarations_KnownKind_AppendsItsDeclarations() + { + var sb = new StringBuilder(); + + ObservationHelperGenerator.AppendHelperDeclarations(sb, new(["KVO"])); + + await Assert.That(sb.ToString()).Contains("__KVOObservable"); + } + + /// + /// Verifies that a kind no plugin answers to contributes nothing and does not throw. A generator that threw + /// on an unexpected kind would fail the consumer's build rather than merely generate less. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task AppendHelperDeclarations_UnknownKind_AppendsNothing() + { + var sb = new StringBuilder(); + + ObservationHelperGenerator.AppendHelperDeclarations(sb, new([UnknownKind])); + + await Assert.That(sb.Length).IsEqualTo(0); + } +}