"""Unit tests for schedule math: d_hat, overlaps, P0 lock, strong feedback.""" from app.schedule_math import ( d_hat, is_strong_feedback, overlaps, recompute_priors, validate_blocks, ) from app.schedule_reschedule import rules_backup_reschedule def test_d_hat_shrinkage() -> None: # n=3, mean=40, prior=30, m=3 → 0.5*40 + 0.5*30 = 35 assert abs(d_hat(40.0, 3, 30.0, m=3) - 35.0) < 1e-9 assert d_hat(None, 0, 45.0, m=3) == 45.0 def test_strong_feedback() -> None: assert is_strong_feedback( {"did": "done", "actual_min": 20, "quality": 4, "fun": 3} ) assert not is_strong_feedback({"did": "skipped", "actual_min": 20, "quality": 4, "fun": 3}) assert not is_strong_feedback({"did": "done", "actual_min": None, "quality": 4, "fun": 3}) def test_overlap_and_p0_lock() -> None: assert overlaps("09:00", "10:00", "09:30", "10:30") assert not overlaps("09:00", "10:00", "10:00", "11:00") blocks = [ { "id": "a", "start": "09:00", "end": "10:00", "planned_min": 60, "priority": "P0", "locked": True, } ] prev = [{"id": "a", "start": "09:00", "end": "10:00", "priority": "P0", "locked": True}] moved = [ { "id": "a", "start": "11:00", "end": "12:00", "planned_min": 60, "priority": "P0", "locked": True, } ] errors_moved, _ = validate_blocks(moved, previous_p0=prev, allow_p0_move=False) errors_ok, _ = validate_blocks(blocks, previous_p0=prev, allow_p0_move=False) assert errors_moved assert not errors_ok def test_all_grind_soft_warn() -> None: duty = [ { "id": "a", "start": "09:00", "end": "10:00", "planned_min": 60, "intent": "duty", "priority": "P1", } ] errors, warnings = validate_blocks( duty, must_include_explore_or_restore=True, capacity_hint=0.7, hard_explore=False, ) assert not errors assert any("all-grind" in w for w in warnings) errors_hard, _ = validate_blocks( duty, must_include_explore_or_restore=True, capacity_hint=0.7, hard_explore=True, ) assert any("all-grind" in e for e in errors_hard) def test_recompute_priors() -> None: blocks = { "b1": {"id": "b1", "kind": "earn_ship", "planned_min": 45}, } feedback = [ { "block_id": "b1", "did": "done", "actual_min": 50, "quality": 4, "fun": 2, "strong": True, "would_repeat": "yes", } ] priors = recompute_priors(feedback, blocks, shrink_k=3) assert priors["earn_ship"]["n"] == 1 assert priors["earn_ship"]["d_hat"] > 0 def test_rules_backup_keeps_p0() -> None: blocks = [ { "id": "p0", "date": "2026-07-19", "start": "08:00", "end": "08:30", "title": "Food", "kind": "food_out", "intent": "duty", "priority": "P0", "planned_min": 30, "status": "planned", "locked": True, "notes": "", "version_added": 1, "source": "user", }, { "id": "p2", "date": "2026-07-19", "start": "14:00", "end": "16:00", "title": "Flex", "kind": "other", "intent": "duty", "priority": "P2", "planned_min": 120, "status": "planned", "locked": False, "notes": "", "version_added": 1, "source": "user", }, ] out = rules_backup_reschedule( blocks, day="2026-07-19", risk_score=2.0, capacity_hint=0.4, ) p0 = next(b for b in out if b.id == "p0") assert p0.start == "08:00" and p0.end == "08:30"