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() + const day = getByTestId('calendar-day') + expect(day).toBeInTheDocument() + expect(day.textContent).toContain('15') + }) + + it('renders as disabled when isCurrentMonth is false', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--disabled') + }) + + it('renders events when provided', () => { + const { getByTestId } = render( + + ) + const events = getByTestId('calendar-day-events') + expect(events).toBeInTheDocument() + expect(events.children).toHaveLength(2) + }) + + it('applies has-events class when events exist', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--has-events') + }) + + it('calls onClick when clicked', () => { + const onClick = vi.fn() + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + fireEvent.click(day) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Enter key', () => { + const onClick = vi.fn() + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + fireEvent.keyDown(day, { key: 'Enter' }) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Space key', () => { + const onClick = vi.fn() + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + fireEvent.keyDown(day, { key: ' ' }) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('provides accessibility label with event count', () => { + const { getByTestId } = render( + + ) + const day = getByTestId('calendar-day') + expect(day).toHaveAttribute('aria-label', '2026-06-15 with 2 event(s)') + }) + + it('renders without events by default', () => { + const { queryByTestId } = render() + const events = queryByTestId('calendar-day-events') + expect(events).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/common/CalendarDay.tsx b/frontend/src/components/common/CalendarDay.tsx new file mode 100644 index 0000000..bc2571e --- /dev/null +++ b/frontend/src/components/common/CalendarDay.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from 'react' + +export interface CalendarDayProps { + date: string + events?: { type: string; color: string; title?: string }[] + isCurrentMonth?: boolean + onClick?: () => void +} + +export function CalendarDay({ + date, + events = [], + isCurrentMonth = true, + onClick, +}: CalendarDayProps): ReactNode { + const dayNum = new Date(date + 'T00:00:00Z').getUTCDate() + + return ( +
0 ? ` with ${events.length} event(s)` : ''}`} + className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onClick?.() + } + }} + > +
{dayNum}
+ {events.length > 0 && ( +
+ {events.map((event, idx) => ( +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/common/CalendarEvent.test.tsx b/frontend/src/components/common/CalendarEvent.test.tsx new file mode 100644 index 0000000..56c295b --- /dev/null +++ b/frontend/src/components/common/CalendarEvent.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { CalendarEvent } from './CalendarEvent' + +describe('CalendarEvent', () => { + it('renders event with leave type label', () => { + const { getByTestId } = render( + + ) + const event = getByTestId('calendar-event') + expect(event).toBeInTheDocument() + expect(event.textContent).toContain('vacation') + }) + + it('applies color style', () => { + const { getByTestId } = render( + + ) + const event = getByTestId('calendar-event') + expect(event).toHaveStyle({ backgroundColor: '#FF5733' }) + }) + + it('displays custom title on hover', () => { + const { getByTitle } = render( + + ) + expect(getByTitle('Summer vacation')).toBeInTheDocument() + }) + + it('renders dismiss button when onDismiss provided', () => { + const { getByTestId } = render( + {}} /> + ) + const button = getByTestId('dismiss-button') + expect(button).toBeInTheDocument() + expect(button.textContent).toBe('×') + }) + + it('does not render dismiss button when onDismiss not provided', () => { + const { queryByTestId } = render( + + ) + expect(queryByTestId('dismiss-button')).not.toBeInTheDocument() + }) + + it('calls onDismiss when dismiss button clicked', () => { + const onDismiss = vi.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('dismiss-button') + fireEvent.click(button) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('stops propagation on dismiss button click', () => { + const onDismiss = vi.fn() + const { getByTestId } = render( + + ) + const button = getByTestId('dismiss-button') + const event = new MouseEvent('click', { bubbles: true }) + const stopPropagation = vi.spyOn(event, 'stopPropagation') + fireEvent(button, event) + expect(stopPropagation).toHaveBeenCalled() + }) + + it('provides accessibility label', () => { + const { getByTestId } = render( + {}} /> + ) + const button = getByTestId('dismiss-button') + expect(button).toHaveAttribute('aria-label', 'Dismiss vacation') + }) +}) diff --git a/frontend/src/components/common/CalendarEvent.tsx b/frontend/src/components/common/CalendarEvent.tsx new file mode 100644 index 0000000..fadb1c9 --- /dev/null +++ b/frontend/src/components/common/CalendarEvent.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from 'react' + +export interface CalendarEventProps { + leaveType: string + color: string + title?: string + onDismiss?: () => void +} + +export function CalendarEvent({ + leaveType, + color, + title, + onDismiss, +}: CalendarEventProps): ReactNode { + return ( +
+ + {leaveType} + + {onDismiss && ( + + )} +
+ ) +} diff --git a/frontend/src/components/dashboard/DaysBreakdown.test.tsx b/frontend/src/components/dashboard/DaysBreakdown.test.tsx new file mode 100644 index 0000000..dc71f70 --- /dev/null +++ b/frontend/src/components/dashboard/DaysBreakdown.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { DaysBreakdown } from './DaysBreakdown' + +describe('DaysBreakdown', () => { + it('renders empty state when no breakdown provided', () => { + const { getByTestId } = render() + expect(getByTestId('days-breakdown').textContent).toContain('No days recorded yet') + }) + + it('renders table with breakdown data', () => { + const breakdown = [ + { type: 'vacation', count: 10 }, + { type: 'sick leave', count: 3 }, + { type: 'parental', count: 50 }, + ] + const { getByTestId } = render() + const table = getByTestId('days-breakdown') + expect(table.querySelector('table')).toBeInTheDocument() + expect(table.textContent).toContain('vacation') + expect(table.textContent).toContain('10') + }) + + it('renders correct number of rows', () => { + const breakdown = [ + { type: 'vacation', count: 10 }, + { type: 'sick leave', count: 3 }, + { type: 'parental', count: 50 }, + ] + const { getByTestId } = render() + expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument() + expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument() + expect(getByTestId('breakdown-row-parental')).toBeInTheDocument() + }) + + it('displays correct values in table', () => { + const breakdown = [{ type: 'vacation', count: 25 }] + const { getByTestId } = render() + const row = getByTestId('breakdown-row-vacation') + expect(row.textContent).toContain('vacation') + expect(row.textContent).toContain('25') + }) + + it('renders table headers', () => { + const breakdown = [{ type: 'vacation', count: 10 }] + const { getByTestId } = render() + const table = getByTestId('days-breakdown').querySelector('table') + expect(table?.querySelector('thead')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/dashboard/DaysBreakdown.tsx b/frontend/src/components/dashboard/DaysBreakdown.tsx new file mode 100644 index 0000000..7e126cc --- /dev/null +++ b/frontend/src/components/dashboard/DaysBreakdown.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react' + +export interface DaysBreakdownProps { + breakdown: Array<{ type: string; count: number }> +} + +export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode { + if (!breakdown || breakdown.length === 0) { + return ( +
+

No days recorded yet

+
+ ) + } + + return ( +
+ + + + + + + + + {breakdown.map((item, idx) => ( + + + + + ))} + +
Leave TypeDays
{item.type}{item.count}
+
+ ) +} diff --git a/frontend/src/components/dashboard/ProgressChart.test.tsx b/frontend/src/components/dashboard/ProgressChart.test.tsx new file mode 100644 index 0000000..522df28 --- /dev/null +++ b/frontend/src/components/dashboard/ProgressChart.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ProgressChart } from './ProgressChart' + +describe('ProgressChart', () => { + it('renders progress chart', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('progress-chart')).toBeInTheDocument() + }) + + it('calculates percentage correctly', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('percentage').textContent).toBe('25%') + }) + + it('displays weeks correctly', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('current-weeks').textContent).toBe('10 / 50 weeks') + }) + + it('sets fill width based on percentage', () => { + const { getByTestId } = render( + + ) + const fill = getByTestId('progress-fill') + expect(fill).toHaveStyle({ width: '50%' }) + }) + + it('caps percentage at 100%', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('percentage').textContent).toBe('100%') + expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' }) + }) + + it('shows 0% when no progress', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('percentage').textContent).toBe('0%') + }) + + it('displays child age when provided', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('progress-chart').textContent).toContain('Age: 2') + }) + + it('handles zero target weeks', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('percentage').textContent).toBe('0%') + }) +}) diff --git a/frontend/src/components/dashboard/ProgressChart.tsx b/frontend/src/components/dashboard/ProgressChart.tsx new file mode 100644 index 0000000..ddc66cf --- /dev/null +++ b/frontend/src/components/dashboard/ProgressChart.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from 'react' + +export interface ProgressChartProps { + currentWeeks: number + targetWeeks: number + childAge?: number +} + +export function ProgressChart({ + currentWeeks, + targetWeeks, + childAge, +}: ProgressChartProps): ReactNode { + const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0 + + return ( +
+
+

Parental Leave Progress

+ {childAge !== undefined && Age: {childAge}} +
+
+
+
+
+ + {currentWeeks} / {targetWeeks} weeks + + + {Math.round(percentage)}% + +
+
+ ) +} diff --git a/frontend/src/components/inputs/BreakPeriodsInput.test.tsx b/frontend/src/components/inputs/BreakPeriodsInput.test.tsx new file mode 100644 index 0000000..050ddf0 --- /dev/null +++ b/frontend/src/components/inputs/BreakPeriodsInput.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { BreakPeriodsInput } from './BreakPeriodsInput' + +describe('BreakPeriodsInput', () => { + const mockPeriods = [ + { + id: '1', + name: 'Summer', + startDate: '2026-06-01', + endDate: '2026-08-31', + targetWeeks: 12, + }, + ] + + it('renders break periods list', () => { + const { getByTestId } = render( + {}} onRemove={() => {}} /> + ) + expect(getByTestId('period-1')).toBeInTheDocument() + expect(getByTestId('period-1').textContent).toContain('Summer') + }) + + it('shows empty state when no periods', () => { + const { getByTestId } = render( + {}} onRemove={() => {}} /> + ) + expect(getByTestId('empty-state')).toBeInTheDocument() + }) + + it('displays add button', () => { + const { getByTestId } = render( + {}} onRemove={() => {}} /> + ) + expect(getByTestId('add-button')).toBeInTheDocument() + }) + + it('shows form when add button clicked', () => { + const { getByTestId, queryByTestId } = render( + {}} onRemove={() => {}} /> + ) + expect(queryByTestId('add-form')).not.toBeInTheDocument() + fireEvent.click(getByTestId('add-button')) + expect(getByTestId('add-form')).toBeInTheDocument() + }) + + it('calls onAdd with form data', () => { + const onAdd = vi.fn() + const { getByTestId } = render( + {}} /> + ) + fireEvent.click(getByTestId('add-button')) + + fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } }) + fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } }) + fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } }) + fireEvent.change(getByTestId('period-weeks'), { target: { value: '4' } }) + + fireEvent.click(getByTestId('submit-button')) + + expect(onAdd).toHaveBeenCalledWith({ + name: 'Winter', + startDate: '2026-12-01', + endDate: '2026-12-31', + targetWeeks: 4, + }) + }) + + it('calls onRemove when remove button clicked', () => { + const onRemove = vi.fn() + const { getByTestId } = render( + {}} onRemove={onRemove} /> + ) + fireEvent.click(getByTestId('remove-1')) + expect(onRemove).toHaveBeenCalledWith('1') + }) + + it('closes form on cancel', () => { + const { getByTestId, queryByTestId } = render( + {}} onRemove={() => {}} /> + ) + fireEvent.click(getByTestId('add-button')) + expect(getByTestId('add-form')).toBeInTheDocument() + + fireEvent.click(getByTestId('cancel-button')) + expect(queryByTestId('add-form')).not.toBeInTheDocument() + }) + + it('disables submit when fields incomplete', () => { + const { getByTestId } = render( + {}} onRemove={() => {}} /> + ) + fireEvent.click(getByTestId('add-button')) + const submit = getByTestId('submit-button') as HTMLButtonElement + expect(submit.disabled).toBe(true) + + fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } }) + fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } }) + fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } }) + + expect(submit.disabled).toBe(false) + }) + + it('disables buttons when loading', () => { + const { getByTestId } = render( + {}} + onRemove={() => {}} + isLoading + /> + ) + expect((getByTestId('add-button') as HTMLButtonElement).disabled).toBe(true) + expect((getByTestId('remove-1') as HTMLButtonElement).disabled).toBe(true) + }) +}) diff --git a/frontend/src/components/inputs/BreakPeriodsInput.tsx b/frontend/src/components/inputs/BreakPeriodsInput.tsx new file mode 100644 index 0000000..b8a73b2 --- /dev/null +++ b/frontend/src/components/inputs/BreakPeriodsInput.tsx @@ -0,0 +1,153 @@ +import { ReactNode, useState } from 'react' + +export interface BreakPeriodsInputProps { + periods: Array<{ id: string; name: string; startDate: string; endDate: string; targetWeeks: number }> + onAdd: (period: Omit) => void + onRemove: (id: string) => void + isLoading?: boolean +} + +export function BreakPeriodsInput({ + periods, + onAdd, + onRemove, + isLoading = false, +}: BreakPeriodsInputProps): ReactNode { + const [formOpen, setFormOpen] = useState(false) + const [name, setName] = useState('') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [targetWeeks, setTargetWeeks] = useState('0') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (name && startDate && endDate && targetWeeks) { + onAdd({ name, startDate, endDate, targetWeeks: parseInt(targetWeeks, 10) }) + setName('') + setStartDate('') + setEndDate('') + setTargetWeeks('0') + setFormOpen(false) + } + } + + return ( +
+

Break Periods

+ + {periods.length === 0 ? ( +

+ No break periods defined yet +

+ ) : ( +
    + {periods.map((period) => ( +
  • +
    + {period.name} + + {period.startDate} to {period.endDate} ({period.targetWeeks} weeks) + +
    + +
  • + ))} +
+ )} + + {formOpen && ( +
+
+ + setName(e.target.value)} + placeholder="e.g., Summer" + data-testid="period-name" + /> +
+ +
+ + setStartDate(e.target.value)} + data-testid="period-start" + /> +
+ +
+ + setEndDate(e.target.value)} + data-testid="period-end" + /> +
+ +
+ + setTargetWeeks(e.target.value)} + data-testid="period-weeks" + /> +
+ +
+ + +
+
+ )} + + {!formOpen && ( + + )} +
+ ) +} diff --git a/frontend/src/components/inputs/ParentProfileInput.test.tsx b/frontend/src/components/inputs/ParentProfileInput.test.tsx new file mode 100644 index 0000000..6006b9b --- /dev/null +++ b/frontend/src/components/inputs/ParentProfileInput.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { ParentProfileInput } from './ParentProfileInput' + +describe('ParentProfileInput', () => { + it('renders salary type and employment status selects', () => { + const { getByTestId } = render( + {}} /> + ) + expect(getByTestId('salary-type')).toBeInTheDocument() + expect(getByTestId('employment-status')).toBeInTheDocument() + }) + + it('displays current salary type', () => { + const { getByTestId } = render( + {}} + /> + ) + const select = getByTestId('salary-type') as HTMLSelectElement + expect(select.value).toBe('hourly') + }) + + it('displays current employment status', () => { + const { getByTestId } = render( + {}} + /> + ) + const select = getByTestId('employment-status') as HTMLSelectElement + expect(select.value).toBe('self_employed') + }) + + it('calls onChange for salary type change', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const select = getByTestId('salary-type') + fireEvent.change(select, { target: { value: 'monthly' } }) + expect(onChange).toHaveBeenCalledWith('salaryType', 'monthly') + }) + + it('calls onChange for employment status change', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const select = getByTestId('employment-status') + fireEvent.change(select, { target: { value: 'employed' } }) + expect(onChange).toHaveBeenCalledWith('employmentStatus', 'employed') + }) + + it('renders all salary type options', () => { + const { getByTestId } = render( + {}} /> + ) + const select = getByTestId('salary-type') + expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4) + }) + + it('renders all employment status options', () => { + const { getByTestId } = render( + {}} /> + ) + const select = getByTestId('employment-status') + expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4) + }) + + it('handles undefined parent properties', () => { + const { getByTestId } = render( + {}} /> + ) + const salaryType = getByTestId('salary-type') as HTMLSelectElement + const employmentStatus = getByTestId('employment-status') as HTMLSelectElement + expect(salaryType.value).toBe('') + expect(employmentStatus.value).toBe('') + }) +}) diff --git a/frontend/src/components/inputs/ParentProfileInput.tsx b/frontend/src/components/inputs/ParentProfileInput.tsx new file mode 100644 index 0000000..23f11e1 --- /dev/null +++ b/frontend/src/components/inputs/ParentProfileInput.tsx @@ -0,0 +1,54 @@ +import { ReactNode } from 'react' + +export interface ParentProfileInputProps { + parent: { + salaryType?: string + employmentStatus?: string + } + onChange: (field: string, val: string) => void +} + +export function ParentProfileInput({ + parent, + onChange, +}: ParentProfileInputProps): ReactNode { + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/inputs/PlanningGoalSelector.test.tsx b/frontend/src/components/inputs/PlanningGoalSelector.test.tsx new file mode 100644 index 0000000..0a37f95 --- /dev/null +++ b/frontend/src/components/inputs/PlanningGoalSelector.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { PlanningGoalSelector } from './PlanningGoalSelector' + +describe('PlanningGoalSelector', () => { + it('renders three planning goal options', () => { + const { getByTestId } = render( + {}} /> + ) + expect(getByTestId('goal-maximize-contiguous')).toBeInTheDocument() + expect(getByTestId('goal-minimize-stress')).toBeInTheDocument() + expect(getByTestId('goal-balance')).toBeInTheDocument() + }) + + it('renders as radio buttons', () => { + const { getByTestId } = render( + {}} /> + ) + const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement + const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + expect(radio1.type).toBe('radio') + expect(radio2.type).toBe('radio') + }) + + it('checks selected value', () => { + const { getByTestId } = render( + {}} /> + ) + const radio = getByTestId('goal-minimize-stress') as HTMLInputElement + expect(radio.checked).toBe(true) + }) + + it('calls onChange when selecting option', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const radio = getByTestId('goal-balance') + fireEvent.click(radio) + expect(onChange).toHaveBeenCalledWith('balance') + }) + + it('updates selection when value changes', () => { + const { getByTestId, rerender } = render( + {}} /> + ) + let radio = getByTestId('goal-maximize-contiguous') as HTMLInputElement + expect(radio.checked).toBe(true) + + rerender( {}} />) + radio = getByTestId('goal-balance') as HTMLInputElement + expect(radio.checked).toBe(true) + }) + + it('only one option can be selected at a time', () => { + const { getByTestId } = render( + {}} /> + ) + const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement + const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + expect(radio1.checked).toBe(true) + expect(radio2.checked).toBe(false) + }) + + it('has no selection when value is undefined', () => { + const { getByTestId } = render( + {}} /> + ) + const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement + const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement + expect(radio1.checked).toBe(false) + expect(radio2.checked).toBe(false) + }) +}) diff --git a/frontend/src/components/inputs/PlanningGoalSelector.tsx b/frontend/src/components/inputs/PlanningGoalSelector.tsx new file mode 100644 index 0000000..0635bb0 --- /dev/null +++ b/frontend/src/components/inputs/PlanningGoalSelector.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from 'react' + +export interface PlanningGoalSelectorProps { + value?: string + onChange: (goal: string) => void +} + +export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode { + return ( +
+
+ Planning Goal + +
+ onChange(e.target.value)} + data-testid="goal-maximize-contiguous" + /> + +
+ +
+ onChange(e.target.value)} + data-testid="goal-minimize-stress" + /> + +
+ +
+ onChange(e.target.value)} + data-testid="goal-balance" + /> + +
+
+
+ ) +} diff --git a/frontend/src/components/inputs/TipCard.test.tsx b/frontend/src/components/inputs/TipCard.test.tsx new file mode 100644 index 0000000..f9171f2 --- /dev/null +++ b/frontend/src/components/inputs/TipCard.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { TipCard } from './TipCard' + +describe('TipCard', () => { + const mockTip = { + id: 'tip1', + severity: 'info' as const, + messageKey: 'wrap_red_days', + data: {}, + } + + it('renders tip card with title', () => { + const { getByTestId } = render() + expect(getByTestId('tip-card')).toBeInTheDocument() + expect(getByTestId('tip-card').textContent).toContain('Wrap Red Days') + }) + + it('applies severity class', () => { + const { getByTestId } = render() + expect(getByTestId('tip-card')).toHaveClass('tip-card--info') + }) + + it('renders warning severity class', () => { + const warningTip = { ...mockTip, severity: 'warning' as const } + const { getByTestId } = render() + expect(getByTestId('tip-card')).toHaveClass('tip-card--warning') + }) + + it('renders action severity class', () => { + const actionTip = { ...mockTip, severity: 'action' as const } + const { getByTestId } = render() + expect(getByTestId('tip-card')).toHaveClass('tip-card--action') + }) + + it('renders dismiss button when onDismiss provided', () => { + const { getByTestId } = render( + {}} /> + ) + expect(getByTestId('dismiss-button')).toBeInTheDocument() + }) + + it('does not render dismiss button when onDismiss not provided', () => { + const { queryByTestId } = render() + expect(queryByTestId('dismiss-button')).not.toBeInTheDocument() + }) + + it('calls onDismiss with tip id when dismissed', () => { + const onDismiss = vi.fn() + const { getByTestId } = render( + + ) + fireEvent.click(getByTestId('dismiss-button')) + expect(onDismiss).toHaveBeenCalledWith('tip1') + }) + + it('displays message key as body text', () => { + const { getByTestId } = render() + expect(getByTestId('tip-card').textContent).toContain('wrap_red_days') + }) + + it('provides accessibility label', () => { + const { getByTestId } = render() + expect(getByTestId('tip-card')).toHaveAttribute('aria-label', 'Tip: Wrap Red Days') + }) + + it('maps unknown messageKey to itself', () => { + const unknownTip = { ...mockTip, messageKey: 'unknown_key' } + const { getByTestId } = render() + expect(getByTestId('tip-card').textContent).toContain('unknown_key') + }) +}) diff --git a/frontend/src/components/inputs/TipCard.tsx b/frontend/src/components/inputs/TipCard.tsx new file mode 100644 index 0000000..a6ba092 --- /dev/null +++ b/frontend/src/components/inputs/TipCard.tsx @@ -0,0 +1,51 @@ +import { ReactNode } from 'react' + +export interface TipCardProps { + tip: { + id: string + severity: 'info' | 'warning' | 'action' + messageKey: string + data: Record + } + onDismiss?: (tipId: string) => void +} + +export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode { + const titleMap: Record = { + wrap_red_days: 'Wrap Red Days', + prioritize_dubbeldagar: 'Use Dubbeldagar', + extend_contiguous: 'Extend Contiguous Leave', + } + + const title = titleMap[tip.messageKey] || tip.messageKey + + const severityClassMap = { + info: 'tip-card--info', + warning: 'tip-card--warning', + action: 'tip-card--action', + } + + return ( +
+
+

{title}

+ {onDismiss && ( + + )} +
+

{tip.messageKey}

+
+ ) +} diff --git a/frontend/src/components/inputs/VacationDaysInput.test.tsx b/frontend/src/components/inputs/VacationDaysInput.test.tsx new file mode 100644 index 0000000..2fe73a5 --- /dev/null +++ b/frontend/src/components/inputs/VacationDaysInput.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { VacationDaysInput } from './VacationDaysInput' + +describe('VacationDaysInput', () => { + it('renders input with label', () => { + const { getByTestId, getByText } = render( + {}} /> + ) + expect(getByTestId('vacation-days-input')).toBeInTheDocument() + expect(getByText('Annual vacation days')).toBeInTheDocument() + }) + + it('displays current value', () => { + const { getByTestId } = render( + {}} /> + ) + const input = getByTestId('vacation-days-field') as HTMLInputElement + expect(input.value).toBe('30') + }) + + it('calls onChange with new value', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const input = getByTestId('vacation-days-field') + fireEvent.change(input, { target: { value: '30' } }) + expect(onChange).toHaveBeenCalledWith(30) + }) + + it('enforces min constraint', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const input = getByTestId('vacation-days-field') + fireEvent.change(input, { target: { value: '2' } }) + expect(onChange).toHaveBeenCalledWith(5) // min enforced + }) + + it('enforces max constraint', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const input = getByTestId('vacation-days-field') + fireEvent.change(input, { target: { value: '100' } }) + expect(onChange).toHaveBeenCalledWith(50) // max enforced + }) + + it('displays default range', () => { + const { getByTestId } = render( + {}} /> + ) + expect(getByTestId('vacation-days-input').textContent).toContain('0 – 365') + }) + + it('displays custom range', () => { + const { getByTestId } = render( + {}} min={10} max={50} /> + ) + expect(getByTestId('vacation-days-input').textContent).toContain('10 – 50') + }) + + it('handles non-numeric input gracefully', () => { + const onChange = vi.fn() + const { getByTestId } = render( + + ) + const input = getByTestId('vacation-days-field') + fireEvent.change(input, { target: { value: 'abc' } }) + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/inputs/VacationDaysInput.tsx b/frontend/src/components/inputs/VacationDaysInput.tsx new file mode 100644 index 0000000..35c0633 --- /dev/null +++ b/frontend/src/components/inputs/VacationDaysInput.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from 'react' + +export interface VacationDaysInputProps { + label: string + value: number + onChange: (val: number) => void + min?: number + max?: number +} + +export function VacationDaysInput({ + label, + value, + onChange, + min = 0, + max = 365, +}: VacationDaysInputProps): ReactNode { + return ( +
+ + { + const num = parseInt(e.target.value, 10) + if (!isNaN(num)) { + onChange(Math.max(min, Math.min(num, max))) + } + }} + data-testid="vacation-days-field" + className="vacation-days-input__field" + /> +
+ + {min} – {max} + +
+
+ ) +}