Skip to content

Commit def2f2c

Browse files
committed
tests: interop contract enable unbind checks for event/bind
1 parent dce0a8e commit def2f2c

3 files changed

Lines changed: 150 additions & 49 deletions

File tree

skills/igniteui-blazor-lite-testing/references/interop-contracts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ A `[Parameter] TValue X` plus `EventCallback<TValue> XChanged` is what `@bind-X`
114114
argsJson: """{"detail": true}""", expect: true)
115115
```
116116

117-
One dispatch pins: the driving event's registration crossed, the callback member kept the callback, the binding received the decoded value, and the property adopted it. Payload is the same shape and can be reused from the driving event's own `.Event` spec.
117+
One dispatch pins: the driving event's registration crossed, the callback member kept the callback, the binding received the decoded value, and the property adopted it. Clearing the parameter then has to stop the callback. Payload is the same shape and can be reused from the driving event's own `.Event` spec.
118118

119119
- `expect:` is the value the dispatch must produce, often a projection of the detail — a checkbox detail is an object, the bound value is its `checked` field. The runner checks the property doesn't already hold it, so a spec can't pass without the change happening. For a type with no value equality add `assert:` and check field-wise.
120120
- `arrange:` when the dispatch needs more — Calendar's `Selection` for the `Values` branch, Combo's `Data`. Order the named arguments arrange → argsJson → expect, so a spec reads arrange/act/assert.

tests/IgniteUI.Blazor.Tests/ComponentWithContractTestBase.cs

Lines changed: 87 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -399,21 +399,36 @@ void Sink(object? value)
399399
$"{bind.PropertyName} did not adopt the value pushed to the binding — {mismatch.Message}");
400400
}
401401

402-
// TODO: unbinding is blocked by the same defect as an event callback's, so one fix
403-
// covers both — clearing the parameter arrives as a callback with a Receiver and a
404-
// null Delegate, which fails the setter's Empty check, takes the *bound* branch, and
405-
// NREs in BaseRendererControl.CompareEventCallbacks on leftDelegate.Equals(...).
406-
// What to assert differs from an event's, though: a bind member's empty branch only
407-
// nulls its field (no SetHandler(null), no OnRefChanged), so the driving event stays
408-
// registered and the property keeps adopting client changes — only the callback
409-
// stops. Needs BindPair to accept a null sink, as EventContractSpec.Bind already
410-
// does. Then:
411-
// received = null;
412-
// cut.Render(ps => bind.BindPair(ps, null));
413-
// Assert.False(bind.ChangedIsBound(cut.Instance)); // member resets to Empty
414-
// harness.RaiseEvent(containerId, bind.DrivingEvent, bind.ArgsJson.Get(harness, cut));
415-
// Assert.Null(received); // the callback stops firing
416-
// Assert.NotNull(bind.ReadProperty(cut.Instance)); // but the property still tracks
402+
// Unbinding stops the callback. Either callback carrying no handler must clear it,
403+
// so both are verified.
404+
// Unlike an event member, a bind member's empty branch only nulls its own field,
405+
// so the driving event stays subscribed and the property keeps adopting client changes.
406+
// TODO: Evaluate design, since EnsureXHandled forced that registration and never withdraws it.
407+
VerifyUnbind(useEmpty: false);
408+
Rebind();
409+
VerifyUnbind(useEmpty: true);
410+
411+
void VerifyUnbind(bool useEmpty)
412+
{
413+
var assigned = useEmpty ? "EventCallback<T>.Empty" : "default";
414+
415+
cut.Render(ps => bind.BindPair(ps, null, useEmpty));
416+
Assert.False(
417+
bind.ChangedIsBound(cut.Instance),
418+
$"{bind.PropertyName}Changed still reports a bound callback after being cleared with {assigned}");
419+
420+
delivered = false;
421+
harness.RaiseEvent(containerId, bind.DrivingEvent, bind.ArgsJson.Get(harness, cut));
422+
Assert.False(delivered, $"{bind.PropertyName}Changed fired after being unbound with {assigned}");
423+
}
424+
425+
void Rebind()
426+
{
427+
cut.Render(ps => bind.BindPair(ps, Sink));
428+
Assert.True(
429+
bind.ChangedIsBound(cut.Instance),
430+
$"{bind.PropertyName}Changed did not take the callback again after being cleared");
431+
}
417432
}
418433
catch (Exception ex) when (ex is not ContractViolationException)
419434
{
@@ -454,10 +469,11 @@ protected void VerifyEventContract()
454469
{
455470
object? received = null;
456471
object bound = null!;
472+
void Sink(object args) => received = args;
457473
var cut = Render<TComponent>(ps =>
458474
{
459475
evt.Arrange?.Invoke(ps);
460-
bound = evt.Bind(ps, args => received = args);
476+
bound = evt.Bind(ps, Sink);
461477
});
462478
var containerId = harness.ContainerIdOf(cut);
463479

@@ -487,21 +503,61 @@ protected void VerifyEventContract()
487503
evt.AssertWithComponent?.Invoke(cut.Instance, received);
488504
evt.AssertWithCut?.Invoke(cut, received);
489505

490-
// TODO: removing a bound callback crashes today, on either path. Razor-bound
491-
// values arrive wrapped by EventCallback.Factory.Create, so a "default"
492-
// carries a Receiver with a null Delegate — that fails the setter's Empty
493-
// check, takes the *bound* branch, and NREs in
494-
// BaseRendererControl.CompareEventCallbacks on leftDelegate.Equals(...);
495-
// a raw Empty (what bUnit/programmatic SetParameters passes) does reach the
496-
// unset branch and NREs in OnRefChanged on newValue.ToString(). Once fixed,
497-
// validate the removal round-trip; pin the actual cleared wire shape then:
498-
// cut.Render(ps => bound = evt.Bind(ps, null)); // bUnit 2.x: no SetParametersAndRender
499-
// Assert.Equal(bound, evt.Get(cut.Instance)); // member resets to the empty callback
500-
// var cleared = harness.FindPropertyUpdate(containerId, wireName);
501-
// Assert.Equal(JsonValueKind.Null, cleared!.Value.ValueKind);
502-
// received = null;
503-
// harness.RaiseEvent(containerId, evt.EventName, argsJson);
504-
// Assert.Null(received); // deregistered handlers must not be invoked
506+
// Re-binding an equivalent callback must not read as a new subscription — Blazor hands over a
507+
// fresh delegate for the same handler every render. Read through the member (a setter assigns
508+
// and registers in one branch) since probing the wire for an absent message costs a 2s retry.
509+
// Only bites on net8.0/net9.0, whose EventCallback.Equals compares the delegate by reference.
510+
cut.Render(ps => evt.Bind(ps, Sink));
511+
Assert.Equal(bound, evt.Get(cut.Instance));
512+
513+
// Unbinding is the other half of the registration contract: clearing the parameter
514+
// must reset the member, tell the client to unsubscribe, and stop delivery. Either
515+
// callback carrying no handler must clear it, so both are verified.
516+
VerifyUnbind(useEmpty: false);
517+
Rebind();
518+
VerifyUnbind(useEmpty: true);
519+
520+
void VerifyUnbind(bool useEmpty)
521+
{
522+
var assigned = useEmpty ? "EventCallback<T>.Empty" : "default";
523+
524+
// Forget the previous phase's traffic, so what follows is the client state
525+
harness.ClearObserved();
526+
cut.Render(ps => evt.Bind(ps, null, useEmpty));
527+
528+
// Asserted through the member rather than against the assigned value: clearing with
529+
// default reads back as Empty, since the getter substitutes it for a null field.
530+
Assert.False(
531+
evt.IsBound(cut.Instance),
532+
$"clearing \"{evt.EventName}\" with {assigned} left the member holding a live callback");
533+
534+
// TODO: active @bind-X may still need the sub; fix will make this check conditional.
535+
var cleared = harness.FindPropertyUpdate(containerId, wireName);
536+
if (cleared is null || cleared.Value.ValueKind != JsonValueKind.Null)
537+
{
538+
throw new XunitException(
539+
$"clearing \"{evt.EventName}\" with {assigned} left it registered as " +
540+
$"{cleared?.ToString() ?? "nothing"} — the client would keep reporting an " +
541+
"event nobody handles");
542+
}
543+
544+
received = null;
545+
harness.RaiseEvent(containerId, evt.EventName, argsJson);
546+
Assert.Null(received);
547+
}
548+
549+
void Rebind()
550+
{
551+
harness.ClearObserved();
552+
cut.Render(ps => evt.Bind(ps, Sink));
553+
var rebound = harness.FindPropertyUpdate(containerId, wireName);
554+
if (rebound is null || rebound.Value.GetString() != evt.EventName)
555+
{
556+
throw new XunitException(
557+
$"re-binding \"{evt.EventName}\" transmitted {rebound?.ToString() ?? "no registration"} — " +
558+
"the client would never resubscribe");
559+
}
560+
}
505561
}
506562
catch (Exception ex) when (ex is not ContractViolationException)
507563
{

tests/IgniteUI.Blazor.Tests/Interop/ComponentContract.cs

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,28 @@ public sealed class EventContractSpec<TComponent> where TComponent : IComponent
155155

156156
/// <summary>
157157
/// Sets the event parameter and returns the boxed <see cref="EventCallback{TValue}"/> it
158-
/// assigned, so the runner can assert the member round-trips that exact value: with a
159-
/// sink, a callback forwarding received args to it; with null, an empty callback (the
160-
/// removal round-trip — bUnit has no parameter removal, unbinding IS an add).
158+
/// assigned: with a sink, a callback forwarding received args to it; with a null sink, one of the
159+
/// two callbacks that carry no handler — <c>default</c>, or <see cref="EventCallback{TValue}.Empty"/>
160+
/// when <paramref name="useEmpty"/> is set. (bUnit has no parameter removal, so unbinding IS an add.)
161161
/// </summary>
162-
public required Func<ComponentParameterCollectionBuilder<TComponent>, Action<object>?, object> Bind { get; init; }
162+
public delegate object BindEvent(
163+
ComponentParameterCollectionBuilder<TComponent> ps,
164+
Action<object>? sink,
165+
bool useEmpty = false);
166+
167+
/// <inheritdoc cref="BindEvent"/>
168+
public required BindEvent Bind { get; init; }
163169

164170
/// <summary>Reads the event member back (boxed) — the typed read half of <see cref="Bind"/>.</summary>
165171
public required Func<TComponent, object> Get { get; init; }
166172

173+
/// <summary>
174+
/// Whether the member holds a live subscription. What was assigned and what reads back differ
175+
/// when clearing with <c>default</c> — the getter substitutes <c>Empty</c> for its null backing
176+
/// field — so unbinding is asserted through this rather than against the assigned value.
177+
/// </summary>
178+
public required Func<TComponent, bool> IsBound { get; init; }
179+
167180
/// <summary>The declared event args type; the runner asserts the received args are assignable to it.</summary>
168181
public required Type ArgsType { get; init; }
169182
/// <summary>The dispatched payload, settled against the render when the spec declared it late.</summary>
@@ -198,9 +211,16 @@ public sealed class BindContractSpec<TComponent> where TComponent : IComponent
198211
/// <summary>
199212
/// Arranges the render exactly as <c>@bind-X</c> would (bUnit's <c>ps.Bind</c>), routing the
200213
/// pushed value to the sink so the spec asserts the user-facing contract rather than the
201-
/// callback member. Returns nothing — the sink is the observation.
214+
/// callback member. Returns nothing — the sink is the observation. A null sink clears the
215+
/// callback instead, as <c>default</c> or as <c>Empty</c> when <paramref name="useEmpty"/> is set.
202216
/// </summary>
203-
public required Action<ComponentParameterCollectionBuilder<TComponent>, Action<object?>> BindPair { get; init; }
217+
public delegate void BindPairSetup(
218+
ComponentParameterCollectionBuilder<TComponent> ps,
219+
Action<object?>? sink,
220+
bool useEmpty = false);
221+
222+
/// <inheritdoc cref="BindPairSetup"/>
223+
public required BindPairSetup BindPair { get; init; }
204224

205225
/// <summary>Reads the bound property back off the component, to assert it was updated.</summary>
206226
public required Func<TComponent, object?> ReadProperty { get; init; }
@@ -685,8 +705,9 @@ public ComponentContract<TComponent> Event(
685705
_events.Add(new EventContractSpec<TComponent>
686706
{
687707
EventName = name ?? MemberName(member),
688-
Bind = (ps, on) => BindMember(ps, member, on),
708+
Bind = (ps, on, useEmpty) => BindMember(ps, member, on, useEmpty),
689709
Get = GetterOf(member),
710+
IsBound = IsBoundOf(member),
690711
ArgsType = typeof(IgbVoidEventArgs),
691712
Source = new SpecSource(atFile, atLine),
692713
});
@@ -744,9 +765,19 @@ public ComponentContract<TComponent> Bind<TValue, TArgs>(
744765
DrivingEvent = MemberName(via),
745766
// Exactly what @bind-X expands to, via bUnit's own two-way binding helper. The
746767
// wrappers have no <Prop>Expression parameter, so the value expression is omitted.
747-
BindPair = (ps, sink) => ps.Bind(property, initial, value => sink(value)),
768+
BindPair = (ps, sink, useEmpty) =>
769+
{
770+
if (sink is null)
771+
{
772+
ps.Add(changed, useEmpty ? EventCallback<TValue>.Empty : default);
773+
}
774+
else
775+
{
776+
ps.Bind(property, initial, value => sink(value));
777+
}
778+
},
748779
ReadProperty = c => readProp(c),
749-
ChangedIsBound = c => !EventCallback<TValue>.Empty.Equals(readChanged(c)),
780+
ChangedIsBound = c => readChanged(c).HasHandler(),
750781
ArgsJson = argsJson,
751782
Expected = expect,
752783
AssertValue = assert is null ? null : o => assert((TValue)o!),
@@ -778,8 +809,9 @@ public ComponentContract<TComponent> Event<TArgs>(
778809
_events.Add(new EventContractSpec<TComponent>
779810
{
780811
EventName = name ?? MemberName(member),
781-
Bind = (ps, on) => BindMember(ps, member, on),
812+
Bind = (ps, on, useEmpty) => BindMember(ps, member, on, useEmpty),
782813
Get = GetterOf(member),
814+
IsBound = IsBoundOf(member),
783815
ArgsType = typeof(TArgs),
784816
ArgsJson = argsJson,
785817
AssertArgs = assert is null ? null : o => assert((TArgs)o),
@@ -803,8 +835,9 @@ public ComponentContract<TComponent> Event<TArgs>(
803835
_events.Add(new EventContractSpec<TComponent>
804836
{
805837
EventName = MemberName(member),
806-
Bind = (ps, on) => BindMember(ps, member, on),
838+
Bind = (ps, on, useEmpty) => BindMember(ps, member, on, useEmpty),
807839
Get = GetterOf(member),
840+
IsBound = IsBoundOf(member),
808841
ArgsType = typeof(TArgs),
809842
ArgsJson = argsJson,
810843
AssertWithComponent = (c, o) => assert(c, (TArgs)o),
@@ -831,8 +864,9 @@ public ComponentContract<TComponent> Event<TArgs>(
831864
_events.Add(new EventContractSpec<TComponent>
832865
{
833866
EventName = MemberName(member),
834-
Bind = (ps, on) => BindMember(ps, member, on),
867+
Bind = (ps, on, useEmpty) => BindMember(ps, member, on, useEmpty),
835868
Get = GetterOf(member),
869+
IsBound = IsBoundOf(member),
836870
ArgsType = typeof(TArgs),
837871
Arrange = arrange,
838872
ArgsJson = argsJson,
@@ -851,16 +885,20 @@ private static string MemberName<TArgs>(Expression<Func<TComponent, EventCallbac
851885
MemberOf(member).Name;
852886

853887
/// <summary>
854-
/// The write half of an event spec's bind/read loop, captured here where the args type
855-
/// is known: assigns the parameter (an empty callback when <paramref name="sink"/> is
856-
/// null) and returns the exact boxed callback for the runner's round-trip assert.
888+
/// The write half of an event spec's bind/read loop, captured here where the args type is known.
889+
/// Assigns the parameter and returns exactly what it assigned. A null <paramref name="sink"/>
890+
/// clears it, as either of the two callbacks that carry no handler — <c>default</c>, or
891+
/// <see cref="EventCallback{TValue}.Empty"/> when <paramref name="useEmpty"/> is set.
857892
/// </summary>
858893
private static EventCallback<TArgs> BindMember<TArgs>(
859894
ComponentParameterCollectionBuilder<TComponent> ps,
860895
Expression<Func<TComponent, EventCallback<TArgs>>> member,
861-
Action<object>? sink)
896+
Action<object>? sink,
897+
bool useEmpty = false)
862898
{
863-
var callback = sink is null ? default : new EventCallback<TArgs>(null, sink);
899+
var callback = sink is null
900+
? (useEmpty ? EventCallback<TArgs>.Empty : default)
901+
: new EventCallback<TArgs>(null, sink);
864902
ps.Add(member, callback);
865903
return callback;
866904
}
@@ -872,6 +910,13 @@ private static Func<TComponent, object> GetterOf<TArgs>(Expression<Func<TCompone
872910
return c => get(c);
873911
}
874912

913+
/// <summary>Reads whether the member holds a live subscription (see <see cref="EventContractSpec{TComponent}.IsBound"/>).</summary>
914+
private static Func<TComponent, bool> IsBoundOf<TArgs>(Expression<Func<TComponent, EventCallback<TArgs>>> member)
915+
{
916+
var get = member.Compile();
917+
return c => get(c).HasHandler();
918+
}
919+
875920
/// <summary>Derives the wire return kind from the .NET return type; exotic shapes use the InteropReturn overloads.</summary>
876921
private static InteropReturn StubFor<TResult>(TResult value) => value switch
877922
{

0 commit comments

Comments
 (0)