Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green. Flexible wishes were funded twice across propose -> apply -> re-propose. Because planned_leave_day carries no wish_id, the engine could not tell that applied days were that wish's own prior occurrence; it treated them as occupied space, routed the candidate search around them, and funded the year again on shifted dates while reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18 paid days instead of 9. Fixed wishes were unaffected and stay unaffected. Fix (allocation_service.py), per the approved approach of counting already-applied days inside the bounding range rather than adding a wish_id column: - _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its own freshly-computed windows, so a fixed wish's new placement is never mistaken for a flexible wish's history - _reuse_already_funded_window finds this wish's persisted dates within its bound and reuses the window containing them, via the ordinary fund_window path - full funding therefore yields zero new days and partial funding tops up exactly the remainder, with no duplication and no false shortfall - with no persisted dates, or if the reused window is no longer collision-free, it falls through to the original search, so an unsatisfied year is never frozen Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator switched to strict, so an uncached year raises UncachedYearError -> ValidationError -> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as working days. Also fixed normalize_day dropping the second name when two holidays share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red days, not 18: Pingstdagen coincides with Nationaldagen. New backend/tests/integration/test_user_journey.py drives the real app over HTTP across seven journeys: the double-booking guard, the fixed-wish control, apply idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish text reaches the wire now that everything is message_key + data. New backend/scripts/seed_demo.py seeds a realistic workspace through the real service layer for manual testing: demo@example.com / DemoPass123! Backend 414 -> 435 tests. Frontend unchanged at 705. Still not verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -39,14 +39,17 @@ class TestCacheStructure:
|
||||
assert isinstance(calendar_data["years"], list)
|
||||
assert len(calendar_data["years"]) > 0
|
||||
|
||||
def test_cache_covers_10_years(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must contain exactly 10 years."""
|
||||
assert len(calendar_data["years"]) == 10
|
||||
def test_cache_covers_30_years(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must contain exactly 30 years (extended from the original
|
||||
10 so the planning engine's horizon can never realistically run past
|
||||
the cache — see `AllocationInputLoader.load`'s `strict=True` switch
|
||||
in `app.services.allocation_service`)."""
|
||||
assert len(calendar_data["years"]) == 30
|
||||
|
||||
def test_cache_year_range(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Cache must cover current year + 9 more (10 total)."""
|
||||
"""Cache must cover current year + 29 more (30 total)."""
|
||||
years = [y["year"] for y in calendar_data["years"]]
|
||||
assert years == list(range(2026, 2036)), f"Expected [2026..2035], got {years}"
|
||||
assert years == list(range(2026, 2056)), f"Expected [2026..2055], got {years}"
|
||||
|
||||
def test_each_year_has_days(self, calendar_data: dict[str, Any]) -> None:
|
||||
"""Each year must have a 'days' list."""
|
||||
@@ -399,3 +402,127 @@ class TestCacheConsistency:
|
||||
with open(CACHE_FILE, "rb") as f:
|
||||
content = f.read()
|
||||
assert content.endswith(b"\n"), "Cache file must end with newline"
|
||||
|
||||
|
||||
class TestRedDayCountConsistency:
|
||||
"""No year in the extended range may be silently poorer than its
|
||||
neighbours — this is exactly the shape of the bug this cache extension
|
||||
fixes (years past the old cache lost Julafton/Nyårsafton/Midsommarafton
|
||||
to the `holidays`-library fallback, 13 red days instead of 18)."""
|
||||
|
||||
def test_red_day_count_consistent_with_one_documented_exception(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Every year has 18 red (public + de_facto) dates, EXCEPT a year
|
||||
where Pingstdagen (Easter + 49 days) coincides with Sveriges
|
||||
nationaldag (fixed June 6) — then the two holidays share a single
|
||||
calendar date, giving 17 distinct dates. That happens for 2049 and
|
||||
2055 in this cache. Verified by name (not just assumed from the
|
||||
count), so a genuinely degraded year can't hide behind it.
|
||||
"""
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
red_days = [d for d in year_data["days"] if d["category"] in ("public", "de_facto")]
|
||||
june_6 = next(
|
||||
(d for d in red_days if d["date"] == f"{year:04d}-06-06"),
|
||||
None,
|
||||
)
|
||||
pingst_meets_nationaldag = june_6 is not None and (
|
||||
"pingstdagen" in june_6["name_sv"] and "nationaldag" in june_6["name_sv"]
|
||||
)
|
||||
expected = 17 if pingst_meets_nationaldag else 18
|
||||
assert len(red_days) == expected, (
|
||||
f"{year}: expected {expected} red dates, got {len(red_days)} "
|
||||
f"({sorted(d['date'] for d in red_days)})"
|
||||
)
|
||||
|
||||
def test_the_known_coincidence_years_are_exactly_2049_and_2055(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pin down which years hit the 17-count exception, so a future cache
|
||||
regeneration that shifts the covered range doesn't silently change
|
||||
this test's assumptions without notice."""
|
||||
coincidence_years = set()
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
for day in year_data["days"]:
|
||||
if (
|
||||
day["date"] == f"{year:04d}-06-06"
|
||||
and "pingstdagen" in day["name_sv"]
|
||||
and "nationaldag" in day["name_sv"]
|
||||
):
|
||||
coincidence_years.add(year)
|
||||
assert coincidence_years == {2049, 2055}
|
||||
|
||||
|
||||
class TestMultiObservanceNamesArePreserved:
|
||||
"""A date can carry more than one `observances` entry from the Dagsmart
|
||||
API when two distinct holidays coincide (see `normalize_day` in
|
||||
`scripts/fetch_swedish_calendar.py`). Taking only the first observance
|
||||
would silently drop the second holiday's name — verify both survive."""
|
||||
|
||||
def test_pingstdagen_and_nationaldagen_both_named_in_2049(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
cache_year = next(y for y in calendar_data["years"] if y["year"] == 2049)
|
||||
day = next(d for d in cache_year["days"] if d["date"] == "2049-06-06")
|
||||
assert "pingstdagen" in day["name_sv"]
|
||||
assert "nationaldag" in day["name_sv"]
|
||||
assert day["category"] == "public"
|
||||
assert day["working_day"] is False
|
||||
|
||||
|
||||
class TestEasterAcrossTheExtendedRange:
|
||||
"""Easter moves every year (computed, not fixed) — sample a few years
|
||||
spread across the new 30-year range to confirm Långfredagen/Påskdagen/
|
||||
Annandag påsk keep landing on the correct relative weekdays this far out,
|
||||
not just near the start of the cache."""
|
||||
|
||||
@pytest.mark.parametrize("year", [2026, 2038, 2049, 2055])
|
||||
def test_easter_triduum_lands_correctly(self, calendar_data: dict[str, Any], year: int) -> None:
|
||||
cache_year = next(y for y in calendar_data["years"] if y["year"] == year)
|
||||
by_date = {d["date"]: d for d in cache_year["days"]}
|
||||
|
||||
good_friday = next(d for d, info in by_date.items() if "långfredagen" in info["name_sv"])
|
||||
easter_sunday = next(d for d, info in by_date.items() if "påskdagen" in info["name_sv"])
|
||||
easter_monday = next(d for d, info in by_date.items() if "annandag påsk" in info["name_sv"])
|
||||
|
||||
gf = datetime.date.fromisoformat(good_friday)
|
||||
es = datetime.date.fromisoformat(easter_sunday)
|
||||
em = datetime.date.fromisoformat(easter_monday)
|
||||
|
||||
assert gf.weekday() == 4, f"{year}: Långfredagen must be a Friday"
|
||||
assert es.weekday() == 6, f"{year}: Påskdagen must be a Sunday"
|
||||
assert em.weekday() == 0, f"{year}: Annandag påsk must be a Monday"
|
||||
assert es - gf == datetime.timedelta(days=2), (
|
||||
f"{year}: Good Friday must be 2 days before Easter Sunday"
|
||||
)
|
||||
assert em - es == datetime.timedelta(days=1), (
|
||||
f"{year}: Easter Monday must be the day after Easter Sunday"
|
||||
)
|
||||
|
||||
|
||||
class TestValborgIsNotADefaultDayOff:
|
||||
"""§6 product decision: Valborgsmässoafton (30 April) is deliberately NOT
|
||||
a statutory/de-facto day off — it was added, then removed, because most
|
||||
Swedes work on it. Confirm the extended cache still treats it as an
|
||||
ordinary day (or, when it happens to be a genuine klämdag, that it's
|
||||
filed as `bridge_day` — still `working_day: true` — never `public`/
|
||||
`de_facto`)."""
|
||||
|
||||
def test_valborg_never_public_or_de_facto_in_any_cached_year(
|
||||
self, calendar_data: dict[str, Any]
|
||||
) -> None:
|
||||
for year_data in calendar_data["years"]:
|
||||
year = year_data["year"]
|
||||
valborg = next(
|
||||
(d for d in year_data["days"] if d["date"] == f"{year:04d}-04-30"),
|
||||
None,
|
||||
)
|
||||
if valborg is None:
|
||||
continue # not even a bridge day this year — also fine
|
||||
assert valborg["category"] != "public", f"{year}: Valborg wrongly marked public"
|
||||
assert valborg["category"] != "de_facto", f"{year}: Valborg wrongly marked de_facto"
|
||||
assert valborg["working_day"] is True, (
|
||||
f"{year}: Valborg must remain an ordinary working day"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user