"""调制因子派生测试(辰正 §5.2 / §7.2 / §7.3)。""" from datetime import UTC, datetime import pytest from app.modules.time_sense.duration import OfflineDuration from app.modules.time_sense.lunar import Festival from app.modules.time_sense.modulation import ( ModulationInput, compute_modulation, ) from app.modules.time_sense.snapshot import ( LocationContext, SnapshotInput, TemporalSnapshot, compute_snapshot, ) def _festival_snapshot(festival_name: str) -> TemporalSnapshot: """构造一个命中指定节日、白天的快照(用真实 compute_snapshot)。""" # 2026-02-17 = 春节正月初一;其它节日按需选日期 festival_dates = { "春节": "2026-02-17", "中秋节": "2026-09-25", # 中秋八十五 "国庆节": "2026-10-01", } iso = festival_dates.get(festival_name, "2026-07-04") y, m, d = (int(x) for x in iso.split("-")) inp = SnapshotInput( now=datetime(y, m, d, 4, 0, tzinfo=UTC), # UTC 04:00 = 上海 12:00(上午/中午) timezone="Asia/Shanghai", location=LocationContext(timezone="Asia/Shanghai", city="上海"), anniversaries=[], acquaintance_anchor=datetime(y, m, d, tzinfo=UTC), last_active_at=datetime(y, m, d, 3, 0, tzinfo=UTC), ) return compute_snapshot(inp) def _snapshot(**overrides) -> TemporalSnapshot: """构造一个无节日的基线快照(上海上午、周末)。""" inp = SnapshotInput( now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 timezone="Asia/Shanghai", location=LocationContext(timezone="Asia/Shanghai", city="上海"), anniversaries=[], acquaintance_anchor=datetime(2025, 9, 19, tzinfo=UTC), last_active_at=datetime(2026, 7, 4, 3, 0, tzinfo=UTC), ) snap = compute_snapshot(inp) # 允许测试覆盖关键字段 mutated = snap.model_dump() mutated.update(overrides) return TemporalSnapshot(**mutated) def test_festival_joy_no_festival_low(): factors = compute_modulation(_snapshot(), ModulationInput()) assert factors.festival_joy == pytest.approx(0.1, abs=0.01) def test_festival_joy_spring_festival_high(): snap = _festival_snapshot("春节") factors = compute_modulation(snap, ModulationInput()) assert factors.festival_joy == pytest.approx(0.9, abs=0.01) def test_festival_joy_multi_takes_max(): # 多节日同时命中:取最大权重(Finding 3:覆盖 _festival_joy 的 max 聚合路径) snap = _snapshot( festivals=[ Festival(name="春节", calendar="lunar"), Festival(name="元旦", calendar="solar"), ] ) factors = compute_modulation(snap, ModulationInput()) assert factors.festival_joy == pytest.approx(0.9, abs=0.01) def test_late_night_fragility_deep_night_with_turns(): # 凌晨 + 连续轮数 → 脆弱度上升 snap = _snapshot(day_period="凌晨", local_hour=2) f0 = compute_modulation(snap, ModulationInput(conversation_turns_today=0)) f3 = compute_modulation(snap, ModulationInput(conversation_turns_today=3)) assert f0.late_night_fragility == pytest.approx(0.7, abs=0.01) assert f3.late_night_fragility > f0.late_night_fragility assert f3.late_night_fragility <= 1.0 def test_late_night_fragility_daytime_low(): factors = compute_modulation(_snapshot(), ModulationInput()) # 上午 assert factors.late_night_fragility == pytest.approx(0.1, abs=0.01) def test_reunion_intensity_passes_through(): snap = _snapshot( offline=OfflineDuration(minutes=10000, band="reunion_impact", reunion_intensity=0.85) ) factors = compute_modulation(snap, ModulationInput()) assert factors.reunion_intensity == pytest.approx(0.85, abs=0.01) def test_weekend_relax_weekend_with_festival(): snap = _festival_snapshot("国庆节") # 2026-10-01 周四 → 不是周末,手工覆盖 snap = snap.model_copy(update={"is_weekend": True, "festivals": snap.festivals}) factors = compute_modulation(snap, ModulationInput()) assert factors.weekend_relax == pytest.approx(0.9, abs=0.01) def test_weekend_relax_workday(): snap = _snapshot(is_weekend=False) factors = compute_modulation(snap, ModulationInput()) assert factors.weekend_relax == pytest.approx(0.2, abs=0.01) def test_season_resonance_in_range(): factors = compute_modulation(_snapshot(), ModulationInput()) # 夏 assert 0.0 <= factors.season_resonance <= 1.0 assert factors.season_resonance == pytest.approx(0.6, abs=0.01) def test_same_period_resonance_morning_low(): factors = compute_modulation(_snapshot(), ModulationInput()) # 上午 assert factors.same_period_resonance == pytest.approx(0.2, abs=0.01) @pytest.mark.parametrize( "signals, snapshot_overrides, expected_kind", [ # 深夜 + 时差 ≥ 3h → jetlag ( {"user_birthday_today": False}, {"day_period": "凌晨", "local_hour": 2, "home_tz_diff_minutes": 200}, "jetlag", ), # 用户生日 → birthday(优先级高) ({"user_birthday_today": True}, {}, "birthday"), # 清晨 4-5 点首条消息 → early_morning ( {"is_first_message_today": True}, {"day_period": "清晨", "local_hour": 5}, "early_morning", ), # 深夜工作话题 → overwork ({"content_work_related": True}, {"day_period": "深夜", "local_hour": 23}, "overwork"), # 节日 + 孤独 → festival_lonely ( {"content_lonely": True}, {"festivals": [Festival(name="春节", calendar="lunar")]}, "festival_lonely", ), # 凌晨 + 连续 ≥ 3 轮 → late_night ({"conversation_turns_today": 3}, {"day_period": "凌晨", "local_hour": 2}, "late_night"), # 深夜 + 连续 ≥ 3 轮 → late_night(Finding 1 回归防护:深夜也触发) ({"conversation_turns_today": 3}, {"day_period": "深夜", "local_hour": 23}, "late_night"), ], ) def test_care_kind_triggers(signals, snapshot_overrides, expected_kind): snap = _snapshot(**snapshot_overrides) factors = compute_modulation(snap, ModulationInput(**signals)) assert factors.care_kind == expected_kind def test_care_kind_none_when_no_trigger(): factors = compute_modulation(_snapshot(), ModulationInput()) # 白天、无信号 assert factors.care_kind is None def test_care_suggest_high_when_care_kind_hit(): snap = _snapshot(day_period="凌晨", local_hour=2, home_tz_diff_minutes=200) factors = compute_modulation(snap, ModulationInput()) assert factors.care_suggest >= 0.7 def test_care_suggest_low_when_no_care(): factors = compute_modulation(_snapshot(), ModulationInput()) # 上午 assert factors.care_suggest < 0.2 def test_all_factors_in_unit_range(): snap = _snapshot(day_period="凌晨", local_hour=2) factors = compute_modulation(snap, ModulationInput(conversation_turns_today=5)) for name in ( "festival_joy", "late_night_fragility", "reunion_intensity", "weekend_relax", "season_resonance", "same_period_resonance", "care_suggest", ): v = getattr(factors, name) assert 0.0 <= v <= 1.0, f"{name}={v} 超出 [0,1]" def test_care_kind_jetlag_threshold_configurable(): """设计 §7.3:jetlag 阈值经 ModulationInput 可调(守护配置接入,防回归摆设)。""" snap = _snapshot(day_period="凌晨", local_hour=2, home_tz_diff_minutes=200) # 默认阈值 180:200 >= 180 → 触发 jetlag assert compute_modulation(snap, ModulationInput()).care_kind == "jetlag" # 抬高阈值到 300:200 < 300 → 不再 jetlag(其余条件也不命中 → None) raised = compute_modulation(snap, ModulationInput(jetlag_threshold_minutes=300)) assert raised.care_kind != "jetlag" def test_care_kind_late_night_turns_configurable(): """设计 §7.3:late_night 最小轮数经 ModulationInput 可调。""" snap = _snapshot(day_period="凌晨", local_hour=2) # 2 轮 < 默认 3 → 不触发 assert compute_modulation(snap, ModulationInput(conversation_turns_today=2)).care_kind is None # 阈值降到 2:2 >= 2 → 触发 late_night lowered = compute_modulation( snap, ModulationInput(conversation_turns_today=2, late_night_min_turns=2) ) assert lowered.care_kind == "late_night"