The Edges It Misses
The agent's tests come from the code, and the bugs that hurt come from the domain. Cragmoor's rules have three edges that anyone who works the front desk names in a minute and that a test derived from the code never does: two time zones, the 16th place in a class, and a 15-minute hold that runs out while a request is in flight.
An agent asked for edge cases without the rule in front of it lists the code's edges: an empty list, a missing value, a negative number. Those are worth testing and they are not where members lose money. This page is about getting the other list, and writing it down as tests.
Where Edges Come From
Read the rule, not the code, and three kinds of edge fall out of it. Every number in a rule is a boundary to test below, on and above: for 12 hours, that is 11 hours 59, exactly 12 and 12 hours 1. Every "where" is a place with its own clock, so every time zone gets a test. Every "while" is a timing case, two things happening in an order the rule has to settle.
The agent sees the code's branches and the domain expert sees the rule's. So get the list from the rule text and from Ines, then compare the agent's list against it. Where the two differ, the rule's list wins.
Two Time Zones
The refund deadline is 12 hours before the class on the gym's own clock, at four gyms in two zones an hour apart. That needs one test per zone, each with a fixed clock, and each checking the line a member reads: free cancellation until 21:00 the evening before a 09:00 class.
It also needs the nights the clocks change. For a 09:00 class on the morning after the clocks go forward overnight, twelve elapsed hours earlier is 20:00 on the wall clock; after they go back, it is 22:00; on an ordinary night, 21:00. "Twelve hours before" means one of two things on those nights, an hour apart, and the rule has to say which. Ines chose elapsed hours, because twelve hours' notice should be twelve hours of real time, and the test names record her choice.
| The night before the class | Twelve elapsed hours | Twelve hours on the wall clock |
|---|---|---|
| An ordinary night (class 2026-11-14 09:00) | until 2026-11-13 21:00 | until 2026-11-13 21:00 |
| Clocks go forward (class 2026-03-08 09:00) | until 2026-03-07 20:00 | until 2026-03-07 21:00 |
| Clocks go back (class 2026-11-01 09:00) | until 2026-10-31 22:00 | until 2026-10-31 21:00 |
@pytest.mark.parametrize("gym", [FIRST_ZONE, SECOND_ZONE])
def test_deadline_is_21_00_local_in_each_zone(gym):
booking = drop_in(gym, datetime(2026, 11, 14, 9, 0))
assert cancellation_notice(booking) == "Free cancellation until 2026-11-13 21:00"
def test_twelve_elapsed_hours_when_clocks_go_forward():
booking = drop_in(FIRST_ZONE, datetime(2026, 3, 8, 9, 0))
assert cancellation_notice(booking) == "Free cancellation until 2026-03-07 20:00"
def test_twelve_elapsed_hours_when_clocks_go_back():
booking = drop_in(FIRST_ZONE, datetime(2026, 11, 1, 9, 0))
assert cancellation_notice(booking) == "Free cancellation until 2026-10-31 22:00"
tests/test_refund_edges.py:37: AssertionError: assert 'Free cancell...6-10-31 21:00' == 'Free cancell...6-10-31 22:00' FAILED tests/test_refund_edges.py::test_twelve_elapsed_hours_when_clocks_go_forward FAILED tests/test_refund_edges.py::test_twelve_elapsed_hours_when_clocks_go_back 2 failed, 9 passed in 0.03s
start = schedule.localize(starts_at, gym.time_zone) - return start - FULL_REFUND_WINDOW + # Twelve elapsed hours, not twelve on the wall clock: subtract in UTC. + return start.astimezone(UTC) - FULL_REFUND_WINDOW
21 passed in 0.03s
test.each([FIRST_ZONE, SECOND_ZONE])("deadline is 21:00 local at $code", (gym) => {
const booking = dropIn(gym, "2026-11-14T09:00");
expect(cancellationNotice(booking)).toBe("Free cancellation until 2026-11-13 21:00");
});
test("twelve elapsed hours when clocks go forward", () => {
const booking = dropIn(FIRST_ZONE, "2026-03-08T09:00");
expect(cancellationNotice(booking)).toBe("Free cancellation until 2026-03-07 20:00");
});
test("twelve elapsed hours when clocks go back", () => {
const booking = dropIn(FIRST_ZONE, "2026-11-01T09:00");
expect(cancellationNotice(booking)).toBe("Free cancellation until 2026-10-31 22:00");
});
Tests 11 passed (11)
@ParameterizedTest
@CsvSource({"north, America/Chicago", "west, America/Denver"})
void deadlineIs2100LocalInEachZone(String code, String zone) {
Booking booking = dropIn(new Gym(code, zone), SATURDAY_9AM);
assertEquals("Free cancellation until 2026-11-13 21:00", booking.cancellationNotice());
}
@Test
void twelveElapsedHoursWhenClocksGoForward() {
Booking booking = dropIn(FIRST_ZONE, LocalDateTime.of(2026, 3, 8, 9, 0));
assertEquals("Free cancellation until 2026-03-07 20:00", booking.cancellationNotice());
}
@Test
void twelveElapsedHoursWhenClocksGoBack() {
Booking booking = dropIn(FIRST_ZONE, LocalDateTime.of(2026, 11, 1, 9, 0));
assertEquals("Free cancellation until 2026-10-31 22:00", booking.cancellationNotice());
}
[ 11 tests found ] [ 11 tests successful ] [ 0 tests failed ]
Run these against the narrowed refund change and the answer depends on the language. Subtracting 12 hours from a zoned time counts elapsed time in two of the three languages this book shows and wall-clock hours in the third, so in one language both clock-change tests fail and the deadline code needs a one-line fix. None of that was visible on an ordinary night. Whichever language you read, the test is what told you which kind of hours your code counts, and the rule is what decided which kind it should.
One more "where" comes from Chapter 7: the gym with no time zone on record. After the symptom fix was refused, the deadline code fails loudly for that gym instead of guessing, and that behaviour gets a test too, asserting the failure. A test that pins a loud failure looks odd, but it is what stops the next agent from "handling" the missing zone with a default again.
The 16th Place
A class holds 16 climbers. The 16th booking succeeds; the 17th joins the waitlist; a cancellation from a full class frees exactly one place and triggers exactly one offer to the waitlist. Test at 15, 16 and 17 bookings, and test a cancellation from a full class, asserting one offer and not "at least one". An agent's tests for booking will check that a booking succeeds and that a full class refuses one. Neither checks the transition, which is where the off-by-one lives.
The offer matters as much as the count. A cancellation from a full class should produce one offer to the first member on the waitlist, held for 15 minutes, and nothing else. A test that asserts "an offer was sent" passes when two were sent, which is the exact bug of Kai's third week. Assert the number of offers and who received each one.
The Hold Expiring Mid-Request
A waitlist offer is held for 15 minutes. A member presses "claim" at 14 minutes 59.8 seconds, and the server processes the claim at 15 minutes 0.3 seconds, the numbers this page uses. Which instant decides, the press or the processing? The rule has to say, and nobody had asked until a test needed an answer.
Once the rule says, the test fixes the clock at both instants and asserts the outcome. No real waiting, no threads, no flakiness: a timing edge with one request in flight is deterministic once the clock is injected. Chapter 10's double offer lives one step further along this edge, where two requests are in flight at once.
Asking the Agent for Edges
The agent is quick and useful at listing edge cases when it is given the rule text, not only the code. Without the rule its list is generic: empty, missing, negative, very large. With the rule, it will usually find the boundary and the zones, and sometimes the clock change. Check its list against every number, place and time in the rule, and add what it missed.
The difference is easy to see side by side. Asked for edge cases of the refund function with only the code open, the agent below lists the code's edges. Given the rule's text and the fact that gyms sit in two zones, its list moves to the domain, and it still misses the hold, which no refund text mentions. That gap is what Ines is for.
Tables and Properties
A table of hours before the class, zone and expected refund covers a boundary densely in one test, and makes a missing row easy to see. A property states the rule over many generated inputs instead of a few chosen ones: a refund is granted exactly when the cancellation is at least 12 hours before the class, in the gym's time. The simplest form sweeps every minute across the boundary; property-testing libraries generate the inputs for you and shrink any failure to the smallest example. Either finds the boundary the examples skipped.
@pytest.mark.parametrize("gym", [FIRST_ZONE, SECOND_ZONE])
@pytest.mark.parametrize("minutes, expected", [(721, 2400), (720, 2400), (719, 0)])
def test_boundary_table(gym, minutes, expected):
booking = drop_in(gym, datetime(2026, 11, 14, 9, 0))
assert refunds.refund_cents(booking, minutes_before(booking, minutes)) == expected
def test_refunded_exactly_when_at_least_720_minutes_ahead():
for gym in (FIRST_ZONE, SECOND_ZONE):
booking = drop_in(gym, datetime(2026, 11, 14, 9, 0))
for minutes in range(660, 781): # every minute from 11 to 13 hours ahead
expected = 2400 if minutes >= 720 else 0
assert refunds.refund_cents(booking, minutes_before(booking, minutes)) == expected, minutesdescribe.each([FIRST_ZONE, SECOND_ZONE])("boundary table at $code", (gym) => {
test.each([[721, 2400], [720, 2400], [719, 0]])("%i minutes ahead refunds %i", (minutes, expected) => {
const booking = dropIn(gym, "2026-11-14T09:00");
expect(refunds.refundCents(booking, minutesBefore(booking, minutes))).toBe(expected);
});
});
test("refunded exactly when at least 720 minutes ahead", () => {
for (const gym of [FIRST_ZONE, SECOND_ZONE]) {
const booking = dropIn(gym, "2026-11-14T09:00");
for (let minutes = 660; minutes <= 780; minutes++) { // every minute from 11 to 13 hours ahead
const expected = minutes >= 720 ? 2400 : 0;
expect(refunds.refundCents(booking, minutesBefore(booking, minutes)), `${minutes}`).toBe(expected);
}
}
});@ParameterizedTest
@CsvSource({
"America/Chicago, 721, 2400", "America/Chicago, 720, 2400", "America/Chicago, 719, 0",
"America/Denver, 721, 2400", "America/Denver, 720, 2400", "America/Denver, 719, 0",
})
void boundaryTable(String zone, int minutes, long expected) {
Booking booking = dropIn(new Gym("gym", zone), SATURDAY_9AM);
assertEquals(expected, Refunds.refundCents(booking, minutesBefore(booking, minutes)));
}
@Test
void refundedExactlyWhenAtLeast720MinutesAhead() {
for (Gym gym : List.of(FIRST_ZONE, SECOND_ZONE)) {
Booking booking = dropIn(gym, SATURDAY_9AM);
for (int minutes = 660; minutes <= 780; minutes++) { // every minute from 11 to 13 hours ahead
long expected = minutes >= 720 ? 2400 : 0;
assertEquals(expected, Refunds.refundCents(booking, minutesBefore(booking, minutes)), "" + minutes);
}
}
}- "The agent's tests cover edge cases: it tested empty and missing inputs." Those are the code's edges. The domain's are at 12 hours, the 16th place and the zone line.
- "Time-zone bugs need time-zone code to cause them." The refund rewrite in Chapter 6 contained no time-zone code at all, and that was the bug.
- "'Twelve hours before' is unambiguous." On a clock-change night it is either twelve elapsed hours or twelve on the wall clock, an hour apart, and the rule has to pick one.
- "Timing edges cannot be unit-tested." The hold expiry can be tested deterministically by fixing the clock at both instants. The concurrent version is Chapter 10's.
- "Edge cases are rare, so they matter less." Some member crosses the 12-hour line at one of four gyms every day. Rare per booking is routine per year.
- Derive edge tests from the rule text: each number tested below, on and above, each zone once, each "while" as a timing case.
- Fix the clock in every time-dependent test. Real time makes a boundary test pass on most days.
- Write the rule's choice into the test's name, as in "twelve elapsed hours when clocks go forward". The name is where the decision is recorded.
- Ask the agent for edge candidates with the rule in context, then add what it missed. Its list is a start, and the rule is the check.
Knowledge Check
An agent writes tests for the refund code and covers a missing booking, a negative price and an empty gym list. Which edge is it most likely to have missed, and why?
- The 12-hour boundary in each zone, because it comes from the rule
- A very large price, because it did not test numeric overflow at all
- A null cancellation time, because the code never checks for one
- An empty waitlist, because agents rarely test empty collections
A 09:00 class is on the morning after the clocks go forward overnight. Under Ines's rule of twelve elapsed hours, when does the free cancellation end?
- At 21:00 the evening before, the same as on an ordinary night
- At 22:00 the evening before, an hour later than on other nights
- At 20:00 the evening before, an hour earlier than on other nights
- At 09:00 UTC the day before, as the rule is computed in UTC
A member presses "claim" at 14 minutes 59.8 seconds into a 15-minute hold, and the server processes it at 15 minutes 0.3 seconds. How should this be settled and tested?
- Run the test with real waits and accept whichever result happens
- Let the agent decide which instant counts when it writes the code
- The rule names the deciding instant; the test fixes the clock at both
- Leave it untested, since timing cases cannot be unit-tested at all
Which set of booking counts tests the capacity rule for a class of 16?
- 0, 1 and 16 bookings, covering the empty, single and full class
- 15, 16 and 17 bookings, and a cancellation from a full class
- 16, 100 and 1,000 bookings, to check behaviour under heavy load
- 16 bookings only, since 16 is the single number the rule names
You got correct