commit 9f7d4e6a4f0c53a1f8cc3b426902d6783d7a71e9 Author: Willem van den Ende Date: Wed Sep 30 15:05:04 2026 +0100 Add Order and OrderBook domains with test suites diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b868cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# .NET build output +bin/ +obj/ + +# Test results +TestResults/ + +# IDE +.idea/ +.vs/ +*.user +*.suo + +# OS +.DS_Store + +# dotnet CLI local data (when the CLI home points inside the repo) +.local/ diff --git a/csharp/README.md b/csharp/README.md new file mode 100644 index 0000000..39efad3 --- /dev/null +++ b/csharp/README.md @@ -0,0 +1,24 @@ +# Show, Don't Tell — C# exercise workspace + +Workspace for the "Show, Don't Tell — Using Examples with Coding Agents" exercise (C# / .NET 10 / NUnit). + +## Prerequisites + +- .NET 10 SDK (version pinned in `global.json`) +- If you use [mise](https://mise.jdx.dev): `mise install` installs the SDK listed in `mise.toml` + +## Build and run the tests + +```sh +dotnet build +dotnet test +``` + +## Layout + +| Directory | Contents | +| --- | --- | +| `src/OrderDomain`, `tests/OrderTests` | Order domain with discount behaviour, and its tests | +| `src/OrderBookDomain`, `tests/OrderBookTests` | OrderBook state machine, and its integration tests | + +Open `ShowDontTell.sln` in your IDE (Rider, Visual Studio, VS Code with the C# extension). diff --git a/csharp/ShowDontTell.sln b/csharp/ShowDontTell.sln new file mode 100644 index 0000000..bd31c83 --- /dev/null +++ b/csharp/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}") = "Exercise2", "Exercise2", "{83A6B86A-4CD0-44BE-9FE0-408AC31E69F8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Exercise1", "Exercise1", "{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/csharp/global.json b/csharp/global.json new file mode 100644 index 0000000..01cb658 --- /dev/null +++ b/csharp/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature" + } +} diff --git a/csharp/mise.toml b/csharp/mise.toml new file mode 100644 index 0000000..df386ef --- /dev/null +++ b/csharp/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "10" diff --git a/csharp/src/OrderBookDomain/Order.cs b/csharp/src/OrderBookDomain/Order.cs new file mode 100644 index 0000000..8fb4293 --- /dev/null +++ b/csharp/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/csharp/src/OrderBookDomain/OrderBook.cs b/csharp/src/OrderBookDomain/OrderBook.cs new file mode 100644 index 0000000..6ce8098 --- /dev/null +++ b/csharp/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/csharp/src/OrderBookDomain/OrderBookDomain.csproj b/csharp/src/OrderBookDomain/OrderBookDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/csharp/src/OrderBookDomain/OrderBookDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/csharp/src/OrderDomain/Order.cs b/csharp/src/OrderDomain/Order.cs new file mode 100644 index 0000000..ca509ec --- /dev/null +++ b/csharp/src/OrderDomain/Order.cs @@ -0,0 +1,18 @@ +namespace OrderDomain; + +public class Order +{ + public List Lines { get; } = new(); + + public decimal Total => Lines.Sum(l => l.Value); + + public bool IsCompleted { get; private set; } + + public void AddLine(decimal unitPrice, int quantity) + => Lines.Add(new OrderLine(unitPrice, quantity)); + + public void ApplyDiscount(int lineIndex) + => Lines[lineIndex].ApplyDiscount(); + + public void Complete() => IsCompleted = true; +} diff --git a/csharp/src/OrderDomain/OrderDomain.csproj b/csharp/src/OrderDomain/OrderDomain.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/csharp/src/OrderDomain/OrderDomain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/csharp/src/OrderDomain/OrderLine.cs b/csharp/src/OrderDomain/OrderLine.cs new file mode 100644 index 0000000..dbd1431 --- /dev/null +++ b/csharp/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/csharp/tests/OrderBookTests/OrderBookLifecycleTests.cs b/csharp/tests/OrderBookTests/OrderBookLifecycleTests.cs new file mode 100644 index 0000000..599af5f --- /dev/null +++ b/csharp/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/csharp/tests/OrderBookTests/OrderBookTests.csproj b/csharp/tests/OrderBookTests/OrderBookTests.csproj new file mode 100644 index 0000000..71067ec --- /dev/null +++ b/csharp/tests/OrderBookTests/OrderBookTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/csharp/tests/OrderTests/OrderCompletionTests.cs b/csharp/tests/OrderTests/OrderCompletionTests.cs new file mode 100644 index 0000000..74b6acc --- /dev/null +++ b/csharp/tests/OrderTests/OrderCompletionTests.cs @@ -0,0 +1,17 @@ +using NUnit.Framework; +using OrderDomain; + +namespace OrderTests; + +public class OrderCompletionTests +{ + [Test] + public void CompletedOrder_TotalIsCorrect() + { + var order = new Order(); + order.AddLine(100m, 1); + order.Complete(); + + Assert.That(order.Total, Is.EqualTo(100m)); + } +} diff --git a/csharp/tests/OrderTests/OrderTests.csproj b/csharp/tests/OrderTests/OrderTests.csproj new file mode 100644 index 0000000..b33d1de --- /dev/null +++ b/csharp/tests/OrderTests/OrderTests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/csharp/tests/OrderTests/OrderTotalTests.cs b/csharp/tests/OrderTests/OrderTotalTests.cs new file mode 100644 index 0000000..682a2b8 --- /dev/null +++ b/csharp/tests/OrderTests/OrderTotalTests.cs @@ -0,0 +1,90 @@ +using NUnit.Framework; +using OrderDomain; + +namespace OrderTests; + +public class OrderTotalTests +{ + [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.Total, Is.EqualTo(210m)); + Assert.That(order.Lines[0].Value, Is.EqualTo(160m)); + Assert.That(order.Lines[1].Value, Is.EqualTo(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)); + } +}