{title}
+ {onDismiss && ( + + )} +{tip.messageKey}
+diff --git a/backend/tests/unit/test_services/test_tips_service.py b/backend/tests/unit/test_services/test_tips_service.py
new file mode 100644
index 0000000..ec33d0e
--- /dev/null
+++ b/backend/tests/unit/test_services/test_tips_service.py
@@ -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
diff --git a/frontend/src/components/common/CalendarDay.test.tsx b/frontend/src/components/common/CalendarDay.test.tsx
new file mode 100644
index 0000000..a536892
--- /dev/null
+++ b/frontend/src/components/common/CalendarDay.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, fireEvent } from '@testing-library/react'
+import { CalendarDay } from './CalendarDay'
+
+describe('CalendarDay', () => {
+ it('renders day number', () => {
+ const { getByTestId } = render(
No days recorded yet
+| Leave Type | +Days | +
|---|---|
| {item.type} | +{item.count} | +
+ No break periods defined yet +
+ ) : ( +{tip.messageKey}
+