Compare commits

..
13 Commits
Author SHA1 Message Date
mostalive 334567d9c5 Update README.md 2026-10-13 12:30:30 +00:00
mostalive 4e0aea732a Add payment state to Order with guarded Pay transition
- Order records payments: Payments, AmountPaid, Outstanding,
  IsFullyPaid (parameterless, replaces IsFullyPaid(expectedTotal))
- Pay rejects open orders, non-positive amounts, and overpayments
- 4 new OrderCompletion tests assert observable state:
  PaymentOnOpenOrder_Throws_OrderStillUnpaid,
  CompletedOrder_PartialPayment_IsNotFullyPaid,
  CompletedOrder_PaymentsSummingToTotal_IsFullyPaid,
  Overpayment_Throws_AmountPaidUnchanged
- 11 tests green (6 OrderTotal + 5 OrderCompletion)
2026-10-01 13:46:10 +01:00
mostalive 5dd7a9ef2c Add top-level README describing the three language tracks 2026-10-01 13:44:26 +01:00
mostalive 0749062ce1 Port Round 2 to Python (OrderBook): 14 tests total green, START state preserved 2026-10-01 13:43:59 +01:00
mostalive 974053625d Port Round 1 to Python (OrderDomain): 7 tests green, START state preserved 2026-10-01 13:42:21 +01:00
mostalive 2831b8c3f9 Port Round 2 to Java (OrderBook): 14 tests total green, START state preserved 2026-10-01 13:38:42 +01:00
mostalive f927998149 Ignore pi session storage 2026-10-01 13:35:33 +01:00
mostalive 8841912bdc Add uv.lock from workspace-local uv sync 2026-10-01 13:35:33 +01:00
mostalive 83e1c79ab7 Port Round 1 to Java (OrderDomain): 7 tests green, START state preserved 2026-10-01 13:35:33 +01:00
mostalive d9f78162e7 Redirect Maven/uv caches into workspace (sandbox-safe mise env) 2026-10-01 12:47:45 +01:00
mostalive 035ccb5e27 Ignore Java/Python build artifacts and yak tracker 2026-10-01 12:41:40 +01:00
mostalive e41dcaf4fe Add Python scaffold (uv project with pytest, mise toolchain) 2026-10-01 12:41:40 +01:00
mostalive bd94df4439 Add Java scaffold (Maven multi-module, JUnit 5 + AssertJ, mise toolchain) 2026-10-01 12:41:40 +01:00
32 changed files with 1246 additions and 3 deletions
+17
View File
@@ -16,3 +16,20 @@ TestResults/
# dotnet CLI local data (when the CLI home points inside the repo)
.local/
## yak tracker
.yaks
## Java
target/
.m2repo/
## Python
__pycache__/
*.py[oc]
.venv/
.pytest_cache/
.ruff_cache/
.uv-cache/
.uv-managed-python/
.pi/
+4
View File
@@ -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).
+20 -2
View File
@@ -2,12 +2,22 @@ namespace OrderDomain;
public class Order
{
private readonly List<decimal> payments = new();
public List<OrderLine> Lines { get; } = new();
public decimal Total => Lines.Sum(l => l.Value);
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)
=> Lines.Add(new OrderLine(unitPrice, quantity));
@@ -16,6 +26,14 @@ public class Order
public void Complete() => IsCompleted = true;
public bool IsFullyPaid(decimal expectedTotal)
=> IsCompleted && Total == expectedTotal;
public void Pay(decimal amount)
{
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.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);
}
}
+23
View File
@@ -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`).
+7
View File
@@ -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"
+15
View File
@@ -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
}
+35
View File
@@ -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);
}
}
+15
View File
@@ -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;
}
}
+35
View File
@@ -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");
}
}
+35
View File
@@ -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>
+24
View File
@@ -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`).
+8
View File
@@ -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"
+11
View File
@@ -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"]
+3
View File
@@ -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})."
)
+3
View File
@@ -0,0 +1,3 @@
from order_domain.order import Order, OrderLine
__all__ = ["Order", "OrderLine"]
+39
View File
@@ -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
+32
View File
@@ -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")
+79
View File
@@ -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" }]