@@ -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