diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 56dcd73..66a29d6 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 | 020 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 021 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | diff --git a/docs/sqa/reviews/rc-020-code-mil-006-verify-and-time-travel.md b/docs/sqa/reviews/rc-020-code-mil-006-verify-and-time-travel.md new file mode 100644 index 0000000..3d438c1 --- /dev/null +++ b/docs/sqa/reviews/rc-020-code-mil-006-verify-and-time-travel.md @@ -0,0 +1,62 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-020 | +| CrossReference | [MIL-006], [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-006 | [f57040e] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-006] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests of MIL-006 +- 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 | 174 unit tests (booking entries, verification, date independence for all seven starting weekdays, command line) need no browser or network; 15 container tests run the adapter against local 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-006 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 comparison and the mismatch message) is met, and the date independence is tested for every starting weekday (no hardcoded date, no list-position rule). Criteria 2 and 3 (container runs on the live site, including after the admin advances 3 days) are not verified, and criterion 4 is not met: `step-7.png` and `step-8.png` come from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site; the admin time controls also need a login. 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 (also after advancing the site 3 days as admin) and keep `docs/images/step-7.png` and `step-8.png` | S01 | when S01 has time | + +--- + +[MIL-006]: ../../milestones/mil-006-verify-and-time-travel.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[f57040e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/f57040e136feac01f10d99279038f48992518f43 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 48ac3b6..b2f31ed 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -40,6 +40,7 @@ Tracks backward and forward links between artifact instances so that the Busines | 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) | | Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) | +| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-020 (Go-with-conditions) | ## Concern Coverage diff --git a/src/snack_and_lift/application/ports.py b/src/snack_and_lift/application/ports.py index 24915a0..7d6f45d 100644 --- a/src/snack_and_lift/application/ports.py +++ b/src/snack_and_lift/application/ports.py @@ -1,66 +1,82 @@ -"""@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.""" - ... +"""@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.booking_entry import BookingEntry +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 open_my_bookings(self) -> None: + """@brief Open the My Bookings page and wait until it is shown. + + @exception GymError The page did not load. + """ + ... + + def list_my_bookings(self) -> list[BookingEntry]: + """@brief Read every confirmed booking and waitlist entry of the My Bookings page. + + @return The entries, confirmed ones first; empty when the page shows its empty state. + @exception GymError The page shows an error (the message is its text) or did not settle. + """ + ... + + 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/application/verification.py b/src/snack_and_lift/application/verification.py new file mode 100644 index 0000000..b2d83a1 --- /dev/null +++ b/src/snack_and_lift/application/verification.py @@ -0,0 +1,47 @@ +"""@file verification.py +@brief Lecture steps 7 and 8: check the bookings on the My Bookings page. +""" + +import logging +from collections.abc import Sequence + +from snack_and_lift import constants +from snack_and_lift.application.ports import GymPort +from snack_and_lift.domain.booking_entry import BookingEntry, is_target_entry +from snack_and_lift.domain.errors import NoBookingsFoundError +from snack_and_lift.domain.models import ClassResult +from snack_and_lift.domain.verification import VerificationResult, build_verification + +logger = logging.getLogger(__name__) + + +def get_my_bookings(gym: GymPort) -> list[BookingEntry]: + """@brief Open the My Bookings page and read all its entries. + + @param gym The gym site. + @return Every confirmed booking and waitlist entry, not only the target classes. + @exception NoBookingsFoundError The page shows no entry at all. + @exception GymError The page could not be opened or read. + """ + gym.open_my_bookings() + entries = gym.list_my_bookings() + if not entries: + raise NoBookingsFoundError(constants.MESSAGE_NO_BOOKINGS_FOUND) + return entries + + +def verify_bookings(gym: GymPort, results: Sequence[ClassResult]) -> VerificationResult: + """@brief Compare what the bot booked with the My Bookings page. + + The page is not opened when the bot handled no class. + + @param gym The gym site. + @param results What happened to each class. + @return The comparison of the expected entries with the Tuesday and Thursday 6pm entries. + @exception NoBookingsFoundError Classes were handled but the page shows no entry. + @exception GymError The page could not be opened or read. + """ + if not results: + return build_verification(results, []) + entries = [entry for entry in get_my_bookings(gym) if is_target_entry(entry)] + return build_verification(results, entries) diff --git a/src/snack_and_lift/cli.py b/src/snack_and_lift/cli.py index 5bbfd8b..ffbbb6f 100644 --- a/src/snack_and_lift/cli.py +++ b/src/snack_and_lift/cli.py @@ -1,209 +1,222 @@ -"""@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, book_target_classes -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.domain.models import ClassResult -from snack_and_lift.domain.report import format_detail_list, format_summary -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", - ) - parser.add_argument( - "--first-tuesday-only", - action="store_true", - help="book only the next Tuesday 6pm class (lecture steps 3 and 4)", - ) - 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 _save_console(gym: GymPort, screenshot_dir: Path | None, text: str, name: str) -> None: - """@brief Save console text as an image when a folder was asked for. - - @param gym The gym site. - @param screenshot_dir The folder, or None for no screenshots. - @param text The console text to draw. - @param name The file name. - """ - if screenshot_dir is not None: - gym.save_text_screenshot(text, screenshot_dir / name) - - -def _run( - gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials -) -> None: - """@brief Open the site, log in and book the classes, saving the step images. - - Books every Tuesday and Thursday 6pm class and logs the summary and the detailed list; - with `--first-tuesday-only` it books the next Tuesday class only. - - - @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") - if args.first_tuesday_only: - book_next_tuesday(gym) - _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) - _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) - return - window_saved = False - - def save_window_once(_: ClassResult) -> None: - nonlocal window_saved - if not window_saved: - _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) - window_saved = True - - results = book_target_classes(gym, save_window_once) - if not window_saved: - _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) - _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) - summary = format_summary(results) - logger.info("%s", summary) - _save_console(gym, args.screenshot_dir, summary, constants.SCREENSHOT_STEP_5) - detail = format_detail_list(results) - logger.info("%s", detail) - _save_console(gym, args.screenshot_dir, f"{summary}\n{detail}", constants.SCREENSHOT_STEP_6) - - -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 +"""@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, book_target_classes +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.application.verification import verify_bookings +from snack_and_lift.domain.errors import GymError, LoginFailedError, MissingCredentialsError +from snack_and_lift.domain.models import ClassResult +from snack_and_lift.domain.report import format_detail_list, format_summary +from snack_and_lift.domain.verification import format_verification +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.", + epilog=( + "exit codes: 0 done, 1 a step failed, 2 credentials missing, " + "3 the My Bookings page does not match the bookings made" + ), + ) + 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", + ) + parser.add_argument( + "--first-tuesday-only", + action="store_true", + help="book only the next Tuesday 6pm class (lecture steps 3 and 4); skips the verification", + ) + 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 _save_console(gym: GymPort, screenshot_dir: Path | None, text: str, name: str) -> None: + """@brief Save console text as an image when a folder was asked for. + + @param gym The gym site. + @param screenshot_dir The folder, or None for no screenshots. + @param text The console text to draw. + @param name The file name. + """ + if screenshot_dir is not None: + gym.save_text_screenshot(text, screenshot_dir / name) + + +def _run( + gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials +) -> bool: + """@brief Open the site, log in, book the classes and verify them, saving the step images. + + Books every Tuesday and Thursday 6pm class, logs the summary and the detailed list and + verifies the bookings on the My Bookings page; with `--first-tuesday-only` it books the + next Tuesday class only and does not verify. + + @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. + @return False when the My Bookings page does not match the bookings made, else True. + @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") + if args.first_tuesday_only: + book_next_tuesday(gym) + _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) + _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) + return True + window_saved = False + + def save_window_once(_: ClassResult) -> None: + nonlocal window_saved + if not window_saved: + _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) + window_saved = True + + results = book_target_classes(gym, save_window_once) + if not window_saved: + _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) + _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) + summary = format_summary(results) + logger.info("%s", summary) + _save_console(gym, args.screenshot_dir, summary, constants.SCREENSHOT_STEP_5) + detail = format_detail_list(results) + logger.info("%s", detail) + _save_console(gym, args.screenshot_dir, f"{summary}\n{detail}", constants.SCREENSHOT_STEP_6) + verification = verify_bookings(gym, results) + _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_7) + verification_text = format_verification(verification) + logger.info("%s", verification_text) + _save_console(gym, args.screenshot_dir, verification_text, constants.SCREENSHOT_STEP_8) + return verification.is_success + + +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, 3 mismatch. + """ + 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: + verified = _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 if verified else constants.EXIT_VERIFICATION_MISMATCH diff --git a/src/snack_and_lift/constants.py b/src/snack_and_lift/constants.py index ece8c38..98143f8 100644 --- a/src/snack_and_lift/constants.py +++ b/src/snack_and_lift/constants.py @@ -152,3 +152,54 @@ CONSOLE_PAGE_TEMPLATE: Final[str] = ( ## Prefix of the `data:` URL that carries the console page. CONSOLE_PAGE_URL_PREFIX: Final[str] = "data:text/html;charset=utf-8," + +## File names of the screenshots of lecture steps 7 and 8. +SCREENSHOT_STEP_7: Final[str] = "step-7.png" +SCREENSHOT_STEP_8: Final[str] = "step-8.png" + +## Process exit code when the My Bookings page does not match what the bot did. +EXIT_VERIFICATION_MISMATCH: Final[int] = 3 + +## Id of the navigation link that opens the My Bookings page. +MY_BOOKINGS_LINK_ID: Final[str] = "my-bookings-link" + +## Id of the My Bookings page, shown after its bookings were loaded. +MY_BOOKINGS_PAGE_ID: Final[str] = "my-bookings-page" + +## Id of the element shown when the account has no booking and no waitlist entry. +EMPTY_BOOKINGS_STATE_ID: Final[str] = "empty-bookings-state" + +## CSS selectors of the confirmed booking cards and the waitlist cards. +BOOKING_CARD_CSS: Final[str] = "#confirmed-bookings-section div[id^='booking-card-']" +WAITLIST_CARD_CSS: Final[str] = "#waitlist-section div[id^='waitlist-card-']" + +## Prefixes of the element ids of a booking card and of a waitlist card. +BOOKING_CARD_ID_PREFIX: Final[str] = "booking-card-" +BOOKING_NAME_ID_PREFIX: Final[str] = "booking-class-name-" +WAITLIST_CARD_ID_PREFIX: Final[str] = "waitlist-card-" +WAITLIST_NAME_ID_PREFIX: Final[str] = "waitlist-class-name-" + +## XPath, relative to a card, of the paragraph "When: Tue, Aug 12, 6:00 PM". +WHEN_XPATH: Final[str] = ".//p[strong[starts-with(normalize-space(), 'When:')]]" + +## Prefix of the date and time text of a booking card. +WHEN_TEXT_PREFIX: Final[str] = "When:" + +## Suffix the My Bookings page adds to the name of a waitlist entry. +WAITLIST_NAME_SUFFIX: Final[str] = " (Waitlist)" + +## Message of the error raised when the My Bookings page shows no booking at all. +MESSAGE_NO_BOOKINGS_FOUND: Final[str] = "The My Bookings page shows no booking or waitlist entry" + +## Lines of the verification on the My Bookings page (lecture steps 7 and 8). +VERIFY_TITLE: Final[str] = "--- VERIFYING ON MY BOOKINGS PAGE ---" +VERIFY_LINE_VERIFIED: Final[str] = " ✓ Verified: {name}" +VERIFY_RESULT_TITLE: Final[str] = "--- VERIFICATION RESULT ---" +VERIFY_EXPECTED: Final[str] = "Expected: {count} {noun}" +VERIFY_FOUND: Final[str] = "Found: {count} {noun}" +VERIFY_NOUN_SINGULAR: Final[str] = "booking" +VERIFY_NOUN_PLURAL: Final[str] = "bookings" +VERIFY_SUCCESS: Final[str] = "✅ SUCCESS: All bookings verified!" +VERIFY_MISMATCH: Final[str] = "❌ MISMATCH: Missing {count} bookings" +VERIFY_LINE_MISSING: Final[str] = " ✗ Missing: {entry}" +VERIFY_LINE_UNEXPECTED: Final[str] = " ? Unexpected: {entry}" diff --git a/src/snack_and_lift/domain/booking_entry.py b/src/snack_and_lift/domain/booking_entry.py new file mode 100644 index 0000000..432a75f --- /dev/null +++ b/src/snack_and_lift/domain/booking_entry.py @@ -0,0 +1,88 @@ +"""@file booking_entry.py +@brief One entry of the My Bookings page: a confirmed booking or a waitlist entry. +""" + +import re +from dataclasses import dataclass + +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 +from snack_and_lift.domain.rules import parse_time + +_WHEN = re.compile(r"(?P[A-Z][a-z]{2}, [A-Z][a-z]{2} \d{1,2}), (?P