diff --git a/Dockerfile b/Dockerfile index 6f31eaf..2469b5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,3 +17,4 @@ RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]" COPY tests ./tests ENTRYPOINT ["python", "-m", "cookie_clicker_bot"] +CMD ["--headless"] diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index e91f9ca..e285cc2 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g. | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 008 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 015 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 | diff --git a/docs/images/step-1.png b/docs/images/step-1.png new file mode 100644 index 0000000..bbd4a1a Binary files /dev/null and b/docs/images/step-1.png differ diff --git a/docs/images/step-2.png b/docs/images/step-2.png new file mode 100644 index 0000000..e5e6577 Binary files /dev/null and b/docs/images/step-2.png differ diff --git a/docs/sqa/reviews/rc-015-code-mil-003.md b/docs/sqa/reviews/rc-015-code-mil-003.md new file mode 100644 index 0000000..52a4ca2 --- /dev/null +++ b/docs/sqa/reviews/rc-015-code-mil-003.md @@ -0,0 +1,62 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-015 | +| CrossReference | [MIL-003], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Code review record | [4de0501] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-003] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests added by MIL-003: constants, game port, game loop, Selenium adapter, browser factory, cli and 20 new tests (written with the python-developer agent); a 15 s container run clicked 344 times and produced step-1.png and step-2.png +- Language and domain: n/a +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | ruff (N rules) passes; names follow PEP 8 casing. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names use the terms of DICT-001 (cookie, upgrade, bot); no single-letter names outside comprehensions. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` pass with the rules in `pyproject.toml`; no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes; every signature is annotated. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; ruff B and S rules pass. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff B006 and A rules pass. | +| 7 | Files, locks and connections are managed with context managers | Pass | The browser is closed in a `finally` block in `main`; the screenshot folder is created with `Path.mkdir`. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings with `@brief`. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Diagnostics use `logging`; the required result line `cookies/second: N` is the program's output, not a log; no secrets are read or logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists (ADR-0001 skips use case design); ADR-0002 records the layout instead. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 23 tests pass; fakes replace the browser; 100% coverage of the logic modules. | +| 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` lists Selenium as the only runtime dependency; dev extras are test and lint tools. | +| 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 | Constants live only in `constants.py`; rules are defined once. | +| 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 no Selenium; `application` depends on a port; 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 | Pass | Modules have one reason to change; the game is behind a narrow port. | +| 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 | Layers `domain`, `application`, `infrastructure` under `src/`, `unit` and `integration` under `tests/`; no `presentation` package by ADR-0002. | +| 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 | English names, snake_case files, no spaces. | + +## Overall Verdict + +Go — the code of MIL-003 meets every Mandatory criterion; `ruff`, `mypy --strict` and `pytest` were run and pass. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-003]: ../../milestones/mil-003-click-bot.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[4de0501]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory-new/commit/4de05013ff6b204587ce694e40b757c9665cd7fd diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index a03bb5e..b3f08d0 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -42,6 +42,7 @@ Tracks backward and forward links between artifact instances so that the Busines | [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-012 | | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | - | RC-013 | | Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002] | - | RC-014 | +| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002] | - | RC-015 | ## Concern Coverage diff --git a/src/cookie_clicker_bot/application/game_loop.py b/src/cookie_clicker_bot/application/game_loop.py new file mode 100644 index 0000000..116e5b0 --- /dev/null +++ b/src/cookie_clicker_bot/application/game_loop.py @@ -0,0 +1,36 @@ +"""@file game_loop.py +@brief The game loop of the bot, written against the game port. +""" + +import time +from collections.abc import Callable + +from cookie_clicker_bot.application.ports import GamePort + + +def click_cookie(game: GamePort) -> None: + """@brief Click the big cookie once (lecture step 2). + + @param game The game to play. + """ + game.click_cookie() + + +def click_cookie_for( + game: GamePort, + duration_seconds: float, + clock: Callable[[], float] = time.monotonic, +) -> int: + """@brief Click the cookie as fast as possible until the time is up. + + @param game The game to play. + @param duration_seconds How long to keep clicking. + @param clock Monotonic clock in seconds; a test passes a fake one. + @return The number of clicks made. + """ + deadline = clock() + duration_seconds + clicks = 0 + while clock() < deadline: + click_cookie(game) + clicks += 1 + return clicks diff --git a/src/cookie_clicker_bot/application/ports.py b/src/cookie_clicker_bot/application/ports.py new file mode 100644 index 0000000..d3bfa42 --- /dev/null +++ b/src/cookie_clicker_bot/application/ports.py @@ -0,0 +1,28 @@ +"""@file ports.py +@brief The port through which the game loop talks to the game. +""" + +from pathlib import Path +from typing import Protocol + + +class GamePort(Protocol): + """@brief What the bot needs from the game, whatever drives the browser.""" + + def open_game(self) -> None: + """@brief Load the game page.""" + + def dismiss_language(self) -> None: + """@brief Pick English on the language selection and wait for the game.""" + + def click_cookie(self) -> None: + """@brief Click the big cookie once.""" + + def save_screenshot(self, path: Path) -> None: + """@brief Write a screenshot of the game to a PNG file. + + @param path Target file; its folder is created when missing. + """ + + def close(self) -> None: + """@brief Close the browser.""" diff --git a/src/cookie_clicker_bot/cli.py b/src/cookie_clicker_bot/cli.py index 148cd8a..eae2ccd 100644 --- a/src/cookie_clicker_bot/cli.py +++ b/src/cookie_clicker_bot/cli.py @@ -2,11 +2,85 @@ @brief Command-line entry point (the composition root of the bot). """ +import argparse +import logging +from collections.abc import Callable, Sequence +from pathlib import Path -def main() -> int: +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.game_loop import click_cookie_for +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.infrastructure.browser import create_driver +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + +logger = logging.getLogger(__name__) + + +def create_game(headless: bool) -> GamePort: + """@brief Build the Selenium game. + + @param headless True to run Chromium without a window. + @return The game, ready to open. + """ + return SeleniumGame(create_driver(headless)) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """@brief Read the command line. + + @param argv Arguments without the program name; None reads `sys.argv`. + @return The parsed options. + """ + parser = argparse.ArgumentParser( + prog="cookie-clicker-bot", + description="Plays Cookie Clicker: clicks the big cookie as fast as it can.", + ) + parser.add_argument( + "--duration", + type=float, + default=constants.RUN_DURATION_SECONDS, + help="seconds to play (default: %(default)s)", + ) + parser.add_argument( + "--headless", + action="store_true", + help="run Chromium without a window (the Docker image does this)", + ) + parser.add_argument( + "--screenshot-start", + type=Path, + help="write a screenshot of the game before the first click", + ) + parser.add_argument( + "--screenshot", + type=Path, + help="write a screenshot of the game when the run ends", + ) + return parser.parse_args(argv) + + +def main( + argv: Sequence[str] | None = None, + game_factory: Callable[[bool], GamePort] = create_game, +) -> int: """@brief Start the bot. + @param argv Arguments without the program name; None reads `sys.argv`. + @param game_factory Builds the game; a test passes a fake. @return Process exit code, 0 when the run finished. """ - print("cookie-clicker-bot: the game loop arrives with MIL-003 to MIL-005.") + logging.basicConfig(level=logging.INFO, format="%(message)s") + args = parse_args(argv) + game = game_factory(args.headless) + try: + game.open_game() + game.dismiss_language() + if args.screenshot_start is not None: + game.save_screenshot(args.screenshot_start) + clicks = click_cookie_for(game, args.duration) + logger.info("clicked the cookie %d times in %s seconds", clicks, args.duration) + if args.screenshot is not None: + game.save_screenshot(args.screenshot) + finally: + game.close() return 0 diff --git a/src/cookie_clicker_bot/constants.py b/src/cookie_clicker_bot/constants.py index 926a493..746d18a 100644 --- a/src/cookie_clicker_bot/constants.py +++ b/src/cookie_clicker_bot/constants.py @@ -1,5 +1,5 @@ """@file constants.py -@brief Every constant of the bot: the game address, timings and messages. +@brief Every constant of the bot: the game address, selectors, timings and messages. Selectors of the game page are added here by the milestone that first uses them. """ @@ -20,3 +20,31 @@ WAIT_TIMEOUT_SECONDS: Final[int] = 20 ## Folder where screenshots of a container run are written. SCREENSHOT_DIR: Final[str] = "docs/images" + +## Id of the button that picks English on the language selection page. +LANGUAGE_ENGLISH_ID: Final[str] = "langSelect-EN" + +## Id of the big cookie in the left-hand pane. +BIG_COOKIE_ID: Final[str] = "bigCookie" + +## Id of the element that shows the cookie count and the cookies per second. +COOKIE_COUNT_ID: Final[str] = "cookies" + +## Seconds between two polls while waiting for an element. +POLL_INTERVAL_SECONDS: Final[float] = 0.2 + +## Environment variable that names the Chromium binary (set in the Docker image). +CHROME_BIN_ENV: Final[str] = "CHROME_BIN" + +## Environment variable that names the Chromium driver (set in the Docker image). +CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH" + +## Size of the browser window in pixels, so the screenshots look the same every time. +WINDOW_SIZE: Final[str] = "1366,768" + +## Chromium arguments needed to run headless inside a container. +HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = ( + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", +) diff --git a/src/cookie_clicker_bot/infrastructure/browser.py b/src/cookie_clicker_bot/infrastructure/browser.py new file mode 100644 index 0000000..c4dff92 --- /dev/null +++ b/src/cookie_clicker_bot/infrastructure/browser.py @@ -0,0 +1,33 @@ +"""@file browser.py +@brief Creates the Chromium driver, headless when asked. +""" + +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service + +from cookie_clicker_bot import constants + + +def create_driver(headless: bool) -> webdriver.Chrome: + """@brief Start Chromium. + + The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when + these are set (the Docker image sets them); otherwise Selenium finds them. + + @param headless True to run without a window, as in the Docker container. + @return A running Chromium driver. + """ + options = Options() + options.add_argument(f"--window-size={constants.WINDOW_SIZE}") + if headless: + for argument in constants.HEADLESS_BROWSER_ARGUMENTS: + options.add_argument(argument) + binary = os.environ.get(constants.CHROME_BIN_ENV) + if binary: + options.binary_location = binary + driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV) + service = Service(executable_path=driver_path) if driver_path else Service() + return webdriver.Chrome(service=service, options=options) diff --git a/src/cookie_clicker_bot/infrastructure/selenium_game.py b/src/cookie_clicker_bot/infrastructure/selenium_game.py new file mode 100644 index 0000000..1ca8bf3 --- /dev/null +++ b/src/cookie_clicker_bot/infrastructure/selenium_game.py @@ -0,0 +1,131 @@ +"""@file selenium_game.py +@brief Selenium adapter that implements the game port. +""" + +import time +from collections.abc import Callable +from pathlib import Path +from typing import Protocol, TypeVar + +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + StaleElementReferenceException, +) +from selenium.webdriver.common.by import By + +from cookie_clicker_bot import constants + +T = TypeVar("T") + + +class ElementLike(Protocol): + """@brief The part of a Selenium element that the adapter uses.""" + + @property + def text(self) -> str: + """@brief Visible text of the element.""" + ... + + def click(self) -> None: + """@brief Click the element.""" + + def is_displayed(self) -> bool: + """@brief True when the element is visible.""" + ... + + +class DriverLike(Protocol): + """@brief The part of a Selenium driver that the adapter uses.""" + + def get(self, url: str) -> None: + """@brief Load a page.""" + + def find_element(self, by: str, value: str) -> ElementLike: + """@brief Find one element.""" + ... + + def save_screenshot(self, filename: str) -> bool: + """@brief Write a PNG screenshot.""" + ... + + def quit(self) -> None: + """@brief Close the browser.""" + + +class SeleniumGame: + """@brief Plays the game page through a Selenium driver.""" + + def __init__( + self, + driver: DriverLike, + timeout_seconds: float = constants.WAIT_TIMEOUT_SECONDS, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + """@brief Wrap a driver. + + @param driver The Selenium driver (or a fake in tests). + @param timeout_seconds Longest wait for an element. + @param clock Monotonic clock in seconds. + @param sleep Pause function used between polls. + """ + self._driver = driver + self._timeout = timeout_seconds + self._clock = clock + self._sleep = sleep + + def open_game(self) -> None: + """@brief Load the game page.""" + self._driver.get(constants.GAME_URL) + + def dismiss_language(self) -> None: + """@brief Pick English, then wait until the big cookie can be clicked.""" + english = self._wait_for(lambda: self._visible(constants.LANGUAGE_ENGLISH_ID)) + english.click() + self._wait_for(lambda: self._visible(constants.BIG_COOKIE_ID)) + + def click_cookie(self) -> None: + """@brief Click the big cookie once.""" + try: + self._driver.find_element(By.ID, constants.BIG_COOKIE_ID).click() + except (ElementClickInterceptedException, StaleElementReferenceException): + # A banner or a re-render covered the cookie for a moment; try again. + return + + def save_screenshot(self, path: Path) -> None: + """@brief Write a screenshot of the game to a PNG file. + + @param path Target file; its folder is created when missing. + """ + path.parent.mkdir(parents=True, exist_ok=True) + self._driver.save_screenshot(str(path)) + + def close(self) -> None: + """@brief Close the browser.""" + self._driver.quit() + + def _visible(self, element_id: str) -> ElementLike | None: + """@brief Return the element with this id when it exists and is displayed.""" + try: + element = self._driver.find_element(By.ID, element_id) + except NoSuchElementException: + return None + return element if element.is_displayed() else None + + def _wait_for(self, locate: Callable[[], T | None]) -> T: + """@brief Poll until `locate` returns a value. + + @param locate Function that returns the value, or None while not ready. + @return The value. + @throws TimeoutError When the value is not there within the timeout. + """ + deadline = self._clock() + self._timeout + while True: + found = locate() + if found is not None: + return found + if self._clock() >= deadline: + message = f"the game page was not ready within {self._timeout} s" + raise TimeoutError(message) + self._sleep(constants.POLL_INTERVAL_SECONDS) diff --git a/tests/integration/test_selenium_game.py b/tests/integration/test_selenium_game.py new file mode 100644 index 0000000..d7c8fdb --- /dev/null +++ b/tests/integration/test_selenium_game.py @@ -0,0 +1,177 @@ +"""@file test_selenium_game.py +@brief Tests of the Selenium adapter against a fake driver (no browser). +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + StaleElementReferenceException, +) + +from cookie_clicker_bot import constants +from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame + + +class FakeElement: + """An element that records clicks and may raise on click.""" + + def __init__( + self, + displayed: Callable[[], bool] = lambda: True, + click_error: Exception | None = None, + ) -> None: + self.text = "" + self.clicks = 0 + self._displayed = displayed + self._click_error = click_error + + def click(self) -> None: + if self._click_error is not None: + raise self._click_error + self.clicks += 1 + + def is_displayed(self) -> bool: + return self._displayed() + + +class FakeDriver: + """A driver with elements by id.""" + + def __init__(self, elements: dict[str, FakeElement] | None = None) -> None: + self.elements = elements or {} + self.loaded: list[str] = [] + self.screenshots: list[str] = [] + self.quit_called = False + + def get(self, url: str) -> None: + self.loaded.append(url) + + def find_element(self, by: str, value: str) -> FakeElement: + if by != "id" or value not in self.elements: + raise NoSuchElementException(value) + return self.elements[value] + + def save_screenshot(self, filename: str) -> bool: + self.screenshots.append(filename) + return True + + def quit(self) -> None: + self.quit_called = True + + +class FakeTime: + """A clock that only moves when the fake sleep is called.""" + + def __init__(self) -> None: + self.now = 0.0 + self.sleeps: list[float] = [] + + def clock(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +def make_game(driver: FakeDriver, fake_time: FakeTime | None = None) -> SeleniumGame: + fake_time = fake_time or FakeTime() + return SeleniumGame( + driver, timeout_seconds=1.0, clock=fake_time.clock, sleep=fake_time.sleep + ) + + +def ready_driver() -> FakeDriver: + return FakeDriver( + { + constants.LANGUAGE_ENGLISH_ID: FakeElement(), + constants.BIG_COOKIE_ID: FakeElement(), + } + ) + + +def test_open_game_loads_the_game_address() -> None: + driver = FakeDriver() + + make_game(driver).open_game() + + assert driver.loaded == [constants.GAME_URL] + + +def test_dismiss_language_clicks_english_and_waits_for_the_cookie() -> None: + driver = ready_driver() + + make_game(driver).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + + +def test_dismiss_language_polls_until_the_button_is_visible() -> None: + fake_time = FakeTime() + driver = ready_driver() + driver.elements[constants.LANGUAGE_ENGLISH_ID] = FakeElement( + displayed=lambda: fake_time.now >= 0.4 + ) + + make_game(driver, fake_time).dismiss_language() + + assert driver.elements[constants.LANGUAGE_ENGLISH_ID].clicks == 1 + assert fake_time.sleeps == [constants.POLL_INTERVAL_SECONDS] * 2 + + +def test_dismiss_language_times_out_when_the_button_never_shows() -> None: + fake_time = FakeTime() + + with pytest.raises(TimeoutError): + make_game(FakeDriver(), fake_time).dismiss_language() + + assert fake_time.now >= 1.0 + + +def test_dismiss_language_times_out_when_the_cookie_never_shows() -> None: + driver = FakeDriver({constants.LANGUAGE_ENGLISH_ID: FakeElement()}) + + with pytest.raises(TimeoutError): + make_game(driver).dismiss_language() + + +def test_click_cookie_clicks_the_big_cookie() -> None: + driver = ready_driver() + + make_game(driver).click_cookie() + + assert driver.elements[constants.BIG_COOKIE_ID].clicks == 1 + + +@pytest.mark.parametrize( + "error", + [ElementClickInterceptedException(), StaleElementReferenceException()], +) +def test_click_cookie_ignores_a_covered_or_stale_cookie(error: Exception) -> None: + driver = FakeDriver({constants.BIG_COOKIE_ID: FakeElement(click_error=error)}) + + make_game(driver).click_cookie() + + +def test_save_screenshot_creates_the_folder_and_calls_the_driver( + tmp_path: Path, +) -> None: + driver = FakeDriver() + target = tmp_path / "images" / "shot.png" + + make_game(driver).save_screenshot(target) + + assert target.parent.is_dir() + assert driver.screenshots == [str(target)] + + +def test_close_quits_the_driver() -> None: + driver = FakeDriver() + + make_game(driver).close() + + assert driver.quit_called diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..1932d47 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,117 @@ +"""@file test_cli.py +@brief Tests of the command line and the run sequence with a fake game. +""" + +from pathlib import Path + +import pytest + +from cookie_clicker_bot import constants +from cookie_clicker_bot.application.ports import GamePort +from cookie_clicker_bot.cli import main, parse_args + + +class RecordingGame: + """A game that records every call in order.""" + + def __init__(self, fail_on_open: bool = False) -> None: + self.calls: list[str] = [] + self.screenshots: list[Path] = [] + self._fail_on_open = fail_on_open + + def open_game(self) -> None: + self.calls.append("open_game") + if self._fail_on_open: + message = "boom" + raise RuntimeError(message) + + def dismiss_language(self) -> None: + self.calls.append("dismiss_language") + + def click_cookie(self) -> None: + self.calls.append("click_cookie") + + def save_screenshot(self, path: Path) -> None: + self.calls.append("save_screenshot") + self.screenshots.append(path) + + def close(self) -> None: + self.calls.append("close") + + +def test_parse_args_uses_defaults() -> None: + args = parse_args([]) + + assert args.duration == constants.RUN_DURATION_SECONDS == 300 + assert args.headless is False + assert args.screenshot_start is None + assert args.screenshot is None + + +def test_parse_args_reads_every_option() -> None: + args = parse_args( + [ + "--headless", + "--duration", + "10", + "--screenshot-start", + "a.png", + "--screenshot", + "b.png", + ] + ) + + assert args.headless is True + assert args.duration == 10 + assert args.screenshot_start == Path("a.png") + assert args.screenshot == Path("b.png") + + +def test_main_opens_dismisses_and_closes() -> None: + game = RecordingGame() + + code = main(["--duration", "0"], lambda _headless: game) + + assert code == 0 + assert game.calls == ["open_game", "dismiss_language", "close"] + + +def test_main_passes_headless_to_the_factory() -> None: + seen: list[bool] = [] + + def factory(headless: bool) -> GamePort: + seen.append(headless) + return RecordingGame() + + main(["--headless", "--duration", "0"], factory) + + assert seen == [True] + + +def test_main_takes_both_screenshots_when_asked() -> None: + game = RecordingGame() + + main( + ["--duration", "0", "--screenshot-start", "s.png", "--screenshot", "e.png"], + lambda _headless: game, + ) + + assert game.screenshots == [Path("s.png"), Path("e.png")] + assert game.calls[-1] == "close" + + +def test_main_takes_no_screenshot_by_default() -> None: + game = RecordingGame() + + main(["--duration", "0"], lambda _headless: game) + + assert game.screenshots == [] + + +def test_main_closes_the_game_when_an_error_is_raised() -> None: + game = RecordingGame(fail_on_open=True) + + with pytest.raises(RuntimeError, match="boom"): + main(["--duration", "0"], lambda _headless: game) + + assert game.calls == ["open_game", "close"] diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py new file mode 100644 index 0000000..93999fd --- /dev/null +++ b/tests/unit/test_game_loop.py @@ -0,0 +1,66 @@ +"""@file test_game_loop.py +@brief Tests of the game loop with a fake game and a fake clock. +""" + +from pathlib import Path + +from cookie_clicker_bot.application.game_loop import click_cookie, click_cookie_for + + +class FakeGame: + """A game that only counts clicks.""" + + def __init__(self) -> None: + self.clicks = 0 + + def open_game(self) -> None: + pass + + def dismiss_language(self) -> None: + pass + + def click_cookie(self) -> None: + self.clicks += 1 + + def save_screenshot(self, path: Path) -> None: + pass + + def close(self) -> None: + pass + + +class FakeClock: + """A clock that moves forward one second each time it is read.""" + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + value = self.now + self.now += 1.0 + return value + + +def test_click_cookie_delegates_to_the_game() -> None: + game = FakeGame() + + click_cookie(game) + + assert game.clicks == 1 + + +def test_click_cookie_for_clicks_until_the_deadline() -> None: + game = FakeGame() + + # Reads: deadline = 0 + 5; the loop reads 1, 2, 3, 4 (clicks) and 5 (stops). + clicks = click_cookie_for(game, 5, FakeClock()) + + assert clicks == 4 + assert game.clicks == 4 + + +def test_click_cookie_for_does_not_click_when_duration_is_zero() -> None: + game = FakeGame() + + assert click_cookie_for(game, 0, FakeClock()) == 0 + assert game.clicks == 0