diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 5b3d158..dbed37f 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/*.md | 005 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 | ## Languages diff --git a/docs/sqa/reviews/rc-010-code-mil-003.md b/docs/sqa/reviews/rc-010-code-mil-003.md new file mode 100644 index 0000000..6e26b3c --- /dev/null +++ b/docs/sqa/reviews/rc-010-code-mil-003.md @@ -0,0 +1,62 @@ +# 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 | [cc95105] | + +--- + +## 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 +[cc95105]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/055-higher-lower/commit/cc95105cf4fb9915e4c392fa58eec5d9a1f7c454 diff --git a/server.py b/server.py new file mode 100644 index 0000000..2cdf7b9 --- /dev/null +++ b/server.py @@ -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) diff --git a/src/higher_lower/application/guess_game.py b/src/higher_lower/application/guess_game.py new file mode 100644 index 0000000..bf81caf --- /dev/null +++ b/src/higher_lower/application/guess_game.py @@ -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 diff --git a/src/higher_lower/domain/rules.py b/src/higher_lower/domain/rules.py new file mode 100644 index 0000000..c84cc9e --- /dev/null +++ b/src/higher_lower/domain/rules.py @@ -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 diff --git a/src/higher_lower/infrastructure/secret_number.py b/src/higher_lower/infrastructure/secret_number.py new file mode 100644 index 0000000..e13a897 --- /dev/null +++ b/src/higher_lower/infrastructure/secret_number.py @@ -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) diff --git a/src/higher_lower/presentation/web/__init__.py b/src/higher_lower/presentation/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/higher_lower/presentation/web/app.py b/src/higher_lower/presentation/web/app.py new file mode 100644 index 0000000..97ce347 --- /dev/null +++ b/src/higher_lower/presentation/web/app.py @@ -0,0 +1,68 @@ +"""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 Blueprint, Flask, current_app + +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 + +## Key under which the game is stored in the Flask extensions registry. +_GAME_KEY = "guess_game" + +## Blueprint that holds the views of the game. +_blueprint = Blueprint("higher_lower", __name__) + + +def _current_game() -> GuessGame: + """Find the game stored in the running application. + + @return The game created by create_app. + """ + game: GuessGame = current_app.extensions[_GAME_KEY] + return game + + +@_blueprint.route("/") +def home() -> str: + """Show the home page. + + @return The home page HTML. + """ + return render_page(HOME_PAGE) + + +@_blueprint.route("/") +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. + """ + game = _current_game() + if not game.is_in_range(guess): + return render_page(OUT_OF_RANGE_PAGE) + return render_page(page_for_result(game.check(guess))) + + +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) + app = Flask(__name__) + app.extensions[_GAME_KEY] = GuessGame(secret_number) + app.register_blueprint(_blueprint) + return app diff --git a/src/higher_lower/presentation/web/page.py b/src/higher_lower/presentation/web/page.py new file mode 100644 index 0000000..4e3e21b --- /dev/null +++ b/src/higher_lower/presentation/web/page.py @@ -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] diff --git a/src/higher_lower/presentation/web/view.py b/src/higher_lower/presentation/web/view.py new file mode 100644 index 0000000..b79e856 --- /dev/null +++ b/src/higher_lower/presentation/web/view.py @@ -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'

{heading}

' diff --git a/tests/integration/test_web_app.py b/tests/integration/test_web_app.py new file mode 100644 index 0000000..518ee1e --- /dev/null +++ b/tests/integration/test_web_app.py @@ -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'

{heading}

' + + +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 diff --git a/tests/unit/test_guess_game.py b/tests/unit/test_guess_game.py new file mode 100644 index 0000000..1bdb99d --- /dev/null +++ b/tests/unit/test_guess_game.py @@ -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) diff --git a/tests/unit/test_rules.py b/tests/unit/test_rules.py new file mode 100644 index 0000000..a0b7d6c --- /dev/null +++ b/tests/unit/test_rules.py @@ -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 diff --git a/tests/unit/test_secret_number.py b/tests/unit/test_secret_number.py new file mode 100644 index 0000000..72e3d01 --- /dev/null +++ b/tests/unit/test_secret_number.py @@ -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