Files
F-r-ldraledigplannerare/backend/tests/integration/test_migration_004_backfill.py
T
jacobh2 ad0e3c0b33 Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 12:04:20 +02:00

298 lines
10 KiB
Python

"""Migration 004's backfill: one `recurrence='yearly'` row -> one row per year.
`test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe
the same *shape*. This proves 004 moves the *data* correctly: an existing
repeating wish must come out the other side as a real row per year, sharing a
`series_id`, each carrying the dates the pre-004 `window_for_year()` would
have produced for that year — including the fixed-mode weekday snap. Getting
that wrong would silently move every recurring family's summer.
Driven through the real Alembic chain (upgrade to 003, insert pre-004 rows,
upgrade to head), not through `_FULL_SCHEMA`, because the pre-004 shape no
longer exists anywhere else.
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import date, datetime
from pathlib import Path
from alembic import command
from alembic.config import Config
def _alembic_config(db_path: Path) -> Config:
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
return cfg
def _insert_pre_004_fixture(
db_path: Path,
*,
child_birth_date: date | None,
wishes: list[dict[str, object]],
) -> dict[str, str]:
"""Seed a workspace (+ optional child) and the given pre-004 leave_wish rows."""
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
ws_id = str(uuid.uuid4())
child_id = str(uuid.uuid4()) if child_birth_date is not None else None
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
(ws_id, "WS", now),
)
if child_id is not None:
assert child_birth_date is not None
conn.execute(
"INSERT INTO child (id, workspace_id, name, birth_date, "
"preschool_start_date, preschool_planning_days_per_year, created_at) "
"VALUES (?, ?, ?, ?, NULL, 0, ?)",
(child_id, ws_id, "Barn", child_birth_date.isoformat(), now),
)
for wish in wishes:
conn.execute(
"INSERT INTO leave_wish (id, workspace_id, child_id, name, "
"window_start_month, window_start_day, window_end_month, window_end_day, "
"target_weeks, recurrence, first_year, last_year, priority, parent_mode, "
"window_mode, created_at, updated_at) "
"VALUES (:id, :workspace_id, :child_id, :name, :wsm, :wsd, :wem, :wed, "
":target_weeks, :recurrence, :first_year, :last_year, 1, 'either', "
":window_mode, :now, :now)",
{
"workspace_id": ws_id,
"child_id": child_id if wish.get("tied_to_child", True) else None,
"now": now,
**{k: v for k, v in wish.items() if k != "tied_to_child"},
},
)
conn.commit()
finally:
conn.close()
return {"workspace_id": ws_id, "child_id": child_id or ""}
def _rows(db_path: Path, name: str) -> list[sqlite3.Row]:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
return conn.execute(
"SELECT * FROM leave_wish WHERE name = ? ORDER BY first_year", (name,)
).fetchall()
finally:
conn.close()
def test_yearly_wish_is_expanded_into_one_row_per_year(tmp_path: Path) -> None:
db = tmp_path / "backfill.db"
command.upgrade(_alembic_config(db), "003")
anchor_id = str(uuid.uuid4())
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": anchor_id,
"name": "Sommar",
# 2026-10-05 is a Monday; 10-11 the Sunday after.
"wsm": 10,
"wsd": 5,
"wem": 10,
"wed": 11,
"target_weeks": 1,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Sommar")
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029]
# One series, and the original row survives as the first year's occurrence
# (so the `planned_leave_day.wish_id` rows pointing at it stay attached).
series_ids = {r["series_id"] for r in rows}
assert len(series_ids) == 1
assert None not in series_ids
assert rows[0]["id"] == anchor_id
# The weekday snap is now IN the rows: each year keeps Monday->Sunday
# rather than drifting to the naive 10-05 (a Tuesday in 2027).
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
assert stored == [(10, 5), (10, 4), (10, 2), (10, 8)]
for row in rows:
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
assert start.weekday() == 0, row["first_year"]
assert end.weekday() == 6, row["first_year"]
assert (end - start).days == 6
# Series-level intent is mirrored onto every row.
assert {r["last_year"] for r in rows} == {2029}
# And the dropped `recurrence` column really is gone.
assert "recurrence" not in rows[0].keys()
def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_path: Path) -> None:
db = tmp_path / "age4.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1), # 4th birthday in 2030
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Jul",
"wsm": 12,
"wsd": 20,
"wem": 1,
"wed": 6, # wraps into January
"target_weeks": 3,
"recurrence": "yearly",
"first_year": 2026,
"last_year": None, # "until the child turns 4"
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Jul")
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029, 2030]
assert {r["last_year"] for r in rows} == {None}
# Still a wrapping window on every row: end sorts before start.
for row in rows:
assert (row["window_end_month"], row["window_end_day"]) < (
row["window_start_month"],
row["window_start_day"],
)
def test_flexible_yearly_wish_keeps_its_bounding_range_weekday_aligned(tmp_path: Path) -> None:
"""`command.upgrade(..., "head")` runs 004's backfill AND 008's
realignment, so this proves the two migrations agree with each other:
004 backfills the pre-fix (unsnapped) flexible bound, and 008 — since
"head" now includes it — snaps it the same way `project_window` does
post-fix, rather than leaving it at 004's naive output.
"""
db = tmp_path / "flexible.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Var",
"wsm": 3,
"wsd": 1, # 2027-03-01 is a Monday
"wem": 5,
"wed": 31,
"target_weeks": 1,
"recurrence": "yearly",
"first_year": 2027,
"last_year": 2029,
"window_mode": "flexible",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Var")
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
# The bound keeps its weekday alignment (Monday, like the anchor) and
# its exact 91-day span, sliding a few days per year rather than staying
# pinned to the identical naive month/day.
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
assert stored == [(3, 1), (2, 28), (2, 26)]
for row in rows:
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
# Never wraps (May > March), so the end date stays in the same year.
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
assert start.weekday() == 0, row["first_year"]
assert (end - start).days == 91, row["first_year"]
def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None:
db = tmp_path / "once.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Engang",
"wsm": 4,
"wsd": 1,
"wem": 4,
"wed": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": 2027,
"last_year": None,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Engang")
assert len(rows) == 1
assert rows[0]["series_id"] is None
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (4, 1)
def test_downgrade_collapses_a_series_back_to_its_first_year(tmp_path: Path) -> None:
db = tmp_path / "roundtrip.db"
command.upgrade(_alembic_config(db), "003")
anchor_id = str(uuid.uuid4())
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": anchor_id,
"name": "Sommar",
"wsm": 6,
"wsd": 15,
"wem": 7,
"wed": 26,
"target_weeks": 6,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
assert len(_rows(db, "Sommar")) == 4
command.downgrade(_alembic_config(db), "003")
rows = _rows(db, "Sommar")
assert len(rows) == 1
assert rows[0]["id"] == anchor_id
assert rows[0]["recurrence"] == "yearly"
assert rows[0]["first_year"] == 2026
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (6, 15)