Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6962decc9f | ||
|
|
332d65255c | ||
|
|
da3f999c6f | ||
|
|
42f05bdd0f | ||
|
|
434bdafefd | ||
|
|
a248541e58 | ||
|
|
034f792c1a | ||
|
|
fedbc39165 | ||
|
|
85f5eecd07 | ||
|
|
52427ca69c | ||
|
|
353d157e22 | ||
|
|
3b9e381cd4 | ||
|
|
ca6a706abe | ||
|
|
bd28943482 | ||
|
|
2c568f1fa2 | ||
|
|
cbdd553394 | ||
|
|
7a8412ad6b | ||
|
|
08ced00f62 | ||
|
|
0301fd2f0b | ||
|
|
c0aa0b4b2b | ||
|
|
6871e54333 | ||
|
|
5d28e682b4 | ||
|
|
37f3cc436a | ||
|
|
a830e3ae5f | ||
|
|
944ba204cf | ||
|
|
f57040e136 | ||
|
|
8c6cc148f5 | ||
|
|
0a665ee9ac | ||
|
|
1088a88e59 | ||
|
|
45e6fc72df | ||
|
|
2ce7e3d8dc | ||
|
|
e0b6abb662 | ||
|
|
41b0dace11 | ||
|
|
08ba464ec9 | ||
|
|
735cb73cde | ||
|
|
245dd1689e | ||
|
|
2fa9e15ab5 | ||
|
|
d40c60a957 |
@@ -0,0 +1,41 @@
|
||||
# Publish the project's wiki pages to the git host's wiki.
|
||||
#
|
||||
# On a push to main that changes docs/wiki/, this workflow calls the reusable
|
||||
# workflow of TirSystem/github-action, which copies the Markdown pages of
|
||||
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
|
||||
# what the folder holds: English and the PO language as a minimum).
|
||||
#
|
||||
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
|
||||
# does not copy it publishes nothing.
|
||||
#
|
||||
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
|
||||
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
|
||||
# repository: a Gitea token with scope write:repository for a user that
|
||||
# can write this repository (see guides/credentials.md of github-action).
|
||||
# 2. Enable the wiki in the repository settings.
|
||||
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
|
||||
# A runner must be registered for the repository (or its organization).
|
||||
#
|
||||
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
|
||||
# always equals the folder. Leave it "false" to keep hand-written pages.
|
||||
|
||||
name: Publish wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "docs/wiki/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wiki:
|
||||
uses: TirSystem/git-workflows/.gitea/workflows/publish-wiki-pages.yml@v1
|
||||
with:
|
||||
wiki_dir: docs/wiki
|
||||
prune: "true"
|
||||
secrets:
|
||||
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Publish accepted milestones to the git host.
|
||||
#
|
||||
# On a push to main that changes docs/milestones/ or docs/sqa/reviews/, this
|
||||
# workflow runs `sync-project.sh --accepted-only --apply`: every milestone whose
|
||||
# latest Version History row is Accepted and whose latest review record is a Go
|
||||
# is created or updated as a Milestone with its tasks as Issues. A Proposed
|
||||
# milestone, or one without a Go review, is skipped. Nothing is closed or
|
||||
# deleted. Each run updates the issues of every accepted milestone (the sync is
|
||||
# idempotent, but the host records the edit).
|
||||
#
|
||||
# INSTALLING THIS FILE IS THE STANDING AUTHORIZATION TO PUBLISH. A project opts
|
||||
# in by copying it; an agent does not install it on its own.
|
||||
#
|
||||
# Copy it to .gitea/workflows/sync-milestones.yml (Gitea Actions) or
|
||||
# .github/workflows/sync-milestones.yml (GitHub Actions). Then:
|
||||
# 1. Create a CI secret and put its name in the two `secrets.` lines below
|
||||
# (SYNC_TOKEN is a placeholder). Gitea does not accept secret names that
|
||||
# start with GITEA_ or GITHUB_.
|
||||
# 2. Give the token the smallest scope that works: read and write on issues
|
||||
# and milestones. If the framework submodule is a private repository, the
|
||||
# token must also be able to read it (it is used to check it out).
|
||||
# 3. Run it once by hand from the Actions page with dry_run left on: it prints
|
||||
# the plan and creates nothing. Turn dry_run off only for a real run.
|
||||
# A runner must be registered for the repository (or its organisation).
|
||||
#
|
||||
# The steps are one command each: the logic is framework/scripts/ci-publish-milestones.sh,
|
||||
# a file that is linted and tested (QC-WF-001 criterion 20).
|
||||
|
||||
name: Publish accepted milestones
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/milestones/**"
|
||||
- "docs/sqa/reviews/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Preview only: print the plan and create nothing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Two merges in a row queue instead of racing.
|
||||
concurrency:
|
||||
group: publish-accepted-milestones
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out the project
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
token: ${{ secrets.SYNC_TOKEN }}
|
||||
|
||||
# The framework submodule is registered with an SSH address, which the runner
|
||||
# cannot use. The address is rewritten to HTTPS for this step only, and the token
|
||||
# goes in through the environment, never on a command line. .gitmodules stays.
|
||||
- name: Fetch the framework submodule over HTTPS
|
||||
env:
|
||||
GIT_CONFIG_COUNT: "2"
|
||||
GIT_CONFIG_KEY_0: url.https://git.tirsystem.com/.insteadOf
|
||||
GIT_CONFIG_VALUE_0: ssh://git@git.tirsystem.com:10022/
|
||||
GIT_CONFIG_KEY_1: http.https://git.tirsystem.com/.extraheader
|
||||
GIT_CONFIG_VALUE_1: "Authorization: token ${{ secrets.SYNC_TOKEN }}"
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Make sure curl and jq are available
|
||||
run: bash framework/scripts/ci-publish-milestones.sh ensure-tools
|
||||
|
||||
- name: Publish accepted milestones
|
||||
env:
|
||||
# Gitea reads GITEA_TOKEN, GitHub's gh reads GH_TOKEN; both come from
|
||||
# the one secret named above.
|
||||
GITEA_TOKEN: ${{ secrets.SYNC_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.SYNC_TOKEN }}
|
||||
# Empty on a push (a real run); "true" or "false" on a manual run.
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: bash framework/scripts/ci-publish-milestones.sh publish
|
||||
@@ -43,3 +43,4 @@ Thumbs.db
|
||||
|
||||
# Selenium Chrome profile created by the bot (holds the practice site data)
|
||||
/chrome_profile/
|
||||
.env.gym
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CHROME_BIN=/usr/bin/chromium \
|
||||
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
|
||||
SNACK_AND_LIFT_E2E=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
|
||||
COPY tests ./tests
|
||||
|
||||
ENTRYPOINT ["python", "-m", "snack_and_lift"]
|
||||
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CHROME_BIN=/usr/bin/chromium \
|
||||
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
|
||||
SNACK_AND_LIFT_E2E=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation fonts-dejavu-core fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
|
||||
COPY tests ./tests
|
||||
|
||||
ENTRYPOINT ["python", "-m", "snack_and_lift"]
|
||||
|
||||
@@ -8,6 +8,7 @@ A Selenium bot that books classes at the fictional gym "Snack and Lift" for you:
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Lecture steps and screenshots](#-lecture-steps-and-screenshots)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
@@ -75,6 +76,26 @@ Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
|
||||
|
||||
## ▶️ 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"
|
||||
```
|
||||
|
||||
Instead of setting variables by hand, you can put the same lines in a file named `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`, one per line). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. The file `.env.gym` is ignored by git: never commit it. Two optional lines, `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD`, hold the admin account of the practice site; the bot loads them but does not use them yet.
|
||||
|
||||
### Start the bot
|
||||
|
||||
With the environment active:
|
||||
|
||||
```bash
|
||||
@@ -94,6 +115,22 @@ docker build -t snack-and-lift .
|
||||
docker run --rm snack-and-lift
|
||||
```
|
||||
|
||||
## 📸 Lecture steps and screenshots
|
||||
|
||||
Each step has a wiki page that explains what it teaches, in [English](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home), [Dansk](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-da) and [Bahasa Melayu](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-ms). All nine images are screenshots of a real run against the live site. Step 8 ends in SUCCESS; the My Bookings page also lists bookings from earlier runs, which the check prints as "Also booked (not checked)".
|
||||
|
||||
| Step | Wiki page | Screenshot |
|
||||
| --- | --- | --- |
|
||||
| 1 | [Setup and navigation](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-1) |  |
|
||||
| 2 | [Automated login](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-2) |  |
|
||||
| 3 | [Book the next Tuesday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-3) |  |
|
||||
| 4 | [Check if a class is booked](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-4) |  |
|
||||
| 5 | [Counters and summary](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-5) |  |
|
||||
| 6 | [Every Tuesday and Thursday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-6) |  |
|
||||
| 7 | [Verify on My Bookings](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-7) |  |
|
||||
| 8 | [Time travel QA](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-8) |  |
|
||||
| 9 | [Resilience (retry)](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-9) |  |
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| BC | Business Case | docs/business-case.md | 002 |
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 011 |
|
||||
| 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 | 026 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,74 @@
|
||||
# MIL-009 Verification fix: only compare the bookings of the run
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-009 |
|
||||
| CrossReference | [BC-001], [MIL-006] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, added after a real run (issue #50) | [3b9e381] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the My Bookings check ignores bookings that lie outside the window the bot worked on, so that a real account with bookings from earlier runs ends in SUCCESS, and whether the report never prints a negative count.
|
||||
|
||||
## Deliverable
|
||||
|
||||
A corrected comparison in `domain/verification.py` and its use in `application/verification.py`, unit tests and an end-to-end fixture with extra future bookings, new screenshots for lecture steps 7 to 9 from a real run, and updated README and wiki notes.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Unit tests show that entries outside the window of the run are listed as a note and do not cause MISMATCH | 0 failures; a test uses entries after the last handled class | A test fails or is missing |
|
||||
| 2 | Unit tests show that the missing count is never negative and that a class the bot booked but the page lacks is still a MISMATCH | 0 failures | A test fails or is missing |
|
||||
| 3 | Unit and container tests of the existing steps still pass with at least 80% coverage of the logic modules | Both true | Either false |
|
||||
| 4 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` |
|
||||
| 5 | `docs/images/step-8.png` from a real run shows SUCCESS | S01 reran the bot with their own account and the image is committed | The image shows MISMATCH or is not replaced |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-006 | Changes the verification that MIL-006 built |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| O5, success criterion 3 | [BC-001] |
|
||||
| CN-001, CN-003 | [SA-001] |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-06 — inside the planned window 2026-10-28 to 2026-11-06 of [PP-001]; the Business Case sets no deadline.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Limit the comparison to the window of the run | In `build_verification` count only page entries dated from the first to the last class the bot handled; list the other entries as "also booked, not checked". The missing count never goes below zero. | No | O5 |
|
||||
| 2 | Test the corrected comparison | Unit tests for entries outside the window, a booked class that is missing on the page, and the count; an end-to-end fixture with extra future bookings. | No | O5 |
|
||||
| 3 | Refresh the step 7 to 9 screenshots and the notes | S01 reruns the bot with their own account and `--screenshot-dir`; the AI commits the images and removes the MISMATCH note from the README and the step 8 wiki pages in three languages. | No | O5, O8 |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[MIL-006]: ./mil-006-verify-and-time-travel.md
|
||||
[PP-001]: ../project-plan.md
|
||||
[SA-001]: ../stakeholder-analysis.md
|
||||
[3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948
|
||||
@@ -0,0 +1,74 @@
|
||||
# MIL-010 Gym accounts from a credentials file
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-010 |
|
||||
| CrossReference | [BC-001], [MIL-003] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the bot can read the student and the admin account of the practice site from the personal file `.env.gym`, so that S01 does not set environment variables by hand before each run, while the file stays out of the repository and the tests.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`credentials.py` that loads the student account and, load-only, the admin account from the environment, plus a small reader of the `.env.gym` file in the infrastructure layer that the command line merges under the real environment, unit tests with dummy values only, and README and wiki instructions.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Unit tests show that the student and the admin account load from file values, and that a real environment variable wins over the file | 0 failures | A test fails or is missing |
|
||||
| 2 | Unit tests show that a missing or empty variable raises an error that names the variable and never prints a value, and that the admin account is optional | 0 failures | A test fails or is missing |
|
||||
| 3 | No test reads `.env` or `.env.gym`; `.env.gym` is in `.gitignore`; no password appears in the repository | `git grep` finds no real credential; tests use `tmp_path` | A real file is read or a credential is committed |
|
||||
| 4 | All earlier tests pass with at least 80% coverage of the logic modules | Both true | Either false |
|
||||
| 5 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-003 | Changes the credentials handling that MIL-003 built |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| O2, O7 | [BC-001] |
|
||||
| CN-004, CN-005, CN-009 | [SA-001] |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-06 — inside the planned window 2026-10-28 to 2026-11-06 of [PP-001]; the Business Case sets no deadline.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Load the student and the admin account | In `credentials.py` read `SNACK_AND_LIFT_EMAIL` and `SNACK_AND_LIFT_PASSWORD` (required) and `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` (optional, load-only; nothing uses the admin yet). Errors name the variables, never a value. | No | O2 |
|
||||
| 2 | Read the `.env.gym` file | Add a small reader (no new dependency) in `infrastructure/` for `KEY=VALUE` lines; the command line merges the file under the real environment, and a missing file is not an error. | No | O2, O7 |
|
||||
| 3 | Test and document | Unit tests with dummy values in `tmp_path`; update the README and the Getting Started wiki pages (three languages) and keep `.env.gym` in `.gitignore`. | No | O7, O8 |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[MIL-003]: ./mil-003-login.md
|
||||
[PP-001]: ../project-plan.md
|
||||
[SA-001]: ../stakeholder-analysis.md
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
@@ -4,20 +4,21 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | PP-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008] |
|
||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: eight gateways | [50e8331] |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-009, a verification fix found in a real run (S01 accepted the plan in chat) | [3b9e381] |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This plan schedules eight gateways (`MIL-001` to `MIL-008`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
|
||||
This plan schedules ten gateways (`MIL-001` to `MIL-010`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
|
||||
|
||||
## Planning Assumptions
|
||||
|
||||
@@ -39,6 +40,8 @@ This plan schedules eight gateways (`MIL-001` to `MIL-008`) that take the Snack
|
||||
| Lecture steps 7 and 8 | [MIL-006] | 2026-11-01 to 2026-11-02 | 2026-11-02 | S01 | none (technical tasks) | My Bookings verification, time travel, screenshots 7 and 8 | |
|
||||
| Lecture step 9 | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | `retry`, resilient `login`, `book_class`, `get_my_bookings`, screenshot 9 | |
|
||||
| Documentation, wiki and release | [MIL-008] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | |
|
||||
| Verification fix | [MIL-009] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Verification compares only the window of the run; new screenshots 7 to 9 | |
|
||||
| Gym accounts from a file | [MIL-010] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | `.env.gym` credentials for the student and the admin account | |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -77,7 +80,7 @@ Project starts 2026-10-28
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008
|
||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010
|
||||
```
|
||||
|
||||
A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date.
|
||||
@@ -109,4 +112,8 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed.
|
||||
[MIL-006]: ./milestones/mil-006-verify-and-time-travel.md
|
||||
[MIL-007]: ./milestones/mil-007-resilience.md
|
||||
[MIL-008]: ./milestones/mil-008-documentation.md
|
||||
[MIL-009]: ./milestones/mil-009-verification-fix.md
|
||||
[MIL-010]: ./milestones/mil-010-credentials-file.md
|
||||
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
|
||||
[3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-020 |
|
||||
| CrossReference | [MIL-006], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-006 | [f57040e] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-006]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-006
|
||||
- Language and domain: n/a
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; messages go through `logging`. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 174 unit tests (booking entries, verification, date independence for all seven starting weekdays, command line) need no browser or network; 15 container tests run the adapter against local fixtures; coverage of the non-infrastructure modules is 100%. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All file and folder names are English lowercase with `_`, `-` and `.` only. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go-with-conditions - the code of MIL-006 meets every Mandatory criterion of QC-PY-001 and there is no blocking finding, but part of the gate evidence cannot be produced by the stand-in. Gate criterion 1 (unit tests of the comparison and the mismatch message) is met, and the date independence is tested for every starting weekday (no hardcoded date, no list-position rule). Criteria 2 and 3 (container runs on the live site, including after the admin advances 3 days) are not verified, and criterion 4 is not met: `step-7.png` and `step-8.png` come from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site; the admin time controls also need a login. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Run the bot with your own account (also after advancing the site 3 days as admin) and keep `docs/images/step-7.png` and `step-8.png` | S01 | when S01 has time |
|
||||
|
||||
---
|
||||
|
||||
[MIL-006]: ../../milestones/mil-006-verify-and-time-travel.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[f57040e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/f57040e136feac01f10d99279038f48992518f43
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-021 |
|
||||
| CrossReference | [MIL-007], [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-007 | [5d28e68] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-007]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-007
|
||||
- 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 | 237 unit tests (retry, resilient login, booking and bookings read, a seeded chaos test at 50% failure: 97 of 100 seeds complete, seeds 0 to 17 must all pass) need no browser or network; 20 container tests run the adapter against failing 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-007 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 1 (retry unit tests) and 2 (a booking only counts when the page confirms it) are met in tests. Criterion 3 (three container runs on the live site at 50% simulated failure) is not verified, and criterion 4 is not met: `step-9.png` comes from a run with an account. The framework forbids the stand-in to enter passwords or register on a third-party site, and the network simulation is an admin control behind a login. Known behaviour: a wrong password or an empty bookings page is also retried seven times (about 3 seconds) before exit code 1. 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 |
|
||||
| --- | --- | --- |
|
||||
| Enable the network simulation at 50% as admin, run the bot three times with your own account and keep `docs/images/step-9.png` | S01 | when S01 has time |
|
||||
|
||||
---
|
||||
|
||||
[MIL-007]: ../../milestones/mil-007-resilience.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[5d28e68]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/5d28e682b41e1a03b0cac1a6b2ea4977750d45b8
|
||||
@@ -0,0 +1,69 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-022 |
|
||||
| CrossReference | [MIL-008], [QC-WF-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, the workflows publish-wiki.yml and sync-milestones.yml of MIL-008 task 5 | [0301fd2] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-008]
|
||||
- Checklist used: [QC-WF-001] (`QC-WF-001`)
|
||||
- Scope: the workflows publish-wiki.yml and sync-milestones.yml of MIL-008 task 5
|
||||
- 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 | The file meets the YAML checklist ([QC-YML-001]) | Pending | `yamllint` is not installed on the stand-in's machine, so the YAML checklist QC-YML-001 was not run; the files parse (actionlint reads them) and use 2-space indentation. |
|
||||
| 2 | The file is in `.gitea/workflows/` or `.github/workflows/` with the extension `.yml` or `.yaml`, and the same workflow does not exist in both directories | Pass | Both files are in `.gitea/workflows/` with the extension `.yml`; neither exists in `.github/workflows/`. |
|
||||
| 3 | The file declares `name`, `on` and `jobs`; `on` lists explicit events and, where it matters, `branches` or `paths` | Pass | Both files declare `name`, `on` and `jobs`; `on` lists `push` on `main` with `paths`, and `workflow_dispatch`. |
|
||||
| 4 | A header comment states the purpose, the triggers, the inputs, the secrets it needs, what it changes outside the repository, and the decision or milestone it implements | Pass | Both headers state the purpose, triggers, secrets, what changes outside the repository and the installation steps (copied from the framework template); the decision is MIL-008 task 5. |
|
||||
| 5 | Every job and step has a descriptive `name`, unique within its job | Pass | `sync-milestones.yml` names every step. `publish-wiki.yml` has one job that calls a reusable workflow and has no steps of its own. |
|
||||
| 6 | The file passes `actionlint` (or the project's workflow validation test) and the tool version is stated | Pass | `actionlint` 1.7.12 reports no finding for both files (exit code 0). |
|
||||
| 7 | Every job declares `permissions` explicitly with the least access that works (`contents: read` or none by default); write access only on the job that needs it | Pass | `sync-milestones.yml` sets `contents: read` on its job; `publish-wiki.yml` sets `contents: read` for the whole workflow (added to the framework template). The wiki write uses the token secret. |
|
||||
| 8 | Every action used is pinned to a full commit SHA or an exact release tag, never to a branch; a moving major tag (`@v1`) is accepted only for an action published by the same organisation under a stated release policy | Pass | `actions/checkout@v4.2.2` is an exact release tag. `publish-wiki.yml` uses `TirSystem/git-workflows/.gitea/workflows/publish-wiki-pages.yml@v1`, a moving major tag of a reusable workflow of the same organisation, which the framework template accepts. |
|
||||
| 9 | No secret is written in the file; a secret is read as `secrets.NAME`, passed to a step through `env:`, never placed on a command line or echoed | Pass | Both files read `secrets.SYNC_TOKEN` and `secrets.TOKEN_FOR_GITEA_WIKI` only; no secret value is in the repository. |
|
||||
| 10 | Untrusted text (`inputs.*`, event fields such as a title, branch name or label, step outputs derived from them) is never interpolated into a `run:` script; it is passed through `env:` and used as a quoted variable | Pass | No `run:` block interpolates `inputs.*` or event text; `DRY_RUN` is passed through `env:`. |
|
||||
| 11 | A trigger that gives a run secrets or a write token on code from a fork or an untrusted branch (`pull_request_target`, `workflow_run`) does not check out or execute that code | Pass | Neither workflow uses `pull_request_target` or `workflow_run`. |
|
||||
| 12 | A workflow that changes state outside the repository (release, tag, publish, sync, deploy) is started by hand or by a protected branch, and a manual run defaults to a dry run with an explicit input to apply | Pass | Both run on a push to `main` or by hand; the manual run of `sync-milestones.yml` defaults to a dry run. `publish-wiki.yml` has no dry run (it is a call of the reusable workflow). |
|
||||
| 13 | The token the workflow uses has only the scope the job needs, and where the host's built-in token is enough it is used instead of a personal token | Pending | The scope of both tokens is set by S01 when the secrets are created. |
|
||||
| 14 | Every job sets `runs-on` to an explicit label and `timeout-minutes` | Pass | `sync-milestones.yml` sets `runs-on: ubuntu-latest` and `timeout-minutes: 15`. `publish-wiki.yml` calls a reusable workflow, where the called workflow sets them (a calling job cannot). |
|
||||
| 15 | `concurrency` is set where two runs would conflict: a check cancels the older run (`cancel-in-progress: true`); a publishing job queues (`cancel-in-progress: false`) | Pass | `sync-milestones.yml` sets `concurrency` with `cancel-in-progress: false`; `publish-wiki.yml` leaves it to the reusable workflow. |
|
||||
| 16 | Triggers are filtered (`branches`, `paths`, `types`) so the workflow runs only for the changes it concerns | Pass | Both are filtered by `branches: [main]` and `paths`. |
|
||||
| 17 | Tools and dependencies are installed at pinned versions; a version is a workflow input with a default or a value in one place | Pass | `sync-milestones.yml` installs nothing itself; `curl` and `jq` are checked by the framework script. The wiki workflow installs nothing. |
|
||||
| 18 | A dependency cache is used where the install is slow enough to matter | N-A | No dependency is installed that a cache would speed up. |
|
||||
| 19 | A failing step names the tool, the file and the reason in its log (annotation or `::error::` line) | Pending | Not verified: the log messages come from the framework script and the reusable workflow, which the stand-in did not run. |
|
||||
| 20 | A `run:` block is within the limit on embedded program text of criterion 7 of [QC-YML-001]; a longer script is a file (`tools/`, or a shared repository fetched at a pinned ref) that the step calls with one command, and the workflow passes its settings through `env:` | Pass | Each `run:` block is one command; the logic is in `framework/scripts/ci-publish-milestones.sh`. |
|
||||
| 21 | Logic used by more than one workflow or repository is a reusable workflow (`workflow_call`) or a composite action; each input has a `description`, a `type` and a `default` where one is sensible | Pass | `publish-wiki.yml` calls the reusable workflow; `sync-milestones.yml` calls one framework script. The inputs of `workflow_dispatch` have a description, type and default. |
|
||||
| 22 | Every workflow feature used is verified on the instance that runs it, and the host and version are recorded; a feature the target hosts differ on is noted in the header | Pending | Neither workflow has run on the Gitea instance yet: the secrets do not exist and a merge to `main` is what triggers the first run. |
|
||||
| 23 | A reusable workflow states, in its header, how a caller pins it and the script it fetches, so the workflow and its script are the same release | N-A | Neither file is a reusable workflow; both are callers. |
|
||||
| 24 | A static test of the repository's workflows exists and runs on every pull request: valid YAML, required keys, explicit permissions and runner, no inline script above the limit | N-A | No static test of the workflows exists in this project; the checks are `actionlint` on request. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go-with-conditions - the two workflows are the framework's templates, copied into `.gitea/workflows/`, and pass `actionlint`. Mandatory criteria 1 (the YAML checklist: `yamllint` was not run) and 22 (feature checked on the instance) are Pending, because the stand-in cannot create the secrets `TOKEN_FOR_GITEA_WIKI` and `SYNC_TOKEN` or watch a run on the host. The workflows will fail or skip until S01 creates them. 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 |
|
||||
| --- | --- | --- |
|
||||
| Create the secrets `TOKEN_FOR_GITEA_WIKI` (wiki write) and `SYNC_TOKEN` (issues and milestones) in the organisation, enable the wiki, then run both workflows once by hand | S01 | before the next merge to main |
|
||||
| Run `yamllint` on `.gitea/workflows/` | S01 | when convenient |
|
||||
|
||||
---
|
||||
|
||||
[MIL-008]: ../../milestones/mil-008-documentation.md
|
||||
[QC-WF-001]: ../../../framework/qc/code/qc-workflow.md
|
||||
[0301fd2]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/0301fd2f0bc22a26ee8d71ec41dab2bce0d38dc3
|
||||
@@ -0,0 +1,65 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-023 |
|
||||
| CrossReference | [MIL-009], [QC-MIL-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 | [3b9e381] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-009]
|
||||
- Checklist used: [QC-MIL-001] (`QC-MIL-001`)
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete files, runs or documents. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding milestone and the reason. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability cites O5 of BC-001 and the concerns of SA-001. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership names S01 as owner and S02 (AI as stand-in) as approving reviewer. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the window 2026-10-28 to 2026-11-06 of PP-001. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells of MIL-009 are English (stand-in assessment, see the verdict). |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is given in the registry (Languages); the text keeps to it. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, booking, verification (stand-in assessment, see the verdict). |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists in `docs/`. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context (stand-in assessment, see the verdict). |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out or defined where first used, or are the framework's own IDs. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — MIL-009 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. Criteria 3, 5 and 9 of QC-LANG-001 were assessed by the stand-in only; the framework would hold them `Pending` for an independent language reviewer. S01 is asked to confirm them in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-009]: ../../milestones/mil-009-verification-fix.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||
[3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-024 |
|
||||
| CrossReference | [MIL-009], [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-009 | [52427ca] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-009]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-009
|
||||
- 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 | 249 unit tests need no browser or network (new: entries outside the window, inclusive ends, missing class, unexpected entry, no results, New Year wrap-around, invalid dates); 21 container tests include a fixture with extra future bookings; 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-009 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 1 to 3 are met in tests. Criterion 5 is not met yet: `docs/images/step-8.png` still shows the MISMATCH of the run before the fix, because a new image needs a run with S01's own account, which the stand-in may not log in to. The README and the wiki say that the image predates the fix. 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 |
|
||||
| --- | --- | --- |
|
||||
| Rerun `python -m snack_and_lift --screenshot-dir docs/images` with your own account and tell the AI, which then commits the new images and removes the note | S01 | when S01 has time |
|
||||
|
||||
---
|
||||
|
||||
[MIL-009]: ../../milestones/mil-009-verification-fix.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[52427ca]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/52427ca69cc0193ba9734b548f31a33549606249
|
||||
@@ -0,0 +1,65 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-025 |
|
||||
| CrossReference | [MIL-010], [QC-MIL-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 | [434bdaf] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-010]
|
||||
- Checklist used: [QC-MIL-001] (`QC-MIL-001`)
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete files, runs or documents. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding milestone and the reason. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability cites O2 and O7 of BC-001 and the concerns of SA-001. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership names S01 as owner and S02 (AI as stand-in) as approving reviewer. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the window 2026-10-28 to 2026-11-06 of PP-001. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells of MIL-010 are English (stand-in assessment, see the verdict). |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is given in the registry (Languages); the text keeps to it. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, booking, verification (stand-in assessment, see the verdict). |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists in `docs/`. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context (stand-in assessment, see the verdict). |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out or defined where first used, or are the framework's own IDs. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — MIL-010 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. Criteria 3, 5 and 9 of QC-LANG-001 were assessed by the stand-in only; the framework would hold them `Pending` for an independent language reviewer. S01 is asked to confirm them in the end-of-run summary.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-010]: ../../milestones/mil-010-credentials-file.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
|
||||
@@ -0,0 +1,62 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-026 |
|
||||
| CrossReference | [MIL-010], [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-010 | [42f05bd] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-010]
|
||||
- Checklist used: [QC-PY-001] (`QC-PY-001`)
|
||||
- Scope: full review of the source and tests of MIL-010
|
||||
- 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 | 273 unit tests need no browser or network (new: the admin account, half-set variables, the env file parser, precedence of the environment, `--env-file`); an autouse fixture keeps every CLI test in `tmp_path`, so no test can read `.env` or `.env.gym`; 21 container tests pass; 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 — the code of MIL-010 meets every Mandatory criterion of QC-PY-001. No blocking finding. 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 |
|
||||
| --- | --- | --- |
|
||||
| None | S01 | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-010]: ../../milestones/mil-010-credentials-file.md
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[42f05bd]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/42f05bdd0f5c300fbd162e8cf50075fc98964e1b
|
||||
@@ -23,7 +23,7 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
|
||||
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 |
|
||||
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-008] | RC-003 |
|
||||
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-010] | RC-003 |
|
||||
| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 |
|
||||
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 |
|
||||
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 |
|
||||
@@ -36,7 +36,15 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-013 |
|
||||
| [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-009] | Milestone | en | it | [BC-001], [MIL-006] | - | RC-023 |
|
||||
| [MIL-010] | Milestone | en | it | [BC-001], [MIL-003] | - | RC-025 |
|
||||
| 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) |
|
||||
| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-020 (Go-with-conditions) |
|
||||
| Source and tests of [MIL-007] | Python source code | - | - | [MIL-007], [ADR-0002] | - | RC-021 (Go-with-conditions) |
|
||||
| Workflows and wiki of [MIL-008] | Workflow files, wiki pages | - | - | [MIL-008] | - | RC-022 (Go-with-conditions) |
|
||||
|
||||
## Concern Coverage
|
||||
|
||||
@@ -46,8 +54,8 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
| CN-002 | [PP-001], [MIL-001] | [ADR-0001] | Review records RC-001 onward | Covered |
|
||||
| CN-003 | [MIL-006], [MIL-007] | [ADR-0002] | Unit tests of `retry`; container chaos run | Planned |
|
||||
| CN-004 | [MIL-003] to [MIL-007] | [ADR-0002] | Code review against QC-PY-001 | Planned |
|
||||
| CN-005 | [MIL-002], [MIL-008] | - | `check-readme.sh` | Planned |
|
||||
| CN-006 | [MIL-008] | - | Page count per language | Planned |
|
||||
| CN-005 | [MIL-002], [MIL-008] | - | `check-readme.sh` | Covered |
|
||||
| CN-006 | [MIL-008] | - | Page count per language (12 pages in each of en, da, ms) | Covered |
|
||||
| CN-007 | [MIL-001] | - | Repository API response | Covered |
|
||||
| CN-008 | [MIL-002] | [ADR-0002] | `pyproject.toml` | Planned |
|
||||
| CN-009 | [MIL-001] | - | `.gitignore` lists `.env` | Covered |
|
||||
@@ -69,4 +77,6 @@ Tracks backward and forward links between artifact instances so that the Busines
|
||||
[MIL-006]: ../milestones/mil-006-verify-and-time-travel.md
|
||||
[MIL-007]: ../milestones/mil-007-resilience.md
|
||||
[MIL-008]: ../milestones/mil-008-documentation.md
|
||||
[MIL-009]: ../milestones/mil-009-verification-fix.md
|
||||
[MIL-010]: ../milestones/mil-010-credentials-file.md
|
||||
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
[English](Getting-Started) · **Dansk** · [Bahasa Melayu](Getting-Started-ms)
|
||||
|
||||
# Kom i gang
|
||||
|
||||
## Krav
|
||||
|
||||
Python 3.13 eller nyere, Chrome eller Chromium og (valgfrit) Docker.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Din konto
|
||||
|
||||
Botten indeholder ingen adgangskode. Opret en konto én gang i hånden på siden (kør med `--keep-open`, så mappen `chrome_profile` gemmer sidens data), og giv derefter botten kontoen:
|
||||
|
||||
```bash
|
||||
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
|
||||
export SNACK_AND_LIFT_PASSWORD="your password"
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## Kontofilen
|
||||
|
||||
I stedet for at sætte variabler i hånden kan du skrive de samme linjer i en fil `.env.gym` i projektmappen (`SNACK_AND_LIFT_EMAIL=...` og `SNACK_AND_LIFT_PASSWORD=...`). Botten læser den ved start; `--env-file PATH` peger på en anden fil, og en rigtig miljøvariabel vinder over filen. Git ignorerer `.env.gym`: læg den aldrig i repositoriet. De valgfrie `SNACK_AND_LIFT_ADMIN_EMAIL` og `SNACK_AND_LIFT_ADMIN_PASSWORD` gemmer øvelsessidens admin-konto; botten indlæser dem, men bruger dem ikke endnu.
|
||||
|
||||
## Kør og test
|
||||
|
||||
```bash
|
||||
snack-and-lift
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Nyttige indstillinger: `--headless`, `--keep-open`, `--first-tuesday-only` (trin 3 og 4), `--retries N`, `--profile-dir DIR`, `--screenshot-dir DIR`.
|
||||
|
||||
## Exitkoder
|
||||
|
||||
0 færdig, 1 et trin fejlede (også når alle forsøg fejlede), 2 loginoplysninger mangler, 3 siden My Bookings passer ikke med de bookinger der blev lavet.
|
||||
|
||||
## Skærmbilleder (vedligeholder)
|
||||
|
||||
Siden gemmer sine data i browserens profil, så en kørsel i en container kræver en konto der findes i den profil. Brug `--env-file` til loginoplysningerne (aldrig på kommandolinjen eller i repositoriet) og et volume til profilen og skærmbillederne:
|
||||
|
||||
```bash
|
||||
docker build -t snack-and-lift .
|
||||
docker run --rm --env-file my.env -v snack-profile:/app/chrome_profile -v "$PWD/docs/images:/app/screenshots" snack-and-lift --headless --screenshot-dir /app/screenshots
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
[English](Getting-Started) · [Dansk](Getting-Started-da) · **Bahasa Melayu**
|
||||
|
||||
# Mula menggunakan
|
||||
|
||||
## Keperluan
|
||||
|
||||
Python 3.13 atau lebih baharu, Chrome atau Chromium, dan (pilihan) Docker.
|
||||
|
||||
## Pemasangan
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Akaun anda
|
||||
|
||||
Bot tidak mengandungi kata laluan. Daftar sekali secara manual di laman (jalankan dengan `--keep-open` supaya folder `chrome_profile` menyimpan data laman), kemudian beri bot akaun itu:
|
||||
|
||||
```bash
|
||||
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
|
||||
export SNACK_AND_LIFT_PASSWORD="your password"
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## Fail akaun
|
||||
|
||||
Daripada menetapkan pemboleh ubah secara manual, letakkan baris yang sama dalam fail `.env.gym` di folder projek (`SNACK_AND_LIFT_EMAIL=...` dan `SNACK_AND_LIFT_PASSWORD=...`). Bot membacanya semasa bermula; `--env-file PATH` menamakan fail lain, dan pemboleh ubah persekitaran sebenar mengatasi fail. Git mengabaikan `.env.gym`: jangan sekali-kali commit. `SNACK_AND_LIFT_ADMIN_EMAIL` dan `SNACK_AND_LIFT_ADMIN_PASSWORD` yang pilihan menyimpan akaun admin laman latihan; bot memuatkannya tetapi belum menggunakannya.
|
||||
|
||||
## Jalankan dan uji
|
||||
|
||||
```bash
|
||||
snack-and-lift
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Pilihan berguna: `--headless`, `--keep-open`, `--first-tuesday-only` (langkah 3 dan 4), `--retries N`, `--profile-dir DIR`, `--screenshot-dir DIR`.
|
||||
|
||||
## Kod keluar
|
||||
|
||||
0 selesai, 1 satu langkah gagal (juga apabila semua percubaan gagal), 2 kelayakan tiada, 3 halaman My Bookings tidak sepadan dengan tempahan yang dibuat.
|
||||
|
||||
## Mengambil tangkapan skrin (penyelenggara)
|
||||
|
||||
Laman menyimpan datanya dalam profil pelayar, jadi larian dalam bekas memerlukan akaun yang wujud dalam profil itu. Gunakan `--env-file` untuk kelayakan (jangan pada baris arahan atau dalam repositori) dan volume untuk profil serta tangkapan skrin:
|
||||
|
||||
```bash
|
||||
docker build -t snack-and-lift .
|
||||
docker run --rm --env-file my.env -v snack-profile:/app/chrome_profile -v "$PWD/docs/images:/app/screenshots" snack-and-lift --headless --screenshot-dir /app/screenshots
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
**English** · [Dansk](Getting-Started-da) · [Bahasa Melayu](Getting-Started-ms)
|
||||
|
||||
# Getting started
|
||||
|
||||
## Requirements
|
||||
|
||||
Python 3.13 or newer, Chrome or Chromium, and (optional) Docker.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Your account
|
||||
|
||||
The bot contains no password. Register once by hand on the site (run with `--keep-open`, so the `chrome_profile` folder keeps the site's data), then give the bot the account:
|
||||
|
||||
```bash
|
||||
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
|
||||
export SNACK_AND_LIFT_PASSWORD="your password"
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
|
||||
$env:SNACK_AND_LIFT_PASSWORD = "your password"
|
||||
```
|
||||
|
||||
## The account file
|
||||
|
||||
Instead of setting variables by hand, put the same lines in a file `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. Git ignores `.env.gym`: never commit it. The optional `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` hold the practice site's admin account; the bot loads them but does not use them yet.
|
||||
|
||||
## Run and test
|
||||
|
||||
```bash
|
||||
snack-and-lift
|
||||
```
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Useful options: `--headless`, `--keep-open`, `--first-tuesday-only` (steps 3 and 4), `--retries N`, `--profile-dir DIR`, `--screenshot-dir DIR`.
|
||||
|
||||
## Exit codes
|
||||
|
||||
0 done, 1 a step failed (also when every retry failed), 2 credentials missing, 3 the My Bookings page does not match the bookings made.
|
||||
|
||||
## Taking the screenshots (maintainer)
|
||||
|
||||
The site keeps its data in the browser profile, so a container run needs an account that exists in that profile. Use `--env-file` for the credentials (never write them on the command line or into the repository) and a volume for the profile and the screenshots:
|
||||
|
||||
```bash
|
||||
docker build -t snack-and-lift .
|
||||
docker run --rm --env-file my.env -v snack-profile:/app/chrome_profile -v "$PWD/docs/images:/app/screenshots" snack-and-lift --headless --screenshot-dir /app/screenshots
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
[English](Home) · **Dansk** · [Bahasa Melayu](Home-ms)
|
||||
|
||||
# Snack and Lift-bot
|
||||
|
||||
En Selenium-bot der booker hold på øvelsesgymmet [Snack and Lift](https://appbrewery.github.io/gym/). Den er løsningen på dag 49 i Udemys *100 Days of Code*. Hver side nedenfor forklarer hvad ét trin i lektionen lærer.
|
||||
|
||||
Botten logger ind, booker hvert tirsdags- og torsdagshold kl. 18 (eller kommer på ventelisten), bekræfter resultatet på siden My Bookings og prøver igen når siden fejler en forespørgsel.
|
||||
|
||||
- [Kom i gang](Getting-Started-da): `.venv`, din konto, kørsel og test.
|
||||
- De ni trin, nedenfor.
|
||||
- [Fejlfinding](Troubleshooting-da).
|
||||
|
||||
Kildekode og sager: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift).
|
||||
|
||||
## De ni trin
|
||||
|
||||
1. [Trin 1: Opsætning, Chrome-profil og navigation](Step-1-da)
|
||||
2. [Trin 2: Automatisk login](Step-2-da)
|
||||
3. [Trin 3: Book næste tirsdagshold](Step-3-da)
|
||||
4. [Trin 4: Tjek om et hold allerede er booket](Step-4-da)
|
||||
5. [Trin 5: Tællere og et resumé](Step-5-da)
|
||||
6. [Trin 6: Alle tirsdags- og torsdagshold](Step-6-da)
|
||||
7. [Trin 7: Bekræft på siden My Bookings](Step-7-da)
|
||||
8. [Trin 8: Kvalitetssikring med tidsrejse](Step-8-da)
|
||||
9. [Trin 9: Robusthed mod netværksfejl](Step-9-da)
|
||||
@@ -0,0 +1,25 @@
|
||||
[English](Home) · [Dansk](Home-da) · **Bahasa Melayu**
|
||||
|
||||
# Bot Snack and Lift
|
||||
|
||||
Bot Selenium yang menempah kelas di gim latihan [Snack and Lift](https://appbrewery.github.io/gym/). Ia penyelesaian untuk hari 49 *100 Days of Code* Udemy. Setiap halaman di bawah menerangkan apa yang diajar oleh satu langkah kuliah.
|
||||
|
||||
Bot log masuk, menempah setiap kelas Selasa dan Khamis pukul 6 petang (atau menyertai senarai menunggu), mengesahkan hasilnya di halaman My Bookings dan mencuba semula apabila laman gagal memproses permintaan.
|
||||
|
||||
- [Mula menggunakan](Getting-Started-ms): `.venv`, akaun anda, menjalankan dan menguji.
|
||||
- Sembilan langkah, di bawah.
|
||||
- [Menyelesaikan masalah](Troubleshooting-ms).
|
||||
|
||||
Kod sumber dan isu: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift).
|
||||
|
||||
## Sembilan langkah
|
||||
|
||||
1. [Langkah 1: Persediaan, profil Chrome dan navigasi](Step-1-ms)
|
||||
2. [Langkah 2: Log masuk automatik](Step-2-ms)
|
||||
3. [Langkah 3: Tempah kelas Selasa seterusnya](Step-3-ms)
|
||||
4. [Langkah 4: Semak sama ada kelas sudah ditempah](Step-4-ms)
|
||||
5. [Langkah 5: Pembilang dan ringkasan](Step-5-ms)
|
||||
6. [Langkah 6: Semua kelas Selasa dan Khamis](Step-6-ms)
|
||||
7. [Langkah 7: Sahkan di halaman My Bookings](Step-7-ms)
|
||||
8. [Langkah 8: Jaminan kualiti dengan perjalanan masa](Step-8-ms)
|
||||
9. [Langkah 9: Ketahanan terhadap kegagalan rangkaian](Step-9-ms)
|
||||
@@ -0,0 +1,25 @@
|
||||
**English** · [Dansk](Home-da) · [Bahasa Melayu](Home-ms)
|
||||
|
||||
# Snack and Lift bot
|
||||
|
||||
A Selenium bot that books classes at the practice gym [Snack and Lift](https://appbrewery.github.io/gym/). It is the solution to day 49 of Udemy's *100 Days of Code*. Each page below explains what one lecture step teaches.
|
||||
|
||||
The bot logs in, books every Tuesday and Thursday 6pm class (or joins the waitlist), verifies the result on the My Bookings page and retries when the site fails a request.
|
||||
|
||||
- [Getting started](Getting-Started): the `.venv`, your account, running and testing.
|
||||
- The nine steps, below.
|
||||
- [Troubleshooting](Troubleshooting).
|
||||
|
||||
Source and issues: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift).
|
||||
|
||||
## The nine steps
|
||||
|
||||
1. [Step 1: Setup, Chrome profile and navigation](Step-1)
|
||||
2. [Step 2: Automated login](Step-2)
|
||||
3. [Step 3: Book the next Tuesday class](Step-3)
|
||||
4. [Step 4: Check if a class is already booked](Step-4)
|
||||
5. [Step 5: Counters and a summary](Step-5)
|
||||
6. [Step 6: Every Tuesday and Thursday class](Step-6)
|
||||
7. [Step 7: Verify on the My Bookings page](Step-7)
|
||||
8. [Step 8: Time travel quality assurance](Step-8)
|
||||
9. [Step 9: Resilience against network failures](Step-9)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-1) · **Dansk** · [Bahasa Melayu](Step-1-ms)
|
||||
|
||||
# Trin 1: Opsætning, Chrome-profil og navigation
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at starte Chrome fra Selenium med en egen profilmappe (`chrome_profile`), så sidens data overlever mellem kørsler, og med indstillingen `detach`, så vinduet bliver åbent når scriptet slutter. Botten åbner forsiden og venter på login-knappen i stedet for at sove.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/infrastructure/browser.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forside](Home-da) · [Næste: 2](Step-2-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-1) · [Dansk](Step-1-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 1: Persediaan, profil Chrome dan navigasi
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar memulakan Chrome daripada Selenium dengan folder profil sendiri (`chrome_profile`) supaya data laman kekal antara larian, dan dengan pilihan `detach` supaya tetingkap kekal terbuka apabila skrip tamat. Bot membuka halaman utama dan menunggu butang log masuk, bukannya tidur.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/infrastructure/browser.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Utama](Home-ms) · [Seterusnya: 2](Step-2-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-1-da) · [Bahasa Melayu](Step-1-ms)
|
||||
|
||||
# Step 1: Setup, Chrome profile and navigation
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to start Chrome from Selenium with its own profile folder (`chrome_profile`), so the site's data survives between runs, and with the `detach` option, so the window stays open when the script ends. The bot opens the home page and waits for the login button instead of sleeping.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/infrastructure/browser.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Home](Home) · [Next: 2](Step-2)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-2) · **Dansk** · [Bahasa Melayu](Step-2-ms)
|
||||
|
||||
# Trin 2: Automatisk login
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer eksplicitte ventetider (`WebDriverWait`) og at udfylde en formular. `login()` skriver e-mail og adgangskode fra miljøvariabler, indsender og venter til timeplanen vises. En forkert adgangskode vises via sidens fejlmeddelelse.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/application/session.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 1](Step-1-da) · [Forside](Home-da) · [Næste: 3](Step-3-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-2) · [Dansk](Step-2-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 2: Log masuk automatik
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar menunggu secara eksplisit (`WebDriverWait`) dan mengisi borang. `login()` menaip e-mel dan kata laluan daripada pemboleh ubah persekitaran, menghantar, dan menunggu sehingga jadual kelas dipaparkan. Kata laluan salah dilaporkan melalui mesej ralat laman.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/application/session.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 1](Step-1-ms) · [Utama](Home-ms) · [Seterusnya: 3](Step-3-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-2-da) · [Bahasa Melayu](Step-2-ms)
|
||||
|
||||
# Step 2: Automated login
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn explicit waits (`WebDriverWait`) and how to fill a form. `login()` types the e-mail and password from environment variables, submits, and waits until the class schedule is shown. A wrong password is reported by the site's error message.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/application/session.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 1](Step-1) · [Home](Home) · [Next: 3](Step-3)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-3) · **Dansk** · [Bahasa Melayu](Step-3-ms)
|
||||
|
||||
# Trin 3: Book næste tirsdagshold
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at finde et element relativt til et andet: næste tirsdags dagsgruppe findes med et XPath-opslag på forfædre, derefter 18-kortet og dets knap. `book_class()` klikker på knappen og venter til teksten ændres.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 2](Step-2-da) · [Forside](Home-da) · [Næste: 4](Step-4-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-3) · [Dansk](Step-3-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 3: Tempah kelas Selasa seterusnya
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar mencari elemen berdasarkan elemen lain: kumpulan hari Selasa seterusnya dicari dengan carian XPath ancestor, kemudian kad pukul 6 petang dan butangnya. `book_class()` menekan butang dan menunggu teks berubah.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 2](Step-2-ms) · [Utama](Home-ms) · [Seterusnya: 4](Step-4-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-3-da) · [Bahasa Melayu](Step-3-ms)
|
||||
|
||||
# Step 3: Book the next Tuesday class
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to find an element relative to another one: the day group of the next Tuesday is found with an XPath ancestor lookup, then the 6pm card and its button. `book_class()` clicks the button and waits until its text changes.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 2](Step-2) · [Home](Home) · [Next: 4](Step-4)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-4) · **Dansk** · [Bahasa Melayu](Step-4-ms)
|
||||
|
||||
# Trin 4: Tjek om et hold allerede er booket
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at læse en knaps tilstand før du handler: "Book Class", "Join Waitlist", "Booked" eller "Waitlisted". Et hold der allerede er booket klikkes ikke igen, og botten udskriver en ✓-linje om hvad den gjorde.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/domain/rules.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 3](Step-3-da) · [Forside](Home-da) · [Næste: 5](Step-5-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-4) · [Dansk](Step-4-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 4: Semak sama ada kelas sudah ditempah
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar membaca keadaan butang sebelum bertindak: "Book Class", "Join Waitlist", "Booked" atau "Waitlisted". Kelas yang sudah ditempah tidak ditekan semula, dan bot mencetak baris ✓ tentang apa yang dilakukannya.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/domain/rules.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 3](Step-3-ms) · [Utama](Home-ms) · [Seterusnya: 5](Step-5-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-4-da) · [Bahasa Melayu](Step-4-ms)
|
||||
|
||||
# Step 4: Check if a class is already booked
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to read the state of a button before acting: "Book Class", "Join Waitlist", "Booked" or "Waitlisted". A class that is already booked is not clicked again, and the bot prints a ✓ line that says what it did.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/domain/rules.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 3](Step-3) · [Home](Home) · [Next: 5](Step-5)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-5) · **Dansk** · [Bahasa Melayu](Step-5-ms)
|
||||
|
||||
# Trin 5: Tællere og et resumé
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at føre tællere mens en løkke kører og at udskrive en rapport: BOOKING SUMMARY viser nye bookinger, nye ventelisteplaceringer og hold der allerede var booket eller på venteliste.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/domain/report.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 4](Step-4-da) · [Forside](Home-da) · [Næste: 6](Step-6-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-5) · [Dansk](Step-5-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 5: Pembilang dan ringkasan
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar menyimpan pembilang semasa gelung berjalan dan mencetak laporan: BOOKING SUMMARY menyenaraikan tempahan baharu, penyertaan senarai menunggu baharu, serta kelas yang sudah ditempah atau dalam senarai menunggu.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/domain/report.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 4](Step-4-ms) · [Utama](Home-ms) · [Seterusnya: 6](Step-6-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-5-da) · [Bahasa Melayu](Step-5-ms)
|
||||
|
||||
# Step 5: Counters and a summary
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to keep counters while a loop runs and to print a report: the BOOKING SUMMARY lists new bookings, new waitlist entries, and classes that were already booked or waitlisted.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/domain/report.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 4](Step-4) · [Home](Home) · [Next: 6](Step-6)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-6) · **Dansk** · [Bahasa Melayu](Step-6-ms)
|
||||
|
||||
# Trin 6: Alle tirsdags- og torsdagshold
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at gennemløbe alle matchende elementer: hvert tirsdags- og torsdagshold kl. 18 håndteres, og DETAILED CLASS LIST udskriver hvert hold med dato, tid og resultat (New Booking, New Waitlist, Already Booked, Already Waitlisted).
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 5](Step-5-da) · [Forside](Home-da) · [Næste: 7](Step-7-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-6) · [Dansk](Step-6-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 6: Semua kelas Selasa dan Khamis
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar mengulang semua elemen yang sepadan: setiap kelas Selasa dan Khamis pukul 6 petang diuruskan, dan DETAILED CLASS LIST mencetak setiap kelas dengan tarikh, masa dan hasil (New Booking, New Waitlist, Already Booked, Already Waitlisted).
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 5](Step-5-ms) · [Utama](Home-ms) · [Seterusnya: 7](Step-7-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-6-da) · [Bahasa Melayu](Step-6-ms)
|
||||
|
||||
# Step 6: Every Tuesday and Thursday class
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to loop over all matching elements: every Tuesday and Thursday 6pm class is handled, and the DETAILED CLASS LIST prints each class with its date, time and result (New Booking, New Waitlist, Already Booked, Already Waitlisted).
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/application/booking.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 5](Step-5) · [Home](Home) · [Next: 7](Step-7)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-7) · **Dansk** · [Bahasa Melayu](Step-7-ms)
|
||||
|
||||
# Trin 7: Bekræft på siden My Bookings
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at kontrollere dit eget arbejde fra en anden kilde: `get_my_bookings()` læser siden My Bookings (bookinger og venteliste) og `verify_bookings()` sammenligner med det botten gjorde. Resultatet er SUCCESS eller MISMATCH (exitkode 3).
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/application/verification.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 6](Step-6-da) · [Forside](Home-da) · [Næste: 8](Step-8-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-7) · [Dansk](Step-7-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 7: Sahkan di halaman My Bookings
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar menyemak kerja sendiri daripada sumber kedua: `get_my_bookings()` membaca halaman My Bookings (tempahan dan senarai menunggu) dan `verify_bookings()` membandingkannya dengan apa yang dilakukan bot. Hasilnya SUCCESS atau MISMATCH (kod keluar 3).
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/application/verification.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 6](Step-6-ms) · [Utama](Home-ms) · [Seterusnya: 8](Step-8-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-7-da) · [Bahasa Melayu](Step-7-ms)
|
||||
|
||||
# Step 7: Verify on the My Bookings page
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to check your own work from a second source: `get_my_bookings()` reads the My Bookings page (bookings and waitlist entries) and `verify_bookings()` compares it with what the bot did. The result is SUCCESS or MISMATCH (exit code 3).
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/application/verification.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 6](Step-6) · [Home](Home) · [Next: 8](Step-8)
|
||||
@@ -0,0 +1,19 @@
|
||||
[English](Step-8) · **Dansk** · [Bahasa Melayu](Step-8-ms)
|
||||
|
||||
# Trin 8: Kvalitetssikring med tidsrejse
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at datoer er den almindelige fejlkilde: sidens admin-knap "advance 3 days" flytter dens ur. Botten læser dagsetiketterne ("Today", "Tomorrow", "Thu, Aug 14") i stedet for at antage en dato, så den også virker efter et tidshop.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/domain/day_label.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
> Siden My Bookings viser også bookinger fra tidligere kørsler uden for denne kørsels dage. Kontrollen sammenligner kun kørslens dage og skriver de øvrige som "Also booked (not checked)".
|
||||
|
||||
[Forrige: 7](Step-7-da) · [Forside](Home-da) · [Næste: 9](Step-9-da)
|
||||
@@ -0,0 +1,19 @@
|
||||
[English](Step-8) · [Dansk](Step-8-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 8: Jaminan kualiti dengan perjalanan masa
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar bahawa tarikh ialah punca pepijat biasa: kawalan admin laman "advance 3 days" menggerakkan jamnya. Bot membaca label hari ("Today", "Tomorrow", "Thu, Aug 14") dan bukannya menganggap tarikh, jadi ia berfungsi selepas lompatan masa juga.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/domain/day_label.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
> Halaman My Bookings turut menyenaraikan tempahan daripada larian terdahulu di luar hari larian ini. Semakan hanya membandingkan hari larian dan mencetak yang lain sebagai "Also booked (not checked)".
|
||||
|
||||
[Sebelumnya: 7](Step-7-ms) · [Utama](Home-ms) · [Seterusnya: 9](Step-9-ms)
|
||||
@@ -0,0 +1,19 @@
|
||||
**English** · [Dansk](Step-8-da) · [Bahasa Melayu](Step-8-ms)
|
||||
|
||||
# Step 8: Time travel quality assurance
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn that dates are the usual bug: the site's admin control "advance 3 days" moves its clock. The bot reads the day labels ("Today", "Tomorrow", "Thu, Aug 14") instead of assuming a date, so it works after a time jump too.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/domain/day_label.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
> The My Bookings page also lists bookings from earlier runs outside the days of this run. The check compares only the days of the run and prints the others as "Also booked (not checked)".
|
||||
|
||||
[Previous: 7](Step-7) · [Home](Home) · [Next: 9](Step-9)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-9) · **Dansk** · [Bahasa Melayu](Step-9-ms)
|
||||
|
||||
# Trin 9: Robusthed mod netværksfejl
|
||||
|
||||
## Hvad du får ud af det
|
||||
|
||||
Du lærer at prøve igen: `retry(func, retries=7, description=None)` gentager et kald der kaster en fejl, venter mellem forsøgene, logger en advarsel og kaster RetriesExhaustedError til sidst. `login()`, `book_class()` og `get_my_bookings()` er pakket ind i den, så en side der taber forespørgsler ikke stopper kørslen.
|
||||
|
||||
## Hvor det er i koden
|
||||
|
||||
`src/snack_and_lift/application/retry.py`
|
||||
|
||||
## Skærmbillede
|
||||
|
||||

|
||||
|
||||
[Forrige: 8](Step-8-da) · [Forside](Home-da)
|
||||
@@ -0,0 +1,17 @@
|
||||
[English](Step-9) · [Dansk](Step-9-da) · **Bahasa Melayu**
|
||||
|
||||
# Langkah 9: Ketahanan terhadap kegagalan rangkaian
|
||||
|
||||
## Apa yang anda peroleh
|
||||
|
||||
Anda belajar mencuba semula: `retry(func, retries=7, description=None)` mengulang panggilan yang gagal, menunggu antara percubaan, mencatat amaran dan melempar RetriesExhaustedError di akhir. `login()`, `book_class()` dan `get_my_bookings()` dibalut dengannya, jadi laman yang menjatuhkan permintaan tidak menghentikan larian.
|
||||
|
||||
## Di mana dalam kod
|
||||
|
||||
`src/snack_and_lift/application/retry.py`
|
||||
|
||||
## Tangkapan skrin
|
||||
|
||||

|
||||
|
||||
[Sebelumnya: 8](Step-8-ms) · [Utama](Home-ms)
|
||||
@@ -0,0 +1,17 @@
|
||||
**English** · [Dansk](Step-9-da) · [Bahasa Melayu](Step-9-ms)
|
||||
|
||||
# Step 9: Resilience against network failures
|
||||
|
||||
## What you gain
|
||||
|
||||
You learn to retry: `retry(func, retries=7, description=None)` repeats a call that raises, waits between attempts, logs a warning and raises RetriesExhaustedError at the end. `login()`, `book_class()` and `get_my_bookings()` are wrapped with it, so a site that drops requests does not stop the run.
|
||||
|
||||
## Where it is in the code
|
||||
|
||||
`src/snack_and_lift/application/retry.py`
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
[Previous: 8](Step-8) · [Home](Home)
|
||||
@@ -0,0 +1,12 @@
|
||||
[English](Troubleshooting) · **Dansk** · [Bahasa Melayu](Troubleshooting-ms)
|
||||
|
||||
# Fejlfinding
|
||||
|
||||
| Symptom | Årsag og løsning |
|
||||
| --- | --- |
|
||||
| Exitkode 2 | `SNACK_AND_LIFT_EMAIL` eller `SNACK_AND_LIFT_PASSWORD` er ikke sat. |
|
||||
| Login fejler 7 gange, derefter exit 1 | Forkert e-mail eller adgangskode, eller kontoen findes ikke i denne `chrome_profile` (siden gemmer konti i browseren). Opret den igen med `--keep-open`. |
|
||||
| Exitkode 3 og MISMATCH | Siden My Bookings viser ikke det botten bookede. Kør igen; tjek om et hold var fyldt. |
|
||||
| Advarsler "attempt 2 of 7" | Sidens fejlrate er slået til; det er retry der arbejder (trin 9). |
|
||||
| Chrome advarer om adgangskoden | Chrome markerer svage adgangskoder som `12346`; vælg en anden. |
|
||||
| Intet vindue i Docker | Containeren er headless med vilje; brug `--screenshot-dir`. |
|
||||
@@ -0,0 +1,12 @@
|
||||
[English](Troubleshooting) · [Dansk](Troubleshooting-da) · **Bahasa Melayu**
|
||||
|
||||
# Menyelesaikan masalah
|
||||
|
||||
| Gejala | Punca dan penyelesaian |
|
||||
| --- | --- |
|
||||
| Kod keluar 2 | `SNACK_AND_LIFT_EMAIL` atau `SNACK_AND_LIFT_PASSWORD` tidak ditetapkan. |
|
||||
| Log masuk gagal 7 kali, kemudian keluar 1 | E-mel atau kata laluan salah, atau akaun tiada dalam `chrome_profile` ini (laman menyimpan akaun dalam pelayar). Daftar semula dengan `--keep-open`. |
|
||||
| Kod keluar 3 dan MISMATCH | Halaman My Bookings tidak menyenaraikan apa yang ditempah bot. Jalankan semula; semak sama ada kelas penuh. |
|
||||
| Amaran "attempt 2 of 7" | Kadar kegagalan laman dihidupkan; ini retry sedang berfungsi (langkah 9). |
|
||||
| Chrome memberi amaran tentang kata laluan | Chrome menandakan kata laluan lemah seperti `12346`; pilih yang lain. |
|
||||
| Tiada tetingkap dalam Docker | Bekas ini tanpa kepala (headless) secara sengaja; gunakan `--screenshot-dir`. |
|
||||
@@ -0,0 +1,12 @@
|
||||
**English** · [Dansk](Troubleshooting-da) · [Bahasa Melayu](Troubleshooting-ms)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
| --- | --- |
|
||||
| Exit code 2 | `SNACK_AND_LIFT_EMAIL` or `SNACK_AND_LIFT_PASSWORD` is not set. |
|
||||
| Login fails 7 times, then exit 1 | Wrong e-mail or password, or the account is not in this `chrome_profile` (the site keeps accounts in the browser). Register again with `--keep-open`. |
|
||||
| Exit code 3 and MISMATCH | The My Bookings page does not list what the bot booked. Run again; check whether a class was full. |
|
||||
| Warnings "attempt 2 of 7" | The site's failure rate is on; this is the retry at work (step 9). |
|
||||
| Chrome warns about the password | Chrome flags weak passwords such as `12346`; choose another. |
|
||||
| No window in Docker | The container is headless by design; use `--screenshot-dir`. |
|
||||
@@ -0,0 +1 @@
|
||||
[Repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift) · [Snack and Lift](https://appbrewery.github.io/gym/) · AGPL-3.0
|
||||
@@ -0,0 +1,11 @@
|
||||
**English**
|
||||
- [Home](Home) · [Getting started](Getting-Started) · [Troubleshooting](Troubleshooting)
|
||||
- [1](Step-1) · [2](Step-2) · [3](Step-3) · [4](Step-4) · [5](Step-5) · [6](Step-6) · [7](Step-7) · [8](Step-8) · [9](Step-9)
|
||||
|
||||
**Dansk**
|
||||
- [Forside](Home-da) · [Kom i gang](Getting-Started-da) · [Fejlfinding](Troubleshooting-da)
|
||||
- [1](Step-1-da) · [2](Step-2-da) · [3](Step-3-da) · [4](Step-4-da) · [5](Step-5-da) · [6](Step-6-da) · [7](Step-7-da) · [8](Step-8-da) · [9](Step-9-da)
|
||||
|
||||
**Bahasa Melayu**
|
||||
- [Utama](Home-ms) · [Mula menggunakan](Getting-Started-ms) · [Menyelesaikan masalah](Troubleshooting-ms)
|
||||
- [1](Step-1-ms) · [2](Step-2-ms) · [3](Step-3-ms) · [4](Step-4-ms) · [5](Step-5-ms) · [6](Step-6-ms) · [7](Step-7-ms) · [8](Step-8-ms) · [9](Step-9-ms)
|
||||
@@ -29,7 +29,7 @@ where = ["src"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra --strict-markers"
|
||||
pythonpath = ["src"]
|
||||
pythonpath = ["src", "."]
|
||||
markers = [
|
||||
"browser: needs a real browser (run in the Docker container)",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""@file booking.py
|
||||
@brief Lecture steps 3 to 6: book the Tuesday and Thursday 6pm classes and report their state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.application.ports import GymPort
|
||||
from snack_and_lift.application.retry import Sleep, retry
|
||||
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 _read_current_card(gym: GymPort, card: ClassCard) -> ClassCard:
|
||||
"""@brief Read a class again from the page, to see what an earlier attempt did.
|
||||
|
||||
@param gym The gym site.
|
||||
@param card The class, as it was read before.
|
||||
@return The class with the state the page shows now.
|
||||
@exception GymError The schedule could not be read or the class is no longer on it.
|
||||
"""
|
||||
for current in gym.list_class_cards():
|
||||
if current.card_id == card.card_id:
|
||||
return current
|
||||
raise GymError(constants.MESSAGE_CLASS_NOT_ON_SCHEDULE.format(card_id=card.card_id))
|
||||
|
||||
|
||||
def book_class_with_retry(
|
||||
gym: GymPort,
|
||||
card: ClassCard,
|
||||
retries: int = constants.RETRY_COUNT,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> ClassResult:
|
||||
"""@brief Book a class, repeating it when the site drops the request.
|
||||
|
||||
Before each repeat the class is read from the page again. When the button now shows the
|
||||
state that the click should give, the click did go through although its confirmation
|
||||
failed, so the class counts as newly booked or waitlisted and is not clicked again.
|
||||
|
||||
@param gym The gym site.
|
||||
@param card The class, as read from the page.
|
||||
@param retries The most attempts to make.
|
||||
@param sleep Waits between two attempts.
|
||||
@return The class and what happened.
|
||||
@exception RetriesExhaustedError Every attempt failed.
|
||||
"""
|
||||
attempts = 0
|
||||
|
||||
def attempt() -> ClassResult:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return book_class(gym, card)
|
||||
current = _read_current_card(gym, card)
|
||||
action = decide_outcome_action(card)
|
||||
if CONFIRMED_STATE.get(action) is current.state:
|
||||
outcome = OUTCOME_OF_ACTION[action]
|
||||
logger.info(format_outcome(outcome, card))
|
||||
return ClassResult(card, outcome)
|
||||
return book_class(gym, current)
|
||||
|
||||
description = constants.RETRY_DESCRIPTION_BOOK.format(name=card.name, day=card.day_label)
|
||||
return retry(attempt, retries, description, sleep=sleep)
|
||||
|
||||
|
||||
def book_next_tuesday(
|
||||
gym: GymPort, retries: int = constants.RETRY_COUNT, sleep: Sleep = time.sleep
|
||||
) -> ClassResult | None:
|
||||
"""@brief Book the next Tuesday class at the target time.
|
||||
|
||||
@param gym The gym site.
|
||||
@param retries The most attempts to book the class.
|
||||
@param sleep Waits between two attempts.
|
||||
@return The result, or None when the schedule has no such class.
|
||||
@exception GymError The page could not be read or the booking failed on every attempt.
|
||||
"""
|
||||
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_with_retry(gym, card, retries, sleep)
|
||||
|
||||
|
||||
def book_target_classes(
|
||||
gym: GymPort,
|
||||
on_result: Callable[[ClassResult], None] | None = None,
|
||||
retries: int = constants.RETRY_COUNT,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> 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.
|
||||
@param retries The most attempts to book one class.
|
||||
@param sleep Waits between two attempts.
|
||||
@return What happened to each class; empty when the schedule has none.
|
||||
@exception GymError The page could not be read or a booking failed on every attempt.
|
||||
"""
|
||||
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_with_retry(gym, card, retries, sleep)
|
||||
results.append(result)
|
||||
if on_result is not None:
|
||||
on_result(result)
|
||||
return results
|
||||
@@ -0,0 +1,63 @@
|
||||
"""@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 or the env file (they must not be empty)"
|
||||
)
|
||||
return Credentials(email=email, password=password)
|
||||
|
||||
|
||||
def load_admin_credentials(environment: Mapping[str, str]) -> Credentials | None:
|
||||
"""@brief Read the optional admin account from the environment.
|
||||
|
||||
@param environment The environment variables, for example `os.environ`.
|
||||
@return The credentials, or None when neither variable is set.
|
||||
@exception MissingCredentialsError Only one of the two variables is set; the message
|
||||
names the missing variable and never a value.
|
||||
"""
|
||||
email = environment.get(constants.ADMIN_EMAIL_ENV, "").strip()
|
||||
password = environment.get(constants.ADMIN_PASSWORD_ENV, "")
|
||||
if not email and not password:
|
||||
return None
|
||||
if not email:
|
||||
raise MissingCredentialsError(_admin_missing(constants.ADMIN_EMAIL_ENV))
|
||||
if not password:
|
||||
raise MissingCredentialsError(_admin_missing(constants.ADMIN_PASSWORD_ENV))
|
||||
return Credentials(email=email, password=password)
|
||||
|
||||
|
||||
def _admin_missing(name: str) -> str:
|
||||
"""@brief Build the message for a half-set admin account.
|
||||
|
||||
@param name The variable that is missing or empty.
|
||||
@return The message; it names the variable only.
|
||||
"""
|
||||
return constants.MESSAGE_ADMIN_HALF_SET.format(name=name)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""@file ports.py
|
||||
@brief The ports the use cases need from the outside world.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry
|
||||
from snack_and_lift.domain.models import ClassCard
|
||||
|
||||
|
||||
class GymPort(Protocol):
|
||||
"""@brief The gym site as the use cases see it."""
|
||||
|
||||
def open_site(self) -> None:
|
||||
"""@brief Open the home page and wait until it is ready.
|
||||
|
||||
@exception GymError The page did not load.
|
||||
"""
|
||||
...
|
||||
|
||||
def log_in(self, email: str, password: str) -> None:
|
||||
"""@brief Log in and wait until the class schedule is shown.
|
||||
|
||||
@param email The account e-mail.
|
||||
@param password The account password.
|
||||
@exception LoginFailedError The site refused the login.
|
||||
@exception GymError The page did not behave as expected.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_class_cards(self) -> list[ClassCard]:
|
||||
"""@brief Read every class card of the schedule, in page order.
|
||||
|
||||
@return The classes, each with the day of its group.
|
||||
@exception GymError The schedule could not be read.
|
||||
"""
|
||||
...
|
||||
|
||||
def press_class_button(self, card_id: str) -> str:
|
||||
"""@brief Click the button of a class and wait until the request has finished.
|
||||
|
||||
@param card_id The id of the card without its prefix.
|
||||
@return The new text of the button.
|
||||
@exception GymError The site shows an error for the class (the message is its text)
|
||||
or nothing settled in time.
|
||||
"""
|
||||
...
|
||||
|
||||
def open_my_bookings(self) -> None:
|
||||
"""@brief Open the My Bookings page and wait until it is shown.
|
||||
|
||||
@exception GymError The page did not load.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_my_bookings(self) -> list[BookingEntry]:
|
||||
"""@brief Read every confirmed booking and waitlist entry of the My Bookings page.
|
||||
|
||||
@return The entries, confirmed ones first; empty when the page shows its empty state.
|
||||
@exception GymError The page shows an error (the message is its text) or did not settle.
|
||||
"""
|
||||
...
|
||||
|
||||
def save_text_screenshot(self, text: str, path: Path) -> None:
|
||||
"""@brief Save an image of console text.
|
||||
|
||||
@param text The lines to draw, in a dark monospace style.
|
||||
@param path The PNG file to write; its folders are created.
|
||||
"""
|
||||
...
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
"""@brief Save a screenshot of the current page.
|
||||
|
||||
@param path The PNG file to write; its folders are created.
|
||||
"""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Close the browser."""
|
||||
...
|
||||
@@ -0,0 +1,67 @@
|
||||
"""@file retry.py
|
||||
@brief Lecture step 9: repeat a step that failed because the site dropped a request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.domain.errors import GymError, RetriesExhaustedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
## Waits for a number of seconds; tests pass a recorder so that they never really sleep.
|
||||
Sleep = Callable[[float], None]
|
||||
|
||||
|
||||
def retry[T](
|
||||
func: Callable[[], T],
|
||||
retries: int = constants.RETRY_COUNT,
|
||||
description: str | None = None,
|
||||
*,
|
||||
delay: float = constants.RETRY_DELAY_SECONDS,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> T:
|
||||
"""@brief Call a function until it succeeds, at most `retries` times.
|
||||
|
||||
Only a `GymError` is repeated: it is what a dropped request looks like. Any other error,
|
||||
such as missing credentials, is a defect and passes through at once.
|
||||
|
||||
@param func The step, called without arguments.
|
||||
@param retries The most attempts to make, at least 1.
|
||||
@param description What the step does, for the log (never put a secret in it).
|
||||
@param delay Seconds to wait between two attempts.
|
||||
@param sleep Waits for a number of seconds.
|
||||
@return What the function returned on its first successful attempt.
|
||||
@exception ValueError `retries` is below 1.
|
||||
@exception RetriesExhaustedError Every attempt raised a `GymError`; the last one is the cause.
|
||||
"""
|
||||
if retries < 1:
|
||||
raise ValueError(constants.MESSAGE_RETRIES_INVALID.format(retries=retries))
|
||||
name = description or constants.RETRY_DEFAULT_DESCRIPTION
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
result = func()
|
||||
except GymError as error:
|
||||
if attempt == retries:
|
||||
message = constants.MESSAGE_RETRIES_EXHAUSTED.format(
|
||||
description=name, retries=retries, error=error
|
||||
)
|
||||
raise RetriesExhaustedError(message) from error
|
||||
logger.warning(
|
||||
constants.MESSAGE_RETRY_FAILED.format(
|
||||
attempt=attempt,
|
||||
retries=retries,
|
||||
description=name,
|
||||
error=str(error).rstrip("."),
|
||||
)
|
||||
)
|
||||
sleep(delay)
|
||||
else:
|
||||
if attempt > 1:
|
||||
logger.info(
|
||||
constants.MESSAGE_RETRY_SUCCEEDED.format(description=name, attempts=attempt)
|
||||
)
|
||||
return result
|
||||
raise AssertionError("unreachable: the last attempt returns or raises") # pragma: no cover
|
||||
@@ -0,0 +1,46 @@
|
||||
"""@file session.py
|
||||
@brief Lecture step 2: the login of the bot.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.application.credentials import Credentials
|
||||
from snack_and_lift.application.ports import GymPort
|
||||
from snack_and_lift.application.retry import Sleep, retry
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def login_with_retry(
|
||||
gym: GymPort,
|
||||
credentials: Credentials,
|
||||
retries: int = constants.RETRY_COUNT,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> None:
|
||||
"""@brief Log in, repeating the login when the site drops the request.
|
||||
|
||||
@param gym The gym site.
|
||||
@param credentials The account to log in with.
|
||||
@param retries The most attempts to make.
|
||||
@param sleep Waits between two attempts.
|
||||
@exception RetriesExhaustedError Every attempt failed.
|
||||
"""
|
||||
retry(
|
||||
lambda: login(gym, credentials),
|
||||
retries,
|
||||
constants.RETRY_DESCRIPTION_LOGIN,
|
||||
sleep=sleep,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""@file verification.py
|
||||
@brief Lecture steps 7 and 8: check the bookings on the My Bookings page.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.application.ports import GymPort
|
||||
from snack_and_lift.application.retry import Sleep, retry
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry, is_target_entry
|
||||
from snack_and_lift.domain.errors import NoBookingsFoundError
|
||||
from snack_and_lift.domain.models import ClassResult
|
||||
from snack_and_lift.domain.verification import VerificationResult, build_verification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_my_bookings(gym: GymPort) -> list[BookingEntry]:
|
||||
"""@brief Open the My Bookings page and read all its entries.
|
||||
|
||||
@param gym The gym site.
|
||||
@return Every confirmed booking and waitlist entry, not only the target classes.
|
||||
@exception NoBookingsFoundError The page shows no entry at all.
|
||||
@exception GymError The page could not be opened or read.
|
||||
"""
|
||||
gym.open_my_bookings()
|
||||
entries = gym.list_my_bookings()
|
||||
if not entries:
|
||||
raise NoBookingsFoundError(constants.MESSAGE_NO_BOOKINGS_FOUND)
|
||||
return entries
|
||||
|
||||
|
||||
def get_my_bookings_with_retry(
|
||||
gym: GymPort, retries: int = constants.RETRY_COUNT, sleep: Sleep = time.sleep
|
||||
) -> list[BookingEntry]:
|
||||
"""@brief Read the My Bookings page, repeating it when the page fails to load.
|
||||
|
||||
@param gym The gym site.
|
||||
@param retries The most attempts to make.
|
||||
@param sleep Waits between two attempts.
|
||||
@return Every confirmed booking and waitlist entry.
|
||||
@exception RetriesExhaustedError Every attempt failed.
|
||||
"""
|
||||
return retry(
|
||||
lambda: get_my_bookings(gym), retries, constants.RETRY_DESCRIPTION_BOOKINGS, sleep=sleep
|
||||
)
|
||||
|
||||
|
||||
def verify_bookings(
|
||||
gym: GymPort,
|
||||
results: Sequence[ClassResult],
|
||||
retries: int = constants.RETRY_COUNT,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> VerificationResult:
|
||||
"""@brief Compare what the bot booked with the My Bookings page.
|
||||
|
||||
The page is not opened when the bot handled no class.
|
||||
|
||||
@param gym The gym site.
|
||||
@param results What happened to each class.
|
||||
@param retries The most attempts to read the page.
|
||||
@param sleep Waits between two attempts.
|
||||
@return The comparison of the expected entries with the Tuesday and Thursday 6pm entries.
|
||||
@exception RetriesExhaustedError The page could not be read on any attempt.
|
||||
"""
|
||||
if not results:
|
||||
return build_verification(results, [])
|
||||
entries = [
|
||||
entry for entry in get_my_bookings_with_retry(gym, retries, sleep) if is_target_entry(entry)
|
||||
]
|
||||
return build_verification(results, entries)
|
||||
@@ -4,36 +4,276 @@
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
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_admin_credentials,
|
||||
load_credentials,
|
||||
)
|
||||
from snack_and_lift.application.ports import GymPort
|
||||
from snack_and_lift.application.retry import Sleep
|
||||
from snack_and_lift.application.session import login_with_retry
|
||||
from snack_and_lift.application.verification import verify_bookings
|
||||
from snack_and_lift.domain.errors import GymError, LoginFailedError, MissingCredentialsError
|
||||
from snack_and_lift.domain.models import ClassResult
|
||||
from snack_and_lift.domain.report import format_detail_list, format_summary
|
||||
from snack_and_lift.domain.verification import format_verification
|
||||
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
|
||||
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
|
||||
from snack_and_lift.infrastructure.env_file import read_env_file
|
||||
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
|
||||
|
||||
## Builds the gym site from (headless, profile folder, keep open).
|
||||
GymFactory = Callable[[bool, Path, bool], GymPort]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> GymPort:
|
||||
"""@brief Start Chrome and wrap it as the gym site.
|
||||
|
||||
@param headless True to run without a window.
|
||||
@param profile_dir The Chrome user data folder.
|
||||
@param keep_open True to leave the browser open when the program ends.
|
||||
@return The gym site.
|
||||
"""
|
||||
return SeleniumGym(create_driver(headless, profile_dir, keep_open))
|
||||
|
||||
|
||||
def _at_least_one(text: str) -> int:
|
||||
"""@brief Read a whole number of at least 1 for an option.
|
||||
|
||||
@param text The text of the option.
|
||||
@return The number.
|
||||
@exception argparse.ArgumentTypeError The text is not a whole number of at least 1.
|
||||
"""
|
||||
try:
|
||||
number = int(text)
|
||||
except ValueError:
|
||||
number = 0
|
||||
if number < 1:
|
||||
raise argparse.ArgumentTypeError(f"{text!r} is not a whole number of at least 1")
|
||||
return number
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""@brief Read the command line.
|
||||
|
||||
@param argv Arguments without the program name; None reads `sys.argv`.
|
||||
@return The parsed options.
|
||||
@return The parsed options; `keep_open` is True unless `--headless` is given.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="snack-and-lift",
|
||||
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
|
||||
epilog=(
|
||||
"exit codes: 0 done, 1 a step failed (also when every retry failed), "
|
||||
"2 credentials missing in the environment or the env file, "
|
||||
"3 the My Bookings page does not match the bookings made"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run Chrome without a window (the Docker image does this)",
|
||||
)
|
||||
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); skips the verification",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retries",
|
||||
type=_at_least_one,
|
||||
default=constants.RETRY_COUNT,
|
||||
metavar="N",
|
||||
help=(
|
||||
"attempts for the login, each booking and the My Bookings page when the site "
|
||||
f"drops a request (default: {constants.RETRY_COUNT}, at least 1)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env-file",
|
||||
type=Path,
|
||||
default=Path(constants.ENV_FILE_NAME),
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"file with the gym accounts as KEY=VALUE lines "
|
||||
f"(default: ./{constants.ENV_FILE_NAME}); a real environment variable wins"
|
||||
),
|
||||
)
|
||||
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:
|
||||
"""@brief Start the bot.
|
||||
def _use_utf8_output() -> None:
|
||||
"""@brief Make stdout UTF-8 so the check mark of the messages prints on Windows."""
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
|
||||
"""@brief Save a screenshot when a folder was asked for.
|
||||
|
||||
@param gym The gym site.
|
||||
@param screenshot_dir The folder, or None for no screenshots.
|
||||
@param name The file name.
|
||||
"""
|
||||
if screenshot_dir is not None:
|
||||
gym.save_screenshot(screenshot_dir / name)
|
||||
|
||||
|
||||
def _save_console(gym: GymPort, screenshot_dir: Path | None, text: str, name: str) -> None:
|
||||
"""@brief Save console text as an image when a folder was asked for.
|
||||
|
||||
@param gym The gym site.
|
||||
@param screenshot_dir The folder, or None for no screenshots.
|
||||
@param text The console text to draw.
|
||||
@param name The file name.
|
||||
"""
|
||||
if screenshot_dir is not None:
|
||||
gym.save_text_screenshot(text, screenshot_dir / name)
|
||||
|
||||
|
||||
def _run(
|
||||
gym: GymPort,
|
||||
args: argparse.Namespace,
|
||||
recorder: ConsoleRecorder,
|
||||
credentials: Credentials,
|
||||
sleep: Sleep,
|
||||
) -> bool:
|
||||
"""@brief Open the site, log in, book the classes and verify them, saving the step images.
|
||||
|
||||
Books every Tuesday and Thursday 6pm class, logs the summary and the detailed list and
|
||||
verifies the bookings on the My Bookings page; with `--first-tuesday-only` it books the
|
||||
next Tuesday class only and does not verify.
|
||||
|
||||
@param gym The gym site.
|
||||
@param args The parsed options.
|
||||
@param recorder The recorded console lines, drawn for step 4.
|
||||
@param credentials The account to log in with.
|
||||
@param sleep Waits between two attempts of a retried step.
|
||||
@return False when the My Bookings page does not match the bookings made, else True.
|
||||
@exception GymError A step failed on every attempt.
|
||||
"""
|
||||
gym.open_site()
|
||||
_save_step(gym, args.screenshot_dir, "step-1.png")
|
||||
login_with_retry(gym, credentials, args.retries, sleep)
|
||||
_save_step(gym, args.screenshot_dir, "step-2.png")
|
||||
if args.first_tuesday_only:
|
||||
book_next_tuesday(gym, args.retries, sleep)
|
||||
_save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3)
|
||||
_save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4)
|
||||
return True
|
||||
window_saved = False
|
||||
|
||||
def save_window_once(_: ClassResult) -> None:
|
||||
nonlocal window_saved
|
||||
if not window_saved:
|
||||
_save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3)
|
||||
window_saved = True
|
||||
|
||||
results = book_target_classes(gym, save_window_once, args.retries, sleep)
|
||||
if not window_saved:
|
||||
_save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3)
|
||||
_save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4)
|
||||
summary = format_summary(results)
|
||||
logger.info("%s", summary)
|
||||
_save_console(gym, args.screenshot_dir, summary, constants.SCREENSHOT_STEP_5)
|
||||
detail = format_detail_list(results)
|
||||
logger.info("%s", detail)
|
||||
_save_console(gym, args.screenshot_dir, f"{summary}\n{detail}", constants.SCREENSHOT_STEP_6)
|
||||
verification = verify_bookings(gym, results, args.retries, sleep)
|
||||
_save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_7)
|
||||
verification_text = format_verification(verification)
|
||||
logger.info("%s", verification_text)
|
||||
_save_console(gym, args.screenshot_dir, verification_text, constants.SCREENSHOT_STEP_8)
|
||||
_save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_9)
|
||||
return verification.is_success
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
gym_factory: GymFactory = selenium_gym_factory,
|
||||
sleep: Sleep = time.sleep,
|
||||
) -> int:
|
||||
"""@brief Start the bot: open the site, log in, book the classes and verify them.
|
||||
|
||||
@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.
|
||||
@param sleep Waits between two attempts of a retried step; tests pass a recorder.
|
||||
@return Process exit code: 0 done, 1 a step failed, 2 credentials missing, 3 mismatch.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
parse_args(argv)
|
||||
logger.info("snack-and-lift: the lecture steps are added by the next milestones")
|
||||
return 0
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
recorder = ConsoleRecorder()
|
||||
root.addHandler(recorder)
|
||||
try:
|
||||
return _main(argv, gym_factory, recorder, sleep)
|
||||
finally:
|
||||
root.removeHandler(recorder)
|
||||
|
||||
|
||||
def _main(
|
||||
argv: Sequence[str] | None, gym_factory: GymFactory, recorder: ConsoleRecorder, sleep: Sleep
|
||||
) -> 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.
|
||||
@param sleep Waits between two attempts of a retried step.
|
||||
@return Process exit code.
|
||||
"""
|
||||
_use_utf8_output()
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
environment = {**read_env_file(args.env_file), **os.environ}
|
||||
credentials = load_credentials(environment)
|
||||
load_admin_credentials(environment) # load-only for now; nothing uses it yet
|
||||
except MissingCredentialsError as error:
|
||||
logger.error("%s", error)
|
||||
return 2
|
||||
gym = gym_factory(args.headless, args.profile_dir, args.keep_open)
|
||||
try:
|
||||
verified = _run(gym, args, recorder, credentials, sleep)
|
||||
except LoginFailedError as error:
|
||||
logger.error("Login failed: %s", error)
|
||||
return 1
|
||||
except GymError as error:
|
||||
logger.error("Failed: %s", error)
|
||||
return 1
|
||||
finally:
|
||||
if not args.keep_open:
|
||||
gym.close()
|
||||
return 0 if verified else constants.EXIT_VERIFICATION_MISMATCH
|
||||
|
||||
@@ -18,12 +18,71 @@ WAIT_TIMEOUT_SECONDS: Final[int] = 10
|
||||
## Attempts of the `retry` wrapper before it gives up (lecture step 9).
|
||||
RETRY_COUNT: Final[int] = 7
|
||||
|
||||
## Seconds to wait between two attempts of the `retry` wrapper.
|
||||
RETRY_DELAY_SECONDS: Final[float] = 0.5
|
||||
|
||||
## Name of a retried step that was given no description.
|
||||
RETRY_DEFAULT_DESCRIPTION: Final[str] = "operation"
|
||||
|
||||
## Descriptions of the retried steps, as they appear in the log.
|
||||
RETRY_DESCRIPTION_LOGIN: Final[str] = "login"
|
||||
RETRY_DESCRIPTION_BOOK: Final[str] = "book {name} on {day}"
|
||||
RETRY_DESCRIPTION_BOOKINGS: Final[str] = "get my bookings"
|
||||
|
||||
## Messages of the `retry` wrapper.
|
||||
MESSAGE_RETRY_FAILED: Final[str] = (
|
||||
"⚠ Attempt {attempt}/{retries} failed: {description}: {error}. Retrying..."
|
||||
)
|
||||
MESSAGE_RETRY_SUCCEEDED: Final[str] = "✓ {description} succeeded after {attempts} attempts"
|
||||
MESSAGE_RETRIES_EXHAUSTED: Final[str] = "{description} failed after {retries} attempts: {error}"
|
||||
MESSAGE_RETRIES_INVALID: Final[str] = "retries must be at least 1, got {retries}"
|
||||
|
||||
## Error raised when a class card is no longer on the schedule while a booking is repeated.
|
||||
MESSAGE_CLASS_NOT_ON_SCHEDULE: Final[str] = "class {card_id} not found on the schedule"
|
||||
|
||||
## File name of the screenshot of lecture step 9 (the console with the retry warnings).
|
||||
SCREENSHOT_STEP_9: Final[str] = "step-9.png"
|
||||
|
||||
## Weekdays of the classes the bot books, as the site abbreviates them.
|
||||
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).
|
||||
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"
|
||||
|
||||
## Names of the environment variables that hold the admin account (read, not used yet).
|
||||
ADMIN_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_EMAIL"
|
||||
ADMIN_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_PASSWORD"
|
||||
|
||||
## Error for an admin account with one variable only; {name} is the missing variable.
|
||||
MESSAGE_ADMIN_HALF_SET: Final[str] = (
|
||||
"Set {name} too: the admin account needs both "
|
||||
"SNACK_AND_LIFT_ADMIN_EMAIL and SNACK_AND_LIFT_ADMIN_PASSWORD"
|
||||
)
|
||||
|
||||
## Default file with the gym accounts, relative to the current directory.
|
||||
ENV_FILE_NAME: Final[str] = ".env.gym"
|
||||
|
||||
## Folder where screenshots of a container run are written.
|
||||
SCREENSHOT_DIR: Final[str] = "docs/images"
|
||||
|
||||
@@ -43,3 +102,165 @@ HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
|
||||
"--disable-dev-shm-usage",
|
||||
"--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,"
|
||||
|
||||
## File names of the screenshots of lecture steps 7 and 8.
|
||||
SCREENSHOT_STEP_7: Final[str] = "step-7.png"
|
||||
SCREENSHOT_STEP_8: Final[str] = "step-8.png"
|
||||
|
||||
## Process exit code when the My Bookings page does not match what the bot did.
|
||||
EXIT_VERIFICATION_MISMATCH: Final[int] = 3
|
||||
|
||||
## Id of the navigation link that opens the My Bookings page.
|
||||
MY_BOOKINGS_LINK_ID: Final[str] = "my-bookings-link"
|
||||
|
||||
## Id of the My Bookings page, shown after its bookings were loaded.
|
||||
MY_BOOKINGS_PAGE_ID: Final[str] = "my-bookings-page"
|
||||
|
||||
## Id of the element shown when the account has no booking and no waitlist entry.
|
||||
EMPTY_BOOKINGS_STATE_ID: Final[str] = "empty-bookings-state"
|
||||
|
||||
## CSS selectors of the confirmed booking cards and the waitlist cards.
|
||||
BOOKING_CARD_CSS: Final[str] = "#confirmed-bookings-section div[id^='booking-card-']"
|
||||
WAITLIST_CARD_CSS: Final[str] = "#waitlist-section div[id^='waitlist-card-']"
|
||||
|
||||
## Prefixes of the element ids of a booking card and of a waitlist card.
|
||||
BOOKING_CARD_ID_PREFIX: Final[str] = "booking-card-"
|
||||
BOOKING_NAME_ID_PREFIX: Final[str] = "booking-class-name-"
|
||||
WAITLIST_CARD_ID_PREFIX: Final[str] = "waitlist-card-"
|
||||
WAITLIST_NAME_ID_PREFIX: Final[str] = "waitlist-class-name-"
|
||||
|
||||
## XPath, relative to a card, of the paragraph "When: Tue, Aug 12, 6:00 PM".
|
||||
WHEN_XPATH: Final[str] = ".//p[strong[starts-with(normalize-space(), 'When:')]]"
|
||||
|
||||
## Prefix of the date and time text of a booking card.
|
||||
WHEN_TEXT_PREFIX: Final[str] = "When:"
|
||||
|
||||
## Suffix the My Bookings page adds to the name of a waitlist entry.
|
||||
WAITLIST_NAME_SUFFIX: Final[str] = " (Waitlist)"
|
||||
|
||||
## Message of the error raised when the My Bookings page shows no booking at all.
|
||||
MESSAGE_NO_BOOKINGS_FOUND: Final[str] = "The My Bookings page shows no booking or waitlist entry"
|
||||
|
||||
## Lines of the verification on the My Bookings page (lecture steps 7 and 8).
|
||||
VERIFY_TITLE: Final[str] = "--- VERIFYING ON MY BOOKINGS PAGE ---"
|
||||
VERIFY_LINE_VERIFIED: Final[str] = " ✓ Verified: {name}"
|
||||
VERIFY_RESULT_TITLE: Final[str] = "--- VERIFICATION RESULT ---"
|
||||
VERIFY_EXPECTED: Final[str] = "Expected: {count} {noun}"
|
||||
VERIFY_FOUND: Final[str] = "Found: {count} {noun}"
|
||||
VERIFY_NOUN_SINGULAR: Final[str] = "booking"
|
||||
VERIFY_NOUN_PLURAL: Final[str] = "bookings"
|
||||
VERIFY_SUCCESS: Final[str] = "✅ SUCCESS: All bookings verified!"
|
||||
VERIFY_MISMATCH: Final[str] = "❌ MISMATCH: Missing {count} bookings"
|
||||
VERIFY_LINE_MISSING: Final[str] = " ✗ Missing: {entry}"
|
||||
VERIFY_LINE_UNEXPECTED: Final[str] = " ? Unexpected: {entry}"
|
||||
VERIFY_LINE_ALSO_BOOKED: Final[str] = " i Also booked (not checked): {entry}"
|
||||
|
||||
## Month abbreviations of the dates on the pages, January first.
|
||||
MONTH_ABBREVIATIONS: Final[tuple[str, ...]] = (
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
)
|
||||
|
||||
## Days of a year, with the 29th of February, used to compare dates that have no year.
|
||||
DAYS_IN_DATE_CYCLE: Final[int] = 366
|
||||
|
||||
## A leap year, so that the 29th of February is a valid date.
|
||||
DATE_CYCLE_YEAR: Final[int] = 2000
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""@file booking_entry.py
|
||||
@brief One entry of the My Bookings page: a confirmed booking or a waitlist entry.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.domain.day_label import parse_day_label
|
||||
from snack_and_lift.domain.errors import PageFormatError
|
||||
from snack_and_lift.domain.rules import parse_time
|
||||
|
||||
_WHEN = re.compile(r"(?P<date>[A-Z][a-z]{2}, [A-Z][a-z]{2} \d{1,2}), (?P<time>.+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BookingEntry:
|
||||
"""@brief A booking or waitlist entry, as read from the My Bookings page.
|
||||
|
||||
`name` is the class name WITHOUT the ` (Waitlist)` suffix the page adds to a waitlist
|
||||
entry; `waitlisted` carries that fact. `when_text` is the page text without the
|
||||
`When:` prefix, for example `Tue, Aug 12, 6:00 PM`.
|
||||
"""
|
||||
|
||||
name: str
|
||||
when_text: str
|
||||
waitlisted: bool
|
||||
|
||||
@classmethod
|
||||
def from_page(cls, heading: str, when_text: str, waitlisted: bool) -> "BookingEntry":
|
||||
"""@brief Build an entry from the texts of its card.
|
||||
|
||||
@param heading The class name as the page shows it, possibly with the waitlist suffix.
|
||||
@param when_text The date and time paragraph, with or without the `When:` prefix.
|
||||
@param waitlisted True for a card of the waitlist section.
|
||||
@return The entry, with the suffix and the prefix removed.
|
||||
"""
|
||||
name = heading.strip().removesuffix(constants.WAITLIST_NAME_SUFFIX.strip()).strip()
|
||||
when = when_text.strip().removeprefix(constants.WHEN_TEXT_PREFIX).strip()
|
||||
return cls(name, when, waitlisted)
|
||||
|
||||
def _split_when(self) -> tuple[str, str]:
|
||||
"""@brief Split the date text from the time text.
|
||||
|
||||
@return The date text (`Tue, Aug 12`) and the time text (`6:00 PM`).
|
||||
@exception PageFormatError The text has no date and time.
|
||||
"""
|
||||
match = _WHEN.fullmatch(self.when_text)
|
||||
if match is None:
|
||||
raise PageFormatError(f"Unexpected booking date and time: {self.when_text!r}")
|
||||
return match["date"], match["time"]
|
||||
|
||||
@property
|
||||
def weekday(self) -> str:
|
||||
"""@brief The weekday of the entry (`Tue`).
|
||||
|
||||
@exception PageFormatError The date and time text is not valid.
|
||||
"""
|
||||
return parse_day_label(self._split_when()[0])[0]
|
||||
|
||||
@property
|
||||
def date_text(self) -> str:
|
||||
"""@brief The date of the entry (`Tue, Aug 12`), comparable with a class card's date.
|
||||
|
||||
@exception PageFormatError The date and time text is not valid.
|
||||
"""
|
||||
return parse_day_label(self._split_when()[0])[1]
|
||||
|
||||
@property
|
||||
def time(self) -> tuple[int, int]:
|
||||
"""@brief The hour (0-23) and the minute of the entry.
|
||||
|
||||
@exception PageFormatError The date and time text is not valid.
|
||||
"""
|
||||
return parse_time(self._split_when()[1])
|
||||
|
||||
|
||||
def is_target_entry(entry: BookingEntry) -> bool:
|
||||
"""@brief Tell whether the entry is a class the bot books: a target weekday at the target time.
|
||||
|
||||
@param entry The entry.
|
||||
@return True for Tuesday or Thursday at `TARGET_HOUR`:`TARGET_MINUTE`.
|
||||
@exception PageFormatError The date and time text is not valid.
|
||||
"""
|
||||
return entry.weekday in constants.TARGET_WEEKDAYS and entry.time == (
|
||||
constants.TARGET_HOUR,
|
||||
constants.TARGET_MINUTE,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""@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."""
|
||||
|
||||
|
||||
class RetriesExhaustedError(GymError):
|
||||
"""@brief A step failed on every attempt; the cause is the error of the last attempt."""
|
||||
|
||||
|
||||
class NoBookingsFoundError(GymError):
|
||||
"""@brief The My Bookings page shows no booking where the bot expects some.
|
||||
|
||||
The project's counterpart of Selenium's `NoSuchElementException`, which the lecture asks
|
||||
the student to raise when the bookings that should be there are missing.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""@file verification.py
|
||||
@brief Lecture steps 7 and 8: compare what the bot did with the My Bookings page.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry
|
||||
from snack_and_lift.domain.errors import PageFormatError
|
||||
from snack_and_lift.domain.models import ClassResult, Outcome
|
||||
|
||||
_WAITLISTED_OUTCOMES = (Outcome.NEW_WAITLIST, Outcome.ALREADY_WAITLISTED)
|
||||
|
||||
## An entry is identified by its class name, its date text and whether it is waitlisted.
|
||||
_Key = tuple[str, str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerificationResult:
|
||||
"""@brief What the My Bookings page shows compared with what the bot did.
|
||||
|
||||
`verified`, `missing`, `unexpected` and `also_booked` hold display names, with the waitlist
|
||||
suffix for a waitlist entry; all but `verified` also say the day (`Spin Class on Tue, Aug 12`).
|
||||
Only entries from the earliest to the latest class the bot handled are checked: `found`
|
||||
counts them, and `also_booked` lists the entries outside that window, which never cause
|
||||
a mismatch.
|
||||
"""
|
||||
|
||||
expected: int
|
||||
found: int
|
||||
verified: list[str]
|
||||
missing: list[str]
|
||||
unexpected: list[str]
|
||||
also_booked: list[str]
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
"""@brief True when every expected entry was found and nothing else was."""
|
||||
return self.expected == self.found and not self.missing and not self.unexpected
|
||||
|
||||
|
||||
def _display_name(name: str, waitlisted: bool) -> str:
|
||||
"""@brief The name as the page shows it."""
|
||||
return name + constants.WAITLIST_NAME_SUFFIX if waitlisted else name
|
||||
|
||||
|
||||
def _expected_keys(results: Sequence[ClassResult]) -> list[_Key]:
|
||||
"""@brief The entries the bot's results say must be on the page."""
|
||||
return [(r.card.name, r.card.date_text, r.outcome in _WAITLISTED_OUTCOMES) for r in results]
|
||||
|
||||
|
||||
def _entry_keys(entries: Sequence[BookingEntry]) -> list[_Key]:
|
||||
"""@brief The identity of each entry found on the page."""
|
||||
return [(e.name, e.date_text, e.waitlisted) for e in entries]
|
||||
|
||||
|
||||
def _describe(key: _Key) -> str:
|
||||
"""@brief Write an entry for the missing and unexpected lines."""
|
||||
name, day, waitlisted = key
|
||||
return f"{_display_name(name, waitlisted)} on {day}"
|
||||
|
||||
|
||||
def _day_number(date_text: str) -> int:
|
||||
"""@brief The position of a date (`Tue, Aug 12`) in a year; the page shows no year.
|
||||
|
||||
@exception PageFormatError The text has no valid month and day.
|
||||
"""
|
||||
month_text, _, day_text = date_text.partition(", ")[2].partition(" ")
|
||||
try:
|
||||
month = constants.MONTH_ABBREVIATIONS.index(month_text) + 1
|
||||
day = date(constants.DATE_CYCLE_YEAR, month, int(day_text))
|
||||
except ValueError as error:
|
||||
raise PageFormatError(f"Unexpected date: {date_text!r}") from error
|
||||
return day.timetuple().tm_yday
|
||||
|
||||
|
||||
def _offset(day: int, start: int) -> int:
|
||||
"""@brief The days from `start` to `day`, counting over the turn of the year."""
|
||||
return (day - start) % constants.DAYS_IN_DATE_CYCLE
|
||||
|
||||
|
||||
def _window(keys: Sequence[_Key]) -> tuple[int, int] | None:
|
||||
"""@brief The first day and the length in days of the shortest span holding all the keys.
|
||||
|
||||
@return None when there is no key.
|
||||
"""
|
||||
days = [_day_number(key[1]) for key in keys]
|
||||
spans = [(max(_offset(day, start) for day in days), start) for start in days]
|
||||
if not spans:
|
||||
return None
|
||||
span, start = min(spans)
|
||||
return start, span
|
||||
|
||||
|
||||
def _is_in_window(key: _Key, window: tuple[int, int] | None) -> bool:
|
||||
"""@brief Tell whether the date of the key lies in the window, ends included."""
|
||||
if window is None:
|
||||
return False
|
||||
start, span = window
|
||||
return _offset(_day_number(key[1]), start) <= span
|
||||
|
||||
|
||||
def build_verification(
|
||||
results: Sequence[ClassResult], entries: Sequence[BookingEntry]
|
||||
) -> VerificationResult:
|
||||
"""@brief Compare the results of the booking with the target entries of the page.
|
||||
|
||||
@param results What happened to each class; waitlisted when the outcome is a waitlist one.
|
||||
@param entries The Tuesday and Thursday 6pm entries found on the page.
|
||||
@return The comparison; only the entries from the earliest to the latest class handled
|
||||
are checked, the others are `also_booked`, and no class handled means no check.
|
||||
@exception PageFormatError An entry or a card has no valid date.
|
||||
"""
|
||||
expected = _expected_keys(results)
|
||||
window = _window(expected)
|
||||
keys = _entry_keys(entries)
|
||||
checked = [key for key in keys if _is_in_window(key, window)]
|
||||
also_booked = [_describe(key) for key in keys if not _is_in_window(key, window)]
|
||||
remaining = Counter(checked)
|
||||
verified: list[str] = []
|
||||
missing: list[str] = []
|
||||
for key in expected:
|
||||
if remaining[key] > 0:
|
||||
remaining[key] -= 1
|
||||
verified.append(_display_name(key[0], key[2]))
|
||||
else:
|
||||
missing.append(_describe(key))
|
||||
unexpected = [_describe(key) for key in remaining.elements()]
|
||||
return VerificationResult(
|
||||
len(expected), len(checked), verified, missing, unexpected, also_booked
|
||||
)
|
||||
|
||||
|
||||
def _count_text(template: str, count: int) -> str:
|
||||
"""@brief Write a count with `booking` in the singular for 1."""
|
||||
noun = constants.VERIFY_NOUN_SINGULAR if count == 1 else constants.VERIFY_NOUN_PLURAL
|
||||
return template.format(count=count, noun=noun)
|
||||
|
||||
|
||||
def format_verification(result: VerificationResult) -> str:
|
||||
"""@brief Write the verification block.
|
||||
|
||||
@param result The comparison.
|
||||
@return The block, starting with `--- VERIFYING ON MY BOOKINGS PAGE ---`.
|
||||
"""
|
||||
lines = [constants.VERIFY_TITLE]
|
||||
lines.extend(constants.VERIFY_LINE_VERIFIED.format(name=name) for name in result.verified)
|
||||
lines.append("")
|
||||
lines.append(constants.VERIFY_RESULT_TITLE)
|
||||
lines.append(_count_text(constants.VERIFY_EXPECTED, result.expected))
|
||||
lines.append(_count_text(constants.VERIFY_FOUND, result.found))
|
||||
lines.extend(constants.VERIFY_LINE_ALSO_BOOKED.format(entry=t) for t in result.also_booked)
|
||||
if result.is_success:
|
||||
lines.append(constants.VERIFY_SUCCESS)
|
||||
return "\n".join(lines)
|
||||
lines.append(constants.VERIFY_MISMATCH.format(count=len(result.missing)))
|
||||
lines.extend(constants.VERIFY_LINE_MISSING.format(entry=text) for text in result.missing)
|
||||
lines.extend(constants.VERIFY_LINE_UNEXPECTED.format(entry=text) for text in result.unexpected)
|
||||
return "\n".join(lines)
|
||||
@@ -1,8 +1,9 @@
|
||||
"""@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
|
||||
from pathlib import Path
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
@@ -11,23 +12,49 @@ from selenium.webdriver.chrome.service import Service
|
||||
from snack_and_lift import constants
|
||||
|
||||
|
||||
def create_driver(headless: bool) -> webdriver.Chrome:
|
||||
"""@brief Start Chrome.
|
||||
def default_profile_dir() -> Path:
|
||||
"""@brief The folder where Chrome keeps the profile of the bot.
|
||||
|
||||
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.
|
||||
@return `chrome_profile` below the working directory.
|
||||
"""
|
||||
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.
|
||||
@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.add_argument(f"--user-data-dir={profile_dir}")
|
||||
options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
|
||||
if keep_open:
|
||||
options.add_experimental_option("detach", True)
|
||||
if headless:
|
||||
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
|
||||
options.add_argument(argument)
|
||||
binary = os.environ.get(constants.CHROME_BIN_ENV)
|
||||
if 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)
|
||||
service = Service(executable_path=driver_path) if driver_path else Service()
|
||||
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,44 @@
|
||||
"""@file env_file.py
|
||||
@brief Reads `KEY=VALUE` lines from an env file (never logs a value).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
## Prefix a line may carry so the file can also be sourced by a shell.
|
||||
_EXPORT_PREFIX = "export "
|
||||
|
||||
## Quote characters that may surround a value.
|
||||
_QUOTES = ("'", '"')
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
"""@brief Strip one pair of matching surrounding quotes.
|
||||
|
||||
@param value The text after the `=`.
|
||||
@return The value without the surrounding pair, otherwise unchanged.
|
||||
"""
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def read_env_file(path: Path) -> dict[str, str]:
|
||||
"""@brief Read the variables of an env file.
|
||||
|
||||
Blank lines, `#` comments, lines without `=` and an empty key are skipped.
|
||||
|
||||
@param path The file.
|
||||
@return The variables; empty when the file does not exist.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
variables: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, _, value = stripped.partition("=")
|
||||
key = key.strip().removeprefix(_EXPORT_PREFIX).strip()
|
||||
if key:
|
||||
variables[key] = _unquote(value.strip())
|
||||
return variables
|
||||
@@ -0,0 +1,262 @@
|
||||
"""@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.booking_entry import BookingEntry
|
||||
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.
|
||||
"""
|
||||
# After a failed login the form is still shown, so a repeated login skips the nav button.
|
||||
if not self._driver.find_elements(By.ID, constants.LOGIN_EMAIL_INPUT_ID):
|
||||
self._wait.until(
|
||||
ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))
|
||||
).click()
|
||||
email_input = self._wait.until(
|
||||
ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
|
||||
)
|
||||
password_input = self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID)
|
||||
for field, value in ((email_input, email), (password_input, password)):
|
||||
field.clear()
|
||||
field.send_keys(value)
|
||||
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 open_my_bookings(self) -> None:
|
||||
"""@brief Click the My Bookings link and wait for the page.
|
||||
|
||||
@exception GymError The page did not load.
|
||||
"""
|
||||
self._wait.until(ec.element_to_be_clickable((By.ID, constants.MY_BOOKINGS_LINK_ID))).click()
|
||||
self._wait.until(ec.presence_of_element_located((By.ID, constants.MY_BOOKINGS_PAGE_ID)))
|
||||
|
||||
@_translate_errors
|
||||
def list_my_bookings(self) -> list[BookingEntry]:
|
||||
"""@brief Wait for the bookings to load, then read the confirmed and waitlist cards.
|
||||
|
||||
@return The entries, confirmed ones first; empty when the page shows its empty state.
|
||||
@exception GymError The page shows an error box, or nothing was shown in time.
|
||||
"""
|
||||
self._wait.until(
|
||||
ec.any_of(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, constants.BOOKING_CARD_CSS)),
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, constants.WAITLIST_CARD_CSS)),
|
||||
ec.presence_of_element_located((By.ID, constants.EMPTY_BOOKINGS_STATE_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 GymError(errors[0].text or "My Bookings error")
|
||||
confirmed = self._driver.find_elements(By.CSS_SELECTOR, constants.BOOKING_CARD_CSS)
|
||||
waitlist = self._driver.find_elements(By.CSS_SELECTOR, constants.WAITLIST_CARD_CSS)
|
||||
return [
|
||||
self._read_entry(
|
||||
element, constants.BOOKING_CARD_ID_PREFIX, constants.BOOKING_NAME_ID_PREFIX, False
|
||||
)
|
||||
for element in confirmed
|
||||
] + [
|
||||
self._read_entry(
|
||||
element, constants.WAITLIST_CARD_ID_PREFIX, constants.WAITLIST_NAME_ID_PREFIX, True
|
||||
)
|
||||
for element in waitlist
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _read_entry(
|
||||
element: WebElement, card_prefix: str, name_prefix: str, waitlisted: bool
|
||||
) -> BookingEntry:
|
||||
"""@brief Turn one booking or waitlist card into plain data.
|
||||
|
||||
@param element The card.
|
||||
@param card_prefix The prefix of the card id.
|
||||
@param name_prefix The prefix of the id of the card's heading.
|
||||
@param waitlisted True for a card of the waitlist section.
|
||||
@return The entry.
|
||||
"""
|
||||
card_id = (element.get_attribute("id") or "").removeprefix(card_prefix)
|
||||
return BookingEntry.from_page(
|
||||
heading=element.find_element(By.ID, name_prefix + card_id).text,
|
||||
when_text=element.find_element(By.XPATH, constants.WHEN_XPATH).text,
|
||||
waitlisted=waitlisted,
|
||||
)
|
||||
|
||||
@_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()
|
||||
@@ -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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!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";
|
||||
// Imitates the network simulation: ?fail=N drops the first N submits after a short delay.
|
||||
let failuresLeft = parseInt(new URLSearchParams(window.location.search).get("fail") || "0", 10);
|
||||
document.getElementById("login-form").addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
document.getElementById("message-area").innerHTML = "";
|
||||
if (failuresLeft > 0) {
|
||||
failuresLeft -= 1;
|
||||
setTimeout(function () {
|
||||
document.getElementById("message-area").innerHTML =
|
||||
'<div id="error-message">Network request failed. Please try again.</div>';
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Fixture gym my bookings</title></head>
|
||||
<body>
|
||||
<nav><a id="my-bookings-link" href="my_bookings.html">My Bookings</a></nav>
|
||||
<div id="my-bookings-page" data-bookings-count="0" data-waitlist-count="0" data-loading="true">
|
||||
<h1>My Bookings</h1>
|
||||
<p id="loading-placeholder">Loading your bookings...</p>
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
<script>
|
||||
// Imitates the site: a loading text, then the page for the state in the query
|
||||
// (?state=empty or ?state=error; anything else shows two bookings and a waitlist entry;
|
||||
// ?extra=future adds three more Tuesday and Thursday 6pm bookings after the others).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get("state");
|
||||
// ?fail=N shows the error for the first N loads of the page in this tab (the network
|
||||
// simulation); the count lives in sessionStorage because every load starts a new page.
|
||||
const loads = parseInt(sessionStorage.getItem("loads") || "0", 10);
|
||||
const dropped = loads < parseInt(params.get("fail") || "0", 10);
|
||||
sessionStorage.setItem("loads", String(loads + 1));
|
||||
// The nav link keeps the state, so opening the page from here shows the same state again.
|
||||
document.getElementById("my-bookings-link").href = "my_bookings.html" + window.location.search;
|
||||
function futureBookings() {
|
||||
const whens = ["Thu, Aug 21, 6:00 PM", "Tue, Aug 26, 6:00 PM", "Thu, Aug 28, 6:00 PM"];
|
||||
return whens.map(function (when, i) {
|
||||
return '<div id="booking-card-' + (13 + i) + '" data-booking-status="confirmed">' +
|
||||
'<h3 id="booking-class-name-' + (13 + i) + '">Spin Class</h3>' +
|
||||
'<p><strong>When:</strong> ' + when + '</p></div>';
|
||||
}).join("");
|
||||
}
|
||||
const page = document.getElementById("my-bookings-page");
|
||||
const content = document.getElementById("content");
|
||||
setTimeout(function () {
|
||||
document.getElementById("loading-placeholder").remove();
|
||||
page.dataset.loading = "false";
|
||||
if (state === "error" || dropped) {
|
||||
content.innerHTML = '<div id="error-message">Failed to load bookings</div>';
|
||||
} else if (state === "empty") {
|
||||
content.innerHTML = '<div id="empty-bookings-state">You have no bookings yet</div>';
|
||||
} else {
|
||||
const extra = params.get("extra") === "future" ? futureBookings() : "";
|
||||
page.dataset.bookingsCount = params.get("extra") === "future" ? "5" : "2";
|
||||
page.dataset.waitlistCount = "1";
|
||||
content.innerHTML =
|
||||
'<div id="confirmed-bookings-section">' +
|
||||
'<div id="booking-card-11" data-booking-status="confirmed">' +
|
||||
'<h3 id="booking-class-name-11">Spin Class</h3>' +
|
||||
'<p><strong>When:</strong> Tue, Aug 12, 6:00 PM</p></div>' +
|
||||
'<div id="booking-card-12" data-booking-status="confirmed">' +
|
||||
'<h3 id="booking-class-name-12">Yoga Class</h3>' +
|
||||
'<p><strong>When:</strong> Wed, Aug 13, 7:00 AM</p></div>' +
|
||||
extra +
|
||||
'</div>' +
|
||||
'<div id="waitlist-section">' +
|
||||
'<div id="waitlist-card-21" data-booking-status="waitlisted">' +
|
||||
'<h3 id="waitlist-class-name-21">HIIT Class (Waitlist)</h3>' +
|
||||
'<p><strong>When:</strong> Thu, Aug 14, 6:00 PM</p></div>' +
|
||||
'</div>';
|
||||
}
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Fixture gym schedule</title></head>
|
||||
<body>
|
||||
<nav><a id="my-bookings-link" href="my_bookings.html">My Bookings</a></nav>
|
||||
<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-times="1">
|
||||
<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.
|
||||
// ?fail=N makes every button fail on its first N clicks (the network simulation).
|
||||
const failParam = parseInt(new URLSearchParams(window.location.search).get("fail") || "0", 10);
|
||||
document.querySelectorAll("div[id^='class-card-']").forEach(function (card) {
|
||||
card.dataset.failLeft = String(Math.max(failParam, parseInt(card.dataset.failTimes || "0", 10)));
|
||||
});
|
||||
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 (parseInt(card.dataset.failLeft, 10) > 0) {
|
||||
card.dataset.failLeft = String(parseInt(card.dataset.failLeft, 10) - 1);
|
||||
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,6 +3,8 @@
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.common.by import By
|
||||
@@ -22,7 +24,7 @@ pytestmark = [
|
||||
|
||||
|
||||
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:
|
||||
driver.get(constants.GYM_URL)
|
||||
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""@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 Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from snack_and_lift.application.booking import book_class_with_retry, book_target_classes
|
||||
from snack_and_lift.application.credentials import Credentials
|
||||
from snack_and_lift.application.session import login_with_retry
|
||||
from snack_and_lift.application.verification import get_my_bookings, get_my_bookings_with_retry
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry
|
||||
from snack_and_lift.domain.errors import (
|
||||
GymError,
|
||||
LoginFailedError,
|
||||
NoBookingsFoundError,
|
||||
RetriesExhaustedError,
|
||||
)
|
||||
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:
|
||||
# Card 302 drops its first click; the booking repeats it and the page confirms the second.
|
||||
results = book_target_classes(schedule_gym, sleep=lambda _: None)
|
||||
|
||||
assert [(r.card.card_id, r.outcome) for r in results] == [
|
||||
("201", Outcome.NEW_WAITLIST),
|
||||
("302", Outcome.NEW_BOOKING),
|
||||
]
|
||||
|
||||
|
||||
FIXTURE_MY_BOOKINGS = (Path(__file__).parent / "fixtures" / "my_bookings.html").as_uri()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_my_bookings_page(tmp_path: Path) -> Iterator[Callable[[str], SeleniumGym]]:
|
||||
"""@brief Opens the My Bookings fixture in the given state (a query such as `?state=empty`)."""
|
||||
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
|
||||
|
||||
def open_page(query: str = "") -> SeleniumGym:
|
||||
driver.get(FIXTURE_MY_BOOKINGS + query)
|
||||
return SeleniumGym(driver, base_url=FIXTURE_MY_BOOKINGS)
|
||||
|
||||
try:
|
||||
yield open_page
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
|
||||
def test_open_my_bookings_follows_the_nav_link(schedule_gym: SeleniumGym) -> None:
|
||||
schedule_gym.open_my_bookings()
|
||||
|
||||
assert [entry.name for entry in schedule_gym.list_my_bookings()] == [
|
||||
"Spin Class",
|
||||
"Yoga Class",
|
||||
"HIIT Class",
|
||||
]
|
||||
|
||||
|
||||
def test_list_my_bookings_reads_confirmed_and_waitlist_entries(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
entries = open_my_bookings_page("").list_my_bookings()
|
||||
|
||||
assert entries == [
|
||||
BookingEntry("Spin Class", "Tue, Aug 12, 6:00 PM", False),
|
||||
BookingEntry("Yoga Class", "Wed, Aug 13, 7:00 AM", False),
|
||||
BookingEntry("HIIT Class", "Thu, Aug 14, 6:00 PM", True),
|
||||
]
|
||||
|
||||
|
||||
def test_get_my_bookings_returns_bookings_listed_after_the_others(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
entries = get_my_bookings(open_my_bookings_page("?extra=future"))
|
||||
|
||||
assert [entry.when_text for entry in entries][-4:] == [
|
||||
"Thu, Aug 21, 6:00 PM",
|
||||
"Tue, Aug 26, 6:00 PM",
|
||||
"Thu, Aug 28, 6:00 PM",
|
||||
"Thu, Aug 14, 6:00 PM",
|
||||
]
|
||||
assert len(entries) == 6
|
||||
|
||||
|
||||
def test_list_my_bookings_returns_nothing_for_the_empty_state(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
assert open_my_bookings_page("?state=empty").list_my_bookings() == []
|
||||
|
||||
|
||||
def test_get_my_bookings_raises_for_the_empty_state(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
with pytest.raises(NoBookingsFoundError):
|
||||
get_my_bookings(open_my_bookings_page("?state=empty"))
|
||||
|
||||
|
||||
def test_list_my_bookings_raises_with_the_text_of_the_error_box(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
with pytest.raises(GymError, match="Failed to load bookings"):
|
||||
open_my_bookings_page("?state=error").list_my_bookings()
|
||||
|
||||
|
||||
FIXTURE_LOGIN = (Path(__file__).parent / "fixtures" / "login.html").as_uri()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_fixture(tmp_path: Path) -> Iterator[Callable[[str], SeleniumGym]]:
|
||||
"""@brief Opens a fixture page by its address, with the query that sets its failures."""
|
||||
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
|
||||
|
||||
def open_page(url: str) -> SeleniumGym:
|
||||
driver.get(url)
|
||||
return SeleniumGym(driver, base_url=url)
|
||||
|
||||
try:
|
||||
yield open_page
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
|
||||
def test_login_with_retry_logs_in_after_two_dropped_requests(
|
||||
open_fixture: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
gym = open_fixture(FIXTURE_LOGIN + "?fail=2")
|
||||
sleeps: list[float] = []
|
||||
|
||||
login_with_retry(gym, Credentials(FIXTURE_EMAIL, FIXTURE_PASSWORD), sleep=sleeps.append)
|
||||
|
||||
assert len(sleeps) == 2
|
||||
|
||||
|
||||
def test_login_with_retry_gives_up_when_every_request_is_dropped(
|
||||
open_fixture: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
gym = open_fixture(FIXTURE_LOGIN + "?fail=5")
|
||||
|
||||
with pytest.raises(RetriesExhaustedError, match="Network request failed"):
|
||||
login_with_retry(
|
||||
gym, Credentials(FIXTURE_EMAIL, FIXTURE_PASSWORD), retries=3, sleep=lambda _: None
|
||||
)
|
||||
|
||||
|
||||
def test_book_class_with_retry_books_after_two_dropped_clicks(
|
||||
open_fixture: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
gym = open_fixture(FIXTURE_SCHEDULE + "?fail=2")
|
||||
card = next(card for card in gym.list_class_cards() if card.card_id == "302")
|
||||
|
||||
result = book_class_with_retry(gym, card, sleep=lambda _: None)
|
||||
|
||||
assert result.outcome is Outcome.NEW_BOOKING
|
||||
assert next(c for c in gym.list_class_cards() if c.card_id == "302").button_text == "Booked"
|
||||
|
||||
|
||||
def test_book_target_classes_completes_when_every_class_drops_two_clicks(
|
||||
open_fixture: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
gym = open_fixture(FIXTURE_SCHEDULE + "?fail=2")
|
||||
|
||||
results = book_target_classes(gym, sleep=lambda _: None)
|
||||
|
||||
assert [(r.card.card_id, r.outcome) for r in results] == [
|
||||
("201", Outcome.NEW_WAITLIST),
|
||||
("302", Outcome.NEW_BOOKING),
|
||||
]
|
||||
|
||||
|
||||
def test_get_my_bookings_with_retry_reads_the_page_after_two_failed_loads(
|
||||
open_fixture: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
gym = open_fixture(FIXTURE_MY_BOOKINGS + "?fail=2")
|
||||
sleeps: list[float] = []
|
||||
|
||||
entries = get_my_bookings_with_retry(gym, sleep=sleeps.append)
|
||||
|
||||
assert [entry.name for entry in entries] == ["Spin Class", "Yoga Class", "HIIT Class"]
|
||||
assert sleeps
|
||||
@@ -0,0 +1,176 @@
|
||||
"""@file fakes.py
|
||||
@brief A fake of the GymPort for the unit tests.
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry
|
||||
from snack_and_lift.domain.errors import GymError, LoginFailedError
|
||||
from snack_and_lift.domain.models import ButtonState, ClassCard
|
||||
|
||||
_PRESS_RESULTS = {
|
||||
constants.BUTTON_TEXT_BOOK: constants.BUTTON_TEXT_BOOKED,
|
||||
constants.BUTTON_TEXT_JOIN_WAITLIST: constants.BUTTON_TEXT_WAITLISTED,
|
||||
}
|
||||
|
||||
|
||||
_WAITLISTED_BY_STATE = {
|
||||
ButtonState.BOOK: False,
|
||||
ButtonState.BOOKED: False,
|
||||
ButtonState.JOIN_WAITLIST: True,
|
||||
ButtonState.WAITLISTED: True,
|
||||
}
|
||||
|
||||
|
||||
def _entry_of(card: ClassCard) -> BookingEntry | None:
|
||||
"""@brief The My Bookings entry that a card ends in, or None when it is not booked."""
|
||||
flag = _WAITLISTED_BY_STATE.get(card.state)
|
||||
if flag is None:
|
||||
return None
|
||||
time_text = card.time_text.removeprefix(constants.TIME_TEXT_PREFIX).strip()
|
||||
return BookingEntry(card.name, f"{card.date_text}, {time_text}", flag)
|
||||
|
||||
|
||||
NETWORK_ERROR_TEXT = "Network request failed. Please try again."
|
||||
|
||||
|
||||
class FakeGym:
|
||||
"""@brief Records the calls; can refuse the login, serve cards and fail a click.
|
||||
|
||||
Unless `bookings` is given, the My Bookings page shows every card that is, or by the
|
||||
click becomes, booked or waitlisted. A successful click turns the card's button into its
|
||||
new state, so a card read again shows what the click did.
|
||||
|
||||
`fail_first` is a deterministic failure schedule: it maps the name of a port method
|
||||
(`log_in`, `press_class_button`, `open_my_bookings`, `list_my_bookings`) to the number of
|
||||
its first calls that fail like a dropped request. With `click_goes_through`, a failing
|
||||
`press_class_button` still turns the button (the click arrived, the answer did not).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
login_error: str | None = None,
|
||||
cards: list[ClassCard] | None = None,
|
||||
press_result: str | None = None,
|
||||
press_error: str | None = None,
|
||||
bookings: list[BookingEntry] | None = None,
|
||||
bookings_error: str | None = None,
|
||||
fail_first: dict[str, int] | None = None,
|
||||
click_goes_through: bool = False,
|
||||
) -> None:
|
||||
self.login_error = login_error
|
||||
self.cards = cards or []
|
||||
self.press_result = press_result
|
||||
self.press_error = press_error
|
||||
self.bookings = bookings
|
||||
self.bookings_error = bookings_error
|
||||
self.click_goes_through = click_goes_through
|
||||
self._failures_left = dict(fail_first or {})
|
||||
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 _should_fail(self, name: str) -> bool:
|
||||
"""@brief Whether this call is one of the scheduled failures; counts it."""
|
||||
left = self._failures_left.get(name, 0)
|
||||
if left > 0:
|
||||
self._failures_left[name] = left - 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def _turn_button(self, card_id: str) -> str:
|
||||
"""@brief Turn the button of a card to the state its click gives; return the new text."""
|
||||
index = next(i for i, card in enumerate(self.cards) if card.card_id == card_id)
|
||||
card = self.cards[index]
|
||||
new_text = _PRESS_RESULTS[card.button_text]
|
||||
self.cards[index] = replace(card, button_text=new_text)
|
||||
return new_text
|
||||
|
||||
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)
|
||||
if self._should_fail("log_in"):
|
||||
raise LoginFailedError(NETWORK_ERROR_TEXT)
|
||||
|
||||
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
|
||||
if self._should_fail("press_class_button"):
|
||||
if self.click_goes_through:
|
||||
self._turn_button(card_id)
|
||||
raise GymError(NETWORK_ERROR_TEXT)
|
||||
return self._turn_button(card_id)
|
||||
|
||||
def open_my_bookings(self) -> None:
|
||||
self.calls.append("open_my_bookings")
|
||||
if self._should_fail("open_my_bookings"):
|
||||
raise GymError(NETWORK_ERROR_TEXT)
|
||||
|
||||
def list_my_bookings(self) -> list[BookingEntry]:
|
||||
self.calls.append("list_my_bookings")
|
||||
if self._should_fail("list_my_bookings"):
|
||||
raise GymError(NETWORK_ERROR_TEXT)
|
||||
if self.bookings_error is not None:
|
||||
raise GymError(self.bookings_error)
|
||||
if self.bookings is not None:
|
||||
return list(self.bookings)
|
||||
entries = (_entry_of(card) for card in self.cards)
|
||||
return [entry for entry in entries if entry is not None]
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ChaosGym(FakeGym):
|
||||
"""@brief A gym whose login, click and bookings read each fail with a set probability.
|
||||
|
||||
The site's network simulation fails up to 50% of these requests and writes nothing when
|
||||
one fails. The random numbers come from a seeded generator, so a run is repeatable.
|
||||
"""
|
||||
|
||||
_FAILING_CALLS = frozenset({"log_in", "press_class_button", "list_my_bookings"})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rng: random.Random,
|
||||
failure_probability: float = 0.5,
|
||||
cards: list[ClassCard] | None = None,
|
||||
) -> None:
|
||||
super().__init__(cards=cards)
|
||||
self._rng = rng
|
||||
self._failure_probability = failure_probability
|
||||
|
||||
def _should_fail(self, name: str) -> bool:
|
||||
return name in self._FAILING_CALLS and self._rng.random() < self._failure_probability
|
||||
|
||||
|
||||
def no_sleep(seconds: float) -> None:
|
||||
"""@brief A `sleep` that returns at once, so a retry test never waits."""
|
||||
@@ -0,0 +1,93 @@
|
||||
"""@file test_application_verification.py
|
||||
@brief Reading the My Bookings page and verifying the bookings, against a fake gym.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from tests.unit.fakes import FakeGym, no_sleep
|
||||
from tests.unit.test_booking_entry import make_entry
|
||||
from tests.unit.test_models import make_card
|
||||
|
||||
from snack_and_lift.application.booking import book_target_classes
|
||||
from snack_and_lift.application.verification import get_my_bookings, verify_bookings
|
||||
from snack_and_lift.domain.errors import GymError, NoBookingsFoundError, RetriesExhaustedError
|
||||
from snack_and_lift.domain.models import ClassResult, Outcome
|
||||
|
||||
|
||||
def test_get_my_bookings_opens_the_page_and_returns_all_entries() -> None:
|
||||
entries = [make_entry("Wed, Aug 13, 7:00 AM"), make_entry(waitlisted=True)]
|
||||
gym = FakeGym(bookings=entries)
|
||||
|
||||
assert get_my_bookings(gym) == entries
|
||||
assert gym.calls == ["open_my_bookings", "list_my_bookings"]
|
||||
|
||||
|
||||
def test_get_my_bookings_raises_when_the_page_has_no_entry() -> None:
|
||||
with pytest.raises(NoBookingsFoundError):
|
||||
get_my_bookings(FakeGym(bookings=[]))
|
||||
|
||||
|
||||
def test_get_my_bookings_propagates_the_page_error() -> None:
|
||||
with pytest.raises(GymError, match="Failed to load"):
|
||||
get_my_bookings(FakeGym(bookings_error="Failed to load bookings"))
|
||||
|
||||
|
||||
def test_verify_bookings_succeeds_for_the_booked_classes() -> None:
|
||||
gym = FakeGym(
|
||||
cards=[
|
||||
make_card("Tue, Aug 12", card_id="1"),
|
||||
make_card("Thu, Aug 14", card_id="2", button_text="Join Waitlist"),
|
||||
]
|
||||
)
|
||||
results = book_target_classes(gym)
|
||||
|
||||
verification = verify_bookings(gym, results)
|
||||
|
||||
assert verification.is_success
|
||||
assert verification.verified == ["Spin Class", "Spin Class (Waitlist)"]
|
||||
|
||||
|
||||
def test_verify_bookings_ignores_entries_that_are_not_target_classes() -> None:
|
||||
gym = FakeGym(bookings=[make_entry(), make_entry("Wed, Aug 13, 6:00 PM")])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
assert verify_bookings(gym, results).is_success
|
||||
|
||||
|
||||
def test_verify_bookings_lists_target_entries_after_the_window_as_also_booked() -> None:
|
||||
gym = FakeGym(bookings=[make_entry(), make_entry("Thu, Aug 21, 6:00 PM")])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
verification = verify_bookings(gym, results)
|
||||
|
||||
assert verification.is_success
|
||||
assert verification.also_booked == ["Spin Class on Thu, Aug 21"]
|
||||
|
||||
|
||||
def test_verify_bookings_reports_a_missing_booking() -> None:
|
||||
gym = FakeGym(bookings=[make_entry("Wed, Aug 13, 7:00 AM")])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
verification = verify_bookings(gym, results)
|
||||
|
||||
assert not verification.is_success
|
||||
assert verification.missing == ["Spin Class on Tue, Aug 12"]
|
||||
|
||||
|
||||
def test_verify_bookings_raises_when_classes_were_booked_but_the_page_is_empty() -> None:
|
||||
gym = FakeGym(bookings=[])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
with pytest.raises(RetriesExhaustedError) as caught:
|
||||
verify_bookings(gym, results, sleep=no_sleep)
|
||||
|
||||
assert isinstance(caught.value.__cause__, NoBookingsFoundError)
|
||||
|
||||
|
||||
def test_verify_bookings_skips_the_page_when_nothing_is_expected() -> None:
|
||||
gym = FakeGym()
|
||||
|
||||
verification = verify_bookings(gym, [])
|
||||
|
||||
assert verification.is_success
|
||||
assert verification.expected == 0
|
||||
assert gym.calls == []
|
||||
@@ -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, no_sleep
|
||||
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, sleep=no_sleep)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""@file test_booking_entry.py
|
||||
@brief The entries of the My Bookings page: parsing and the target test.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry, is_target_entry
|
||||
from snack_and_lift.domain.errors import GymError, NoBookingsFoundError, PageFormatError
|
||||
|
||||
|
||||
def make_entry(
|
||||
when_text: str = "Tue, Aug 12, 6:00 PM", name: str = "Spin Class", waitlisted: bool = False
|
||||
) -> BookingEntry:
|
||||
return BookingEntry(name, when_text, waitlisted)
|
||||
|
||||
|
||||
def test_entry_derives_weekday_date_and_time() -> None:
|
||||
entry = make_entry("Thu, Aug 14, 6:00 PM")
|
||||
|
||||
assert entry.weekday == "Thu"
|
||||
assert entry.date_text == "Thu, Aug 14"
|
||||
assert entry.time == (18, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"when_text", ["", "Someday", "Tue, Aug 12", "Tue, Aug 12, noon", "6:00 PM"]
|
||||
)
|
||||
def test_entry_with_bad_when_text_raises(when_text: str) -> None:
|
||||
with pytest.raises(PageFormatError):
|
||||
is_target_entry(make_entry(when_text))
|
||||
|
||||
|
||||
def test_from_page_removes_the_waitlist_suffix_and_the_when_prefix() -> None:
|
||||
entry = BookingEntry.from_page("Spin Class (Waitlist)", "When: Tue, Aug 12, 6:00 PM", True)
|
||||
|
||||
assert entry == BookingEntry("Spin Class", "Tue, Aug 12, 6:00 PM", True)
|
||||
|
||||
|
||||
def test_from_page_keeps_a_plain_name() -> None:
|
||||
assert BookingEntry.from_page("Spin Class", "Tue, Aug 12, 6:00 PM", False).name == "Spin Class"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("when_text", "expected"),
|
||||
[
|
||||
("Tue, Aug 12, 6:00 PM", True),
|
||||
("Thu, Aug 14, 6:00 PM", True),
|
||||
("Wed, Aug 13, 6:00 PM", False),
|
||||
("Tue, Aug 12, 6:00 AM", False),
|
||||
("Thu, Aug 14, 6:30 PM", False),
|
||||
],
|
||||
)
|
||||
def test_is_target_entry(when_text: str, expected: bool) -> None:
|
||||
assert is_target_entry(make_entry(when_text)) is expected
|
||||
|
||||
|
||||
def test_entry_is_immutable() -> None:
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
make_entry().name = "Other" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_no_bookings_found_is_a_gym_error() -> None:
|
||||
assert issubclass(NoBookingsFoundError, GymError)
|
||||
@@ -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"
|
||||
@@ -1,8 +1,33 @@
|
||||
"""@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, no_sleep
|
||||
from tests.unit.test_booking_entry import make_entry
|
||||
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.infrastructure.browser import default_profile_dir
|
||||
|
||||
DUMMY_EMAIL = "dummy@example.invalid"
|
||||
DUMMY_PASSWORD = "dummy-password"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run from an empty folder so the default env file is never a real one."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
|
||||
@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:
|
||||
@@ -13,5 +38,360 @@ def test_headless_flag_is_read() -> None:
|
||||
assert parse_args(["--headless"]).headless is True
|
||||
|
||||
|
||||
def test_main_returns_zero() -> None:
|
||||
assert main([]) == 0
|
||||
def test_browser_stays_open_by_default_when_not_headless() -> None:
|
||||
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",
|
||||
tmp_path / "step-7.png",
|
||||
]
|
||||
assert [path for _, path in gym.text_screenshots] == [
|
||||
tmp_path / "step-4.png",
|
||||
tmp_path / "step-5.png",
|
||||
tmp_path / "step-6.png",
|
||||
tmp_path / "step-8.png",
|
||||
tmp_path / "step-9.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",
|
||||
"save_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",
|
||||
"open_my_bookings",
|
||||
"list_my_bookings",
|
||||
"save_screenshot",
|
||||
"save_text_screenshot",
|
||||
"save_text_screenshot",
|
||||
]
|
||||
assert gym.screenshots[-2:] == [tmp_path / "step-3.png", tmp_path / "step-7.png"]
|
||||
(_, _), (step_5, _), (step_6, _), (step_8, step_8_path), (step_9, step_9_path) = (
|
||||
gym.text_screenshots
|
||||
)
|
||||
assert step_8_path == tmp_path / "step-8.png"
|
||||
assert step_8.startswith("--- VERIFYING ON MY BOOKINGS PAGE ---")
|
||||
assert step_8.endswith("✅ SUCCESS: All bookings verified!")
|
||||
assert step_9_path == tmp_path / "step-9.png"
|
||||
assert step_9.startswith("✓ Logged in")
|
||||
assert step_9.endswith("✅ SUCCESS: All bookings verified!")
|
||||
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_the_verification_and_returns_0_on_success(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
gym = FakeGym(cards=[make_card("Tue, Aug 19")])
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert main([], lambda *_: gym) == 0
|
||||
|
||||
assert "--- VERIFYING ON MY BOOKINGS PAGE ---\n ✓ Verified: Spin Class" in caplog.text
|
||||
assert "Expected: 1 booking\nFound: 1 booking\n✅ SUCCESS" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("credentials_env")
|
||||
def test_main_notes_bookings_after_the_handled_classes_and_returns_0(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
gym = FakeGym(
|
||||
cards=[make_card("Tue, Aug 19")],
|
||||
bookings=[make_entry("Tue, Aug 19, 6:00 PM"), make_entry("Tue, Aug 26, 6:00 PM")],
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert main([], lambda *_: gym) == 0
|
||||
|
||||
assert " i Also booked (not checked): Spin Class on Tue, Aug 26\n✅ SUCCESS" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("credentials_env")
|
||||
def test_main_returns_3_on_a_mismatch(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[make_entry("Wed, Aug 20, 7:00 AM")])
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
code = main(["--headless", "--screenshot-dir", str(tmp_path)], lambda *_: gym)
|
||||
|
||||
assert code == 3
|
||||
assert "❌ MISMATCH: Missing 1 bookings" in caplog.text
|
||||
assert " ✗ Missing: Spin Class on Tue, Aug 19" in caplog.text
|
||||
assert gym.screenshots[-1] == tmp_path / "step-7.png"
|
||||
assert gym.text_screenshots[-2][1] == tmp_path / "step-8.png"
|
||||
assert gym.text_screenshots[-1][1] == tmp_path / "step-9.png"
|
||||
assert gym.closed is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("credentials_env")
|
||||
def test_main_returns_1_when_the_bookings_page_is_empty() -> None:
|
||||
gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[])
|
||||
|
||||
assert main([], lambda *_: gym, no_sleep) == 1
|
||||
|
||||
|
||||
@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 "VERIFYING" not in caplog.text
|
||||
assert "open_my_bookings" not in gym.calls
|
||||
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, no_sleep) == 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, no_sleep) == 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)]
|
||||
|
||||
|
||||
def _write_env_file(path: Path, email: str, password: str) -> Path:
|
||||
path.write_text(
|
||||
f"{constants.ACCOUNT_EMAIL_ENV}={email}\n"
|
||||
f"export {constants.ACCOUNT_PASSWORD_ENV}='{password}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_env_file_defaults_to_the_gym_file_name() -> None:
|
||||
assert parse_args([]).env_file == Path(constants.ENV_FILE_NAME)
|
||||
|
||||
|
||||
def test_env_file_option_is_read(tmp_path: Path) -> None:
|
||||
assert parse_args(["--env-file", str(tmp_path / "x.env")]).env_file == tmp_path / "x.env"
|
||||
|
||||
|
||||
def test_main_reads_the_account_from_the_env_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
|
||||
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
|
||||
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
|
||||
gym = FakeGym()
|
||||
|
||||
code = main(["--env-file", str(env_file)], lambda *_: gym)
|
||||
|
||||
assert code == 0
|
||||
assert gym.logins == [("file@example.invalid", "file-pass")]
|
||||
|
||||
|
||||
def test_main_lets_a_real_environment_variable_win_over_the_env_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
|
||||
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
|
||||
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
|
||||
gym = FakeGym()
|
||||
|
||||
code = main(["--env-file", str(env_file)], lambda *_: gym)
|
||||
|
||||
assert code == 0
|
||||
assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)]
|
||||
|
||||
|
||||
def test_main_exits_2_when_neither_environment_nor_file_has_the_account(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
|
||||
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
|
||||
|
||||
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
|
||||
|
||||
|
||||
def test_main_exits_2_when_the_admin_account_is_half_set(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
|
||||
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
|
||||
monkeypatch.setenv(constants.ADMIN_EMAIL_ENV, "admin@example.invalid")
|
||||
monkeypatch.delenv(constants.ADMIN_PASSWORD_ENV, raising=False)
|
||||
|
||||
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
|
||||
|
||||
@@ -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 == []
|
||||