ooad1-8 Exercise 1: A Reservation as a State Machine ========================================================== ex1.py (Python 3.14; run with py ex1.py ; it also writes reservation.state.mmd): """Exercise 1: the lifecycle of a reservation as a table-driven state machine, with its diagram and transition matrix generated from the table.""" import unittest from dataclasses import dataclass from datetime import date, timedelta HOLD_DAYS = 3 class IllegalTransition(Exception): pass @dataclass class Reservation: expires_on: date | None = None @dataclass(frozen=True) class T: source: str event: str target: str guard: str = "" TABLE = [ T("Queued", "copy_available", "Held"), T("Queued", "cancel", "Cancelled"), T("Held", "collect", "Collected", guard="today <= expires"), T("Held", "day_passed", "Expired", guard="today > expires"), T("Held", "cancel", "Cancelled"), ] STATES = ["Queued", "Held", "Collected", "Expired", "Cancelled"] EVENTS = ["copy_available", "collect", "day_passed", "cancel"] IGNORED = {(s, "day_passed") for s in ("Queued", "Collected", "Expired", "Cancelled")} class Machine: def __init__(self, state="Queued"): self.state, self.r = state, Reservation() def send(self, event: str, today: date | None = None) -> str: if (self.state, event) in IGNORED: return self.state options = [t for t in TABLE if t.source == self.state and t.event == event] if not options: raise IllegalTransition(f"{event} is not allowed in {self.state}") t = options[0] if t.guard == "today <= expires" and not today <= self.r.expires_on: raise IllegalTransition("the hold has expired") # collecting late is refused loudly if t.guard == "today > expires" and not today > self.r.expires_on: return self.state # not yet expired: discard the tick if t.target == "Held": self.r.expires_on = today + timedelta(days=HOLD_DAYS) self.state = t.target return self.state def matrix() -> dict: out = {} for s in STATES: for e in EVENTS: m = Machine("Queued") m.r.expires_on = date(2026, 11, 4) m.state = s today = date(2026, 11, 5) if e == "day_passed" else date(2026, 11, 4) # satisfy each guard try: out[(s, e)] = "ignored" if (s, e) in IGNORED else m.send(e, today) except IllegalTransition: out[(s, e)] = "illegal" return out def mermaid() -> str: lines = ["stateDiagram-v2", " [*] --> Queued"] for t in TABLE: lines.append(f" {t.source} --> {t.target} : {t.event}" + (f" [{t.guard}]" if t.guard else "")) lines += [f" {s} --> [*]" for s in ("Collected", "Expired", "Cancelled")] return "\n".join(lines) + "\n" class Tests(unittest.TestCase): def test_happy_path(self): m = Machine() self.assertEqual(m.send("copy_available", date(2026, 11, 1)), "Held") self.assertEqual(m.send("collect", date(2026, 11, 4)), "Collected") def test_expiry(self): m = Machine() m.send("copy_available", date(2026, 11, 1)) self.assertEqual(m.send("day_passed", date(2026, 11, 4)), "Held") # the last day is still valid self.assertEqual(m.send("day_passed", date(2026, 11, 5)), "Expired") def test_collecting_after_the_deadline_is_refused(self): m = Machine() m.send("copy_available", date(2026, 11, 1)) with self.assertRaises(IllegalTransition): m.send("collect", date(2026, 11, 5)) def test_cancel_from_queued_and_held_but_not_after(self): for path in ([], ["copy_available"]): m = Machine() for e in path: m.send(e, date(2026, 11, 1)) self.assertEqual(m.send("cancel"), "Cancelled") with self.assertRaises(IllegalTransition): m.send("cancel") def test_cannot_collect_before_a_copy_is_available(self): with self.assertRaises(IllegalTransition): Machine().send("collect", date(2026, 11, 1)) if __name__ == "__main__": print("transition matrix (computed by driving the machine):") mx = matrix() print(f" {'':<11}" + "".join(f"{e:<16}" for e in EVENTS)) for s in STATES: print(f" {s:<11}" + "".join(f"{mx[(s, e)]:<16}" for e in EVENTS)) print() result = unittest.TextTestRunner(verbosity=0).run(unittest.defaultTestLoader.loadTestsFromTestCase(Tests)) print("tests:", result.testsRun, "run, failures", len(result.failures) + len(result.errors)) open("reservation.state.mmd", "w", encoding="utf8").write(mermaid()) Output: ---------------------------------------------------------------------- Ran 5 tests in 0.000s OK transition matrix (computed by driving the machine): copy_available collect day_passed cancel Queued Held illegal ignored Cancelled Held illegal Collected Expired Cancelled Collected illegal illegal ignored illegal Expired illegal illegal ignored illegal Cancelled illegal illegal ignored illegal tests: 5 run, failures 0 Generated diagram source (reservation.state.mmd): stateDiagram-v2 [*] --> Queued Queued --> Held : copy_available Queued --> Cancelled : cancel Held --> Collected : collect [today <= expires] Held --> Expired : day_passed [today > expires] Held --> Cancelled : cancel Collected --> [*] Expired --> [*] Cancelled --> [*] Points the tests settle: - The last day of the hold is still valid: day_passed on the deadline day leaves the reservation Held; the day after expires it. Collecting on the day after is refused (an error: IllegalTransition), a deliberate choice to be loud about a late collection rather than silently discard it. - Cancelling twice is illegal the second time: Cancelled is a final state. - day_passed is ignored (not illegal) in every state except Held, because a nightly job sends it to everything. WHY THIS WORKS AS AN ANSWER --------------------------- The table has five rows, and everything else follows from it: the matrix shows each of the other cells as illegal or ignored, and the diagram is generated from the same rows. The two guards ([today <= expires] on collect, [today > expires] on day_passed) are complementary on the deadline, which is why the boundary test matters.