Compare commits

..
Author SHA1 Message Date
Tirsvad 1088a88e59 Resolve pending commit links 2026-10-28 17:54:19 +08:00
Tirsvad 45e6fc72df Add review record RC-019 for MIL-005 2026-10-28 17:54:17 +08:00
Tirsvad 2ce7e3d8dc Add counters, summary and every Tuesday and Thursday class (steps 5 and 6)
Target class lookup for Tuesday and Thursday 6pm, counters, booking summary
and detailed class list, --first-tuesday-only for the earlier step,
screenshot names for steps 5 and 6, unit and container tests.

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
2026-10-28 17:54:11 +08:00
Tirsvad e0b6abb662 Merge pull request 'MIL-004: book a class and check its state' (#43) from mil-004-book-class into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 10:49:17 +01:00
14 changed files with 549 additions and 13 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | MIL | Milestone / Gateway | docs/milestones/*.md | 009 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 019 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 020 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | 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 | | 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 | [41b0dac] |
--- ---
@@ -59,3 +59,4 @@ Go-with-conditions - the code of MIL-004 meets every Mandatory criterion of QC-P
[MIL-004]: ../../milestones/mil-004-book-class.md [MIL-004]: ../../milestones/mil-004-book-class.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[41b0dac]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/41b0dace113f17d9fc0ffece8a1133ad15d0fb21
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-019 |
| CrossReference | [MIL-005], [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-005 | [45e6fc7] |
---
## Artifact Under Review
- Instance reviewed: [MIL-005]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-005
- 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 | 122 unit tests (target lookup, counters, summary and detail text, booking of all target classes, command line) need no browser or network; 10 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-005 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 counters, summary and detail list) is met. Criterion 2 (a container run on the live site that processes every Tuesday and Thursday card once) is not verified, and criterion 3 is not met: `step-5.png` and `step-6.png` come from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site; a container test against the local fixture checks that every target card is processed once. 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-5.png` and `step-6.png` | S01 | when S01 has time |
---
[MIL-005]: ../../milestones/mil-005-counters-and-both-days.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[45e6fc7]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/45e6fc72dfb39d3bb58b4129a159fe3727aa26c2
+1
View File
@@ -39,6 +39,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 | | 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-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-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) |
## Concern Coverage ## Concern Coverage
+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")]) == []