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
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ outside the namespace.
| instantiable | `.WhichAreInstantiable()` | `.IsInstantiable()` | `.AreInstantiable()` |
| immutable | `.WhichAreImmutable()` | `.IsImmutable()` | `.AreImmutable()` |
| default constructor | `.WhichHaveADefaultConstructor()` | `.HasADefaultConstructor()` | `.HaveADefaultConstructor()` |
| only nullable members | `.WhichOnlyHaveNullableMembers()` | `.OnlyHasNullableMembers()` | `.OnlyHaveNullableMembers()` |
| only non-nullable members | `.WhichOnlyHaveNonNullableMembers()` | `.OnlyHasNonNullableMembers()` | `.OnlyHaveNonNullableMembers()` |
| custom predicate | `.Which(t => …)` | `.Satisfies(t => …)` | `.All().Satisfy(t => …)` |

`WhichInheritFrom` / `InheritsFrom` consider only the **base-class chain** (not implemented interfaces) and
Expand Down Expand Up @@ -325,6 +327,16 @@ A type is *immutable* when all instance fields (including inherited ones) are `r
properties (including inherited ones) have no setter or an `init`-only setter. Static members do not affect
immutability. Failure messages list the offending mutable members for actionable feedback.

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

```csharp
await Expect.That(In.AssemblyContaining<MyRequest>()
.Types().WithName("Request").AsSuffix())
.OnlyHaveNullableMembers();
```

> **Negation:** every kind/modifier row above has a negated form. Most use `WhichAreNot…` on filters and
> `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotSealed()`, `IsNotAClass()`, `AreNotStatic()`,
> `IsNotInstantiable()`). The *default constructor* row uses `WhichDoNotHaveADefaultConstructor()`,
Expand Down Expand Up @@ -526,6 +538,7 @@ In addition to [access modifiers](#access-modifiers),
| of type (or a subtype) | `.OfType<T>()` | `.IsOfType<T>()` | `.AreOfType<T>()` |
| of exact type | `.OfExactType<T>()` | `.IsOfExactType<T>()` | `.AreOfExactType<T>()` |
| static *(properties & fields)* | `.WhichAreStatic()` | `.IsStatic()` | `.AreStatic()` |
| nullable *(properties & fields)* | `.WhichAreNullable()` | `.IsNullable()` | `.AreNullable()` |
| abstract / sealed *(properties only)* | `.WhichAreAbstract()` / `.WhichAreSealed()` | `.IsAbstract()` / `.IsSealed()` | `.AreAbstract()` / `.AreSealed()` |
| virtual *(properties only)* | `.WhichAreVirtual()` | `.IsVirtual()` | `.AreVirtual()` |
| override *(properties only)* | `.WhichOverride()` | `.Overrides()` | `.Override()` |
Expand All @@ -543,10 +556,27 @@ In addition to [access modifiers](#access-modifiers),
| read-only *(fields only)* | `.WhichAreReadOnly()` | `.IsReadOnly()` | `.AreReadOnly()` |
| constant *(fields only)* | `.WhichAreConstant()` | `.IsConstant()` | `.AreConstant()` |

> **Negation:** the `static`, `abstract`, `sealed`, `virtual`, `required`, `indexer`, `extension property`,
> `read-only` *(fields)* and `constant` rows have a negated form: `WhichAreNot…` on filters and `IsNot…` /
> `AreNot…` on assertions (e.g. `WhichAreNotConstant()`, `IsNotConstant()`, `AreNotConstant()`); `override` uses
> `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.
> **Negation:** the `static`, `nullable`, `abstract`, `sealed`, `virtual`, `required`, `indexer`,
> `extension property`, `read-only` *(fields)* and `constant` rows have a negated form: `WhichAreNot…` on filters
> and `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotConstant()`, `IsNotConstant()`, `AreNotConstant()`);
> `override` uses `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.

#### Nullability

A property or field counts as *nullable* when its type is a `Nullable<T>` value type (e.g. `int?`) or a
reference type annotated as nullable (e.g. `string?`, based on the nullable reference type metadata emitted
by the compiler). The check follows the declared annotation on every target framework: reference types
without nullability annotations (oblivious code compiled without `<Nullable>enable</Nullable>`) and
unconstrained generic type parameters (`T`, as opposed to `T?`) count as non-nullable, and post-condition
attributes like `[AllowNull]` or `[MaybeNull]` are ignored.

```csharp
// All properties and fields of the request types must be nullable
await Expect.That(In.AssemblyContaining<MyRequest>()
.Types().WithName("Request").AsSuffix()
.Properties())
.AreNullable();
```

`WhichAreExtensionProperties()`, `IsAnExtensionProperty()` and `AreExtensionProperties()` match extension properties
declared with the C# extension block syntax (`extension(...) { … }`), both instance and static. The real properties
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Reflection;
using aweXpect.Reflection.Collections;
using aweXpect.Reflection.Helpers;

namespace aweXpect.Reflection;

public static partial class FieldFilters
{
/// <summary>
/// Filters for fields that are nullable.
/// </summary>
public static Filtered.Fields WhichAreNullable(this Filtered.Fields @this)
=> @this.Which(Filter.Prefix<FieldInfo>(
field => field.IsNullable(),
"nullable "));

/// <summary>
/// Filters for fields that are not nullable.
/// </summary>
public static Filtered.Fields WhichAreNotNullable(this Filtered.Fields @this)
=> @this.Which(Filter.Prefix<FieldInfo>(
field => !field.IsNullable(),
"non-nullable "));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Reflection;
using aweXpect.Reflection.Collections;
using aweXpect.Reflection.Helpers;

namespace aweXpect.Reflection;

public static partial class PropertyFilters
{
/// <summary>
/// Filters for properties that are nullable.
/// </summary>
public static Filtered.Properties WhichAreNullable(this Filtered.Properties @this)
=> @this.Which(Filter.Prefix<PropertyInfo>(
property => property.IsNullable(),
"nullable "));

/// <summary>
/// Filters for properties that are not nullable.
/// </summary>
public static Filtered.Properties WhichAreNotNullable(this Filtered.Properties @this)
=> @this.Which(Filter.Prefix<PropertyInfo>(
property => !property.IsNullable(),
"non-nullable "));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using aweXpect.Reflection.Collections;
using aweXpect.Reflection.Helpers;

namespace aweXpect.Reflection;

public static partial class TypeFilters
{
/// <summary>
/// Filters for types whose fields and properties are all nullable, including inherited members or only
/// those declared directly on the type according to the <paramref name="memberScope" />.
/// </summary>
public static Filtered.Types WhichOnlyHaveNullableMembers(this Filtered.Types @this,
MemberScope memberScope = MemberScope.DeclaredOnly)
=> @this.Which(Filter.Suffix<Type>(
type => type.GetNotNullableMembers(memberScope).Length == 0,
"which only have nullable members "));

/// <summary>
/// Filters for types whose fields and properties are all non-nullable, including inherited members or only
/// those declared directly on the type according to the <paramref name="memberScope" />.
/// </summary>
public static Filtered.Types WhichOnlyHaveNonNullableMembers(this Filtered.Types @this,
MemberScope memberScope = MemberScope.DeclaredOnly)
=> @this.Which(Filter.Suffix<Type>(
type => type.GetNullableMembers(memberScope).Length == 0,
"which only have non-nullable members "));
}
57 changes: 57 additions & 0 deletions Source/aweXpect.Reflection/Helpers/MemberViolationRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using aweXpect.Core;

namespace aweXpect.Reflection.Helpers;

/// <summary>
/// Renders the grouped failure output of the nullability member constraints: one indented line per failing
/// type, each followed by its list of violating members.
/// </summary>
/// <remarks>
/// Shared between the nullable and the non-nullable member constraints, so that the formatting (indentation,
/// comma placement, null handling) cannot drift between the two.
/// </remarks>
internal static class MemberViolationRenderer
{
/// <summary>
/// Appends <c>{it}{header}[</c>, one indented line per type in <paramref name="types" /> (appending
/// <c>{memberHeader}[…]</c> when <paramref name="violations" /> has an entry for it), and a closing <c>]</c>.
/// </summary>
/// <remarks>
/// A <see langword="null" /> type has no violations to list; it fails because it cannot satisfy the
/// expectation, so it is rendered without a (contradictory empty) violation list.
/// </remarks>
public static void AppendTypesWithViolatingMembers(
StringBuilder stringBuilder,
string it,
string header,
IReadOnlyList<Type?> types,
IReadOnlyDictionary<Type, MemberInfo[]> violations,
string memberHeader,
string? indentation)
{
string itemIndentation = (indentation ?? string.Empty) + " ";
stringBuilder.Append(it).Append(header).Append('[');
for (int index = 0; index < types.Count; index++)
{
Type? type = types[index];
stringBuilder.Append(Environment.NewLine).Append(itemIndentation)
.Append(Formatter.Format(type));

if (type is not null && violations.TryGetValue(type, out MemberInfo[]? members))
{
stringBuilder.Append(memberHeader).Append(Formatter.Format(members));
}

if (index < types.Count - 1)
{
stringBuilder.Append(',');
}
}

stringBuilder.Append(Environment.NewLine).Append(indentation).Append(']');
}
}
Loading
Loading