diff --git a/Directory.Packages.props b/Directory.Packages.props index c9af8a04..f1183d3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,8 @@ + + diff --git a/IgniteUI.Blazor.Lite.slnx b/IgniteUI.Blazor.Lite.slnx index b7807437..3452cb08 100644 --- a/IgniteUI.Blazor.Lite.slnx +++ b/IgniteUI.Blazor.Lite.slnx @@ -16,6 +16,7 @@ + diff --git a/tests/IgniteUI.Blazor.Lite.IntegrationTests/ComboDataTest.cs b/tests/IgniteUI.Blazor.Lite.IntegrationTests/ComboDataTest.cs new file mode 100644 index 00000000..0cc56367 --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.IntegrationTests/ComboDataTest.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using IgniteUI.Blazor.Lite.IntegrationTests.Infrastructure; + +namespace IgniteUI.Blazor.Lite.IntegrationTests +{ + /// + /// Combo data-binding scenarios against the WebAssembly-rendered TestBed page + /// (/combo-data). Unlike the Server-rendered component sweep, data here crosses + /// the in-process (unmarshalled) channel — the same path a production WASM app uses. + /// Assertions read the live web component's data: what the user's combo shows. + /// + [Parallelizable(ParallelScope.Self)] + public class ComboDataTest : BlazorPageTest + { + [Test] + public async Task TypedFields_TransferToClient() + { + await OpenScenarioAsync("typed-products", expectedCount: 3); + + var first = (await ClientDataAsync())[0]; + Assert.Multiple(() => + { + Assert.That(first.GetProperty("Id").GetInt32(), Is.EqualTo(1)); + Assert.That(first.GetProperty("Name").GetString(), Is.EqualTo("Chai")); + Assert.That(first.GetProperty("Price").GetDouble(), Is.EqualTo(18.5)); + Assert.That(first.GetProperty("Discontinued").GetBoolean(), Is.False); + Assert.That(first.GetProperty("UnitsSold").GetInt64(), Is.EqualTo(42_000_000_000)); + // The client holds a JS Date; compare instants so the machine's timezone drops out. + Assert.That(ClientInstant(first.GetProperty("Restocked")), Is.EqualTo(new DateTime(2026, 1, 15).ToUniversalTime())); + }); + } + + [Test] + public async Task NullableFields_PreserveNullsAndValues() + { + await OpenScenarioAsync("nullable-readings", expectedCount: 3); + + var data = await ClientDataAsync(); + var full = data[0]; + var empty = data[1]; + var mixed = data[2]; + Assert.Multiple(() => + { + Assert.That(full.GetProperty("Quantity").GetInt32(), Is.EqualTo(5)); + Assert.That(full.GetProperty("Value").GetDouble(), Is.EqualTo(2.5)); + Assert.That(full.GetProperty("Flagged").GetBoolean(), Is.True); + Assert.That(ClientInstant(full.GetProperty("MeasuredOn")), Is.EqualTo(new DateTime(2026, 3, 3).ToUniversalTime())); + + Assert.That(empty.GetProperty("Quantity").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(empty.GetProperty("Value").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(empty.GetProperty("Flagged").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(empty.GetProperty("MeasuredOn").ValueKind, Is.EqualTo(JsonValueKind.Null)); + + Assert.That(mixed.GetProperty("Quantity").GetInt32(), Is.EqualTo(7)); + Assert.That(mixed.GetProperty("Value").ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(mixed.GetProperty("Flagged").GetBoolean(), Is.False); + }); + } + + [Test] + public async Task NestedObjects_TransferNestedValues() + { + await OpenScenarioAsync("nested-orders", expectedCount: 2); + + var data = await ClientDataAsync(); + Assert.Multiple(() => + { + Assert.That(data[0].GetProperty("Reference").GetString(), Is.EqualTo("ORD-1")); + Assert.That(data[0].GetProperty("Customer").GetProperty("Name").GetString(), Is.EqualTo("Maria")); + Assert.That(data[1].GetProperty("Customer").GetProperty("City").GetString(), Is.EqualTo("Madrid")); + }); + } + + [Test] + public async Task ObjectValues_SelectionTravelsBackAsDataItems() + { + await OpenScenarioAsync("objects-as-values", expectedCount: 3); + + await Page.EvaluateAsync("() => document.querySelector('igc-combo').show()"); + // Playwright's own click fails its actionability check on the combo's + // shadow-DOM items, so click through the DOM. + await Page.WaitForFunctionAsync("() => document.querySelector('igc-combo').shadowRoot.querySelector('igc-combo-item') !== null"); + await Page.EvaluateAsync("() => document.querySelector('igc-combo').shadowRoot.querySelector('igc-combo-item').click()"); + await Page.WaitForFunctionAsync( + "() => window.clientPageRef.invokeMethodAsync('GetLastValue').then(v => v.count === 1)"); + + var last = await Page.EvaluateAsync( + "() => window.clientPageRef.invokeMethodAsync('GetLastValue')"); + Assert.Multiple(() => + { + Assert.That(last.GetProperty("names")[0].GetString(), Is.EqualTo("Chai")); + Assert.That(last.GetProperty("sameInstances").GetBoolean(), Is.True, + "received values should resolve back to the bound data instances"); + }); + } + + [Test] + [Ignore("Item types with public primitive-typed fields crash schema creation: JsonDataSourceSchema.Commit " + + "stores the typed field getters in a Func[] and throws ArrayTypeMismatchException " + + "(TypedPropertyGetters uses Delegate[]). Enable once the field getter array type is fixed.")] + public async Task NestedPublicFields_TransferToClient() + { + await OpenScenarioAsync("nested-field-shipments", expectedCount: 2); + + var data = await ClientDataAsync(); + Assert.Multiple(() => + { + Assert.That(data[0].GetProperty("Box").GetProperty("Width").GetDouble(), Is.EqualTo(2.5)); + Assert.That(data[1].GetProperty("Box").GetProperty("Height").GetDouble(), Is.EqualTo(3.0)); + }); + } + + [Test] + public async Task PrimitiveStrings_TransferAsItems() + { + await OpenScenarioAsync("primitive-strings", expectedCount: 3); + + var data = await ClientDataAsync(); + Assert.That(data[0].GetString(), Is.EqualTo("alpha")); + Assert.That(data[2].GetString(), Is.EqualTo("gamma")); + } + + [Test] + public async Task ObservableCollectionMutations_FlowToClient() + { + await OpenScenarioAsync("observable-products", expectedCount: 3); + + await MutateAsync("add"); + await WaitForItemCountAsync(4); + var data = await ClientDataAsync(); + Assert.That(data[3].GetProperty("Name").GetString(), Is.EqualTo("Added"), "added item should appear last"); + + await MutateAsync("removeFirst"); + await WaitForItemCountAsync(3); + data = await ClientDataAsync(); + Assert.That(data[0].GetProperty("Name").GetString(), Is.EqualTo("Chang"), "remaining items should shift up on remove"); + + await MutateAsync("replaceFirst"); + await Page.WaitForFunctionAsync("() => document.querySelector('igc-combo').data[0].Name === 'Replaced'"); + + await MutateAsync("clear"); + await WaitForItemCountAsync(0); + } + + [Test] + public async Task DataSwap_ReplacesClientData() + { + await OpenScenarioAsync("typed-products", expectedCount: 3); + + await Page.EvaluateAsync( + "() => window.clientPageRef.invokeMethodAsync('SetComboScenario', 'primitive-strings')"); + await Page.WaitForFunctionAsync("() => document.querySelector('igc-combo').data?.[0] === 'alpha'"); + } + + private async Task OpenScenarioAsync(string scenario, int expectedCount) + { + await Page.GotoAsync("http://localhost:5249/combo-data?scenario=" + scenario); + await Page.WaitForFunctionAsync("() => !!window.clientPageRef"); + // Prerender is off on the page, so the combo holding data means the WASM + // runtime is live and the transfer completed. + await WaitForItemCountAsync(expectedCount); + } + + private Task WaitForItemCountAsync(int count) + => Page.WaitForFunctionAsync($"() => document.querySelector('igc-combo')?.data?.length === {count}"); + + private async Task ClientDataAsync() + { + var data = await Page.EvaluateAsync("() => document.querySelector('igc-combo').data"); + Assert.That(data.ValueKind, Is.EqualTo(JsonValueKind.Array), "the combo should expose its data as an array"); + return data; + } + + private Task MutateAsync(string action) + => Page.EvaluateAsync($"() => window.clientPageRef.invokeMethodAsync('MutateComboData', '{action}')"); + + /// Parses a client-side JS Date (reported as an ISO string) to its UTC instant. + private static DateTime ClientInstant(JsonElement value) + => DateTime.Parse(value.GetString()!, null, System.Globalization.DateTimeStyles.AdjustToUniversal); + } +} diff --git a/tests/IgniteUI.Blazor.Lite.TestBed.Client/ComboDataScenarios.cs b/tests/IgniteUI.Blazor.Lite.TestBed.Client/ComboDataScenarios.cs new file mode 100644 index 00000000..0cbbd308 --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.TestBed.Client/ComboDataScenarios.cs @@ -0,0 +1,97 @@ +using System.Collections.ObjectModel; + +namespace IgniteUI.Blazor.Lite.TestBed.Client; + +/// +/// Combo data-binding scenarios shared between the WASM e2e page (ComboDataPage, +/// compiled here) and the in-process unit suite (UnmarshalledDataChannelTests, +/// which links this file), so both exercise the same data shapes. +/// +public static class ComboDataScenarios +{ + public sealed record Scenario(object Data, string? ValueKey, string? DisplayKey); + + public static Scenario Get(string name) => name switch + { + "typed-products" => new(Products(), "Id", "Name"), + "objects-as-values" => new(Products(), null, "Name"), + "observable-products" => new(new ObservableCollection(Products()), "Id", "Name"), + "nullable-readings" => new(new List + { + new() { Id = 1, Label = "full", Quantity = 5, Value = 2.5, Flagged = true, MeasuredOn = new DateTime(2026, 3, 3) }, + new() { Id = 2, Label = "empty", Quantity = null, Value = null, Flagged = null, MeasuredOn = null }, + new() { Id = 3, Label = "mixed", Quantity = 7, Value = null, Flagged = false, MeasuredOn = null }, + }, "Id", "Label"), + "nested-orders" => new(new List + { + new() { Id = 1, Reference = "ORD-1", Customer = new Customer { Name = "Maria", City = "Berlin" } }, + new() { Id = 2, Reference = "ORD-2", Customer = new Customer { Name = "Ana", City = "Madrid" } }, + }, "Id", "Reference"), + "nested-field-shipments" => new(new List + { + new() { Id = 1, Code = "SHP-1", Box = new BoxSize { Width = 2.5, Height = 1.5 } }, + new() { Id = 2, Code = "SHP-2", Box = new BoxSize { Width = 4.0, Height = 3.0 } }, + }, "Id", "Code"), + "primitive-strings" => new(new List { "alpha", "beta", "gamma" }, null, null), + _ => throw new ArgumentException($"Unknown combo data scenario \"{name}\".", nameof(name)), + }; + + public static List Products() => + [ + new() { Id = 1, Name = "Chai", Price = 18.5, Discontinued = false, UnitsSold = 42_000_000_000, Restocked = new DateTime(2026, 1, 15) }, + new() { Id = 2, Name = "Chang", Price = 19.0, Discontinued = true, UnitsSold = 7, Restocked = new DateTime(2025, 6, 30) }, + new() { Id = 3, Name = "Aniseed Syrup", Price = 10.0, Discontinued = false, UnitsSold = 1300, Restocked = new DateTime(2024, 12, 1) }, + ]; + + public static Product AddedProduct() => + new() { Id = 4, Name = "Added", Price = 4.4, UnitsSold = 4, Restocked = new DateTime(2026, 4, 4) }; + + public static Product ReplacementProduct() => + new() { Id = 99, Name = "Replaced", Price = 9.9, UnitsSold = 9, Restocked = new DateTime(2026, 9, 9) }; + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public double Price { get; set; } + public bool Discontinued { get; set; } + public long UnitsSold { get; set; } + public DateTime Restocked { get; set; } + } + + public class Reading + { + public int Id { get; set; } + public string Label { get; set; } = ""; + public int? Quantity { get; set; } + public double? Value { get; set; } + public bool? Flagged { get; set; } + public DateTime? MeasuredOn { get; set; } + } + + public class Order + { + public int Id { get; set; } + public string Reference { get; set; } = ""; + public Customer Customer { get; set; } = new(); + } + + public class Customer + { + public string Name { get; set; } = ""; + public string City { get; set; } = ""; + } + + public class Shipment + { + public int Id { get; set; } + public string Code { get; set; } = ""; + public BoxSize Box { get; set; } = new(); + } + + public class BoxSize + { + public double Width; + public double Height; + } +} diff --git a/tests/IgniteUI.Blazor.Lite.TestBed.Client/IgniteUI.Blazor.Lite.TestBed.Client.csproj b/tests/IgniteUI.Blazor.Lite.TestBed.Client/IgniteUI.Blazor.Lite.TestBed.Client.csproj new file mode 100644 index 00000000..69cbc4af --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.TestBed.Client/IgniteUI.Blazor.Lite.TestBed.Client.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + true + + + + + + + + + + + diff --git a/tests/IgniteUI.Blazor.Lite.TestBed.Client/Pages/ComboDataPage.razor b/tests/IgniteUI.Blazor.Lite.TestBed.Client/Pages/ComboDataPage.razor new file mode 100644 index 00000000..ac3d495a --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.TestBed.Client/Pages/ComboDataPage.razor @@ -0,0 +1,111 @@ +@page "/combo-data" +@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false)) +@using System.Collections.ObjectModel +@implements IDisposable +@inject IJSRuntime JS + +@* Combo data-binding scenario page, WebAssembly-rendered so data crosses the same + in-process channel a production WASM app uses (the Server-rendered sweep covers + the JSON channel). The scenario comes from the query string; tests drive further + scenarios and mutations through the JSInvokable hooks below via window.clientPageRef. + Prerender is off, so the combo existing in the DOM means the WASM runtime is live. *@ + +
+ +
+ +@code { + private object? _data; + private string? _valueKey; + private string? _displayKey; + private ObservableCollection? _observable; + private object[]? _lastValue; + private DotNetObjectReference? _selfRef; + + [SupplyParameterFromQuery(Name = "scenario")] + public string? Scenario { get; set; } + + protected override void OnParametersSet() + { + Apply(Scenario ?? "typed-products"); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _selfRef = DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("registerClientPageRef", _selfRef); + } + } + + public void Dispose() + { + _selfRef?.Dispose(); + } + + [JSInvokable] + public async Task SetComboScenario(string scenario) + { + await InvokeAsync(() => + { + Apply(scenario); + StateHasChanged(); + }); + } + + /// Reports what ValueChanged last received: the item names and whether they + /// are the same instances as in the bound data (resolved back, not copies). + [JSInvokable] + public object GetLastValue() + { + var received = _lastValue ?? Array.Empty(); + var bound = (_data as System.Collections.IEnumerable)?.Cast().ToList() ?? new List(); + return new + { + Count = received.Length, + Names = received.Select(v => (v as ComboDataScenarios.Product)?.Name ?? v?.ToString()).ToArray(), + SameInstances = received.Length > 0 && received.All(v => bound.Any(b => ReferenceEquals(b, v))), + }; + } + + private void OnValueChanged(object[] value) + { + _lastValue = value; + } + + /// Mutates the observable-products collection in place; change notifications, + /// not re-renders, carry it to the client. + [JSInvokable] + public async Task MutateComboData(string action) + { + await InvokeAsync(() => + { + var items = _observable!; + switch (action) + { + case "add": + items.Add(ComboDataScenarios.AddedProduct()); + break; + case "removeFirst": + items.RemoveAt(0); + break; + case "replaceFirst": + items[0] = ComboDataScenarios.ReplacementProduct(); + break; + case "clear": + items.Clear(); + break; + } + }); + } + + private void Apply(string scenario) + { + var s = ComboDataScenarios.Get(scenario); + _data = s.Data; + _valueKey = s.ValueKey; + _displayKey = s.DisplayKey; + _observable = s.Data as ObservableCollection; + } +} diff --git a/tests/IgniteUI.Blazor.Lite.TestBed.Client/Program.cs b/tests/IgniteUI.Blazor.Lite.TestBed.Client/Program.cs new file mode 100644 index 00000000..d19e1055 --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.TestBed.Client/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace IgniteUI.Blazor.Lite.TestBed.Client; + +public class Program +{ + public static async Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.Services.AddIgniteUIBlazor(); + await builder.Build().RunAsync(); + } +} diff --git a/tests/IgniteUI.Blazor.Lite.TestBed.Client/_Imports.razor b/tests/IgniteUI.Blazor.Lite.TestBed.Client/_Imports.razor new file mode 100644 index 00000000..1b793bb9 --- /dev/null +++ b/tests/IgniteUI.Blazor.Lite.TestBed.Client/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.JSInterop +@using IgniteUI.Blazor.Controls diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/Components/App.razor b/tests/IgniteUI.Blazor.Lite.TestBed/Components/App.razor index 4b383c68..18fcdc1c 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/Components/App.razor +++ b/tests/IgniteUI.Blazor.Lite.TestBed/Components/App.razor @@ -13,7 +13,8 @@ - + @* No global render mode: Pages declare InteractiveServer(Home) or InteractiveWebAssembly (client proj). *@ + diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Pages/Home.razor b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Pages/Home.razor index 5e07905a..40896c7a 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Pages/Home.razor +++ b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/" +@rendermode InteractiveServer @using IgniteUI.Blazor.Lite.TestBed.Components.Common @using IgniteUI.Blazor.Controls @using System.Reflection diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Routes.razor b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Routes.razor index f756e19d..27f5e67b 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/Components/Routes.razor +++ b/tests/IgniteUI.Blazor.Lite.TestBed/Components/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/IgniteUI.Blazor.Lite.TestBed.csproj b/tests/IgniteUI.Blazor.Lite.TestBed/IgniteUI.Blazor.Lite.TestBed.csproj index 3f56de75..c5682242 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/IgniteUI.Blazor.Lite.TestBed.csproj +++ b/tests/IgniteUI.Blazor.Lite.TestBed/IgniteUI.Blazor.Lite.TestBed.csproj @@ -6,10 +6,12 @@ + + diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/Program.cs b/tests/IgniteUI.Blazor.Lite.TestBed/Program.cs index a3a377cc..bad8c5bd 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/Program.cs +++ b/tests/IgniteUI.Blazor.Lite.TestBed/Program.cs @@ -7,7 +7,8 @@ public static void Main(string[] args) var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); builder.Services.AddIgniteUIBlazor(); @@ -22,8 +23,14 @@ public static void Main(string[] args) app.UseStaticFiles(); app.UseAntiforgery(); + // The WebAssembly render mode serves the _framework boot assets through mapped + // static asset endpoints; UseStaticFiles alone does not cover them. + app.MapStaticAssets(); + app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(IgniteUI.Blazor.Lite.TestBed.Client.Program).Assembly); app.Run(); } diff --git a/tests/IgniteUI.Blazor.Lite.TestBed/wwwroot/app.js b/tests/IgniteUI.Blazor.Lite.TestBed/wwwroot/app.js index 62c537a2..4abd18e4 100644 --- a/tests/IgniteUI.Blazor.Lite.TestBed/wwwroot/app.js +++ b/tests/IgniteUI.Blazor.Lite.TestBed/wwwroot/app.js @@ -12,6 +12,13 @@ function onAfterRender() { console.log('App Loaded.'); } +/** WASM client pages pass a `DotNetObjectReference` here for tests to invoke scenario methods; + * `DotNet.invokeMethodAsync` throws if there's a second .NET runtime (e.g. an InteractiveServer island, like HeadOutlet). + */ +function registerClientPageRef(pageRef) { + window.clientPageRef = pageRef; +} + async function setSelector(componentSelector) { window.targetName = componentSelector; } diff --git a/tests/IgniteUI.Blazor.Tests/IgniteUI.Blazor.Tests.csproj b/tests/IgniteUI.Blazor.Tests/IgniteUI.Blazor.Tests.csproj index 1a7594f7..e63b7884 100644 --- a/tests/IgniteUI.Blazor.Tests/IgniteUI.Blazor.Tests.csproj +++ b/tests/IgniteUI.Blazor.Tests/IgniteUI.Blazor.Tests.csproj @@ -24,4 +24,9 @@ + + + + + diff --git a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs index c0a5a1dd..23e3806a 100644 --- a/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs +++ b/tests/IgniteUI.Blazor.Tests/Interop/RendererMessageInteropHarness.cs @@ -25,14 +25,29 @@ public sealed class RendererMessageInteropHarness : InteropHarness private readonly System.Collections.Concurrent.ConcurrentDictionary _stubbedMethods = new(StringComparer.Ordinal); private readonly Dictionary> _methodHandlers = new(StringComparer.Ordinal); + /// + /// Default: forces the JSON data-source channel (as on Blazor Server), keeping data + /// transfers observable as refChanged messages. + /// public RendererMessageInteropHarness(BunitJSInterop js) + : this(js, forceJsonDataMarshalling: true) + { + } + + /// + /// With false, drives the in-process + /// (unmarshalled) data channel instead: the service's runtime carries an + /// InvokeUnmarshalled method that RuntimeHelper discovers by reflection, so + /// DataSourceManager picks UnmarshalledDataSource and the column messages are + /// recorded in instead of crossing to JS. + /// + public RendererMessageInteropHarness(BunitJSInterop js, bool forceJsonDataMarshalling) { _js = js; - // Force the JSON data-source channel (as on Blazor Server): bUnit's runtime is - // in-process but has no WASM InvokeUnmarshalled support, so the unmarshalled - // data channel is unreachable here; JSON marshalling keeps data transfers - // observable as refChanged messages. - _service = new IgniteUIBlazor(js.JSRuntime, IgniteUIBlazorSettings.Create().WithForceJsonDataMarshalling(true)); + var runtime = forceJsonDataMarshalling + ? js.JSRuntime + : new UnmarshalledRecordingRuntime(js.JSRuntime, RecordUnmarshalledMessage); + _service = new IgniteUIBlazor(runtime, IgniteUIBlazorSettings.Create().WithForceJsonDataMarshalling(forceJsonDataMarshalling)); // Answer every message with an "undefined" return envelope by default — // an unanswered invokeMethod would otherwise await its return forever. @@ -42,6 +57,79 @@ public RendererMessageInteropHarness(BunitJSInterop js) .SetResult(ToResultPayload(InteropReturn.Undefined)); } + /// A data-source column transfer observed on the unmarshalled channel. + internal sealed record UnmarshalledColumnMessage(string MethodName, string RefName, int Index, UnmarshalledColumn[]? Columns); + + // Written by the channel on background flush threads, read on the test thread. + private readonly List _unmarshalledMessages = new(); + + internal IReadOnlyList UnmarshalledColumnMessages + { + get { lock (_unmarshalledMessages) { return _unmarshalledMessages.ToList(); } } + } + + /// Retries briefly — column messages flush on an async queue tick. + internal UnmarshalledColumnMessage? WaitForUnmarshalledMessage(Func match) + { + for (var attempt = 0; attempt < 80; attempt++) + { + var message = UnmarshalledColumnMessages.LastOrDefault(match); + if (message is not null) + { + return message; + } + Thread.Sleep(25); + } + return null; + } + + private void RecordUnmarshalledMessage(string methodName, string refName, int index, UnmarshalledColumn[]? columns) + { + lock (_unmarshalledMessages) + { + _unmarshalledMessages.Add(new UnmarshalledColumnMessage(methodName, refName, index, columns)); + } + } + + /// + /// In-process runtime whose InvokeUnmarshalled methods RuntimeHelper discovers by + /// name-based reflection — the seam replacing the API modern runtimes removed. + /// Everything else delegates to bUnit's runtime. + /// + private sealed class UnmarshalledRecordingRuntime : Microsoft.JSInterop.IJSInProcessRuntime + { + private readonly Microsoft.JSInterop.IJSInProcessRuntime _inner; + private readonly Action _record; + + public UnmarshalledRecordingRuntime(Microsoft.JSInterop.IJSRuntime inner, Action record) + { + _inner = (Microsoft.JSInterop.IJSInProcessRuntime)inner; + _record = record; + } + + public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1, T2 arg2) + { + _record(identifier, (string)(object)arg0!, (int)(object)arg1!, (UnmarshalledColumn[]?)(object?)arg2); + return default!; + } + + public TResult InvokeUnmarshalled(string identifier, T0 arg0, T1 arg1) + { + // Data-intents variant; recorded with no columns. + _record(identifier, (string)(object)arg0!, -1, null); + return default!; + } + + public TResult Invoke(string identifier, params object?[]? args) + => _inner.Invoke(identifier, args); + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => _inner.InvokeAsync(identifier, args); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => _inner.InvokeAsync(identifier, cancellationToken, args); + } + public override IIgniteUIBlazor Service => _service; public override void ConfigureServices(IServiceCollection services) => diff --git a/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs new file mode 100644 index 00000000..f57933ff --- /dev/null +++ b/tests/IgniteUI.Blazor.Tests/UnmarshalledDataChannelTests.cs @@ -0,0 +1,148 @@ +using System.Collections.ObjectModel; +using Bunit; +using IgniteUI.Blazor.Controls; +using IgniteUI.Blazor.Lite.TestBed.Client; +using IgniteUI.Blazor.Tests.Interop; +using static IgniteUI.Blazor.Tests.Interop.RendererMessageInteropHarness; + +namespace IgniteUI.Blazor.Tests; + +/// +/// The in-process (unmarshalled) data channel, driven through — +/// the unit twin of the browser-based ComboDataTest integration suite, sharing its +/// scenarios (). Assertions read the +/// messages the channel emits; the pointer transport and +/// the JS-side reader remain integration-only. +/// +public class UnmarshalledDataChannelTests : BunitContext +{ + private readonly RendererMessageInteropHarness _interop; + + public UnmarshalledDataChannelTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + _interop = new RendererMessageInteropHarness(JSInterop, forceJsonDataMarshalling: false); + _interop.ConfigureServices(Services); + } + + [Fact] + public void TypedFields_TransferAsTypedColumns() + { + var create = RenderScenario("typed-products"); + + Assert.Equal(3, Column(create, "Name").ActualCount); + Assert.Equal(["Chai", "Chang", "Aniseed Syrup"], Column(create, "Name").StringValues.Take(3)); + Assert.Equal([18.5, 19.0, 10.0], Column(create, "Price").DoubleValues.Take(3)); + Assert.Equal([0, 1, 0], Column(create, "Discontinued").IntValues.Take(3)); + Assert.Equal([42_000_000_000, 7, 1300], Column(create, "UnitsSold").LongValues.Take(3)); + Assert.StartsWith("2026-01-15T00:00:00", Column(create, "Restocked").StringValues[0]); + Assert.True(Guid.TryParse(Column(create, "___id").StringValues[0], out _), "items should carry a uuid ___id column"); + } + + [Fact] + public void NullableFields_TransferValuesWithNullFlags() + { + var create = RenderScenario("nullable-readings"); + + var quantity = Column(create, "Quantity"); + Assert.Equal([false, true, false], quantity.NullValues.Take(3)); + Assert.Equal(5, quantity.IntValues[0]); + Assert.Equal(7, quantity.IntValues[2]); + + var value = Column(create, "Value"); + Assert.Equal([false, true, true], value.NullValues.Take(3)); + Assert.Equal(2.5, value.DoubleValues[0]); + + var flagged = Column(create, "Flagged"); + Assert.Equal([false, true, false], flagged.NullValues.Take(3)); + Assert.Equal(1, flagged.IntValues[0]); + Assert.Equal(0, flagged.IntValues[2]); + + var measuredOn = Column(create, "MeasuredOn"); + Assert.StartsWith("2026-03-03T00:00:00", measuredOn.StringValues[0]); + Assert.Null(measuredOn.StringValues[1]); + } + + [Fact] + public void NestedObjects_TransferAsDottedPathColumns() + { + var create = RenderScenario("nested-orders"); + + Assert.Equal(["ORD-1", "ORD-2"], Column(create, "Reference").StringValues.Take(2)); + Assert.Equal(["Maria", "Ana"], Column(create, "Customer.Name").StringValues.Take(2)); + Assert.Equal(["Berlin", "Madrid"], Column(create, "Customer.City").StringValues.Take(2)); + } + + [Fact] + public void PrimitiveStrings_TransferAsPrimitiveColumn() + { + var create = RenderScenario("primitive-strings"); + + var values = Column(create, "___primitiveValueCollection"); + Assert.Equal(3, values.ActualCount); + Assert.Equal(["alpha", "beta", "gamma"], values.StringValues.Take(3)); + } + + [Fact(Skip = "Item types with public primitive-typed fields crash schema creation: JsonDataSourceSchema.Commit " + + "stores the typed field getters in a Func[] and throws ArrayTypeMismatchException " + + "(TypedPropertyGetters uses Delegate[]). Enable once the field getter array type is fixed.")] + public void NestedPublicFields_TransferAsColumns() + { + var create = RenderScenario("nested-field-shipments"); + + Assert.Equal([2.5, 4.0], Column(create, "Box.Width").DoubleValues.Take(2)); + Assert.Equal([1.5, 3.0], Column(create, "Box.Height").DoubleValues.Take(2)); + } + + [Fact] + public void ObservableCollectionMutations_EmitChannelMessages() + { + var scenario = ComboDataScenarios.Get("observable-products"); + var items = (ObservableCollection)scenario.Data; + RenderScenario(scenario); + + items.Add(ComboDataScenarios.AddedProduct()); + var insert = WaitFor("igUnmarshalledDataSourceInsert"); + Assert.Equal(3, insert.Index); + Assert.Equal(4, Column(insert, "Name").ActualCount); + Assert.Equal("Added", Column(insert, "Name").StringValues[3]); + + items.RemoveAt(0); + var remove = WaitFor("igUnmarshalledDataSourceRemove"); + Assert.Equal(0, remove.Index); + Assert.Equal(3, Column(remove, "Name").ActualCount); + Assert.Equal("Chang", Column(remove, "Name").StringValues[0]); + + // A replace crosses as remove + insert at the same index, not as an update message. + items[0] = ComboDataScenarios.ReplacementProduct(); + var replaceInsert = WaitFor("igUnmarshalledDataSourceInsert", + m => m.Index == 0 && Column(m, "Name").StringValues[0] == "Replaced"); + Assert.Equal(3, Column(replaceInsert, "Name").ActualCount); + + // Clear resets the source's columns; the message itself carries none. + items.Clear(); + var clear = WaitFor("igUnmarshalledDataSourceClear"); + Assert.Empty(clear.Columns!); + } + + private UnmarshalledColumnMessage RenderScenario(string scenario) => + RenderScenario(ComboDataScenarios.Get(scenario)); + + private UnmarshalledColumnMessage RenderScenario(ComboDataScenarios.Scenario scenario) + { + _interop.PrimeReady(); + Render>(ps => ps + .Add(c => c.Data, scenario.Data) + .Add(c => c.ValueKey, scenario.ValueKey) + .Add(c => c.DisplayKey, scenario.DisplayKey)); + _interop.MakeReady(); + return WaitFor("igUnmarshalledDataSourceCreate"); + } + + private UnmarshalledColumnMessage WaitFor(string methodName, Func? match = null) => + _interop.WaitForUnmarshalledMessage(m => m.MethodName == methodName && m.Columns is not null && (match?.Invoke(m) ?? true)) + ?? throw new Xunit.Sdk.XunitException($"No \"{methodName}\" column message arrived on the unmarshalled channel."); + + private static UnmarshalledColumn Column(UnmarshalledColumnMessage message, string propertyPath) => + Assert.Single(message.Columns!, c => c.PropertyPath == propertyPath); +}