diff --git a/csharp/tests/OrderTests/OrderStateConstraint.cs b/csharp/tests/OrderTests/OrderStateConstraint.cs
new file mode 100644
index 0000000..430ed5f
--- /dev/null
+++ b/csharp/tests/OrderTests/OrderStateConstraint.cs
@@ -0,0 +1,74 @@
+using System.Linq;
+using NUnit.Framework.Constraints;
+using OrderDomain;
+
+namespace OrderTests;
+
+///
+/// Custom NUnit matcher that bundles multiple order assertions into one.
+/// Participants: you do not need to understand how this works —
+/// just recognize the pattern and use it as an example for the agent.
+///
+public static class Has
+{
+ public static IResolveConstraint OrderState(decimal expectedTotal, decimal[] expectedLineValues)
+ => new OrderStateConstraint(expectedTotal, expectedLineValues);
+}
+
+public class OrderStateConstraint : Constraint
+{
+ private readonly decimal _expectedTotal;
+ private readonly decimal[] _expectedLineValues;
+
+ public OrderStateConstraint(decimal expectedTotal, decimal[] expectedLineValues)
+ {
+ _expectedTotal = expectedTotal;
+ _expectedLineValues = expectedLineValues;
+ }
+
+ public override string Description =>
+ $"order with total {_expectedTotal} and line values [{string.Join(", ", _expectedLineValues)}]";
+
+ public override ConstraintResult ApplyTo(TActual actual)
+ {
+ var order = (Order)(object)actual!;
+ var actualTotal = order.Total;
+ var actualLineValues = order.Lines.Select(l => l.Value).ToArray();
+
+ var details = new List();
+ if (actualTotal != _expectedTotal)
+ details.Add($"Total: expected {_expectedTotal}, but was {actualTotal}");
+ if (!actualLineValues.SequenceEqual(_expectedLineValues))
+ details.Add(
+ $"Line values: expected [{string.Join(", ", _expectedLineValues)}], " +
+ $"but was [{string.Join(", ", actualLineValues)}]");
+
+ return new OrderStateResult(this, actual!, details.Count == 0, details);
+ }
+
+ ///
+ /// A ConstraintResult that carries the per-field mismatch details and
+ /// writes them as additional lines in the failure message, e.g.
+ ///
+ /// Expected: order with total 210 and line values [160, 50]
+ /// But was: <Order>
+ /// Total: expected 210, but was 250
+ /// Line values: expected [160, 50], but was [200, 50]
+ ///
+ private sealed class OrderStateResult : ConstraintResult
+ {
+ private readonly IReadOnlyList _details;
+
+ public OrderStateResult(Constraint constraint, object? actual, bool isSuccess, IReadOnlyList details)
+ : base(constraint, actual, isSuccess)
+ {
+ _details = details;
+ }
+
+ public override void WriteAdditionalLinesTo(MessageWriter writer)
+ {
+ foreach (var detail in _details)
+ writer.WriteMessageLine(detail);
+ }
+ }
+}
diff --git a/csharp/tests/OrderTests/OrderTotalTests.cs b/csharp/tests/OrderTests/OrderTotalTests.cs
index 682a2b8..94608e5 100644
--- a/csharp/tests/OrderTests/OrderTotalTests.cs
+++ b/csharp/tests/OrderTests/OrderTotalTests.cs
@@ -5,6 +5,8 @@ namespace OrderTests;
public class OrderTotalTests
{
+ // This test was refactored — notice the custom matcher.
+ // The other tests still need the same treatment.
[Test]
public void DiscountOnFirstLine_TotalAndLineValues()
{
@@ -14,9 +16,9 @@ public class OrderTotalTests
order.ApplyDiscount(0);
- Assert.That(order.Total, Is.EqualTo(210m));
- Assert.That(order.Lines[0].Value, Is.EqualTo(160m));
- Assert.That(order.Lines[1].Value, Is.EqualTo(50m));
+ Assert.That(order, Has.OrderState(
+ expectedTotal: 210m,
+ expectedLineValues: [160m, 50m]));
}
[Test]
@@ -87,4 +89,4 @@ public class OrderTotalTests
Assert.That(order.Lines[1].Value, Is.EqualTo(48m));
Assert.That(order.Lines[2].Value, Is.EqualTo(30m));
}
-}
+}
\ No newline at end of file