From 4228ccb03034063f20e1ebab55ba352070899d19 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Thu, 3 Sep 2026 21:06:39 +0100 Subject: [PATCH] initial commit v2 --- .gitignore | 22 ++++ ShowDontTell.sln | 45 +++++++ TestResults/probe | 0 global.json | 6 + integration/OrderBookDomain/Order.cs | 25 ++++ integration/OrderBookDomain/OrderBook.cs | 69 +++++++++++ .../OrderBookDomain/OrderBookDomain.csproj | 7 ++ .../OrderBookTests/OrderBookLifecycleTests.cs | 114 ++++++++++++++++++ .../OrderBookTests/OrderBookTests.csproj | 16 +++ mise.toml | 2 + src/OrderBookDomain/Order.cs | 25 ++++ src/OrderBookDomain/OrderBook.cs | 69 +++++++++++ src/OrderBookDomain/OrderBookDomain.csproj | 7 ++ src/OrderDomain/Order.cs | 14 +++ src/OrderDomain/OrderDomain.csproj | 7 ++ src/OrderDomain/OrderLine.cs | 19 +++ .../OrderBookTests/OrderBookLifecycleTests.cs | 114 ++++++++++++++++++ tests/OrderBookTests/OrderBookTests.csproj | 16 +++ tests/OrderTests/OrderStateConstraint.cs | 74 ++++++++++++ tests/OrderTests/OrderTests.csproj | 16 +++ tests/OrderTests/OrderTotalTests.cs | 92 ++++++++++++++ warmup/OrderDomain/Order.cs | 14 +++ warmup/OrderDomain/OrderDomain.csproj | 7 ++ warmup/OrderDomain/OrderLine.cs | 19 +++ warmup/OrderTests/OrderStateConstraint.cs | 74 ++++++++++++ warmup/OrderTests/OrderTests.csproj | 16 +++ warmup/OrderTests/OrderTotalTests.cs | 92 ++++++++++++++ 27 files changed, 981 insertions(+) create mode 100644 .gitignore create mode 100644 ShowDontTell.sln create mode 100644 TestResults/probe create mode 100644 global.json create mode 100644 integration/OrderBookDomain/Order.cs create mode 100644 integration/OrderBookDomain/OrderBook.cs create mode 100644 integration/OrderBookDomain/OrderBookDomain.csproj create mode 100644 integration/OrderBookTests/OrderBookLifecycleTests.cs create mode 100644 integration/OrderBookTests/OrderBookTests.csproj create mode 100644 mise.toml create mode 100644 src/OrderBookDomain/Order.cs create mode 100644 src/OrderBookDomain/OrderBook.cs create mode 100644 src/OrderBookDomain/OrderBookDomain.csproj create mode 100644 src/OrderDomain/Order.cs create mode 100644 src/OrderDomain/OrderDomain.csproj create mode 100644 src/OrderDomain/OrderLine.cs create mode 100644 tests/OrderBookTests/OrderBookLifecycleTests.cs create mode 100644 tests/OrderBookTests/OrderBookTests.csproj create mode 100644 tests/OrderTests/OrderStateConstraint.cs create mode 100644 tests/OrderTests/OrderTests.csproj create mode 100644 tests/OrderTests/OrderTotalTests.cs create mode 100644 warmup/OrderDomain/Order.cs create mode 100644 warmup/OrderDomain/OrderDomain.csproj create mode 100644 warmup/OrderDomain/OrderLine.cs create mode 100644 warmup/OrderTests/OrderStateConstraint.cs create mode 100644 warmup/OrderTests/OrderTests.csproj create mode 100644 warmup/OrderTests/OrderTotalTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85a6a73 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/ShowDontTell.sln b/ShowDontTell.sln new file mode 100644 index 0000000..eac3402 --- /dev/null +++ b/ShowDontTell.sln @@ -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 diff --git a/TestResults/probe b/TestResults/probe new file mode 100644 index 0000000..e69de29 diff --git a/global.json b/global.json new file mode 100644 index 0000000..01cb658 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature" + } +} diff --git a/integration/OrderBookDomain/Order.cs b/integration/OrderBookDomain/Order.cs new file mode 100644 index 0000000..8fb4293 --- /dev/null +++ b/integration/OrderBookDomain/Order.cs @@ -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; + } +} diff --git a/integration/OrderBookDomain/OrderBook.cs b/integration/OrderBookDomain/OrderBook.cs new file mode 100644 index 0000000..6ce8098 --- /dev/null +++ b/integration/OrderBookDomain/OrderBook.cs @@ -0,0 +1,69 @@ +namespace OrderBookDomain; + +public class OrderBook +{ + private readonly TimeSpan _paymentWindow; + private readonly Dictionary _ordersById = new(); + private int _nextId = 1; + + public OrderBook(TimeSpan? paymentWindow = null) + => _paymentWindow = paymentWindow ?? TimeSpan.FromDays(14); + + public IReadOnlyList 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})."); + } +} diff --git a/integration/OrderBookDomain/OrderBookDomain.csproj b/integration/OrderBookDomain/OrderBookDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/integration/OrderBookDomain/OrderBookDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/integration/OrderBookTests/OrderBookLifecycleTests.cs b/integration/OrderBookTests/OrderBookLifecycleTests.cs new file mode 100644 index 0000000..599af5f --- /dev/null +++ b/integration/OrderBookTests/OrderBookLifecycleTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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)); + } +} diff --git a/integration/OrderBookTests/OrderBookTests.csproj b/integration/OrderBookTests/OrderBookTests.csproj new file mode 100644 index 0000000..98c5bd8 --- /dev/null +++ b/integration/OrderBookTests/OrderBookTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..df386ef --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "10" diff --git a/src/OrderBookDomain/Order.cs b/src/OrderBookDomain/Order.cs new file mode 100644 index 0000000..8fb4293 --- /dev/null +++ b/src/OrderBookDomain/Order.cs @@ -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; + } +} diff --git a/src/OrderBookDomain/OrderBook.cs b/src/OrderBookDomain/OrderBook.cs new file mode 100644 index 0000000..6ce8098 --- /dev/null +++ b/src/OrderBookDomain/OrderBook.cs @@ -0,0 +1,69 @@ +namespace OrderBookDomain; + +public class OrderBook +{ + private readonly TimeSpan _paymentWindow; + private readonly Dictionary _ordersById = new(); + private int _nextId = 1; + + public OrderBook(TimeSpan? paymentWindow = null) + => _paymentWindow = paymentWindow ?? TimeSpan.FromDays(14); + + public IReadOnlyList 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})."); + } +} diff --git a/src/OrderBookDomain/OrderBookDomain.csproj b/src/OrderBookDomain/OrderBookDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/src/OrderBookDomain/OrderBookDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/src/OrderDomain/Order.cs b/src/OrderDomain/Order.cs new file mode 100644 index 0000000..58114ff --- /dev/null +++ b/src/OrderDomain/Order.cs @@ -0,0 +1,14 @@ +namespace OrderDomain; + +public class Order +{ + public List 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(); +} diff --git a/src/OrderDomain/OrderDomain.csproj b/src/OrderDomain/OrderDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/src/OrderDomain/OrderDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/src/OrderDomain/OrderLine.cs b/src/OrderDomain/OrderLine.cs new file mode 100644 index 0000000..dbd1431 --- /dev/null +++ b/src/OrderDomain/OrderLine.cs @@ -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; +} diff --git a/tests/OrderBookTests/OrderBookLifecycleTests.cs b/tests/OrderBookTests/OrderBookLifecycleTests.cs new file mode 100644 index 0000000..599af5f --- /dev/null +++ b/tests/OrderBookTests/OrderBookLifecycleTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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)); + } +} diff --git a/tests/OrderBookTests/OrderBookTests.csproj b/tests/OrderBookTests/OrderBookTests.csproj new file mode 100644 index 0000000..71067ec --- /dev/null +++ b/tests/OrderBookTests/OrderBookTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/tests/OrderTests/OrderStateConstraint.cs b/tests/OrderTests/OrderStateConstraint.cs new file mode 100644 index 0000000..430ed5f --- /dev/null +++ b/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/tests/OrderTests/OrderTests.csproj b/tests/OrderTests/OrderTests.csproj new file mode 100644 index 0000000..b33d1de --- /dev/null +++ b/tests/OrderTests/OrderTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/tests/OrderTests/OrderTotalTests.cs b/tests/OrderTests/OrderTotalTests.cs new file mode 100644 index 0000000..94608e5 --- /dev/null +++ b/tests/OrderTests/OrderTotalTests.cs @@ -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)); + } +} \ No newline at end of file diff --git a/warmup/OrderDomain/Order.cs b/warmup/OrderDomain/Order.cs new file mode 100644 index 0000000..58114ff --- /dev/null +++ b/warmup/OrderDomain/Order.cs @@ -0,0 +1,14 @@ +namespace OrderDomain; + +public class Order +{ + public List 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(); +} diff --git a/warmup/OrderDomain/OrderDomain.csproj b/warmup/OrderDomain/OrderDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/warmup/OrderDomain/OrderDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/warmup/OrderDomain/OrderLine.cs b/warmup/OrderDomain/OrderLine.cs new file mode 100644 index 0000000..dbd1431 --- /dev/null +++ b/warmup/OrderDomain/OrderLine.cs @@ -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; +} diff --git a/warmup/OrderTests/OrderStateConstraint.cs b/warmup/OrderTests/OrderStateConstraint.cs new file mode 100644 index 0000000..430ed5f --- /dev/null +++ b/warmup/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/warmup/OrderTests/OrderTests.csproj b/warmup/OrderTests/OrderTests.csproj new file mode 100644 index 0000000..4b90ecd --- /dev/null +++ b/warmup/OrderTests/OrderTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/warmup/OrderTests/OrderTotalTests.cs b/warmup/OrderTests/OrderTotalTests.cs new file mode 100644 index 0000000..94608e5 --- /dev/null +++ b/warmup/OrderTests/OrderTotalTests.cs @@ -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)); + } +} \ No newline at end of file