"""The allocator must never re-place a day the user removed by hand. User's requirement, verbatim: "removing a single day should stay permanent. Even if it's an auto added period day." The wish still says "a full week", so without `AllocationInput.excluded_days` the next `POST /api/plan/sync` simply re-derives the removed date — which is exactly what these tests pin down. Two decisions are asserted here, both explained at length on `_Ledger._excluded_window_dates`: 1. **The parent is part of the key.** A both-parents window places one day per parent on the same date; removing one parent's day leaves the partner's standing. A date only leaves the window entirely when no responsible parent may take it. 2. **An excluded date shrinks the window's target; it is not a shortfall.** A day the user removed is not a day the plan failed to fund, so `shortfall_days` must not grow (which would make the new shortfall remedies advise freeing resources for a day nobody wants, and would make `sync_plan`'s eviction preview treat a deliberate removal as a lost priority race). """ from datetime import date from app.services.allocation_service import Allocator, DayPool, VacationBudget from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish # Feb 2027 is the suite's clean "no red-day interference" month; Feb 1-7 2027 # is Mon-Sun, so 5 spendable days + Sat 6 + Sun 7. _WINDOW_START = (2, 1) _WINDOW_END = (2, 7) _REMOVED = date(2027, 2, 3) # a Wednesday in the middle of the window def _solo_scenario(*, excluded: frozenset | None = None, reuse=None): """A one-parent, one-week fixed window. `reuse` takes a `(child, parent)` pair from an earlier call so a test can build two inputs that are identical down to the uuids — needed both for a before/after comparison and for the determinism check, since `_Ledger` mutates its copies of the pool/vacation dicts in place and every `propose()` run therefore needs its own fresh input. """ if reuse is not None: child, parent = reuse else: child, parent = _child(birth_date=date(2025, 6, 1)), _parent(name="Solo") wish = _wish( child_id=child.id, name="Sportlov", start=_WINDOW_START, end=_WINDOW_END, weeks=1, first_year=2027, parent_mode="specific", parent_uuid=parent.uuid, ) 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(200, 90, 60)}, vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], excluded_days=excluded, ) return alloc_input, parent, child def _both_scenario(*, excluded: frozenset | None = None, reuse=None): """A both-parents ("co-leave") one-week fixed window; `reuse` as above.""" if reuse is not None: child, parent_a, parent_b = reuse else: child = _child(birth_date=date(2025, 6, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, name="Tillsammans", start=_WINDOW_START, end=_WINDOW_END, weeks=1, first_year=2027, parent_mode="both", ) alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 90, 60), (parent_b.uuid, child.id): DayPool(200, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(25, 0, 0), (parent_b.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[wish], excluded_days=excluded, ) return alloc_input, parent_a, parent_b, child class TestExcludedDayIsNeverPlaced: def test_solo_window_skips_the_removed_date(self) -> None: baseline_input, parent, child = _solo_scenario() baseline = Allocator().propose(baseline_input) assert _REMOVED in {d.date for d in baseline.days if d.day_type != "free"} alloc_input, parent, child = _solo_scenario( excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent) ) proposal = Allocator().propose(alloc_input) assert _REMOVED not in {d.date for d in proposal.days} # The rest of the window is untouched. assert {d.date for d in proposal.days if d.day_type != "free"} == { date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5), } def test_removed_date_is_not_reported_as_a_shortfall(self) -> None: """Decision: an excluded date reduces the window's target. The user removed it; the plan did not fail to fund it. If this counted as a gap, `_build_remedies` would start proposing ways to free up resources for a day nobody wants, and `sync_plan`'s eviction preview would block the sync that carries out the removal. """ _seed, parent, child = _solo_scenario() alloc_input, _p, _c = _solo_scenario( excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent) ) proposal = Allocator().propose(alloc_input) outcome = proposal.outcomes[0] assert outcome.shortfall_days == 0 # 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) def test_an_unrelated_parents_exclusion_does_not_touch_this_window(self) -> None: _seed, parent, child = _solo_scenario() alloc_input, _p, _c = _solo_scenario( excluded=frozenset({(_parent(name="Stranger").uuid, child.id, _REMOVED)}), reuse=(child, parent), ) proposal = Allocator().propose(alloc_input) assert _REMOVED in {d.date for d in proposal.days if d.day_type != "free"} assert proposal.outcomes[0].shortfall_days == 0 class TestExclusionIsKeyedPerParent: def test_both_parents_window_keeps_the_partners_day(self) -> None: """The parent-keying decision, stated as a test. Removing "this day" from A's band must not silently cancel B's leave on the same date — B never asked for that, and on a shared plan they would only discover it by noticing the hole. """ alloc_input, parent_a, parent_b, child = _both_scenario() baseline = Allocator().propose(alloc_input) baseline_on_date = [d for d in baseline.days if d.date == _REMOVED] assert {d.parent_personal_data_uuid for d in baseline_on_date} == { parent_a.uuid, parent_b.uuid, } alloc_input, parent_a, parent_b, child = _both_scenario( excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}), reuse=(child, parent_a, parent_b), ) proposal = Allocator().propose(alloc_input) on_date = [d for d in proposal.days if d.date == _REMOVED] assert [d.parent_personal_data_uuid for d in on_date] == [parent_b.uuid] assert on_date[0].day_type != "free" def test_the_date_leaves_the_window_only_when_both_parents_are_removed(self) -> None: _seed, parent_a, parent_b, child = _both_scenario() alloc_input, _a, _b, _c = _both_scenario( excluded=frozenset( { (parent_a.uuid, child.id, _REMOVED), (parent_b.uuid, child.id, _REMOVED), } ), reuse=(child, parent_a, parent_b), ) proposal = Allocator().propose(alloc_input) assert _REMOVED not in {d.date for d in proposal.days} assert proposal.outcomes[0].shortfall_days == 0 def test_one_parents_exclusion_is_not_a_shortfall_for_the_window(self) -> None: _seed, parent_a, parent_b, child = _both_scenario() alloc_input, _a, _b, _c = _both_scenario( excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}), reuse=(child, parent_a, parent_b), ) proposal = Allocator().propose(alloc_input) assert proposal.outcomes[0].shortfall_days == 0 class TestSwapWindowDoesNotRefillTheHole: def test_the_removed_date_is_not_handed_to_the_other_parent(self) -> None: """`_fund_swap`'s leftover hand-off must not undo the removal. In a swap window exactly one parent is on leave per date, so a date one parent is removed from would otherwise fall through as "unfunded leftover" and be offered to the partner — the day would visibly come back, just in the other parent's colour. """ child = _child(birth_date=date(2025, 6, 1)) parent_a = _parent(name="A") parent_b = _parent(name="B") wish = _wish( child_id=child.id, name="Sommar", start=_WINDOW_START, end=_WINDOW_END, weeks=1, first_year=2027, parent_mode="either", ) # `parent_mode='either'` with two parents resolves to a swap window. alloc_input = _build_input( today=date(2027, 1, 1), horizon_end=date(2027, 12, 31), children=[child], parents=[parent_a, parent_b], pools={ (parent_a.uuid, child.id): DayPool(200, 90, 60), (parent_b.uuid, child.id): DayPool(200, 90, 60), }, vacation={ (parent_a.uuid, 2027): VacationBudget(25, 0, 0), (parent_b.uuid, 2027): VacationBudget(25, 0, 0), }, wishes=[wish], excluded_days=frozenset({(parent_a.uuid, child.id, _REMOVED)}), ) proposal = Allocator().propose(alloc_input) assert _REMOVED not in {d.date for d in proposal.days} assert proposal.outcomes[0].shortfall_days == 0 class TestDeterminismIsPreserved: def test_same_input_same_output_with_exclusions(self) -> None: """§5 test #11 — the purity guarantee still holds with exclusions.""" _seed_input, parent, child = _solo_scenario() excluded = frozenset({(parent.uuid, child.id, _REMOVED)}) first_input, _p, _c = _solo_scenario(excluded=excluded, reuse=(child, parent)) second_input, _p2, _c2 = _solo_scenario(excluded=excluded, reuse=(child, parent)) first = Allocator().propose(first_input) second = Allocator().propose(second_input) assert [(d.date, d.day_type, d.reason) for d in first.days] == [ (d.date, d.day_type, d.reason) for d in second.days ]