MIL-009: verification fix #54
@@ -228,3 +228,26 @@ VERIFY_SUCCESS: Final[str] = "✅ SUCCESS: All bookings verified!"
|
||||
VERIFY_MISMATCH: Final[str] = "❌ MISMATCH: Missing {count} bookings"
|
||||
VERIFY_LINE_MISSING: Final[str] = " ✗ Missing: {entry}"
|
||||
VERIFY_LINE_UNEXPECTED: Final[str] = " ? Unexpected: {entry}"
|
||||
VERIFY_LINE_ALSO_BOOKED: Final[str] = " i Also booked (not checked): {entry}"
|
||||
|
||||
## Month abbreviations of the dates on the pages, January first.
|
||||
MONTH_ABBREVIATIONS: Final[tuple[str, ...]] = (
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
)
|
||||
|
||||
## Days of a year, with the 29th of February, used to compare dates that have no year.
|
||||
DAYS_IN_DATE_CYCLE: Final[int] = 366
|
||||
|
||||
## A leap year, so that the 29th of February is a valid date.
|
||||
DATE_CYCLE_YEAR: Final[int] = 2000
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
from collections import Counter
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from snack_and_lift import constants
|
||||
from snack_and_lift.domain.booking_entry import BookingEntry
|
||||
from snack_and_lift.domain.errors import PageFormatError
|
||||
from snack_and_lift.domain.models import ClassResult, Outcome
|
||||
|
||||
_WAITLISTED_OUTCOMES = (Outcome.NEW_WAITLIST, Outcome.ALREADY_WAITLISTED)
|
||||
@@ -20,8 +22,11 @@ _Key = tuple[str, str, bool]
|
||||
class VerificationResult:
|
||||
"""@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
|
||||
waitlist entry; `missing` and `unexpected` also say the day (`Spin Class on Tue, Aug 12`).
|
||||
`verified`, `missing`, `unexpected` and `also_booked` hold display names, with the waitlist
|
||||
suffix for a waitlist entry; all but `verified` also say the day (`Spin Class on Tue, Aug 12`).
|
||||
Only entries from the earliest to the latest class the bot handled are checked: `found`
|
||||
counts them, and `also_booked` lists the entries outside that window, which never cause
|
||||
a mismatch.
|
||||
"""
|
||||
|
||||
expected: int
|
||||
@@ -29,6 +34,7 @@ class VerificationResult:
|
||||
verified: list[str]
|
||||
missing: list[str]
|
||||
unexpected: list[str]
|
||||
also_booked: list[str]
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
@@ -57,6 +63,46 @@ def _describe(key: _Key) -> str:
|
||||
return f"{_display_name(name, waitlisted)} on {day}"
|
||||
|
||||
|
||||
def _day_number(date_text: str) -> int:
|
||||
"""@brief The position of a date (`Tue, Aug 12`) in a year; the page shows no year.
|
||||
|
||||
@exception PageFormatError The text has no valid month and day.
|
||||
"""
|
||||
month_text, _, day_text = date_text.partition(", ")[2].partition(" ")
|
||||
try:
|
||||
month = constants.MONTH_ABBREVIATIONS.index(month_text) + 1
|
||||
day = date(constants.DATE_CYCLE_YEAR, month, int(day_text))
|
||||
except ValueError as error:
|
||||
raise PageFormatError(f"Unexpected date: {date_text!r}") from error
|
||||
return day.timetuple().tm_yday
|
||||
|
||||
|
||||
def _offset(day: int, start: int) -> int:
|
||||
"""@brief The days from `start` to `day`, counting over the turn of the year."""
|
||||
return (day - start) % constants.DAYS_IN_DATE_CYCLE
|
||||
|
||||
|
||||
def _window(keys: Sequence[_Key]) -> tuple[int, int] | None:
|
||||
"""@brief The first day and the length in days of the shortest span holding all the keys.
|
||||
|
||||
@return None when there is no key.
|
||||
"""
|
||||
days = [_day_number(key[1]) for key in keys]
|
||||
spans = [(max(_offset(day, start) for day in days), start) for start in days]
|
||||
if not spans:
|
||||
return None
|
||||
span, start = min(spans)
|
||||
return start, span
|
||||
|
||||
|
||||
def _is_in_window(key: _Key, window: tuple[int, int] | None) -> bool:
|
||||
"""@brief Tell whether the date of the key lies in the window, ends included."""
|
||||
if window is None:
|
||||
return False
|
||||
start, span = window
|
||||
return _offset(_day_number(key[1]), start) <= span
|
||||
|
||||
|
||||
def build_verification(
|
||||
results: Sequence[ClassResult], entries: Sequence[BookingEntry]
|
||||
) -> VerificationResult:
|
||||
@@ -64,11 +110,16 @@ def build_verification(
|
||||
|
||||
@param results What happened to each class; waitlisted when the outcome is a waitlist one.
|
||||
@param entries The Tuesday and Thursday 6pm entries found on the page.
|
||||
@return The comparison; 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.
|
||||
"""
|
||||
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] = []
|
||||
missing: list[str] = []
|
||||
for key in expected:
|
||||
@@ -78,7 +129,9 @@ def build_verification(
|
||||
else:
|
||||
missing.append(_describe(key))
|
||||
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:
|
||||
@@ -99,10 +152,11 @@ def format_verification(result: VerificationResult) -> str:
|
||||
lines.append(constants.VERIFY_RESULT_TITLE)
|
||||
lines.append(_count_text(constants.VERIFY_EXPECTED, result.expected))
|
||||
lines.append(_count_text(constants.VERIFY_FOUND, result.found))
|
||||
lines.extend(constants.VERIFY_LINE_ALSO_BOOKED.format(entry=t) for t in result.also_booked)
|
||||
if result.is_success:
|
||||
lines.append(constants.VERIFY_SUCCESS)
|
||||
return "\n".join(lines)
|
||||
lines.append(constants.VERIFY_MISMATCH.format(count=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_UNEXPECTED.format(entry=text) for text in result.unexpected)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
</div>
|
||||
<script>
|
||||
// Imitates the site: a loading text, then the page for the state in the query
|
||||
// (?state=empty or ?state=error; anything else shows two bookings and a waitlist entry).
|
||||
// (?state=empty or ?state=error; anything else shows two bookings and a waitlist entry;
|
||||
// ?extra=future adds three more Tuesday and Thursday 6pm bookings after the others).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get("state");
|
||||
// ?fail=N shows the error for the first N loads of the page in this tab (the network
|
||||
@@ -20,6 +21,14 @@
|
||||
sessionStorage.setItem("loads", String(loads + 1));
|
||||
// The nav link keeps the state, so opening the page from here shows the same state again.
|
||||
document.getElementById("my-bookings-link").href = "my_bookings.html" + window.location.search;
|
||||
function futureBookings() {
|
||||
const whens = ["Thu, Aug 21, 6:00 PM", "Tue, Aug 26, 6:00 PM", "Thu, Aug 28, 6:00 PM"];
|
||||
return whens.map(function (when, i) {
|
||||
return '<div id="booking-card-' + (13 + i) + '" data-booking-status="confirmed">' +
|
||||
'<h3 id="booking-class-name-' + (13 + i) + '">Spin Class</h3>' +
|
||||
'<p><strong>When:</strong> ' + when + '</p></div>';
|
||||
}).join("");
|
||||
}
|
||||
const page = document.getElementById("my-bookings-page");
|
||||
const content = document.getElementById("content");
|
||||
setTimeout(function () {
|
||||
@@ -30,7 +39,8 @@
|
||||
} else if (state === "empty") {
|
||||
content.innerHTML = '<div id="empty-bookings-state">You have no bookings yet</div>';
|
||||
} 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";
|
||||
content.innerHTML =
|
||||
'<div id="confirmed-bookings-section">' +
|
||||
@@ -40,6 +50,7 @@
|
||||
'<div id="booking-card-12" data-booking-status="confirmed">' +
|
||||
'<h3 id="booking-class-name-12">Yoga Class</h3>' +
|
||||
'<p><strong>When:</strong> Wed, Aug 13, 7:00 AM</p></div>' +
|
||||
extra +
|
||||
'</div>' +
|
||||
'<div id="waitlist-section">' +
|
||||
'<div id="waitlist-card-21" data-booking-status="waitlisted">' +
|
||||
|
||||
@@ -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(
|
||||
open_my_bookings_page: Callable[[str], SeleniumGym],
|
||||
) -> None:
|
||||
|
||||
@@ -53,6 +53,16 @@ def test_verify_bookings_ignores_entries_that_are_not_target_classes() -> None:
|
||||
assert verify_bookings(gym, results).is_success
|
||||
|
||||
|
||||
def test_verify_bookings_lists_target_entries_after_the_window_as_also_booked() -> None:
|
||||
gym = FakeGym(bookings=[make_entry(), make_entry("Thu, Aug 21, 6:00 PM")])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
verification = verify_bookings(gym, results)
|
||||
|
||||
assert verification.is_success
|
||||
assert verification.also_booked == ["Spin Class on Thu, Aug 21"]
|
||||
|
||||
|
||||
def test_verify_bookings_reports_a_missing_booking() -> None:
|
||||
gym = FakeGym(bookings=[make_entry("Wed, Aug 13, 7:00 AM")])
|
||||
results = [ClassResult(make_card("Tue, Aug 12"), Outcome.NEW_BOOKING)]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("credentials_env")
|
||||
def test_main_notes_bookings_after_the_handled_classes_and_returns_0(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
gym = FakeGym(
|
||||
cards=[make_card("Tue, Aug 19")],
|
||||
bookings=[make_entry("Tue, Aug 19, 6:00 PM"), make_entry("Tue, Aug 26, 6:00 PM")],
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert main([], lambda *_: gym) == 0
|
||||
|
||||
assert " i Also booked (not checked): Spin Class on Tue, Aug 26\n✅ SUCCESS" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("credentials_env")
|
||||
def test_main_returns_3_on_a_mismatch(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[make_entry("Wed, Aug 20, 7:00 AM")])
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
@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_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.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")
|
||||
|
||||
|
||||
def test_unexpected_entry_gives_a_negative_missing_count() -> None:
|
||||
def test_unexpected_entry_inside_the_window_is_a_mismatch() -> None:
|
||||
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)
|
||||
|
||||
assert result.unexpected == ["Spin Class on Thu, Aug 14"]
|
||||
assert "❌ MISMATCH: Missing -1 bookings" in text
|
||||
assert text.endswith(" ? Unexpected: Spin Class on Thu, Aug 14")
|
||||
assert not result.is_success
|
||||
assert result.unexpected == ["Yoga 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:
|
||||
@@ -75,7 +215,7 @@ def test_waitlist_flag_must_match() -> None:
|
||||
|
||||
assert result.missing == ["Spin Class 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:
|
||||
|
||||
Reference in New Issue
Block a user