From 8c6cc148f5b839db08547160cb5f9cf8ef8fe219 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 18:02:44 +0800 Subject: [PATCH 1/3] Add My Bookings verification and date-independent booking (steps 7 and 8) Booking entries parsed from the My Bookings page, comparison of expected and found bookings with the lecture's output, NoBookingsFoundError as the counterpart of NoSuchElementException, exit code 3 on a mismatch, tests for every starting weekday of the 8-day window, container tests against local fixtures. Task: MIL-006#1 Task: MIL-006#2 Task: MIL-006#3 Task: MIL-006#4 --- src/snack_and_lift/application/ports.py | 148 +++--- .../application/verification.py | 47 ++ src/snack_and_lift/cli.py | 431 +++++++++-------- src/snack_and_lift/constants.py | 51 ++ src/snack_and_lift/domain/booking_entry.py | 88 ++++ src/snack_and_lift/domain/errors.py | 8 + src/snack_and_lift/domain/verification.py | 108 +++++ .../infrastructure/selenium_gym.py | 449 ++++++++++-------- tests/e2e/fixtures/my_bookings.html | 47 ++ tests/e2e/fixtures/schedule.html | 1 + tests/e2e/test_selenium_gym.py | 66 ++- tests/unit/fakes.py | 42 +- tests/unit/test_application_verification.py | 81 ++++ tests/unit/test_booking_entry.py | 65 +++ tests/unit/test_cli.py | 53 ++- tests/unit/test_date_independence.py | 87 ++++ tests/unit/test_verification.py | 106 +++++ 17 files changed, 1403 insertions(+), 475 deletions(-) create mode 100644 src/snack_and_lift/application/verification.py create mode 100644 src/snack_and_lift/domain/booking_entry.py create mode 100644 src/snack_and_lift/domain/verification.py create mode 100644 tests/e2e/fixtures/my_bookings.html create mode 100644 tests/unit/test_application_verification.py create mode 100644 tests/unit/test_booking_entry.py create mode 100644 tests/unit/test_date_independence.py create mode 100644 tests/unit/test_verification.py 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