ooad1-6 Exercise 1: The Bike Shop Invoice, Two Ways ========================================================= ex1.py (Python 3.14): """Bike shop invoice: who should work out what? A central calculator versus responsibilities given to the classes that know.""" import unittest from dataclasses import dataclass, field LABOUR_PENCE_PER_HOUR = 3000 # --- Version 1: one function that reaches into everything --- @dataclass class PartV1: name: str price_pence: int @dataclass class JobPartV1: part: PartV1 quantity: int @dataclass class JobV1: hours: float parts: list[JobPartV1] = field(default_factory=list) def invoice_total_v1(job: JobV1) -> int: total = int(job.hours * LABOUR_PENCE_PER_HOUR) for jp in job.parts: total += jp.part.price_pence * jp.quantity # reaches through JobPart into Part return total # --- Version 2: Information Expert: each class answers the question it holds the data for --- @dataclass class Part: name: str price_pence: int @dataclass class JobPart: part: Part quantity: int def cost(self) -> int: # JobPart knows the quantity and its Part return self.part.price_pence * self.quantity @dataclass class Job: hours: float parts: list[JobPart] = field(default_factory=list) def labour(self) -> int: # Job knows the hours return int(self.hours * LABOUR_PENCE_PER_HOUR) def total(self) -> int: # Job knows its parts and its hours return self.labour() + sum(p.cost() for p in self.parts) class Tests(unittest.TestCase): CASES = [(0.0, []), (1.5, [("tube", 450, 2)]), (2.0, [("chain", 1299, 1), ("tube", 450, 1), ("brake pad", 800, 4)])] def test_both_versions_agree(self): for hours, parts in self.CASES: v1 = JobV1(hours, [JobPartV1(PartV1(n, p), q) for n, p, q in parts]) v2 = Job(hours, [JobPart(Part(n, p), q) for n, p, q in parts]) self.assertEqual(invoice_total_v1(v1), v2.total()) def test_known_value(self): self.assertEqual(Job(1.5, [JobPart(Part("tube", 450), 2)]).total(), 5400) def test_a_new_part_pricing_rule_touches_one_class(self): class DiscountedJobPart(JobPart): # e.g. a trade discount on parts: only JobPart.cost changes def cost(self) -> int: return super().cost() * 90 // 100 self.assertEqual(Job(0, [DiscountedJobPart(Part("tube", 1000), 1)]).total(), 900) if __name__ == "__main__": unittest.main(verbosity=2) Run with: py ex1.py Output: test_a_new_part_pricing_rule_touches_one_class (__main__.Tests.test_a_new_part_pricing_rule_touches_one_class) ... ok test_both_versions_agree (__main__.Tests.test_both_versions_agree) ... ok test_known_value (__main__.Tests.test_known_value) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK WHY THIS WORKS AS AN ANSWER --------------------------- Version 1 is one function that reaches through a job into each JobPart and into each Part for its price. Version 2 asks each class the question it holds the data for: JobPart knows its quantity and its Part, so it works out its own cost; the Job knows the hours and the list, so it adds them up. The first test shows the two agree on three jobs including an empty one; the known-value test pins 1.5 hours plus two 450p tubes to 5400p (4500 + 900). The third test is the point of Information Expert. A trade discount on parts is a change to how a JobPart is costed, and the subclass in the test changes only JobPart.cost; Job.total, which adds the parts up, is untouched. In version 1 the same change edits the central function. Honest limit: for a one-function change the difference is small, and version 2 has more classes and methods. It pays off when the rules multiply.