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:
@@ -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
|
||||
Reference in New Issue
Block a user