Give every year of a repeating period its own row

A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 15:53:25 +02:00
parent c6593062e3
commit 6d92b87bf3
43 changed files with 2735 additions and 638 deletions
+4 -1
View File
@@ -273,8 +273,11 @@ async def leave_wish_repo(
async def leave_wish_service(
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
child_repo: SQLiteChildRepository,
) -> LeaveWishService:
return LeaveWishService(leave_wish_repo, planned_leave_repo)
# `child_repo` lets an open-ended ("until the child turns 4") series
# materialise against the child's real birth date; see LeaveWishService.
return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo)
# ---------------------------------------------------------------------------
@@ -0,0 +1,285 @@
"""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)
+13 -2
View File
@@ -588,13 +588,24 @@ class TestFlexibleYearlyWishTracksEasterEachYear:
last_year=years[-1],
window_mode="flexible",
)
wish_id = wish["id"]
# A repeating wish is materialised into one row per year sharing a
# `series_id`; the create response is the first year's row, so the
# later years' outcomes carry their OWN row ids.
assert wish["series_id"] is not None
listed = await client.get(f"/api/leave-wishes/{ws_id}", headers=headers)
assert listed.status_code == 200, listed.text
wish_id_by_year = {
row["first_year"]: row["id"]
for row in listed.json()
if row["series_id"] == wish["series_id"]
}
assert set(wish_id_by_year) == set(years), wish_id_by_year
proposal = await _propose(client, headers, ws_id)
windows: dict[int, tuple[date, date]] = {}
for year in years:
outcome = _outcome_for_wish(proposal, wish_id, year=year)
outcome = _outcome_for_wish(proposal, wish_id_by_year[year], year=year)
assert outcome["strategy"] != "unplaced", outcome
assert outcome["shortfall_days"] == 0, outcome
start = date.fromisoformat(outcome["start_date"])
-2
View File
@@ -370,7 +370,6 @@ class TestForeignWorkspaceIdIs404:
window_end_month=1,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2030,
last_year=None,
priority=1,
@@ -396,7 +395,6 @@ class TestForeignWorkspaceIdIs404:
window_end_month=1,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2030,
last_year=None,
priority=1,
@@ -173,7 +173,6 @@ class TestPlannedLeaveRepositoryBatchMethods:
window_end_month=4,
window_end_day=30,
target_weeks=1,
recurrence="once",
first_year=2026,
last_year=None,
priority=1,
+502 -34
View File
@@ -5,6 +5,7 @@ Aim for >=90% coverage on new code (ARCHITECTURE.md §10).
"""
from datetime import date, datetime
from typing import Any
from uuid import UUID, uuid4
import pytest
@@ -20,6 +21,10 @@ from app.utils.errors import NotFoundError, ValidationError
from tests.conftest import SeededIds
# A stable series id for tests that only need "this row belongs to a series".
_SERIES_ID = UUID("11111111-2222-3333-4444-555555555555")
def _make(workspace_id=None, **overrides) -> LeaveWish:
"""Helper to create a test LeaveWish entity."""
base = dict(
@@ -33,7 +38,6 @@ def _make(workspace_id=None, **overrides) -> LeaveWish:
window_end_day=15,
target_weeks=6,
target_days=None,
recurrence="yearly",
first_year=2027,
last_year=None,
priority=1,
@@ -47,9 +51,9 @@ def _make(workspace_id=None, **overrides) -> LeaveWish:
class TestLeaveWishWindowLogic:
"""Direct tests of LeaveWish's recurring-window helpers.
"""Direct tests of LeaveWish's window helpers.
These are the rules the allocator (Phase B) depends on most — especially
These are the rules the allocator depends on most — especially
year-wrapping, which is the easiest thing in this feature to get backwards.
"""
@@ -70,40 +74,60 @@ class TestLeaveWishWindowLogic:
wish = _make(window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=1)
assert wish.window_wraps() is False
def test_window_for_year_no_wrap_stays_in_same_year(self) -> None:
def test_window_no_wrap_stays_in_same_year(self) -> None:
wish = _make(
window_start_month=6, window_start_day=15, window_end_month=8, window_end_day=15
first_year=2027,
window_start_month=6,
window_start_day=15,
window_end_month=8,
window_end_day=15,
)
start, end = wish.window_for_year(2027)
start, end = wish.window()
assert start == date(2027, 6, 15)
assert end == date(2027, 8, 15)
def test_window_for_year_wraps_end_into_next_year(self) -> None:
def test_window_wraps_end_into_next_year(self) -> None:
wish = _make(
window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=7
first_year=2027,
window_start_month=12,
window_start_day=20,
window_end_month=1,
window_end_day=7,
)
start, end = wish.window_for_year(2027)
start, end = wish.window()
assert start == date(2027, 12, 20)
assert end == date(2028, 1, 7)
def test_applies_to_year_before_first_year_is_false(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=None)
assert wish.applies_to_year(2026) is False
def test_window_reads_the_row_back_verbatim_with_no_projection(self) -> None:
"""Two rows of one series that diverged (e.g. a per-year Easter shift)
each report their OWN dates. The old shape re-derived every later year
from `first_year`, so this could not be expressed at all.
"""
series = uuid4()
a = _make(first_year=2027, series_id=series)
b = _make(
first_year=2028,
series_id=series,
window_start_month=7,
window_start_day=1,
window_end_month=8,
window_end_day=31,
)
assert a.window() == (date(2027, 6, 15), date(2027, 8, 15))
assert b.window() == (date(2028, 7, 1), date(2028, 8, 31))
def test_applies_to_year_once_matches_only_first_year(self) -> None:
wish = _make(recurrence="once", first_year=2027, last_year=2027)
assert wish.applies_to_year(2027) is True
assert wish.applies_to_year(2028) is False
def test_applies_to_year_yearly_with_no_last_year_recurs_indefinitely(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=None)
assert wish.applies_to_year(2027) is True
assert wish.applies_to_year(2050) is True
def test_applies_to_year_yearly_stops_after_last_year(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=2029)
assert wish.applies_to_year(2029) is True
assert wish.applies_to_year(2030) is False
def test_window_clamps_a_feb_29_anchor_in_a_non_leap_year(self) -> None:
"""A Feb-29 window in a non-leap year used to raise ValueError out of
the allocator; a whole-day planner has to answer Feb 28 instead.
"""
wish = _make(
first_year=2027,
window_start_month=2,
window_start_day=29,
window_end_month=3,
window_end_day=5,
)
assert wish.window() == (date(2027, 2, 28), date(2027, 3, 5))
# -- target_span_days() (calendar-drawn periods, day-granular) ---------
@@ -134,7 +158,7 @@ class TestSQLiteLeaveWishRepository:
assert fetched.id == created.id
assert fetched.name == "Sommar"
assert fetched.target_weeks == 6
assert fetched.recurrence == "yearly"
assert fetched.series_id is None
assert fetched.parent_mode == "either"
assert fetched.child_id is None
assert fetched.parent_personal_data_uuid is None
@@ -211,7 +235,7 @@ class TestSQLiteLeaveWishRepository:
window_end_month=1,
window_end_day=7,
target_weeks=4,
recurrence="yearly",
series_id=_SERIES_ID,
first_year=2027,
last_year=2031,
priority=2,
@@ -227,7 +251,7 @@ class TestSQLiteLeaveWishRepository:
assert created.window_end_month == 1
assert created.window_end_day == 7
assert created.target_weeks == 4
assert created.recurrence == "yearly"
assert created.series_id == _SERIES_ID
assert created.first_year == 2027
assert created.last_year == 2031
assert created.priority == 2
@@ -239,6 +263,7 @@ class TestSQLiteLeaveWishRepository:
assert fetched is not None
assert fetched.child_id == seeded_ids.child_id
assert fetched.last_year == 2031
assert fetched.series_id == _SERIES_ID
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
async def test_create_and_update_round_trip_target_days(
@@ -480,7 +505,7 @@ class TestLeaveWishService:
)
assert result.name == "Sommar"
assert result.target_weeks == 6
assert result.recurrence == "once" # default
assert result.series_id is None # default: a standalone occurrence
assert result.parent_mode == "either" # default
assert result.wants_dubbeldag is False # default
@@ -491,6 +516,10 @@ class TestLeaveWishService:
seeded_ids: SeededIds,
recurrence: str,
) -> None:
"""`recurrence` is an instruction now, not a stored field: 'yearly'
materialises a series (so the returned row carries a `series_id`),
'once' leaves a standalone occurrence.
"""
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
@@ -500,9 +529,10 @@ class TestLeaveWishService:
window_end_day=10,
target_weeks=1,
first_year=2027,
last_year=2029 if recurrence == "yearly" else None,
recurrence=recurrence,
)
assert result.recurrence == recurrence
assert (result.series_id is not None) is (recurrence == "yearly")
# Placeholder resolved to seeded_ids.personal_data_uuid at test time, since
# parametrize case values are collected before fixtures exist.
@@ -864,8 +894,15 @@ class TestLeaveWishService:
async def test_create_allows_last_year_equal_first_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A one-year series is legal and produces exactly one row.
(`recurrence='yearly'` is required for `last_year` to survive at all
now — it is the series' end-year intent, and a standalone occurrence
has no series to end. See `LeaveWishService._validate`.)
"""
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
@@ -874,10 +911,13 @@ class TestLeaveWishService:
window_end_month=1,
window_end_day=10,
target_weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2027,
)
assert result.last_year == 2027
assert result.series_id is not None
assert len(await leave_wish_repo.get_by_series(result.series_id)) == 1
async def test_create_allows_null_last_year(
self,
@@ -957,7 +997,7 @@ class TestLeaveWishService:
seeded_ids: SeededIds,
) -> None:
"""Every non-clear-flag optional field, set to a real value in one call."""
entity = _make(workspace_id=seeded_ids.workspace_id, recurrence="once", last_year=None)
entity = _make(workspace_id=seeded_ids.workspace_id, series_id=None, last_year=None)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
@@ -982,7 +1022,7 @@ class TestLeaveWishService:
assert result.window_end_month == 4
assert result.window_end_day == 20
assert result.target_weeks == 9
assert result.recurrence == "yearly"
assert result.series_id is not None # 'yearly' materialised a series
assert result.first_year == 2028
assert result.last_year == 2030
assert result.priority == 5
@@ -1007,11 +1047,19 @@ class TestLeaveWishService:
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, last_year=2030)
"""Clearing a series' end year re-opens it to the child's 4th birthday."""
entity = _make(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
series_id=_SERIES_ID,
first_year=2027,
last_year=2030,
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
scope="series",
_clear_last_year=True,
)
assert result.last_year is None
@@ -1296,3 +1344,423 @@ class TestLeaveWishServiceDeleteCascade:
survivor = await planned_leave_repo.get_by_id(ad_hoc.id)
assert survivor is not None
assert survivor.wish_id is None
class TestLeaveWishSeriesMaterialisation:
"""A repeating wish is stored as one row per year, sharing a `series_id`.
This is the shape that makes "edit/remove just this year" possible at all:
before it, a later year had no row to address, so the frontend had to
rewrite the shared month/day pattern for every year (edit) or narrow
`first_year`/`last_year` — and split the wish in two for a middle year
(remove). Both contortions are gone.
"""
_BASE: dict[str, Any] = dict(
name="Sommar",
window_start_month=10,
window_start_day=5, # 2026-10-05 is a Monday
window_end_month=10,
window_end_day=11, # ...10-11 the Sunday after
target_weeks=1,
)
async def _series(
self,
service: LeaveWishService,
repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
**overrides: Any,
) -> tuple[LeaveWish, list[LeaveWish]]:
kwargs: dict[str, Any] = {
**self._BASE,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
**overrides,
}
anchor = await service.create(workspace_id=seeded_ids.workspace_id, **kwargs)
assert anchor.series_id is not None
return anchor, await repo.get_by_series(anchor.series_id)
async def test_create_yearly_writes_one_row_per_year_sharing_a_series_id(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
assert {r.series_id for r in rows} == {anchor.series_id}
assert anchor.first_year == 2026 # create() returns the first occurrence
# Every row is addressable on its own — distinct ids, one per year.
assert len({r.id for r in rows}) == 4
async def test_create_once_writes_a_single_standalone_row(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
assert created.series_id is None
assert created.last_year is None
assert len(await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)) == 1
async def test_the_weekday_snap_lands_in_the_stored_rows(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Mon-Sun stays Mon-Sun, and the shifted dates are the row's own
columns — nothing re-derives a later year from the first one.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
(10, 5),
(10, 4),
(10, 2),
(10, 8),
]
for row in rows:
start, end = row.window()
assert start.weekday() == 0, row.first_year
assert end.weekday() == 6, row.first_year
assert (end - start).days == 6
async def test_flexible_series_keeps_one_bounding_range_per_year_unshifted(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A flexible wish's fields are a bounding range, not a placement, so
materialising gives each year the SAME month/day range — the per-year
placement search inside it stays the allocator's job.
"""
_, rows = await self._series(
leave_wish_service,
leave_wish_repo,
seeded_ids,
window_start_month=3,
window_start_day=1,
window_end_month=5,
window_end_day=31,
window_mode="flexible",
)
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
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)}
async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""`last_year=None` means "until the child turns 4" — the app's whole
premise. The seeded child is born 2024-01-01, so 2028 is the last year.
"""
_, rows = await self._series(
leave_wish_service,
leave_wish_repo,
seeded_ids,
child_id=seeded_ids.child_id,
first_year=2026,
last_year=None,
)
assert [r.first_year for r in rows] == [2026, 2027, 2028]
async def test_editing_one_year_leaves_its_siblings_alone(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The reason this shape exists: 2028's Easter is not 2027's, so one
year must be able to move without dragging the others with it.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
before = {r.first_year: r.window() for r in rows}
target = next(r for r in rows if r.first_year == 2028)
updated = await leave_wish_service.update(
leave_wish_id=target.id,
window_start_month=11,
window_start_day=6,
window_end_month=11,
window_end_day=12,
scope="occurrence",
)
assert updated.window() == (date(2028, 11, 6), date(2028, 11, 12))
assert updated.series_id == target.series_id # still part of the series
after = {
r.first_year: r.window()
for r in await leave_wish_repo.get_by_series(target.series_id)
if r.first_year != 2028
}
assert after == {y: w for y, w in before.items() if y != 2028}
async def test_editing_the_series_reprojects_every_year_from_the_edited_row(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
""" "Every year" makes the clicked occurrence the new anchor; the other
years follow it with the same weekday-preserving projection the old
single-row shape ran at read time.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
target = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.update(
leave_wish_id=target.id,
name="Höstlov",
window_start_month=11,
window_start_day=6, # 2028-11-06 is a Monday
window_end_month=11,
window_end_day=12,
scope="series",
)
assert target.series_id is not None
after = await leave_wish_repo.get_by_series(target.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
assert {r.name for r in after} == {"Höstlov"}
for row in after:
start, end = row.window()
assert start.weekday() == 0, row.first_year
assert end.weekday() == 6, row.first_year
assert start.month == 11
assert (end - start).days == 6
async def test_extending_last_year_adds_the_missing_rows(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert len(rows) == 4
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2031, scope="series")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029, 2030, 2031]
assert {r.last_year for r in after} == {2031}
async def test_shortening_last_year_deletes_the_rows_past_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
dropped = next(r for r in rows if r.first_year == 2029)
orphan_candidate = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2029, 10, 9),
day_type="sjukpenning",
is_dubbeldag=False,
wish_id=dropped.id,
is_pinned=False,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2027, scope="series")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027]
# The dropped years take their planned days with them, exactly like
# deleting a period does.
assert await planned_leave_repo.get_by_id(orphan_candidate.id) is None
async def test_changing_last_year_on_one_occurrence_is_rejected(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""`last_year` belongs to the series, not to one year of it — silently
desyncing the mirror would be worse than a 422.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
target = next(r for r in rows if r.first_year == 2028)
with pytest.raises(ValidationError, match="series"):
await leave_wish_service.update(
leave_wish_id=target.id, last_year=2031, scope="occurrence"
)
async def test_making_a_standalone_wish_repeat_materialises_around_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
assert created.series_id is None
updated = await leave_wish_service.update(
leave_wish_id=created.id, recurrence="yearly", last_year=2028
)
assert updated.series_id is not None
rows = await leave_wish_repo.get_by_series(updated.series_id)
assert [r.first_year for r in rows] == [2026, 2027, 2028]
assert rows[0].id == created.id # the original row anchors the series
async def test_stopping_a_series_keeps_the_clicked_year_and_drops_the_rest(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
keeper = next(r for r in rows if r.first_year == 2028)
updated = await leave_wish_service.update(
leave_wish_id=keeper.id, recurrence="once", scope="series"
)
assert updated.series_id is None
assert updated.last_year is None
remaining = await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)
assert [r.id for r in remaining] == [keeper.id]
async def test_delete_occurrence_removes_only_that_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""What used to require narrowing the range — or splitting the wish in
two around a middle year — is now one plain row delete.
"""
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
middle = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(middle.id, scope="occurrence")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2029]
async def test_delete_series_removes_every_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
middle = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(middle.id, scope="series")
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
async def test_delete_series_on_a_standalone_wish_removes_just_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
""" "This year" and "every year" coincide on a one-off occurrence."""
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
await leave_wish_service.delete(created.id, scope="series")
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
async def test_an_unknown_scope_is_rejected(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
with pytest.raises(ValidationError, match="scope"):
await leave_wish_service.update(leave_wish_id=created.id, scope="everything")
with pytest.raises(ValidationError, match="scope"):
await leave_wish_service.delete(created.id, scope="everything")
async def test_a_removed_year_can_be_put_back_into_its_series(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The calendar's undo of "remove this year" must restore the year as
part of the repeating wish, not as a detached one-off a later "every
year" edit would miss.
"""
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert anchor.series_id is not None
removed = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(removed.id, scope="occurrence")
restored = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=removed.first_year,
window_start_month=removed.window_start_month,
window_start_day=removed.window_start_day,
window_end_month=removed.window_end_month,
window_end_day=removed.window_end_day,
target_weeks=removed.target_weeks,
name=removed.name,
series_id=anchor.series_id,
)
assert restored.series_id == anchor.series_id
assert restored.last_year == anchor.last_year # the mirror stays consistent
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
async def test_an_unknown_series_id_on_create_is_rejected(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
with pytest.raises(ValidationError, match="series_id"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=2026,
series_id=uuid4(),
**self._BASE,
)
async def test_series_id_cannot_be_combined_with_recurrence_yearly(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, _ = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
with pytest.raises(ValidationError, match="series_id"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=2035,
recurrence="yearly",
last_year=2036,
series_id=anchor.series_id,
**self._BASE,
)
@@ -433,3 +433,124 @@ class TestLeaveWishWantsDubbeldagRoundTrip:
assert update_response.status_code == 200
assert update_response.json()["wants_dubbeldag"] is True
class TestLeaveWishScopeQueryParam:
"""`?scope=series` widens PUT/DELETE from one year to the whole series.
The calendar's period popover offers "this year" vs "every year"; the
scope has to reach the service or the choice is decorative.
"""
async def _workspace(self, connection: aiosqlite.Connection) -> Workspace:
now = datetime.now()
ws = await SQLiteWorkspaceRepository(connection).create(
Workspace(id=uuid4(), name="Familj", created_at=now)
)
await SQLitePersonalDataRepository(connection).create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now
)
)
return ws
async def _create_series(self, client: AsyncClient, ws: Workspace) -> list[dict[str, object]]:
first_year = date.today().year + 1
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"name": "Höstlov",
"window_start_month": 10,
"window_start_day": 5,
"window_end_month": 10,
"window_end_day": 11,
"target_weeks": 1,
"recurrence": "yearly",
"first_year": first_year,
"last_year": first_year + 2,
},
)
assert response.status_code == 201, response.text
assert response.json()["series_id"] is not None
listed = await client.get(f"/api/leave-wishes/{ws.id}")
assert listed.status_code == 200, listed.text
return sorted(listed.json(), key=lambda row: row["first_year"])
async def test_create_yearly_returns_a_series_and_lists_one_row_per_year(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
first_year = date.today().year + 1
assert [r["first_year"] for r in rows] == [first_year, first_year + 1, first_year + 2]
assert len({r["series_id"] for r in rows}) == 1
# `recurrence` is an instruction, not a response field any more.
assert "recurrence" not in rows[0]
async def test_update_defaults_to_this_year_only(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
response = await client.put(
f"/api/leave-wishes/{rows[1]['id']}", json={"name": "Bara detta år"}
)
assert response.status_code == 200, response.text
after = sorted(
(await client.get(f"/api/leave-wishes/{ws.id}")).json(), key=lambda r: r["first_year"]
)
assert [r["name"] for r in after] == ["Höstlov", "Bara detta år", "Höstlov"]
async def test_update_with_scope_series_applies_to_every_year(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
response = await client.put(
f"/api/leave-wishes/{rows[1]['id']}?scope=series", json={"name": "Alla år"}
)
assert response.status_code == 200, response.text
after = (await client.get(f"/api/leave-wishes/{ws.id}")).json()
assert {r["name"] for r in after} == {"Alla år"}
assert len(after) == 3
async def test_delete_defaults_to_this_year_only(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}")
assert response.status_code == 204, response.text
after = (await client.get(f"/api/leave-wishes/{ws.id}")).json()
assert [r["id"] for r in sorted(after, key=lambda r: r["first_year"])] == [
rows[0]["id"],
rows[2]["id"],
]
async def test_delete_with_scope_series_removes_every_year(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
response = await client.delete(f"/api/leave-wishes/{rows[1]['id']}?scope=series")
assert response.status_code == 204, response.text
assert (await client.get(f"/api/leave-wishes/{ws.id}")).json() == []
async def test_an_unknown_scope_is_a_422(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
ws = await self._workspace(connection)
rows = await self._create_series(client, ws)
response = await client.delete(f"/api/leave-wishes/{rows[0]['id']}?scope=everything")
assert response.status_code == 422, response.text
@@ -31,7 +31,6 @@ def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
window_end_month=6,
window_end_day=30,
target_weeks=1,
recurrence="once",
first_year=2026,
last_year=None,
priority=1,
@@ -37,7 +37,6 @@ def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
window_end_month=6,
window_end_day=30,
target_weeks=1,
recurrence="once",
first_year=2026,
last_year=None,
priority=1,
@@ -40,7 +40,6 @@ def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish:
window_end_month=2,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2027,
last_year=None,
priority=1,
@@ -284,7 +284,6 @@ class TestAllocationInputLoader:
window_end_month=10,
window_end_day=7,
target_weeks=1,
recurrence="once",
first_year=2027,
last_year=None,
priority=1,
@@ -579,9 +578,12 @@ class TestAllocationInputLoader:
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
"""A wish with a far-out `last_year` and no children yet (so there's
no age-12 hard cap to save it) can still in principle push the
horizon past even a ~30-year cache. Rather than silently falling
"""A wish row anchored in a far-out year, with no children yet (so
there's no age-12 hard cap to save it), can still in principle push
the horizon past even a ~30-year cache. `LeaveWishService` clamps a
materialised series to the cached range, but a row can reach the DB
another way (a hand-written import, an older client, this test's
direct repo write), so the loader must still defend itself. Rather than silently falling
back to the live `holidays` library (the original bug) or silently
clamping the horizon (which would contradict the §6 decision to plan
past B4 and show the squeeze), the loader must raise a
@@ -604,9 +606,8 @@ class TestAllocationInputLoader:
window_end_month=6,
window_end_day=30,
target_weeks=4,
recurrence="yearly",
first_year=2027,
last_year=2200,
first_year=2200,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
@@ -1,13 +1,13 @@
"""Regression tests for two behaviours investigated after a live bug report:
1. `recurrence: 'once'` wishes — reported as producing *zero* planned days
1. one-off (standalone, non-series) wishes — reported as producing *zero* planned days
for a drag-created period, with no error/rejection surfaced anywhere.
Investigation (see the task write-up) traced the live symptom to the demo
seed data: the new wish's default (lowest) priority + `parent_mode:
'specific'` bound to a parent whose vacation budget for that particular
year is deliberately zeroed by `scripts/seed_demo.py`. `LeaveWish
.applies_to_year`/`_candidate_years`/`_expand_all_windows` all handle
`recurrence == 'once'` correctly — proven here with ample resources (the
a standalone occurrence correctly — proven here with ample resources (the
window is produced and fully funded) and with a past window (correctly
produces nothing, the *good* kind of "nothing"). These tests exist so
that conclusion has a permanent regression guard instead of living only
@@ -34,6 +34,7 @@ from tests.unit.test_services.test_allocation_service import (
_child,
_parent,
_wish,
_wish_series,
)
@@ -53,7 +54,6 @@ class TestOnceRecurrenceProducesDays:
end=(10, 9), # Mon-Fri, 5 days
weeks=1,
target_days=5,
recurrence="once",
first_year=2026,
parent_mode="specific",
parent_uuid=parent.uuid,
@@ -100,7 +100,6 @@ class TestOnceRecurrenceProducesDays:
start=(3, 1),
end=(3, 5),
weeks=1,
recurrence="once",
first_year=2021,
parent_mode="specific",
parent_uuid=parent.uuid,
@@ -135,18 +134,18 @@ class TestWrappingWindowStraddlingToday:
"""
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
wishes = _wish_series(
child_id=child.id,
name="Jullov",
start=(12, 20),
end=(1, 16), # wraps into January
weeks=4,
recurrence="yearly",
first_year=2025,
last_year=2026, # explicit, so this doesn't depend on the child's 4th birthday
parent_mode="specific",
parent_uuid=parent.uuid,
)
wish = wishes[0] # the 2025-anchored occurrence — the one under test
today = date(2026, 1, 5) # inside the 2025-anchored window's January tail
alloc_input = _build_input(
today=today,
@@ -158,7 +157,7 @@ class TestWrappingWindowStraddlingToday:
(parent.uuid, 2025): VacationBudget(25, 0, 0),
(parent.uuid, 2026): VacationBudget(25, 0, 0),
},
wishes=[wish],
wishes=wishes,
)
proposal = Allocator().propose(alloc_input)
@@ -17,11 +17,13 @@ Where a test *wants* red-day-cluster (tier-2) behaviour, it uses a window
around a real holiday (Easter 2027 / Jul 2027-2028) instead.
"""
from dataclasses import replace
from datetime import date, timedelta
from typing import Any
from uuid import UUID, uuid4
from app.config import settings
from app.entities.leave_wish import LeaveWish
from app.entities.leave_wish import LeaveWish, project_window
from app.entities.planned_leave import PlannedLeaveDay
from app.services.allocation_service import (
REASON_DUBBELDAG,
@@ -92,7 +94,7 @@ def _wish(
end: tuple[int, int] = (2, 28),
weeks: int = 4,
target_days: int | None = None,
recurrence: str = "once",
series_id: UUID | None = None,
first_year: int = 2027,
last_year: int | None = None,
priority: int = 1,
@@ -116,7 +118,7 @@ def _wish(
window_end_day=end[1],
target_weeks=weeks,
target_days=target_days,
recurrence=recurrence,
series_id=series_id,
first_year=first_year,
last_year=last_year,
priority=priority,
@@ -127,6 +129,43 @@ def _wish(
)
def _wish_series(*, first_year: int, last_year: int, **kwargs: Any) -> list[LeaveWish]:
"""The per-year rows `LeaveWishService` materialises for a repeating wish.
A repeating wish is no longer one row the allocator expands; it is one row
per year, each already carrying its own dates (see
`app.entities.leave_wish`). Tests that used to build a single
`recurrence='yearly'` wish build the rows here instead, through the same
`project_window` the service uses — so the weekday snap these rows carry is
the production projection, not a copy of it.
"""
series_id = uuid4()
anchor = _wish(first_year=first_year, last_year=last_year, series_id=series_id, **kwargs)
rows = [anchor]
for year in range(first_year + 1, last_year + 1):
start, end = project_window(
start_month=anchor.window_start_month,
start_day=anchor.window_start_day,
end_month=anchor.window_end_month,
end_day=anchor.window_end_day,
anchor_year=first_year,
year=year,
window_mode=anchor.window_mode,
)
rows.append(
replace(
anchor,
id=uuid4(),
first_year=year,
window_start_month=start.month,
window_start_day=start.day,
window_end_month=end.month,
window_end_day=end.day,
)
)
return rows
def _build_input(
*,
today: date,
@@ -1523,13 +1562,12 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster:
def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
wishes = _wish_series(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2029,
window_mode="flexible",
@@ -1545,7 +1583,7 @@ class TestCase21FlexibleYearlyWishTracksMovingEaster:
(parent.uuid, 2028): VacationBudget(50, 0, 0),
(parent.uuid, 2029): VacationBudget(50, 0, 0),
},
wishes=[wish],
wishes=wishes,
extra_holiday_years=(2028, 2029),
)
@@ -2426,81 +2464,117 @@ class TestStrategyReportsDegradation:
# ---------------------------------------------------------------------------
# todo.md #1 -- a `window_mode == 'fixed'` wish's recurrence is a week-based
# habit ("off Mon-Sun"), not a literal month/day. `window_for_year` must snap
# later years' naive month/day anchor onto the nearest date sharing
# `first_year`'s start weekday, and hold the window's day-span fixed instead
# of re-anchoring end_month/end_day. Flexible wishes are unaffected -- their
# four fields are a bounding range to search inside, never a placement.
# These are pure entity-level tests (no Allocator needed) since the rule
# lives entirely in `LeaveWish.window_for_year`.
# todo.md #1 -- a `window_mode == 'fixed'` repeating wish is a week-based habit
# ("off Mon-Sun"), not a literal month/day. Each later year's naive month/day
# anchor must be snapped onto the nearest date sharing the anchor year's start
# weekday, holding the window's day-span fixed instead of re-anchoring
# end_month/end_day. Flexible wishes are unaffected -- their four fields are a
# bounding range to search inside, never a placement.
#
# The snap now happens ONCE, when `LeaveWishService` materialises the series
# into per-year rows (`project_window`), and the snapped dates are what each
# row stores -- so these assert on the stored rows rather than on a projection
# re-run at read time. The dates are the same ones the old single-row shape
# produced; `_wish_series` runs the production projection, so nothing here
# re-derives the arithmetic. Still pure entity-level tests, no Allocator.
# ---------------------------------------------------------------------------
class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
def test_reported_user_case_shifts_to_the_nearest_matching_weekday(self) -> None:
"""2026-10-05..10-11 is Mon-Sun. Naively recurring the same month/day
"""2026-10-05..10-11 is Mon-Sun. Naively repeating the same month/day
would land 2027 on Tue-Mon -- the exact bug report. The nearest date
to 2027-10-05 sharing Monday is 2027-10-04, and holding the 6-day
span gives 2027-10-04..10-10 (also Mon-Sun); 2028 lands on
10-02..10-08 for the same reason (verified with date.weekday()
directly, not by re-deriving the arithmetic in this docstring).
"""
wish = _wish(
child_id=None,
start=(10, 5),
end=(10, 11),
recurrence="yearly",
first_year=2026,
window_mode="fixed",
)
rows = {
w.first_year: w
for w in _wish_series(
child_id=None,
start=(10, 5),
end=(10, 11),
first_year=2026,
last_year=2028,
window_mode="fixed",
)
}
assert set(rows) == {2026, 2027, 2028}
# Every year is its own row, all in one series.
assert len({w.series_id for w in rows.values()}) == 1
assert all(w.series_id is not None for w in rows.values())
start_2026, end_2026 = wish.window_for_year(2026)
start_2026, end_2026 = rows[2026].window()
assert (start_2026, end_2026) == (date(2026, 10, 5), date(2026, 10, 11))
assert start_2026.weekday() == 0 # Monday
assert end_2026.weekday() == 6 # Sunday
start_2027, end_2027 = wish.window_for_year(2027)
start_2027, end_2027 = rows[2027].window()
assert (start_2027, end_2027) == (date(2027, 10, 4), date(2027, 10, 10))
assert start_2027.weekday() == 0
assert end_2027.weekday() == 6
start_2028, end_2028 = wish.window_for_year(2028)
start_2028, end_2028 = rows[2028].window()
assert (start_2028, end_2028) == (date(2028, 10, 2), date(2028, 10, 8))
assert start_2028.weekday() == 0
assert end_2028.weekday() == 6
def test_the_snap_is_stored_on_the_row_not_recomputed(self) -> None:
"""The whole point of materialising: the shifted dates are the row's
own month/day columns, so a later per-year edit has somewhere to go
and nothing re-derives 2027 from 2026 behind the user's back.
"""
rows = {
w.first_year: w
for w in _wish_series(
child_id=None,
start=(10, 5),
end=(10, 11),
first_year=2026,
last_year=2027,
window_mode="fixed",
)
}
assert (rows[2027].window_start_month, rows[2027].window_start_day) == (10, 4)
assert (rows[2027].window_end_month, rows[2027].window_end_day) == (10, 10)
def test_first_year_is_returned_unshifted(self) -> None:
wish = _wish(
rows = _wish_series(
child_id=None,
start=(10, 5),
end=(10, 11),
recurrence="yearly",
first_year=2026,
last_year=2028,
window_mode="fixed",
)
assert wish.window_for_year(2026) == (date(2026, 10, 5), date(2026, 10, 11))
assert rows[0].first_year == 2026
assert rows[0].window() == (date(2026, 10, 5), date(2026, 10, 11))
def test_new_year_wrapping_fixed_wish_still_wraps(self) -> None:
"""Jullov 12-20..01-06 (Sun -> Wed, a 17-day span) must keep landing
its end in `year + 1` relative to its own anchor year after the
weekday shift, exactly like the unshifted (first_year) case.
its end in `first_year + 1` relative to its own row's year after the
weekday shift, exactly like the unshifted (anchor year) case.
"""
wish = _wish(
child_id=None,
start=(12, 20),
end=(1, 6),
recurrence="yearly",
first_year=2026,
window_mode="fixed",
)
assert wish.window_wraps() is True
rows = {
w.first_year: w
for w in _wish_series(
child_id=None,
start=(12, 20),
end=(1, 6),
first_year=2026,
last_year=2027,
window_mode="fixed",
)
}
assert rows[2026].window_wraps() is True
assert rows[2027].window_wraps() is True
start_2026, end_2026 = wish.window_for_year(2026)
start_2026, end_2026 = rows[2026].window()
assert start_2026 == date(2026, 12, 20)
assert end_2026 == date(2027, 1, 6)
start_2027, end_2027 = wish.window_for_year(2027)
start_2027, end_2027 = rows[2027].window()
assert start_2027 == date(2027, 12, 19)
assert end_2027 == date(2028, 1, 5)
assert (end_2027 - start_2027) == (end_2026 - start_2026)
@@ -2511,18 +2585,23 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
"""A flexible wish's four fields describe a bounding range to search
inside, not a placement -- no weekday shift, ever, including for
Season presets which must stay byte-identical after this change.
Each year still gets its OWN row (carrying that year's range); the
per-year placement search inside it is the allocator's job.
"""
wish = _wish(
child_id=None,
start=(3, 1),
end=(5, 31),
recurrence="yearly",
first_year=2026,
window_mode="flexible",
)
assert wish.window_for_year(2026) == (date(2026, 3, 1), date(2026, 5, 31))
assert wish.window_for_year(2027) == (date(2027, 3, 1), date(2027, 5, 31))
assert wish.window_for_year(2028) == (date(2028, 3, 1), date(2028, 5, 31))
rows = {
w.first_year: w
for w in _wish_series(
child_id=None,
start=(3, 1),
end=(5, 31),
first_year=2026,
last_year=2028,
window_mode="flexible",
)
}
assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31))
assert rows[2027].window() == (date(2027, 3, 1), date(2027, 5, 31))
assert rows[2028].window() == (date(2028, 3, 1), date(2028, 5, 31))
# ---------------------------------------------------------------------------
@@ -2863,13 +2942,12 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure:
# August has no Swedish red days nearby, so tier-2 never fires.
child = _child(birth_date=date(2027, 9, 1)) # fourth birthday well after 2030
parent = _parent(salary_type="hourly")
wish = _wish(
wishes = _wish_series(
child_id=child.id,
name="Sportlov",
start=(8, 2),
end=(8, 8),
weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2030,
priority=1,
@@ -2896,7 +2974,7 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure:
# shortfall, not a full-window one.
pools={(parent.uuid, child.id): DayPool(18, 0, 60)},
vacation={(parent.uuid, y): VacationBudget(0, 0, 0) for y in range(2026, 2031)},
wishes=[wish, sibling],
wishes=[*wishes, sibling],
# `_build_input` only auto-merges holidays for
# {today.year, horizon_end.year, horizon_end.year + 1} =
# {2027, 2030, 2031} -- without this, 2028/2029 would be missing
@@ -2907,7 +2985,8 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure:
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes if o.wish_id == wish.id}
series_ids = {w.id for w in wishes}
by_year = {o.year: o for o in proposal.outcomes if o.wish_id in series_ids}
assert set(by_year) == {2027, 2028, 2029, 2030}
for y in (2027, 2028, 2029):
@@ -2930,7 +3009,7 @@ class TestCase34PartialFundingAcrossYearsShortfallNotFailure:
# A usable plan, not a refusal: the fourth year still has concrete
# `ProposedDay`s for the days it could actually afford (2 free + 3
# sjukpenning = 5), not zero.
year4_days = [d for d in proposal.days if d.wish_id == wish.id and d.date.year == 2030]
year4_days = [d for d in proposal.days if d.wish_id in series_ids and d.date.year == 2030]
assert len(year4_days) == 5
assert sum(1 for d in year4_days if d.day_type == "sjukpenning") == 3
@@ -2951,13 +3030,12 @@ class TestCase35ShiftRemedyFindsMovingEaster:
def test_widened_search_finds_that_years_easter_cluster(self) -> None:
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030 -- past both years
parent = _parent(salary_type="hourly")
wish = _wish(
wishes = _wish_series(
child_id=child.id,
name="Vårvecka",
start=(3, 22),
end=(3, 28),
weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2028,
)
@@ -2976,17 +3054,19 @@ class TestCase35ShiftRemedyFindsMovingEaster:
(parent.uuid, 2027): VacationBudget(0, 0, 0),
(parent.uuid, 2028): VacationBudget(0, 0, 0),
},
wishes=[wish],
wishes=wishes,
)
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes}
# The wish's own placement each year, from the production entity --
# NOT hand-derived -- confirms the weekday-preserving snap moves the
# naive 2028 anchor from Mon 3/20 rather than literally 3/22.
assert wish.window_for_year(2027) == (date(2027, 3, 22), date(2027, 3, 28))
assert wish.window_for_year(2028) == (date(2028, 3, 20), date(2028, 3, 26))
# Each year's placement, read straight off its OWN row -- the
# weekday-preserving snap was applied once, when the series was
# materialised, and moves the naive 2028 anchor from Mon 3/20 rather
# than leaving it literally on 3/22.
rows_by_year = {w.first_year: w for w in wishes}
assert rows_by_year[2027].window() == (date(2027, 3, 22), date(2027, 3, 28))
assert rows_by_year[2028].window() == (date(2028, 3, 20), date(2028, 3, 26))
outcome_2027 = by_year[2027]
assert outcome_2027.shortfall_days > 0
@@ -3020,5 +3100,5 @@ class TestCase35ShiftRemedyFindsMovingEaster:
# Both wish-name provenance fields still travel onto the remedy
# (§branches test already covers this for the non-Easter case; keep
# it here too since this remedy's `data` shape just grew).
assert shift_2027.data["wish_name_message_key"] == wish.name_message_key
assert shift_2028.data["wish_name_message_key"] == wish.name_message_key
assert shift_2027.data["wish_name_message_key"] == rows_by_year[2027].name_message_key
assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key
@@ -77,7 +77,6 @@ def _jullov_wrap_scenario(*, parent_a_starved: bool):
start=(12, 20),
end=(1, 16), # wraps into January
weeks=4,
recurrence="once",
first_year=2027,
parent_mode="either",
)
@@ -127,10 +127,10 @@ def _make_break(
) -> LeaveWish:
"""Build a single-year LeaveWish equivalent to the old BreakPeriod(start, end).
`recurrence="once"` + `first_year=start.year` pins the wish to the single
concrete window (start, end) — `window_for_year` reproduces those exact
dates as long as `end >= start` in the same year (true for every caller
here), so this is a drop-in replacement for the retired BreakPeriod shape.
Every wish row is one occurrence now, so `first_year=start.year` plus the
month/day pair IS the window: `LeaveWish.window()` reads back exactly
(start, end) as long as `end >= start` in the same year (true for every
caller here). A drop-in replacement for the retired BreakPeriod shape.
"""
return LeaveWish(
id=uuid4(),
@@ -142,9 +142,7 @@ def _make_break(
window_end_month=end.month,
window_end_day=end.day,
target_weeks=target_weeks,
recurrence="once",
first_year=start.year,
last_year=start.year,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
@@ -259,7 +259,6 @@ class TestRemoveMember:
window_end_month=8,
window_end_day=31,
target_weeks=4,
recurrence="yearly",
first_year=2026,
last_year=None,
priority=1,