初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,39 @@
"""temporal_context 文本组装测试(辰正 §8.1)。"""
from app.modules.time_sense.context_text import render_temporal_context
def test_context_contains_shichen_and_period():
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
from tests.modules.time_sense.test_modulation import _snapshot
snap = _snapshot(day_period="深夜", local_hour=23)
factors = compute_modulation(snap, ModulationInput())
text = render_temporal_context(snap, factors)
assert "深夜" in text
assert "周" in text
def test_context_reunion_line():
from app.modules.time_sense.duration import OfflineDuration
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
from tests.modules.time_sense.test_modulation import _snapshot
snap = _snapshot(
offline=OfflineDuration(minutes=4320 * 2, band="reunion_impact", reunion_intensity=0.7)
)
factors = compute_modulation(snap, ModulationInput())
text = render_temporal_context(snap, factors)
assert "久别重逢" in text
def test_context_no_lunar_does_not_crash():
"""lunar=None 时第二行跳过,不抛异常。"""
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
from tests.modules.time_sense.test_modulation import _snapshot
snap = _snapshot(lunar=None)
factors = compute_modulation(snap, ModulationInput())
text = render_temporal_context(snap, factors)
assert isinstance(text, str)
assert len(text) > 0
@@ -0,0 +1,104 @@
"""时间深度与离线档位测试。"""
from datetime import UTC, datetime, timedelta
import pytest
from pydantic import ValidationError
from app.modules.time_sense.duration import (
OfflineThresholds,
classify_offline_band,
compute_acquaintance_duration,
format_duration_cn,
)
def test_acquaintance_duration_zero_days():
anchor = datetime(2026, 7, 3, tzinfo=UTC)
now = anchor
d = compute_acquaintance_duration(anchor, now)
assert d.total_days == 0
assert "今天" in d.natural_language or "刚" in d.natural_language
def test_acquaintance_duration_287_days():
anchor = datetime(2026, 7, 3, tzinfo=UTC) - timedelta(days=287)
now = datetime(2026, 7, 3, tzinfo=UTC)
d = compute_acquaintance_duration(anchor, now)
assert d.total_days == 287
assert d.years == 0
assert d.months == 9
assert d.days == 17 # 287 % 30 = 17(按 30 天/月近似)
assert "287" in d.natural_language
def test_acquaintance_duration_one_year():
anchor = datetime(2025, 7, 3, tzinfo=UTC)
now = datetime(2026, 7, 3, tzinfo=UTC)
d = compute_acquaintance_duration(anchor, now)
assert d.total_days == 365
assert d.years == 1
def test_format_duration_cn_100_days():
# 100 天 = 3 个月 10 天(按 30 天/月近似),实现走月日分支
assert format_duration_cn(100) == "3 个月 10 天(100 天)"
def test_format_duration_cn_365_days():
text = format_duration_cn(365)
assert "1 年" in text
def test_classify_offline_band_active():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
last = now - timedelta(minutes=30)
t = OfflineThresholds()
result = classify_offline_band(last, now, t)
assert result.band == "active"
assert result.minutes == 30
def test_classify_offline_band_short_absence():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
last = now - timedelta(hours=5)
result = classify_offline_band(last, now, OfflineThresholds())
assert result.band == "short_absence"
def test_classify_offline_band_reunion_impact():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
last = now - timedelta(days=5)
result = classify_offline_band(last, now, OfflineThresholds())
assert result.band == "reunion_impact"
assert result.reunion_intensity > 0.5
def test_classify_offline_boundary_59min_is_active():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
last = now - timedelta(minutes=59)
assert classify_offline_band(last, now, OfflineThresholds()).band == "active"
def test_classify_offline_boundary_60min_is_short_absence():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
last = now - timedelta(minutes=60)
assert classify_offline_band(last, now, OfflineThresholds()).band == "short_absence"
def test_offline_thresholds_violates_monotonicity():
with pytest.raises(ValidationError):
OfflineThresholds(active_minutes=100, short_absence_minutes=50)
def test_reunion_intensity_clamped_05_to_1():
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
# 刚过 reunion_impact 阈值(4320 分钟 + 1 分钟)
last = now - timedelta(minutes=4321)
intensity = classify_offline_band(last, now, OfflineThresholds()).reunion_intensity
assert 0.5 <= intensity <= 1.0
# 超大间隔:30 天 = 43200 分钟,应趋近 1.0
last_far = now - timedelta(minutes=43200)
intensity_far = classify_offline_band(last_far, now, OfflineThresholds()).reunion_intensity
assert intensity_far > 0.9
@@ -0,0 +1,101 @@
"""农历/节气/节日引擎测试。"""
from datetime import date
from app.modules.time_sense.lunar import (
Shichen,
get_day_period,
get_festivals,
get_shichen,
get_solar_term,
solar_to_lunar,
)
def test_solar_to_lunar_spring_festival_2026():
# 2026-02-17 是农历丙午年正月初一(春节)
result = solar_to_lunar(date(2026, 2, 17))
assert result.year_ganzhi == "丙午"
assert result.month == 1
assert result.day == 1
assert result.is_leap_month is False
def test_solar_to_lunar_leap_month_recognized():
# 农历闰月标记:取一个已知闰月日期(如 2025 农历闰六月初一 ≈ 2025-07-25)
result = solar_to_lunar(date(2025, 7, 25))
assert result.month == 6
assert result.is_leap_month is True
def test_get_shichen_zi():
# 子时:23 点与 0 点都属子
assert get_shichen(23).name == "子"
assert get_shichen(0).name == "子"
def test_get_shichen_wu():
assert get_shichen(12).name == "午"
def test_get_day_period_deep_night():
assert get_day_period(2) == "凌晨"
assert get_day_period(23) == "深夜"
def test_get_solar_term_returns_current_or_nearest():
# 立春通常在 2/3-2/5;2026 年立春为 2/4
info = get_solar_term(date(2026, 2, 4))
assert info.current_name == "立春"
def test_get_solar_term_cross_year_next_in_chinese():
# 2026-12-25 跨年带入项,下一节气小寒在 getJieQiTable() 中以 XIAO_HAN 出现
info = get_solar_term(date(2026, 12, 25))
assert info.next_name == "小寒"
assert info.current_name is None
def test_get_solar_term_non_term_day_current_none():
_ALL_JIEQI_CN = {
"立春",
"雨水",
"惊蛰",
"春分",
"清明",
"谷雨",
"立夏",
"小满",
"芒种",
"夏至",
"小暑",
"大暑",
"立秋",
"处暑",
"白露",
"秋分",
"寒露",
"霜降",
"立冬",
"小雪",
"大雪",
"冬至",
"小寒",
"大寒",
}
info = get_solar_term(date(2026, 7, 15))
assert info.current_name is None
assert info.next_name in _ALL_JIEQI_CN
assert info.days_to_next > 0
def test_get_festivals_spring_festival():
festivals = get_festivals(date(2026, 2, 17))
names = [f.name for f in festivals]
assert "春节" in names
def test_shichen_model_has_range():
s = Shichen(name="子", start_hour=23, end_hour=1)
assert s.name == "子"
assert s.start_hour == 23
@@ -0,0 +1,217 @@
"""调制因子派生测试(辰正 §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"
@@ -0,0 +1,70 @@
"""感慨触发纯函数测试(辰正 §7.2)。"""
from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment
from app.modules.time_sense.snapshot import TemporalSnapshot
def _snapshot(**overrides) -> TemporalSnapshot:
"""复用 modulation 测试的快照构造思路:构造最小可用 snapshot。"""
from tests.modules.time_sense.test_modulation import _snapshot as _base_snapshot
return _base_snapshot(**overrides)
def test_duration_node_trigger():
snap = _snapshot()
signals = SentimentInput(duration_days=100, duration_nodes={100, 365})
assert detect_sentiment(snap, signals) == "duration_node"
def test_anniversary_prewarm_trigger():
snap = _snapshot()
signals = SentimentInput(duration_days=50, next_milestone_days=5, prewarm_days=7)
assert detect_sentiment(snap, signals) == "anniversary_prewarm"
def test_anniversary_prewarm_not_today():
# next_milestone_days=0 表示今日命中(active_milestone),不算 prewarm
snap = _snapshot()
signals = SentimentInput(duration_days=50, next_milestone_days=0, prewarm_days=7)
assert detect_sentiment(snap, signals) != "anniversary_prewarm"
def test_reunion_memory_trigger():
snap = _snapshot()
signals = SentimentInput(duration_days=10, is_reunion=True, has_reunion_memory=True)
assert detect_sentiment(snap, signals) == "reunion_memory"
def test_late_night_overlay_trigger():
snap = _snapshot(day_period="凌晨")
signals = SentimentInput(duration_days=10, is_reunion=True)
assert detect_sentiment(snap, signals) == "late_night_overlay"
def test_solar_term_trigger():
from app.modules.time_sense.lunar import SolarTermInfo
snap = _snapshot(
solar_term=SolarTermInfo(current_name="立春", next_name="雨水", days_to_next=15)
)
signals = SentimentInput(duration_days=10, strong_solar_terms={"立春"})
assert detect_sentiment(snap, signals) == "solar_term"
def test_no_trigger_returns_none():
snap = _snapshot()
signals = SentimentInput(duration_days=10)
assert detect_sentiment(snap, signals) is None
def test_priority_duration_over_prewarm():
"""认识 100 天且距周年 3 天 → duration_node 优先。"""
snap = _snapshot()
signals = SentimentInput(
duration_days=100,
next_milestone_days=3,
duration_nodes={100},
prewarm_days=7,
)
assert detect_sentiment(snap, signals) == "duration_node"
@@ -0,0 +1,116 @@
"""时态快照测试。"""
from datetime import UTC, datetime
from app.modules.time_sense.duration import OfflineThresholds
from app.modules.time_sense.snapshot import (
AnniversaryRef,
LocationContext,
SnapshotInput,
compute_snapshot,
)
def _input(**overrides):
base = {
"now": datetime(2026, 7, 3, 2, 42, tzinfo=UTC),
"timezone": "Asia/Shanghai",
"location": LocationContext(timezone="Asia/Shanghai", city="上海"),
"anniversaries": [],
"acquaintance_anchor": datetime(2025, 9, 19, tzinfo=UTC),
"last_active_at": datetime(2026, 7, 3, 2, 0, tzinfo=UTC),
"home_timezone": "Asia/Shanghai",
"offline_thresholds": OfflineThresholds(),
}
base.update(overrides)
return SnapshotInput(**base)
def test_snapshot_basic_fields():
snap = compute_snapshot(_input())
assert snap.timezone == "Asia/Shanghai"
# 上海 UTC+8,02:42 UTC -> 10:42 本地
assert snap.local_hour == 10
assert snap.shichen in {"辰", "巳"} # 7-11 点区间
assert snap.day_period in {"上午"}
assert snap.weekday is not None
def test_snapshot_acquaintance_and_offline():
snap = compute_snapshot(_input())
assert snap.acquaintance.total_days > 280
assert snap.offline.band == "active"
def test_snapshot_home_tz_diff_zero():
snap = compute_snapshot(_input())
assert snap.home_tz_diff_minutes == 0
def test_snapshot_next_milestone_from_anniversary():
# 下一个相识周年:相识日 2025-09-19,到 2026-07-03 距下一周年(2026-09-19)约 78 天
anni = AnniversaryRef(
id="anniv_1",
name="相识纪念日",
calendar="solar",
date_iso="2025-09-19",
recurs_yearly=True,
kind="anniversary",
)
snap = compute_snapshot(_input(anniversaries=[anni]))
assert snap.next_milestone is not None
assert snap.next_milestone.name == "相识纪念日"
assert 70 < snap.next_milestone.days_until < 85
def test_snapshot_active_milestone_today():
# 相识日恰好今天(构造一个今日命中的纪念日)
today = datetime(2026, 7, 3, tzinfo=UTC)
anni = AnniversaryRef(
id="anniv_birthday",
name="小明生日",
calendar="solar",
date_iso="2026-07-03",
recurs_yearly=False,
kind="birthday",
)
snap = compute_snapshot(_input(anniversaries=[anni], acquaintance_anchor=today))
assert any(m.name == "小明生日" for m in snap.active_milestones)
assert snap.next_milestone is None or snap.next_milestone.name != "小明生日"
def test_snapshot_lunar_present():
snap = compute_snapshot(_input())
# 农历字段非空(lunar_python 可用时)
assert snap.lunar is not None
assert snap.lunar.year_ganzhi == "丙午"
def test_snapshot_2_29_anniversary_in_non_leap_year():
# 2/29 周年在非闰年不应崩溃,且应回退到 2/28 后能算出下一周年
anni = AnniversaryRef(
id="anniv_leap",
name="闰年相识",
calendar="solar",
date_iso="2024-02-29",
recurs_yearly=True,
kind="anniversary",
)
snap = compute_snapshot(_input(anniversaries=[anni]))
assert snap.next_milestone is not None
assert snap.next_milestone.name == "闰年相识"
def test_snapshot_recurring_milestone_today():
# recurs_yearly=True 且月日命中今天:应进入 active_milestones
anni = AnniversaryRef(
id="anniv_today",
name="今日周年",
calendar="solar",
date_iso="2026-07-03",
recurs_yearly=True,
kind="anniversary",
)
snap = compute_snapshot(_input(anniversaries=[anni]))
assert any(m.id == "anniv_today" for m in snap.active_milestones)