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
Tirsvad 41b0dace11 Add review record RC-018 and resolve pending commit links for MIL-004 2026-10-28 17:49:10 +08:00
Tirsvad 08ba464ec9 Add class card parsing, booking and state checks (steps 3 and 4)
Day label and time parsing, next Tuesday 6pm lookup without list-position
tricks, decision on Book Class / Join Waitlist / Booked / Waitlisted,
confirmed clicks, console recorder and text screenshots, unit tests and
container tests against local fixtures.

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
Task: MIL-004#4
2026-10-28 17:49:06 +08:00
Tirsvad 735cb73cde Merge pull request 'MIL-003: profile, navigation and automated login' (#42) from mil-003-login into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-28 10:42:08 +01:00
Tirsvad 245dd1689e Add review record RC-017 and resolve pending commit links for MIL-003 2026-10-28 17:41:59 +08:00
Tirsvad 2fa9e15ab5 Add Chrome profile, navigation and automated login (steps 1 and 2)
Chrome options with detach and the chrome_profile folder, Selenium adapter
with explicit waits, login use case, credentials from environment
variables, command line, unit tests, container tests against local
fixtures and the step-1 screenshot.

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
2026-10-28 17:41:55 +08:00
Tirsvad d40c60a957 Merge pull request 'MIL-002: project scaffold and tooling' (#41) from mil-002-scaffold into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
2026-10-28 10:30:27 +01:00
39 changed files with 2584 additions and 71 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
SNACK_AND_LIFT_E2E=1 SNACK_AND_LIFT_E2E=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \ && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+18
View File
@@ -75,6 +75,24 @@ Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run ## ▶️ Run
### Your gym account
The bot never contains a password. Register your own account once, by hand, as the lecture does: run the bot with `--keep-open` so Chrome stays open (the `chrome_profile` folder keeps the site's data between runs), click "Join Today... Or Tomorrow" and register with an e-mail such as `yourname@test.com` and a password you remember (not a trivial one such as `12346`, Chrome warns about those). Then give the bot the account through two environment variables:
```bash
# Linux and macOS
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
export SNACK_AND_LIFT_PASSWORD="your password"
```
```powershell
# Windows PowerShell
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
### Start the bot
With the environment active: With the environment active:
```bash ```bash
+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 | 017 | | 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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-017 |
| CrossReference | [MIL-003], [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-003 | [245dd16] |
---
## Artifact Under Review
- Instance reviewed: [MIL-003]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-003
- 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 | 36 unit tests (credentials, login use case, command line, Chrome options) need no browser or network; 3 container tests run the adapter against local HTML fixtures with dummy credentials; 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-003 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 criteria 2 and 4 are met (36 unit tests pass; this review). Criterion 3 is half met: `docs/images/step-1.png` is a real screenshot of the home page taken in the container; `step-2.png` shows the logged-in schedule and needs an account. Criterion 1 (a container run that logs in to the live site) is not verified. The framework forbids the stand-in to enter passwords or create accounts on a third-party site, so the live login was not run; the adapter is verified against local fixtures only, and its selectors were read from the site's public scripts. 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 once with your own account (README, section Screenshots) and keep `docs/images/step-2.png`; report any selector that failed | S01 | when S01 has time |
---
[MIL-003]: ../../milestones/mil-003-login.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[245dd16]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/245dd1689e10cc8d87c6f2a4934286cc36ca34e2
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-018 |
| CrossReference | [MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-004 | [41b0dac] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-004
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; messages go through `logging`. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 103 unit tests (models, parsing, decisions, booking, recorder, command line) need no browser or network; 9 container tests run the adapter against local HTML fixtures; coverage of the non-infrastructure modules is 100%. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All file and folder names are English lowercase with `_`, `-` and `.` only. |
## Overall Verdict
Go-with-conditions - the code of MIL-004 meets every Mandatory criterion of QC-PY-001 and there is no blocking finding, but part of the gate evidence cannot be produced by the stand-in. Gate criterion 1 (unit tests of the four button states and the parsing) is met. Criterion 2 (a container run that books or recognises the Tuesday class on the live site) is not verified, and criterion 3 is not met: `step-3.png` and `step-4.png` come from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site, so the live run was not made; the adapter is verified against local fixtures that copy the site's element IDs, button texts and day labels. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Run the bot with your own account and keep `docs/images/step-3.png` and `step-4.png`; report any selector that failed | S01 | when S01 has time |
---
[MIL-004]: ../../milestones/mil-004-book-class.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[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
+3
View File
@@ -37,6 +37,9 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 | | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 | | Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) |
## Concern Coverage ## Concern Coverage
+1 -1
View File
@@ -29,7 +29,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-ra --strict-markers" addopts = "-ra --strict-markers"
pythonpath = ["src"] pythonpath = ["src", "."]
markers = [ markers = [
"browser: needs a real browser (run in the Docker container)", "browser: needs a real browser (run in the Docker container)",
] ]
+85
View File
@@ -0,0 +1,85 @@
"""@file booking.py
@brief Lecture steps 3 to 6: book the Tuesday and Thursday 6pm classes and report their state.
"""
import logging
from collections.abc import Callable
from snack_and_lift import constants
from snack_and_lift.application.ports import GymPort
from snack_and_lift.domain.errors import GymError
from snack_and_lift.domain.models import ButtonState, ClassCard, ClassResult
from snack_and_lift.domain.rules import (
CONFIRMED_STATE,
OUTCOME_OF_ACTION,
decide_outcome_action,
find_next_class,
find_target_classes,
format_outcome,
)
logger = logging.getLogger(__name__)
def book_class(gym: GymPort, card: ClassCard) -> ClassResult:
"""@brief Book or waitlist a class, or report that it already is.
@param gym The gym site.
@param card The class, as read from the page.
@return The class and what happened.
@exception UnknownButtonError The button is busy, completed or unknown.
@exception GymError The click failed or was not confirmed by the button.
"""
action = decide_outcome_action(card)
expected = CONFIRMED_STATE.get(action)
if expected is not None:
new_text = gym.press_class_button(card.card_id)
if ButtonState.from_text(new_text) is not expected:
raise GymError(f"click not confirmed: the button says {new_text!r}")
outcome = OUTCOME_OF_ACTION[action]
logger.info(format_outcome(outcome, card))
return ClassResult(card, outcome)
def book_next_tuesday(gym: GymPort) -> ClassResult | None:
"""@brief Book the next Tuesday class at the target time.
@param gym The gym site.
@return The result, or None when the schedule has no such class.
@exception GymError The page could not be read or the booking failed.
"""
card = find_next_class(gym.list_class_cards(), constants.NEXT_CLASS_WEEKDAY)
if card is None:
logger.warning(
constants.MESSAGE_NO_CLASS_FOUND.format(
weekday=constants.NEXT_CLASS_WEEKDAY,
time=constants.TARGET_TIME_TEXT,
)
)
return None
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
@@ -0,0 +1,35 @@
"""@file credentials.py
@brief The account the bot logs in with, read from the environment (never from the code).
"""
from collections.abc import Mapping
from dataclasses import dataclass, field
from snack_and_lift import constants
from snack_and_lift.domain.errors import MissingCredentialsError
@dataclass(frozen=True, slots=True)
class Credentials:
"""@brief E-mail and password of the gym account; the password is never printed."""
email: str
password: str = field(repr=False)
def load_credentials(environment: Mapping[str, str]) -> Credentials:
"""@brief Read the account from the environment.
@param environment The environment variables, for example `os.environ`.
@return The credentials.
@exception MissingCredentialsError A variable is missing or empty; the message names
the variables and never a value.
"""
email = environment.get(constants.ACCOUNT_EMAIL_ENV, "").strip()
password = environment.get(constants.ACCOUNT_PASSWORD_ENV, "")
if not email or not password:
raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
"in the environment (they must not be empty)"
)
return Credentials(email=email, password=password)
+66
View File
@@ -0,0 +1,66 @@
"""@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."""
...
+21
View File
@@ -0,0 +1,21 @@
"""@file session.py
@brief Lecture step 2: the login of the bot.
"""
import logging
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.ports import GymPort
logger = logging.getLogger(__name__)
def login(gym: GymPort, credentials: Credentials) -> None:
"""@brief Log in and wait until the class schedule is shown.
@param gym The gym site.
@param credentials The account to log in with.
@exception LoginFailedError The login failed; `retry` repeats it (lecture step 9).
"""
gym.log_in(credentials.email, credentials.password)
logger.info("✓ Logged in: the class schedule is shown")
+178 -8
View File
@@ -4,16 +4,45 @@
import argparse import argparse
import logging import logging
from collections.abc import Sequence 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__) 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: def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line. """@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`. @param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options. @return The parsed options; `keep_open` is True unless `--headless` is given.
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="snack-and-lift", prog="snack-and-lift",
@@ -24,16 +53,157 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="run Chrome without a window (the Docker image does this)", help="run Chrome without a window (the Docker image does this)",
) )
return parser.parse_args(argv) 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 main(argv: Sequence[str] | None = None) -> int: def _use_utf8_output() -> None:
"""@brief Start the bot. """@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 argv Arguments without the program name; None reads `sys.argv`.
@return Process exit code. @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") logging.basicConfig(level=logging.INFO, format="%(message)s")
parse_args(argv) root = logging.getLogger()
logger.info("snack-and-lift: the lecture steps are added by the next milestones") 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 return 0
+109
View File
@@ -24,6 +24,27 @@ TARGET_WEEKDAYS: Final[tuple[str, ...]] = ("Tue", "Thu")
## Id of the login button in the navigation bar of the site (the home page has no title). ## Id of the login button in the navigation bar of the site (the home page has no title).
NAV_LOGIN_BUTTON_ID: Final[str] = "login-button" NAV_LOGIN_BUTTON_ID: Final[str] = "login-button"
## Id of the e-mail field of the login form.
LOGIN_EMAIL_INPUT_ID: Final[str] = "email-input"
## Id of the password field of the login form.
LOGIN_PASSWORD_INPUT_ID: Final[str] = "password-input"
## Id of the submit button of the login form.
LOGIN_SUBMIT_BUTTON_ID: Final[str] = "submit-button"
## Id of the element that shows a login error.
ERROR_MESSAGE_ID: Final[str] = "error-message"
## Id of the class schedule page, shown after a successful login.
SCHEDULE_PAGE_ID: Final[str] = "schedule-page"
## Name of the environment variable that holds the account e-mail.
ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD"
## Folder where screenshots of a container run are written. ## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images" SCREENSHOT_DIR: Final[str] = "docs/images"
@@ -43,3 +64,91 @@ HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
) )
## Weekday of the class that lecture steps 3 and 4 book, as the site abbreviates it.
NEXT_CLASS_WEEKDAY: Final[str] = "Tue"
## Hour (24-hour clock) of the class the bot books.
TARGET_HOUR: Final[int] = 18
## Minute of the class the bot books.
TARGET_MINUTE: Final[int] = 0
## CSS selector of every class card on the schedule.
CLASS_CARD_CSS: Final[str] = "div[id^='class-card-']"
## Prefix of the element ids of one card; the card id (for example "123") follows it.
CLASS_CARD_ID_PREFIX: Final[str] = "class-card-"
CLASS_NAME_ID_PREFIX: Final[str] = "class-name-"
CLASS_TIME_ID_PREFIX: Final[str] = "class-time-"
BOOK_BUTTON_ID_PREFIX: Final[str] = "book-button-"
COMPLETED_LABEL_ID_PREFIX: Final[str] = "completed-label-"
CLASS_ERROR_ID_PREFIX: Final[str] = "class-error-"
## XPath, relative to a card, of the title of the day group the card is in (the "ancestor" hint).
DAY_TITLE_XPATH: Final[str] = (
"ancestor::div[starts-with(@id,'day-group-')]//h2[starts-with(@id,'day-title-')]"
)
## Texts of the button of a card.
BUTTON_TEXT_BOOK: Final[str] = "Book Class"
BUTTON_TEXT_JOIN_WAITLIST: Final[str] = "Join Waitlist"
BUTTON_TEXT_BOOKED: Final[str] = "Booked"
BUTTON_TEXT_WAITLISTED: Final[str] = "Waitlisted"
BUTTON_TEXT_BOOKING: Final[str] = "Booking..."
BUTTON_TEXT_JOINING: Final[str] = "Joining..."
BUTTON_TEXT_COMPLETED: Final[str] = "Completed"
## Prefix of the time text of a card ("Time: 6:00 PM").
TIME_TEXT_PREFIX: Final[str] = "Time:"
## Day labels the site wraps around the date of the next two days.
DAY_LABEL_WRAPPERS: Final[tuple[str, ...]] = ("Today", "Tomorrow")
## Console messages of the booking, with the class name and the day label as the site shows it.
MESSAGE_BOOKED: Final[str] = "✓ Successfully booked: {name} on {day}"
MESSAGE_WAITLISTED: Final[str] = "✓ Joined waitlist for: {name} on {day}"
MESSAGE_ALREADY_BOOKED: Final[str] = "✓ Already booked: {name} on {day}"
MESSAGE_ALREADY_WAITLISTED: Final[str] = "✓ Already on waitlist: {name} on {day}"
MESSAGE_NO_CLASS_FOUND: Final[str] = "No {weekday} class at {time} found on the schedule"
## 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_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.
CONSOLE_PAGE_TEMPLATE: Final[str] = (
"<!DOCTYPE html><html><head><meta charset='utf-8'></head>"
"<body style='margin:0;background:#1e1e1e;color:#d4d4d4'>"
"<pre style='margin:0;padding:16px;font:16px/1.5 monospace;white-space:pre-wrap'>"
"$text</pre></body></html>"
)
## Prefix of the `data:` URL that carries the console page.
CONSOLE_PAGE_URL_PREFIX: Final[str] = "data:text/html;charset=utf-8,"
+29
View File
@@ -0,0 +1,29 @@
"""@file day_label.py
@brief Reads the title of a day group of the schedule ("Today (Tue, Aug 12)").
Kept apart from `rules.py` so that `models.py` can use it without a circular import.
"""
import re
from snack_and_lift import constants
from snack_and_lift.domain.errors import PageFormatError
_DATE = re.compile(r"(?P<weekday>[A-Z][a-z]{2}), [A-Z][a-z]{2} \d{1,2}")
_WRAPPER = re.compile(rf"(?:{'|'.join(constants.DAY_LABEL_WRAPPERS)}) \((?P<inner>.+)\)")
def parse_day_label(label: str) -> tuple[str, str]:
"""@brief Split a day label into the weekday and the date text.
@param label `Today (Tue, Aug 12)`, `Tomorrow (Wed, Aug 13)` or `Thu, Aug 14`.
@return The weekday (`Tue`) and the date text (`Tue, Aug 12`).
@exception PageFormatError The label has none of these forms.
"""
text = label.strip()
wrapped = _WRAPPER.fullmatch(text)
date_text = wrapped["inner"] if wrapped else text
match = _DATE.fullmatch(date_text)
if match is None:
raise PageFormatError(f"Unexpected day label: {label!r}")
return match["weekday"], date_text
+23
View File
@@ -0,0 +1,23 @@
"""@file errors.py
@brief The errors of the bot.
"""
class GymError(Exception):
"""@brief The gym site did not behave as the bot expected (a page, element or timeout)."""
class LoginFailedError(GymError):
"""@brief The site refused the login; the message is the text the site shows."""
class MissingCredentialsError(Exception):
"""@brief The account e-mail or password is not set in the environment."""
class UnknownButtonError(GymError):
"""@brief A class button shows a text the bot cannot act on (busy, completed or unknown)."""
class PageFormatError(GymError):
"""@brief A text read from the page (a day label or a time) has an unexpected format."""
+94
View File
@@ -0,0 +1,94 @@
"""@file models.py
@brief Plain data of the schedule: the button state, a class card and the result of a booking.
"""
from dataclasses import dataclass
from enum import Enum, auto
from snack_and_lift import constants
from snack_and_lift.domain.day_label import parse_day_label
class ButtonState(Enum):
"""@brief What the button of a class card says."""
BOOK = auto()
JOIN_WAITLIST = auto()
BOOKED = auto()
WAITLISTED = auto()
BUSY = auto()
COMPLETED = auto()
UNKNOWN = auto()
@classmethod
def from_text(cls, text: str) -> "ButtonState":
"""@brief Map the text of a button to its state.
@param text The visible text of the button (or the label of a past class).
@return The state; UNKNOWN for a text the bot does not know.
"""
return _STATE_BY_TEXT.get(text.strip(), cls.UNKNOWN)
_STATE_BY_TEXT: dict[str, ButtonState] = {
constants.BUTTON_TEXT_BOOK: ButtonState.BOOK,
constants.BUTTON_TEXT_JOIN_WAITLIST: ButtonState.JOIN_WAITLIST,
constants.BUTTON_TEXT_BOOKED: ButtonState.BOOKED,
constants.BUTTON_TEXT_WAITLISTED: ButtonState.WAITLISTED,
constants.BUTTON_TEXT_BOOKING: ButtonState.BUSY,
constants.BUTTON_TEXT_JOINING: ButtonState.BUSY,
constants.BUTTON_TEXT_COMPLETED: ButtonState.COMPLETED,
}
@dataclass(frozen=True, slots=True)
class ClassCard:
"""@brief One class of the schedule, as read from the page.
`card_id` is the id of the card without the `class-card-` prefix (for example
"123"); the site builds the ids of the card's other elements from the same suffix.
"""
card_id: str
name: str
day_label: str
time_text: str
button_text: str
@property
def state(self) -> ButtonState:
"""@brief The state of the button."""
return ButtonState.from_text(self.button_text)
@property
def weekday(self) -> str:
"""@brief The weekday of the day group (`Tue`).
@exception PageFormatError The day label is not in a known form.
"""
return parse_day_label(self.day_label)[0]
@property
def date_text(self) -> str:
"""@brief The date of the day group (`Tue, Aug 12`).
@exception PageFormatError The day label is not in a known form.
"""
return parse_day_label(self.day_label)[1]
class Outcome(Enum):
"""@brief What the booking did, or found, for a class."""
NEW_BOOKING = auto()
NEW_WAITLIST = auto()
ALREADY_BOOKED = auto()
ALREADY_WAITLISTED = auto()
@dataclass(frozen=True, slots=True)
class ClassResult:
"""@brief The class that was handled and what happened to it."""
card: ClassCard
outcome: Outcome
+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)
+159
View File
@@ -0,0 +1,159 @@
"""@file rules.py
@brief The booking rules: which class is the target, what to do for a button state, what to say.
"""
import re
from collections.abc import Iterable
from enum import Enum, auto
from snack_and_lift import constants
from snack_and_lift.domain.day_label import parse_day_label
from snack_and_lift.domain.errors import PageFormatError, UnknownButtonError
from snack_and_lift.domain.models import ButtonState, ClassCard, Outcome
__all__ = [
"CONFIRMED_STATE",
"OUTCOME_OF_ACTION",
"Action",
"decide_outcome_action",
"find_next_class",
"find_target_classes",
"format_outcome",
"is_target_time",
"parse_day_label",
"parse_time",
]
_TIME = re.compile(
rf"(?:{re.escape(constants.TIME_TEXT_PREFIX)}\s*)?(\d{{1,2}}):(\d{{2}})\s*(AM|PM)",
re.IGNORECASE,
)
_MAX_CLOCK_HOUR = 12
_MAX_MINUTE = 59
class Action(Enum):
"""@brief What the bot does for a class card."""
CLICK_BOOK = auto()
CLICK_WAITLIST = auto()
ALREADY_BOOKED = auto()
ALREADY_WAITLISTED = auto()
## The outcome that follows from each action.
OUTCOME_OF_ACTION: dict[Action, Outcome] = {
Action.CLICK_BOOK: Outcome.NEW_BOOKING,
Action.CLICK_WAITLIST: Outcome.NEW_WAITLIST,
Action.ALREADY_BOOKED: Outcome.ALREADY_BOOKED,
Action.ALREADY_WAITLISTED: Outcome.ALREADY_WAITLISTED,
}
## The button state that proves a click worked.
CONFIRMED_STATE: dict[Action, ButtonState] = {
Action.CLICK_BOOK: ButtonState.BOOKED,
Action.CLICK_WAITLIST: ButtonState.WAITLISTED,
}
_ACTION_OF_STATE: dict[ButtonState, Action] = {
ButtonState.BOOK: Action.CLICK_BOOK,
ButtonState.JOIN_WAITLIST: Action.CLICK_WAITLIST,
ButtonState.BOOKED: Action.ALREADY_BOOKED,
ButtonState.WAITLISTED: Action.ALREADY_WAITLISTED,
}
_MESSAGE_OF_OUTCOME: dict[Outcome, str] = {
Outcome.NEW_BOOKING: constants.MESSAGE_BOOKED,
Outcome.NEW_WAITLIST: constants.MESSAGE_WAITLISTED,
Outcome.ALREADY_BOOKED: constants.MESSAGE_ALREADY_BOOKED,
Outcome.ALREADY_WAITLISTED: constants.MESSAGE_ALREADY_WAITLISTED,
}
def parse_time(text: str) -> tuple[int, int]:
"""@brief Read a 12-hour time such as `6:00 PM`.
@param text The time, with or without the `Time:` prefix.
@return The hour (0-23) and the minute.
@exception PageFormatError The text is not a valid time.
"""
match = _TIME.fullmatch(text.strip())
if match is None:
raise PageFormatError(f"Unexpected time text: {text!r}")
hour, minute = int(match[1]), int(match[2])
if not 1 <= hour <= _MAX_CLOCK_HOUR or minute > _MAX_MINUTE:
raise PageFormatError(f"Time out of range: {text!r}")
hour %= _MAX_CLOCK_HOUR
if match[3].upper() == "PM":
hour += _MAX_CLOCK_HOUR
return hour, minute
def is_target_time(card: ClassCard) -> bool:
"""@brief Tell whether the class starts at the time the bot books.
@param card The class.
@return True at `TARGET_HOUR`:`TARGET_MINUTE`.
@exception PageFormatError The time text is not valid.
"""
return parse_time(card.time_text) == (constants.TARGET_HOUR, constants.TARGET_MINUTE)
def find_next_class(cards: Iterable[ClassCard], weekday: str) -> ClassCard | None:
"""@brief Find the first class of a weekday at the target time, in page order.
The weekday is read from each card's day label; the position in the list means nothing.
Classes already completed are skipped.
@param cards The classes in the order of the page.
@param weekday The weekday as the site abbreviates it (`Tue`).
@return The class, or None when there is none.
"""
for card in cards:
if card.state is ButtonState.COMPLETED:
continue
if card.weekday == weekday and is_target_time(card):
return card
return None
def 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:
"""@brief Decide what to do from the state of the button.
@param card The class.
@return The action.
@exception UnknownButtonError The button is busy, completed or unknown; `retry` can repeat.
"""
action = _ACTION_OF_STATE.get(card.state)
if action is None:
raise UnknownButtonError(f"Cannot act on button {card.button_text!r} of {card.name}")
return action
def format_outcome(outcome: Outcome, card: ClassCard) -> str:
"""@brief Build the console message of an outcome.
@param outcome What happened.
@param card The class; its day label is shown as the site writes it.
@return For example `✓ Successfully booked: Spin Class on Tomorrow (Thu, Aug 14)`.
"""
return _MESSAGE_OF_OUTCOME[outcome].format(name=card.name, day=card.day_label)
+33 -6
View File
@@ -1,8 +1,9 @@
"""@file browser.py """@file browser.py
@brief Creates the Chrome driver, headless when asked. @brief Creates the Chrome driver with a persistent profile, headless when asked.
""" """
import os import os
from pathlib import Path
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.options import Options
@@ -11,23 +12,49 @@ from selenium.webdriver.chrome.service import Service
from snack_and_lift import constants from snack_and_lift import constants
def create_driver(headless: bool) -> webdriver.Chrome: def default_profile_dir() -> Path:
"""@brief Start Chrome. """@brief The folder where Chrome keeps the profile of the bot.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when @return `chrome_profile` below the working directory.
these are set (the Docker image sets them); otherwise Selenium finds them. """
return Path.cwd() / constants.PROFILE_DIR_NAME
def build_options(headless: bool, profile_dir: Path, keep_open: bool) -> Options:
"""@brief Build the Chrome options (no browser is started).
@param headless True to run without a window, as in the Docker container. @param headless True to run without a window, as in the Docker container.
@return A running Chrome driver. @param profile_dir The Chrome user data folder.
@param keep_open True to leave the browser open when the program ends.
@return The options.
""" """
options = Options() options = Options()
options.add_argument(f"--user-data-dir={profile_dir}")
options.add_argument(f"--window-size={constants.WINDOW_SIZE}") options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if keep_open:
options.add_experimental_option("detach", True)
if headless: if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS: for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument) options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV) binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary: if binary:
options.binary_location = binary options.binary_location = binary
return options
def create_driver(headless: bool, profile_dir: Path, keep_open: bool) -> webdriver.Chrome:
"""@brief Start Chrome.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@param profile_dir The Chrome user data folder; it is created when missing.
@param keep_open True to leave the browser open when the program ends.
@return A running Chrome driver.
"""
profile_dir.mkdir(parents=True, exist_ok=True)
options = build_options(headless, profile_dir, keep_open)
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV) driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service() service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options) return webdriver.Chrome(service=service, options=options)
@@ -0,0 +1,35 @@
"""@file console_recorder.py
@brief Keeps the lines the bot logs, so they can be drawn as the lecture's console screenshot.
"""
import logging
class ConsoleRecorder(logging.Handler):
"""@brief A logging handler that stores the formatted message of every record."""
def __init__(self, level: int = logging.NOTSET) -> None:
"""@brief Create an empty recorder.
@param level The lowest level to keep.
"""
super().__init__(level)
self.setFormatter(logging.Formatter("%(message)s"))
self._lines: list[str] = []
@property
def lines(self) -> list[str]:
"""@brief The recorded lines, oldest first (a copy)."""
return list(self._lines)
@property
def text(self) -> str:
"""@brief The recorded lines joined by newlines."""
return "\n".join(self._lines)
def emit(self, record: logging.LogRecord) -> None:
"""@brief Store one record.
@param record The log record.
"""
self._lines.append(self.format(record))
@@ -0,0 +1,194 @@
"""@file selenium_gym.py
@brief The Selenium adapter of the GymPort.
"""
import functools
import html
import logging
import urllib.parse
from collections.abc import Callable
from pathlib import Path
from string import Template
from typing import Concatenate, Literal
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from snack_and_lift import constants
from snack_and_lift.domain.errors import GymError, LoginFailedError
from snack_and_lift.domain.models import ButtonState, ClassCard
logger = logging.getLogger(__name__)
_SELENIUM_ERRORS = (
TimeoutException,
NoSuchElementException,
StaleElementReferenceException,
ElementClickInterceptedException,
)
def _translate_errors[S, **P, R](
method: Callable[Concatenate[S, P], R],
) -> Callable[Concatenate[S, P], R]:
"""@brief Turn Selenium errors of a method into GymError.
@param method The adapter method to wrap.
@return The wrapped method.
"""
@functools.wraps(method)
def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
try:
return method(self, *args, **kwargs)
except _SELENIUM_ERRORS as error:
raise GymError(f"{method.__name__} failed: {type(error).__name__}") from error
return wrapper
class SeleniumGym:
"""@brief The gym site driven by a Selenium WebDriver."""
def __init__(self, driver: WebDriver, base_url: str = constants.GYM_URL) -> None:
"""@brief Wrap a driver.
@param driver A running WebDriver.
@param base_url The address of the home page.
"""
self._driver = driver
self._base_url = base_url
self._wait = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS)
@_translate_errors
def open_site(self) -> None:
"""@brief Open the home page and wait for the login button.
@exception GymError The page did not load.
"""
self._driver.get(self._base_url)
self._wait.until(ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)))
@_translate_errors
def log_in(self, email: str, password: str) -> None:
"""@brief Fill in the login form and wait for the schedule or an error.
@param email The account e-mail.
@param password The account password.
@exception LoginFailedError The site shows a login error.
@exception GymError The page did not behave as expected.
"""
self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click()
self._wait.until(
ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
).send_keys(email)
self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password)
self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click()
self._wait.until(
ec.any_of(
ec.presence_of_element_located((By.ID, constants.SCHEDULE_PAGE_ID)),
ec.presence_of_element_located((By.ID, constants.ERROR_MESSAGE_ID)),
)
)
errors = self._driver.find_elements(By.ID, constants.ERROR_MESSAGE_ID)
if errors:
raise LoginFailedError(errors[0].text)
@_translate_errors
def list_class_cards(self) -> list[ClassCard]:
"""@brief Read every class card; the day comes from the ancestor day group.
@return The classes in page order.
@exception GymError The schedule could not be read.
"""
self._wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, constants.CLASS_CARD_CSS))
)
elements = self._driver.find_elements(By.CSS_SELECTOR, constants.CLASS_CARD_CSS)
return [self._read_card(element) for element in elements]
def _read_card(self, element: WebElement) -> ClassCard:
"""@brief Turn one card element into plain data.
@param element The card.
@return The class.
"""
card_id = (element.get_attribute("id") or "").removeprefix(constants.CLASS_CARD_ID_PREFIX)
buttons = element.find_elements(By.ID, constants.BOOK_BUTTON_ID_PREFIX + card_id)
labels = element.find_elements(By.ID, constants.COMPLETED_LABEL_ID_PREFIX + card_id)
control = (buttons or labels or [None])[0]
return ClassCard(
card_id=card_id,
name=element.find_element(By.ID, constants.CLASS_NAME_ID_PREFIX + card_id).text,
day_label=element.find_element(By.XPATH, constants.DAY_TITLE_XPATH).text,
time_text=element.find_element(By.ID, constants.CLASS_TIME_ID_PREFIX + card_id).text,
button_text=control.text if control is not None else "",
)
@_translate_errors
def press_class_button(self, card_id: str) -> str:
"""@brief Scroll to the button, click it and wait for the result or the error box.
@param card_id The id of the card without its prefix.
@return The new text of the button.
@exception GymError The site shows an error for the class, or nothing settled in time.
"""
button_id = constants.BOOK_BUTTON_ID_PREFIX + card_id
button = self._wait.until(ec.presence_of_element_located((By.ID, button_id)))
self._driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", button)
before = button.text
self._wait.until(ec.element_to_be_clickable((By.ID, button_id))).click()
def settled(driver: WebDriver) -> tuple[str, str] | Literal[False]:
errors = driver.find_elements(By.ID, constants.CLASS_ERROR_ID_PREFIX + card_id)
text = driver.find_element(By.ID, button_id).text
if errors:
return text, errors[0].text or "class error"
if text != before and ButtonState.from_text(text) is not ButtonState.BUSY:
return text, ""
return False
text, error = self._wait.until(settled)
if error:
raise GymError(error)
return text
@_translate_errors
def save_text_screenshot(self, text: str, path: Path) -> None:
"""@brief Draw console text in a temporary tab and save a screenshot of it.
A tab is used so that the schedule page keeps its state.
@param text The lines to draw.
@param path The PNG file to write; its folders are created.
"""
page = Template(constants.CONSOLE_PAGE_TEMPLATE).substitute(text=html.escape(text))
original = self._driver.current_window_handle
self._driver.switch_to.new_window("tab")
try:
self._driver.get(constants.CONSOLE_PAGE_URL_PREFIX + urllib.parse.quote(page))
self.save_screenshot(path)
finally:
self._driver.close()
self._driver.switch_to.window(original)
def save_screenshot(self, path: Path) -> None:
"""@brief Save a screenshot of the current page.
@param path The PNG file to write; its folders are created.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@brief Quit the browser."""
self._driver.quit()
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym home</title></head>
<body>
<nav><a href="login.html"><button id="login-button">Login</button></a></nav>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym login</title></head>
<body>
<form id="login-form">
<input id="email-input" type="email">
<input id="password-input" type="password">
<button id="submit-button" type="submit">Log in</button>
</form>
<div id="message-area"></div>
<script>
// Dummy account of this fixture only; it is not a real account.
const ACCEPTED_EMAIL = "fixture@example.invalid";
const ACCEPTED_PASSWORD = "fixture-password";
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
const email = document.getElementById("email-input").value;
const password = document.getElementById("password-input").value;
if (email === ACCEPTED_EMAIL && password === ACCEPTED_PASSWORD) {
window.location.href = "schedule.html";
} else {
document.getElementById("message-area").innerHTML =
'<div id="error-message">Invalid email or password</div>';
}
});
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym schedule</title></head>
<body>
<div id="schedule-page">
<h1>Class Schedule (Today: Wed, Aug 13)</h1>
<select id="day-filter"><option>Next 7 Days</option></select>
<div id="day-group-wed-aug-13">
<h2 id="day-title-wed-aug-13">Today (Wed, Aug 13)</h2>
<div id="class-card-101" data-class-type="yoga" data-user-booked="false">
<h3 id="class-name-101">Yoga Class</h3>
<p id="class-time-101"><strong>Time:</strong> 6:00 AM</p>
<span id="completed-label-101">Completed</span>
</div>
<div id="class-card-102" data-class-type="spin" data-user-booked="false">
<h3 id="class-name-102">Spin Class</h3>
<p id="class-time-102"><strong>Time:</strong> 6:00 PM</p>
<button id="book-button-102">Book Class</button>
</div>
</div>
<div id="day-group-thu-aug-14">
<h2 id="day-title-thu-aug-14">Tomorrow (Thu, Aug 14)</h2>
<div id="class-card-201" data-class-type="hiit" data-user-booked="false">
<h3 id="class-name-201">HIIT Class</h3>
<p id="class-time-201"><strong>Time:</strong> 6:00 PM</p>
<button id="book-button-201">Join Waitlist</button>
</div>
</div>
<div id="day-group-fri-aug-15">
<h2 id="day-title-fri-aug-15">Fri, Aug 15</h2>
<div id="class-card-401" data-class-type="hiit" data-user-booked="true">
<h3 id="class-name-401">HIIT Class</h3>
<p id="class-time-401"><strong>Time:</strong> 5:00 PM</p>
<button id="book-button-401" disabled>Booked</button>
</div>
</div>
<div id="day-group-tue-aug-19">
<h2 id="day-title-tue-aug-19">Tue, Aug 19</h2>
<div id="class-card-301" data-class-type="yoga" data-user-booked="false">
<h3 id="class-name-301">Yoga Class</h3>
<p id="class-time-301"><strong>Time:</strong> 7:00 AM</p>
<button id="book-button-301">Book Class</button>
</div>
<div id="class-card-302" data-class-type="spin" data-user-booked="false" data-fail-once="true">
<h3 id="class-name-302">Spin Class</h3>
<p id="class-time-302"><strong>Time:</strong> 6:00 PM</p>
<button id="book-button-302">Book Class</button>
</div>
</div>
</div>
<script>
// Imitates the site: a busy text, then the new state; card 302 fails on its first click.
document.querySelectorAll("button[id^='book-button-']").forEach(function (button) {
button.addEventListener("click", function () {
const id = button.id.replace("book-button-", "");
const card = document.getElementById("class-card-" + id);
const oldText = button.textContent;
const stale = document.getElementById("class-error-" + id);
if (stale) { stale.remove(); }
const waitlist = oldText === "Join Waitlist";
button.textContent = waitlist ? "Joining..." : "Booking...";
button.disabled = true;
setTimeout(function () {
if (card.dataset.failOnce === "true") {
card.dataset.failOnce = "false";
button.textContent = oldText;
button.disabled = false;
const box = document.createElement("div");
box.id = "class-error-" + id;
box.textContent = "Network error, please try again";
card.appendChild(box);
return;
}
button.textContent = waitlist ? "Waitlisted" : "Booked";
}, 200);
});
});
</script>
</body>
</html>
+3 -1
View File
@@ -3,6 +3,8 @@
""" """
import os import os
import tempfile
from pathlib import Path
import pytest import pytest
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -22,7 +24,7 @@ pytestmark = [
def test_gym_home_page_shows_the_login_button() -> None: def test_gym_home_page_shows_the_login_button() -> None:
driver = create_driver(headless=True) driver = create_driver(headless=True, profile_dir=Path(tempfile.mkdtemp()), keep_open=False)
try: try:
driver.get(constants.GYM_URL) driver.get(constants.GYM_URL)
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until( button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
+128
View File
@@ -0,0 +1,128 @@
"""@file test_selenium_gym.py
@brief The Selenium adapter against local HTML fixtures; runs only in the Docker container.
"""
import os
from collections.abc import Iterator
from pathlib import Path
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.models import Outcome
from snack_and_lift.infrastructure.browser import create_driver
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
pytestmark = [
pytest.mark.browser,
pytest.mark.skipif(
os.environ.get("SNACK_AND_LIFT_E2E") != "1",
reason="needs a real browser; run it in the Docker container",
),
]
FIXTURE_HOME = (Path(__file__).parent / "fixtures" / "home.html").as_uri()
# Dummy account that the fixture login.html accepts; it is not a real account.
FIXTURE_EMAIL = "fixture@example.invalid"
FIXTURE_PASSWORD = "fixture-password"
@pytest.fixture
def gym(tmp_path: Path) -> Iterator[SeleniumGym]:
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
try:
yield SeleniumGym(driver, base_url=FIXTURE_HOME)
finally:
driver.quit()
def test_log_in_succeeds_with_accepted_credentials(gym: SeleniumGym) -> None:
gym.open_site()
gym.log_in(FIXTURE_EMAIL, FIXTURE_PASSWORD)
def test_log_in_raises_for_wrong_credentials(gym: SeleniumGym) -> None:
gym.open_site()
with pytest.raises(LoginFailedError, match="Invalid"):
gym.log_in(FIXTURE_EMAIL, "wrong-password")
def test_save_screenshot_creates_the_folder(gym: SeleniumGym, tmp_path: Path) -> None:
gym.open_site()
target = tmp_path / "shots" / "step-1.png"
gym.save_screenshot(target)
assert target.stat().st_size > 0
FIXTURE_SCHEDULE = (Path(__file__).parent / "fixtures" / "schedule.html").as_uri()
@pytest.fixture
def schedule_gym(tmp_path: Path) -> Iterator[SeleniumGym]:
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
try:
driver.get(FIXTURE_SCHEDULE)
yield SeleniumGym(driver, base_url=FIXTURE_SCHEDULE)
finally:
driver.quit()
def test_list_class_cards_reads_the_day_from_the_ancestor_group(schedule_gym: SeleniumGym) -> None:
cards = {card.card_id: card for card in schedule_gym.list_class_cards()}
assert list(cards) == ["101", "102", "201", "401", "301", "302"]
assert cards["102"].day_label == "Today (Wed, Aug 13)"
assert cards["201"].weekday == "Thu"
assert cards["302"].weekday == "Tue"
assert cards["302"].name == "Spin Class"
assert cards["302"].time_text == "Time: 6:00 PM"
assert cards["302"].button_text == "Book Class"
assert cards["101"].button_text == "Completed"
assert cards["401"].button_text == "Booked"
def test_press_class_button_confirms_booking(schedule_gym: SeleniumGym) -> None:
assert schedule_gym.press_class_button("102") == "Booked"
def test_press_class_button_confirms_waitlist(schedule_gym: SeleniumGym) -> None:
assert schedule_gym.press_class_button("201") == "Waitlisted"
def test_press_class_button_raises_with_the_error_text_then_succeeds(
schedule_gym: SeleniumGym,
) -> None:
with pytest.raises(GymError, match="Network error"):
schedule_gym.press_class_button("302")
assert schedule_gym.press_class_button("302") == "Booked"
def test_save_text_screenshot_writes_a_png_and_keeps_the_page(
schedule_gym: SeleniumGym, tmp_path: Path
) -> None:
target = tmp_path / "shots" / "step-4.png"
schedule_gym.save_text_screenshot("✓ Successfully booked: <Spin Class>", target)
assert target.read_bytes().startswith(b"\x89PNG")
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),
]
+71
View File
@@ -0,0 +1,71 @@
"""@file fakes.py
@brief A fake of the GymPort for the unit tests.
"""
from pathlib import Path
from snack_and_lift import constants
from snack_and_lift.domain.errors import GymError, LoginFailedError
from snack_and_lift.domain.models import ClassCard
_PRESS_RESULTS = {
constants.BUTTON_TEXT_BOOK: constants.BUTTON_TEXT_BOOKED,
constants.BUTTON_TEXT_JOIN_WAITLIST: constants.BUTTON_TEXT_WAITLISTED,
}
class FakeGym:
"""@brief Records the calls; can refuse the login, serve cards and fail a click."""
def __init__(
self,
login_error: str | None = None,
cards: list[ClassCard] | None = None,
press_result: str | None = None,
press_error: str | None = None,
) -> None:
self.login_error = login_error
self.cards = cards or []
self.press_result = press_result
self.press_error = press_error
self.calls: list[str] = []
self.logins: list[tuple[str, str]] = []
self.screenshots: list[Path] = []
self.text_screenshots: list[tuple[str, Path]] = []
self.pressed: list[str] = []
self.closed = False
def open_site(self) -> None:
self.calls.append("open_site")
def log_in(self, email: str, password: str) -> None:
self.calls.append("log_in")
self.logins.append((email, password))
if self.login_error is not None:
raise LoginFailedError(self.login_error)
def list_class_cards(self) -> list[ClassCard]:
self.calls.append("list_class_cards")
return list(self.cards)
def press_class_button(self, card_id: str) -> str:
self.calls.append("press_class_button")
self.pressed.append(card_id)
if self.press_error is not None:
raise GymError(self.press_error)
if self.press_result is not None:
return self.press_result
card = next(card for card in self.cards if card.card_id == card_id)
return _PRESS_RESULTS[card.button_text]
def save_text_screenshot(self, text: str, path: Path) -> None:
self.calls.append("save_text_screenshot")
self.text_screenshots.append((text, path))
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
def close(self) -> None:
self.calls.append("close")
self.closed = True
+150
View File
@@ -0,0 +1,150 @@
"""@file test_booking.py
@brief Booking a class and booking the next Tuesday class, against a fake gym.
"""
import logging
import pytest
from tests.unit.fakes import FakeGym
from tests.unit.test_models import make_card
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.models import Outcome
@pytest.mark.parametrize(
("button_text", "outcome", "message"),
[
("Book Class", Outcome.NEW_BOOKING, "✓ Successfully booked: Spin Class on"),
("Join Waitlist", Outcome.NEW_WAITLIST, "✓ Joined waitlist for: Spin Class on"),
],
)
def test_book_class_clicks_and_confirms(
button_text: str, outcome: Outcome, message: str, caplog: pytest.LogCaptureFixture
) -> None:
card = make_card(button_text=button_text)
gym = FakeGym(cards=[card])
with caplog.at_level(logging.INFO):
result = book_class(gym, card)
assert result.outcome is outcome
assert gym.pressed == ["123"]
assert message in caplog.text
@pytest.mark.parametrize(
("button_text", "outcome"),
[("Booked", Outcome.ALREADY_BOOKED), ("Waitlisted", Outcome.ALREADY_WAITLISTED)],
)
def test_book_class_does_not_click_when_already_done(button_text: str, outcome: Outcome) -> None:
gym = FakeGym()
result = book_class(gym, make_card(button_text=button_text))
assert result.outcome is outcome
assert gym.pressed == []
def test_book_class_raises_when_click_is_not_confirmed() -> None:
card = make_card()
gym = FakeGym(cards=[card], press_result="Book Class")
with pytest.raises(GymError, match="click not confirmed"):
book_class(gym, card)
def test_book_class_raises_when_waitlist_click_gives_booked() -> None:
card = make_card(button_text="Join Waitlist")
with pytest.raises(GymError, match="click not confirmed"):
book_class(FakeGym(cards=[card], press_result="Booked"), card)
def test_book_class_propagates_a_site_error() -> None:
with pytest.raises(GymError, match="Network down"):
book_class(FakeGym(cards=[make_card()], press_error="Network down"), make_card())
def test_book_class_raises_for_a_busy_button() -> None:
with pytest.raises(UnknownButtonError):
book_class(FakeGym(), make_card(button_text="Booking..."))
def test_book_next_tuesday_books_the_first_tuesday_class() -> None:
cards = [
make_card("Today (Wed, Aug 13)", card_id="1"),
make_card("Tue, Aug 19", card_id="2"),
make_card("Tue, Aug 26", card_id="3"),
]
gym = FakeGym(cards=cards)
result = book_next_tuesday(gym)
assert result is not None
assert result.card.card_id == "2"
assert gym.pressed == ["2"]
def test_book_next_tuesday_logs_when_there_is_no_class(caplog: pytest.LogCaptureFixture) -> None:
gym = FakeGym(cards=[make_card("Wed, Aug 13")])
with caplog.at_level(logging.INFO):
assert book_next_tuesday(gym) is None
assert "No Tue class at 18:00 found" in caplog.text
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)
+50
View File
@@ -0,0 +1,50 @@
"""@file test_browser_options.py
@brief The Chrome options are built correctly (no browser is started).
"""
from pathlib import Path
import pytest
from snack_and_lift import constants
from snack_and_lift.infrastructure.browser import build_options, default_profile_dir
def test_profile_dir_is_passed_to_chrome(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert f"--user-data-dir={tmp_path}" in options.arguments
def test_default_profile_dir_is_below_the_working_directory() -> None:
assert default_profile_dir() == Path.cwd() / constants.PROFILE_DIR_NAME
def test_keep_open_detaches_the_browser(tmp_path: Path) -> None:
assert build_options(False, tmp_path, True).experimental_options["detach"] is True
def test_browser_is_not_detached_when_not_kept_open(tmp_path: Path) -> None:
assert "detach" not in build_options(False, tmp_path, False).experimental_options
def test_headless_adds_the_headless_arguments(tmp_path: Path) -> None:
options = build_options(True, tmp_path, False)
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
assert argument in options.arguments
def test_window_has_no_headless_arguments_by_default(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert "--headless=new" not in options.arguments
assert f"--window-size={constants.WINDOW_SIZE}" in options.arguments
def test_chrome_binary_comes_from_the_environment(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv(constants.CHROME_BIN_ENV, "/opt/chrome")
assert build_options(False, tmp_path, False).binary_location == "/opt/chrome"
+239 -3
View File
@@ -1,8 +1,26 @@
"""@file test_cli.py """@file test_cli.py
@brief The command line accepts its options and the entry point returns 0. @brief The command line accepts its options and the entry point wires the use case.
""" """
import logging
from pathlib import Path
import pytest
from tests.unit.fakes import FakeGym
from tests.unit.test_models import make_card
from snack_and_lift import constants
from snack_and_lift.cli import main, parse_args from snack_and_lift.cli import main, parse_args
from snack_and_lift.infrastructure.browser import default_profile_dir
DUMMY_EMAIL = "dummy@example.invalid"
DUMMY_PASSWORD = "dummy-password"
@pytest.fixture
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
def test_headless_is_off_by_default() -> None: def test_headless_is_off_by_default() -> None:
@@ -13,5 +31,223 @@ def test_headless_flag_is_read() -> None:
assert parse_args(["--headless"]).headless is True assert parse_args(["--headless"]).headless is True
def test_main_returns_zero() -> None: def test_browser_stays_open_by_default_when_not_headless() -> None:
assert main([]) == 0 assert parse_args([]).keep_open is True
def test_browser_is_closed_by_default_when_headless() -> None:
assert parse_args(["--headless"]).keep_open is False
def test_keep_open_flag_overrides_headless_default() -> None:
assert parse_args(["--headless", "--keep-open"]).keep_open is True
def test_profile_dir_defaults_to_working_directory() -> None:
assert parse_args([]).profile_dir == default_profile_dir()
def test_profile_dir_option_is_read(tmp_path: Path) -> None:
assert parse_args(["--profile-dir", str(tmp_path)]).profile_dir == tmp_path
def test_screenshot_dir_is_optional() -> None:
assert parse_args([]).screenshot_dir is None
@pytest.mark.usefixtures("credentials_env")
def test_main_saves_both_screenshots_on_success(tmp_path: Path) -> None:
gym = FakeGym()
code = main(["--screenshot-dir", str(tmp_path)], lambda *_: gym)
assert code == 0
assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)]
assert gym.screenshots == [
tmp_path / "step-1.png",
tmp_path / "step-2.png",
tmp_path / "step-3.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 == [
"open_site",
"save_screenshot",
"log_in",
"save_screenshot",
"list_class_cards",
"save_screenshot",
"save_text_screenshot",
"save_text_screenshot",
"save_text_screenshot",
]
@pytest.mark.usefixtures("credentials_env")
def test_main_books_the_class_and_draws_the_console_text(tmp_path: Path) -> None:
gym = FakeGym(cards=[make_card("Tue, Aug 19")])
assert main(["--screenshot-dir", str(tmp_path)], lambda *_: gym) == 0
assert gym.pressed == ["123"]
text, _ = gym.text_screenshots[0]
assert "✓ Logged in" 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")
def test_main_returns_1_when_the_booking_fails() -> None:
gym = FakeGym(cards=[make_card("Tue, Aug 19")], press_error="Network down")
assert main(["--headless"], lambda *_: gym) == 1
assert gym.closed is True
@pytest.mark.usefixtures("credentials_env")
def test_main_saves_no_screenshot_without_directory() -> None:
gym = FakeGym()
assert main([], lambda *_: gym) == 0
assert gym.screenshots == []
def test_main_returns_2_when_credentials_are_missing(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
gym = FakeGym()
code = main([], lambda *_: gym)
assert code == 2
assert gym.calls == []
assert constants.ACCOUNT_EMAIL_ENV in caplog.text
@pytest.mark.usefixtures("credentials_env")
def test_main_returns_1_when_login_fails() -> None:
gym = FakeGym(login_error="Invalid credentials")
assert main(["--headless"], lambda *_: gym) == 1
assert gym.closed is True
@pytest.mark.usefixtures("credentials_env")
def test_main_closes_the_gym_when_not_kept_open() -> None:
gym = FakeGym()
assert main(["--headless"], lambda *_: gym) == 0
assert gym.closed is True
@pytest.mark.usefixtures("credentials_env")
def test_main_keeps_the_gym_open_when_asked() -> None:
gym = FakeGym()
assert main([], lambda *_: gym) == 0
assert gym.closed is False
@pytest.mark.usefixtures("credentials_env")
def test_main_passes_options_to_the_factory(tmp_path: Path) -> None:
received: list[tuple[bool, Path, bool]] = []
def factory(headless: bool, profile_dir: Path, keep_open: bool) -> FakeGym:
received.append((headless, profile_dir, keep_open))
return FakeGym()
main(["--headless", "--profile-dir", str(tmp_path)], factory)
assert received == [(True, tmp_path, False)]
+30
View File
@@ -0,0 +1,30 @@
"""@file test_console_recorder.py
@brief The recorder keeps the formatted lines of the log.
"""
import logging
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
def test_recorder_keeps_formatted_messages() -> None:
logger = logging.getLogger("test_console_recorder")
logger.setLevel(logging.INFO)
recorder = ConsoleRecorder()
logger.addHandler(recorder)
try:
logger.info("✓ Logged %s", "in")
logger.warning("careful")
finally:
logger.removeHandler(recorder)
assert recorder.lines == ["✓ Logged in", "careful"]
assert recorder.text == "✓ Logged in\ncareful"
def test_recorder_lines_are_a_copy() -> None:
recorder = ConsoleRecorder()
recorder.lines.append("x")
assert recorder.lines == []
+14
View File
@@ -9,6 +9,20 @@ def test_gym_address_is_https() -> None:
assert constants.GYM_URL.startswith("https://") assert constants.GYM_URL.startswith("https://")
def test_credentials_are_read_from_named_environment_variables() -> None:
assert constants.ACCOUNT_EMAIL_ENV == "SNACK_AND_LIFT_EMAIL"
assert constants.ACCOUNT_PASSWORD_ENV == "SNACK_AND_LIFT_PASSWORD"
def test_login_element_ids() -> None:
assert constants.NAV_LOGIN_BUTTON_ID == "login-button"
assert constants.LOGIN_EMAIL_INPUT_ID == "email-input"
assert constants.LOGIN_PASSWORD_INPUT_ID == "password-input"
assert constants.LOGIN_SUBMIT_BUTTON_ID == "submit-button"
assert constants.ERROR_MESSAGE_ID == "error-message"
assert constants.SCHEDULE_PAGE_ID == "schedule-page"
def test_retry_count_is_seven() -> None: def test_retry_count_is_seven() -> None:
assert constants.RETRY_COUNT == 7 assert constants.RETRY_COUNT == 7
+44
View File
@@ -0,0 +1,44 @@
"""@file test_credentials.py
@brief Credentials come from the environment and never show the password.
"""
import pytest
from snack_and_lift import constants
from snack_and_lift.application.credentials import Credentials, load_credentials
from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value"
def test_load_credentials_reads_both_variables() -> None:
environment = {
constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid",
constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD,
}
assert load_credentials(environment) == Credentials("dummy@example.invalid", DUMMY_PASSWORD)
@pytest.mark.parametrize(
"environment",
[
{},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid"},
{constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "", constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid", constants.ACCOUNT_PASSWORD_ENV: ""},
],
)
def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str]) -> None:
with pytest.raises(MissingCredentialsError) as caught:
load_credentials(environment)
message = str(caught.value)
assert constants.ACCOUNT_EMAIL_ENV in message
assert constants.ACCOUNT_PASSWORD_ENV in message
assert DUMMY_PASSWORD not in message
def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
+57
View File
@@ -0,0 +1,57 @@
"""@file test_models.py
@brief Button states and the values derived from a class card.
"""
import dataclasses
import pytest
from snack_and_lift.domain.errors import PageFormatError
from snack_and_lift.domain.models import ButtonState, ClassCard
def make_card(
day_label: str = "Tomorrow (Thu, Aug 14)",
time_text: str = "Time: 6:00 PM",
button_text: str = "Book Class",
card_id: str = "123",
name: str = "Spin Class",
) -> ClassCard:
return ClassCard(card_id, name, day_label, time_text, button_text)
@pytest.mark.parametrize(
("text", "state"),
[
("Book Class", ButtonState.BOOK),
("Join Waitlist", ButtonState.JOIN_WAITLIST),
("Booked", ButtonState.BOOKED),
("Waitlisted", ButtonState.WAITLISTED),
("Booking...", ButtonState.BUSY),
("Joining...", ButtonState.BUSY),
("Completed", ButtonState.COMPLETED),
(" Booked ", ButtonState.BOOKED),
("", ButtonState.UNKNOWN),
("Cancel", ButtonState.UNKNOWN),
],
)
def test_button_state_is_read_from_text(text: str, state: ButtonState) -> None:
assert ButtonState.from_text(text) is state
def test_card_derives_state_weekday_and_date() -> None:
card = make_card()
assert card.state is ButtonState.BOOK
assert card.weekday == "Thu"
assert card.date_text == "Thu, Aug 14"
def test_card_with_bad_day_label_raises() -> None:
with pytest.raises(PageFormatError):
_ = make_card(day_label="Someday").weekday
def test_card_is_immutable() -> None:
with pytest.raises(dataclasses.FrozenInstanceError):
make_card().name = "Other" # type: ignore[misc]
+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"
+164
View File
@@ -0,0 +1,164 @@
"""@file test_rules.py
@brief Day and time parsing, finding the target class, the decision and the messages.
"""
import pytest
from tests.unit.test_models import make_card
from snack_and_lift.domain.errors import PageFormatError, UnknownButtonError
from snack_and_lift.domain.models import Outcome
from snack_and_lift.domain.rules import (
Action,
decide_outcome_action,
find_next_class,
find_target_classes,
format_outcome,
is_target_time,
parse_day_label,
parse_time,
)
@pytest.mark.parametrize(
("label", "expected"),
[
("Today (Tue, Aug 12)", ("Tue", "Tue, Aug 12")),
("Tomorrow (Wed, Aug 13)", ("Wed", "Wed, Aug 13")),
("Thu, Aug 14", ("Thu", "Thu, Aug 14")),
(" Fri, Sep 5 ", ("Fri", "Fri, Sep 5")),
],
)
def test_day_label_is_split(label: str, expected: tuple[str, str]) -> None:
assert parse_day_label(label) == expected
@pytest.mark.parametrize("label", ["", "Tuesday", "Today Tue, Aug 12", "Today (Tue, Aug 12", "x"])
def test_day_label_junk_raises(label: str) -> None:
with pytest.raises(PageFormatError):
parse_day_label(label)
@pytest.mark.parametrize(
("text", "expected"),
[
("6:00 PM", (18, 0)),
("Time: 6:00 PM", (18, 0)),
("12:00 AM", (0, 0)),
("12:30 AM", (0, 30)),
("12:00 PM", (12, 0)),
("11:59 PM", (23, 59)),
("1:05 am", (1, 5)),
],
)
def test_time_is_read(text: str, expected: tuple[int, int]) -> None:
assert parse_time(text) == expected
@pytest.mark.parametrize("text", ["", "noon", "6 PM", "0:00 AM", "13:00 PM", "6:60 PM", "18:00"])
def test_time_junk_raises(text: str) -> None:
with pytest.raises(PageFormatError):
parse_time(text)
def test_target_time_is_six_pm() -> None:
assert is_target_time(make_card(time_text="Time: 6:00 PM"))
assert not is_target_time(make_card(time_text="Time: 6:00 AM"))
assert not is_target_time(make_card(time_text="Time: 6:30 PM"))
def test_next_class_is_found_when_the_schedule_starts_on_a_wednesday() -> None:
cards = [
make_card("Today (Wed, Aug 13)", card_id="1"),
make_card("Thu, Aug 14", card_id="2"),
make_card("Tue, Aug 19", time_text="Time: 7:00 AM", card_id="3"),
make_card("Tue, Aug 19", card_id="4"),
make_card("Tue, Aug 26", card_id="5"),
]
found = find_next_class(cards, "Tue")
assert found is not None
assert found.card_id == "4"
def test_next_class_accepts_a_today_label() -> None:
cards = [make_card("Today (Tue, Aug 12)", card_id="9")]
found = find_next_class(cards, "Tue")
assert found is not None
assert found.card_id == "9"
def test_next_class_skips_completed_classes() -> None:
cards = [
make_card("Today (Tue, Aug 12)", button_text="Completed", card_id="1"),
make_card("Tue, Aug 19", card_id="2"),
]
found = find_next_class(cards, "Tue")
assert found is not None
assert found.card_id == "2"
def test_next_class_is_none_without_a_match() -> None:
assert find_next_class([make_card("Thu, Aug 14")], "Tue") is None
assert find_next_class([], "Tue") is None
@pytest.mark.parametrize(
("button_text", "action"),
[
("Book Class", Action.CLICK_BOOK),
("Join Waitlist", Action.CLICK_WAITLIST),
("Booked", Action.ALREADY_BOOKED),
("Waitlisted", Action.ALREADY_WAITLISTED),
],
)
def test_action_follows_the_button_state(button_text: str, action: Action) -> None:
assert decide_outcome_action(make_card(button_text=button_text)) is action
@pytest.mark.parametrize("button_text", ["Booking...", "Joining...", "Completed", "???"])
def test_action_for_busy_or_unknown_button_raises(button_text: str) -> None:
with pytest.raises(UnknownButtonError):
decide_outcome_action(make_card(button_text=button_text))
@pytest.mark.parametrize(
("outcome", "message"),
[
(Outcome.NEW_BOOKING, "✓ Successfully booked: Spin Class on Tomorrow (Thu, Aug 14)"),
(Outcome.NEW_WAITLIST, "✓ Joined waitlist for: Spin Class on Tomorrow (Thu, Aug 14)"),
(Outcome.ALREADY_BOOKED, "✓ Already booked: Spin Class on Tomorrow (Thu, Aug 14)"),
(Outcome.ALREADY_WAITLISTED, "✓ Already on waitlist: Spin Class on Tomorrow (Thu, Aug 14)"),
],
)
def test_outcome_message(outcome: Outcome, message: str) -> None:
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")]) == []
+27
View File
@@ -0,0 +1,27 @@
"""@file test_session.py
@brief The login use case hands the credentials to the gym site.
"""
import pytest
from tests.unit.fakes import FakeGym
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import LoginFailedError
CREDENTIALS = Credentials("dummy@example.invalid", "dummy-password")
def test_login_passes_the_credentials_to_the_gym() -> None:
gym = FakeGym()
login(gym, CREDENTIALS)
assert gym.logins == [("dummy@example.invalid", "dummy-password")]
def test_login_propagates_a_refused_login() -> None:
gym = FakeGym(login_error="Invalid credentials")
with pytest.raises(LoginFailedError, match="Invalid credentials"):
login(gym, CREDENTIALS)