初始化提交
This commit is contained in:
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)
|
||||
Reference in new issue
Block a user