diff --git a/CLAUDE.md b/CLAUDE.md index 3ca46be2..e08035aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ dotnet clean ReactiveUI.Binding.SourceGenerators.slnf The working folder must be `./src` folder. **IMPORTANT:** +- All TUnit test projects use `en-US` for the current culture and UI culture, configured by an assembly attribute in `Directory.Build.props`. Culture-specific regression tests use TUnit's `Culture` attribute to override it for that test. - Do NOT use `--no-build` flag when running tests. Always build before testing to ensure all code changes are compiled. - Use `--output Detailed` to see Console.WriteLine output from tests (place BEFORE any `--` separator). diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 01b70b16..36e334ef 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -119,6 +119,10 @@ + + + <_Parameter1>en-US + $(AssemblyName).testconfig.json PreserveNewest diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs index 8f23e093..b5dc6f6c 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs @@ -172,6 +172,12 @@ internal static EquatableArray ExtractProperties( true)); } + // View model replacement must also follow a view model declared by a referenced view base. + if (!seen.Contains(ViewModelPropertyName) && FindViewModelProperty(typeSymbol) is { } viewModel) + { + properties.Add(ExtractProperty(typeSymbol, viewModel)); + } + return new([.. properties]); } diff --git a/src/examples/Documentation/Pages/bindings/BindInteractionExamples.cs b/src/examples/Documentation/Pages/bindings/BindInteractionExamples.cs index 897d4e7b..3de560f6 100644 --- a/src/examples/Documentation/Pages/bindings/BindInteractionExamples.cs +++ b/src/examples/Documentation/Pages/bindings/BindInteractionExamples.cs @@ -31,6 +31,41 @@ public static class BindInteractionExamples /// The answer of the handler registered last. private const string LatestAnswer = "latest"; + /// Binds before the view has a view model and moves the handler when the selected board changes. + /// A task that completes when both boards have answered. + public static async Task FollowTheSelectedIssueBoard() + { + IssueBoardView view = new(); + using IDisposable binding = view.BindInteraction(view.ViewModel, x => x.ConfirmClose, static context => + { + context.SetOutput(true); + return Task.CompletedTask; + }); + + Issue issue = new() { Number = CheckoutIssueNumber }; + IssueBoardViewModel first = new(InMemoryGitHubServer.CreateSeeded()); + view.ViewModel = first; + Console.WriteLine(await first.ConfirmClose.Handle(issue)); + + IssueBoardViewModel second = new(InMemoryGitHubServer.CreateSeeded()); + view.ViewModel = second; + Console.WriteLine(await second.ConfirmClose.Handle(issue)); + + try + { + _ = await first.ConfirmClose.Handle(issue); + } + catch (UnhandledInteractionException) + { + Console.WriteLine("The previous board has no handler."); + } + + // Output: + // True + // True + // The previous board has no handler. + } + /// Answers the close confirmation through a task-based handler that waits for the user, so the close stays pending until the dialog is answered. /// A task that completes when the issue is closed. public static async Task ConfirmCloseWithTaskHandler() diff --git a/src/examples/Documentation/Pages/bindings/Program.cs b/src/examples/Documentation/Pages/bindings/Program.cs index 460d3f36..be99a45b 100644 --- a/src/examples/Documentation/Pages/bindings/Program.cs +++ b/src/examples/Documentation/Pages/bindings/Program.cs @@ -163,6 +163,8 @@ await BindInteractionExamples.ConfirmCloseWithTaskHandler(); +await BindInteractionExamples.FollowTheSelectedIssueBoard(); + await BindInteractionExamples.ConfirmDropWithObservableHandler(); await BindInteractionExamples.ConfirmCloseThroughInterfaceProperty(); diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs index ccd4b05a..d0f2d469 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs @@ -381,13 +381,14 @@ public static GeneratorTestResult RunGeneratorBeside( /// Loads generated bindings with optionally isolated converter registrations. /// The generated consumer compilation. /// Whether the binding runtime has private static state. + /// Referenced compilations to emit into the consumer's context. /// The consumer assembly and its collectible context. /// The consumer could not be emitted. [SuppressMessage( "Security", "SES1402:Assembly loaded from an unverifiable source", Justification = "loads the compilation this test just emitted, in-process, into a collectible context")] - public static LoadedAssembly EmitAndLoad(GeneratorTestResult result, bool isolateBindingRuntime) + public static LoadedAssembly EmitAndLoad(GeneratorTestResult result, bool isolateBindingRuntime, params CompilationReference[] dependencies) { ArgumentNullException.ThrowIfNull(result); @@ -408,6 +409,19 @@ public static LoadedAssembly EmitAndLoad(GeneratorTestResult result, bool isolat assemblyStream.Position = 0; var context = new CollectibleAssemblyLoadContext { IsolateBindingRuntime = isolateBindingRuntime }; + foreach (var dependency in dependencies) + { + using var dependencyStream = new MemoryStream(); + var dependencyResult = dependency.Compilation.Emit(dependencyStream); + if (!dependencyResult.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, dependencyResult.Diagnostics)); + } + + dependencyStream.Position = 0; + _ = context.LoadFromStream(dependencyStream); + } + var assembly = context.LoadFromStream(assemblyStream); return new(assembly, context); } diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TypeDetectionExtractorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TypeDetectionExtractorTests.cs index 0ba3854b..75aef1ff 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TypeDetectionExtractorTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TypeDetectionExtractorTests.cs @@ -231,6 +231,122 @@ public void Dispose() { } await Assert.That(info.InheritsWinUIDependencyObject).IsEqualTo(expected); } + /// Captures only the inherited view model needed to follow a view across assembly boundaries. + /// The view model declaration in the referenced base. + /// The declaration that may hide the base member. + /// The captured property type, or null when there is no readable property. + /// Whether its getter is public. + /// Whether the consumer declares the property. + /// A task representing the asynchronous test operation. + [Test] + [Arguments("public Model ViewModel { get; set; }", "", "global::External.Model", true, false)] + [Arguments("public object ViewModel { get; set; }", "", "object", true, false)] + [Arguments("public static Model ViewModel { get; set; }", "", null, false, false)] + [Arguments("public Model ViewModel { private get; set; }", "", null, false, false)] + [Arguments("public Model ViewModel { set { } }", "", null, false, false)] + [Arguments("public Model ViewModel;", "", null, false, false)] + [Arguments("", "", null, false, false)] + [Arguments("public Model ViewModel { get; set; }", "public new object ViewModel { get; set; }", "object", true, true)] + [Arguments("public Model ViewModel { get; set; }", "private new object ViewModel { get; set; }", "object", false, true)] + [Arguments("public Model ViewModel { get; set; }", "public new static object ViewModel { get; set; }", null, false, false)] + [Arguments("public Model ViewModel { get; set; }", "public new object ViewModel { set { } }", null, false, false)] + [Arguments("public Model ViewModel { get; set; }", "public new object ViewModel;", null, false, false)] + public async Task ExtractProperties_ReferencedViewModel_PreservesVisibleDeclaration( + string baseMember, + string ownMember, + string? expectedType, + bool expectedPublic, + bool expectedDeclared) + { + var reference = TestHelper.CompileToReference( + $$""" + namespace External + { + public class Model { } + public class ViewBase + { + {{baseMember}} + public string Unrelated { get; set; } + } + } + """, + "ReferencedViews", + null); + var compilation = TestHelper.CreateCompilation( + $$""" + public class View : External.ViewBase + { + {{ownMember}} + } + """, + languageVersion: null, + useReactiveRuntime: false, + assemblyName: "Consumer", + additionalReferences: [reference]); + var typeSymbol = GetNamedTypeSymbol(compilation, "View"); + + var properties = TypeDetectionExtractor.ExtractProperties(typeSymbol, default); + + await Assert.That(properties.Length).IsEqualTo(expectedType is null ? 0 : 1); + if (expectedType is null) + { + return; + } + + await Assert.That(properties[0].PropertyName).IsEqualTo("ViewModel"); + await Assert.That(properties[0].PropertyTypeFullName).IsEqualTo(expectedType); + await Assert.That(properties[0].HasPublicGetter).IsEqualTo(expectedPublic); + await Assert.That(properties[0].IsDeclaredByType).IsEqualTo(expectedDeclared); + } + + /// Preserves the inherited view model's notification companion metadata. + /// A task representing the asynchronous test operation. + [Test] + public async Task ExtractProperties_ReferencedViewModel_InspectsInheritedCompanions() + { + var reference = TestHelper.CompileToReference( + """ + public class ViewBase : System.ComponentModel.Component + { + public object ViewModel { get; set; } + public static readonly object ViewModelProperty = new object(); + public event System.EventHandler ViewModelChanged; + } + """, + "ReferencedViewCompanions", + null); + var compilation = TestHelper.CreateCompilation( + "public class View : ViewBase { }", + languageVersion: null, + useReactiveRuntime: false, + assemblyName: "Consumer", + additionalReferences: [reference]); + var typeSymbol = GetNamedTypeSymbol(compilation, "View"); + + var properties = TypeDetectionExtractor.ExtractProperties(typeSymbol, default); + + await Assert.That(properties.Length).IsEqualTo(1); + await Assert.That(properties[0].IsDependencyProperty).IsTrue(); + await Assert.That(properties[0].HasChangeEvent).IsTrue(); + await Assert.That(properties[0].SymbolsInspected).IsTrue(); + await Assert.That(properties[0].PlatformObservations.Length).IsEqualTo(1); + } + + /// Describes a write-only property without requiring a getter symbol. + /// A task representing the asynchronous test operation. + [Test] + public async Task ExtractProperty_WriteOnlyMember_HasNoPublicGetter() + { + var compilation = TestHelper.CreateCompilation("public class Model { public string Value { set { } } }"); + var typeSymbol = GetNamedTypeSymbol(compilation, "Model"); + var property = (IPropertySymbol)typeSymbol.GetMembers("Value").Single(); + + var metadata = TypeDetectionExtractor.ExtractProperty(typeSymbol, property); + + await Assert.That(metadata.HasPublicGetter).IsFalse(); + await Assert.That(metadata.SymbolsInspected).IsTrue(); + } + /// Gets a named type symbol from a compilation. /// The compilation. /// The type name. diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ReferencedViewModelReplacementRuntimeTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ReferencedViewModelReplacementRuntimeTests.cs new file mode 100644 index 00000000..c6ba2bdd --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ReferencedViewModelReplacementRuntimeTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +namespace ReactiveUI.Binding.SourceGenerators.Tests.RuntimeExecution; + +/// Executes bindings on a generic view base supplied by another assembly. +public class ReferencedViewModelReplacementRuntimeTests +{ + /// The separately compiled view assembly name. + private const string ExternalAssemblyName = "ExternalViews"; + + /// The generic view implemented in the referenced assembly. + private const string BaseSource = """ + using System.ComponentModel; + using ReactiveUI.Binding; + namespace ExternalViews + { + public class ViewBase : IViewFor, INotifyPropertyChanged where T : class + { + private T _viewModel; + public event PropertyChangedEventHandler PropertyChanged; + public T ViewModel + { + get { return _viewModel; } + set { _viewModel = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("ViewModel")); } + } + object IViewFor.ViewModel { get { return ViewModel; } set { ViewModel = (T)value; } } + } + } + """; + + /// The consumer binds once and follows every view model transition. + private const string InteractionSource = """ + using System; + using System.Threading.Tasks; + using ReactiveUI.Binding; + namespace TestApp + { + public class Model + { + public Interaction Confirm { get; } = new Interaction(); + } + public class View : ExternalViews.ViewBase { } + public static class Usage + { + public static async Task Run() + { + var view = new View(); + var first = new Model(); + var second = new Model(); + var later = new Model(); + var binding = view.BindInteraction(view.ViewModel, vm => vm.Confirm, ctx => + { + ctx.SetOutput("handled"); + return Task.CompletedTask; + }); + view.ViewModel = first; + var a = await Answer(first); + view.ViewModel = second; + var b = await Answer(first); + var c = await Answer(second); + view.ViewModel = null; + var d = await Answer(second); + view.ViewModel = later; + var e = await Answer(later); + binding.Dispose(); + var f = await Answer(later); + view.ViewModel = first; + var g = await Answer(first); + return string.Join(",", a, b, c, d, e, f, g); + } + private static async Task Answer(Model model) + { + try { return await model.Confirm.Handle("ask"); } + catch (UnhandledInteractionException) { return "unhandled"; } + } + } + } + """; + + /// Property bindings follow the referenced base's current model. + private const string PropertySource = """ + using System; + using System.ComponentModel; + using System.Threading.Tasks; + using ReactiveUI.Binding; + namespace TestApp + { + public class Model : INotifyPropertyChanged + { + private string _name; + public event PropertyChangedEventHandler PropertyChanged; + public string Name { get { return _name; } set { _name = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Name")); } } + } + public class View : ExternalViews.ViewBase + { + public string Text { get; set; } = "empty"; + } + public static class Usage + { + public static Task Run() + { + var view = new View(); + var first = new Model { Name = "first" }; + var second = new Model { Name = "second" }; + var later = new Model { Name = "later" }; + var binding = view.OneWayBind(view.ViewModel, vm => vm.Name, v => v.Text); + view.ViewModel = first; + var a = view.Text; + view.ViewModel = second; + first.Name = "stale"; + var b = view.Text; + view.ViewModel = null; + second.Name = "stale"; + view.ViewModel = later; + var c = view.Text; + binding.Dispose(); + later.Name = "disposed"; + view.ViewModel = first; + return Task.FromResult(string.Join(",", a, b, c, view.Text)); + } + } + } + """; + + /// The file-and-line dispatcher observes the inherited typed view model. + /// The asynchronous test. + [Test] + public async Task BindInteraction_ReferencedGenericViewBase_GeneratesForCSharp73() + { + var reference = TestHelper.CompileToReference(BaseSource, ExternalAssemblyName, LanguageVersion.CSharp7_3); + var result = TestHelper.RunGenerator(InteractionSource, LanguageVersion.CSharp7_3, null, false, [reference]); + await result.CompilationSucceeds(); + await result.HasNoGeneratorDiagnostics(); + await result.GeneratedSourceContains("BindInteractionDispatch.g.cs", "\"ViewModel\""); + } + + /// Moves a task handler between view models and removes it on disposal. + /// Whether the handler returns an observable. + /// Whether the binding starts with a view model. + /// Whether to use the System.Reactive runtime flavour. + /// The asynchronous test. + [Test] + [MatrixDataSource] + public async Task BindInteraction_ReferencedGenericViewBase_TracksTheFullLifecycle( + [Matrix(false, true)] bool observableHandler, + [Matrix(false, true)] bool initiallyAssigned, + [Matrix(false, true)] bool reactiveRuntime) + { + var source = observableHandler + ? InteractionSource.Replace("return Task.CompletedTask;", "return new Completed();", StringComparison.Ordinal) + .Replace( + "public class Model", + "public class Completed : IObservable, IDisposable { " + + "public IDisposable Subscribe(IObserver observer) { observer.OnNext(0); observer.OnCompleted(); return this; } " + + "public void Dispose() { } } public class Model", + StringComparison.Ordinal) + : InteractionSource; + if (initiallyAssigned) + { + source = source.Replace("var binding =", "view.ViewModel = first; var binding =", StringComparison.Ordinal); + } + + await AssertLifecycle(source, "handled,unhandled,handled,unhandled,handled,unhandled,unhandled", reactiveRuntime); + } + + /// Property bindings observe a model inherited from the referenced view base. + /// Whether to use the two-way binding API. + /// The asynchronous test. + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task PropertyBinding_ReferencedGenericViewBase_TracksTheFullLifecycle(bool twoWay) + { + var source = twoWay ? PropertySource.Replace("view.OneWayBind(", "view.Bind(", StringComparison.Ordinal) : PropertySource; + await AssertLifecycle(source, "first,second,later,later"); + } + + /// Command bindings follow the referenced base and stop invoking after disposal. + /// The asynchronous test. + [Test] + public async Task BindCommand_ReferencedGenericViewBase_TracksTheFullLifecycle() + { + const string source = """ + using System; + using System.Threading.Tasks; + using System.Windows.Input; + using ReactiveUI.Binding; + namespace TestApp + { + public class Command : ICommand + { + private readonly Action _execute; + public Command(Action execute) { _execute = execute; } + public event EventHandler CanExecuteChanged; + public bool CanExecute(object p) { return true; } + public void Execute(object p) { _execute(p); } + } + public class Model { public ICommand Save { get; set; } public string Parameter { get; set; } } + public class Button + { + public event EventHandler Click; + public void Invoke() { Click?.Invoke(this, EventArgs.Empty); } + } + public class View : ExternalViews.ViewBase { public Button SaveButton { get; } = new Button(); } + public static class Usage + { + public static Task Run() + { + var trace = ""; + var view = new View(); + var first = new Model { Save = new Command(p => trace += "first:" + p + ","), Parameter = "a" }; + var second = new Model { Save = new Command(p => trace += "second:" + p + ","), Parameter = "b" }; + var later = new Model { Save = new Command(p => trace += "later:" + p + ","), Parameter = "c" }; + var binding = view.BindCommand(view.ViewModel, vm => vm.Save, v => v.SaveButton, vm => vm.Parameter); + view.SaveButton.Invoke(); + view.ViewModel = first; + view.SaveButton.Invoke(); + view.ViewModel = second; + view.SaveButton.Invoke(); + view.ViewModel = null; + view.SaveButton.Invoke(); + view.ViewModel = later; + view.SaveButton.Invoke(); + binding.Dispose(); + view.SaveButton.Invoke(); + view.ViewModel = first; + view.SaveButton.Invoke(); + return Task.FromResult(trace); + } + } + } + """; + await AssertLifecycle(source, "first:a,second:b,later:c,"); + } + + /// Generates and executes the consumer with a separately emitted view assembly. + /// The consumer source. + /// The expected lifecycle trace. + /// Whether to use the System.Reactive runtime flavour. + /// The asynchronous assertion. + private static async Task AssertLifecycle(string source, string expected, bool reactiveRuntime = false) + { + var baseSource = BaseSource; + if (reactiveRuntime) + { + baseSource = baseSource.Replace("using ReactiveUI.Binding;", "using ReactiveUI.Binding.Reactive;", StringComparison.Ordinal); + source = source.Replace("using ReactiveUI.Binding;", "using ReactiveUI.Binding.Reactive;", StringComparison.Ordinal); + } + + var reference = reactiveRuntime + ? TestHelper.CreateCompilation(baseSource, LanguageVersion.CSharp10, true, ExternalAssemblyName, []).ToMetadataReference() + : TestHelper.CompileToReference(baseSource, ExternalAssemblyName, LanguageVersion.CSharp10); + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10, null, reactiveRuntime, [reference]); + await result.CompilationSucceeds(); + var (assembly, context) = TestHelper.EmitAndLoad(result, false, (CompilationReference)reference); + try + { + var method = assembly.GetType("TestApp.Usage")!.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!; + var outcome = await (Task)method.Invoke(null, null)!; + await Assert.That(outcome).IsEqualTo(expected); + } + finally + { + context.Unload(); + } + } +}