MIL-005: counters and every Tuesday and Thursday class #44

Merged
Tirsvad merged 3 commits from mil-005-counters-and-both-days into main 2026-10-28 10:54:27 +01:00
10 changed files with 483 additions and 11 deletions
Showing only changes of commit 2ce7e3d8dc - Show all commits
+29 -2
View File
@@ -1,8 +1,9 @@
"""@file booking.py """@file booking.py
@brief Lecture steps 3 and 4: book the next Tuesday 6pm class and report its state. @brief Lecture steps 3 to 6: book the Tuesday and Thursday 6pm classes and report their state.
""" """
import logging import logging
from collections.abc import Callable
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.application.ports import GymPort from snack_and_lift.application.ports import GymPort
@@ -13,6 +14,7 @@ from snack_and_lift.domain.rules import (
OUTCOME_OF_ACTION, OUTCOME_OF_ACTION,
decide_outcome_action, decide_outcome_action,
find_next_class, find_next_class,
find_target_classes,
format_outcome, format_outcome,
) )
@@ -51,8 +53,33 @@ def book_next_tuesday(gym: GymPort) -> ClassResult | None:
logger.warning( logger.warning(
constants.MESSAGE_NO_CLASS_FOUND.format( constants.MESSAGE_NO_CLASS_FOUND.format(
weekday=constants.NEXT_CLASS_WEEKDAY, weekday=constants.NEXT_CLASS_WEEKDAY,
time=f"{constants.TARGET_HOUR:02d}:{constants.TARGET_MINUTE:02d}", time=constants.TARGET_TIME_TEXT,
) )
) )
return None return None
return book_class(gym, card) return book_class(gym, card)
def book_target_classes(
gym: GymPort, on_result: Callable[[ClassResult], None] | None = None
) -> list[ClassResult]:
"""@brief Book every Tuesday and Thursday class at the target time, in page order.
The cards are listed once; each class is found again by its id when it is pressed.
@param gym The gym site.
@param on_result Called after each class was handled, for example to take a screenshot.
@return What happened to each class; empty when the schedule has none.
@exception GymError The page could not be read or a booking failed.
"""
cards = find_target_classes(gym.list_class_cards())
if not cards:
logger.warning(constants.MESSAGE_NO_TARGET_CLASSES.format(time=constants.TARGET_TIME_TEXT))
return []
results: list[ClassResult] = []
for card in cards:
result = book_class(gym, card)
results.append(result)
if on_result is not None:
on_result(result)
return results
+46 -4
View File
@@ -10,11 +10,13 @@ from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.application.booking import book_next_tuesday 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.credentials import Credentials, load_credentials
from snack_and_lift.application.ports import GymPort from snack_and_lift.application.ports import GymPort
from snack_and_lift.application.session import login from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import GymError, LoginFailedError, MissingCredentialsError 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.browser import create_driver, default_profile_dir
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
@@ -69,6 +71,11 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
default=None, default=None,
help="save a screenshot after each step into this folder", 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) args = parser.parse_args(argv)
if args.keep_open is None: if args.keep_open is None:
args.keep_open = not args.headless args.keep_open = not args.headless
@@ -95,10 +102,26 @@ def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
gym.save_screenshot(screenshot_dir / name) 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( def _run(
gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials
) -> None: ) -> None:
"""@brief Open the site, log in and book the next Tuesday class, saving the step images. """@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 gym The gym site.
@param args The parsed options. @param args The parsed options.
@@ -110,10 +133,29 @@ def _run(
_save_step(gym, args.screenshot_dir, "step-1.png") _save_step(gym, args.screenshot_dir, "step-1.png")
login(gym, credentials) login(gym, credentials)
_save_step(gym, args.screenshot_dir, "step-2.png") _save_step(gym, args.screenshot_dir, "step-2.png")
if args.first_tuesday_only:
book_next_tuesday(gym) book_next_tuesday(gym)
_save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3)
if args.screenshot_dir is not None: _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4)
gym.save_text_screenshot(recorder.text, args.screenshot_dir / 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( def main(
+27 -1
View File
@@ -112,9 +112,35 @@ MESSAGE_ALREADY_BOOKED: Final[str] = "✓ Already booked: {name} on {day}"
MESSAGE_ALREADY_WAITLISTED: Final[str] = "✓ Already on waitlist: {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" 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. ## Logged when the schedule has no class on a target weekday at the target time.
MESSAGE_NO_TARGET_CLASSES: Final[str] = (
"No Tuesday or Thursday class at {time} found on the schedule"
)
## Time of the target classes as written in messages (24-hour clock).
TARGET_TIME_TEXT: Final[str] = f"{TARGET_HOUR:02d}:{TARGET_MINUTE:02d}"
## Lines of the booking summary (lecture step 5).
SUMMARY_TITLE: Final[str] = "--- BOOKING SUMMARY ---"
SUMMARY_NEW_BOOKINGS: Final[str] = "New bookings: {count}"
SUMMARY_NEW_WAITLIST: Final[str] = "New waitlist entries: {count}"
SUMMARY_ALREADY_DONE: Final[str] = "Already booked/waitlisted: {count}"
SUMMARY_TOTAL: Final[str] = "Total Tuesday & Thursday 6pm classes: {count}"
## Lines of the detailed class list (lecture step 6).
DETAIL_TITLE: Final[str] = "--- DETAILED CLASS LIST ---"
DETAIL_LINE: Final[str] = " • [{tag}] {name} on {day}"
DETAIL_NONE: Final[str] = " No matching class found"
DETAIL_TAG_NEW_BOOKING: Final[str] = "New Booking"
DETAIL_TAG_NEW_WAITLIST: Final[str] = "New Waitlist"
DETAIL_TAG_ALREADY_BOOKED: Final[str] = "Already Booked"
DETAIL_TAG_ALREADY_WAITLISTED: Final[str] = "Already Waitlisted"
## File names of the screenshots of lecture steps 3 to 6.
SCREENSHOT_STEP_3: Final[str] = "step-3.png" SCREENSHOT_STEP_3: Final[str] = "step-3.png"
SCREENSHOT_STEP_4: Final[str] = "step-4.png" SCREENSHOT_STEP_4: Final[str] = "step-4.png"
SCREENSHOT_STEP_5: Final[str] = "step-5.png"
SCREENSHOT_STEP_6: Final[str] = "step-6.png"
## `string.Template` of the page that renders console text for a screenshot. ## `string.Template` of the page that renders console text for a screenshot.
CONSOLE_PAGE_TEMPLATE: Final[str] = ( CONSOLE_PAGE_TEMPLATE: Final[str] = (
+81
View File
@@ -0,0 +1,81 @@
"""@file report.py
@brief Lecture steps 5 and 6: count what the bot did and write the summary and the class list.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from snack_and_lift import constants
from snack_and_lift.domain.models import ClassResult, Outcome
_TAG_OF_OUTCOME: dict[Outcome, str] = {
Outcome.NEW_BOOKING: constants.DETAIL_TAG_NEW_BOOKING,
Outcome.NEW_WAITLIST: constants.DETAIL_TAG_NEW_WAITLIST,
Outcome.ALREADY_BOOKED: constants.DETAIL_TAG_ALREADY_BOOKED,
Outcome.ALREADY_WAITLISTED: constants.DETAIL_TAG_ALREADY_WAITLISTED,
}
_ALREADY_DONE = (Outcome.ALREADY_BOOKED, Outcome.ALREADY_WAITLISTED)
@dataclass(frozen=True, slots=True)
class Counters:
"""@brief How many classes ended in each way."""
new_bookings: int
new_waitlist: int
already_done: int
@property
def total(self) -> int:
"""@brief The number of classes handled."""
return self.new_bookings + self.new_waitlist + self.already_done
def count_results(results: Sequence[ClassResult]) -> Counters:
"""@brief Count the results by outcome.
@param results What happened to each class.
@return The counters; already booked and already waitlisted count together.
"""
return Counters(
new_bookings=sum(1 for r in results if r.outcome is Outcome.NEW_BOOKING),
new_waitlist=sum(1 for r in results if r.outcome is Outcome.NEW_WAITLIST),
already_done=sum(1 for r in results if r.outcome in _ALREADY_DONE),
)
def format_summary(results: Sequence[ClassResult]) -> str:
"""@brief Write the booking summary block.
@param results What happened to each class.
@return The block, starting with `--- BOOKING SUMMARY ---`, lines joined by newlines.
"""
counters = count_results(results)
return "\n".join(
[
constants.SUMMARY_TITLE,
constants.SUMMARY_NEW_BOOKINGS.format(count=counters.new_bookings),
constants.SUMMARY_NEW_WAITLIST.format(count=counters.new_waitlist),
constants.SUMMARY_ALREADY_DONE.format(count=counters.already_done),
constants.SUMMARY_TOTAL.format(count=counters.total),
]
)
def format_detail_list(results: Sequence[ClassResult]) -> str:
"""@brief Write the detailed class list, one line per class in the order handled.
@param results What happened to each class.
@return The block, starting with `--- DETAILED CLASS LIST ---`; says so when empty.
"""
lines = [constants.DETAIL_TITLE]
if not results:
lines.append(constants.DETAIL_NONE)
lines.extend(
constants.DETAIL_LINE.format(
tag=_TAG_OF_OUTCOME[result.outcome], name=result.card.name, day=result.card.day_label
)
for result in results
)
return "\n".join(lines)
+20
View File
@@ -17,6 +17,7 @@ __all__ = [
"Action", "Action",
"decide_outcome_action", "decide_outcome_action",
"find_next_class", "find_next_class",
"find_target_classes",
"format_outcome", "format_outcome",
"is_target_time", "is_target_time",
"parse_day_label", "parse_day_label",
@@ -116,6 +117,25 @@ def find_next_class(cards: Iterable[ClassCard], weekday: str) -> ClassCard | Non
return None return None
def find_target_classes(cards: Iterable[ClassCard]) -> list[ClassCard]:
"""@brief Find every class on a target 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.
@return The classes to handle; empty when there is none.
@exception PageFormatError A day label or time text is not valid.
"""
return [
card
for card in cards
if card.state is not ButtonState.COMPLETED
and card.weekday in constants.TARGET_WEEKDAYS
and is_target_time(card)
]
def decide_outcome_action(card: ClassCard) -> Action: def decide_outcome_action(card: ClassCard) -> Action:
"""@brief Decide what to do from the state of the button. """@brief Decide what to do from the state of the button.
+16
View File
@@ -8,7 +8,9 @@ from pathlib import Path
import pytest import pytest
from snack_and_lift.application.booking import book_target_classes
from snack_and_lift.domain.errors import GymError, LoginFailedError from snack_and_lift.domain.errors import GymError, LoginFailedError
from snack_and_lift.domain.models import Outcome
from snack_and_lift.infrastructure.browser import create_driver from snack_and_lift.infrastructure.browser import create_driver
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
@@ -110,3 +112,17 @@ def test_save_text_screenshot_writes_a_png_and_keeps_the_page(
assert target.read_bytes().startswith(b"\x89PNG") assert target.read_bytes().startswith(b"\x89PNG")
assert schedule_gym.list_class_cards() assert schedule_gym.list_class_cards()
def test_book_target_classes_books_every_tuesday_and_thursday_class(
schedule_gym: SeleniumGym,
) -> None:
with pytest.raises(GymError, match="Network error"):
book_target_classes(schedule_gym)
results = book_target_classes(schedule_gym)
assert [(r.card.card_id, r.outcome) for r in results] == [
("201", Outcome.ALREADY_WAITLISTED),
("302", Outcome.NEW_BOOKING),
]
+54 -1
View File
@@ -8,7 +8,11 @@ import pytest
from tests.unit.fakes import FakeGym from tests.unit.fakes import FakeGym
from tests.unit.test_models import make_card from tests.unit.test_models import make_card
from snack_and_lift.application.booking import book_class, book_next_tuesday from snack_and_lift.application.booking import (
book_class,
book_next_tuesday,
book_target_classes,
)
from snack_and_lift.domain.errors import GymError, UnknownButtonError from snack_and_lift.domain.errors import GymError, UnknownButtonError
from snack_and_lift.domain.models import Outcome from snack_and_lift.domain.models import Outcome
@@ -95,3 +99,52 @@ def test_book_next_tuesday_logs_when_there_is_no_class(caplog: pytest.LogCapture
assert "No Tue class at 18:00 found" in caplog.text assert "No Tue class at 18:00 found" in caplog.text
assert gym.pressed == [] assert gym.pressed == []
def test_book_target_classes_handles_every_target_class_in_order() -> None:
cards = [
make_card("Today (Wed, Aug 13)", card_id="1"),
make_card("Tomorrow (Thu, Aug 14)", card_id="2"),
make_card("Tue, Aug 19", card_id="3", button_text="Join Waitlist"),
make_card("Thu, Aug 21", card_id="4", button_text="Booked"),
make_card("Tue, Aug 26", card_id="5", button_text="Waitlisted"),
]
gym = FakeGym(cards=cards)
results = book_target_classes(gym)
assert [result.outcome for result in results] == [
Outcome.NEW_BOOKING,
Outcome.NEW_WAITLIST,
Outcome.ALREADY_BOOKED,
Outcome.ALREADY_WAITLISTED,
]
assert gym.pressed == ["2", "3"]
assert gym.calls.count("list_class_cards") == 1
def test_book_target_classes_calls_back_after_each_class() -> None:
gym = FakeGym(
cards=[make_card("Thu, Aug 14", card_id="1"), make_card("Tue, Aug 19", card_id="2")]
)
seen: list[str] = []
book_target_classes(gym, lambda result: seen.append(result.card.card_id))
assert seen == ["1", "2"]
def test_book_target_classes_logs_when_there_is_none(caplog: pytest.LogCaptureFixture) -> None:
gym = FakeGym(cards=[make_card("Wed, Aug 13")])
with caplog.at_level(logging.INFO):
assert book_target_classes(gym) == []
assert "No Tuesday or Thursday class at 18:00 found" in caplog.text
def test_book_target_classes_propagates_a_failing_class() -> None:
gym = FakeGym(cards=[make_card("Thu, Aug 14")], press_error="Network down")
with pytest.raises(GymError, match="Network down"):
book_target_classes(gym)
+96 -1
View File
@@ -2,6 +2,7 @@
@brief The command line accepts its options and the entry point wires the use case. @brief The command line accepts its options and the entry point wires the use case.
""" """
import logging
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -67,7 +68,11 @@ def test_main_saves_both_screenshots_on_success(tmp_path: Path) -> None:
tmp_path / "step-2.png", tmp_path / "step-2.png",
tmp_path / "step-3.png", tmp_path / "step-3.png",
] ]
assert [path for _, path in gym.text_screenshots] == [tmp_path / "step-4.png"] assert [path for _, path in gym.text_screenshots] == [
tmp_path / "step-4.png",
tmp_path / "step-5.png",
tmp_path / "step-6.png",
]
assert gym.calls == [ assert gym.calls == [
"open_site", "open_site",
"save_screenshot", "save_screenshot",
@@ -76,6 +81,8 @@ def test_main_saves_both_screenshots_on_success(tmp_path: Path) -> None:
"list_class_cards", "list_class_cards",
"save_screenshot", "save_screenshot",
"save_text_screenshot", "save_text_screenshot",
"save_text_screenshot",
"save_text_screenshot",
] ]
@@ -91,6 +98,94 @@ def test_main_books_the_class_and_draws_the_console_text(tmp_path: Path) -> None
assert "✓ Successfully booked: Spin Class on Tue, Aug 19" in text assert "✓ Successfully booked: Spin Class on Tue, Aug 19" in text
@pytest.mark.usefixtures("credentials_env")
def test_main_books_every_target_class_and_logs_the_report(
caplog: pytest.LogCaptureFixture,
) -> None:
gym = FakeGym(
cards=[
make_card("Tomorrow (Thu, Aug 14)", card_id="1"),
make_card("Fri, Aug 15", card_id="2"),
make_card("Tue, Aug 19", card_id="3", button_text="Join Waitlist"),
]
)
with caplog.at_level(logging.INFO):
assert main([], lambda *_: gym) == 0
assert gym.pressed == ["1", "3"]
assert "New bookings: 1\nNew waitlist entries: 1\nAlready booked/waitlisted: 0" in caplog.text
assert "Total Tuesday & Thursday 6pm classes: 2" in caplog.text
assert " • [New Booking] Spin Class on Tomorrow (Thu, Aug 14)" in caplog.text
assert " • [New Waitlist] Spin Class on Tue, Aug 19" in caplog.text
@pytest.mark.usefixtures("credentials_env")
def test_main_saves_step_3_after_the_first_class_and_the_report_images(tmp_path: Path) -> None:
gym = FakeGym(
cards=[make_card("Thu, Aug 14", card_id="1"), make_card("Tue, Aug 19", card_id="2")]
)
assert main(["--screenshot-dir", str(tmp_path)], lambda *_: gym) == 0
assert gym.calls == [
"open_site",
"save_screenshot",
"log_in",
"save_screenshot",
"list_class_cards",
"press_class_button",
"save_screenshot",
"press_class_button",
"save_text_screenshot",
"save_text_screenshot",
"save_text_screenshot",
]
assert gym.screenshots[-1] == tmp_path / "step-3.png"
(_, _), (step_5, _), (step_6, _) = gym.text_screenshots
assert step_5.startswith("--- BOOKING SUMMARY ---\nNew bookings: 2")
assert "--- DETAILED CLASS LIST ---" not in step_5
assert step_6.startswith(step_5)
assert "--- DETAILED CLASS LIST ---\n • [New Booking] Spin Class on Thu, Aug 14" in step_6
@pytest.mark.usefixtures("credentials_env")
def test_main_logs_an_empty_report_without_target_classes(
caplog: pytest.LogCaptureFixture,
) -> None:
gym = FakeGym(cards=[make_card("Wed, Aug 13")])
with caplog.at_level(logging.INFO):
assert main([], lambda *_: gym) == 0
assert "Total Tuesday & Thursday 6pm classes: 0" in caplog.text
assert "No matching class found" in caplog.text
@pytest.mark.usefixtures("credentials_env")
def test_main_first_tuesday_only_books_one_class_and_no_report(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
gym = FakeGym(
cards=[make_card("Thu, Aug 14", card_id="1"), make_card("Tue, Aug 19", card_id="2")]
)
with caplog.at_level(logging.INFO):
code = main(["--first-tuesday-only", "--screenshot-dir", str(tmp_path)], lambda *_: gym)
assert code == 0
assert gym.pressed == ["2"]
assert "BOOKING SUMMARY" not in caplog.text
assert [path.name for path in gym.screenshots] == ["step-1.png", "step-2.png", "step-3.png"]
assert [path.name for _, path in gym.text_screenshots] == ["step-4.png"]
@pytest.mark.usefixtures("credentials_env")
def test_first_tuesday_only_flag_is_read() -> None:
assert parse_args(["--first-tuesday-only"]).first_tuesday_only is True
assert parse_args([]).first_tuesday_only is False
@pytest.mark.usefixtures("credentials_env") @pytest.mark.usefixtures("credentials_env")
def test_main_returns_1_when_the_booking_fails() -> None: def test_main_returns_1_when_the_booking_fails() -> None:
gym = FakeGym(cards=[make_card("Tue, Aug 19")], press_error="Network down") gym = FakeGym(cards=[make_card("Tue, Aug 19")], press_error="Network down")
+86
View File
@@ -0,0 +1,86 @@
"""@file test_report.py
@brief The counters, the booking summary and the detailed class list.
"""
from tests.unit.test_models import make_card
from snack_and_lift.domain.models import ClassResult, Outcome
from snack_and_lift.domain.report import (
Counters,
count_results,
format_detail_list,
format_summary,
)
def make_result(outcome: Outcome, day_label: str = "Tue, Aug 19") -> ClassResult:
return ClassResult(make_card(day_label), outcome)
def test_count_results_groups_the_outcomes() -> None:
results = [
make_result(Outcome.NEW_BOOKING),
make_result(Outcome.NEW_BOOKING),
make_result(Outcome.NEW_WAITLIST),
make_result(Outcome.ALREADY_BOOKED),
make_result(Outcome.ALREADY_WAITLISTED),
]
counters = count_results(results)
assert counters == Counters(new_bookings=2, new_waitlist=1, already_done=2)
assert counters.total == 5
def test_count_results_is_zero_for_no_results() -> None:
assert count_results([]).total == 0
def test_format_summary_matches_the_lecture() -> None:
results = [
make_result(Outcome.NEW_BOOKING, "Tomorrow (Thu, Aug 14)"),
make_result(Outcome.NEW_WAITLIST),
]
assert format_summary(results) == (
"--- BOOKING SUMMARY ---\n"
"New bookings: 1\n"
"New waitlist entries: 1\n"
"Already booked/waitlisted: 0\n"
"Total Tuesday & Thursday 6pm classes: 2"
)
def test_format_summary_for_no_results() -> None:
assert format_summary([]).endswith("Total Tuesday & Thursday 6pm classes: 0")
def test_format_detail_list_matches_the_lecture() -> None:
results = [
make_result(Outcome.NEW_BOOKING, "Tomorrow (Thu, Aug 14)"),
make_result(Outcome.NEW_WAITLIST),
]
assert format_detail_list(results) == (
"--- DETAILED CLASS LIST ---\n"
" • [New Booking] Spin Class on Tomorrow (Thu, Aug 14)\n"
" • [New Waitlist] Spin Class on Tue, Aug 19"
)
def test_format_detail_list_tags_every_outcome() -> None:
results = [
make_result(Outcome.ALREADY_BOOKED),
make_result(Outcome.ALREADY_WAITLISTED),
]
lines = format_detail_list(results).splitlines()
assert lines[1:] == [
" • [Already Booked] Spin Class on Tue, Aug 19",
" • [Already Waitlisted] Spin Class on Tue, Aug 19",
]
def test_format_detail_list_says_when_no_class_was_found() -> None:
assert format_detail_list([]) == "--- DETAILED CLASS LIST ---\n No matching class found"
+26
View File
@@ -11,6 +11,7 @@ from snack_and_lift.domain.rules import (
Action, Action,
decide_outcome_action, decide_outcome_action,
find_next_class, find_next_class,
find_target_classes,
format_outcome, format_outcome,
is_target_time, is_target_time,
parse_day_label, parse_day_label,
@@ -136,3 +137,28 @@ def test_action_for_busy_or_unknown_button_raises(button_text: str) -> None:
) )
def test_outcome_message(outcome: Outcome, message: str) -> None: def test_outcome_message(outcome: Outcome, message: str) -> None:
assert format_outcome(outcome, make_card()) == message assert format_outcome(outcome, make_card()) == message
def test_find_target_classes_returns_tuesdays_and_thursdays_in_page_order() -> None:
cards = [
make_card("Today (Wed, Aug 13)", card_id="1"),
make_card("Tomorrow (Thu, Aug 14)", card_id="2"),
make_card("Fri, Aug 15", card_id="3"),
make_card("Tue, Aug 19", card_id="4"),
]
assert [card.card_id for card in find_target_classes(cards)] == ["2", "4"]
def test_find_target_classes_ignores_other_times_and_completed_cards() -> None:
cards = [
make_card("Thu, Aug 14", time_text="Time: 7:00 AM", card_id="1"),
make_card("Thu, Aug 14", button_text="Completed", card_id="2"),
make_card("Tue, Aug 19", time_text="Time: 6:00 PM", card_id="3"),
]
assert [card.card_id for card in find_target_classes(cards)] == ["3"]
def test_find_target_classes_is_empty_when_none_match() -> None:
assert find_target_classes([make_card("Wed, Aug 13")]) == []