"""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_the_same_bounding_range_every_year(tmp_path: Path) -> None: 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, "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] # A flexible wish's four fields are a bounding range, never a placement — # no weekday snap, so every year's range is the identical month/day pair. assert { (r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"]) for r in rows } == {(3, 1, 5, 31)} 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)