Parsing of cookie counts and prices, the most-expensive-affordable rule, the click-and-buy loop with a check interval, adapter methods and cli option, and the screenshot of lecture step 3 from a container run. Code review RC-016. Closes #18 Closes #19 Closes #20 Closes #21 Task: MIL-004#1 Task: MIL-004#2 Task: MIL-004#3 Task: MIL-004#4
255 lines
7.1 KiB
Python
255 lines
7.1 KiB
Python
"""@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.domain.upgrades import Upgrade
|
|
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,
|
|
text: str = "",
|
|
attributes: dict[str, str] | None = None,
|
|
children: dict[str, "FakeElement"] | None = None,
|
|
) -> None:
|
|
self.text = text
|
|
self.clicks = 0
|
|
self._displayed = displayed
|
|
self._click_error = click_error
|
|
self._attributes = attributes or {}
|
|
self._children = children or {}
|
|
|
|
def get_attribute(self, name: str) -> str | None:
|
|
return self._attributes.get(name)
|
|
|
|
def find_element(self, by: str, value: str) -> "FakeElement":
|
|
if by != "css selector" or value not in self._children:
|
|
raise NoSuchElementException(value)
|
|
return self._children[value]
|
|
|
|
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.groups: dict[str, list[FakeElement]] = {}
|
|
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 find_elements(self, by: str, value: str) -> list[FakeElement]:
|
|
if by != "css selector":
|
|
return []
|
|
return self.groups.get(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 make_product(key: str, name: str, price: str) -> FakeElement:
|
|
return FakeElement(
|
|
attributes={"id": key},
|
|
children={
|
|
constants.PRODUCT_NAME_SELECTOR: FakeElement(text=name),
|
|
constants.PRODUCT_PRICE_SELECTOR: FakeElement(text=price),
|
|
},
|
|
)
|
|
|
|
|
|
def test_cookie_count_reads_the_cookie_counter() -> None:
|
|
driver = FakeDriver(
|
|
{
|
|
constants.COOKIE_COUNT_ID: FakeElement(
|
|
text="1.235\nmillion cookies\nper second: 0"
|
|
)
|
|
}
|
|
)
|
|
|
|
assert make_game(driver).cookie_count() == 1_235_000
|
|
|
|
|
|
def test_upgrades_reads_the_visible_products() -> None:
|
|
driver = FakeDriver()
|
|
driver.groups[constants.VISIBLE_PRODUCT_SELECTOR] = [
|
|
make_product("product0", "Cursor", "15"),
|
|
make_product("product2", "Farm", "1,100"),
|
|
]
|
|
|
|
assert make_game(driver).upgrades() == [
|
|
Upgrade("product0", "Cursor", 15),
|
|
Upgrade("product2", "Farm", 1100),
|
|
]
|
|
|
|
|
|
def test_upgrades_is_empty_when_no_product_is_visible() -> None:
|
|
assert make_game(FakeDriver()).upgrades() == []
|
|
|
|
|
|
def test_buy_clicks_the_product_with_the_upgrade_key() -> None:
|
|
driver = FakeDriver({"product1": FakeElement()})
|
|
|
|
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
|
|
|
assert driver.elements["product1"].clicks == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[ElementClickInterceptedException(), StaleElementReferenceException()],
|
|
)
|
|
def test_buy_ignores_a_covered_or_stale_product(error: Exception) -> None:
|
|
driver = FakeDriver({"product1": FakeElement(click_error=error)})
|
|
|
|
make_game(driver).buy(Upgrade("product1", "Grandma", 100))
|
|
|
|
|
|
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
|