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
This commit is contained in:
@@ -140,7 +140,19 @@ class TestExcludedDayIsNeverPlaced:
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
assert outcome.remedies == []
|
||||
# None of the three genuinely shortfall-gated remedies fire for a
|
||||
# zero-shortfall window -- the exclusion did not create a gap to
|
||||
# remedy. This used to be `remedies == []` outright, but the
|
||||
# `shift_window_into_free_days` suggestion (§todo item, red-day
|
||||
# shift toast) is deliberately NOT shortfall-gated for a `fixed`
|
||||
# window any more: a fully-funded fixed window can still surface
|
||||
# "moving this frees N days", and this Feb 1-7 window genuinely has
|
||||
# a cheaper nearby placement. See `_Ledger._build_remedies`.
|
||||
assert {r.kind for r in outcome.remedies} <= {"shift_window_into_free_days"}
|
||||
assert not any(
|
||||
r.kind in {"reduce_preschool_reserve", "carry_vacation_forward", "lower_priority_wish"}
|
||||
for r in outcome.remedies
|
||||
)
|
||||
# `weeks_funded` still tells the truth about what was actually
|
||||
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
|
||||
assert outcome.weeks_funded == round(6 / 7, 2)
|
||||
|
||||
@@ -220,6 +220,7 @@ def _planned(
|
||||
on: date,
|
||||
day_type: str = "vacation",
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool = False,
|
||||
) -> PlannedLeaveDay:
|
||||
"""A persisted `planned_leave_day` row — the "already applied, before
|
||||
|
||||
@@ -227,10 +228,12 @@ def _planned(
|
||||
flexible re-propose; extended for §fix parent-reassignment, decision B)
|
||||
test cases below. Only the fields `AllocationInput.existing_planned`/
|
||||
`all_existing_planned` actually read (`parent_personal_data_uuid`,
|
||||
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
|
||||
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
|
||||
placement by — pass the wish's `.id` whenever a test relies on that
|
||||
reuse mechanism finding these rows.
|
||||
`child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id`
|
||||
is what `_Ledger._persisted_by_wish` now matches a flexible wish's own
|
||||
prior placement by — pass the wish's `.id` whenever a test relies on
|
||||
that reuse mechanism finding these rows. `is_pinned=True` marks a date
|
||||
the user locked in place — the Step 3 realignment escape hatch must
|
||||
never move a window that contains one.
|
||||
"""
|
||||
return PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
@@ -240,6 +243,7 @@ def _planned(
|
||||
date=on,
|
||||
day_type=day_type,
|
||||
wish_id=wish_id,
|
||||
is_pinned=is_pinned,
|
||||
)
|
||||
|
||||
|
||||
@@ -1288,9 +1292,12 @@ class TestCase16FlexibleTieBreakByEarliestStart:
|
||||
def test_equal_cost_candidates_break_tie_by_earliest_start(self) -> None:
|
||||
# A holiday-free bound (Feb 2027): every 7-day slice contains
|
||||
# exactly one Saturday and one Sunday, so every candidate has the
|
||||
# *same* calendar-only cost (5) regardless of start date -- the
|
||||
# only thing that can break the tie is `candidate.start`, never
|
||||
# dict/set iteration order.
|
||||
# *same* calendar-only cost (5) regardless of start date. Since
|
||||
# §Decision 2, the tie is actually broken by Monday-alignment first,
|
||||
# THEN earliest start -- 2027-02-01 happens to be a Monday, so this
|
||||
# still pins down the same winner; `TestFlexibleWishPrefersMonday`
|
||||
# below exercises the Monday-preference itself against a bound whose
|
||||
# earliest start is NOT a Monday.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
@@ -1956,16 +1963,163 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
|
||||
assert flex_outcome.strategy != STRATEGY_UNPLACED
|
||||
assert flex_outcome.shortfall_days == 0
|
||||
|
||||
# Since Step 3's realignment escape hatch, "moves" here means all the
|
||||
# way to the globally cheapest remaining window, not just the
|
||||
# nearest one that happens to still contain 2027-03-23: with 03-25
|
||||
# occupied, the only windows reusable around the applied day cost 5
|
||||
# (Wed 03-17..03-23 or Thu 03-18..03-24, no red days) -- both
|
||||
# misaligned (not Monday-start), which is what lets the escape
|
||||
# hatch compare them against a fresh search. That search finds
|
||||
# 03-26..04-01 (Fri-Thu), which still catches the Easter cluster
|
||||
# (Fri-Mon free) at cost 3 -- strictly cheaper, so it wins even
|
||||
# though it abandons 03-23 entirely and is itself not Monday-start
|
||||
# (§Decision 2: red days always outrank alignment).
|
||||
assert (flex_outcome.start_date, flex_outcome.end_date) == (
|
||||
date(2027, 3, 26),
|
||||
date(2027, 4, 1),
|
||||
)
|
||||
assert flex_outcome.cost_breakdown["free"] == 4
|
||||
assert flex_outcome.cost_breakdown["vacation"] == 3
|
||||
|
||||
flex_dates = {
|
||||
flex_outcome.start_date + timedelta(days=i)
|
||||
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
|
||||
}
|
||||
# Still honours the previously-applied day (it's inside the new
|
||||
# window's span) but never re-claims the fixed wish's date.
|
||||
assert date(2027, 3, 23) in flex_dates
|
||||
# The previously-applied day is abandoned along with the rest of the
|
||||
# old window -- cheaper wins over "keep what's already there" -- and
|
||||
# the fixed wish's date is (still) never re-claimed.
|
||||
assert date(2027, 3, 23) not in flex_dates
|
||||
assert date(2027, 3, 25) not in flex_dates
|
||||
|
||||
|
||||
class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
|
||||
"""§fix (Step 3) — the flexible-window realignment escape hatch.
|
||||
|
||||
A flexible wish-year already funded before §Decision 2 existed (or
|
||||
before its bound moved) is otherwise frozen forever to whichever weekday
|
||||
it first landed on, by `_reuse_already_funded_window`'s own reuse-the-
|
||||
prior-window mechanism. It must self-heal onto a Monday the next time
|
||||
`propose()` runs -- UNLESS one of its persisted dates carries a manual
|
||||
edit (pinned, or explicitly removed), in which case the user's choice
|
||||
always outlives realignment.
|
||||
|
||||
All three cases share the same bound: August 2027, holiday-free, so
|
||||
every 7-day candidate costs the same 5 spendable days and the only thing
|
||||
that can separate them is the Monday-alignment tie-break.
|
||||
"""
|
||||
|
||||
def _alloc_input(
|
||||
self,
|
||||
wish: LeaveWish,
|
||||
child: ChildFacts,
|
||||
parent: ParentFacts,
|
||||
existing: list[PlannedLeaveDay],
|
||||
) -> AllocationInput:
|
||||
return _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
def test_a_misaligned_year_releases_onto_a_monday(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
# A prior apply funded the full Tuesday-start week 08-03..08-09.
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
# Released the Tuesday window for the earliest Monday-start
|
||||
# candidate that doesn't collide with the abandoned dates.
|
||||
assert outcome.start_date == date(2027, 8, 16)
|
||||
assert outcome.start_date.weekday() == 0
|
||||
|
||||
def test_a_pinned_day_keeps_the_window_from_moving(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid,
|
||||
child_id=child.id,
|
||||
on=date(2027, 8, d),
|
||||
wish_id=wish.id,
|
||||
is_pinned=(d == 3), # the user locked the first day in place
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
# Same misaligned Tuesday window as the unprotected case above --
|
||||
# but this time it must NOT move, because one of its days is pinned.
|
||||
assert outcome.start_date == date(2027, 8, 3)
|
||||
assert outcome.start_date.weekday() == 1 # still Tuesday
|
||||
|
||||
def test_an_excluded_day_keeps_the_window_from_moving(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = replace(
|
||||
self._alloc_input(wish, child, parent, existing),
|
||||
# The user explicitly removed one of the window's days
|
||||
# (§remove-this-day) -- still a manual edit, must not move.
|
||||
excluded_days=frozenset({(parent.uuid, child.id, date(2027, 8, 4))}),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 8, 3)
|
||||
assert outcome.start_date.weekday() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §Task 4 — pinned days: fixed, and debited off the top before any window
|
||||
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
||||
@@ -2587,18 +2741,24 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
||||
assert start_2027.weekday() == start_2026.weekday()
|
||||
assert end_2027.weekday() == end_2026.weekday()
|
||||
|
||||
def test_flexible_wish_bounding_range_is_unchanged_across_years(self) -> None:
|
||||
def test_flexible_wish_bounding_range_stays_weekday_aligned_across_years(self) -> None:
|
||||
"""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.
|
||||
|
||||
inside, not a placement -- but the range must still get the same
|
||||
weekday snap as a fixed placement, or a week-shaped bound (the
|
||||
reported bug, extended: also breaks a Season preset whose bound
|
||||
happens to be week-shaped) silently drifts onto a different weekday
|
||||
every non-anchor year. The bound's day-*span* stays exactly fixed
|
||||
(91 days here) so the allocator's per-year search space never
|
||||
shrinks; only the start slides by the anchor's own `_shift_to_weekday`
|
||||
rule. Each year still gets its OWN row (carrying that year's range);
|
||||
the per-year placement search inside it is the allocator's job.
|
||||
"""
|
||||
rows = {
|
||||
w.first_year: w
|
||||
for w in _wish_series(
|
||||
child_id=None,
|
||||
start=(3, 1),
|
||||
start=(3, 1), # 2026-03-01 is a Sunday
|
||||
end=(5, 31),
|
||||
first_year=2026,
|
||||
last_year=2028,
|
||||
@@ -2606,8 +2766,148 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
||||
)
|
||||
}
|
||||
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))
|
||||
assert rows[2027].window() == (date(2027, 2, 28), date(2027, 5, 30))
|
||||
assert rows[2028].window() == (date(2028, 2, 27), date(2028, 5, 28))
|
||||
for row in rows.values():
|
||||
start, end = row.window()
|
||||
assert start.weekday() == 6 # Sunday, like the anchor
|
||||
assert (end - start).days == 91
|
||||
|
||||
|
||||
class TestFlexibleWishPrefersMonday:
|
||||
"""§Decision 2 (user-confirmed) — a flexible window's placement search
|
||||
|
||||
prefers a Monday start, but a non-Monday wins whenever it absorbs
|
||||
strictly more red days; red days always outrank alignment. Covers the
|
||||
three cases the plan called out: a wide, holiday-free bound (pure
|
||||
ranking, `_rank_candidate`'s new `misaligned` term); a bound whose
|
||||
length equals the target (the degenerate single-candidate case only
|
||||
Step 1's bound snap can fix, since `_rank_candidate` never even runs a
|
||||
second candidate to prefer); and a strictly-cheaper non-Monday still
|
||||
winning over a pricier Monday alternative.
|
||||
"""
|
||||
|
||||
def test_wide_holiday_free_bound_places_on_a_monday_every_year(self) -> None:
|
||||
# August: no Swedish public holidays, so every 7-day candidate in the
|
||||
# bound costs the same 5 spendable days -- the only thing that can
|
||||
# separate them is the Monday-alignment tie-break.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wishes = _wish_series(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
last_year=2029,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2029, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
|
||||
vacation={
|
||||
(parent.uuid, 2027): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2028): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2029): VacationBudget(50, 0, 0),
|
||||
},
|
||||
wishes=wishes,
|
||||
extra_holiday_years=(2027, 2028, 2029),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
by_year = {o.year: o for o in proposal.outcomes}
|
||||
assert set(by_year) == {2027, 2028, 2029}
|
||||
for o in by_year.values():
|
||||
assert o.shortfall_days == 0
|
||||
assert o.start_date.weekday() == 0 # Monday, every year
|
||||
# The bound itself keeps sliding (§Decision 1, Step 1's bound snap)
|
||||
# so each year's Monday is a genuinely different date, not one
|
||||
# window re-proposed three times.
|
||||
assert by_year[2027].start_date == date(2027, 8, 2)
|
||||
assert by_year[2028].start_date == date(2028, 7, 31)
|
||||
assert by_year[2029].start_date == date(2029, 7, 30)
|
||||
|
||||
def test_bound_length_equal_to_target_repeats_monday_to_sunday(self) -> None:
|
||||
# The degenerate case: a 42-day bound for a 42-day (6-week) target
|
||||
# gives `_candidate_windows` exactly ONE candidate per year, so no
|
||||
# amount of ranking can align it -- only Step 1's bound snap (the
|
||||
# bound itself keeping its weekday alignment year over year) can.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wishes = _wish_series(
|
||||
child_id=child.id,
|
||||
name="Sommar",
|
||||
start=(6, 28), # 2027-06-28 is a Monday
|
||||
end=(8, 8),
|
||||
weeks=6,
|
||||
first_year=2027,
|
||||
last_year=2029,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2029, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
|
||||
vacation={
|
||||
(parent.uuid, 2027): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2028): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2029): VacationBudget(50, 0, 0),
|
||||
},
|
||||
wishes=wishes,
|
||||
extra_holiday_years=(2027, 2028, 2029),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
by_year = {o.year: o for o in proposal.outcomes}
|
||||
assert set(by_year) == {2027, 2028, 2029}
|
||||
assert by_year[2027].start_date == date(2027, 6, 28)
|
||||
assert by_year[2028].start_date == date(2028, 6, 26)
|
||||
assert by_year[2029].start_date == date(2029, 6, 25)
|
||||
for o in by_year.values():
|
||||
assert o.start_date.weekday() == 0
|
||||
assert (o.end_date - o.start_date).days == 41
|
||||
|
||||
def test_strictly_cheaper_non_monday_still_wins(self) -> None:
|
||||
# Same Easter-cluster bound as TestCase15: the Tuesday-start window
|
||||
# touching Långfredagen..Annandag påsk costs 3 spendable days.
|
||||
# Nearby Monday-start candidates (2027-03-22..03-28 and
|
||||
# 2027-03-29..04-04) each cost 4 -- strictly more -- so red days
|
||||
# must still win over alignment.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.start_date.weekday() == 1 # Tuesday, not Monday
|
||||
assert outcome.cost_breakdown["free"] == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3108,3 +3408,159 @@ class TestCase35ShiftRemedyFindsMovingEaster:
|
||||
# it here too since this remedy's `data` shape just grew).
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §todo item (red-day shift toast) — a `window_mode == 'fixed'` window's
|
||||
# placement is unconditional (never auto-moved), so it needs the
|
||||
# shift_window_into_free_days SUGGESTION surfaced even when fully funded
|
||||
# (previously hidden behind `shortfall_days > 0`). A `window_mode ==
|
||||
# 'flexible'` window's own bound search already IS the shift, so it must
|
||||
# NEVER get that remedy kind, and instead reports how much the search
|
||||
# improved on the naive "first slot in the bound" placement via
|
||||
# `WindowOutcome.shift_gain_days`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCase36ShiftRemedyOnFullyFundedFixedWindow:
|
||||
def test_fully_funded_fixed_window_still_gets_the_shift_suggestion(self) -> None:
|
||||
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030, past 2027
|
||||
parent = _parent(salary_type="monthly")
|
||||
# Same window TestCase35 uses, but funded generously -- unlike that
|
||||
# test's deliberately starved pool, this window is FULLY funded, so
|
||||
# the only thing that could still hide the shift suggestion is
|
||||
# `_build_remedies`'s shortfall gate (now removed for fixed windows).
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårvecka",
|
||||
start=(3, 22),
|
||||
end=(3, 28),
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="fixed",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
shift = next(r for r in outcome.remedies if r.kind == "shift_window_into_free_days")
|
||||
# Same best placement TestCase35 finds for this exact window: shift
|
||||
# 1 day later to pick up Easter Monday (2027-03-29, a real holiday).
|
||||
assert shift.data["new_start_date"] == "2027-03-23"
|
||||
assert shift.data["new_end_date"] == "2027-03-29"
|
||||
assert shift.days_freed == 1
|
||||
|
||||
|
||||
class TestCase37ShiftRemedyNeverOnFlexibleWindow:
|
||||
def test_flexible_window_never_gets_the_shift_remedy_even_with_a_shortfall(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
# Deliberately starved (same recipe as TestCase35) so the placed
|
||||
# (searched, not the whole bound -- this is not the §10.4
|
||||
# "unplaced" case TestCase20 covers) window carries a genuine
|
||||
# shortfall -- the flexible contract says its own search already
|
||||
# IS the shift, so the remedy must stay absent regardless.
|
||||
pools={(parent.uuid, child.id): DayPool(1, 0, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.strategy != STRATEGY_UNPLACED
|
||||
assert outcome.shortfall_days > 0
|
||||
assert all(r.kind != "shift_window_into_free_days" for r in outcome.remedies)
|
||||
|
||||
|
||||
class TestCase38FlexibleShiftGainDaysReportsSearchImprovement:
|
||||
def test_winner_beating_naive_bound_start_reports_positive_gain(self) -> None:
|
||||
# Same setup as TestCase15FlexibleWishFindsEasterCluster: the search
|
||||
# picks 2027-03-23..03-29 (3 spendable days -- the Easter cluster)
|
||||
# over the naive bound-start placement 2027-03-01..03-07 (5
|
||||
# spendable days -- Mon-Fri all working, verified against
|
||||
# `get_swedish_holidays(2027)`) -- a 2-day gain.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.end_date == date(2027, 3, 29)
|
||||
assert outcome.shift_gain_days == 2
|
||||
|
||||
def test_winner_landing_on_naive_bound_start_reports_zero(self) -> None:
|
||||
# Same setup as TestCase16FlexibleTieBreakByEarliestStart: every
|
||||
# candidate in the holiday-free February bound costs the same, and
|
||||
# 2027-02-01 (the bound's own start) is also the Monday-aligned
|
||||
# tie-break winner -- the winner IS the naive placement, so there is
|
||||
# nothing to report.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Sportlov",
|
||||
start=FEB_2027[0],
|
||||
end=FEB_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 2, 1)
|
||||
assert outcome.shift_gain_days == 0
|
||||
|
||||
Reference in New Issue
Block a user