Compare commits
13
Commits
fb3a593e90
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334567d9c5 | ||
|
|
4e0aea732a | ||
|
|
5dd7a9ef2c | ||
|
|
0749062ce1 | ||
|
|
974053625d | ||
|
|
2831b8c3f9 | ||
|
|
f927998149 | ||
|
|
8841912bdc | ||
|
|
83e1c79ab7 | ||
|
|
d9f78162e7 | ||
|
|
035ccb5e27 | ||
|
|
e41dcaf4fe | ||
|
|
bd94df4439 |
+17
@@ -16,3 +16,20 @@ TestResults/
|
|||||||
|
|
||||||
# dotnet CLI local data (when the CLI home points inside the repo)
|
# dotnet CLI local data (when the CLI home points inside the repo)
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
## yak tracker
|
||||||
|
.yaks
|
||||||
|
|
||||||
|
## Java
|
||||||
|
target/
|
||||||
|
.m2repo/
|
||||||
|
|
||||||
|
## Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.uv-cache/
|
||||||
|
.uv-managed-python/
|
||||||
|
.pi/
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Show, Don't Tell — Using Examples with Coding Agents, Learning Hour
|
||||||
|
|
||||||
|
This Repository has been adopted by the [Samman Coaching Society](https://www.sammancoaching.org). It's new location is [on github](https://github.com/sammancoaching/show-dont-tell).
|
||||||
|
|
||||||
@@ -2,12 +2,22 @@ namespace OrderDomain;
|
|||||||
|
|
||||||
public class Order
|
public class Order
|
||||||
{
|
{
|
||||||
|
private readonly List<decimal> payments = new();
|
||||||
|
|
||||||
public List<OrderLine> Lines { get; } = new();
|
public List<OrderLine> Lines { get; } = new();
|
||||||
|
|
||||||
public decimal Total => Lines.Sum(l => l.Value);
|
public decimal Total => Lines.Sum(l => l.Value);
|
||||||
|
|
||||||
public bool IsCompleted { get; private set; }
|
public bool IsCompleted { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<decimal> Payments => payments;
|
||||||
|
|
||||||
|
public decimal AmountPaid => payments.Sum();
|
||||||
|
|
||||||
|
public decimal Outstanding => Total - AmountPaid;
|
||||||
|
|
||||||
|
public bool IsFullyPaid => IsCompleted && Outstanding == 0m;
|
||||||
|
|
||||||
public void AddLine(decimal unitPrice, int quantity)
|
public void AddLine(decimal unitPrice, int quantity)
|
||||||
=> Lines.Add(new OrderLine(unitPrice, quantity));
|
=> Lines.Add(new OrderLine(unitPrice, quantity));
|
||||||
|
|
||||||
@@ -16,6 +26,14 @@ public class Order
|
|||||||
|
|
||||||
public void Complete() => IsCompleted = true;
|
public void Complete() => IsCompleted = true;
|
||||||
|
|
||||||
public bool IsFullyPaid(decimal expectedTotal)
|
public void Pay(decimal amount)
|
||||||
=> IsCompleted && Total == expectedTotal;
|
{
|
||||||
|
if (!IsCompleted)
|
||||||
|
throw new InvalidOperationException("Cannot pay an open order.");
|
||||||
|
if (amount <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(amount), amount, "Payment must be positive.");
|
||||||
|
if (amount > Outstanding)
|
||||||
|
throw new InvalidOperationException("Payment exceeds outstanding amount.");
|
||||||
|
payments.Add(amount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,61 @@ public class OrderCompletionTests
|
|||||||
order.AddLine(100m, 1);
|
order.AddLine(100m, 1);
|
||||||
order.Complete();
|
order.Complete();
|
||||||
|
|
||||||
Assert.That(order.IsFullyPaid(100m), Is.True);
|
Assert.That(order.IsCompleted && order.Total == 100m, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PaymentOnOpenOrder_Throws_OrderStillUnpaid()
|
||||||
|
{
|
||||||
|
var order = new Order();
|
||||||
|
order.AddLine(100m, 1);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => order.Pay(100m));
|
||||||
|
|
||||||
|
Assert.That(order.IsCompleted, Is.False);
|
||||||
|
Assert.That(order.AmountPaid, Is.EqualTo(0m));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CompletedOrder_PartialPayment_IsNotFullyPaid()
|
||||||
|
{
|
||||||
|
var order = new Order();
|
||||||
|
order.AddLine(100m, 1);
|
||||||
|
order.Complete();
|
||||||
|
|
||||||
|
order.Pay(40m);
|
||||||
|
|
||||||
|
Assert.That(order.AmountPaid, Is.EqualTo(40m));
|
||||||
|
Assert.That(order.Outstanding, Is.EqualTo(60m));
|
||||||
|
Assert.That(order.IsFullyPaid, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CompletedOrder_PaymentsSummingToTotal_IsFullyPaid()
|
||||||
|
{
|
||||||
|
var order = new Order();
|
||||||
|
order.AddLine(100m, 1);
|
||||||
|
order.Complete();
|
||||||
|
|
||||||
|
order.Pay(40m);
|
||||||
|
order.Pay(60m);
|
||||||
|
|
||||||
|
Assert.That(order.Payments, Is.EqualTo(new[] { 40m, 60m }));
|
||||||
|
Assert.That(order.Outstanding, Is.EqualTo(0m));
|
||||||
|
Assert.That(order.IsFullyPaid, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Overpayment_Throws_AmountPaidUnchanged()
|
||||||
|
{
|
||||||
|
var order = new Order();
|
||||||
|
order.AddLine(100m, 1);
|
||||||
|
order.Complete();
|
||||||
|
order.Pay(100m);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => order.Pay(10m));
|
||||||
|
|
||||||
|
Assert.That(order.AmountPaid, Is.EqualTo(100m));
|
||||||
|
Assert.That(order.IsFullyPaid, Is.True);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Show, Don't Tell — Java exercise workspace
|
||||||
|
|
||||||
|
Java track for the "Show, Don't Tell — Using Examples with Coding Agents" exercise (Java 21 / Maven / JUnit 5 / AssertJ).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Java 21 (Temurin) and Maven, versions pinned in `mise.toml`
|
||||||
|
- If you use [mise](https://mise.jdx.dev): `mise install` installs both
|
||||||
|
|
||||||
|
## Build and run the tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Module | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `order-domain`, `order-tests` | Order domain with discount behaviour, and its tests (Round 1) |
|
||||||
|
| `order-book-domain`, `order-book-tests` | OrderBook state machine, and its integration tests (Round 2) |
|
||||||
|
|
||||||
|
The exercise starts in its START state: all tests are green, the refactoring participants should do is NOT applied. The custom matcher example lives in `order-tests` (`OrderStateAssertion`).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[tools]
|
||||||
|
java = "temurin-21.0.9+10.0.LTS"
|
||||||
|
maven = "3.9.16"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
# Keep the Maven local repository inside the workspace (sandboxes often block ~/.m2)
|
||||||
|
MAVEN_OPTS = "-Dmaven.repo.local=.m2repo"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>show-dont-tell-java</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>order-book-domain</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package orderbookdomain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class Order {
|
||||||
|
|
||||||
|
private final int id;
|
||||||
|
private final String customer;
|
||||||
|
|
||||||
|
private OrderStatus status;
|
||||||
|
private Instant finalizedAt;
|
||||||
|
|
||||||
|
Order(int id, String customer) {
|
||||||
|
this.id = id;
|
||||||
|
this.customer = customer;
|
||||||
|
this.status = OrderStatus.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCustomer() {
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getFinalizedAt() {
|
||||||
|
return finalizedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setStatus(OrderStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFinalizedAt(Instant finalizedAt) {
|
||||||
|
this.finalizedAt = finalizedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package orderbookdomain;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class OrderBook {
|
||||||
|
|
||||||
|
private final Duration paymentWindow;
|
||||||
|
private final Map<Integer, Order> ordersById = new HashMap<>();
|
||||||
|
private int nextId = 1;
|
||||||
|
|
||||||
|
public OrderBook() {
|
||||||
|
this(Duration.ofDays(14));
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderBook(Duration paymentWindow) {
|
||||||
|
this.paymentWindow = paymentWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Order> getOrders() {
|
||||||
|
return List.copyOf(ordersById.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order openOrder(String customer) {
|
||||||
|
Order order = new Order(nextId++, customer);
|
||||||
|
ordersById.put(order.getId(), order);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order getOrder(int orderId) {
|
||||||
|
Order order = ordersById.get(orderId);
|
||||||
|
if (order == null) {
|
||||||
|
throw new IllegalStateException("Unknown order id " + orderId + ".");
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finalizeOrder(int orderId, Instant finalizedAt) {
|
||||||
|
Order order = getOrder(orderId);
|
||||||
|
requireStatus(order, OrderStatus.PENDING, "finalize");
|
||||||
|
order.setStatus(OrderStatus.WAITING_FOR_PAYMENT);
|
||||||
|
order.setFinalizedAt(finalizedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void payOrder(int orderId, Instant paidAt) {
|
||||||
|
Order order = getOrder(orderId);
|
||||||
|
requireStatus(order, OrderStatus.WAITING_FOR_PAYMENT, "pay");
|
||||||
|
if (Duration.between(order.getFinalizedAt(), paidAt).compareTo(paymentWindow) > 0) {
|
||||||
|
order.setStatus(OrderStatus.CANCELLED);
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Payment for order " + orderId + " arrived after the payment window; the order was cancelled.");
|
||||||
|
}
|
||||||
|
order.setStatus(OrderStatus.FULFILLING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shipOrder(int orderId) {
|
||||||
|
Order order = getOrder(orderId);
|
||||||
|
requireStatus(order, OrderStatus.FULFILLING, "ship");
|
||||||
|
order.setStatus(OrderStatus.DELIVERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelOrder(int orderId) {
|
||||||
|
Order order = getOrder(orderId);
|
||||||
|
if (order.getStatus() != OrderStatus.PENDING && order.getStatus() != OrderStatus.WAITING_FOR_PAYMENT) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot cancel order " + orderId + " in status " + order.getStatus() + ".");
|
||||||
|
}
|
||||||
|
order.setStatus(OrderStatus.CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireStatus(Order order, OrderStatus expected, String action) {
|
||||||
|
if (order.getStatus() != expected) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot " + action + " order " + order.getId() + " in status " + order.getStatus()
|
||||||
|
+ " (expected " + expected + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package orderbookdomain;
|
||||||
|
|
||||||
|
public enum OrderStatus {
|
||||||
|
PENDING,
|
||||||
|
WAITING_FOR_PAYMENT,
|
||||||
|
FULFILLING,
|
||||||
|
DELIVERED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>show-dont-tell-java</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>order-book-tests</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>order-book-domain</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<version>${assertj.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package orderbooktests;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import orderbookdomain.OrderBook;
|
||||||
|
import orderbookdomain.OrderStatus;
|
||||||
|
|
||||||
|
class OrderBookLifecycleTests {
|
||||||
|
|
||||||
|
private static final Instant DAY_0 = Instant.parse("2025-06-01T00:00:00Z");
|
||||||
|
|
||||||
|
private static Instant at(int day) {
|
||||||
|
return DAY_0.plus(day, ChronoUnit.DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalizeThenPay_movesOrderThroughTwoStates() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var order = book.openOrder("anna");
|
||||||
|
|
||||||
|
book.finalizeOrder(order.getId(), at(1));
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT);
|
||||||
|
|
||||||
|
book.payOrder(order.getId(), at(2));
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payThenShip_deliversTheOrder() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var order = book.openOrder("anna");
|
||||||
|
|
||||||
|
book.finalizeOrder(order.getId(), at(1));
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT);
|
||||||
|
|
||||||
|
book.payOrder(order.getId(), at(2));
|
||||||
|
book.shipOrder(order.getId());
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payWithinWindow_thenLatePayOnSecondOrder_cancelsOnlyThatOrder() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var first = book.openOrder("anna");
|
||||||
|
var second = book.openOrder("bram");
|
||||||
|
|
||||||
|
book.finalizeOrder(first.getId(), at(1));
|
||||||
|
book.finalizeOrder(second.getId(), at(1));
|
||||||
|
assertThat(book.getOrders()).hasSize(2);
|
||||||
|
|
||||||
|
book.payOrder(first.getId(), at(5));
|
||||||
|
assertThat(book.getOrder(first.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> book.payOrder(second.getId(), at(30)))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(book.getOrder(second.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payBeforeFinalize_throws_thenFinalizeStillWorks() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var order = book.openOrder("anna");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> book.payOrder(order.getId(), at(1)))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.PENDING);
|
||||||
|
|
||||||
|
book.finalizeOrder(order.getId(), at(2));
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelPendingOrder_thenPayAfterCancel_throwsAgain() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var order = book.openOrder("anna");
|
||||||
|
|
||||||
|
book.cancelOrder(order.getId());
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> book.payOrder(order.getId(), at(1)))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shippedOrder_cannotBeCancelled() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var order = book.openOrder("anna");
|
||||||
|
|
||||||
|
book.finalizeOrder(order.getId(), at(1));
|
||||||
|
book.payOrder(order.getId(), at(2));
|
||||||
|
book.shipOrder(order.getId());
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> book.cancelOrder(order.getId()))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoCustomers_interleaveTransitions_independently() {
|
||||||
|
OrderBook book = new OrderBook();
|
||||||
|
var annas = book.openOrder("anna");
|
||||||
|
var brams = book.openOrder("bram");
|
||||||
|
|
||||||
|
book.finalizeOrder(brams.getId(), at(1));
|
||||||
|
assertThat(book.getOrder(annas.getId()).getStatus()).isEqualTo(OrderStatus.PENDING);
|
||||||
|
assertThat(book.getOrder(brams.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT);
|
||||||
|
|
||||||
|
book.payOrder(brams.getId(), at(2));
|
||||||
|
book.cancelOrder(annas.getId());
|
||||||
|
assertThat(book.getOrder(annas.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED);
|
||||||
|
assertThat(book.getOrder(brams.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>show-dont-tell-java</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>order-domain</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package orderdomain;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Order {
|
||||||
|
|
||||||
|
private final List<OrderLine> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
private boolean completed;
|
||||||
|
|
||||||
|
public List<OrderLine> getLines() {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTotal() {
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (OrderLine line : lines) {
|
||||||
|
total = total.add(line.getValue());
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCompleted() {
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addLine(BigDecimal unitPrice, int quantity) {
|
||||||
|
lines.add(new OrderLine(unitPrice, quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyDiscount(int lineIndex) {
|
||||||
|
lines.get(lineIndex).applyDiscount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void complete() {
|
||||||
|
completed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C# decimal == is numeric (scale-insensitive); BigDecimal.equals is
|
||||||
|
// scale-sensitive, so compare numerically with compareTo.
|
||||||
|
public boolean isFullyPaid(BigDecimal expectedTotal) {
|
||||||
|
return completed && getTotal().compareTo(expectedTotal) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package orderdomain;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public class OrderLine {
|
||||||
|
|
||||||
|
private final BigDecimal unitPrice;
|
||||||
|
private final int quantity;
|
||||||
|
private boolean hasDiscount;
|
||||||
|
|
||||||
|
public OrderLine(BigDecimal unitPrice, int quantity) {
|
||||||
|
this.unitPrice = unitPrice;
|
||||||
|
this.quantity = quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getUnitPrice() {
|
||||||
|
return unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQuantity() {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasDiscount() {
|
||||||
|
return hasDiscount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getValue() {
|
||||||
|
BigDecimal value = unitPrice.multiply(BigDecimal.valueOf(quantity));
|
||||||
|
return hasDiscount ? value.multiply(new BigDecimal("0.8")) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyDiscount() {
|
||||||
|
hasDiscount = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>show-dont-tell-java</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>order-tests</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>order-domain</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<version>${assertj.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package ordertests;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import orderdomain.Order;
|
||||||
|
|
||||||
|
class OrderCompletionTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedOrder_totalIsCorrect() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("100"), 1);
|
||||||
|
order.complete();
|
||||||
|
|
||||||
|
assertThat(order.isFullyPaid(new BigDecimal("100"))).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ordertests;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.assertj.core.api.AbstractAssert;
|
||||||
|
|
||||||
|
import orderdomain.Order;
|
||||||
|
import orderdomain.OrderLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom AssertJ matcher that bundles multiple order assertions into one.
|
||||||
|
* <p>
|
||||||
|
* Participants: you do not need to understand how this works —
|
||||||
|
* just recognize the pattern and use it as an example for the agent.
|
||||||
|
*/
|
||||||
|
public class OrderStateAssertion extends AbstractAssert<OrderStateAssertion, Order> {
|
||||||
|
|
||||||
|
private OrderStateAssertion(Order actual) {
|
||||||
|
super(actual, OrderStateAssertion.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrderStateAssertion assertThat(Order actual) {
|
||||||
|
return new OrderStateAssertion(actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderStateAssertion hasState(BigDecimal expectedTotal, List<BigDecimal> expectedLineValues) {
|
||||||
|
isNotNull();
|
||||||
|
|
||||||
|
List<String> details = new ArrayList<>();
|
||||||
|
|
||||||
|
BigDecimal actualTotal = actual.getTotal();
|
||||||
|
if (actualTotal.compareTo(expectedTotal) != 0) {
|
||||||
|
details.add("Total: expected " + expectedTotal + ", but was " + actualTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BigDecimal> actualLineValues = new ArrayList<>();
|
||||||
|
for (OrderLine line : actual.getLines()) {
|
||||||
|
actualLineValues.add(line.getValue());
|
||||||
|
}
|
||||||
|
if (!lineValuesMatch(actualLineValues, expectedLineValues)) {
|
||||||
|
details.add("Line values: expected " + expectedLineValues
|
||||||
|
+ ", but was " + actualLineValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!details.isEmpty()) {
|
||||||
|
failWithMessage("Expected order with total " + expectedTotal
|
||||||
|
+ " and line values " + expectedLineValues + " but was:\n"
|
||||||
|
+ String.join("\n", details));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean lineValuesMatch(List<BigDecimal> actual, List<BigDecimal> expected) {
|
||||||
|
if (actual.size() != expected.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < expected.size(); i++) {
|
||||||
|
if (actual.get(i).compareTo(expected.get(i)) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package ordertests;
|
||||||
|
|
||||||
|
import static ordertests.OrderStateAssertion.assertThat;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import orderdomain.Order;
|
||||||
|
|
||||||
|
class OrderTotalTests {
|
||||||
|
|
||||||
|
// This test was refactored — notice the custom matcher.
|
||||||
|
// The other tests still need the same treatment.
|
||||||
|
@Test
|
||||||
|
void discountOnFirstLine_totalAndLineValues() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("100"), 2); // line 0: 200 → 160 with discount
|
||||||
|
order.addLine(new BigDecimal("50"), 1); // line 1: 50
|
||||||
|
|
||||||
|
order.applyDiscount(0);
|
||||||
|
|
||||||
|
assertThat(order).hasState(
|
||||||
|
new BigDecimal("210"),
|
||||||
|
List.of(new BigDecimal("160"), new BigDecimal("50")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discountOnSecondLine_totalAndLineValues() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("100"), 2); // line 0: 200
|
||||||
|
order.addLine(new BigDecimal("50"), 1); // line 1: 50 → 40 with discount
|
||||||
|
|
||||||
|
order.applyDiscount(1);
|
||||||
|
|
||||||
|
assertThat(order.getTotal()).isEqualByComparingTo("240");
|
||||||
|
assertThat(order.getLines().get(0).getValue()).isEqualByComparingTo("200");
|
||||||
|
assertThat(order.getLines().get(1).getValue()).isEqualByComparingTo("40");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discountOnBothLines_totalAndLineValues() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("100"), 2); // line 0: 200 → 160
|
||||||
|
order.addLine(new BigDecimal("50"), 1); // line 1: 50 → 40
|
||||||
|
|
||||||
|
order.applyDiscount(0);
|
||||||
|
order.applyDiscount(1);
|
||||||
|
|
||||||
|
assertThat(order.getTotal()).isEqualByComparingTo("200");
|
||||||
|
assertThat(order.getLines().get(0).getValue()).isEqualByComparingTo("160");
|
||||||
|
assertThat(order.getLines().get(1).getValue()).isEqualByComparingTo("40");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noDiscount_totalAndLineValues() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("100"), 2); // line 0: 200
|
||||||
|
order.addLine(new BigDecimal("50"), 1); // line 1: 50
|
||||||
|
|
||||||
|
assertThat(order.getTotal()).isEqualByComparingTo("250");
|
||||||
|
assertThat(order.getLines().get(0).getValue()).isEqualByComparingTo("200");
|
||||||
|
assertThat(order.getLines().get(1).getValue()).isEqualByComparingTo("50");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleLineWithDiscount_totalAndValue() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("75"), 4); // line 0: 300 → 240
|
||||||
|
|
||||||
|
order.applyDiscount(0);
|
||||||
|
|
||||||
|
assertThat(order.getTotal()).isEqualByComparingTo("240");
|
||||||
|
assertThat(order.getLines().get(0).getValue()).isEqualByComparingTo("240");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void threeLinesOneDiscount_totalAndLineValues() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.addLine(new BigDecimal("20"), 1); // line 0: 20
|
||||||
|
order.addLine(new BigDecimal("30"), 2); // line 1: 60 → 48
|
||||||
|
order.addLine(new BigDecimal("10"), 3); // line 2: 30
|
||||||
|
|
||||||
|
order.applyDiscount(1);
|
||||||
|
|
||||||
|
assertThat(order.getTotal()).isEqualByComparingTo("98");
|
||||||
|
assertThat(order.getLines().get(0).getValue()).isEqualByComparingTo("20");
|
||||||
|
assertThat(order.getLines().get(1).getValue()).isEqualByComparingTo("48");
|
||||||
|
assertThat(order.getLines().get(2).getValue()).isEqualByComparingTo("30");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>dev.learninghour.showdonttell</groupId>
|
||||||
|
<artifactId>show-dont-tell-java</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>order-domain</module>
|
||||||
|
<module>order-book-domain</module>
|
||||||
|
<module>order-tests</module>
|
||||||
|
<module>order-book-tests</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<junit.version>5.14.4</junit.version>
|
||||||
|
<assertj.version>3.27.7</assertj.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.6</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Show, Don't Tell — Python exercise workspace
|
||||||
|
|
||||||
|
Python track for the "Show, Don't Tell — Using Examples with Coding Agents" exercise (Python 3.13 / uv / pytest).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.13 and [uv](https://docs.astral.sh/uv/), versions pinned in `mise.toml`
|
||||||
|
- If you use [mise](https://mise.jdx.dev): `mise install` installs both
|
||||||
|
|
||||||
|
## Set up and run the tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Directory | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/order_domain`, `tests/order_tests` | Order domain with discount behaviour, and its tests (Round 1) |
|
||||||
|
| `src/order_book_domain`, `tests/order_book_tests` | OrderBook state machine, and its integration tests (Round 2) |
|
||||||
|
|
||||||
|
The exercise starts in its START state: all tests are green, the refactoring participants should do is NOT applied. The custom matcher example lives in `tests/order_tests` (`order_state.py`).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[tools]
|
||||||
|
python = "3.13.16"
|
||||||
|
uv = "0.12.21"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
# Keep uv caches/installs inside the workspace (sandboxes often block ~/.local/share/uv)
|
||||||
|
UV_CACHE_DIR = "./.uv-cache"
|
||||||
|
UV_PYTHON_INSTALL_DIR = "./.uv-managed-python"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "showdonttell"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from order_book_domain.order_book import Order, OrderBook, OrderStatus
|
||||||
|
|
||||||
|
__all__ = ["Order", "OrderBook", "OrderStatus"]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(Enum):
|
||||||
|
PENDING = auto()
|
||||||
|
WAITING_FOR_PAYMENT = auto()
|
||||||
|
FULFILLING = auto()
|
||||||
|
DELIVERED = auto()
|
||||||
|
CANCELLED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Order:
|
||||||
|
# Constructed by OrderBook only.
|
||||||
|
def __init__(self, id: int, customer: str) -> None:
|
||||||
|
self.id = id
|
||||||
|
self.customer = customer
|
||||||
|
self.status = OrderStatus.PENDING
|
||||||
|
self.finalized_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderBook:
|
||||||
|
def __init__(self, payment_window: timedelta | None = None) -> None:
|
||||||
|
self.payment_window = payment_window or timedelta(days=14)
|
||||||
|
self._orders_by_id: dict[int, Order] = {}
|
||||||
|
self._next_id = 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def orders(self) -> list[Order]:
|
||||||
|
return list(self._orders_by_id.values())
|
||||||
|
|
||||||
|
def open_order(self, customer: str) -> Order:
|
||||||
|
order = Order(self._next_id, customer)
|
||||||
|
self._next_id += 1
|
||||||
|
self._orders_by_id[order.id] = order
|
||||||
|
return order
|
||||||
|
|
||||||
|
def get_order(self, order_id: int) -> Order:
|
||||||
|
try:
|
||||||
|
return self._orders_by_id[order_id]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"Unknown order id {order_id}.") from None
|
||||||
|
|
||||||
|
def finalize_order(self, order_id: int, finalized_at: datetime) -> None:
|
||||||
|
order = self.get_order(order_id)
|
||||||
|
_require_status(order, OrderStatus.PENDING, "finalize")
|
||||||
|
order.status = OrderStatus.WAITING_FOR_PAYMENT
|
||||||
|
order.finalized_at = finalized_at
|
||||||
|
|
||||||
|
def pay_order(self, order_id: int, paid_at: datetime) -> None:
|
||||||
|
order = self.get_order(order_id)
|
||||||
|
_require_status(order, OrderStatus.WAITING_FOR_PAYMENT, "pay")
|
||||||
|
if paid_at - order.finalized_at > self.payment_window:
|
||||||
|
order.status = OrderStatus.CANCELLED
|
||||||
|
raise ValueError(
|
||||||
|
f"Payment for order {order_id} arrived after the payment window; the order was cancelled."
|
||||||
|
)
|
||||||
|
order.status = OrderStatus.FULFILLING
|
||||||
|
|
||||||
|
def ship_order(self, order_id: int) -> None:
|
||||||
|
order = self.get_order(order_id)
|
||||||
|
_require_status(order, OrderStatus.FULFILLING, "ship")
|
||||||
|
order.status = OrderStatus.DELIVERED
|
||||||
|
|
||||||
|
def cancel_order(self, order_id: int) -> None:
|
||||||
|
order = self.get_order(order_id)
|
||||||
|
if order.status not in (OrderStatus.PENDING, OrderStatus.WAITING_FOR_PAYMENT):
|
||||||
|
raise ValueError(f"Cannot cancel order {order_id} in status {order.status}.")
|
||||||
|
order.status = OrderStatus.CANCELLED
|
||||||
|
|
||||||
|
|
||||||
|
def _require_status(order: Order, expected: OrderStatus, action: str) -> None:
|
||||||
|
if order.status is not expected:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot {action} order {order.id} in status {order.status} (expected {expected})."
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from order_domain.order import Order, OrderLine
|
||||||
|
|
||||||
|
__all__ = ["Order", "OrderLine"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLine:
|
||||||
|
def __init__(self, unit_price: Decimal, quantity: int):
|
||||||
|
self.unit_price = unit_price
|
||||||
|
self.quantity = quantity
|
||||||
|
self.has_discount = False
|
||||||
|
|
||||||
|
def apply_discount(self) -> None:
|
||||||
|
self.has_discount = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> Decimal:
|
||||||
|
if self.has_discount:
|
||||||
|
return self.unit_price * self.quantity * Decimal("0.8")
|
||||||
|
return self.unit_price * self.quantity
|
||||||
|
|
||||||
|
|
||||||
|
class Order:
|
||||||
|
def __init__(self):
|
||||||
|
self.lines: list[OrderLine] = []
|
||||||
|
self.is_completed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
return sum(line.value for line in self.lines)
|
||||||
|
|
||||||
|
def add_line(self, unit_price: Decimal, quantity: int) -> None:
|
||||||
|
self.lines.append(OrderLine(unit_price, quantity))
|
||||||
|
|
||||||
|
def apply_discount(self, line_index: int) -> None:
|
||||||
|
self.lines[line_index].apply_discount()
|
||||||
|
|
||||||
|
def complete(self) -> None:
|
||||||
|
self.is_completed = True
|
||||||
|
|
||||||
|
def is_fully_paid(self, expected_total: Decimal) -> bool:
|
||||||
|
return self.is_completed and self.total == expected_total
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from order_book_domain import OrderBook, OrderStatus
|
||||||
|
|
||||||
|
DAY0 = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def at(day: int) -> datetime:
|
||||||
|
return DAY0 + timedelta(days=day)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_then_pay_moves_order_through_two_states():
|
||||||
|
book = OrderBook()
|
||||||
|
order = book.open_order("anna")
|
||||||
|
|
||||||
|
book.finalize_order(order.id, at(1))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT
|
||||||
|
|
||||||
|
book.pay_order(order.id, at(2))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.FULFILLING
|
||||||
|
|
||||||
|
|
||||||
|
def test_pay_then_ship_delivers_the_order():
|
||||||
|
book = OrderBook()
|
||||||
|
order = book.open_order("anna")
|
||||||
|
|
||||||
|
book.finalize_order(order.id, at(1))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT
|
||||||
|
|
||||||
|
book.pay_order(order.id, at(2))
|
||||||
|
book.ship_order(order.id)
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.DELIVERED
|
||||||
|
|
||||||
|
|
||||||
|
def test_pay_within_window_then_late_pay_on_second_order_cancels_only_that_order():
|
||||||
|
book = OrderBook()
|
||||||
|
first = book.open_order("anna")
|
||||||
|
second = book.open_order("bram")
|
||||||
|
|
||||||
|
book.finalize_order(first.id, at(1))
|
||||||
|
book.finalize_order(second.id, at(1))
|
||||||
|
assert len(book.orders) == 2
|
||||||
|
|
||||||
|
book.pay_order(first.id, at(5))
|
||||||
|
assert book.get_order(first.id).status is OrderStatus.FULFILLING
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
book.pay_order(second.id, at(30))
|
||||||
|
assert book.get_order(second.id).status is OrderStatus.CANCELLED
|
||||||
|
|
||||||
|
|
||||||
|
def test_pay_before_finalize_throws_then_finalize_still_works():
|
||||||
|
book = OrderBook()
|
||||||
|
order = book.open_order("anna")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
book.pay_order(order.id, at(1))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.PENDING
|
||||||
|
|
||||||
|
book.finalize_order(order.id, at(2))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_pending_order_then_pay_after_cancel_throws_again():
|
||||||
|
book = OrderBook()
|
||||||
|
order = book.open_order("anna")
|
||||||
|
|
||||||
|
book.cancel_order(order.id)
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.CANCELLED
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
book.pay_order(order.id, at(1))
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.CANCELLED
|
||||||
|
|
||||||
|
|
||||||
|
def test_shipped_order_cannot_be_cancelled():
|
||||||
|
book = OrderBook()
|
||||||
|
order = book.open_order("anna")
|
||||||
|
|
||||||
|
book.finalize_order(order.id, at(1))
|
||||||
|
book.pay_order(order.id, at(2))
|
||||||
|
book.ship_order(order.id)
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.DELIVERED
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
book.cancel_order(order.id)
|
||||||
|
assert book.get_order(order.id).status is OrderStatus.DELIVERED
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_customers_interleave_transitions_independently():
|
||||||
|
book = OrderBook()
|
||||||
|
annas = book.open_order("anna")
|
||||||
|
brams = book.open_order("bram")
|
||||||
|
|
||||||
|
book.finalize_order(brams.id, at(1))
|
||||||
|
assert book.get_order(annas.id).status is OrderStatus.PENDING
|
||||||
|
assert book.get_order(brams.id).status is OrderStatus.WAITING_FOR_PAYMENT
|
||||||
|
|
||||||
|
book.pay_order(brams.id, at(2))
|
||||||
|
book.cancel_order(annas.id)
|
||||||
|
assert book.get_order(annas.id).status is OrderStatus.CANCELLED
|
||||||
|
assert book.get_order(brams.id).status is OrderStatus.FULFILLING
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Custom 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from order_domain import Order
|
||||||
|
|
||||||
|
|
||||||
|
def assert_order_state(
|
||||||
|
order: Order, *, total: Decimal, line_values: list[Decimal]
|
||||||
|
) -> None:
|
||||||
|
actual_total = order.total
|
||||||
|
actual_line_values = [line.value for line in order.lines]
|
||||||
|
|
||||||
|
details: list[str] = []
|
||||||
|
if actual_total != total:
|
||||||
|
details.append(f"Total: expected {total}, but was {actual_total}")
|
||||||
|
if actual_line_values != line_values:
|
||||||
|
details.append(
|
||||||
|
f"Line values: expected [{', '.join(str(v) for v in line_values)}], "
|
||||||
|
f"but was [{', '.join(str(v) for v in actual_line_values)}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
if details:
|
||||||
|
header = (
|
||||||
|
f"Expected order with total {total} and line values "
|
||||||
|
f"[{', '.join(str(v) for v in line_values)}] but was:"
|
||||||
|
)
|
||||||
|
raise AssertionError("\n".join([header, *details]))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from order_domain import Order
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_order_total_is_correct():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("100"), 1)
|
||||||
|
order.complete()
|
||||||
|
|
||||||
|
assert order.is_fully_paid(Decimal("100"))
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from order_domain import Order
|
||||||
|
from order_state import assert_order_state
|
||||||
|
|
||||||
|
|
||||||
|
# This test was refactored - notice the custom matcher.
|
||||||
|
# The other tests still need the same treatment.
|
||||||
|
def test_discount_on_first_line_total_and_line_values():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("100"), 2) # line 0: 200 -> 160 with discount
|
||||||
|
order.add_line(Decimal("50"), 1) # line 1: 50
|
||||||
|
|
||||||
|
order.apply_discount(0)
|
||||||
|
|
||||||
|
assert_order_state(
|
||||||
|
order, total=Decimal("210"), line_values=[Decimal("160"), Decimal("50")]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discount_on_second_line_total_and_line_values():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("100"), 2) # line 0: 200
|
||||||
|
order.add_line(Decimal("50"), 1) # line 1: 50 -> 40 with discount
|
||||||
|
|
||||||
|
order.apply_discount(1)
|
||||||
|
|
||||||
|
assert order.total == Decimal("240")
|
||||||
|
assert order.lines[0].value == Decimal("200")
|
||||||
|
assert order.lines[1].value == Decimal("40")
|
||||||
|
|
||||||
|
|
||||||
|
def test_discount_on_both_lines_total_and_line_values():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("100"), 2) # line 0: 200 -> 160
|
||||||
|
order.add_line(Decimal("50"), 1) # line 1: 50 -> 40
|
||||||
|
|
||||||
|
order.apply_discount(0)
|
||||||
|
order.apply_discount(1)
|
||||||
|
|
||||||
|
assert order.total == Decimal("200")
|
||||||
|
assert order.lines[0].value == Decimal("160")
|
||||||
|
assert order.lines[1].value == Decimal("40")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_discount_total_and_line_values():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("100"), 2) # line 0: 200
|
||||||
|
order.add_line(Decimal("50"), 1) # line 1: 50
|
||||||
|
|
||||||
|
assert order.total == Decimal("250")
|
||||||
|
assert order.lines[0].value == Decimal("200")
|
||||||
|
assert order.lines[1].value == Decimal("50")
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_line_with_discount_total_and_value():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("75"), 4) # line 0: 300 -> 240
|
||||||
|
|
||||||
|
order.apply_discount(0)
|
||||||
|
|
||||||
|
assert order.total == Decimal("240")
|
||||||
|
assert order.lines[0].value == Decimal("240")
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_lines_one_discount_total_and_line_values():
|
||||||
|
order = Order()
|
||||||
|
order.add_line(Decimal("20"), 1) # line 0: 20
|
||||||
|
order.add_line(Decimal("30"), 2) # line 1: 60 -> 48
|
||||||
|
order.add_line(Decimal("10"), 3) # line 2: 30
|
||||||
|
|
||||||
|
order.apply_discount(1)
|
||||||
|
|
||||||
|
assert order.total == Decimal("98")
|
||||||
|
assert order.lines[0].value == Decimal("20")
|
||||||
|
assert order.lines[1].value == Decimal("48")
|
||||||
|
assert order.lines[2].value == Decimal("30")
|
||||||
Generated
+79
@@ -0,0 +1,79 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "showdonttell"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||||
Reference in New Issue
Block a user