Compare commits

...
Author SHA1 Message Date
Tirsvad da3f999c6f Resolve pending commit links 2026-10-28 22:26:18 +08:00
Tirsvad 42f05bdd0f Load the gym accounts from the .env.gym file
Task: MIL-010#1
Task: MIL-010#2
Task: MIL-010#3
2026-10-28 22:26:03 +08:00
Tirsvad 434bdafefd Plan MIL-010: gym accounts from a credentials file 2026-10-28 22:22:00 +08:00
Tirsvad a248541e58 Merge pull request 'MIL-009: screenshots after the fix' (#55) from mil-009-screenshots into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 12s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Publish wiki / publish-wiki (push) Successful in 10s
Publish wiki / wiki (push) Successful in 11s
2026-10-28 14:48:45 +01:00
Tirsvad 034f792c1a Add step 8 screenshot after the verification fix (MIL-009) 2026-10-28 21:48:36 +08:00
Tirsvad fedbc39165 Merge pull request 'MIL-009: verification fix' (#54) from mil-009-verification-fix into main
Publish accepted milestones / publish (push) Failing after 4s
Publish wiki / publish-wiki (push) Successful in 15s
Publish wiki / wiki (push) Successful in 15s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 11s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
2026-10-28 14:46:15 +01:00
Tirsvad 85f5eecd07 Resolve pending commit links 2026-10-28 21:46:06 +08:00
Tirsvad 52427ca69c Add review record RC-024 and update the step 8 notes (MIL-009) 2026-10-28 21:46:04 +08:00
Tirsvad 353d157e22 Compare only the days of the run in the verification
Task: MIL-009#1
Task: MIL-009#2
2026-10-28 21:45:48 +08:00
Tirsvad 3b9e381cd4 Plan MIL-009: verification fix (issue #50) 2026-10-28 21:39:22 +08:00
Tirsvad ca6a706abe Merge pull request 'MIL-008: real screenshots' (#49) from mil-008-real-screenshots into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 36s
Publish wiki / publish-wiki (push) Successful in 12s
Publish wiki / wiki (push) Successful in 13s
2026-10-28 14:34:50 +01:00
Tirsvad bd28943482 Add real screenshots of steps 1 to 9 (MIL-008) 2026-10-28 21:34:38 +08:00
Tirsvad 2c568f1fa2 Merge pull request 'MIL-008: add step images' (#48) from mil-008-screenshots into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 24s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
Publish wiki / publish-wiki (push) Successful in 15s
Publish wiki / wiki (push) Successful in 15s
2026-10-28 12:04:24 +01:00
Tirsvad cbdd553394 Add step 2 to 9 illustrations and label them (MIL-008) 2026-10-28 19:04:15 +08:00
Tirsvad 7a8412ad6b Merge pull request 'MIL-008: documentation, wiki and release' (#47) from mil-008-documentation into main
Publish wiki / publish-wiki (push) Successful in 36s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 11s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
Publish accepted milestones / publish (push) Failing after 6s
Publish wiki / wiki (push) Successful in 36s
2026-10-28 11:20:58 +01:00
Tirsvad 08ced00f62 Resolve pending commit links 2026-10-28 18:20:49 +08:00
Tirsvad 0301fd2f0b Add wiki in three languages, README screenshot section and wiki workflows (MIL-008) 2026-10-28 18:20:47 +08:00
Tirsvad c0aa0b4b2b Merge pull request 'MIL-007: resilience against network failures' (#46) from mil-007-resilience into main
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-28 11:14:26 +01:00
74 changed files with 1993 additions and 29 deletions
+41
View File
@@ -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 }}
+73
View File
@@ -0,0 +1,73 @@
# 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 and its framework submodule
uses: actions/checkout@v4.2.2
with:
submodules: recursive
token: ${{ secrets.SYNC_TOKEN }}
- 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
+1
View File
@@ -43,3 +43,4 @@ Thumbs.db
# Selenium Chrome profile created by the bot (holds the practice site data) # Selenium Chrome profile created by the bot (holds the practice site data)
/chrome_profile/ /chrome_profile/
.env.gym
+1 -1
View File
@@ -8,7 +8,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
SNACK_AND_LIFT_E2E=1 SNACK_AND_LIFT_E2E=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation fonts-noto-color-emoji \ && 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+19
View File
@@ -8,6 +8,7 @@ A Selenium bot that books classes at the fictional gym "Snack and Lift" for you:
- [Requirements](#-requirements) - [Requirements](#-requirements)
- [Setup](#-setup) - [Setup](#-setup)
- [Run](#-run) - [Run](#-run)
- [Lecture steps and screenshots](#-lecture-steps-and-screenshots)
- [Tests](#-tests) - [Tests](#-tests)
- [License](#-license) - [License](#-license)
- [Links](#-links) - [Links](#-links)
@@ -91,6 +92,8 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password" $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 ### Start the bot
With the environment active: With the environment active:
@@ -112,6 +115,22 @@ docker build -t snack-and-lift .
docker run --rm 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) | ![Step 1](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-1.png) |
| 2 | [Automated login](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-2) | ![Step 2](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-2.png) |
| 3 | [Book the next Tuesday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-3) | ![Step 3](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-3.png) |
| 4 | [Check if a class is booked](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-4) | ![Step 4](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-4.png) |
| 5 | [Counters and summary](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-5) | ![Step 5](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-5.png) |
| 6 | [Every Tuesday and Thursday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-6) | ![Step 6](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-6.png) |
| 7 | [Verify on My Bookings](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-7) | ![Step 7](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-7.png) |
| 8 | [Time travel QA](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-8) | ![Step 8](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png) |
| 9 | [Resilience (retry)](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Step-9) | ![Step 9](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-9.png) |
## 🧪 Tests ## 🧪 Tests
```bash ```bash
+2 -2
View File
@@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g.
| BC | Business Case | docs/business-case.md | 002 | | BC | Business Case | docs/business-case.md | 002 |
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | MIL | Milestone / Gateway | docs/milestones/*.md | 011 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 022 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

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
+11 -4
View File
@@ -4,20 +4,21 @@
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| ID | PP-001 | | 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 | | Language | en |
| Domain | it | | Domain | it |
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | 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 ## 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 ## 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 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 | | | 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 | | | 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 ```plantuml
@startgantt @startgantt
@@ -77,7 +80,7 @@ Project starts 2026-10-28
## Dependencies ## 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. 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-006]: ./milestones/mil-006-verify-and-time-travel.md
[MIL-007]: ./milestones/mil-007-resilience.md [MIL-007]: ./milestones/mil-007-resilience.md
[MIL-008]: ./milestones/mil-008-documentation.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 [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,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
+8 -3
View File
@@ -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 | | [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | | [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 | | [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 |
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 | | [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 |
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 | | [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 |
@@ -36,12 +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-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-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| [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-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) | | Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) | | Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) | | 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-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) | | 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 ## Concern Coverage
@@ -51,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-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-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-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-005 | [MIL-002], [MIL-008] | - | `check-readme.sh` | Covered |
| CN-006 | [MIL-008] | - | Page count per language | Planned | | 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-007 | [MIL-001] | - | Repository API response | Covered |
| CN-008 | [MIL-002] | [ADR-0002] | `pyproject.toml` | Planned | | CN-008 | [MIL-002] | [ADR-0002] | `pyproject.toml` | Planned |
| CN-009 | [MIL-001] | - | `.gitignore` lists `.env` | Covered | | CN-009 | [MIL-001] | - | `.gitignore` lists `.env` | Covered |
@@ -74,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-006]: ../milestones/mil-006-verify-and-time-travel.md
[MIL-007]: ../milestones/mil-007-resilience.md [MIL-007]: ../milestones/mil-007-resilience.md
[MIL-008]: ../milestones/mil-008-documentation.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 [50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
+69
View File
@@ -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
```
+69
View File
@@ -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
```
+69
View File
@@ -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
```
+25
View File
@@ -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)
+25
View File
@@ -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)
+25
View File
@@ -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)
+17
View File
@@ -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
![Trin 1: Opsætning, Chrome-profil og navigation](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-1.png)
[Forside](Home-da) · [Næste: 2](Step-2-da)
+17
View File
@@ -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
![Langkah 1: Persediaan, profil Chrome dan navigasi](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-1.png)
[Utama](Home-ms) · [Seterusnya: 2](Step-2-ms)
+17
View File
@@ -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
![Step 1: Setup, Chrome profile and navigation](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-1.png)
[Home](Home) · [Next: 2](Step-2)
+17
View File
@@ -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
![Trin 2: Automatisk login](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-2.png)
[Forrige: 1](Step-1-da) · [Forside](Home-da) · [Næste: 3](Step-3-da)
+17
View File
@@ -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
![Langkah 2: Log masuk automatik](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-2.png)
[Sebelumnya: 1](Step-1-ms) · [Utama](Home-ms) · [Seterusnya: 3](Step-3-ms)
+17
View File
@@ -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
![Step 2: Automated login](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-2.png)
[Previous: 1](Step-1) · [Home](Home) · [Next: 3](Step-3)
+17
View File
@@ -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
![Trin 3: Book næste tirsdagshold](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-3.png)
[Forrige: 2](Step-2-da) · [Forside](Home-da) · [Næste: 4](Step-4-da)
+17
View File
@@ -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
![Langkah 3: Tempah kelas Selasa seterusnya](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-3.png)
[Sebelumnya: 2](Step-2-ms) · [Utama](Home-ms) · [Seterusnya: 4](Step-4-ms)
+17
View File
@@ -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
![Step 3: Book the next Tuesday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-3.png)
[Previous: 2](Step-2) · [Home](Home) · [Next: 4](Step-4)
+17
View File
@@ -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
![Trin 4: Tjek om et hold allerede er booket](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-4.png)
[Forrige: 3](Step-3-da) · [Forside](Home-da) · [Næste: 5](Step-5-da)
+17
View File
@@ -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
![Langkah 4: Semak sama ada kelas sudah ditempah](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-4.png)
[Sebelumnya: 3](Step-3-ms) · [Utama](Home-ms) · [Seterusnya: 5](Step-5-ms)
+17
View File
@@ -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
![Step 4: Check if a class is already booked](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-4.png)
[Previous: 3](Step-3) · [Home](Home) · [Next: 5](Step-5)
+17
View File
@@ -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
![Trin 5: Tællere og et resumé](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-5.png)
[Forrige: 4](Step-4-da) · [Forside](Home-da) · [Næste: 6](Step-6-da)
+17
View File
@@ -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
![Langkah 5: Pembilang dan ringkasan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-5.png)
[Sebelumnya: 4](Step-4-ms) · [Utama](Home-ms) · [Seterusnya: 6](Step-6-ms)
+17
View File
@@ -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
![Step 5: Counters and a summary](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-5.png)
[Previous: 4](Step-4) · [Home](Home) · [Next: 6](Step-6)
+17
View File
@@ -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
![Trin 6: Alle tirsdags- og torsdagshold](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-6.png)
[Forrige: 5](Step-5-da) · [Forside](Home-da) · [Næste: 7](Step-7-da)
+17
View File
@@ -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
![Langkah 6: Semua kelas Selasa dan Khamis](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-6.png)
[Sebelumnya: 5](Step-5-ms) · [Utama](Home-ms) · [Seterusnya: 7](Step-7-ms)
+17
View File
@@ -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
![Step 6: Every Tuesday and Thursday class](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-6.png)
[Previous: 5](Step-5) · [Home](Home) · [Next: 7](Step-7)
+17
View File
@@ -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
![Trin 7: Bekræft på siden My Bookings](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-7.png)
[Forrige: 6](Step-6-da) · [Forside](Home-da) · [Næste: 8](Step-8-da)
+17
View File
@@ -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
![Langkah 7: Sahkan di halaman My Bookings](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-7.png)
[Sebelumnya: 6](Step-6-ms) · [Utama](Home-ms) · [Seterusnya: 8](Step-8-ms)
+17
View File
@@ -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
![Step 7: Verify on the My Bookings page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-7.png)
[Previous: 6](Step-6) · [Home](Home) · [Next: 8](Step-8)
+19
View File
@@ -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
![Trin 8: Kvalitetssikring med tidsrejse](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> 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)
+19
View File
@@ -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
![Langkah 8: Jaminan kualiti dengan perjalanan masa](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> 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)
+19
View File
@@ -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
![Step 8: Time travel quality assurance](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> 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)
+17
View File
@@ -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
![Trin 9: Robusthed mod netværksfejl](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-9.png)
[Forrige: 8](Step-8-da) · [Forside](Home-da)
+17
View File
@@ -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
![Langkah 9: Ketahanan terhadap kegagalan rangkaian](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-9.png)
[Sebelumnya: 8](Step-8-ms) · [Utama](Home-ms)
+17
View File
@@ -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
![Step 9: Resilience against network failures](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-9.png)
[Previous: 8](Step-8) · [Home](Home)
+12
View File
@@ -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`. |
+12
View File
@@ -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`. |
+12
View File
@@ -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`. |
+1
View File
@@ -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
+11
View File
@@ -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 -1
View File
@@ -30,6 +30,34 @@ def load_credentials(environment: Mapping[str, str]) -> Credentials:
if not email or not password: if not email or not password:
raise MissingCredentialsError( raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} " f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
"in the environment (they must not be empty)" "in the environment or the env file (they must not be empty)"
) )
return Credentials(email=email, password=password) 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)
+21 -3
View File
@@ -12,7 +12,11 @@ from pathlib import Path
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.application.booking import book_next_tuesday, book_target_classes from snack_and_lift.application.booking import book_next_tuesday, book_target_classes
from snack_and_lift.application.credentials import Credentials, load_credentials from snack_and_lift.application.credentials import (
Credentials,
load_admin_credentials,
load_credentials,
)
from snack_and_lift.application.ports import GymPort from snack_and_lift.application.ports import GymPort
from snack_and_lift.application.retry import Sleep from snack_and_lift.application.retry import Sleep
from snack_and_lift.application.session import login_with_retry from snack_and_lift.application.session import login_with_retry
@@ -23,6 +27,7 @@ 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.domain.verification import format_verification
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
from snack_and_lift.infrastructure.env_file import read_env_file
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
## Builds the gym site from (headless, profile folder, keep open). ## Builds the gym site from (headless, profile folder, keep open).
@@ -69,7 +74,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.", description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
epilog=( epilog=(
"exit codes: 0 done, 1 a step failed (also when every retry failed), " "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" "2 credentials missing in the environment or the env file, "
"3 the My Bookings page does not match the bookings made"
), ),
) )
parser.add_argument( parser.add_argument(
@@ -110,6 +116,16 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
f"drops a request (default: {constants.RETRY_COUNT}, at least 1)" 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) args = parser.parse_args(argv)
if args.keep_open is None: if args.keep_open is None:
args.keep_open = not args.headless args.keep_open = not args.headless
@@ -242,7 +258,9 @@ def _main(
_use_utf8_output() _use_utf8_output()
args = parse_args(argv) args = parse_args(argv)
try: try:
credentials = load_credentials(os.environ) 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: except MissingCredentialsError as error:
logger.error("%s", error) logger.error("%s", error)
return 2 return 2
+36
View File
@@ -70,6 +70,19 @@ ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password. ## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_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. ## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images" SCREENSHOT_DIR: Final[str] = "docs/images"
@@ -228,3 +241,26 @@ VERIFY_SUCCESS: Final[str] = "✅ SUCCESS: All bookings verified!"
VERIFY_MISMATCH: Final[str] = "❌ MISMATCH: Missing {count} bookings" VERIFY_MISMATCH: Final[str] = "❌ MISMATCH: Missing {count} bookings"
VERIFY_LINE_MISSING: Final[str] = " ✗ Missing: {entry}" VERIFY_LINE_MISSING: Final[str] = " ✗ Missing: {entry}"
VERIFY_LINE_UNEXPECTED: Final[str] = " ? Unexpected: {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
+60 -6
View File
@@ -5,9 +5,11 @@
from collections import Counter from collections import Counter
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.domain.booking_entry import BookingEntry 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 from snack_and_lift.domain.models import ClassResult, Outcome
_WAITLISTED_OUTCOMES = (Outcome.NEW_WAITLIST, Outcome.ALREADY_WAITLISTED) _WAITLISTED_OUTCOMES = (Outcome.NEW_WAITLIST, Outcome.ALREADY_WAITLISTED)
@@ -20,8 +22,11 @@ _Key = tuple[str, str, bool]
class VerificationResult: class VerificationResult:
"""@brief What the My Bookings page shows compared with what the bot did. """@brief What the My Bookings page shows compared with what the bot did.
`verified`, `missing` and `unexpected` hold display names, with the waitlist suffix for a `verified`, `missing`, `unexpected` and `also_booked` hold display names, with the waitlist
waitlist entry; `missing` and `unexpected` also say the day (`Spin Class on Tue, Aug 12`). 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 expected: int
@@ -29,6 +34,7 @@ class VerificationResult:
verified: list[str] verified: list[str]
missing: list[str] missing: list[str]
unexpected: list[str] unexpected: list[str]
also_booked: list[str]
@property @property
def is_success(self) -> bool: def is_success(self) -> bool:
@@ -57,6 +63,46 @@ def _describe(key: _Key) -> str:
return f"{_display_name(name, waitlisted)} on {day}" 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( def build_verification(
results: Sequence[ClassResult], entries: Sequence[BookingEntry] results: Sequence[ClassResult], entries: Sequence[BookingEntry]
) -> VerificationResult: ) -> VerificationResult:
@@ -64,11 +110,16 @@ def build_verification(
@param results What happened to each class; waitlisted when the outcome is a waitlist one. @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. @param entries The Tuesday and Thursday 6pm entries found on the page.
@return The comparison; the counts are the numbers of results and of entries. @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. @exception PageFormatError An entry or a card has no valid date.
""" """
expected = _expected_keys(results) expected = _expected_keys(results)
remaining = Counter(_entry_keys(entries)) 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] = [] verified: list[str] = []
missing: list[str] = [] missing: list[str] = []
for key in expected: for key in expected:
@@ -78,7 +129,9 @@ def build_verification(
else: else:
missing.append(_describe(key)) missing.append(_describe(key))
unexpected = [_describe(key) for key in remaining.elements()] unexpected = [_describe(key) for key in remaining.elements()]
return VerificationResult(len(expected), len(entries), verified, missing, unexpected) return VerificationResult(
len(expected), len(checked), verified, missing, unexpected, also_booked
)
def _count_text(template: str, count: int) -> str: def _count_text(template: str, count: int) -> str:
@@ -99,10 +152,11 @@ def format_verification(result: VerificationResult) -> str:
lines.append(constants.VERIFY_RESULT_TITLE) lines.append(constants.VERIFY_RESULT_TITLE)
lines.append(_count_text(constants.VERIFY_EXPECTED, result.expected)) lines.append(_count_text(constants.VERIFY_EXPECTED, result.expected))
lines.append(_count_text(constants.VERIFY_FOUND, result.found)) 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: if result.is_success:
lines.append(constants.VERIFY_SUCCESS) lines.append(constants.VERIFY_SUCCESS)
return "\n".join(lines) return "\n".join(lines)
lines.append(constants.VERIFY_MISMATCH.format(count=result.expected - result.found)) 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_MISSING.format(entry=text) for text in result.missing)
lines.extend(constants.VERIFY_LINE_UNEXPECTED.format(entry=text) for text in result.unexpected) lines.extend(constants.VERIFY_LINE_UNEXPECTED.format(entry=text) for text in result.unexpected)
return "\n".join(lines) return "\n".join(lines)
@@ -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
+13 -2
View File
@@ -10,7 +10,8 @@
</div> </div>
<script> <script>
// Imitates the site: a loading text, then the page for the state in the query // 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). // (?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 params = new URLSearchParams(window.location.search);
const state = params.get("state"); const state = params.get("state");
// ?fail=N shows the error for the first N loads of the page in this tab (the network // ?fail=N shows the error for the first N loads of the page in this tab (the network
@@ -20,6 +21,14 @@
sessionStorage.setItem("loads", String(loads + 1)); sessionStorage.setItem("loads", String(loads + 1));
// The nav link keeps the state, so opening the page from here shows the same state again. // 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; 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 page = document.getElementById("my-bookings-page");
const content = document.getElementById("content"); const content = document.getElementById("content");
setTimeout(function () { setTimeout(function () {
@@ -30,7 +39,8 @@
} else if (state === "empty") { } else if (state === "empty") {
content.innerHTML = '<div id="empty-bookings-state">You have no bookings yet</div>'; content.innerHTML = '<div id="empty-bookings-state">You have no bookings yet</div>';
} else { } else {
page.dataset.bookingsCount = "2"; const extra = params.get("extra") === "future" ? futureBookings() : "";
page.dataset.bookingsCount = params.get("extra") === "future" ? "5" : "2";
page.dataset.waitlistCount = "1"; page.dataset.waitlistCount = "1";
content.innerHTML = content.innerHTML =
'<div id="confirmed-bookings-section">' + '<div id="confirmed-bookings-section">' +
@@ -40,6 +50,7 @@
'<div id="booking-card-12" data-booking-status="confirmed">' + '<div id="booking-card-12" data-booking-status="confirmed">' +
'<h3 id="booking-class-name-12">Yoga Class</h3>' + '<h3 id="booking-class-name-12">Yoga Class</h3>' +
'<p><strong>When:</strong> Wed, Aug 13, 7:00 AM</p></div>' + '<p><strong>When:</strong> Wed, Aug 13, 7:00 AM</p></div>' +
extra +
'</div>' + '</div>' +
'<div id="waitlist-section">' + '<div id="waitlist-section">' +
'<div id="waitlist-card-21" data-booking-status="waitlisted">' + '<div id="waitlist-card-21" data-booking-status="waitlisted">' +
+14
View File
@@ -175,6 +175,20 @@ def test_list_my_bookings_reads_confirmed_and_waitlist_entries(
] ]
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( def test_list_my_bookings_returns_nothing_for_the_empty_state(
open_my_bookings_page: Callable[[str], SeleniumGym], open_my_bookings_page: Callable[[str], SeleniumGym],
) -> None: ) -> None:
@@ -53,6 +53,16 @@ def test_verify_bookings_ignores_entries_that_are_not_target_classes() -> None:
assert verify_bookings(gym, results).is_success 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: def test_verify_bookings_reports_a_missing_booking() -> None:
gym = FakeGym(bookings=[make_entry("Wed, Aug 13, 7:00 AM")]) gym = FakeGym(bookings=[make_entry("Wed, Aug 13, 7:00 AM")])
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)] results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
+86
View File
@@ -18,6 +18,12 @@ DUMMY_EMAIL = "dummy@example.invalid"
DUMMY_PASSWORD = "dummy-password" 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 @pytest.fixture
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None: def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
@@ -182,6 +188,21 @@ def test_main_logs_the_verification_and_returns_0_on_success(
assert "Expected: 1 booking\nFound: 1 booking\n✅ SUCCESS" 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") @pytest.mark.usefixtures("credentials_env")
def test_main_returns_3_on_a_mismatch(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: 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")]) gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[make_entry("Wed, Aug 20, 7:00 AM")])
@@ -309,3 +330,68 @@ def test_main_passes_options_to_the_factory(tmp_path: Path) -> None:
main(["--headless", "--profile-dir", str(tmp_path)], factory) main(["--headless", "--profile-dir", str(tmp_path)], factory)
assert received == [(True, tmp_path, False)] 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
+53 -1
View File
@@ -5,7 +5,11 @@
import pytest import pytest
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.application.credentials import Credentials, load_credentials from snack_and_lift.application.credentials import (
Credentials,
load_admin_credentials,
load_credentials,
)
from snack_and_lift.domain.errors import MissingCredentialsError from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value" DUMMY_PASSWORD = "dummy-secret-value"
@@ -42,3 +46,51 @@ def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str])
def test_repr_hides_the_password() -> None: def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD)) assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
def test_load_admin_credentials_reads_both_variables() -> None:
environment = {
constants.ADMIN_EMAIL_ENV: " admin@example.invalid ",
constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD,
}
assert load_admin_credentials(environment) == Credentials(
"admin@example.invalid", DUMMY_PASSWORD
)
@pytest.mark.parametrize(
"environment",
[
{},
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: ""},
{constants.ADMIN_EMAIL_ENV: " "},
],
)
def test_load_admin_credentials_is_none_when_not_set(environment: dict[str, str]) -> None:
assert load_admin_credentials(environment) is None
@pytest.mark.parametrize(
("environment", "missing"),
[
({constants.ADMIN_EMAIL_ENV: "admin@example.invalid"}, constants.ADMIN_PASSWORD_ENV),
(
{constants.ADMIN_EMAIL_ENV: "admin@example.invalid", constants.ADMIN_PASSWORD_ENV: ""},
constants.ADMIN_PASSWORD_ENV,
),
({constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD}, constants.ADMIN_EMAIL_ENV),
(
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD},
constants.ADMIN_EMAIL_ENV,
),
],
)
def test_load_admin_credentials_rejects_half_set(environment: dict[str, str], missing: str) -> None:
with pytest.raises(MissingCredentialsError) as caught:
load_admin_credentials(environment)
message = str(caught.value)
assert message.startswith(f"Set {missing} too")
assert DUMMY_PASSWORD not in message
assert "admin@example.invalid" not in message
+59
View File
@@ -0,0 +1,59 @@
"""@file test_env_file.py
@brief The env file reader parses KEY=VALUE lines (dummy values in tmp_path only).
"""
from pathlib import Path
from snack_and_lift.infrastructure.env_file import read_env_file
def _read(tmp_path: Path, text: str) -> dict[str, str]:
path = tmp_path / "accounts.env"
path.write_text(text, encoding="utf-8")
return read_env_file(path)
def test_read_env_file_returns_empty_when_file_is_missing(tmp_path: Path) -> None:
assert read_env_file(tmp_path / "absent.env") == {}
def test_read_env_file_reads_pairs(tmp_path: Path) -> None:
assert _read(tmp_path, "A=1\nB=two\n") == {"A": "1", "B": "two"}
def test_read_env_file_skips_blank_lines_comments_and_lines_without_equals(
tmp_path: Path,
) -> None:
text = "\n# comment\n # indented comment\nnot a pair\nA=1\n"
assert _read(tmp_path, text) == {"A": "1"}
def test_read_env_file_accepts_export_prefix(tmp_path: Path) -> None:
assert _read(tmp_path, "export A=1\n") == {"A": "1"}
def test_read_env_file_strips_whitespace_around_key(tmp_path: Path) -> None:
assert _read(tmp_path, " A =1\n") == {"A": "1"}
def test_read_env_file_strips_one_pair_of_quotes(tmp_path: Path) -> None:
text = "A=\"dummy one\"\nB='dummy two'\nC=\"'both'\"\n"
assert _read(tmp_path, text) == {"A": "dummy one", "B": "dummy two", "C": "'both'"}
def test_read_env_file_keeps_unmatched_quotes_and_lone_quote(tmp_path: Path) -> None:
assert _read(tmp_path, 'A="dummy\'\nB="\n') == {"A": "\"dummy'", "B": '"'}
def test_read_env_file_keeps_equals_and_hash_inside_value(tmp_path: Path) -> None:
assert _read(tmp_path, "A=x=y#z\n") == {"A": "x=y#z"}
def test_read_env_file_ignores_empty_key(tmp_path: Path) -> None:
assert _read(tmp_path, "=value\n") == {}
def test_read_env_file_keeps_empty_value(tmp_path: Path) -> None:
assert _read(tmp_path, "A=\n") == {"A": ""}
+146 -6
View File
@@ -2,9 +2,11 @@
@brief The comparison of the bot's results with the My Bookings page, and its text. @brief The comparison of the bot's results with the My Bookings page, and its text.
""" """
import pytest
from tests.unit.test_booking_entry import make_entry from tests.unit.test_booking_entry import make_entry
from tests.unit.test_models import make_card from tests.unit.test_models import make_card
from snack_and_lift.domain.errors import PageFormatError
from snack_and_lift.domain.models import ClassResult, Outcome from snack_and_lift.domain.models import ClassResult, Outcome
from snack_and_lift.domain.verification import build_verification, format_verification from snack_and_lift.domain.verification import build_verification, format_verification
@@ -58,16 +60,154 @@ def test_missing_entry_is_a_mismatch_with_a_line() -> None:
assert text.endswith(" ✗ Missing: Spin Class (Waitlist) on Thu, Aug 14") assert text.endswith(" ✗ Missing: Spin Class (Waitlist) on Thu, Aug 14")
def test_unexpected_entry_gives_a_negative_missing_count() -> None: def test_unexpected_entry_inside_the_window_is_a_mismatch() -> None:
result = build_verification( result = build_verification(
[BOOKED], [make_entry("Tue, Aug 12, 6:00 PM"), make_entry("Thu, Aug 14, 6:00 PM")] [BOOKED, WAITLISTED],
[
make_entry("Tue, Aug 12, 6:00 PM"),
make_entry("Thu, Aug 14, 6:00 PM", waitlisted=True),
make_entry("Thu, Aug 14, 6:00 PM", name="Yoga Class"),
],
) )
text = format_verification(result) text = format_verification(result)
assert result.unexpected == ["Spin Class on Thu, Aug 14"] assert not result.is_success
assert "❌ MISMATCH: Missing -1 bookings" in text assert result.unexpected == ["Yoga Class on Thu, Aug 14"]
assert text.endswith(" ? Unexpected: Spin Class on Thu, Aug 14") assert "❌ MISMATCH: Missing 0 bookings" in text
assert text.endswith(" ? Unexpected: Yoga Class on Thu, Aug 14")
def test_entries_outside_the_window_are_a_note_and_a_success() -> None:
result = build_verification(
[BOOKED, WAITLISTED],
[
make_entry("Tue, Aug 12, 6:00 PM"),
make_entry("Thu, Aug 14, 6:00 PM", waitlisted=True),
make_entry("Thu, Aug 7, 6:00 PM"),
make_entry("Tue, Aug 19, 6:00 PM", waitlisted=True),
],
)
assert result.is_success
assert (result.expected, result.found) == (2, 2)
assert result.also_booked == [
"Spin Class on Thu, Aug 7",
"Spin Class (Waitlist) on Tue, Aug 19",
]
assert format_verification(result) == (
"--- VERIFYING ON MY BOOKINGS PAGE ---\n"
" ✓ Verified: Spin Class\n"
" ✓ Verified: Spin Class (Waitlist)\n"
"\n"
"--- VERIFICATION RESULT ---\n"
"Expected: 2 bookings\n"
"Found: 2 bookings\n"
" i Also booked (not checked): Spin Class on Thu, Aug 7\n"
" i Also booked (not checked): Spin Class (Waitlist) on Tue, Aug 19\n"
"✅ SUCCESS: All bookings verified!"
)
def test_window_ends_are_inclusive() -> None:
result = build_verification(
[BOOKED, WAITLISTED],
[make_entry("Tue, Aug 12, 6:00 PM"), make_entry("Thu, Aug 14, 6:00 PM", waitlisted=True)],
)
assert result.also_booked == []
def test_missing_count_is_the_number_of_missing_classes_with_notes_outside() -> None:
result = build_verification(
[BOOKED, WAITLISTED],
[make_entry("Tue, Aug 12, 6:00 PM"), make_entry("Tue, Aug 26, 6:00 PM")],
)
text = format_verification(result)
assert result.missing == ["Spin Class (Waitlist) on Thu, Aug 14"]
assert "Expected: 2 bookings\nFound: 1 booking\n" in text
assert (
" i Also booked (not checked): Spin Class on Tue, Aug 26\n❌ MISMATCH: Missing 1 bookings"
in text
)
def test_missing_count_is_never_negative() -> None:
result = build_verification(
[BOOKED, WAITLISTED],
[
make_entry("Tue, Aug 12, 6:00 PM"),
make_entry("Wed, Aug 13, 6:00 PM"),
make_entry("Wed, Aug 13, 6:00 PM", name="Yoga Class"),
],
)
assert "Missing 1 bookings" in format_verification(result)
assert len(result.unexpected) == 2
def test_real_run_with_bookings_after_the_window_is_a_success() -> None:
first = ClassResult(make_card("Thu, Oct 29"), Outcome.ALREADY_BOOKED)
second = ClassResult(make_card("Tue, Nov 3", card_id="2"), Outcome.ALREADY_BOOKED)
entries = [
make_entry("Thu, Oct 29, 6:00 PM"),
make_entry("Tue, Nov 3, 6:00 PM"),
make_entry("Thu, Nov 12, 6:00 PM"),
make_entry("Tue, Nov 17, 6:00 PM"),
make_entry("Tue, Nov 24, 6:00 PM"),
]
result = build_verification([first, second], entries)
assert result.is_success
assert (result.expected, result.found) == (2, 2)
assert result.also_booked == [
"Spin Class on Thu, Nov 12",
"Spin Class on Tue, Nov 17",
"Spin Class on Tue, Nov 24",
]
def test_no_results_makes_every_entry_a_note_and_no_mismatch() -> None:
result = build_verification([], [make_entry("Tue, Aug 12, 6:00 PM")])
assert result.is_success
assert (result.expected, result.found) == (0, 0)
assert result.also_booked == ["Spin Class on Tue, Aug 12"]
def test_window_holds_over_the_turn_of_the_year() -> None:
december = ClassResult(make_card("Tue, Dec 30"), Outcome.NEW_BOOKING)
january = ClassResult(make_card("Thu, Jan 1", card_id="2"), Outcome.NEW_BOOKING)
result = build_verification(
[december, january],
[
make_entry("Tue, Dec 30, 6:00 PM"),
make_entry("Thu, Jan 1, 6:00 PM"),
make_entry("Thu, Jan 8, 6:00 PM"),
make_entry("Thu, Dec 18, 6:00 PM"),
],
)
assert result.is_success
assert result.also_booked == ["Spin Class on Thu, Jan 8", "Spin Class on Thu, Dec 18"]
def test_leap_day_is_a_valid_date() -> None:
leap = ClassResult(make_card("Thu, Feb 29"), Outcome.NEW_BOOKING)
assert build_verification([leap], [make_entry("Thu, Feb 29, 6:00 PM")]).is_success
@pytest.mark.parametrize("date_text", ["Tue, Foo 12", "Tue, Feb 30"])
def test_an_invalid_date_is_a_page_format_error(date_text: str) -> None:
bad = ClassResult(make_card(date_text), Outcome.NEW_BOOKING)
with pytest.raises(PageFormatError):
build_verification([bad], [])
def test_waitlist_flag_must_match() -> None: def test_waitlist_flag_must_match() -> None:
@@ -75,7 +215,7 @@ def test_waitlist_flag_must_match() -> None:
assert result.missing == ["Spin Class on Tue, Aug 12"] assert result.missing == ["Spin Class on Tue, Aug 12"]
assert result.unexpected == ["Spin Class (Waitlist) on Tue, Aug 12"] assert result.unexpected == ["Spin Class (Waitlist) on Tue, Aug 12"]
assert "Missing 0 bookings" in format_verification(result) assert "Missing 1 bookings" in format_verification(result)
def test_date_must_match() -> None: def test_date_must_match() -> None: