MIL-003 Higher or Lower game: logic, routes, server.py, tests #25

Merged
Tirsvad merged 4 commits from mil-003-higher-lower-game into main 2026-11-06 05:53:57 +01:00
14 changed files with 418 additions and 1 deletions
Showing only changes of commit cc95105cf4 - Show all commits

No files matched your search

+1 -1
View File
@@ -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/*.md | 005 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
## Languages
+61
View File
@@ -0,0 +1,61 @@
# RC-010 Code review of MIL-003
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-010 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-11-06 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-003]
- Checklist used: [QC-PY-001]
- Scope: Code of MIL-003: domain, application, infrastructure and presentation/web packages, `server.py`, and the unit and integration tests; written by the python-developer agent
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules and functions use snake_case, classes PascalCase, constants UPPER_SNAKE |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`compare_guess`, `GuessResult`, `HOME_HEADING`); no unexplained abbreviations |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` report no finding; no suppressions |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` reports no issue |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; no exception is swallowed |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed |
| 7 | Files, locks and connections are managed with context managers | N-A | No file, lock or connection is opened |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen-style docstrings |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no secret in the code; `.env` is neither read nor imported |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No DCD exists (plain technical tasks) |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | pytest tests are named for behaviour, run in any order and use no network |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any` |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Flask (runtime) and pytest, ruff, mypy (extras); none unused |
| 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 | Pass | The comparison rule is only in `domain/rules.py`; headings, colours and GIFs only in `constants.py` |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | domain imports only the standard library; application imports domain and constants; infrastructure imports only the standard library; presentation imports application, infrastructure and Flask; no cycle |
| 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 | Pass | Each module has one reason to change; behaviour is added by extension |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Domain rule, application use case, infrastructure random source and presentation web app sit in their layer packages; tests in `tests/unit` and `tests/integration` |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names are English with letters, digits and `_` only |
## Overall Verdict
Go — All criteria pass or are not applicable; no blocking finding.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | 2026-11-06 |
---
[MIL-003]: ../../docs/milestones/mil-003-higher-lower-game.md
[QC-PY-001]: ../../framework/qc/code/qc-programming-python.md
+12
View File
@@ -0,0 +1,12 @@
"""Entry point of the Higher or Lower URLs project.
@brief Composition root: builds the web application and runs it.
"""
from higher_lower.constants import HOST, PORT
from higher_lower.presentation.web.app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host=HOST, port=PORT)
@@ -0,0 +1,34 @@
"""Use case of the Higher or Lower game.
@brief Holds the secret number and judges guesses against it.
"""
from higher_lower.constants import MAX_NUMBER, MIN_NUMBER
from higher_lower.domain.rules import GuessResult, compare_guess
class GuessGame:
"""@brief One game with a fixed secret number."""
def __init__(self, secret_number: int) -> None:
"""Start a game.
@param secret_number The number the player has to find.
"""
self._secret_number = secret_number
def check(self, guess: int) -> GuessResult:
"""Judge a guess.
@param guess The number the player guessed.
@return Whether the guess is low, high or correct.
"""
return compare_guess(guess, self._secret_number)
def is_in_range(self, guess: int) -> bool:
"""Tell whether a guess is inside the allowed range.
@param guess The number the player guessed.
@return True when the guess is between the minimum and maximum number.
"""
return MIN_NUMBER <= guess <= MAX_NUMBER
+28
View File
@@ -0,0 +1,28 @@
"""Rules of the Higher or Lower game.
@brief Pure comparison of a guess with the secret number.
"""
from enum import Enum
class GuessResult(Enum):
"""@brief Outcome of comparing a guess with the secret number."""
LOW = "low"
HIGH = "high"
CORRECT = "correct"
def compare_guess(guess: int, secret: int) -> GuessResult:
"""Compare a guess with the secret number.
@param guess The number the player guessed.
@param secret The number to find.
@return LOW if the guess is below the secret, HIGH if above, else CORRECT.
"""
if guess < secret:
return GuessResult.LOW
if guess > secret:
return GuessResult.HIGH
return GuessResult.CORRECT
@@ -0,0 +1,16 @@
"""Source of the secret number.
@brief The only place in the project that uses randomness.
"""
import random
def random_secret_number(minimum: int, maximum: int) -> int:
"""Draw a secret number.
@param minimum Lowest possible number.
@param maximum Highest possible number.
@return A random integer from minimum to maximum, both included.
"""
return random.randint(minimum, maximum)
Whitespace-only changes.
+50
View File
@@ -0,0 +1,50 @@
"""Flask application of the Higher or Lower game.
@brief Routes that map a URL path to a guess and show the matching page.
"""
from flask import Flask
from higher_lower.application.guess_game import GuessGame
from higher_lower.constants import MAX_NUMBER, MIN_NUMBER
from higher_lower.infrastructure.secret_number import random_secret_number
from higher_lower.presentation.web.page import (
HOME_PAGE,
OUT_OF_RANGE_PAGE,
page_for_result,
)
from higher_lower.presentation.web.view import render_page
def create_app(secret_number: int | None = None) -> Flask:
"""Build the Flask application.
@param secret_number The number to find; None draws a random one that
stays the same for the lifetime of the application.
@return The configured Flask application.
"""
if secret_number is None:
secret_number = random_secret_number(MIN_NUMBER, MAX_NUMBER)
game = GuessGame(secret_number)
app = Flask(__name__)
@app.route("/")
def home() -> str:
"""Show the home page.
@return The home page HTML.
"""
return render_page(HOME_PAGE)
@app.route("/<int:guess>")
def guess_number(guess: int) -> str:
"""Judge the guess in the URL path.
@param guess The number the player guessed.
@return The page for a low, high, correct or out of range guess.
"""
if not game.is_in_range(guess):
return render_page(OUT_OF_RANGE_PAGE)
return render_page(page_for_result(game.check(guess)))
return app
+52
View File
@@ -0,0 +1,52 @@
"""Page view model of the web presentation.
@brief Plain data for one page, built from a guess result.
"""
from dataclasses import dataclass
from higher_lower.constants import (
CORRECT_COLOUR,
CORRECT_GIF,
CORRECT_HEADING,
HIGH_COLOUR,
HIGH_GIF,
HIGH_HEADING,
HOME_COLOUR,
HOME_GIF,
HOME_HEADING,
LOW_COLOUR,
LOW_GIF,
LOW_HEADING,
OUT_OF_RANGE_COLOUR,
OUT_OF_RANGE_HEADING,
)
from higher_lower.domain.rules import GuessResult
@dataclass(frozen=True, slots=True)
class PageViewModel:
"""@brief What a page shows: a coloured heading and a picture."""
heading: str
colour: str
gif: str
HOME_PAGE = PageViewModel(HOME_HEADING, HOME_COLOUR, HOME_GIF)
OUT_OF_RANGE_PAGE = PageViewModel(OUT_OF_RANGE_HEADING, OUT_OF_RANGE_COLOUR, HOME_GIF)
_PAGES_BY_RESULT: dict[GuessResult, PageViewModel] = {
GuessResult.LOW: PageViewModel(LOW_HEADING, LOW_COLOUR, LOW_GIF),
GuessResult.HIGH: PageViewModel(HIGH_HEADING, HIGH_COLOUR, HIGH_GIF),
GuessResult.CORRECT: PageViewModel(CORRECT_HEADING, CORRECT_COLOUR, CORRECT_GIF),
}
def page_for_result(result: GuessResult) -> PageViewModel:
"""Build the page that tells the player how the guess went.
@param result The outcome of the guess.
@return The page view model for that outcome.
"""
return _PAGES_BY_RESULT[result]
+20
View File
@@ -0,0 +1,20 @@
"""HTML view of the web presentation.
@brief Renders a page view model as HTML.
"""
from html import escape
from higher_lower.presentation.web.page import PageViewModel
def render_page(page: PageViewModel) -> str:
"""Render a page as HTML, escaping every value.
@param page The page to show.
@return A heading in the page colour followed by the picture.
"""
colour = escape(page.colour)
heading = escape(page.heading)
gif = escape(page.gif)
return f'<h1 style="color: {colour}">{heading}</h1><img src="{gif}">'
+89
View File
@@ -0,0 +1,89 @@
"""Tests of the web application through the Flask test client."""
import pytest
from flask.testing import FlaskClient
from higher_lower import constants
from higher_lower.presentation.web.app import create_app
SECRET = 5
@pytest.fixture
def client() -> FlaskClient:
return create_app(SECRET).test_client()
def _body(client: FlaskClient, path: str) -> str:
return client.get(path).get_data(as_text=True)
def _expected(heading: str, colour: str, gif: str) -> str:
return f'<h1 style="color: {colour}">{heading}</h1><img src="{gif}">'
def test_home_page_shows_home_heading_colour_and_gif(client: FlaskClient) -> None:
expected = _expected(
constants.HOME_HEADING, constants.HOME_COLOUR, constants.HOME_GIF
)
assert _body(client, "/") == expected
def test_guess_below_secret_shows_too_low_page(client: FlaskClient) -> None:
expected = _expected(constants.LOW_HEADING, constants.LOW_COLOUR, constants.LOW_GIF)
assert _body(client, "/3") == expected
def test_guess_above_secret_shows_too_high_page(client: FlaskClient) -> None:
expected = _expected(
constants.HIGH_HEADING, constants.HIGH_COLOUR, constants.HIGH_GIF
)
assert _body(client, "/7") == expected
def test_guess_equal_to_secret_shows_correct_page(client: FlaskClient) -> None:
expected = _expected(
constants.CORRECT_HEADING, constants.CORRECT_COLOUR, constants.CORRECT_GIF
)
assert _body(client, "/5") == expected
def test_pages_use_distinct_colours() -> None:
colours = {
constants.HOME_COLOUR,
constants.LOW_COLOUR,
constants.HIGH_COLOUR,
constants.CORRECT_COLOUR,
constants.OUT_OF_RANGE_COLOUR,
}
assert len(colours) == 5
def test_guess_above_range_shows_out_of_range_page_with_home_gif(
client: FlaskClient,
) -> None:
response = client.get("/10")
expected = _expected(
constants.OUT_OF_RANGE_HEADING,
constants.OUT_OF_RANGE_COLOUR,
constants.HOME_GIF,
)
assert response.status_code == 200
assert response.get_data(as_text=True) == expected
@pytest.mark.parametrize("path", ["/-1", "/abc"])
def test_negative_or_text_path_is_not_found(client: FlaskClient, path: str) -> None:
assert client.get(path).status_code == 404
def test_app_without_secret_keeps_one_random_secret() -> None:
client = create_app().test_client()
numbers = range(constants.MIN_NUMBER, constants.MAX_NUMBER + 1)
first = [_body(client, f"/{n}") for n in numbers]
second = [_body(client, f"/{n}") for n in numbers]
correct = _expected(
constants.CORRECT_HEADING, constants.CORRECT_COLOUR, constants.CORRECT_GIF
)
assert first == second
assert first.count(correct) == 1
+24
View File
@@ -0,0 +1,24 @@
"""Tests of the guess game use case."""
import pytest
from higher_lower.application.guess_game import GuessGame
from higher_lower.constants import MAX_NUMBER, MIN_NUMBER
from higher_lower.domain.rules import GuessResult
def test_check_reports_low_high_and_correct() -> None:
game = GuessGame(5)
assert game.check(2) is GuessResult.LOW
assert game.check(8) is GuessResult.HIGH
assert game.check(5) is GuessResult.CORRECT
@pytest.mark.parametrize("guess", [MIN_NUMBER, MAX_NUMBER])
def test_is_in_range_accepts_limits(guess: int) -> None:
assert GuessGame(5).is_in_range(guess)
@pytest.mark.parametrize("guess", [MIN_NUMBER - 1, MAX_NUMBER + 1])
def test_is_in_range_rejects_values_just_outside(guess: int) -> None:
assert not GuessGame(5).is_in_range(guess)
+17
View File
@@ -0,0 +1,17 @@
"""Tests of the game rules."""
import pytest
from higher_lower.domain.rules import GuessResult, compare_guess
@pytest.mark.parametrize(
("guess", "expected"),
[(3, GuessResult.LOW), (7, GuessResult.HIGH), (5, GuessResult.CORRECT)],
)
def test_compare_guess_classifies_guess(guess: int, expected: GuessResult) -> None:
assert compare_guess(guess, 5) is expected
def test_compare_guess_is_correct_at_lower_boundary() -> None:
assert compare_guess(0, 0) is GuessResult.CORRECT
+14
View File
@@ -0,0 +1,14 @@
"""Tests of the secret number source."""
from higher_lower.infrastructure.secret_number import random_secret_number
DRAWS = 500
def test_random_secret_number_stays_in_range() -> None:
draws = [random_secret_number(0, 9) for _ in range(DRAWS)]
assert all(0 <= value <= 9 for value in draws)
def test_random_secret_number_returns_the_only_possible_value() -> None:
assert random_secret_number(4, 4) == 4