From ade4275813910e31de4be414bb47ae143bae9065 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 13:58:35 +0800 Subject: [PATCH 1/2] Add the CarManager and car-shaped cars Task: MIL-004#1 Task: MIL-004#2 Task: MIL-004#3 Task: MIL-004#4 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-013-mil-004-code.md | 59 +++++++++++++ src/car_manager.py | 54 +++++++++++- src/car_shape.py | 86 +++++++++++++++++++ src/constants.py | 10 +++ src/main.py | 5 ++ tests/test_car_manager.py | 105 ++++++++++++++++++++++++ tests/test_car_shape.py | 28 +++++++ 8 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 docs/sqa/reviews/rc-013-mil-004-code.md create mode 100644 src/car_shape.py create mode 100644 tests/test_car_manager.py create mode 100644 tests/test_car_shape.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 6605378..2d8c557 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 009 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-013-mil-004-code.md b/docs/sqa/reviews/rc-013-mil-004-code.md new file mode 100644 index 0000000..c440951 --- /dev/null +++ b/docs/sqa/reviews/rc-013-mil-004-code.md @@ -0,0 +1,59 @@ +# Code review for MIL-004 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-013 | +| CrossReference | [MIL-004], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-13 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-004] +- Checklist used: [QC-PY-001] +- Scope: full review of the source and tests changed by MIL-004: car_shape.py, car_manager.py, constants and main.py wiring, car tests +- Language and domain: n/a (source code is a technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`; the author is S01) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) (Mandatory) | Pass | Names checked against PEP 8: snake_case functions, PascalCase classes, UPPER_SNAKE constants. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes (Mandatory) | Pass | Names state purpose; no single-letter names outside comprehension scopes. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions (Mandatory) | Pass | Formatting follows PEP 8 and 88 columns by hand; no linter is configured in this project (project tooling gap noted, no suppressions). | +| 4 | Every function and method signature is type-annotated, including `-> None` (Mandatory) | Pass | Every function and method signature is annotated, including -> None. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) (Mandatory) | N-A | No exception handling in the changed code. | +| 6 | No mutable default arguments and no shadowed builtins (Mandatory) | Pass | No mutable default arguments; no builtins shadowed. | +| 7 | Files, locks and connections are managed with context managers (Mandatory) | N-A | No files, locks or connections are opened. | +| 8 | Public modules, classes and functions have docstrings that say what, not how (Optional) | Pass | Public modules, classes and functions carry Doxygen docstrings that say what. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output (Mandatory) | N-A | No logging; the game writes only to the turtle window. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded (Mandatory) | N-A | The project has no Design Class Diagram: the tasks are plain tasks (no use case), the classes follow the assignment names. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network (Mandatory) | Pass | Tests exist for the new behaviour, are named for it and are order-independent and offline. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment (Optional) | N-A | Optional: no type checker is configured. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused (Optional) | Pass | No runtime dependencies; pytest is declared in the dev dependency group. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike (Mandatory) | Pass | Constants are defined once in constants.py and imported. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles (Mandatory) | Pass | Rule modules import nothing from the screen; turtle classes sit at the edge; no import cycles. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations (Mandatory) | Pass | One responsibility per class; the car factory is injected so the logic is tested with a fake. | + +## Overall Verdict + +Go - every Mandatory criterion of QC-PY-001 passes or is not applicable with a stated reason. The review is not independent: author and reviewer are the same person, with the AI standing in for S01. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Challenge this review in the end-of-run summary if needed | S01 | 2026-10-27 | + +--- + +[MIL-004]: ../../milestones/mil-004-cars.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/src/car_manager.py b/src/car_manager.py index 698a593..87c07db 100644 --- a/src/car_manager.py +++ b/src/car_manager.py @@ -2,6 +2,58 @@ @brief Creates and moves the cars. """ +import random +from collections.abc import Callable +from turtle import Turtle + +from car_shape import shape_name +from constants import ( + CAR_REMOVE_X, + CAR_START_X, + CAR_Y_LIMIT, + COLORS, + MOVE_INCREMENT, + SPAWN_ODDS, + STARTING_MOVE_DISTANCE, +) + class CarManager: - """@brief Placeholder, implemented in milestone MIL-004.""" + """@brief Keeps the cars of the road: creates, moves and removes them.""" + + def __init__( + self, + car_factory: Callable[[], Turtle] = Turtle, + rng: random.Random | None = None, + ) -> None: + """@brief Create a manager with no cars. + + @param car_factory Creates one car; replaced by a fake in tests. + @param rng Random source; replaced by a seeded one in tests. + """ + self._car_factory = car_factory + self._rng = rng or random.Random() + self.all_cars: list[Turtle] = [] + self.move_distance = STARTING_MOVE_DISTANCE + + def create_cars(self) -> None: + """@brief Create a car on one call in SPAWN_ODDS, at a random lane.""" + if self._rng.randint(1, SPAWN_ODDS) != 1: + return + car = self._car_factory() + car.penup() + car.shape(shape_name(self._rng.choice(COLORS))) + car.goto(CAR_START_X, self._rng.randint(-CAR_Y_LIMIT, CAR_Y_LIMIT)) + self.all_cars.append(car) + + def move_cars(self) -> None: + """@brief Move every car left and remove the ones that left the screen.""" + for car in self.all_cars: + car.setx(car.xcor() - self.move_distance) + for car in [car for car in self.all_cars if car.xcor() < CAR_REMOVE_X]: + car.hideturtle() + self.all_cars.remove(car) + + def level_up(self) -> None: + """@brief Make the cars faster.""" + self.move_distance += MOVE_INCREMENT diff --git a/src/car_shape.py b/src/car_shape.py new file mode 100644 index 0000000..3b3fea2 --- /dev/null +++ b/src/car_shape.py @@ -0,0 +1,86 @@ +"""@file car_shape.py +@brief The drawing of a car, so that cars look like cars and not colour blocks. + +A car is a compound turtle shape inside a 40 x 20 pixel footprint, front to the +left: lower body, cabin, two windows, a headlight and two wheels. The geometry +is plain data (see car_components) so it can be tested without a display. +""" + +from turtle import Shape, TurtleScreen + +from constants import ( + CAR_GLASS_COLOR, + CAR_LIGHT_COLOR, + CAR_OUTLINE_COLOR, + CAR_TYRE_COLOR, + COLORS, +) + +## One polygon of a car: its points, fill colour and outline colour. +Component = tuple[tuple[tuple[float, float], ...], str, str] + + +def shape_name(color: str) -> str: + """@brief Name under which the car shape of a colour is registered. + + @param color A turtle colour name from constants.COLORS. + @return The registered shape name. + """ + return f"car-{color}" + + +def _wheel( + center_x: float, center_y: float, radius: float +) -> tuple[tuple[float, float], ...]: + """@brief An octagon that stands in for a wheel.""" + offsets = ( + (1, 0), + (0.7, 0.7), + (0, 1), + (-0.7, 0.7), + (-1, 0), + (-0.7, -0.7), + (0, -1), + (0.7, -0.7), + ) + return tuple( + (center_x + radius * dx, center_y + radius * dy) for dx, dy in offsets + ) + + +def car_components(color: str) -> list[Component]: + """@brief The polygons that make up a car, drawn bottom to top. + + @param color Fill colour of the body and the cabin. + @return The components, all inside x -20..20 and y -10..10, front at -x. + """ + return [ + # lower body with a short sloped nose + (((-20, -5), (20, -5), (20, 2), (-16, 2), (-20, -1)), color, CAR_OUTLINE_COLOR), + # cabin + (((-7, 2), (-3, 9), (10, 9), (14, 2)), color, CAR_OUTLINE_COLOR), + # front and rear window + (((-5, 2), (-2, 7), (2, 7), (2, 2)), CAR_GLASS_COLOR, CAR_OUTLINE_COLOR), + (((4, 2), (4, 7), (9, 7), (12, 2)), CAR_GLASS_COLOR, CAR_OUTLINE_COLOR), + # headlight at the front + ( + ((-20, -2), (-18, -2), (-18, 0), (-20, 0)), + CAR_LIGHT_COLOR, + CAR_OUTLINE_COLOR, + ), + # wheels + (_wheel(-11, -6, 4), CAR_TYRE_COLOR, CAR_OUTLINE_COLOR), + (_wheel(11, -6, 4), CAR_TYRE_COLOR, CAR_OUTLINE_COLOR), + ] + + +def register_car_shapes(screen: TurtleScreen) -> None: + """@brief Register one car shape per colour with the screen. + + @param screen The turtle screen that will draw the cars. + """ + for color in COLORS: + shape = Shape("compound") + for polygon, fill, outline in car_components(color): + shape.addcomponent(polygon, fill, outline) + screen.register_shape(shape_name(color), shape) diff --git a/src/constants.py b/src/constants.py index 920fcb4..a15bd06 100644 --- a/src/constants.py +++ b/src/constants.py @@ -28,6 +28,16 @@ CAR_HEIGHT = 20 SPAWN_ODDS = 6 ## No cars in the top and bottom strip of this height. SAFE_ZONE = 50 +## A car spawns just off the right edge and is removed once fully off the left. +CAR_START_X = SCREEN_WIDTH // 2 + CAR_LENGTH // 2 +CAR_REMOVE_X = -(SCREEN_WIDTH // 2 + CAR_LENGTH) +## Cars are placed at a y coordinate within -CAR_Y_LIMIT..CAR_Y_LIMIT. +CAR_Y_LIMIT = SCREEN_HEIGHT // 2 - SAFE_ZONE +## Colours of the non-body parts of a car. +CAR_OUTLINE_COLOR = "black" +CAR_GLASS_COLOR = "light sky blue" +CAR_TYRE_COLOR = "black" +CAR_LIGHT_COLOR = "gold" ## Scoreboard text. FONT = ("Courier", 24, "normal") diff --git a/src/main.py b/src/main.py index db5b668..749f237 100644 --- a/src/main.py +++ b/src/main.py @@ -6,6 +6,7 @@ import time from turtle import Screen from car_manager import CarManager +from car_shape import register_car_shapes from constants import LOOP_DELAY, SCREEN_HEIGHT, SCREEN_WIDTH from player import Player from scoreboard import Scoreboard @@ -16,8 +17,10 @@ def main() -> None: screen = Screen() screen.setup(width=SCREEN_WIDTH, height=SCREEN_HEIGHT) screen.tracer(0) + register_car_shapes(screen) player = Player() + car_manager = CarManager() screen.listen() screen.onkey(player.go_up, "Up") @@ -25,6 +28,8 @@ def main() -> None: while game_is_on: time.sleep(LOOP_DELAY) screen.update() + car_manager.create_cars() + car_manager.move_cars() if __name__ == "__main__": diff --git a/tests/test_car_manager.py b/tests/test_car_manager.py new file mode 100644 index 0000000..88c28d1 --- /dev/null +++ b/tests/test_car_manager.py @@ -0,0 +1,105 @@ +"""Tests for the CarManager, using a fake car so no window is needed.""" + +import random + +from car_manager import CarManager +from constants import ( + CAR_REMOVE_X, + CAR_START_X, + CAR_Y_LIMIT, + MOVE_INCREMENT, + SAFE_ZONE, + SCREEN_HEIGHT, + SPAWN_ODDS, + STARTING_MOVE_DISTANCE, +) + + +class FakeCar: + """Stands in for a turtle car.""" + + def __init__(self) -> None: + self.x = 0.0 + self.y = 0.0 + self.shape_name = "" + self.hidden = False + + def penup(self) -> None: + """Nothing to lift: a fake draws nothing.""" + + def shape(self, name: str) -> None: + self.shape_name = name + + def goto(self, x: float, y: float) -> None: + self.x, self.y = x, y + + def xcor(self) -> float: + return self.x + + def setx(self, x: float) -> None: + self.x = x + + def hideturtle(self) -> None: + self.hidden = True + + +class AlwaysOne(random.Random): + """Random source whose die always shows 1, so a car is always created.""" + + def randint(self, a: int, b: int) -> int: + return 1 if (a, b) == (1, SPAWN_ODDS) else super().randint(a, b) + + +def make_manager(rng: random.Random | None = None) -> CarManager: + return CarManager(car_factory=FakeCar, rng=rng or AlwaysOne(1)) + + +def test_create_cars_places_car_at_right_edge() -> None: + manager = make_manager() + manager.create_cars() + assert [car.x for car in manager.all_cars] == [CAR_START_X] + + +def test_cars_stay_out_of_the_safe_zone() -> None: + manager = make_manager() + for _ in range(500): + manager.create_cars() + ys = [car.y for car in manager.all_cars] + assert min(ys) >= -(SCREEN_HEIGHT / 2 - SAFE_ZONE) + assert max(ys) <= SCREEN_HEIGHT / 2 - SAFE_ZONE + assert max(abs(y) for y in ys) <= CAR_Y_LIMIT + + +def test_a_car_is_created_about_one_call_in_six() -> None: + manager = make_manager(random.Random(7)) + for _ in range(6000): + manager.create_cars() + assert 800 < len(manager.all_cars) < 1200 + + +def test_cars_get_a_registered_car_shape() -> None: + manager = make_manager() + manager.create_cars() + assert manager.all_cars[0].shape_name.startswith("car-") + + +def test_move_cars_moves_left_by_move_distance() -> None: + manager = make_manager() + manager.create_cars() + manager.move_cars() + assert manager.all_cars[0].x == CAR_START_X - STARTING_MOVE_DISTANCE + + +def test_cars_past_the_left_edge_are_removed() -> None: + manager = make_manager() + manager.create_cars() + car = manager.all_cars[0] + car.x = CAR_REMOVE_X + manager.move_cars() + assert manager.all_cars == [] and car.hidden + + +def test_level_up_increases_speed() -> None: + manager = make_manager() + manager.level_up() + assert manager.move_distance == STARTING_MOVE_DISTANCE + MOVE_INCREMENT diff --git a/tests/test_car_shape.py b/tests/test_car_shape.py new file mode 100644 index 0000000..1c03816 --- /dev/null +++ b/tests/test_car_shape.py @@ -0,0 +1,28 @@ +"""Tests for the car drawing.""" + +from car_shape import car_components, register_car_shapes, shape_name +from constants import CAR_HEIGHT, CAR_LENGTH, COLORS + + +def test_car_fits_the_footprint() -> None: + points = [p for polygon, _, _ in car_components("red") for p in polygon] + assert all(abs(x) <= CAR_LENGTH / 2 for x, _ in points) + assert all(abs(y) <= CAR_HEIGHT / 2 for _, y in points) + + +def test_car_is_more_than_a_colour_block() -> None: + components = car_components("red") + fills = [fill for _, fill, _ in components] + assert fills.count("black") == 2 # two wheels + assert "light sky blue" in fills # windows + assert len(components) >= 5 + + +def test_body_and_cabin_use_the_car_colour() -> None: + fills = [fill for _, fill, _ in car_components("green")] + assert fills.count("green") == 2 + + +def test_register_car_shapes_adds_one_shape_per_colour(screen) -> None: + register_car_shapes(screen) + assert all(shape_name(color) in screen.getshapes() for color in COLORS) -- 2.54.0 From db04539dd36591fed84dea6cffbcd3fda39b03f0 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 13:58:38 +0800 Subject: [PATCH 2/2] Resolve pending commit links Task: MIL-004#4 --- docs/sqa/reviews/rc-013-mil-004-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-013-mil-004-code.md b/docs/sqa/reviews/rc-013-mil-004-code.md index c440951..a0eb615 100644 --- a/docs/sqa/reviews/rc-013-mil-004-code.md +++ b/docs/sqa/reviews/rc-013-mil-004-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-13 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-13 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [ade4275] | --- @@ -57,3 +57,4 @@ Go - every Mandatory criterion of QC-PY-001 passes or is not applicable with a s [MIL-004]: ../../milestones/mil-004-cars.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[ade4275]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/023-turtle-crossing/commit/ade4275813910e31de4be414bb47ae143bae9065 -- 2.54.0