MIL-009: verification fix #54

Merged
Tirsvad merged 4 commits from mil-009-verification-fix into main 2026-10-28 14:46:16 +01:00
7 changed files with 281 additions and 14 deletions
Showing only changes of commit 353d157e22 - Show all commits
+23
View File
@@ -228,3 +228,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)
+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)]
+15
View File
@@ -182,6 +182,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")])
+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: