Skip to content

Commit 3b35cf8

Browse files
authored
feat: add nullability filters and assertions for events (#342)
Add a `WhichAreNullable` filter and `IsNullable` / `AreNullable` assertions (plus negated variants) for `EventInfo`, matching what already exists for fields and properties.
1 parent 475b693 commit 3b35cf8

23 files changed

Lines changed: 1315 additions & 21 deletions

Docs/pages/02-filters.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,8 @@ properties (including inherited ones) have no setter or an `init`-only setter. S
181181
immutability. Failure messages list the offending mutable members for actionable feedback.
182182

183183
`OnlyHasNullableMembers` / `OnlyHaveNullableMembers` (and the non-nullable counterparts) verify the
184-
[nullability](#nullability) of all declared fields and properties of the type; the failure message lists the
185-
non-compliant members per type:
184+
[nullability](#nullability) of all declared fields, properties and events of the type; the failure message
185+
lists the non-compliant members per type:
186186

187187
```csharp
188188
await Expect.That(In.AssemblyContaining<MyRequest>()
@@ -425,7 +425,8 @@ and `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotConstant()`, `IsNot
425425

426426
A property or field counts as *nullable* when its type is a `Nullable<T>` value type (e.g. `int?`) or a
427427
reference type annotated as nullable (e.g. `string?`, based on the nullable reference type metadata emitted
428-
by the compiler). The check follows the declared annotation on every target framework: reference types
428+
by the compiler). The same applies to [events](#events), whose handler type is always a delegate (reference)
429+
type. The check follows the declared annotation on every target framework: reference types
429430
without nullability annotations (oblivious code compiled without `<Nullable>enable</Nullable>`) and
430431
unconstrained generic type parameters (`T`, as opposed to `T?`) count as non-nullable, and post-condition
431432
attributes like `[AllowNull]` or `[MaybeNull]` are ignored.
@@ -470,15 +471,20 @@ In addition to [access modifiers](#access-modifiers),
470471
| static | `.WhichAreStatic()` | `.IsStatic()` | `.AreStatic()` |
471472
| virtual | `.WhichAreVirtual()` | `.IsVirtual()` | `.AreVirtual()` |
472473
| override | `.WhichOverride()` | `.Overrides()` | `.Override()` |
474+
| nullable | `.WhichAreNullable()` | `.IsNullable()` | `.AreNullable()` |
473475

474476
The `OfType` / `IsOfType` / `AreOfType` filters and assertions match the event's handler type (its
475477
`EventHandlerType`, e.g. `EventHandler<T>`); the `…ExactType` variants match only the exact handler type.
476478
Use `OrOfType<T>()` / `OrOfExactType<T>()` to allow several handler types.
477479

480+
An event counts as *nullable* when its handler type is annotated as nullable
481+
(e.g. `event EventHandler? Changed;`), following the same [nullability](#nullability) rules as
482+
properties and fields.
483+
478484
:::note[Negation]
479-
The `abstract`, `sealed`, `static` and `virtual` rows have a negated form: `WhichAreNot…` on filters and
480-
`IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotSealed()`, `IsNotSealed()`, `AreNotSealed()`);
481-
`override` uses `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.
485+
The `abstract`, `sealed`, `static`, `virtual` and `nullable` rows have a negated form: `WhichAreNot…` on
486+
filters and `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotSealed()`, `IsNotSealed()`,
487+
`AreNotSealed()`); `override` uses `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.
482488
:::
483489

484490
```csharp
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Reflection;
2+
using aweXpect.Reflection.Collections;
3+
using aweXpect.Reflection.Helpers;
4+
5+
namespace aweXpect.Reflection;
6+
7+
public static partial class EventFilters
8+
{
9+
/// <summary>
10+
/// Filters for events that are nullable.
11+
/// </summary>
12+
public static Filtered.Events WhichAreNullable(this Filtered.Events @this)
13+
=> @this.Which(Filter.Prefix<EventInfo>(
14+
eventInfo => eventInfo.IsNullable(),
15+
"nullable "));
16+
17+
/// <summary>
18+
/// Filters for events that are not nullable.
19+
/// </summary>
20+
public static Filtered.Events WhichAreNotNullable(this Filtered.Events @this)
21+
=> @this.Which(Filter.Prefix<EventInfo>(
22+
eventInfo => !eventInfo.IsNullable(),
23+
"non-nullable "));
24+
}

Source/aweXpect.Reflection/Filters/TypeFilters.WhichOnlyHaveNullableMembers.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace aweXpect.Reflection;
77
public static partial class TypeFilters
88
{
99
/// <summary>
10-
/// Filters for types whose fields and properties are all nullable, including inherited members or only
10+
/// Filters for types whose fields, properties and events are all nullable, including inherited members or only
1111
/// those declared directly on the type according to the <paramref name="memberScope" />.
1212
/// </summary>
1313
public static Filtered.Types WhichOnlyHaveNullableMembers(this Filtered.Types @this,
@@ -17,8 +17,8 @@ public static Filtered.Types WhichOnlyHaveNullableMembers(this Filtered.Types @t
1717
"which only have nullable members "));
1818

1919
/// <summary>
20-
/// Filters for types whose fields and properties are all non-nullable, including inherited members or only
21-
/// those declared directly on the type according to the <paramref name="memberScope" />.
20+
/// Filters for types whose fields, properties and events are all non-nullable, including inherited members or
21+
/// only those declared directly on the type according to the <paramref name="memberScope" />.
2222
/// </summary>
2323
public static Filtered.Types WhichOnlyHaveNonNullableMembers(this Filtered.Types @this,
2424
MemberScope memberScope = MemberScope.DeclaredOnly)

Source/aweXpect.Reflection/Helpers/NullabilityHelpers.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,41 @@ public static bool IsNullable(this FieldInfo? fieldInfo)
6868
}
6969

7070
/// <summary>
71-
/// Returns the nullable fields and properties of the <paramref name="type" />, including inherited
71+
/// Checks if the <paramref name="eventInfo" /> is nullable.
72+
/// </summary>
73+
/// <remarks>
74+
/// An event is considered nullable if its handler type is annotated as nullable (according to the
75+
/// nullable reference type metadata). Event handler types are always delegate (reference) types,
76+
/// so the <see cref="Nullable{T}" /> value type check does not apply.
77+
/// </remarks>
78+
public static bool IsNullable(this EventInfo? eventInfo)
79+
{
80+
if (eventInfo is null)
81+
{
82+
return false;
83+
}
84+
85+
return IsNullableReferenceType(eventInfo);
86+
}
87+
88+
/// <summary>
89+
/// Returns the nullable fields, properties and events of the <paramref name="type" />, including inherited
7290
/// members or only those declared directly on the type according to the <paramref name="memberScope" />.
7391
/// </summary>
7492
public static MemberInfo[] GetNullableMembers(this Type type,
7593
MemberScope memberScope = MemberScope.DeclaredOnly)
7694
=> type.GetMembersByNullability(memberScope).Nullable;
7795

7896
/// <summary>
79-
/// Returns the non-nullable fields and properties of the <paramref name="type" />, including inherited
97+
/// Returns the non-nullable fields, properties and events of the <paramref name="type" />, including inherited
8098
/// members or only those declared directly on the type according to the <paramref name="memberScope" />.
8199
/// </summary>
82100
public static MemberInfo[] GetNotNullableMembers(this Type type,
83101
MemberScope memberScope = MemberScope.DeclaredOnly)
84102
=> type.GetMembersByNullability(memberScope).NotNullable;
85103

86104
/// <summary>
87-
/// Partitions the fields and properties of the <paramref name="type" /> into nullable and
105+
/// Partitions the fields, properties and events of the <paramref name="type" /> into nullable and
88106
/// non-nullable members in a single pass, including inherited members or only those declared
89107
/// directly on the type according to the <paramref name="memberScope" />.
90108
/// </summary>
@@ -103,6 +121,11 @@ public static (MemberInfo[] Nullable, MemberInfo[] NotNullable) GetMembersByNull
103121
(property.IsNullable() ? nullable : notNullable).Add(property);
104122
}
105123

124+
foreach (EventInfo @event in type.GetDeclaredEvents(memberScope))
125+
{
126+
(@event.IsNullable() ? nullable : notNullable).Add(@event);
127+
}
128+
106129
return (nullable.ToArray(), notNullable.ToArray());
107130
}
108131

@@ -250,6 +273,7 @@ private static bool IsNullableViaGenericArgument(MemberInfo memberInfo)
250273
{
251274
FieldInfo fieldInfo => fieldInfo.FieldType,
252275
PropertyInfo propertyInfo => propertyInfo.PropertyType,
276+
EventInfo eventInfo => eventInfo.EventHandlerType,
253277
_ => null,
254278
};
255279
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Reflection;
2+
using System.Text;
3+
using aweXpect.Core;
4+
using aweXpect.Core.Constraints;
5+
using aweXpect.Reflection.Helpers;
6+
using aweXpect.Results;
7+
8+
namespace aweXpect.Reflection;
9+
10+
public static partial class ThatEvent
11+
{
12+
/// <summary>
13+
/// Verifies that the <see cref="EventInfo" /> is nullable.
14+
/// </summary>
15+
/// <remarks>
16+
/// An event is considered nullable if its handler type is annotated as nullable
17+
/// (according to the nullable reference type metadata).
18+
/// </remarks>
19+
public static AndOrResult<EventInfo?, IThat<EventInfo?>> IsNullable(
20+
this IThat<EventInfo?> subject)
21+
=> new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
22+
=> new IsNullableConstraint(it, grammars)),
23+
subject);
24+
25+
/// <summary>
26+
/// Verifies that the <see cref="EventInfo" /> is not nullable.
27+
/// </summary>
28+
/// <remarks>
29+
/// An event is considered nullable if its handler type is annotated as nullable
30+
/// (according to the nullable reference type metadata).
31+
/// Events without nullability annotations (oblivious code) count as non-nullable.
32+
/// </remarks>
33+
public static AndOrResult<EventInfo?, IThat<EventInfo?>> IsNotNullable(
34+
this IThat<EventInfo?> subject)
35+
=> new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
36+
=> new IsNullableConstraint(it, grammars).Invert()),
37+
subject);
38+
39+
private sealed class IsNullableConstraint(string it, ExpectationGrammars grammars)
40+
: ConstraintResult.WithNotNullValue<EventInfo?>(it, grammars),
41+
IValueConstraint<EventInfo?>
42+
{
43+
public ConstraintResult IsMetBy(EventInfo? actual)
44+
{
45+
Actual = actual;
46+
Outcome = actual.IsNullable() ? Outcome.Success : Outcome.Failure;
47+
return this;
48+
}
49+
50+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
51+
=> stringBuilder.Append("is nullable");
52+
53+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
54+
{
55+
stringBuilder.Append(It).Append(" was non-nullable ");
56+
Formatter.Format(stringBuilder, Actual);
57+
}
58+
59+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
60+
=> stringBuilder.Append("is not nullable");
61+
62+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
63+
{
64+
stringBuilder.Append(It).Append(" was nullable ");
65+
Formatter.Format(stringBuilder, Actual);
66+
}
67+
}
68+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System.Collections.Generic;
2+
using System.Reflection;
3+
using System.Text;
4+
using aweXpect.Core;
5+
using aweXpect.Core.Constraints;
6+
using aweXpect.Reflection.Helpers;
7+
using aweXpect.Results;
8+
#if NET8_0_OR_GREATER
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
#endif
12+
13+
// ReSharper disable PossibleMultipleEnumeration
14+
15+
namespace aweXpect.Reflection;
16+
17+
public static partial class ThatEvents
18+
{
19+
/// <summary>
20+
/// Verifies that all items in the filtered collection of <see cref="EventInfo" /> are nullable.
21+
/// </summary>
22+
public static AndOrResult<IEnumerable<EventInfo?>, IThat<IEnumerable<EventInfo?>>> AreNullable(
23+
this IThat<IEnumerable<EventInfo?>> subject)
24+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IEnumerable<EventInfo?>>((it, grammars)
25+
=> new AreNullableConstraint(it, grammars)),
26+
subject);
27+
28+
#if NET8_0_OR_GREATER
29+
/// <summary>
30+
/// Verifies that all items in the filtered collection of <see cref="EventInfo" /> are nullable.
31+
/// </summary>
32+
public static AndOrResult<IAsyncEnumerable<EventInfo?>, IThat<IAsyncEnumerable<EventInfo?>>> AreNullable(
33+
this IThat<IAsyncEnumerable<EventInfo?>> subject)
34+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IAsyncEnumerable<EventInfo?>>((it, grammars)
35+
=> new AreNullableConstraint(it, grammars)),
36+
subject);
37+
#endif
38+
39+
/// <summary>
40+
/// Verifies that all items in the filtered collection of <see cref="EventInfo" /> are not nullable.
41+
/// </summary>
42+
public static AndOrResult<IEnumerable<EventInfo?>, IThat<IEnumerable<EventInfo?>>> AreNotNullable(
43+
this IThat<IEnumerable<EventInfo?>> subject)
44+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IEnumerable<EventInfo?>>((it, grammars)
45+
=> new AreNotNullableConstraint(it, grammars)),
46+
subject);
47+
48+
#if NET8_0_OR_GREATER
49+
/// <summary>
50+
/// Verifies that all items in the filtered collection of <see cref="EventInfo" /> are not nullable.
51+
/// </summary>
52+
public static AndOrResult<IAsyncEnumerable<EventInfo?>, IThat<IAsyncEnumerable<EventInfo?>>> AreNotNullable(
53+
this IThat<IAsyncEnumerable<EventInfo?>> subject)
54+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IAsyncEnumerable<EventInfo?>>((it, grammars)
55+
=> new AreNotNullableConstraint(it, grammars)),
56+
subject);
57+
#endif
58+
59+
private sealed class AreNullableConstraint(string it, ExpectationGrammars grammars)
60+
: CollectionConstraintResult<EventInfo?>(grammars),
61+
IValueConstraint<IEnumerable<EventInfo?>>
62+
#if NET8_0_OR_GREATER
63+
, IAsyncConstraint<IAsyncEnumerable<EventInfo?>>
64+
#endif
65+
{
66+
#if NET8_0_OR_GREATER
67+
public async Task<ConstraintResult> IsMetBy(IAsyncEnumerable<EventInfo?> actual,
68+
CancellationToken cancellationToken)
69+
=> await SetAsyncValue(actual, @event => @event.IsNullable());
70+
#endif
71+
72+
public ConstraintResult IsMetBy(IEnumerable<EventInfo?> actual)
73+
=> SetValue(actual, @event => @event.IsNullable());
74+
75+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
76+
=> stringBuilder.Append("are all nullable");
77+
78+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
79+
{
80+
stringBuilder.Append(it).Append(" contained non-nullable events ");
81+
Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation));
82+
}
83+
84+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
85+
=> stringBuilder.Append("are not all nullable");
86+
87+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
88+
{
89+
stringBuilder.Append(it).Append(" only contained nullable events ");
90+
Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation));
91+
}
92+
}
93+
94+
private sealed class AreNotNullableConstraint(string it, ExpectationGrammars grammars)
95+
: CollectionConstraintResult<EventInfo?>(grammars),
96+
IValueConstraint<IEnumerable<EventInfo?>>
97+
#if NET8_0_OR_GREATER
98+
, IAsyncConstraint<IAsyncEnumerable<EventInfo?>>
99+
#endif
100+
{
101+
#if NET8_0_OR_GREATER
102+
public async Task<ConstraintResult> IsMetBy(IAsyncEnumerable<EventInfo?> actual,
103+
CancellationToken cancellationToken)
104+
=> await SetAsyncValue(actual, @event => @event?.IsNullable() == false);
105+
#endif
106+
107+
public ConstraintResult IsMetBy(IEnumerable<EventInfo?> actual)
108+
=> SetValue(actual, @event => @event?.IsNullable() == false);
109+
110+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
111+
=> stringBuilder.Append("are all not nullable");
112+
113+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
114+
{
115+
stringBuilder.Append(it).Append(" contained nullable events ");
116+
Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation));
117+
}
118+
119+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
120+
=> stringBuilder.Append("also contain a nullable event");
121+
122+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
123+
{
124+
stringBuilder.Append(it).Append(" only contained non-nullable events ");
125+
Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation));
126+
}
127+
}
128+
}

Source/aweXpect.Reflection/ThatType.OnlyHasNonNullableMembers.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ namespace aweXpect.Reflection;
1212
public static partial class ThatType
1313
{
1414
/// <summary>
15-
/// Verifies that all fields and properties of the <see cref="Type" /> are non-nullable, including inherited
16-
/// members or only those declared directly on the type according to the <paramref name="memberScope" />.
15+
/// Verifies that all fields, properties and events of the <see cref="Type" /> are non-nullable, including
16+
/// inherited members or only those declared directly on the type according to the
17+
/// <paramref name="memberScope" />.
1718
/// </summary>
1819
/// <remarks>
1920
/// A member is considered nullable if its type is a <see cref="Nullable{T}" /> value type or a

Source/aweXpect.Reflection/ThatType.OnlyHasNullableMembers.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace aweXpect.Reflection;
1212
public static partial class ThatType
1313
{
1414
/// <summary>
15-
/// Verifies that all fields and properties of the <see cref="Type" /> are nullable, including inherited
15+
/// Verifies that all fields, properties and events of the <see cref="Type" /> are nullable, including inherited
1616
/// members or only those declared directly on the type according to the <paramref name="memberScope" />.
1717
/// </summary>
1818
/// <remarks>

Source/aweXpect.Reflection/ThatTypes.OnlyHaveNonNullableMembers.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ namespace aweXpect.Reflection;
1919
public static partial class ThatTypes
2020
{
2121
/// <summary>
22-
/// Verifies that all fields and properties of all items in the filtered collection of <see cref="Type" />
22+
/// Verifies that all fields, properties and events of all items in the filtered collection of <see cref="Type" />
2323
/// are non-nullable, including inherited members or only those declared directly on the type according to
2424
/// the <paramref name="memberScope" />.
2525
/// </summary>
@@ -35,7 +35,7 @@ public static partial class ThatTypes
3535

3636
#if NET8_0_OR_GREATER
3737
/// <summary>
38-
/// Verifies that all fields and properties of all items in the filtered collection of <see cref="Type" />
38+
/// Verifies that all fields, properties and events of all items in the filtered collection of <see cref="Type" />
3939
/// are non-nullable, including inherited members or only those declared directly on the type according to
4040
/// the <paramref name="memberScope" />.
4141
/// </summary>

0 commit comments

Comments
 (0)