"""tests/test_time_tracker.py — time_tracker.py 的 pytest 测试套件""" import json import pytest from datetime import datetime, timedelta from pathlib import Path from tools.time_tracker import ( VALID_INTERACTION_TYPES, record_interaction, get_interactions, get_reply_times, get_interaction_frequency, analyze_timeline, analyze_reply_times, analyze_golden_hours, ) @pytest.fixture def base_dir(tmp_path: Path) -> Path: return tmp_path / "crushes" @pytest.fixture def slug() -> str: return "testcrush" @pytest.fixture def crush_dir(base_dir: Path, slug: str) -> Path: d = base_dir / slug d.mkdir(parents=True) (d / "interactions.jsonl").touch() (d / "meta.json").write_text( json.dumps( { "slug": slug, "event_count": 0, "updated_at": "2026-01-01T00:00:00", "interaction_count": 0, "last_interaction": None, "consecutive_days": 0, }, ensure_ascii=False, ), encoding="utf-8", ) return d class TestRecordInteraction: def test_appends_chat_sent(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts = datetime(2026, 5, 15, 22, 30, 0) record_interaction( slug, "chat_sent", {"content_summary": "问她周末有没有空"}, ts=ts, base_dir=base_dir, ) lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 1 record = json.loads(lines[0]) assert record["type"] == "chat_sent" assert record["ts"] == "2026-05-15T22:30:00" assert record["data"]["hour"] == 22 assert record["data"]["day_of_week"] == "fri" assert record["data"]["is_initiator"] is True def test_appends_chat_received_with_reply_delay(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts = datetime(2026, 5, 15, 22, 32, 0) record_interaction( slug, "chat_received", {"content_summary": "有空呀", "reply_delay_min": 2}, ts=ts, base_dir=base_dir, ) lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() record = json.loads(lines[0]) assert record["data"]["reply_delay_min"] == 2 assert record["data"]["is_initiator"] is False def test_appends_meeting(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts = datetime(2026, 5, 17, 19, 0, 0) record_interaction( slug, "meeting", {"duration_min": 180, "activity": "咖啡+散步", "location": "外滩"}, ts=ts, base_dir=base_dir, ) lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() record = json.loads(lines[0]) assert record["type"] == "meeting" assert record["data"]["duration_min"] == 180 def test_raises_on_unknown_type(self, base_dir: Path, slug: str) -> None: with pytest.raises(ValueError, match="未知互动类型"): record_interaction(slug, "unknown_type", {}, base_dir=base_dir) def test_raises_on_missing_slug(self, base_dir: Path) -> None: with pytest.raises(FileNotFoundError): record_interaction("nonexistent", "chat_sent", {"content_summary": "hi"}, base_dir=base_dir) def test_updates_meta_json(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts = datetime(2026, 5, 15, 22, 30, 0) record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts, base_dir=base_dir) meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) assert meta["interaction_count"] == 1 assert meta["last_interaction"] == "2026-05-15T22:30:00" def test_deduplicates_same_ts_and_type(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts = datetime(2026, 5, 15, 22, 30, 0) data = {"content_summary": "hi"} record_interaction(slug, "chat_sent", data, ts=ts, base_dir=base_dir) record_interaction(slug, "chat_sent", data, ts=ts, base_dir=base_dir) lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 1 def test_defaults_ts_to_now(self, crush_dir: Path, base_dir: Path, slug: str) -> None: record_interaction(slug, "chat_sent", {"content_summary": "hi"}, base_dir=base_dir) lines = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines() record = json.loads(lines[0]) assert record["ts"].startswith("202") class TestGetInteractions: def test_returns_all_by_default(self, crush_dir: Path, base_dir: Path, slug: str) -> None: for i in range(5): ts = datetime(2026, 5, 10 + i, 10, 0, 0) record_interaction(slug, "chat_sent", {"content_summary": f"msg {i}"}, ts=ts, base_dir=base_dir) result = get_interactions(slug, base_dir=base_dir) assert len(result) == 5 def test_filters_by_type(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts1 = datetime(2026, 5, 10, 10, 0, 0) ts2 = datetime(2026, 5, 10, 10, 5, 0) record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts1, base_dir=base_dir) record_interaction(slug, "meeting", {"duration_min": 60, "activity": "coffee"}, ts=ts2, base_dir=base_dir) result = get_interactions(slug, types=["meeting"], base_dir=base_dir) assert len(result) == 1 assert result[0]["type"] == "meeting" def test_filters_by_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: old_ts = datetime.now() - timedelta(days=30) recent_ts = datetime.now() - timedelta(days=1) record_interaction(slug, "chat_sent", {"content_summary": "old"}, ts=old_ts, base_dir=base_dir) record_interaction(slug, "chat_sent", {"content_summary": "new"}, ts=recent_ts, base_dir=base_dir) result = get_interactions(slug, days=7, base_dir=base_dir) assert len(result) == 1 assert result[0]["data"]["content_summary"] == "new" def test_returns_empty_for_no_file(self, base_dir: Path, slug: str) -> None: d = base_dir / slug d.mkdir(parents=True) (d / "interactions.jsonl").touch() result = get_interactions(slug, base_dir=base_dir) assert result == [] class TestGetReplyTimes: def test_returns_only_received_with_delay(self, crush_dir: Path, base_dir: Path, slug: str) -> None: ts1 = datetime(2026, 5, 10, 10, 0, 0) ts2 = datetime(2026, 5, 10, 10, 5, 0) ts3 = datetime(2026, 5, 10, 10, 10, 0) record_interaction(slug, "chat_sent", {"content_summary": "hi"}, ts=ts1, base_dir=base_dir) record_interaction(slug, "chat_received", {"content_summary": "hey", "reply_delay_min": 5}, ts=ts2, base_dir=base_dir) record_interaction(slug, "chat_received", {"content_summary": "no delay"}, ts=ts3, base_dir=base_dir) result = get_reply_times(slug, base_dir=base_dir) assert len(result) == 1 assert result[0]["data"]["reply_delay_min"] == 5 class TestAnalyzeTimeline: def _seed_interactions(self, base_dir: Path, slug: str) -> None: now = datetime.now() for day_offset in range(7): day = now - timedelta(days=day_offset) ts = day.replace(hour=20, minute=0, second=0, microsecond=0) if day_offset in (3, 5): continue record_interaction(slug, "chat_sent", {"content_summary": f"msg d-{day_offset}"}, ts=ts, base_dir=base_dir) recv_ts = day.replace(hour=20, minute=10, second=0, microsecond=0) record_interaction( slug, "chat_received", {"content_summary": f"reply d-{day_offset}", "reply_delay_min": 10}, ts=recv_ts, base_dir=base_dir, ) def test_counts_total_interactions(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_interactions(base_dir, slug) result = analyze_timeline(slug, days=7, base_dir=base_dir) assert result["total"] == 10 def test_consecutive_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_interactions(base_dir, slug) result = analyze_timeline(slug, days=7, base_dir=base_dir) assert result["current_streak"] >= 2 def test_max_consecutive(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_interactions(base_dir, slug) result = analyze_timeline(slug, days=7, base_dir=base_dir) assert result["max_streak"] >= 3 def test_initiator_ratio(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_interactions(base_dir, slug) result = analyze_timeline(slug, days=7, base_dir=base_dir) assert result["user_ratio"] == 50.0 def test_active_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_interactions(base_dir, slug) result = analyze_timeline(slug, days=7, base_dir=base_dir) assert result["active_days"] == 5 assert result["total_days"] == 7 class TestAnalyzeReplyTimes: def _seed_replies(self, base_dir: Path, slug: str) -> None: delays = [2, 3, 5, 8, 12, 15, 25, 45, 90, 180, 300, 600, 1200] now = datetime.now() for i, delay in enumerate(delays): ts = now - timedelta(days=i) record_interaction( slug, "chat_received", {"content_summary": f"reply {i}", "reply_delay_min": delay}, ts=ts, base_dir=base_dir, ) def test_average_and_median(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_replies(base_dir, slug) result = analyze_reply_times(slug, days=30, base_dir=base_dir) assert result["average_min"] > 0 assert result["median_min"] > 0 def test_distribution_buckets(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_replies(base_dir, slug) result = analyze_reply_times(slug, days=30, base_dir=base_dir) dist = result["distribution"] assert dist["lte_5min"] > 0 assert dist["gt_4h"] > 0 total_pct = sum(dist.values()) assert abs(total_pct - 100.0) < 1.0 def test_empty_data(self, crush_dir: Path, base_dir: Path, slug: str) -> None: result = analyze_reply_times(slug, days=30, base_dir=base_dir) assert result["average_min"] is None assert result["median_min"] is None class TestAnalyzeGoldenHours: def _seed_hourly(self, base_dir: Path, slug: str) -> None: base = datetime.now() - timedelta(days=2) for _ in range(8): ts = base.replace(hour=21, minute=30, second=0, microsecond=0) record_interaction( slug, "chat_received", {"content_summary": "night reply", "reply_delay_min": 5}, ts=ts, base_dir=base_dir, ) for _ in range(3): ts = base.replace(hour=12, minute=0, second=0, microsecond=0) record_interaction( slug, "chat_received", {"content_summary": "lunch reply", "reply_delay_min": 10}, ts=ts, base_dir=base_dir, ) ts = base.replace(hour=8, minute=0, second=0, microsecond=0) record_interaction( slug, "chat_received", {"content_summary": "morning reply", "reply_delay_min": 15}, ts=ts, base_dir=base_dir, ) def test_peak_hour(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_hourly(base_dir, slug) result = analyze_golden_hours(slug, days=30, base_dir=base_dir) assert result["peak_hour"] == 21 def test_top_windows(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_hourly(base_dir, slug) result = analyze_golden_hours(slug, days=30, base_dir=base_dir) assert len(result["top_windows"]) >= 1 assert result["top_windows"][0]["hour"] == 21 def test_empty_data(self, crush_dir: Path, base_dir: Path, slug: str) -> None: result = analyze_golden_hours(slug, days=30, base_dir=base_dir) assert result["peak_hour"] is None assert result["top_windows"] == [] class TestAnalyzeMilestones: def _seed_profile_and_events(self, crush_dir: Path, base_dir: Path, slug: str) -> None: (crush_dir / "profile.md").write_text( "---\nnickname: 小雨\nslug: testcrush\ncreated_at: \"2026-04-10\"\n---\n\n## 性格画像\n\ntest\n", encoding="utf-8", ) events_path = crush_dir / "events.jsonl" events = [ {"ts": "2026-04-10T14:00:00", "v": 1, "type": "profile_created", "slug": slug, "data": {"nickname": "小雨"}}, {"ts": "2026-04-18T10:00:00", "v": 1, "type": "stage_changed", "slug": slug, "data": {"from": "破冰期", "to": "升温期", "trigger": "test"}}, {"ts": "2026-05-02T10:00:00", "v": 1, "type": "stage_changed", "slug": slug, "data": {"from": "升温期", "to": "暧昧期", "trigger": "test"}}, ] with events_path.open("w", encoding="utf-8") as f: for e in events: f.write(json.dumps(e, ensure_ascii=False) + "\n") def test_stage_durations(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_profile_and_events(crush_dir, base_dir, slug) from tools.time_tracker import analyze_milestones result = analyze_milestones(slug, base_dir=base_dir) stages = result["stages"] assert len(stages) >= 2 assert stages[0]["name"] == "破冰期" assert stages[0]["days"] == 8 assert stages[1]["name"] == "升温期" assert stages[1]["days"] == 14 def test_baseline_comparison(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_profile_and_events(crush_dir, base_dir, slug) from tools.time_tracker import analyze_milestones result = analyze_milestones(slug, base_dir=base_dir) for stage in result["stages"]: assert "baseline_lo" in stage assert "baseline_hi" in stage assert "status" in stage def test_total_days(self, crush_dir: Path, base_dir: Path, slug: str) -> None: self._seed_profile_and_events(crush_dir, base_dir, slug) from tools.time_tracker import analyze_milestones result = analyze_milestones(slug, base_dir=base_dir) assert result["total_days"] >= 22 class TestIntegration: def test_full_workflow(self, crush_dir: Path, base_dir: Path, slug: str) -> None: now = datetime.now() for day in range(7): ts_sent = now - timedelta(days=day, hours=3, minutes=0) ts_sent = ts_sent.replace(second=0, microsecond=0) record_interaction(slug, "chat_sent", {"content_summary": f"msg d-{day}"}, ts=ts_sent, base_dir=base_dir) ts_recv = now - timedelta(days=day, hours=2, minutes=55 - (5 + day)) ts_recv = ts_recv.replace(second=0, microsecond=0) record_interaction( slug, "chat_received", {"content_summary": f"reply d-{day}", "reply_delay_min": 5 + day}, ts=ts_recv, base_dir=base_dir, ) meeting_day = now - timedelta(days=4) ts_meeting = meeting_day.replace(hour=19, minute=0, second=0, microsecond=0) record_interaction( slug, "meeting", {"duration_min": 120, "activity": "coffee"}, ts=ts_meeting, base_dir=base_dir, ) tl = analyze_timeline(slug, days=30, base_dir=base_dir) assert tl["total"] == 15 rt = analyze_reply_times(slug, days=30, base_dir=base_dir) assert rt["average_min"] is not None gh = analyze_golden_hours(slug, days=30, base_dir=base_dir) assert gh["peak_hour"] is not None meta = json.loads((crush_dir / "meta.json").read_text(encoding="utf-8")) assert meta["interaction_count"] == 15 class TestTruncatedLineRecovery: """P2 regression (C4): a crash-truncated last line must not swallow the next record.""" def test_record_after_truncated_line_keeps_new_record( self, crush_dir: Path, base_dir: Path, slug: str ) -> None: path = crush_dir / "interactions.jsonl" path.write_text('{"ts": "2026-01-01T00:00:00", "type": "chat_se', encoding="utf-8") record_interaction( slug, "chat_sent", {"content_summary": "新消息"}, ts=datetime(2026, 5, 20, 10, 0, 0), base_dir=base_dir, ) records = get_interactions(slug, base_dir=base_dir) assert any(r["data"].get("content_summary") == "新消息" for r in records)