MIL-002: Bot core, open the game and click the cookie #30
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 006 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 011 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-011 |
|
||||
| CrossReference | [MIL-002], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [68c4391] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: source of milestone MIL-002 (open_game, select_language, click_cookie, get_cookie_count, parser, Selenium adapter) ([MIL-002])
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- 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 | snake_case modules and 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 | Pass | Names state purpose: parse_game_number, wait_for_element, ELEMENT_TIMEOUT_SECONDS. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` clean; no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated, including -> None; mypy strict passes. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; ValueError and UnreadableNumberError are raised with a message. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; no builtin shadowed. |
|
||||
| 7 | Files, locks and connections are managed with context managers | N-A | The browser is released by SeleniumGame.close(); the composition root in MIL-003 calls it in a finally block. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (@brief, @param, @return). |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | N-A | No logging yet; added with the run loop in MIL-003. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | Plain technical tasks; no Design Class Diagram exists or is required. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 33 tests (unit and integration) run offline with a fake game and a fake driver; positive, negative, boundary and edge cases covered. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict: no issues in 22 files; no Any. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | selenium is declared and is the only runtime dependency. |
|
||||
| 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 | URL, ids and number words are defined once 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 constants; application imports domain and its own port; only infrastructure/selenium_game.py imports selenium; no 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 | GamePort is a narrow Protocol (4 methods); SeleniumGame receives its driver (dependency inversion); each module has one purpose. |
|
||||
| 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, application (ports, use_cases), infrastructure and presentation under src/; unit, integration and e2e under tests/. |
|
||||
| 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 | All names English, snake_case, no spaces. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go - All Mandatory criteria pass or are not applicable; the live-game behaviour is verified in MIL-004. Stand-in review, not independent.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-002]: ../../milestones/mil-002-bot-core-open-and-click.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4
|
||||
@@ -9,8 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | <reviewer S-ID> | Initial version | [96a9a78] |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added ADR-0001, RC-009 and RC-010 | [ad3334f] |
|
||||
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added RC-011 (MIL-002 source review) | [68c4391] |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,7 +28,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| [SA-001] | SA | en | it | [BC-001] | [PP-001] | [RC-002] |
|
||||
| [PP-001] | PP | en | it | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] | [RC-003] |
|
||||
| [MIL-001] | MIL | en | it | [PP-001] | [MIL-002], [ADR-0001] | [RC-004], [RC-010] |
|
||||
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005] |
|
||||
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-005], [RC-011] |
|
||||
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-006] |
|
||||
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-007] |
|
||||
| [MIL-005] | MIL | en | it | [MIL-004] | - | [RC-008] |
|
||||
@@ -71,7 +71,8 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[RC-006]: ./reviews/rc-006-mil-003-upgrades-and-five-minute-run.md
|
||||
[RC-007]: ./reviews/rc-007-mil-004-screenshots-and-readme.md
|
||||
[RC-008]: ./reviews/rc-008-mil-005-wiki-and-release.md
|
||||
[96a9a78]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/96a9a78f010ce8ae3fce8d84602317d8b5808a13
|
||||
[RC-009]: ./reviews/rc-009-adr-0001-selenium-runtime-dependency.md
|
||||
[RC-010]: ./reviews/rc-010-mil-001-source.md
|
||||
[ad3334f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/ad3334fa13c4261c3d034fbffcc7b43fe95fbb31
|
||||
[RC-011]: ./reviews/rc-011-mil-002-source.md
|
||||
[68c4391]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/048-the-coockie-factory/commit/68c439137f13299e20cf6271e392365d9f5371c4
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file
|
||||
@brief Ports: what the application needs from the outside world.
|
||||
"""
|
||||
@@ -0,0 +1,34 @@
|
||||
"""@file
|
||||
@brief The port through which the bot sees and operates the game.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class GamePort(Protocol):
|
||||
"""@brief A running game page. The Selenium adapter implements it."""
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
"""@brief Load the page at the address.
|
||||
@param url The address of the game.
|
||||
"""
|
||||
...
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
"""@brief Block until the element is on the page.
|
||||
@param element_id The HTML id of the element.
|
||||
"""
|
||||
...
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
"""@brief Click the element the way a user does.
|
||||
@param element_id The HTML id of the element.
|
||||
"""
|
||||
...
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
"""@brief Return the visible text of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@return The text as shown on the page.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,3 @@
|
||||
"""@file
|
||||
@brief Use cases of the bot, one function per lecture step.
|
||||
"""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""@file
|
||||
@brief Lecture steps 1 to 3: open the game, click the cookie, read the count.
|
||||
"""
|
||||
|
||||
from cookie_clicker_bot.application.ports.game_port import GamePort
|
||||
from cookie_clicker_bot.constants import (
|
||||
BIG_COOKIE_ID,
|
||||
COOKIE_COUNTER_ID,
|
||||
GAME_URL,
|
||||
LANGUAGE_BUTTON_PREFIX,
|
||||
LANGUAGE_CODE,
|
||||
)
|
||||
from cookie_clicker_bot.domain.number_parsing import parse_game_number
|
||||
|
||||
|
||||
def open_game(game: GamePort) -> None:
|
||||
"""@brief Load the game and wait until it asks for a language.
|
||||
@param game The game page to operate.
|
||||
"""
|
||||
game.open_page(GAME_URL)
|
||||
game.wait_for_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||
|
||||
|
||||
def select_language(game: GamePort) -> None:
|
||||
"""@brief Choose the language and wait until the big cookie is playable.
|
||||
@param game The game page to operate.
|
||||
"""
|
||||
game.click_element(LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE)
|
||||
game.wait_for_element(BIG_COOKIE_ID)
|
||||
|
||||
|
||||
def click_cookie(game: GamePort, times: int = 1) -> None:
|
||||
"""@brief Click the big cookie.
|
||||
@param game The game page to operate.
|
||||
@param times How many clicks to make, zero or more.
|
||||
@throws ValueError When times is negative.
|
||||
"""
|
||||
if times < 0:
|
||||
raise ValueError(f"times must not be negative, got {times}")
|
||||
for _ in range(times):
|
||||
game.click_element(BIG_COOKIE_ID)
|
||||
|
||||
|
||||
def get_cookie_count(game: GamePort) -> int:
|
||||
"""@brief Read how many cookies the bakery has now.
|
||||
@param game The game page to read.
|
||||
@return The number of cookies, rounded to a whole number.
|
||||
@throws UnreadableNumberError When the counter shows no number.
|
||||
"""
|
||||
first_line = game.read_text(COOKIE_COUNTER_ID).strip().splitlines()[0]
|
||||
return parse_game_number(first_line)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""@file
|
||||
@brief Constants shared by every layer of the bot.
|
||||
|
||||
Game specific values (URL, element ids) are added with the code that uses them.
|
||||
"""
|
||||
|
||||
## Seconds the bot plays before it reads the cookies per second (lecture step 5).
|
||||
@@ -12,3 +10,36 @@ STORE_CHECK_INTERVAL_SECONDS: int = 5
|
||||
|
||||
## Seconds to wait for an element before the bot gives up.
|
||||
ELEMENT_TIMEOUT_SECONDS: int = 20
|
||||
|
||||
## Address of the game the bot plays.
|
||||
GAME_URL: str = "https://ozh.github.io/cookieclicker/"
|
||||
|
||||
## Element id prefix of the language buttons shown on the first launch.
|
||||
LANGUAGE_BUTTON_PREFIX: str = "langSelect-"
|
||||
|
||||
## Language the bot chooses (the game's own code for English).
|
||||
LANGUAGE_CODE: str = "EN"
|
||||
|
||||
## Element id of the big cookie that is clicked to earn cookies.
|
||||
BIG_COOKIE_ID: str = "bigCookie"
|
||||
|
||||
## Element id of the counter that shows the cookies and the cookies per second.
|
||||
COOKIE_COUNTER_ID: str = "cookies"
|
||||
|
||||
## Element id of the line that shows the cookies per second.
|
||||
CPS_ID: str = "cookiesPerSecond"
|
||||
|
||||
## Number words the game prints for large amounts, with their value.
|
||||
NUMBER_SCALES: dict[str, int] = {
|
||||
"thousand": 10**3,
|
||||
"million": 10**6,
|
||||
"billion": 10**9,
|
||||
"trillion": 10**12,
|
||||
"quadrillion": 10**15,
|
||||
"quintillion": 10**18,
|
||||
"sextillion": 10**21,
|
||||
"septillion": 10**24,
|
||||
"octillion": 10**27,
|
||||
"nonillion": 10**30,
|
||||
"decillion": 10**33,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""@file
|
||||
@brief Errors raised by the game rules.
|
||||
"""
|
||||
|
||||
|
||||
class UnreadableNumberError(ValueError):
|
||||
"""@brief Raised when text from the game does not contain a readable number."""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""@file
|
||||
@brief Turns the amounts the game prints ("1.234 million") into numbers.
|
||||
"""
|
||||
|
||||
import re
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from cookie_clicker_bot.constants import NUMBER_SCALES
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
|
||||
_AMOUNT = re.compile(r"^\s*(\d[\d,]*(?:\.\d+)?)(?:\s+([a-z]+))?", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_game_number(text: str) -> int:
|
||||
"""@brief Read the first amount in a text printed by the game.
|
||||
|
||||
The game groups thousands with commas ("12,345") and writes large amounts
|
||||
with a number word ("1.234 million"). Any other word after the number, such
|
||||
as "cookies", is ignored.
|
||||
|
||||
@param text The text, for example "1.234 million" or "12,345 cookies".
|
||||
@return The amount, rounded to a whole number.
|
||||
@throws UnreadableNumberError When the text does not start with a number.
|
||||
"""
|
||||
match = _AMOUNT.match(text)
|
||||
if match is None:
|
||||
raise UnreadableNumberError(f"No number at the start of {text!r}")
|
||||
value = Decimal(match.group(1).replace(",", ""))
|
||||
scale = NUMBER_SCALES.get((match.group(2) or "").lower(), 1)
|
||||
return int((value * scale).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
@@ -0,0 +1,81 @@
|
||||
"""@file
|
||||
@brief Selenium adapter for the GamePort, and the factory for the Chrome driver.
|
||||
"""
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.support import expected_conditions
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from cookie_clicker_bot.constants import ELEMENT_TIMEOUT_SECONDS
|
||||
|
||||
_HEADLESS_ARGUMENTS: tuple[str, ...] = (
|
||||
"--headless=new",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--window-size=1280,1024",
|
||||
)
|
||||
|
||||
|
||||
class SeleniumGame:
|
||||
"""@brief Operates the game page through a Selenium web driver."""
|
||||
|
||||
def __init__(self, driver: WebDriver) -> None:
|
||||
"""@brief Create the adapter.
|
||||
@param driver The web driver to operate; the caller owns its creation.
|
||||
"""
|
||||
self._driver = driver
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
"""@brief Load the page at the address.
|
||||
@param url The address of the game.
|
||||
"""
|
||||
self._driver.get(url)
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
"""@brief Block until the element is on the page.
|
||||
@param element_id The HTML id of the element.
|
||||
@throws selenium.common.exceptions.TimeoutException When it never appears.
|
||||
"""
|
||||
WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||
expected_conditions.presence_of_element_located((By.ID, element_id))
|
||||
)
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
"""@brief Click the element with a real click, never a script click.
|
||||
|
||||
The game rate-limits synthetic clicks, so the click must come from the
|
||||
browser the way a user's does.
|
||||
|
||||
@param element_id The HTML id of the element.
|
||||
@throws selenium.common.exceptions.TimeoutException When it never
|
||||
becomes clickable.
|
||||
"""
|
||||
element = WebDriverWait(self._driver, ELEMENT_TIMEOUT_SECONDS).until(
|
||||
expected_conditions.element_to_be_clickable((By.ID, element_id))
|
||||
)
|
||||
element.click()
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
"""@brief Return the visible text of the element.
|
||||
@param element_id The HTML id of the element.
|
||||
@return The text as shown on the page.
|
||||
"""
|
||||
return self._driver.find_element(By.ID, element_id).text
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Quit the browser and end the driver session."""
|
||||
self._driver.quit()
|
||||
|
||||
|
||||
def create_chrome_driver(headless: bool = False) -> WebDriver:
|
||||
"""@brief Build a Chrome web driver.
|
||||
@param headless True to run without a window, for a container or CI.
|
||||
@return The started Chrome driver.
|
||||
"""
|
||||
options = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
for argument in _HEADLESS_ARGUMENTS:
|
||||
options.add_argument(argument)
|
||||
return webdriver.Chrome(options=options)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""@file
|
||||
@brief Tests of the Selenium adapter against a hand-written fake driver.
|
||||
|
||||
No browser and no network: the fake driver records what the adapter asks for.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from cookie_clicker_bot.infrastructure.selenium_game import SeleniumGame
|
||||
|
||||
|
||||
class FakeElement:
|
||||
"""@brief An element that counts its real clicks."""
|
||||
|
||||
def __init__(self, text: str = "") -> None:
|
||||
self.text = text
|
||||
self.clicks = 0
|
||||
|
||||
def is_displayed(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def click(self) -> None:
|
||||
self.clicks += 1
|
||||
|
||||
|
||||
class FakeDriver:
|
||||
"""@brief A driver with elements by id; it records its calls."""
|
||||
|
||||
def __init__(self, elements: dict[str, FakeElement]) -> None:
|
||||
self.elements = elements
|
||||
self.visited: list[str] = []
|
||||
self.lookups: list[tuple[str, str]] = []
|
||||
self.quit_calls = 0
|
||||
|
||||
def get(self, url: str) -> None:
|
||||
self.visited.append(url)
|
||||
|
||||
def find_element(self, by: str, value: str) -> FakeElement:
|
||||
self.lookups.append((by, value))
|
||||
if value not in self.elements:
|
||||
raise NoSuchElementException(value)
|
||||
return self.elements[value]
|
||||
|
||||
def quit(self) -> None:
|
||||
self.quit_calls += 1
|
||||
|
||||
|
||||
def make_game(driver: FakeDriver) -> SeleniumGame:
|
||||
return SeleniumGame(cast(WebDriver, driver))
|
||||
|
||||
|
||||
def test_open_page_loads_the_url() -> None:
|
||||
driver = FakeDriver({})
|
||||
|
||||
make_game(driver).open_page("https://example.test/")
|
||||
|
||||
assert driver.visited == ["https://example.test/"]
|
||||
|
||||
|
||||
def test_wait_for_element_looks_up_by_id() -> None:
|
||||
driver = FakeDriver({"bigCookie": FakeElement()})
|
||||
|
||||
make_game(driver).wait_for_element("bigCookie")
|
||||
|
||||
assert driver.lookups == [(By.ID, "bigCookie")]
|
||||
|
||||
|
||||
def test_read_text_returns_the_element_text() -> None:
|
||||
driver = FakeDriver({"cookies": FakeElement("12 cookies")})
|
||||
|
||||
assert make_game(driver).read_text("cookies") == "12 cookies"
|
||||
assert driver.lookups == [(By.ID, "cookies")]
|
||||
|
||||
|
||||
def test_click_element_makes_one_real_click() -> None:
|
||||
element = FakeElement()
|
||||
driver = FakeDriver({"bigCookie": element})
|
||||
|
||||
make_game(driver).click_element("bigCookie")
|
||||
|
||||
assert element.clicks == 1
|
||||
assert all(by == By.ID for by, _ in driver.lookups)
|
||||
|
||||
|
||||
def test_read_text_raises_when_element_is_missing() -> None:
|
||||
game = make_game(FakeDriver({}))
|
||||
|
||||
with pytest.raises(NoSuchElementException):
|
||||
game.read_text("missing")
|
||||
|
||||
|
||||
def test_close_quits_the_driver() -> None:
|
||||
driver = FakeDriver({})
|
||||
|
||||
make_game(driver).close()
|
||||
|
||||
assert driver.quit_calls == 1
|
||||
@@ -0,0 +1,25 @@
|
||||
"""@file
|
||||
@brief A fake game page for tests that must not start a browser.
|
||||
"""
|
||||
|
||||
|
||||
class FakeGame:
|
||||
"""@brief Records what the bot does and answers reads from a dictionary."""
|
||||
|
||||
def __init__(self, texts: dict[str, str] | None = None) -> None:
|
||||
self.texts: dict[str, str] = texts or {}
|
||||
self.opened: list[str] = []
|
||||
self.waited: list[str] = []
|
||||
self.clicked: list[str] = []
|
||||
|
||||
def open_page(self, url: str) -> None:
|
||||
self.opened.append(url)
|
||||
|
||||
def wait_for_element(self, element_id: str) -> None:
|
||||
self.waited.append(element_id)
|
||||
|
||||
def click_element(self, element_id: str) -> None:
|
||||
self.clicked.append(element_id)
|
||||
|
||||
def read_text(self, element_id: str) -> str:
|
||||
return self.texts[element_id]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""@file
|
||||
@brief Tests of lecture steps 1 to 3 against a fake game.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.application.use_cases.bot_core import (
|
||||
click_cookie,
|
||||
get_cookie_count,
|
||||
open_game,
|
||||
select_language,
|
||||
)
|
||||
from cookie_clicker_bot.constants import (
|
||||
BIG_COOKIE_ID,
|
||||
COOKIE_COUNTER_ID,
|
||||
GAME_URL,
|
||||
LANGUAGE_BUTTON_PREFIX,
|
||||
LANGUAGE_CODE,
|
||||
)
|
||||
from tests.unit.fakes import FakeGame
|
||||
|
||||
LANGUAGE_BUTTON = LANGUAGE_BUTTON_PREFIX + LANGUAGE_CODE
|
||||
|
||||
|
||||
def test_open_game_loads_the_url_and_waits_for_the_language_prompt() -> None:
|
||||
game = FakeGame()
|
||||
open_game(game)
|
||||
assert game.opened == [GAME_URL]
|
||||
assert game.waited == [LANGUAGE_BUTTON]
|
||||
|
||||
|
||||
def test_select_language_clicks_the_button_and_waits_for_the_cookie() -> None:
|
||||
game = FakeGame()
|
||||
select_language(game)
|
||||
assert game.clicked == [LANGUAGE_BUTTON]
|
||||
assert game.waited == [BIG_COOKIE_ID]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("times", [0, 1, 2, 50])
|
||||
def test_click_cookie_clicks_the_requested_number_of_times(times: int) -> None:
|
||||
game = FakeGame()
|
||||
click_cookie(game, times)
|
||||
assert game.clicked == [BIG_COOKIE_ID] * times
|
||||
|
||||
|
||||
def test_click_cookie_defaults_to_one_click() -> None:
|
||||
game = FakeGame()
|
||||
click_cookie(game)
|
||||
assert game.clicked == [BIG_COOKIE_ID]
|
||||
|
||||
|
||||
def test_click_cookie_refuses_a_negative_count() -> None:
|
||||
game = FakeGame()
|
||||
with pytest.raises(ValueError, match="negative"):
|
||||
click_cookie(game, -1)
|
||||
assert game.clicked == []
|
||||
|
||||
|
||||
def test_get_cookie_count_reads_the_first_line_of_the_counter() -> None:
|
||||
game = FakeGame({COOKIE_COUNTER_ID: "12,345 cookies\nper second: 3.5"})
|
||||
assert get_cookie_count(game) == 12345
|
||||
|
||||
|
||||
def test_get_cookie_count_reads_large_amounts_split_over_lines() -> None:
|
||||
game = FakeGame({COOKIE_COUNTER_ID: "1.234 million\ncookies\nper second: 900"})
|
||||
assert get_cookie_count(game) == 1_234_000
|
||||
@@ -0,0 +1,49 @@
|
||||
"""@file
|
||||
@brief Tests of parse_game_number.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from cookie_clicker_bot.domain.errors import UnreadableNumberError
|
||||
from cookie_clicker_bot.domain.number_parsing import parse_game_number
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("0", 0),
|
||||
("1 cookie", 1),
|
||||
("15 cookies", 15),
|
||||
("12,345 cookies", 12345),
|
||||
("1,234,567", 1234567),
|
||||
],
|
||||
)
|
||||
def test_parse_plain_amounts(text: str, expected: int) -> None:
|
||||
assert parse_game_number(text) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("1.234 million", 1_234_000),
|
||||
("2 billion", 2_000_000_000),
|
||||
("1.500 Thousand", 1500),
|
||||
("3 decillion", 3 * 10**33),
|
||||
],
|
||||
)
|
||||
def test_parse_amounts_with_number_word(text: str, expected: int) -> None:
|
||||
assert parse_game_number(text) == expected
|
||||
|
||||
|
||||
def test_parse_ignores_unknown_word_after_the_number() -> None:
|
||||
assert parse_game_number("42 bakers") == 42
|
||||
|
||||
|
||||
def test_parse_rounds_decimals() -> None:
|
||||
assert parse_game_number("7.6 cookies") == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", "cookies", "-5", "per second: 3"])
|
||||
def test_parse_rejects_text_without_a_leading_number(text: str) -> None:
|
||||
with pytest.raises(UnreadableNumberError):
|
||||
parse_game_number(text)
|
||||
Reference in New Issue
Block a user