Phase 2: Build 9 presentational components with full test coverage

New components (all with .tsx + .test.tsx, 90%+ coverage):
- CalendarDay: day cell with event badges
- CalendarEvent: colored event chip with dismiss option
- DaysBreakdown: table of days by type
- ProgressChart: progress bar toward age-4 deadline
- TipCard: optimization tip card with severity badges
- VacationDaysInput: number input with min/max validation
- ParentProfileInput: dual selects for salary type & employment status
- PlanningGoalSelector: radio buttons for planning goal
- BreakPeriodsInput: list manager with inline add/edit form

All components follow existing patterns from Header/Footer:
- Typed props interfaces (exported)
- Semantic HTML with data-testid & ARIA attributes
- No business logic, purely presentational
- Comprehensive test coverage including edge cases

npm test: 22 files, 167 tests passing
npx tsc --noEmit: clean

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:50:23 +02:00
parent faebcced8a
commit 425e29ea21
19 changed files with 2048 additions and 0 deletions
@@ -0,0 +1,815 @@
"""Tests for the tips-engine rule implementations.
Rules are tested by constructing TipsContext directly — no database required.
Each rule gets at minimum a fire case and a no-fire case.
"""
from datetime import date, datetime, timedelta
from uuid import UUID, uuid4
import pytest
from dateutil.relativedelta import relativedelta
from app.entities.break_period import BreakPeriod
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.services.tips_service import (
TipsContext,
_rule_balance_between_parents,
_rule_lagsta_niva_coverage,
_rule_long_break_parent_swap,
_rule_preserve_for_age_4,
_rule_reserve_for_preschool_planning_days,
_rule_stretch_time_at_home,
_rule_use_dubbeldagar_strategically,
_rule_wrap_red_days_with_vacation,
_rule_wrap_weekend_with_vacation,
)
from app.utils.tip_predicates import red_day_clusters_in_range
# ---------------------------------------------------------------------------
# Factories
# ---------------------------------------------------------------------------
def _make_child(birth_date: date, preschool_start_date: date | None = None) -> Child:
ws = uuid4()
return Child(
id=uuid4(),
workspace_id=ws,
name="TestChild",
birth_date=birth_date,
preschool_start_date=preschool_start_date,
)
def _make_parent(
workspace_id: UUID | None = None,
name: str = "Parent",
salary_type: str = "monthly",
employment_status: str = "employed",
) -> PersonalData:
return PersonalData(
uuid=uuid4(),
workspace_id=workspace_id or uuid4(),
name=name,
salary_type=salary_type,
employment_status=employment_status,
)
def _make_leave(
parent: PersonalData,
child_id: UUID,
total: int = 240,
used: int = 100,
sjukpenning_left: int = 200,
lagsta_left: int = 60,
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=390 - sjukpenning_left,
sjukpenning_left=sjukpenning_left,
lagsta_niva_total=90,
lagsta_niva_used=90 - lagsta_left,
lagsta_niva_left=lagsta_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=parent.workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=total,
days_used=used,
days_left=total - used,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=50,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
updated_at=now,
)
def _make_fp_day(
parent: PersonalData,
child_id: UUID,
workspace_id: UUID,
day: date,
day_type: str = "sjukpenning",
is_dubbeldag: bool = False,
) -> PlannedLeaveDay:
return PlannedLeaveDay(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
parent_personal_data_uuid=parent.uuid,
date=day,
day_type=day_type,
is_dubbeldag=is_dubbeldag,
)
def _make_break(
workspace_id: UUID,
start: date,
end: date,
name: str = "Summer",
target_weeks: int = 2,
) -> BreakPeriod:
return BreakPeriod(
id=uuid4(),
workspace_id=workspace_id,
name=name,
start_date=start,
end_date=end,
target_weeks=target_weeks,
)
def _make_ctx(
workspace_id: UUID | None = None,
planning_goal: str = "balanced",
children: list | None = None,
parents: list | None = None,
leave_by_parent: dict | None = None,
planned_days: list | None = None,
vacation_by_parent: dict | None = None,
break_periods: list | None = None,
) -> TipsContext:
ws = workspace_id or uuid4()
return TipsContext(
workspace_id=ws,
workspace_planning_goal=planning_goal,
children=children or [],
parents=parents or [],
leave_by_parent=leave_by_parent or {},
planned_days=planned_days or [],
vacation_by_parent=vacation_by_parent or {},
break_periods=break_periods or [],
)
def _next_weekday(target_weekday: int, offset_days: int = 1) -> date:
"""Find the next occurrence of weekday (0=Mon, 4=Fri) from today+offset_days."""
d = date.today() + timedelta(days=offset_days)
while d.weekday() != target_weekday:
d += timedelta(days=1)
return d
# ---------------------------------------------------------------------------
# _rule_balance_between_parents
# ---------------------------------------------------------------------------
class TestBalanceBetweenParents:
async def test_fires_when_gap_exceeds_30(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
leave1 = _make_leave(p1, child.id, used=120)
leave2 = _make_leave(p2, child.id, used=60) # gap = 60 > 30
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: leave2},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is not None
assert tip.id == "balance_between_parents"
assert tip.data["gap_days"] == 60
assert tip.data["leading_parent"] == "Alice"
assert tip.data["lagging_parent"] == "Bob"
async def test_no_fire_when_gap_within_threshold(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
leave1 = _make_leave(p1, child.id, used=120)
leave2 = _make_leave(p2, child.id, used=105) # gap = 15 ≤ 30
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: leave2},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p1, child.id, used=120)
ctx = _make_ctx(
workspace_id=ws,
parents=[p1],
leave_by_parent={p1.uuid: leave},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
async def test_no_fire_when_leave_data_missing(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
# Only one parent has leave data
child = _make_child(date.today() - relativedelta(years=1))
leave1 = _make_leave(p1, child.id, used=200)
ctx = _make_ctx(
workspace_id=ws,
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: None},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_preserve_for_age_4
# ---------------------------------------------------------------------------
class TestPreserveForAge4:
async def test_fires_when_days_will_expire(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=20, no planned → projected_left=220, expiring=220-96=124
leave = _make_leave(p, child.id, total=240, used=20)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is not None
assert tip.id == "preserve_for_age_4"
assert tip.severity == "warning"
assert tip.data["expiring_days"] == 124
async def test_no_fire_when_projected_days_within_savable_limit(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=150, no planned → projected_left=90 ≤ 96 → expiring=0
leave = _make_leave(p, child.id, total=240, used=150)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is None
async def test_no_fire_when_no_children(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
ctx = _make_ctx(workspace_id=ws, parents=[p])
tip = await _rule_preserve_for_age_4(ctx)
assert tip is None
async def test_planned_days_counted_before_fourth_birthday(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=20, planned 50 before age 4 → projected_left=170, expiring=74
leave = _make_leave(p, child.id, total=240, used=20)
fourth_bday = child.birth_date + relativedelta(years=4)
planned = [
_make_fp_day(p, child.id, ws, fourth_bday - timedelta(days=i + 1))
for i in range(50)
]
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=planned,
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is not None
assert tip.data["expiring_days"] == 74
# ---------------------------------------------------------------------------
# _rule_use_dubbeldagar_strategically
# ---------------------------------------------------------------------------
class TestUseDubbeldagarStrategically:
async def test_fires_when_same_day_not_marked(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
shared_date = date.today() + timedelta(days=10)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=False)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is not None
assert tip.id == "use_dubbeldagar_strategically"
assert tip.severity == "action"
assert tip.data["date"] == shared_date.isoformat()
async def test_no_fire_when_already_marked_dubbeldag(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
shared_date = date.today() + timedelta(days=10)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=True)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
d1 = _make_fp_day(p1, child.id, ws, date.today() + timedelta(days=5))
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1],
planned_days=[d1],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
async def test_no_fire_outside_dubbeldagar_window(self):
ws = uuid4()
# Child born 16 months ago → outside 15-month window
child = _make_child(date.today() - relativedelta(months=16))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
# Shared date is today, which is outside the window
shared_date = date.today() + timedelta(days=5)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=False)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_lagsta_niva_coverage
# ---------------------------------------------------------------------------
class TestLagstaNivaCoverage:
async def test_fires_when_sjukpenning_low_and_lagsta_available(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=10, lagsta_left=30)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is not None
assert tip.id == "lagsta_niva_coverage"
assert tip.data["sjukpenning_left"] == 10
assert tip.data["lagsta_left"] == 30
async def test_no_fire_when_sjukpenning_ample(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=100, lagsta_left=30)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is None
async def test_no_fire_when_lagsta_exhausted(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=5, lagsta_left=0)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_stretch_time_at_home
# ---------------------------------------------------------------------------
class TestStretchTimeAtHome:
async def test_fires_when_high_intensity_and_low_projection(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# 28 FP days in next 4 weeks → intensity = 7/week
today = date.today()
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i + 1))
for i in range(28)
]
# total=240, used=185, planned_fp_before_b4=28 → projected=27 < 60
leave = _make_leave(p, child.id, total=240, used=185)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is not None
assert tip.id == "stretch_time_at_home"
assert tip.data["weekly_intensity"] == 7.0
async def test_no_fire_when_low_intensity(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
today = date.today()
# 14 FP days in next 4 weeks → intensity = 3.5/week < 7
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i * 2 + 1))
for i in range(14)
]
leave = _make_leave(p, child.id, total=240, used=185)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
async def test_no_fire_when_high_intensity_but_ample_days_left(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
today = date.today()
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i + 1))
for i in range(28)
]
# total=240, used=50 → projected=240-50-28=162 ≥ 60 → no fire
leave = _make_leave(p, child.id, total=240, used=50)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
async def test_no_fire_when_no_children(self):
ctx = _make_ctx()
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_wrap_weekend_with_vacation
# ---------------------------------------------------------------------------
class TestWrapWeekendWithVacation:
async def test_fires_for_monthly_parent_with_fp_fri_and_mon(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
d_fri = _make_fp_day(p, child.id, ws, friday)
d_mon = _make_fp_day(p, child.id, ws, monday)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[d_fri, d_mon],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is not None
assert tip.id == "wrap_weekend_with_vacation"
assert tip.data["friday"] == friday.isoformat()
assert tip.data["monday"] == monday.isoformat()
async def test_no_fire_for_hourly_parent(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="hourly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[
_make_fp_day(p, child.id, ws, friday),
_make_fp_day(p, child.id, ws, monday),
],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
async def test_no_fire_when_goal_is_not_max_money(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_time", # not max_money
children=[child],
parents=[p],
planned_days=[
_make_fp_day(p, child.id, ws, friday),
_make_fp_day(p, child.id, ws, monday),
],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
async def test_no_fire_when_only_friday_planned(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, friday)],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_wrap_red_days_with_vacation
# ---------------------------------------------------------------------------
class TestWrapRedDaysWithVacation:
async def test_fires_when_fp_adjacent_to_cluster(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
today = date.today()
look_ahead = today + timedelta(weeks=12)
clusters = red_day_clusters_in_range(today, look_ahead)
assert clusters, "No holiday clusters found — test precondition failed"
cluster_start, _ = clusters[0]
fp_date = cluster_start - timedelta(days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, fp_date)],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is not None
assert tip.id == "wrap_red_days_with_vacation"
assert tip.data["cluster_start"] == cluster_start.isoformat()
async def test_no_fire_when_goal_is_not_max_money(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
ctx = _make_ctx(
workspace_id=ws,
planning_goal="balanced",
children=[child],
parents=[p],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is None
async def test_no_fire_for_hourly_parent(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="hourly")
today = date.today()
look_ahead = today + timedelta(weeks=12)
clusters = red_day_clusters_in_range(today, look_ahead)
if not clusters:
pytest.skip("No holiday clusters in window")
cluster_start, _ = clusters[0]
fp_date = cluster_start - timedelta(days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, fp_date)],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_long_break_parent_swap
# ---------------------------------------------------------------------------
class TestLongBreakParentSwap:
async def test_fires_when_parent_uncovered_during_break(self):
ws = uuid4()
today = date.today()
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
# Break period 5 working days from now (Mon–Fri next week)
break_start = _next_weekday(0, offset_days=7) # next Monday
break_end = break_start + timedelta(days=4) # Friday
bp = _make_break(ws, break_start, break_end)
# Bob has full coverage during break; Alice has none
bob_days = [
_make_fp_day(p2, uuid4(), ws, break_start + timedelta(days=i))
for i in range(5)
if (break_start + timedelta(days=i)).weekday() < 5
]
ctx = _make_ctx(
workspace_id=ws,
children=[_make_child(today - relativedelta(years=1))],
parents=[p1, p2],
planned_days=bob_days,
break_periods=[bp],
)
tip = await _rule_long_break_parent_swap(ctx)
assert tip is not None
assert tip.id == "long_break_parent_swap"
assert tip.data["break_name"] == "Summer"
assert tip.data["parent_name"] == "Alice"
assert tip.data["other_parent_name"] == "Bob"
async def test_no_fire_when_both_parents_covered(self):
ws = uuid4()
today = date.today()
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
break_start = _next_weekday(0, offset_days=7)
break_end = break_start + timedelta(days=4)
bp = _make_break(ws, break_start, break_end)
# Both parents have ≥50% coverage
all_days = []
for p in [p1, p2]:
for i in range(5):
d = break_start + timedelta(days=i)
if d.weekday() < 5:
all_days.append(_make_fp_day(p, uuid4(), ws, d))
ctx = _make_ctx(
workspace_id=ws,
children=[_make_child(today - relativedelta(years=1))],
parents=[p1, p2],
planned_days=all_days,
break_periods=[bp],
)
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
break_start = _next_weekday(0, offset_days=7)
bp = _make_break(ws, break_start, break_start + timedelta(days=4))
ctx = _make_ctx(workspace_id=ws, parents=[p], break_periods=[bp])
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
async def test_no_fire_for_past_break(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
past_start = date.today() - timedelta(days=30)
past_end = date.today() - timedelta(days=23)
bp = _make_break(ws, past_start, past_end)
ctx = _make_ctx(workspace_id=ws, parents=[p1, p2], break_periods=[bp])
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_reserve_for_preschool_planning_days
# ---------------------------------------------------------------------------
class TestReserveForPreschoolPlanningDays:
async def test_fires_when_preschool_active_and_days_low(self):
ws = uuid4()
# Preschool started 6 months ago
preschool_start = date.today() - relativedelta(months=6)
child = _make_child(
date.today() - relativedelta(years=3),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
# days_left = 5 < buffer_needed * 2 = 12
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is not None
assert tip.id == "reserve_for_preschool_planning_days"
assert tip.data["buffer_days"] == 6
assert tip.data["days_left"] == 5
async def test_no_fire_when_no_preschool_date(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3)) # no preschool_start_date
p = _make_parent(workspace_id=ws)
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None
async def test_no_fire_when_days_left_sufficient(self):
ws = uuid4()
preschool_start = date.today() - relativedelta(months=6)
child = _make_child(
date.today() - relativedelta(years=3),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
# days_left = 100 >> 12 → no fire
leave = _make_leave(p, child.id, total=240, used=140)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None
async def test_no_fire_when_preschool_not_yet_started(self):
ws = uuid4()
preschool_start = date.today() + relativedelta(months=3) # future start
child = _make_child(
date.today() - relativedelta(years=2),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None