initial commit v2

This commit is contained in:
2026-09-03 21:06:39 +01:00
commit 4228ccb030
27 changed files with 981 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
## NuGet
*.nupkg
**/packages/
## Build
bin/
obj/
## IDE
*.user
*.suo
*.userosscache
*.sln.docstates
.idea/
*.slnx
*.slnx launches
## NuGet local config (workaround for locked ~/.nuget)
nuget.config
*.log
Library
+45
View File
@@ -0,0 +1,45 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderDomain", "src/OrderDomain/OrderDomain.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderTests", "tests/OrderTests/OrderTests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderBookDomain", "src/OrderBookDomain/OrderBookDomain.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderBookTests", "tests/OrderBookTests/OrderBookTests.csproj", "{D4E5F6A7-B8C9-0123-DEFA-234567890123}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration", "{83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warmup", "Warmup", "{55470487-3CE4-4FD7-BADF-35F7D1473CA5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D4E5F6A7-B8C9-0123-DEFA-234567890123}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C3D4E5F6-A7B8-9012-CDEF-123456789012} = {83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}
{D4E5F6A7-B8C9-0123-DEFA-234567890123} = {83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {55470487-3CE4-4FD7-BADF-35F7D1473CA5}
{B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {55470487-3CE4-4FD7-BADF-35F7D1473CA5}
EndGlobalSection
EndGlobal
View File
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature"
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace OrderBookDomain;
public enum OrderStatus
{
Pending,
WaitingForPayment,
Fulfilling,
Delivered,
Cancelled
}
public class Order
{
public int Id { get; }
public string Customer { get; }
public OrderStatus Status { get; internal set; }
public DateTimeOffset? FinalizedAt { get; internal set; }
internal Order(int id, string customer)
{
Id = id;
Customer = customer;
Status = OrderStatus.Pending;
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace OrderBookDomain;
public class OrderBook
{
private readonly TimeSpan _paymentWindow;
private readonly Dictionary<int, Order> _ordersById = new();
private int _nextId = 1;
public OrderBook(TimeSpan? paymentWindow = null)
=> _paymentWindow = paymentWindow ?? TimeSpan.FromDays(14);
public IReadOnlyList<Order> Orders => _ordersById.Values.ToList();
public Order OpenOrder(string customer)
{
var order = new Order(_nextId++, customer);
_ordersById[order.Id] = order;
return order;
}
public Order GetOrder(int orderId)
=> _ordersById.TryGetValue(orderId, out var order)
? order
: throw new InvalidOperationException($"Unknown order id {orderId}.");
public void FinalizeOrder(int orderId, DateTimeOffset finalizedAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Pending, "finalize");
order.Status = OrderStatus.WaitingForPayment;
order.FinalizedAt = finalizedAt;
}
public void PayOrder(int orderId, DateTimeOffset paidAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.WaitingForPayment, "pay");
if (paidAt - order.FinalizedAt > _paymentWindow)
{
order.Status = OrderStatus.Cancelled;
throw new InvalidOperationException(
$"Payment for order {orderId} arrived after the payment window; the order was cancelled.");
}
order.Status = OrderStatus.Fulfilling;
}
public void ShipOrder(int orderId)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Fulfilling, "ship");
order.Status = OrderStatus.Delivered;
}
public void CancelOrder(int orderId)
{
var order = GetOrder(orderId);
if (order.Status is not (OrderStatus.Pending or OrderStatus.WaitingForPayment))
throw new InvalidOperationException(
$"Cannot cancel order {orderId} in status {order.Status}.");
order.Status = OrderStatus.Cancelled;
}
private static void RequireStatus(Order order, OrderStatus expected, string action)
{
if (order.Status != expected)
throw new InvalidOperationException(
$"Cannot {action} order {order.Id} in status {order.Status} (expected {expected}).");
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,114 @@
using NUnit.Framework;
using OrderBookDomain;
namespace OrderBookTests;
public class OrderBookLifecycleTests
{
private static readonly DateTimeOffset Day0 = new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
private static DateTimeOffset At(int day) => Day0.AddDays(day);
[Test]
public void FinalizeThenPay_MovesOrderThroughTwoStates()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
[Test]
public void PayThenShip_DeliversTheOrder()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void PayWithinWindow_ThenLatePayOnSecondOrder_CancelsOnlyThatOrder()
{
var book = new OrderBook();
var first = book.OpenOrder("anna");
var second = book.OpenOrder("bram");
book.FinalizeOrder(first.Id, At(1));
book.FinalizeOrder(second.Id, At(1));
Assert.That(book.Orders, Has.Count.EqualTo(2));
book.PayOrder(first.Id, At(5));
Assert.That(book.GetOrder(first.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(second.Id, At(30)));
Assert.That(book.GetOrder(second.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void PayBeforeFinalize_Throws_ThenFinalizeStillWorks()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Pending));
book.FinalizeOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
}
[Test]
public void CancelPendingOrder_ThenPayAfterCancel_ThrowsAgain()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.CancelOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void ShippedOrder_CannotBeCancelled()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
Assert.Throws<InvalidOperationException>(() => book.CancelOrder(order.Id));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void TwoCustomers_InterleaveTransitions_Independently()
{
var book = new OrderBook();
var annas = book.OpenOrder("anna");
var brams = book.OpenOrder("bram");
book.FinalizeOrder(brams.Id, At(1));
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Pending));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(brams.Id, At(2));
book.CancelOrder(annas.Id);
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OrderBookDomain\OrderBookDomain.csproj" />
</ItemGroup>
</Project>
+2
View File
@@ -0,0 +1,2 @@
[tools]
dotnet = "10"
+25
View File
@@ -0,0 +1,25 @@
namespace OrderBookDomain;
public enum OrderStatus
{
Pending,
WaitingForPayment,
Fulfilling,
Delivered,
Cancelled
}
public class Order
{
public int Id { get; }
public string Customer { get; }
public OrderStatus Status { get; internal set; }
public DateTimeOffset? FinalizedAt { get; internal set; }
internal Order(int id, string customer)
{
Id = id;
Customer = customer;
Status = OrderStatus.Pending;
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace OrderBookDomain;
public class OrderBook
{
private readonly TimeSpan _paymentWindow;
private readonly Dictionary<int, Order> _ordersById = new();
private int _nextId = 1;
public OrderBook(TimeSpan? paymentWindow = null)
=> _paymentWindow = paymentWindow ?? TimeSpan.FromDays(14);
public IReadOnlyList<Order> Orders => _ordersById.Values.ToList();
public Order OpenOrder(string customer)
{
var order = new Order(_nextId++, customer);
_ordersById[order.Id] = order;
return order;
}
public Order GetOrder(int orderId)
=> _ordersById.TryGetValue(orderId, out var order)
? order
: throw new InvalidOperationException($"Unknown order id {orderId}.");
public void FinalizeOrder(int orderId, DateTimeOffset finalizedAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Pending, "finalize");
order.Status = OrderStatus.WaitingForPayment;
order.FinalizedAt = finalizedAt;
}
public void PayOrder(int orderId, DateTimeOffset paidAt)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.WaitingForPayment, "pay");
if (paidAt - order.FinalizedAt > _paymentWindow)
{
order.Status = OrderStatus.Cancelled;
throw new InvalidOperationException(
$"Payment for order {orderId} arrived after the payment window; the order was cancelled.");
}
order.Status = OrderStatus.Fulfilling;
}
public void ShipOrder(int orderId)
{
var order = GetOrder(orderId);
RequireStatus(order, OrderStatus.Fulfilling, "ship");
order.Status = OrderStatus.Delivered;
}
public void CancelOrder(int orderId)
{
var order = GetOrder(orderId);
if (order.Status is not (OrderStatus.Pending or OrderStatus.WaitingForPayment))
throw new InvalidOperationException(
$"Cannot cancel order {orderId} in status {order.Status}.");
order.Status = OrderStatus.Cancelled;
}
private static void RequireStatus(Order order, OrderStatus expected, string action)
{
if (order.Status != expected)
throw new InvalidOperationException(
$"Cannot {action} order {order.Id} in status {order.Status} (expected {expected}).");
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
namespace OrderDomain;
public class Order
{
public List<OrderLine> Lines { get; } = new();
public decimal Total => Lines.Sum(l => l.Value);
public void AddLine(decimal unitPrice, int quantity)
=> Lines.Add(new OrderLine(unitPrice, quantity));
public void ApplyDiscount(int lineIndex)
=> Lines[lineIndex].ApplyDiscount();
}
+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
namespace OrderDomain;
public class OrderLine
{
public decimal UnitPrice { get; }
public int Quantity { get; }
public bool HasDiscount { get; private set; }
public decimal Value =>
HasDiscount ? UnitPrice * Quantity * 0.8m : UnitPrice * Quantity;
public OrderLine(decimal unitPrice, int quantity)
{
UnitPrice = unitPrice;
Quantity = quantity;
}
public void ApplyDiscount() => HasDiscount = true;
}
@@ -0,0 +1,114 @@
using NUnit.Framework;
using OrderBookDomain;
namespace OrderBookTests;
public class OrderBookLifecycleTests
{
private static readonly DateTimeOffset Day0 = new(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
private static DateTimeOffset At(int day) => Day0.AddDays(day);
[Test]
public void FinalizeThenPay_MovesOrderThroughTwoStates()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
[Test]
public void PayThenShip_DeliversTheOrder()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void PayWithinWindow_ThenLatePayOnSecondOrder_CancelsOnlyThatOrder()
{
var book = new OrderBook();
var first = book.OpenOrder("anna");
var second = book.OpenOrder("bram");
book.FinalizeOrder(first.Id, At(1));
book.FinalizeOrder(second.Id, At(1));
Assert.That(book.Orders, Has.Count.EqualTo(2));
book.PayOrder(first.Id, At(5));
Assert.That(book.GetOrder(first.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(second.Id, At(30)));
Assert.That(book.GetOrder(second.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void PayBeforeFinalize_Throws_ThenFinalizeStillWorks()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Pending));
book.FinalizeOrder(order.Id, At(2));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
}
[Test]
public void CancelPendingOrder_ThenPayAfterCancel_ThrowsAgain()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.CancelOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.Throws<InvalidOperationException>(() => book.PayOrder(order.Id, At(1)));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
}
[Test]
public void ShippedOrder_CannotBeCancelled()
{
var book = new OrderBook();
var order = book.OpenOrder("anna");
book.FinalizeOrder(order.Id, At(1));
book.PayOrder(order.Id, At(2));
book.ShipOrder(order.Id);
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
Assert.Throws<InvalidOperationException>(() => book.CancelOrder(order.Id));
Assert.That(book.GetOrder(order.Id).Status, Is.EqualTo(OrderStatus.Delivered));
}
[Test]
public void TwoCustomers_InterleaveTransitions_Independently()
{
var book = new OrderBook();
var annas = book.OpenOrder("anna");
var brams = book.OpenOrder("bram");
book.FinalizeOrder(brams.Id, At(1));
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Pending));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.WaitingForPayment));
book.PayOrder(brams.Id, At(2));
book.CancelOrder(annas.Id);
Assert.That(book.GetOrder(annas.Id).Status, Is.EqualTo(OrderStatus.Cancelled));
Assert.That(book.GetOrder(brams.Id).Status, Is.EqualTo(OrderStatus.Fulfilling));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OrderBookDomain\OrderBookDomain.csproj" />
</ItemGroup>
</Project>
+74
View File
@@ -0,0 +1,74 @@
using System.Linq;
using NUnit.Framework.Constraints;
using OrderDomain;
namespace OrderTests;
/// <summary>
/// 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.
/// </summary>
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>(TActual actual)
{
var order = (Order)(object)actual!;
var actualTotal = order.Total;
var actualLineValues = order.Lines.Select(l => l.Value).ToArray();
var details = new List<string>();
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);
}
/// <summary>
/// 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: &lt;Order&gt;
/// Total: expected 210, but was 250
/// Line values: expected [160, 50], but was [200, 50]
/// </summary>
private sealed class OrderStateResult : ConstraintResult
{
private readonly IReadOnlyList<string> _details;
public OrderStateResult(Constraint constraint, object? actual, bool isSuccess, IReadOnlyList<string> details)
: base(constraint, actual, isSuccess)
{
_details = details;
}
public override void WriteAdditionalLinesTo(MessageWriter writer)
{
foreach (var detail in _details)
writer.WriteMessageLine(detail);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OrderDomain\OrderDomain.csproj" />
</ItemGroup>
</Project>
+92
View File
@@ -0,0 +1,92 @@
using NUnit.Framework;
using OrderDomain;
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()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200 → 160 with discount
order.AddLine(50m, 1); // line 1: 50
order.ApplyDiscount(0);
Assert.That(order, Has.OrderState(
expectedTotal: 210m,
expectedLineValues: [160m, 50m]));
}
[Test]
public void DiscountOnSecondLine_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200
order.AddLine(50m, 1); // line 1: 50 → 40 with discount
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(240m));
Assert.That(order.Lines[0].Value, Is.EqualTo(200m));
Assert.That(order.Lines[1].Value, Is.EqualTo(40m));
}
[Test]
public void DiscountOnBothLines_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200 → 160
order.AddLine(50m, 1); // line 1: 50 → 40
order.ApplyDiscount(0);
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(200m));
Assert.That(order.Lines[0].Value, Is.EqualTo(160m));
Assert.That(order.Lines[1].Value, Is.EqualTo(40m));
}
[Test]
public void NoDiscount_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200
order.AddLine(50m, 1); // line 1: 50
Assert.That(order.Total, Is.EqualTo(250m));
Assert.That(order.Lines[0].Value, Is.EqualTo(200m));
Assert.That(order.Lines[1].Value, Is.EqualTo(50m));
}
[Test]
public void SingleLineWithDiscount_TotalAndValue()
{
var order = new Order();
order.AddLine(75m, 4); // line 0: 300 → 240
order.ApplyDiscount(0);
Assert.That(order.Total, Is.EqualTo(240m));
Assert.That(order.Lines[0].Value, Is.EqualTo(240m));
}
[Test]
public void ThreeLinesOneDiscount_TotalAndLineValues()
{
var order = new Order();
order.AddLine(20m, 1); // line 0: 20
order.AddLine(30m, 2); // line 1: 60 → 48
order.AddLine(10m, 3); // line 2: 30
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(98m));
Assert.That(order.Lines[0].Value, Is.EqualTo(20m));
Assert.That(order.Lines[1].Value, Is.EqualTo(48m));
Assert.That(order.Lines[2].Value, Is.EqualTo(30m));
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace OrderDomain;
public class Order
{
public List<OrderLine> Lines { get; } = new();
public decimal Total => Lines.Sum(l => l.Value);
public void AddLine(decimal unitPrice, int quantity)
=> Lines.Add(new OrderLine(unitPrice, quantity));
public void ApplyDiscount(int lineIndex)
=> Lines[lineIndex].ApplyDiscount();
}
+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
namespace OrderDomain;
public class OrderLine
{
public decimal UnitPrice { get; }
public int Quantity { get; }
public bool HasDiscount { get; private set; }
public decimal Value =>
HasDiscount ? UnitPrice * Quantity * 0.8m : UnitPrice * Quantity;
public OrderLine(decimal unitPrice, int quantity)
{
UnitPrice = unitPrice;
Quantity = quantity;
}
public void ApplyDiscount() => HasDiscount = true;
}
+74
View File
@@ -0,0 +1,74 @@
using System.Linq;
using NUnit.Framework.Constraints;
using OrderDomain;
namespace OrderTests;
/// <summary>
/// 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.
/// </summary>
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>(TActual actual)
{
var order = (Order)(object)actual!;
var actualTotal = order.Total;
var actualLineValues = order.Lines.Select(l => l.Value).ToArray();
var details = new List<string>();
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);
}
/// <summary>
/// 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: &lt;Order&gt;
/// Total: expected 210, but was 250
/// Line values: expected [160, 50], but was [200, 50]
/// </summary>
private sealed class OrderStateResult : ConstraintResult
{
private readonly IReadOnlyList<string> _details;
public OrderStateResult(Constraint constraint, object? actual, bool isSuccess, IReadOnlyList<string> details)
: base(constraint, actual, isSuccess)
{
_details = details;
}
public override void WriteAdditionalLinesTo(MessageWriter writer)
{
foreach (var detail in _details)
writer.WriteMessageLine(detail);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OrderDomain\OrderDomain.csproj" />
</ItemGroup>
</Project>
+92
View File
@@ -0,0 +1,92 @@
using NUnit.Framework;
using OrderDomain;
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()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200 → 160 with discount
order.AddLine(50m, 1); // line 1: 50
order.ApplyDiscount(0);
Assert.That(order, Has.OrderState(
expectedTotal: 210m,
expectedLineValues: [160m, 50m]));
}
[Test]
public void DiscountOnSecondLine_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200
order.AddLine(50m, 1); // line 1: 50 → 40 with discount
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(240m));
Assert.That(order.Lines[0].Value, Is.EqualTo(200m));
Assert.That(order.Lines[1].Value, Is.EqualTo(40m));
}
[Test]
public void DiscountOnBothLines_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200 → 160
order.AddLine(50m, 1); // line 1: 50 → 40
order.ApplyDiscount(0);
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(200m));
Assert.That(order.Lines[0].Value, Is.EqualTo(160m));
Assert.That(order.Lines[1].Value, Is.EqualTo(40m));
}
[Test]
public void NoDiscount_TotalAndLineValues()
{
var order = new Order();
order.AddLine(100m, 2); // line 0: 200
order.AddLine(50m, 1); // line 1: 50
Assert.That(order.Total, Is.EqualTo(250m));
Assert.That(order.Lines[0].Value, Is.EqualTo(200m));
Assert.That(order.Lines[1].Value, Is.EqualTo(50m));
}
[Test]
public void SingleLineWithDiscount_TotalAndValue()
{
var order = new Order();
order.AddLine(75m, 4); // line 0: 300 → 240
order.ApplyDiscount(0);
Assert.That(order.Total, Is.EqualTo(240m));
Assert.That(order.Lines[0].Value, Is.EqualTo(240m));
}
[Test]
public void ThreeLinesOneDiscount_TotalAndLineValues()
{
var order = new Order();
order.AddLine(20m, 1); // line 0: 20
order.AddLine(30m, 2); // line 1: 60 → 48
order.AddLine(10m, 3); // line 2: 30
order.ApplyDiscount(1);
Assert.That(order.Total, Is.EqualTo(98m));
Assert.That(order.Lines[0].Value, Is.EqualTo(20m));
Assert.That(order.Lines[1].Value, Is.EqualTo(48m));
Assert.That(order.Lines[2].Value, Is.EqualTo(30m));
}
}