Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 4 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@

<!-- MTP Native JSON Configuration -->
<ItemGroup Condition="'$(IsTestProject)' == 'true'">
<!-- Keep ordinary tests independent of the host locale; culture regressions override this per test. -->
<AssemblyAttribute Include="TUnit.Core.Executors.CultureAttribute">
<_Parameter1>en-US</_Parameter1>
</AssemblyAttribute>
<None Include="$(MSBuildThisFileDirectory)testconfig.json">
<Link>$(AssemblyName).testconfig.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ internal static EquatableArray<ObservablePropertyInfo> 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]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,41 @@ public static class BindInteractionExamples
/// <summary>The answer of the handler registered last.</summary>
private const string LatestAnswer = "latest";

/// <summary>Binds before the view has a view model and moves the handler when the selected board changes.</summary>
/// <returns>A task that completes when both boards have answered.</returns>
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<Issue, bool>)
{
Console.WriteLine("The previous board has no handler.");
}

// Output:
// True
// True
// The previous board has no handler.
}

/// <summary>Answers the close confirmation through a task-based handler that waits for the user, so the close stays pending until the dialog is answered.</summary>
/// <returns>A task that completes when the issue is closed.</returns>
public static async Task ConfirmCloseWithTaskHandler()
Expand Down
2 changes: 2 additions & 0 deletions src/examples/Documentation/Pages/bindings/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@

await BindInteractionExamples.ConfirmCloseWithTaskHandler();

await BindInteractionExamples.FollowTheSelectedIssueBoard();

await BindInteractionExamples.ConfirmDropWithObservableHandler();

await BindInteractionExamples.ConfirmCloseThroughInterfaceProperty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,13 +381,14 @@ public static GeneratorTestResult RunGeneratorBeside(
/// <summary>Loads generated bindings with optionally isolated converter registrations.</summary>
/// <param name="result">The generated consumer compilation.</param>
/// <param name="isolateBindingRuntime">Whether the binding runtime has private static state.</param>
/// <param name="dependencies">Referenced compilations to emit into the consumer's context.</param>
/// <returns>The consumer assembly and its collectible context.</returns>
/// <exception cref="InvalidOperationException">The consumer could not be emitted.</exception>
[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);

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,122 @@ public void Dispose() { }
await Assert.That(info.InheritsWinUIDependencyObject).IsEqualTo(expected);
}

/// <summary>Captures only the inherited view model needed to follow a view across assembly boundaries.</summary>
/// <param name="baseMember">The view model declaration in the referenced base.</param>
/// <param name="ownMember">The declaration that may hide the base member.</param>
/// <param name="expectedType">The captured property type, or null when there is no readable property.</param>
/// <param name="expectedPublic">Whether its getter is public.</param>
/// <param name="expectedDeclared">Whether the consumer declares the property.</param>
/// <returns>A task representing the asynchronous test operation.</returns>
[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);
}

/// <summary>Preserves the inherited view model's notification companion metadata.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[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);
}

/// <summary>Describes a write-only property without requiring a getter symbol.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[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();
}

/// <summary>Gets a named type symbol from a compilation.</summary>
/// <param name="compilation">The compilation.</param>
/// <param name="typeName">The type name.</param>
Expand Down
Loading
Loading