Files
F-r-ldraledigplannerare/backend/tests/integration/test_migration_008_realign.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

189 lines
7.0 KiB
Python

"""Migration 008's data rewrite: realign already-materialised flexible series.
`project_window` (`app/entities/leave_wish.py`) now snaps a `window_mode ==
'flexible'` bound's start to the anchor year's start weekday too (Step 1 of
the flexible-window realignment fix), holding the bound's day-span fixed —
mirroring what `window_mode == 'fixed'` already did. But `project_window`
only runs once, at materialisation time (`LeaveWishService`); nothing
re-derives it at read time. Every flexible series row materialised before
this fix therefore stays unsnapped forever unless something rewrites it
once. Migration 008 is that one-time rewrite.
Driven through the real Alembic chain (upgrade to 007, insert POST-004-shape
rows directly — the one-row-per-year shape already exists by 007, so there is
no pre-004 fixture to build here — then upgrade to head/008), mirroring
`test_migration_004_backfill.py`'s style.
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import date, datetime
from pathlib import Path
from typing import Any
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_post_004_fixture(db_path: Path, *, rows: list[dict[str, object]]) -> str:
"""Seed a workspace and the given POST-004-shape `leave_wish` rows.
`rows` are full leave_wish rows (one per year already) — the shape 004
produces and 007 leaves untouched — grouped by `series_id`.
"""
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
ws_id = str(uuid.uuid4())
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
(ws_id, "WS", now),
)
for row in rows:
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, first_year, last_year, priority, parent_mode, "
"window_mode, series_id, created_at, updated_at) "
"VALUES (:id, :workspace_id, NULL, :name, :wsm, :wsd, :wem, :wed, "
":target_weeks, :first_year, :last_year, 1, 'either', "
":window_mode, :series_id, :now, :now)",
{"workspace_id": ws_id, "now": now, **row},
)
conn.commit()
finally:
conn.close()
return ws_id
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 _flexible_series(
*, name: str, series_id: str, first_year: int, last_year: int, wsm: int, wsd: int
) -> list[dict[str, Any]]:
"""Rows in 004's shape for a flexible series whose bound was NAIVELY
materialised (every year the identical month/day pair) — exactly what
every flexible series looked like before this fix, and what 008 must
rewrite.
"""
return [
{
"id": str(uuid.uuid4()),
"name": name,
"wsm": wsm,
"wsd": wsd,
"wem": 5,
"wed": 31,
"target_weeks": 1,
"first_year": year,
"last_year": last_year,
"window_mode": "flexible",
"series_id": series_id,
}
for year in range(first_year, last_year + 1)
]
def test_upgrade_realigns_a_flexible_series_onto_its_anchor_weekday(tmp_path: Path) -> None:
db = tmp_path / "realign.db"
command.upgrade(_alembic_config(db), "007")
series_id = str(uuid.uuid4())
# 2027-03-01 is a Monday; naively every year got the same (3, 1).
rows_in = _flexible_series(
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
)
_insert_post_004_fixture(db, rows=rows_in)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Var")
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
# Anchor (2027) untouched; 2028/2029 snapped to Monday, span held fixed.
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"])
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_downgrade_restores_the_naive_anchor_month_day(tmp_path: Path) -> None:
db = tmp_path / "realign_roundtrip.db"
command.upgrade(_alembic_config(db), "007")
series_id = str(uuid.uuid4())
rows_in = _flexible_series(
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
)
_insert_post_004_fixture(db, rows=rows_in)
command.upgrade(_alembic_config(db), "head")
assert {(r["window_start_month"], r["window_start_day"]) for r in _rows(db, "Var")} != {(3, 1)}
command.downgrade(_alembic_config(db), "007")
rows = _rows(db, "Var")
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
assert {(r["window_start_month"], r["window_start_day"]) for r in rows} == {(3, 1)}
assert {(r["window_end_month"], r["window_end_day"]) for r in rows} == {(5, 31)}
def test_fixed_series_is_left_untouched(tmp_path: Path) -> None:
"""`project_window` already snapped a fixed series correctly at 004's
materialisation time — 008 must be a no-op for it, not a costly
re-run of an already-correct projection.
"""
db = tmp_path / "fixed_untouched.db"
command.upgrade(_alembic_config(db), "007")
series_id = str(uuid.uuid4())
rows_in = [
{
"id": str(uuid.uuid4()),
"name": "Sommar",
"wsm": 10,
"wsd": 5 if year == 2026 else (4 if year == 2027 else 2),
"wem": 10,
"wed": 11 if year == 2026 else (10 if year == 2027 else 8),
"target_weeks": 1,
"first_year": year,
"last_year": 2028,
"window_mode": "fixed",
"series_id": series_id,
}
for year in (2026, 2027, 2028)
]
_insert_post_004_fixture(db, rows=rows_in)
before = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
command.upgrade(_alembic_config(db), "head")
after = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
for year, row in before.items():
assert after[year]["window_start_month"] == row["window_start_month"]
assert after[year]["window_start_day"] == row["window_start_day"]
assert after[year]["window_end_month"] == row["window_end_month"]
assert after[year]["window_end_day"] == row["window_end_day"]