Files
hunjing/backend/tests/modules/time_sense/test_modulation.py
T
2026-08-21 16:00:13 +08:00

218 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""调制因子派生测试(辰正 §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"