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
|
||||
@@ -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(<CalendarDay date="2026-06-15" />)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toBeInTheDocument()
|
||||
expect(day.textContent).toContain('15')
|
||||
})
|
||||
|
||||
it('renders as disabled when isCurrentMonth is false', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" isCurrentMonth={false} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--disabled')
|
||||
})
|
||||
|
||||
it('renders events when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[
|
||||
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
|
||||
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[{ type: 'vacation', color: '#FF5733' }]}
|
||||
/>
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
expect(day).toHaveClass('calendar-day--has-events')
|
||||
})
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" onClick={onClick} />
|
||||
)
|
||||
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(
|
||||
<CalendarDay date="2026-06-15" onClick={onClick} />
|
||||
)
|
||||
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(
|
||||
<CalendarDay date="2026-06-15" onClick={onClick} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
fireEvent.keyDown(day, { key: ' ' })
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('provides accessibility label with event count', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay
|
||||
date="2026-06-15"
|
||||
events={[
|
||||
{ type: 'vacation', color: '#FF5733' },
|
||||
{ type: 'sick', color: '#FFD700' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
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(<CalendarDay date="2026-06-15" />)
|
||||
const events = queryByTestId('calendar-day-events')
|
||||
expect(events).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="calendar-day"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${date}${events.length > 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?.()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="calendar-day__number">{dayNum}</div>
|
||||
{events.length > 0 && (
|
||||
<div className="calendar-day__events" data-testid="calendar-day-events">
|
||||
{events.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="calendar-day__event"
|
||||
style={{ backgroundColor: event.color }}
|
||||
title={event.title ?? event.type}
|
||||
data-testid={`event-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event).toBeInTheDocument()
|
||||
expect(event.textContent).toContain('vacation')
|
||||
})
|
||||
|
||||
it('applies color style', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event).toHaveStyle({ backgroundColor: '#FF5733' })
|
||||
})
|
||||
|
||||
it('displays custom title on hover', () => {
|
||||
const { getByTitle } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" title="Summer vacation" />
|
||||
)
|
||||
expect(getByTitle('Summer vacation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders dismiss button when onDismiss provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
|
||||
)
|
||||
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(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" />
|
||||
)
|
||||
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDismiss when dismiss button clicked', () => {
|
||||
const onDismiss = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
|
||||
)
|
||||
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(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
|
||||
)
|
||||
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(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
|
||||
)
|
||||
const button = getByTestId('dismiss-button')
|
||||
expect(button).toHaveAttribute('aria-label', 'Dismiss vacation')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="calendar-event"
|
||||
className="calendar-event"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<span className="calendar-event__label" title={title ?? leaveType}>
|
||||
{leaveType}
|
||||
</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="calendar-event__dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}}
|
||||
aria-label={`Dismiss ${leaveType}`}
|
||||
data-testid="dismiss-button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<DaysBreakdown breakdown={[]} />)
|
||||
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(<DaysBreakdown breakdown={breakdown} />)
|
||||
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(<DaysBreakdown breakdown={breakdown} />)
|
||||
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(<DaysBreakdown breakdown={breakdown} />)
|
||||
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(<DaysBreakdown breakdown={breakdown} />)
|
||||
const table = getByTestId('days-breakdown').querySelector('table')
|
||||
expect(table?.querySelector('thead')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div data-testid="days-breakdown" className="days-breakdown">
|
||||
<p className="days-breakdown__empty">No days recorded yet</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="days-breakdown" className="days-breakdown">
|
||||
<table className="days-breakdown__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Leave Type</th>
|
||||
<th>Days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.map((item, idx) => (
|
||||
<tr key={idx} data-testid={`breakdown-row-${item.type}`}>
|
||||
<td>{item.type}</td>
|
||||
<td>{item.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('progress-chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calculates percentage correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={25} targetWeeks={100} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('25%')
|
||||
})
|
||||
|
||||
it('displays weeks correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('current-weeks').textContent).toBe('10 / 50 weeks')
|
||||
})
|
||||
|
||||
it('sets fill width based on percentage', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={50} targetWeeks={100} />
|
||||
)
|
||||
const fill = getByTestId('progress-fill')
|
||||
expect(fill).toHaveStyle({ width: '50%' })
|
||||
})
|
||||
|
||||
it('caps percentage at 100%', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={150} targetWeeks={100} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('100%')
|
||||
expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('shows 0% when no progress', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={0} targetWeeks={50} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('0%')
|
||||
})
|
||||
|
||||
it('displays child age when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={50} childAge={2} />
|
||||
)
|
||||
expect(getByTestId('progress-chart').textContent).toContain('Age: 2')
|
||||
})
|
||||
|
||||
it('handles zero target weeks', () => {
|
||||
const { getByTestId } = render(
|
||||
<ProgressChart currentWeeks={10} targetWeeks={0} />
|
||||
)
|
||||
expect(getByTestId('percentage').textContent).toBe('0%')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div data-testid="progress-chart" className="progress-chart">
|
||||
<div className="progress-chart__header">
|
||||
<h3>Parental Leave Progress</h3>
|
||||
{childAge !== undefined && <span className="progress-chart__age">Age: {childAge}</span>}
|
||||
</div>
|
||||
<div className="progress-chart__bar">
|
||||
<div
|
||||
className="progress-chart__fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
data-testid="progress-fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-chart__stats">
|
||||
<span data-testid="current-weeks">
|
||||
{currentWeeks} / {targetWeeks} weeks
|
||||
</span>
|
||||
<span data-testid="percentage" className="progress-chart__percentage">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={() => {}} />
|
||||
)
|
||||
expect(getByTestId('period-1')).toBeInTheDocument()
|
||||
expect(getByTestId('period-1').textContent).toContain('Summer')
|
||||
})
|
||||
|
||||
it('shows empty state when no periods', () => {
|
||||
const { getByTestId } = render(
|
||||
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
|
||||
)
|
||||
expect(getByTestId('empty-state')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays add button', () => {
|
||||
const { getByTestId } = render(
|
||||
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
|
||||
)
|
||||
expect(getByTestId('add-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows form when add button clicked', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<BreakPeriodsInput periods={[]} onAdd={() => {}} 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(
|
||||
<BreakPeriodsInput periods={[]} onAdd={onAdd} onRemove={() => {}} />
|
||||
)
|
||||
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(
|
||||
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={onRemove} />
|
||||
)
|
||||
fireEvent.click(getByTestId('remove-1'))
|
||||
expect(onRemove).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('closes form on cancel', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<BreakPeriodsInput periods={[]} onAdd={() => {}} 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(
|
||||
<BreakPeriodsInput periods={[]} onAdd={() => {}} 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(
|
||||
<BreakPeriodsInput
|
||||
periods={mockPeriods}
|
||||
onAdd={() => {}}
|
||||
onRemove={() => {}}
|
||||
isLoading
|
||||
/>
|
||||
)
|
||||
expect((getByTestId('add-button') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((getByTestId('remove-1') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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<any, 'id'>) => 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 (
|
||||
<div data-testid="break-periods-input" className="break-periods-input">
|
||||
<h3 className="break-periods-input__title">Break Periods</h3>
|
||||
|
||||
{periods.length === 0 ? (
|
||||
<p className="break-periods-input__empty" data-testid="empty-state">
|
||||
No break periods defined yet
|
||||
</p>
|
||||
) : (
|
||||
<ul className="break-periods-input__list">
|
||||
{periods.map((period) => (
|
||||
<li
|
||||
key={period.id}
|
||||
className="break-periods-input__item"
|
||||
data-testid={`period-${period.id}`}
|
||||
>
|
||||
<div className="break-periods-input__info">
|
||||
<strong>{period.name}</strong>
|
||||
<small>
|
||||
{period.startDate} to {period.endDate} ({period.targetWeeks} weeks)
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="break-periods-input__remove"
|
||||
onClick={() => onRemove(period.id)}
|
||||
disabled={isLoading}
|
||||
aria-label={`Remove ${period.name}`}
|
||||
data-testid={`remove-${period.id}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="break-periods-input__form"
|
||||
data-testid="add-form"
|
||||
>
|
||||
<div className="break-periods-input__field">
|
||||
<label htmlFor="period-name">Name</label>
|
||||
<input
|
||||
id="period-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Summer"
|
||||
data-testid="period-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="break-periods-input__field">
|
||||
<label htmlFor="period-start">Start Date</label>
|
||||
<input
|
||||
id="period-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
data-testid="period-start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="break-periods-input__field">
|
||||
<label htmlFor="period-end">End Date</label>
|
||||
<input
|
||||
id="period-end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
data-testid="period-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="break-periods-input__field">
|
||||
<label htmlFor="period-weeks">Target Weeks</label>
|
||||
<input
|
||||
id="period-weeks"
|
||||
type="number"
|
||||
min="0"
|
||||
value={targetWeeks}
|
||||
onChange={(e) => setTargetWeeks(e.target.value)}
|
||||
data-testid="period-weeks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="break-periods-input__actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !name || !startDate || !endDate}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
Add Period
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormOpen(false)}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!formOpen && (
|
||||
<button
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="break-periods-input__add"
|
||||
disabled={isLoading}
|
||||
data-testid="add-button"
|
||||
>
|
||||
+ Add Break Period
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<ParentProfileInput parent={{}} onChange={() => {}} />
|
||||
)
|
||||
expect(getByTestId('salary-type')).toBeInTheDocument()
|
||||
expect(getByTestId('employment-status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays current salary type', () => {
|
||||
const { getByTestId } = render(
|
||||
<ParentProfileInput
|
||||
parent={{ salaryType: 'hourly' }}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)
|
||||
const select = getByTestId('salary-type') as HTMLSelectElement
|
||||
expect(select.value).toBe('hourly')
|
||||
})
|
||||
|
||||
it('displays current employment status', () => {
|
||||
const { getByTestId } = render(
|
||||
<ParentProfileInput
|
||||
parent={{ employmentStatus: 'self_employed' }}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<ParentProfileInput parent={{}} onChange={onChange} />
|
||||
)
|
||||
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(
|
||||
<ParentProfileInput parent={{}} onChange={onChange} />
|
||||
)
|
||||
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(
|
||||
<ParentProfileInput parent={{}} onChange={() => {}} />
|
||||
)
|
||||
const select = getByTestId('salary-type')
|
||||
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('renders all employment status options', () => {
|
||||
const { getByTestId } = render(
|
||||
<ParentProfileInput parent={{}} onChange={() => {}} />
|
||||
)
|
||||
const select = getByTestId('employment-status')
|
||||
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('handles undefined parent properties', () => {
|
||||
const { getByTestId } = render(
|
||||
<ParentProfileInput parent={{}} onChange={() => {}} />
|
||||
)
|
||||
const salaryType = getByTestId('salary-type') as HTMLSelectElement
|
||||
const employmentStatus = getByTestId('employment-status') as HTMLSelectElement
|
||||
expect(salaryType.value).toBe('')
|
||||
expect(employmentStatus.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div data-testid="parent-profile-input" className="parent-profile-input">
|
||||
<div className="parent-profile-input__field">
|
||||
<label htmlFor="salary-type" className="parent-profile-input__label">
|
||||
Salary Type
|
||||
</label>
|
||||
<select
|
||||
id="salary-type"
|
||||
value={parent.salaryType || ''}
|
||||
onChange={(e) => onChange('salaryType', e.target.value)}
|
||||
data-testid="salary-type"
|
||||
className="parent-profile-input__select"
|
||||
>
|
||||
<option value="">Choose salary type</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="self_employed">Self Employed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="parent-profile-input__field">
|
||||
<label htmlFor="employment-status" className="parent-profile-input__label">
|
||||
Employment Status
|
||||
</label>
|
||||
<select
|
||||
id="employment-status"
|
||||
value={parent.employmentStatus || ''}
|
||||
onChange={(e) => onChange('employmentStatus', e.target.value)}
|
||||
data-testid="employment-status"
|
||||
className="parent-profile-input__select"
|
||||
>
|
||||
<option value="">Choose employment status</option>
|
||||
<option value="employed">Employed</option>
|
||||
<option value="self_employed">Self Employed</option>
|
||||
<option value="parental_leave">On Parental Leave</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<PlanningGoalSelector onChange={() => {}} />
|
||||
)
|
||||
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(
|
||||
<PlanningGoalSelector onChange={() => {}} />
|
||||
)
|
||||
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(
|
||||
<PlanningGoalSelector value="minimize_stress" onChange={() => {}} />
|
||||
)
|
||||
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(
|
||||
<PlanningGoalSelector onChange={onChange} />
|
||||
)
|
||||
const radio = getByTestId('goal-balance')
|
||||
fireEvent.click(radio)
|
||||
expect(onChange).toHaveBeenCalledWith('balance')
|
||||
})
|
||||
|
||||
it('updates selection when value changes', () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
|
||||
)
|
||||
let radio = getByTestId('goal-maximize-contiguous') as HTMLInputElement
|
||||
expect(radio.checked).toBe(true)
|
||||
|
||||
rerender(<PlanningGoalSelector value="balance" onChange={() => {}} />)
|
||||
radio = getByTestId('goal-balance') as HTMLInputElement
|
||||
expect(radio.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('only one option can be selected at a time', () => {
|
||||
const { getByTestId } = render(
|
||||
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
|
||||
)
|
||||
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(
|
||||
<PlanningGoalSelector onChange={() => {}} />
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div data-testid="planning-goal-selector" className="planning-goal-selector">
|
||||
<fieldset className="planning-goal-selector__fieldset">
|
||||
<legend className="planning-goal-selector__legend">Planning Goal</legend>
|
||||
|
||||
<div className="planning-goal-selector__option">
|
||||
<input
|
||||
type="radio"
|
||||
id="goal-maximize-contiguous"
|
||||
name="planning-goal"
|
||||
value="maximize_contiguous"
|
||||
checked={value === 'maximize_contiguous'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="goal-maximize-contiguous"
|
||||
/>
|
||||
<label htmlFor="goal-maximize-contiguous">Maximize Contiguous</label>
|
||||
</div>
|
||||
|
||||
<div className="planning-goal-selector__option">
|
||||
<input
|
||||
type="radio"
|
||||
id="goal-minimize-stress"
|
||||
name="planning-goal"
|
||||
value="minimize_stress"
|
||||
checked={value === 'minimize_stress'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="goal-minimize-stress"
|
||||
/>
|
||||
<label htmlFor="goal-minimize-stress">Minimize Stress</label>
|
||||
</div>
|
||||
|
||||
<div className="planning-goal-selector__option">
|
||||
<input
|
||||
type="radio"
|
||||
id="goal-balance"
|
||||
name="planning-goal"
|
||||
value="balance"
|
||||
checked={value === 'balance'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="goal-balance"
|
||||
/>
|
||||
<label htmlFor="goal-balance">Balance</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<TipCard tip={mockTip} />)
|
||||
expect(getByTestId('tip-card')).toBeInTheDocument()
|
||||
expect(getByTestId('tip-card').textContent).toContain('Wrap Red Days')
|
||||
})
|
||||
|
||||
it('applies severity class', () => {
|
||||
const { getByTestId } = render(<TipCard tip={mockTip} />)
|
||||
expect(getByTestId('tip-card')).toHaveClass('tip-card--info')
|
||||
})
|
||||
|
||||
it('renders warning severity class', () => {
|
||||
const warningTip = { ...mockTip, severity: 'warning' as const }
|
||||
const { getByTestId } = render(<TipCard tip={warningTip} />)
|
||||
expect(getByTestId('tip-card')).toHaveClass('tip-card--warning')
|
||||
})
|
||||
|
||||
it('renders action severity class', () => {
|
||||
const actionTip = { ...mockTip, severity: 'action' as const }
|
||||
const { getByTestId } = render(<TipCard tip={actionTip} />)
|
||||
expect(getByTestId('tip-card')).toHaveClass('tip-card--action')
|
||||
})
|
||||
|
||||
it('renders dismiss button when onDismiss provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<TipCard tip={mockTip} onDismiss={() => {}} />
|
||||
)
|
||||
expect(getByTestId('dismiss-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render dismiss button when onDismiss not provided', () => {
|
||||
const { queryByTestId } = render(<TipCard tip={mockTip} />)
|
||||
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDismiss with tip id when dismissed', () => {
|
||||
const onDismiss = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<TipCard tip={mockTip} onDismiss={onDismiss} />
|
||||
)
|
||||
fireEvent.click(getByTestId('dismiss-button'))
|
||||
expect(onDismiss).toHaveBeenCalledWith('tip1')
|
||||
})
|
||||
|
||||
it('displays message key as body text', () => {
|
||||
const { getByTestId } = render(<TipCard tip={mockTip} />)
|
||||
expect(getByTestId('tip-card').textContent).toContain('wrap_red_days')
|
||||
})
|
||||
|
||||
it('provides accessibility label', () => {
|
||||
const { getByTestId } = render(<TipCard tip={mockTip} />)
|
||||
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(<TipCard tip={unknownTip} />)
|
||||
expect(getByTestId('tip-card').textContent).toContain('unknown_key')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface TipCardProps {
|
||||
tip: {
|
||||
id: string
|
||||
severity: 'info' | 'warning' | 'action'
|
||||
messageKey: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
onDismiss?: (tipId: string) => void
|
||||
}
|
||||
|
||||
export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode {
|
||||
const titleMap: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
data-testid="tip-card"
|
||||
className={`tip-card ${severityClassMap[tip.severity]}`}
|
||||
role="article"
|
||||
aria-label={`Tip: ${title}`}
|
||||
>
|
||||
<div className="tip-card__header">
|
||||
<h4 className="tip-card__title">{title}</h4>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="tip-card__dismiss"
|
||||
onClick={() => onDismiss(tip.id)}
|
||||
aria-label={`Dismiss: ${title}`}
|
||||
data-testid="dismiss-button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="tip-card__body">{tip.messageKey}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<VacationDaysInput label="Annual vacation days" value={25} onChange={() => {}} />
|
||||
)
|
||||
expect(getByTestId('vacation-days-input')).toBeInTheDocument()
|
||||
expect(getByText('Annual vacation days')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays current value', () => {
|
||||
const { getByTestId } = render(
|
||||
<VacationDaysInput label="Vacation" value={30} onChange={() => {}} />
|
||||
)
|
||||
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(
|
||||
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
|
||||
)
|
||||
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(
|
||||
<VacationDaysInput
|
||||
label="Vacation"
|
||||
value={10}
|
||||
onChange={onChange}
|
||||
min={5}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<VacationDaysInput
|
||||
label="Vacation"
|
||||
value={25}
|
||||
onChange={onChange}
|
||||
max={50}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} />
|
||||
)
|
||||
expect(getByTestId('vacation-days-input').textContent).toContain('0 – 365')
|
||||
})
|
||||
|
||||
it('displays custom range', () => {
|
||||
const { getByTestId } = render(
|
||||
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} 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(
|
||||
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
|
||||
)
|
||||
const input = getByTestId('vacation-days-field')
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div data-testid="vacation-days-input" className="vacation-days-input">
|
||||
<label htmlFor="vacation-days-field" className="vacation-days-input__label">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id="vacation-days-field"
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<div className="vacation-days-input__range">
|
||||
<small>
|
||||
{min} – {max}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user