218 lines
8.3 KiB
Python
218 lines
8.3 KiB
Python
"""调制因子派生测试(辰正 §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"
|