diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md
index 2594660..8a26942 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 | 009 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
-| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 018 |
+| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
diff --git a/docs/sqa/reviews/rc-017-code-mil-003-login.md b/docs/sqa/reviews/rc-017-code-mil-003-login.md
index e0a0cb7..82313ad 100644
--- a/docs/sqa/reviews/rc-017-code-mil-003-login.md
+++ b/docs/sqa/reviews/rc-017-code-mil-003-login.md
@@ -9,7 +9,7 @@
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
-| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-003 | pending |
+| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-003 | [245dd16] |
---
@@ -59,3 +59,4 @@ Go-with-conditions - the code of MIL-003 meets every Mandatory criterion of QC-P
[MIL-003]: ../../milestones/mil-003-login.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+[245dd16]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/245dd1689e10cc8d87c6f2a4934286cc36ca34e2
diff --git a/docs/sqa/reviews/rc-018-code-mil-004-book-class.md b/docs/sqa/reviews/rc-018-code-mil-004-book-class.md
new file mode 100644
index 0000000..004470b
--- /dev/null
+++ b/docs/sqa/reviews/rc-018-code-mil-004-book-class.md
@@ -0,0 +1,61 @@
+# SQA Review Record
+
+## Metadata
+| Key | Value |
+| --- | --- |
+| ID | RC-018 |
+| CrossReference | [MIL-004], [QC-PY-001] |
+
+## Version History
+| Date | Status | Author | Reviewer | Change | Commit |
+| --- | --- | --- | --- | --- | --- |
+| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-004 | pending |
+
+---
+
+## Artifact Under Review
+
+- Instance reviewed: [MIL-004]
+- Checklist used: [QC-PY-001] (`QC-PY-001`)
+- Scope: full review of the source and tests of MIL-004
+- 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 | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
+| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); 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` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
+| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
+| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
+| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
+| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
+| 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 | Pass | No print calls; messages go through `logging`. |
+| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
+| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 103 unit tests (models, parsing, decisions, booking, recorder, command line) need no browser or network; 9 container tests run the adapter against local HTML fixtures; coverage of the non-infrastructure modules is 100%. |
+| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
+| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is 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 | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
+| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
+| 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 | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
+| 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 | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (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 | All file and folder names are English lowercase with `_`, `-` and `.` only. |
+
+## Overall Verdict
+
+Go-with-conditions - the code of MIL-004 meets every Mandatory criterion of QC-PY-001 and there is no blocking finding, but part of the gate evidence cannot be produced by the stand-in. Gate criterion 1 (unit tests of the four button states and the parsing) is met. Criterion 2 (a container run that books or recognises the Tuesday class on the live site) is not verified, and criterion 3 is not met: `step-3.png` and `step-4.png` come from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site, so the live run was not made; the adapter is verified against local fixtures that copy the site's element IDs, button texts and day labels. 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 |
+| --- | --- | --- |
+| Run the bot with your own account and keep `docs/images/step-3.png` and `step-4.png`; report any selector that failed | S01 | when S01 has time |
+
+---
+
+[MIL-004]: ../../milestones/mil-004-book-class.md
+[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md
index 805676b..71093ec 100644
--- a/docs/sqa/traceability-matrix.md
+++ b/docs/sqa/traceability-matrix.md
@@ -38,6 +38,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
+| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
## Concern Coverage
diff --git a/src/snack_and_lift/application/booking.py b/src/snack_and_lift/application/booking.py
new file mode 100644
index 0000000..4885b0b
--- /dev/null
+++ b/src/snack_and_lift/application/booking.py
@@ -0,0 +1,58 @@
+"""@file booking.py
+@brief Lecture steps 3 and 4: book the next Tuesday 6pm class and report its state.
+"""
+
+import logging
+
+from snack_and_lift import constants
+from snack_and_lift.application.ports import GymPort
+from snack_and_lift.domain.errors import GymError
+from snack_and_lift.domain.models import ButtonState, ClassCard, ClassResult
+from snack_and_lift.domain.rules import (
+ CONFIRMED_STATE,
+ OUTCOME_OF_ACTION,
+ decide_outcome_action,
+ find_next_class,
+ format_outcome,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def book_class(gym: GymPort, card: ClassCard) -> ClassResult:
+ """@brief Book or waitlist a class, or report that it already is.
+
+ @param gym The gym site.
+ @param card The class, as read from the page.
+ @return The class and what happened.
+ @exception UnknownButtonError The button is busy, completed or unknown.
+ @exception GymError The click failed or was not confirmed by the button.
+ """
+ action = decide_outcome_action(card)
+ expected = CONFIRMED_STATE.get(action)
+ if expected is not None:
+ new_text = gym.press_class_button(card.card_id)
+ if ButtonState.from_text(new_text) is not expected:
+ raise GymError(f"click not confirmed: the button says {new_text!r}")
+ outcome = OUTCOME_OF_ACTION[action]
+ logger.info(format_outcome(outcome, card))
+ return ClassResult(card, outcome)
+
+
+def book_next_tuesday(gym: GymPort) -> ClassResult | None:
+ """@brief Book the next Tuesday class at the target time.
+
+ @param gym The gym site.
+ @return The result, or None when the schedule has no such class.
+ @exception GymError The page could not be read or the booking failed.
+ """
+ card = find_next_class(gym.list_class_cards(), constants.NEXT_CLASS_WEEKDAY)
+ if card is None:
+ logger.warning(
+ constants.MESSAGE_NO_CLASS_FOUND.format(
+ weekday=constants.NEXT_CLASS_WEEKDAY,
+ time=f"{constants.TARGET_HOUR:02d}:{constants.TARGET_MINUTE:02d}",
+ )
+ )
+ return None
+ return book_class(gym, card)
diff --git a/src/snack_and_lift/application/ports.py b/src/snack_and_lift/application/ports.py
index 946adad..24915a0 100644
--- a/src/snack_and_lift/application/ports.py
+++ b/src/snack_and_lift/application/ports.py
@@ -1,38 +1,66 @@
-"""@file ports.py
-@brief The ports the use cases need from the outside world.
-"""
-
-from pathlib import Path
-from typing import Protocol
-
-
-class GymPort(Protocol):
- """@brief The gym site as the use cases see it."""
-
- def open_site(self) -> None:
- """@brief Open the home page and wait until it is ready.
-
- @exception GymError The page did not load.
- """
- ...
-
- def log_in(self, email: str, password: str) -> None:
- """@brief Log in and wait until the class schedule is shown.
-
- @param email The account e-mail.
- @param password The account password.
- @exception LoginFailedError The site refused the login.
- @exception GymError The page did not behave as expected.
- """
- ...
-
- def save_screenshot(self, path: Path) -> None:
- """@brief Save a screenshot of the current page.
-
- @param path The PNG file to write; its folders are created.
- """
- ...
-
- def close(self) -> None:
- """@brief Close the browser."""
- ...
+"""@file ports.py
+@brief The ports the use cases need from the outside world.
+"""
+
+from pathlib import Path
+from typing import Protocol
+
+from snack_and_lift.domain.models import ClassCard
+
+
+class GymPort(Protocol):
+ """@brief The gym site as the use cases see it."""
+
+ def open_site(self) -> None:
+ """@brief Open the home page and wait until it is ready.
+
+ @exception GymError The page did not load.
+ """
+ ...
+
+ def log_in(self, email: str, password: str) -> None:
+ """@brief Log in and wait until the class schedule is shown.
+
+ @param email The account e-mail.
+ @param password The account password.
+ @exception LoginFailedError The site refused the login.
+ @exception GymError The page did not behave as expected.
+ """
+ ...
+
+ def list_class_cards(self) -> list[ClassCard]:
+ """@brief Read every class card of the schedule, in page order.
+
+ @return The classes, each with the day of its group.
+ @exception GymError The schedule could not be read.
+ """
+ ...
+
+ def press_class_button(self, card_id: str) -> str:
+ """@brief Click the button of a class and wait until the request has finished.
+
+ @param card_id The id of the card without its prefix.
+ @return The new text of the button.
+ @exception GymError The site shows an error for the class (the message is its text)
+ or nothing settled in time.
+ """
+ ...
+
+ def save_text_screenshot(self, text: str, path: Path) -> None:
+ """@brief Save an image of console text.
+
+ @param text The lines to draw, in a dark monospace style.
+ @param path The PNG file to write; its folders are created.
+ """
+ ...
+
+ def save_screenshot(self, path: Path) -> None:
+ """@brief Save a screenshot of the current page.
+
+ @param path The PNG file to write; its folders are created.
+ """
+ ...
+
+ def close(self) -> None:
+ """@brief Close the browser."""
+ ...
diff --git a/src/snack_and_lift/cli.py b/src/snack_and_lift/cli.py
index 3b9646d..3f9fc31 100644
--- a/src/snack_and_lift/cli.py
+++ b/src/snack_and_lift/cli.py
@@ -1,125 +1,167 @@
-"""@file cli.py
-@brief Command-line entry point (the composition root of the bot).
-"""
-
-import argparse
-import logging
-import os
-import sys
-from collections.abc import Callable, Sequence
-from pathlib import Path
-
-from snack_and_lift.application.credentials import load_credentials
-from snack_and_lift.application.ports import GymPort
-from snack_and_lift.application.session import login
-from snack_and_lift.domain.errors import LoginFailedError, MissingCredentialsError
-from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
-from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
-
-## Builds the gym site from (headless, profile folder, keep open).
-GymFactory = Callable[[bool, Path, bool], GymPort]
-
-logger = logging.getLogger(__name__)
-
-
-def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> GymPort:
- """@brief Start Chrome and wrap it as the gym site.
-
- @param headless True to run without a window.
- @param profile_dir The Chrome user data folder.
- @param keep_open True to leave the browser open when the program ends.
- @return The gym site.
- """
- return SeleniumGym(create_driver(headless, profile_dir, keep_open))
-
-
-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; `keep_open` is True unless `--headless` is given.
- """
- parser = argparse.ArgumentParser(
- prog="snack-and-lift",
- description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
- )
- parser.add_argument(
- "--headless",
- action="store_true",
- help="run Chrome without a window (the Docker image does this)",
- )
- parser.add_argument(
- "--keep-open",
- action="store_true",
- default=None,
- help="leave the browser open at the end (default when not headless)",
- )
- parser.add_argument(
- "--profile-dir",
- type=Path,
- default=None,
- help="Chrome profile folder (default: ./chrome_profile)",
- )
- parser.add_argument(
- "--screenshot-dir",
- type=Path,
- default=None,
- help="save a screenshot after each step into this folder",
- )
- args = parser.parse_args(argv)
- if args.keep_open is None:
- args.keep_open = not args.headless
- if args.profile_dir is None:
- args.profile_dir = default_profile_dir()
- return args
-
-
-def _use_utf8_output() -> None:
- """@brief Make stdout UTF-8 so the check mark of the messages prints on Windows."""
- reconfigure = getattr(sys.stdout, "reconfigure", None)
- if reconfigure is not None:
- reconfigure(encoding="utf-8")
-
-
-def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
- """@brief Save a screenshot when a folder was asked for.
-
- @param gym The gym site.
- @param screenshot_dir The folder, or None for no screenshots.
- @param name The file name.
- """
- if screenshot_dir is not None:
- gym.save_screenshot(screenshot_dir / name)
-
-
-def main(
- argv: Sequence[str] | None = None,
- gym_factory: GymFactory = selenium_gym_factory,
-) -> int:
- """@brief Start the bot: open the site and log in.
-
- @param argv Arguments without the program name; None reads `sys.argv`.
- @param gym_factory Builds the gym site; tests pass a fake.
- @return Process exit code: 0 done, 1 login failed, 2 credentials missing.
- """
- logging.basicConfig(level=logging.INFO, format="%(message)s")
- _use_utf8_output()
- args = parse_args(argv)
- try:
- credentials = load_credentials(os.environ)
- except MissingCredentialsError as error:
- logger.error("%s", error)
- return 2
- gym = gym_factory(args.headless, args.profile_dir, args.keep_open)
- try:
- gym.open_site()
- _save_step(gym, args.screenshot_dir, "step-1.png")
- login(gym, credentials)
- _save_step(gym, args.screenshot_dir, "step-2.png")
- except LoginFailedError as error:
- logger.error("Login failed: %s", error)
- return 1
- finally:
- if not args.keep_open:
- gym.close()
- return 0
+"""@file cli.py
+@brief Command-line entry point (the composition root of the bot).
+"""
+
+import argparse
+import logging
+import os
+import sys
+from collections.abc import Callable, Sequence
+from pathlib import Path
+
+from snack_and_lift import constants
+from snack_and_lift.application.booking import book_next_tuesday
+from snack_and_lift.application.credentials import Credentials, load_credentials
+from snack_and_lift.application.ports import GymPort
+from snack_and_lift.application.session import login
+from snack_and_lift.domain.errors import GymError, LoginFailedError, MissingCredentialsError
+from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
+from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
+from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
+
+## Builds the gym site from (headless, profile folder, keep open).
+GymFactory = Callable[[bool, Path, bool], GymPort]
+
+logger = logging.getLogger(__name__)
+
+
+def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> GymPort:
+ """@brief Start Chrome and wrap it as the gym site.
+
+ @param headless True to run without a window.
+ @param profile_dir The Chrome user data folder.
+ @param keep_open True to leave the browser open when the program ends.
+ @return The gym site.
+ """
+ return SeleniumGym(create_driver(headless, profile_dir, keep_open))
+
+
+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; `keep_open` is True unless `--headless` is given.
+ """
+ parser = argparse.ArgumentParser(
+ prog="snack-and-lift",
+ description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
+ )
+ parser.add_argument(
+ "--headless",
+ action="store_true",
+ help="run Chrome without a window (the Docker image does this)",
+ )
+ parser.add_argument(
+ "--keep-open",
+ action="store_true",
+ default=None,
+ help="leave the browser open at the end (default when not headless)",
+ )
+ parser.add_argument(
+ "--profile-dir",
+ type=Path,
+ default=None,
+ help="Chrome profile folder (default: ./chrome_profile)",
+ )
+ parser.add_argument(
+ "--screenshot-dir",
+ type=Path,
+ default=None,
+ help="save a screenshot after each step into this folder",
+ )
+ args = parser.parse_args(argv)
+ if args.keep_open is None:
+ args.keep_open = not args.headless
+ if args.profile_dir is None:
+ args.profile_dir = default_profile_dir()
+ return args
+
+
+def _use_utf8_output() -> None:
+ """@brief Make stdout UTF-8 so the check mark of the messages prints on Windows."""
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
+ if reconfigure is not None:
+ reconfigure(encoding="utf-8")
+
+
+def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
+ """@brief Save a screenshot when a folder was asked for.
+
+ @param gym The gym site.
+ @param screenshot_dir The folder, or None for no screenshots.
+ @param name The file name.
+ """
+ if screenshot_dir is not None:
+ gym.save_screenshot(screenshot_dir / name)
+
+
+def _run(
+ gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials
+) -> None:
+ """@brief Open the site, log in and book the next Tuesday class, saving the step images.
+
+ @param gym The gym site.
+ @param args The parsed options.
+ @param recorder The recorded console lines, drawn for step 4.
+ @param credentials The account to log in with.
+ @exception GymError A step failed.
+ """
+ gym.open_site()
+ _save_step(gym, args.screenshot_dir, "step-1.png")
+ login(gym, credentials)
+ _save_step(gym, args.screenshot_dir, "step-2.png")
+ book_next_tuesday(gym)
+ _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3)
+ if args.screenshot_dir is not None:
+ gym.save_text_screenshot(recorder.text, args.screenshot_dir / constants.SCREENSHOT_STEP_4)
+
+
+def main(
+ argv: Sequence[str] | None = None,
+ gym_factory: GymFactory = selenium_gym_factory,
+) -> int:
+ """@brief Start the bot: open the site, log in and book the next Tuesday 6pm class.
+
+ @param argv Arguments without the program name; None reads `sys.argv`.
+ @param gym_factory Builds the gym site; tests pass a fake.
+ @return Process exit code: 0 done, 1 a step failed, 2 credentials missing.
+ """
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+ root = logging.getLogger()
+ root.setLevel(logging.INFO)
+ recorder = ConsoleRecorder()
+ root.addHandler(recorder)
+ try:
+ return _main(argv, gym_factory, recorder)
+ finally:
+ root.removeHandler(recorder)
+
+
+def _main(argv: Sequence[str] | None, gym_factory: GymFactory, recorder: ConsoleRecorder) -> int:
+ """@brief The body of `main`, run while the recorder is attached.
+
+ @param argv Arguments without the program name.
+ @param gym_factory Builds the gym site.
+ @param recorder The console recorder.
+ @return Process exit code.
+ """
+ _use_utf8_output()
+ args = parse_args(argv)
+ try:
+ credentials = load_credentials(os.environ)
+ except MissingCredentialsError as error:
+ logger.error("%s", error)
+ return 2
+ gym = gym_factory(args.headless, args.profile_dir, args.keep_open)
+ try:
+ _run(gym, args, recorder, credentials)
+ except LoginFailedError as error:
+ logger.error("Login failed: %s", error)
+ return 1
+ except GymError as error:
+ logger.error("Failed: %s", error)
+ return 1
+ finally:
+ if not args.keep_open:
+ gym.close()
+ return 0
diff --git a/src/snack_and_lift/constants.py b/src/snack_and_lift/constants.py
index aa54a19..1a53a2a 100644
--- a/src/snack_and_lift/constants.py
+++ b/src/snack_and_lift/constants.py
@@ -64,3 +64,65 @@ HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
"--disable-dev-shm-usage",
"--disable-gpu",
)
+
+## Weekday of the class that lecture steps 3 and 4 book, as the site abbreviates it.
+NEXT_CLASS_WEEKDAY: Final[str] = "Tue"
+
+## Hour (24-hour clock) of the class the bot books.
+TARGET_HOUR: Final[int] = 18
+
+## Minute of the class the bot books.
+TARGET_MINUTE: Final[int] = 0
+
+## CSS selector of every class card on the schedule.
+CLASS_CARD_CSS: Final[str] = "div[id^='class-card-']"
+
+## Prefix of the element ids of one card; the card id (for example "123") follows it.
+CLASS_CARD_ID_PREFIX: Final[str] = "class-card-"
+CLASS_NAME_ID_PREFIX: Final[str] = "class-name-"
+CLASS_TIME_ID_PREFIX: Final[str] = "class-time-"
+BOOK_BUTTON_ID_PREFIX: Final[str] = "book-button-"
+COMPLETED_LABEL_ID_PREFIX: Final[str] = "completed-label-"
+CLASS_ERROR_ID_PREFIX: Final[str] = "class-error-"
+
+## XPath, relative to a card, of the title of the day group the card is in (the "ancestor" hint).
+DAY_TITLE_XPATH: Final[str] = (
+ "ancestor::div[starts-with(@id,'day-group-')]//h2[starts-with(@id,'day-title-')]"
+)
+
+## Texts of the button of a card.
+BUTTON_TEXT_BOOK: Final[str] = "Book Class"
+BUTTON_TEXT_JOIN_WAITLIST: Final[str] = "Join Waitlist"
+BUTTON_TEXT_BOOKED: Final[str] = "Booked"
+BUTTON_TEXT_WAITLISTED: Final[str] = "Waitlisted"
+BUTTON_TEXT_BOOKING: Final[str] = "Booking..."
+BUTTON_TEXT_JOINING: Final[str] = "Joining..."
+BUTTON_TEXT_COMPLETED: Final[str] = "Completed"
+
+## Prefix of the time text of a card ("Time: 6:00 PM").
+TIME_TEXT_PREFIX: Final[str] = "Time:"
+
+## Day labels the site wraps around the date of the next two days.
+DAY_LABEL_WRAPPERS: Final[tuple[str, ...]] = ("Today", "Tomorrow")
+
+## Console messages of the booking, with the class name and the day label as the site shows it.
+MESSAGE_BOOKED: Final[str] = "✓ Successfully booked: {name} on {day}"
+MESSAGE_WAITLISTED: Final[str] = "✓ Joined waitlist for: {name} on {day}"
+MESSAGE_ALREADY_BOOKED: Final[str] = "✓ Already booked: {name} on {day}"
+MESSAGE_ALREADY_WAITLISTED: Final[str] = "✓ Already on waitlist: {name} on {day}"
+MESSAGE_NO_CLASS_FOUND: Final[str] = "No {weekday} class at {time} found on the schedule"
+
+## File names of the screenshots of lecture steps 3 and 4.
+SCREENSHOT_STEP_3: Final[str] = "step-3.png"
+SCREENSHOT_STEP_4: Final[str] = "step-4.png"
+
+## `string.Template` of the page that renders console text for a screenshot.
+CONSOLE_PAGE_TEMPLATE: Final[str] = (
+ "
"
+ ""
+ "
"
+ "$text
"
+)
+
+## Prefix of the `data:` URL that carries the console page.
+CONSOLE_PAGE_URL_PREFIX: Final[str] = "data:text/html;charset=utf-8,"
diff --git a/src/snack_and_lift/domain/day_label.py b/src/snack_and_lift/domain/day_label.py
new file mode 100644
index 0000000..1cc54c2
--- /dev/null
+++ b/src/snack_and_lift/domain/day_label.py
@@ -0,0 +1,29 @@
+"""@file day_label.py
+@brief Reads the title of a day group of the schedule ("Today (Tue, Aug 12)").
+
+Kept apart from `rules.py` so that `models.py` can use it without a circular import.
+"""
+
+import re
+
+from snack_and_lift import constants
+from snack_and_lift.domain.errors import PageFormatError
+
+_DATE = re.compile(r"(?P[A-Z][a-z]{2}), [A-Z][a-z]{2} \d{1,2}")
+_WRAPPER = re.compile(rf"(?:{'|'.join(constants.DAY_LABEL_WRAPPERS)}) \((?P.+)\)")
+
+
+def parse_day_label(label: str) -> tuple[str, str]:
+ """@brief Split a day label into the weekday and the date text.
+
+ @param label `Today (Tue, Aug 12)`, `Tomorrow (Wed, Aug 13)` or `Thu, Aug 14`.
+ @return The weekday (`Tue`) and the date text (`Tue, Aug 12`).
+ @exception PageFormatError The label has none of these forms.
+ """
+ text = label.strip()
+ wrapped = _WRAPPER.fullmatch(text)
+ date_text = wrapped["inner"] if wrapped else text
+ match = _DATE.fullmatch(date_text)
+ if match is None:
+ raise PageFormatError(f"Unexpected day label: {label!r}")
+ return match["weekday"], date_text
diff --git a/src/snack_and_lift/domain/errors.py b/src/snack_and_lift/domain/errors.py
index 8a99a1d..d94b0a4 100644
--- a/src/snack_and_lift/domain/errors.py
+++ b/src/snack_and_lift/domain/errors.py
@@ -1,15 +1,23 @@
-"""@file errors.py
-@brief The errors of the bot.
-"""
-
-
-class GymError(Exception):
- """@brief The gym site did not behave as the bot expected (a page, element or timeout)."""
-
-
-class LoginFailedError(GymError):
- """@brief The site refused the login; the message is the text the site shows."""
-
-
-class MissingCredentialsError(Exception):
- """@brief The account e-mail or password is not set in the environment."""
+"""@file errors.py
+@brief The errors of the bot.
+"""
+
+
+class GymError(Exception):
+ """@brief The gym site did not behave as the bot expected (a page, element or timeout)."""
+
+
+class LoginFailedError(GymError):
+ """@brief The site refused the login; the message is the text the site shows."""
+
+
+class MissingCredentialsError(Exception):
+ """@brief The account e-mail or password is not set in the environment."""
+
+
+class UnknownButtonError(GymError):
+ """@brief A class button shows a text the bot cannot act on (busy, completed or unknown)."""
+
+
+class PageFormatError(GymError):
+ """@brief A text read from the page (a day label or a time) has an unexpected format."""
diff --git a/src/snack_and_lift/domain/models.py b/src/snack_and_lift/domain/models.py
new file mode 100644
index 0000000..5eea174
--- /dev/null
+++ b/src/snack_and_lift/domain/models.py
@@ -0,0 +1,94 @@
+"""@file models.py
+@brief Plain data of the schedule: the button state, a class card and the result of a booking.
+"""
+
+from dataclasses import dataclass
+from enum import Enum, auto
+
+from snack_and_lift import constants
+from snack_and_lift.domain.day_label import parse_day_label
+
+
+class ButtonState(Enum):
+ """@brief What the button of a class card says."""
+
+ BOOK = auto()
+ JOIN_WAITLIST = auto()
+ BOOKED = auto()
+ WAITLISTED = auto()
+ BUSY = auto()
+ COMPLETED = auto()
+ UNKNOWN = auto()
+
+ @classmethod
+ def from_text(cls, text: str) -> "ButtonState":
+ """@brief Map the text of a button to its state.
+
+ @param text The visible text of the button (or the label of a past class).
+ @return The state; UNKNOWN for a text the bot does not know.
+ """
+ return _STATE_BY_TEXT.get(text.strip(), cls.UNKNOWN)
+
+
+_STATE_BY_TEXT: dict[str, ButtonState] = {
+ constants.BUTTON_TEXT_BOOK: ButtonState.BOOK,
+ constants.BUTTON_TEXT_JOIN_WAITLIST: ButtonState.JOIN_WAITLIST,
+ constants.BUTTON_TEXT_BOOKED: ButtonState.BOOKED,
+ constants.BUTTON_TEXT_WAITLISTED: ButtonState.WAITLISTED,
+ constants.BUTTON_TEXT_BOOKING: ButtonState.BUSY,
+ constants.BUTTON_TEXT_JOINING: ButtonState.BUSY,
+ constants.BUTTON_TEXT_COMPLETED: ButtonState.COMPLETED,
+}
+
+
+@dataclass(frozen=True, slots=True)
+class ClassCard:
+ """@brief One class of the schedule, as read from the page.
+
+ `card_id` is the id of the card without the `class-card-` prefix (for example
+ "123"); the site builds the ids of the card's other elements from the same suffix.
+ """
+
+ card_id: str
+ name: str
+ day_label: str
+ time_text: str
+ button_text: str
+
+ @property
+ def state(self) -> ButtonState:
+ """@brief The state of the button."""
+ return ButtonState.from_text(self.button_text)
+
+ @property
+ def weekday(self) -> str:
+ """@brief The weekday of the day group (`Tue`).
+
+ @exception PageFormatError The day label is not in a known form.
+ """
+ return parse_day_label(self.day_label)[0]
+
+ @property
+ def date_text(self) -> str:
+ """@brief The date of the day group (`Tue, Aug 12`).
+
+ @exception PageFormatError The day label is not in a known form.
+ """
+ return parse_day_label(self.day_label)[1]
+
+
+class Outcome(Enum):
+ """@brief What the booking did, or found, for a class."""
+
+ NEW_BOOKING = auto()
+ NEW_WAITLIST = auto()
+ ALREADY_BOOKED = auto()
+ ALREADY_WAITLISTED = auto()
+
+
+@dataclass(frozen=True, slots=True)
+class ClassResult:
+ """@brief The class that was handled and what happened to it."""
+
+ card: ClassCard
+ outcome: Outcome
diff --git a/src/snack_and_lift/domain/rules.py b/src/snack_and_lift/domain/rules.py
new file mode 100644
index 0000000..31c8a18
--- /dev/null
+++ b/src/snack_and_lift/domain/rules.py
@@ -0,0 +1,139 @@
+"""@file rules.py
+@brief The booking rules: which class is the target, what to do for a button state, what to say.
+"""
+
+import re
+from collections.abc import Iterable
+from enum import Enum, auto
+
+from snack_and_lift import constants
+from snack_and_lift.domain.day_label import parse_day_label
+from snack_and_lift.domain.errors import PageFormatError, UnknownButtonError
+from snack_and_lift.domain.models import ButtonState, ClassCard, Outcome
+
+__all__ = [
+ "CONFIRMED_STATE",
+ "OUTCOME_OF_ACTION",
+ "Action",
+ "decide_outcome_action",
+ "find_next_class",
+ "format_outcome",
+ "is_target_time",
+ "parse_day_label",
+ "parse_time",
+]
+
+_TIME = re.compile(
+ rf"(?:{re.escape(constants.TIME_TEXT_PREFIX)}\s*)?(\d{{1,2}}):(\d{{2}})\s*(AM|PM)",
+ re.IGNORECASE,
+)
+_MAX_CLOCK_HOUR = 12
+_MAX_MINUTE = 59
+
+
+class Action(Enum):
+ """@brief What the bot does for a class card."""
+
+ CLICK_BOOK = auto()
+ CLICK_WAITLIST = auto()
+ ALREADY_BOOKED = auto()
+ ALREADY_WAITLISTED = auto()
+
+
+## The outcome that follows from each action.
+OUTCOME_OF_ACTION: dict[Action, Outcome] = {
+ Action.CLICK_BOOK: Outcome.NEW_BOOKING,
+ Action.CLICK_WAITLIST: Outcome.NEW_WAITLIST,
+ Action.ALREADY_BOOKED: Outcome.ALREADY_BOOKED,
+ Action.ALREADY_WAITLISTED: Outcome.ALREADY_WAITLISTED,
+}
+
+## The button state that proves a click worked.
+CONFIRMED_STATE: dict[Action, ButtonState] = {
+ Action.CLICK_BOOK: ButtonState.BOOKED,
+ Action.CLICK_WAITLIST: ButtonState.WAITLISTED,
+}
+
+_ACTION_OF_STATE: dict[ButtonState, Action] = {
+ ButtonState.BOOK: Action.CLICK_BOOK,
+ ButtonState.JOIN_WAITLIST: Action.CLICK_WAITLIST,
+ ButtonState.BOOKED: Action.ALREADY_BOOKED,
+ ButtonState.WAITLISTED: Action.ALREADY_WAITLISTED,
+}
+
+_MESSAGE_OF_OUTCOME: dict[Outcome, str] = {
+ Outcome.NEW_BOOKING: constants.MESSAGE_BOOKED,
+ Outcome.NEW_WAITLIST: constants.MESSAGE_WAITLISTED,
+ Outcome.ALREADY_BOOKED: constants.MESSAGE_ALREADY_BOOKED,
+ Outcome.ALREADY_WAITLISTED: constants.MESSAGE_ALREADY_WAITLISTED,
+}
+
+
+def parse_time(text: str) -> tuple[int, int]:
+ """@brief Read a 12-hour time such as `6:00 PM`.
+
+ @param text The time, with or without the `Time:` prefix.
+ @return The hour (0-23) and the minute.
+ @exception PageFormatError The text is not a valid time.
+ """
+ match = _TIME.fullmatch(text.strip())
+ if match is None:
+ raise PageFormatError(f"Unexpected time text: {text!r}")
+ hour, minute = int(match[1]), int(match[2])
+ if not 1 <= hour <= _MAX_CLOCK_HOUR or minute > _MAX_MINUTE:
+ raise PageFormatError(f"Time out of range: {text!r}")
+ hour %= _MAX_CLOCK_HOUR
+ if match[3].upper() == "PM":
+ hour += _MAX_CLOCK_HOUR
+ return hour, minute
+
+
+def is_target_time(card: ClassCard) -> bool:
+ """@brief Tell whether the class starts at the time the bot books.
+
+ @param card The class.
+ @return True at `TARGET_HOUR`:`TARGET_MINUTE`.
+ @exception PageFormatError The time text is not valid.
+ """
+ return parse_time(card.time_text) == (constants.TARGET_HOUR, constants.TARGET_MINUTE)
+
+
+def find_next_class(cards: Iterable[ClassCard], weekday: str) -> ClassCard | None:
+ """@brief Find the first class of a weekday at the target time, in page order.
+
+ The weekday is read from each card's day label; the position in the list means nothing.
+ Classes already completed are skipped.
+
+ @param cards The classes in the order of the page.
+ @param weekday The weekday as the site abbreviates it (`Tue`).
+ @return The class, or None when there is none.
+ """
+ for card in cards:
+ if card.state is ButtonState.COMPLETED:
+ continue
+ if card.weekday == weekday and is_target_time(card):
+ return card
+ return None
+
+
+def decide_outcome_action(card: ClassCard) -> Action:
+ """@brief Decide what to do from the state of the button.
+
+ @param card The class.
+ @return The action.
+ @exception UnknownButtonError The button is busy, completed or unknown; `retry` can repeat.
+ """
+ action = _ACTION_OF_STATE.get(card.state)
+ if action is None:
+ raise UnknownButtonError(f"Cannot act on button {card.button_text!r} of {card.name}")
+ return action
+
+
+def format_outcome(outcome: Outcome, card: ClassCard) -> str:
+ """@brief Build the console message of an outcome.
+
+ @param outcome What happened.
+ @param card The class; its day label is shown as the site writes it.
+ @return For example `✓ Successfully booked: Spin Class on Tomorrow (Thu, Aug 14)`.
+ """
+ return _MESSAGE_OF_OUTCOME[outcome].format(name=card.name, day=card.day_label)
diff --git a/src/snack_and_lift/infrastructure/console_recorder.py b/src/snack_and_lift/infrastructure/console_recorder.py
new file mode 100644
index 0000000..a75ddfc
--- /dev/null
+++ b/src/snack_and_lift/infrastructure/console_recorder.py
@@ -0,0 +1,35 @@
+"""@file console_recorder.py
+@brief Keeps the lines the bot logs, so they can be drawn as the lecture's console screenshot.
+"""
+
+import logging
+
+
+class ConsoleRecorder(logging.Handler):
+ """@brief A logging handler that stores the formatted message of every record."""
+
+ def __init__(self, level: int = logging.NOTSET) -> None:
+ """@brief Create an empty recorder.
+
+ @param level The lowest level to keep.
+ """
+ super().__init__(level)
+ self.setFormatter(logging.Formatter("%(message)s"))
+ self._lines: list[str] = []
+
+ @property
+ def lines(self) -> list[str]:
+ """@brief The recorded lines, oldest first (a copy)."""
+ return list(self._lines)
+
+ @property
+ def text(self) -> str:
+ """@brief The recorded lines joined by newlines."""
+ return "\n".join(self._lines)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """@brief Store one record.
+
+ @param record The log record.
+ """
+ self._lines.append(self.format(record))
diff --git a/src/snack_and_lift/infrastructure/selenium_gym.py b/src/snack_and_lift/infrastructure/selenium_gym.py
index 74f19f7..a05f374 100644
--- a/src/snack_and_lift/infrastructure/selenium_gym.py
+++ b/src/snack_and_lift/infrastructure/selenium_gym.py
@@ -1,111 +1,194 @@
-"""@file selenium_gym.py
-@brief The Selenium adapter of the GymPort.
-"""
-
-import functools
-import logging
-from collections.abc import Callable
-from pathlib import Path
-from typing import Concatenate
-
-from selenium.common.exceptions import (
- ElementClickInterceptedException,
- NoSuchElementException,
- StaleElementReferenceException,
- TimeoutException,
-)
-from selenium.webdriver.common.by import By
-from selenium.webdriver.remote.webdriver import WebDriver
-from selenium.webdriver.support import expected_conditions as ec
-from selenium.webdriver.support.ui import WebDriverWait
-
-from snack_and_lift import constants
-from snack_and_lift.domain.errors import GymError, LoginFailedError
-
-logger = logging.getLogger(__name__)
-
-_SELENIUM_ERRORS = (
- TimeoutException,
- NoSuchElementException,
- StaleElementReferenceException,
- ElementClickInterceptedException,
-)
-
-
-def _translate_errors[S, **P, R](
- method: Callable[Concatenate[S, P], R],
-) -> Callable[Concatenate[S, P], R]:
- """@brief Turn Selenium errors of a method into GymError.
-
- @param method The adapter method to wrap.
- @return The wrapped method.
- """
-
- @functools.wraps(method)
- def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
- try:
- return method(self, *args, **kwargs)
- except _SELENIUM_ERRORS as error:
- raise GymError(f"{method.__name__} failed: {type(error).__name__}") from error
-
- return wrapper
-
-
-class SeleniumGym:
- """@brief The gym site driven by a Selenium WebDriver."""
-
- def __init__(self, driver: WebDriver, base_url: str = constants.GYM_URL) -> None:
- """@brief Wrap a driver.
-
- @param driver A running WebDriver.
- @param base_url The address of the home page.
- """
- self._driver = driver
- self._base_url = base_url
- self._wait = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS)
-
- @_translate_errors
- def open_site(self) -> None:
- """@brief Open the home page and wait for the login button.
-
- @exception GymError The page did not load.
- """
- self._driver.get(self._base_url)
- self._wait.until(ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)))
-
- @_translate_errors
- def log_in(self, email: str, password: str) -> None:
- """@brief Fill in the login form and wait for the schedule or an error.
-
- @param email The account e-mail.
- @param password The account password.
- @exception LoginFailedError The site shows a login error.
- @exception GymError The page did not behave as expected.
- """
- self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click()
- self._wait.until(
- ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
- ).send_keys(email)
- self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password)
- self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click()
- self._wait.until(
- ec.any_of(
- ec.presence_of_element_located((By.ID, constants.SCHEDULE_PAGE_ID)),
- ec.presence_of_element_located((By.ID, constants.ERROR_MESSAGE_ID)),
- )
- )
- errors = self._driver.find_elements(By.ID, constants.ERROR_MESSAGE_ID)
- if errors:
- raise LoginFailedError(errors[0].text)
-
- def save_screenshot(self, path: Path) -> None:
- """@brief Save a screenshot of the current page.
-
- @param path The PNG file to write; its folders are created.
- """
- path.parent.mkdir(parents=True, exist_ok=True)
- self._driver.save_screenshot(str(path))
-
- def close(self) -> None:
- """@brief Quit the browser."""
- self._driver.quit()
+"""@file selenium_gym.py
+@brief The Selenium adapter of the GymPort.
+"""
+
+import functools
+import html
+import logging
+import urllib.parse
+from collections.abc import Callable
+from pathlib import Path
+from string import Template
+from typing import Concatenate, Literal
+
+from selenium.common.exceptions import (
+ ElementClickInterceptedException,
+ NoSuchElementException,
+ StaleElementReferenceException,
+ TimeoutException,
+)
+from selenium.webdriver.common.by import By
+from selenium.webdriver.remote.webdriver import WebDriver
+from selenium.webdriver.remote.webelement import WebElement
+from selenium.webdriver.support import expected_conditions as ec
+from selenium.webdriver.support.ui import WebDriverWait
+
+from snack_and_lift import constants
+from snack_and_lift.domain.errors import GymError, LoginFailedError
+from snack_and_lift.domain.models import ButtonState, ClassCard
+
+logger = logging.getLogger(__name__)
+
+_SELENIUM_ERRORS = (
+ TimeoutException,
+ NoSuchElementException,
+ StaleElementReferenceException,
+ ElementClickInterceptedException,
+)
+
+
+def _translate_errors[S, **P, R](
+ method: Callable[Concatenate[S, P], R],
+) -> Callable[Concatenate[S, P], R]:
+ """@brief Turn Selenium errors of a method into GymError.
+
+ @param method The adapter method to wrap.
+ @return The wrapped method.
+ """
+
+ @functools.wraps(method)
+ def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
+ try:
+ return method(self, *args, **kwargs)
+ except _SELENIUM_ERRORS as error:
+ raise GymError(f"{method.__name__} failed: {type(error).__name__}") from error
+
+ return wrapper
+
+
+class SeleniumGym:
+ """@brief The gym site driven by a Selenium WebDriver."""
+
+ def __init__(self, driver: WebDriver, base_url: str = constants.GYM_URL) -> None:
+ """@brief Wrap a driver.
+
+ @param driver A running WebDriver.
+ @param base_url The address of the home page.
+ """
+ self._driver = driver
+ self._base_url = base_url
+ self._wait = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS)
+
+ @_translate_errors
+ def open_site(self) -> None:
+ """@brief Open the home page and wait for the login button.
+
+ @exception GymError The page did not load.
+ """
+ self._driver.get(self._base_url)
+ self._wait.until(ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)))
+
+ @_translate_errors
+ def log_in(self, email: str, password: str) -> None:
+ """@brief Fill in the login form and wait for the schedule or an error.
+
+ @param email The account e-mail.
+ @param password The account password.
+ @exception LoginFailedError The site shows a login error.
+ @exception GymError The page did not behave as expected.
+ """
+ self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click()
+ self._wait.until(
+ ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
+ ).send_keys(email)
+ self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password)
+ self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click()
+ self._wait.until(
+ ec.any_of(
+ ec.presence_of_element_located((By.ID, constants.SCHEDULE_PAGE_ID)),
+ ec.presence_of_element_located((By.ID, constants.ERROR_MESSAGE_ID)),
+ )
+ )
+ errors = self._driver.find_elements(By.ID, constants.ERROR_MESSAGE_ID)
+ if errors:
+ raise LoginFailedError(errors[0].text)
+
+ @_translate_errors
+ def list_class_cards(self) -> list[ClassCard]:
+ """@brief Read every class card; the day comes from the ancestor day group.
+
+ @return The classes in page order.
+ @exception GymError The schedule could not be read.
+ """
+ self._wait.until(
+ ec.presence_of_element_located((By.CSS_SELECTOR, constants.CLASS_CARD_CSS))
+ )
+ elements = self._driver.find_elements(By.CSS_SELECTOR, constants.CLASS_CARD_CSS)
+ return [self._read_card(element) for element in elements]
+
+ def _read_card(self, element: WebElement) -> ClassCard:
+ """@brief Turn one card element into plain data.
+
+ @param element The card.
+ @return The class.
+ """
+ card_id = (element.get_attribute("id") or "").removeprefix(constants.CLASS_CARD_ID_PREFIX)
+ buttons = element.find_elements(By.ID, constants.BOOK_BUTTON_ID_PREFIX + card_id)
+ labels = element.find_elements(By.ID, constants.COMPLETED_LABEL_ID_PREFIX + card_id)
+ control = (buttons or labels or [None])[0]
+ return ClassCard(
+ card_id=card_id,
+ name=element.find_element(By.ID, constants.CLASS_NAME_ID_PREFIX + card_id).text,
+ day_label=element.find_element(By.XPATH, constants.DAY_TITLE_XPATH).text,
+ time_text=element.find_element(By.ID, constants.CLASS_TIME_ID_PREFIX + card_id).text,
+ button_text=control.text if control is not None else "",
+ )
+
+ @_translate_errors
+ def press_class_button(self, card_id: str) -> str:
+ """@brief Scroll to the button, click it and wait for the result or the error box.
+
+ @param card_id The id of the card without its prefix.
+ @return The new text of the button.
+ @exception GymError The site shows an error for the class, or nothing settled in time.
+ """
+ button_id = constants.BOOK_BUTTON_ID_PREFIX + card_id
+ button = self._wait.until(ec.presence_of_element_located((By.ID, button_id)))
+ self._driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", button)
+ before = button.text
+ self._wait.until(ec.element_to_be_clickable((By.ID, button_id))).click()
+
+ def settled(driver: WebDriver) -> tuple[str, str] | Literal[False]:
+ errors = driver.find_elements(By.ID, constants.CLASS_ERROR_ID_PREFIX + card_id)
+ text = driver.find_element(By.ID, button_id).text
+ if errors:
+ return text, errors[0].text or "class error"
+ if text != before and ButtonState.from_text(text) is not ButtonState.BUSY:
+ return text, ""
+ return False
+
+ text, error = self._wait.until(settled)
+ if error:
+ raise GymError(error)
+ return text
+
+ @_translate_errors
+ def save_text_screenshot(self, text: str, path: Path) -> None:
+ """@brief Draw console text in a temporary tab and save a screenshot of it.
+
+ A tab is used so that the schedule page keeps its state.
+
+ @param text The lines to draw.
+ @param path The PNG file to write; its folders are created.
+ """
+ page = Template(constants.CONSOLE_PAGE_TEMPLATE).substitute(text=html.escape(text))
+ original = self._driver.current_window_handle
+ self._driver.switch_to.new_window("tab")
+ try:
+ self._driver.get(constants.CONSOLE_PAGE_URL_PREFIX + urllib.parse.quote(page))
+ self.save_screenshot(path)
+ finally:
+ self._driver.close()
+ self._driver.switch_to.window(original)
+
+ def save_screenshot(self, path: Path) -> None:
+ """@brief Save a screenshot of the current page.
+
+ @param path The PNG file to write; its folders are created.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self._driver.save_screenshot(str(path))
+
+ def close(self) -> None:
+ """@brief Quit the browser."""
+ self._driver.quit()
diff --git a/tests/e2e/fixtures/schedule.html b/tests/e2e/fixtures/schedule.html
index a15eb8d..364d05a 100644
--- a/tests/e2e/fixtures/schedule.html
+++ b/tests/e2e/fixtures/schedule.html
@@ -2,6 +2,83 @@
Fixture gym schedule
-