Files
wehub-resource-sync 1d6fd29cda
tests / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:27 +08:00

526 lines
19 KiB
Python

"""
tests/test_chat_parser.py
pytest 测试套件 · chat_parser.py
"""
import json
import re
import pytest
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
from tools.chat_parser import (
Message,
detect_format,
parse_wechat_txt,
parse_qq_txt,
parse_qq_mht,
parse_wechat_html,
parse_wechat_csv,
parse_json,
parse_chat,
SignalAnalyzer,
_format_seconds,
generate_report,
)
# ─────────────────────────────────────────────
# 纯函数
# ─────────────────────────────────────────────
class TestFormatSeconds:
def test_seconds_under_minute(self) -> None:
assert _format_seconds(45) == "45秒"
def test_minutes(self) -> None:
assert _format_seconds(180) == "3分钟"
def test_hours(self) -> None:
assert _format_seconds(7200) == "2.0小时"
def test_zero(self) -> None:
assert _format_seconds(0) == "0秒"
class TestMessage:
def test_repr(self) -> None:
ts = datetime(2024, 1, 1, 20, 30)
m = Message(ts, "小美", "你好呀~")
assert "2024-01-01 20:30" in repr(m)
assert "小美" in repr(m)
def test_init_defaults(self) -> None:
ts = datetime.now()
m = Message(ts, "小美", "hello")
assert m.msg_type == "text"
class TestDetectFormat:
@patch("tools.chat_parser.Path")
def test_json_ext(self, mock_path_cls) -> None:
mock_path_cls.return_value.suffix.lower.return_value = ".json"
assert detect_format("x.json") == "json"
def test_mht_ext(self) -> None:
assert detect_format("x.mht") == "qq_mht"
def test_csv_ext(self) -> None:
assert detect_format("x.csv") == "wechat_csv"
def test_html_ext(self) -> None:
assert detect_format("x.html") == "wechat_html"
# ─────────────────────────────────────────────
# 格式解析(fixture 注入临时文件)
# ─────────────────────────────────────────────
class TestParseWechatTxt:
def test_basic(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.txt"
f.write_text(
"2024-01-01 10:00:00\n小美\n你好呀~\n2024-01-01 10:05:00\n\n嗨!\n",
encoding="utf-8",
)
msgs = parse_wechat_txt(str(f), "小美", "我")
assert len(msgs) == 2
assert msgs[0].sender == "小美"
assert msgs[0].content == "你好呀~"
def test_ignores_empty_content(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.txt"
f.write_text("2024-01-01 10:00:00\n小美\n\n2024-01-01 10:05:00\n\n有内容\n", encoding="utf-8")
msgs = parse_wechat_txt(str(f), "小美", "我")
assert all(m.content.strip() for m in msgs)
class TestParseQqTxt:
def test_basic(self, tmp_path: Path) -> None:
f = tmp_path / "qq.txt"
f.write_text(
"2024-01-01 10:00:00 小美(12345)\n你好呀\n第二行\n"
"2024-01-01 10:05:00 我(999)\n嗨!\n",
encoding="utf-8",
)
msgs = parse_qq_txt(str(f), "小美", "我")
assert len(msgs) == 2
assert msgs[0].sender == "小美"
assert msgs[0].content == "你好呀\n第二行"
def test_multiline_content(self, tmp_path: Path) -> None:
f = tmp_path / "qq.txt"
f.write_text("2024-01-01 10:00:00 小美(1)\n第一行\n第二行\n第三行\n", encoding="utf-8")
msgs = parse_qq_txt(str(f), "小美", "我")
assert msgs[0].content == "第一行\n第二行\n第三行"
def test_empty_lines_skipped(self, tmp_path: Path) -> None:
f = tmp_path / "qq.txt"
f.write_text("2024-01-01 10:00:00 小美(1)\n内容\n\n\n", encoding="utf-8")
msgs = parse_qq_txt(str(f), "小美", "我")
assert msgs[0].content == "内容"
class TestParseQqMht:
def test_basic(self, tmp_path: Path) -> None:
f = tmp_path / "qq.mht"
f.write_text(
"<p>2024-01-01 10:00:00 小美(12345) 你好呀~</p>"
"<p>2024-01-01 10:05:00 我(999) 嗨!</p>",
encoding="utf-8",
)
msgs = parse_qq_mht(str(f), "小美", "我")
assert len(msgs) == 2
assert msgs[0].sender == "小美"
def test_html_tags_stripped(self, tmp_path: Path) -> None:
f = tmp_path / "qq.mht"
f.write_text("<b>2024-01-01 10:00:00</b> 小美(1) <i>消息内容</i>", encoding="utf-8")
msgs = parse_qq_mht(str(f), "小美", "我")
assert ">" not in msgs[0].content
class TestParseWechatHtml:
def test_basic(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.html"
f.write_text(
'<div class="message"><span class="time">2024-01-01 10:00:00</span>'
'<span class="sender">小美</span><div class="content">你好呀~</div></div>',
encoding="utf-8",
)
msgs = parse_wechat_html(str(f), "小美", "我")
assert len(msgs) == 1
assert msgs[0].sender == "小美"
assert msgs[0].content == "你好呀~"
def test_slash_date_format(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.html"
f.write_text(
'<div class="message"><span class="time">2024/01/01 10:00:00</span>'
'<span class="sender">小美</span><div class="content">test</div></div>',
encoding="utf-8",
)
msgs = parse_wechat_html(str(f), "小美", "我")
assert len(msgs) == 1
class TestParseWechatCsv:
def test_timestamp_int(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.csv"
ts = int(datetime(2024, 1, 1, 10, 0).timestamp())
f.write_text(f"CreateTime,NickName,StrContent\n{ts},小美,你好呀~\n", encoding="utf-8-sig")
msgs = parse_wechat_csv(str(f), "小美", "我")
assert len(msgs) == 1
assert msgs[0].content == "你好呀~"
def test_string_timestamp(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.csv"
f.write_text("CreateTime,NickName,StrContent\n2024-01-01 10:00:00,小美,hello\n", encoding="utf-8-sig")
msgs = parse_wechat_csv(str(f), "小美", "我")
assert len(msgs) == 1
class TestParseJson:
def test_list_format(self, tmp_path: Path) -> None:
f = tmp_path / "chat.json"
f.write_text(
json.dumps([
{"timestamp": "2024-01-01 10:00:00", "sender": "小美", "content": "你好"},
]),
encoding="utf-8",
)
msgs = parse_json(str(f), "小美", "我")
assert len(msgs) == 1
assert msgs[0].sender == "小美"
def test_dict_with_messages_key(self, tmp_path: Path) -> None:
f = tmp_path / "chat.json"
f.write_text(
json.dumps({"messages": [
{"timestamp": "2024-01-01 10:00:00", "sender": "小美", "content": "hi"},
]}),
encoding="utf-8",
)
msgs = parse_json(str(f), "小美", "我")
assert len(msgs) == 1
def test_epoch_timestamp(self, tmp_path: Path) -> None:
f = tmp_path / "chat.json"
ts = int(datetime(2024, 1, 1, 10, 0).timestamp())
f.write_text(json.dumps([{"timestamp": ts, "sender": "小美", "content": "hello"}]))
msgs = parse_json(str(f), "小美", "我")
assert len(msgs) == 1
class TestParseChat:
def test_filters_relevant_senders(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.txt"
f.write_text(
"2024-01-01 10:00:00\n小美\n你好\n2024-01-01 10:01:00\n陌生人\n内容\n",
encoding="utf-8",
)
msgs = parse_chat(str(f), "小美", "我")
senders = {m.sender for m in msgs}
assert "小美" in senders
assert "陌生人" not in senders
def test_sorts_by_timestamp(self, tmp_path: Path) -> None:
f = tmp_path / "wechat.txt"
f.write_text(
"2024-01-01 12:00:00\n小美\n第二条\n"
"2024-01-01 10:00:00\n小美\n第一条\n",
encoding="utf-8",
)
msgs = parse_chat(str(f), "小美", "我")
assert msgs[0].content == "第一条"
# ─────────────────────────────────────────────
# SignalAnalyzer · 纯函数分析(无需 I/O)
# ─────────────────────────────────────────────
def make_msg(
content: str,
sender: str = "小美",
hour: int = 10,
minute: int = 0,
) -> Message:
return Message(
datetime(2024, 1, 1, hour, minute),
sender,
content,
)
def make_analyzer(msgs: list[Message]) -> SignalAnalyzer:
return SignalAnalyzer(msgs, "小美", "我")
class TestSignalAnalyzer_MessageCounts:
def test_all_zero_when_empty(self) -> None:
assert make_analyzer([]).message_counts()["total"] == 0
def test_counts_both_sides(self) -> None:
msgs = [make_msg("hello", "小美"), make_msg("hi", "我")]
result = make_analyzer(msgs).message_counts()
assert result["from_target"] == 1
assert result["from_user"] == 1
assert result["total"] == 2
class TestSignalAnalyzer_DateRange:
def test_empty_returns_empty_dict(self) -> None:
assert make_analyzer([]).date_range() == {}
def test_calculates_days(self) -> None:
msgs = [
Message(datetime(2024, 1, 1, 10, 0), "小美", "hi"),
Message(datetime(2024, 1, 10, 10, 0), "小美", "hi"),
]
result = make_analyzer(msgs).date_range()
assert result["total_days"] == 10
class TestSignalAnalyzer_InitiativeAnalysis:
def test_empty(self) -> None:
result = make_analyzer([]).initiative_analysis()
assert result["target_initiates"] == 0
def test_target_starts_session(self) -> None:
msgs = [
make_msg("主动", "小美", 10),
make_msg("回复", "我", 11),
]
result = make_analyzer(msgs).initiative_analysis()
assert result["target_initiates"] == 1
def test_initiative_verdict_all_target(self) -> None:
ver = make_analyzer([])._initiative_verdict(6, 0)
assert "🟢" in ver
def test_initiative_verdict_all_user(self) -> None:
ver = make_analyzer([])._initiative_verdict(0, 6)
assert "🔴" in ver
class TestSignalAnalyzer_ReplySpeed:
def test_empty_returns_none_stats(self) -> None:
result = make_analyzer([]).reply_speed_analysis()
assert result["target_reply"]["avg_seconds"] is None
def test_skips_gap_over_4h(self) -> None:
msgs = [
make_msg("a", "小美", 10), # 10:00 target
make_msg("b", "我", 16), # 16:00 user, gap=6h >4h skip
make_msg("c", "小美", 17), # 17:00 target, gap=1h from b <=4h, counts as target delay
]
result = make_analyzer(msgs).reply_speed_analysis()
# After skipping the 6h gap, we have a 1h delay (target's reply to user's msg)
assert result["target_reply"]["avg_seconds"] == 3600
def test_counts_user_reply_to_target(self) -> None:
msgs = [
make_msg("a", "小美", 10),
make_msg("b", "我", 10, 5), # user replies after 5min
]
result = make_analyzer(msgs).reply_speed_analysis()
assert result["user_reply"]["avg_seconds"] == 300
def test_speed_verdict_fast(self) -> None:
ver = make_analyzer([])._speed_verdict({"avg_seconds": 60}, {"avg_seconds": 300})
assert "🟢" in ver
def test_speed_verdict_slow(self) -> None:
ver = make_analyzer([])._speed_verdict({"avg_seconds": 3600}, {"avg_seconds": 60})
assert "🔴" in ver
class TestSignalAnalyzer_MessageLength:
def test_empty(self) -> None:
result = make_analyzer([]).message_length_analysis()
assert result["target_avg_len"] == 0
def test_longer_target_is_better(self) -> None:
msgs = [
make_msg("a" * 200, "小美"),
make_msg("b", "我"),
]
result = make_analyzer(msgs).message_length_analysis()
assert "投入度高" in result["verdict"]
class TestSignalAnalyzer_LateNight:
def test_no_late_night(self) -> None:
msgs = [make_msg("hi", "小美", 14)]
result = make_analyzer(msgs).late_night_analysis()
assert "⚪" in result["verdict"]
def test_late_night_initiates(self) -> None:
msgs = [
Message(datetime(2024, 1, 1, 23, 0), "小美", "hi"),
Message(datetime(2024, 1, 1, 23, 30), "我", "回复"),
Message(datetime(2024, 1, 1, 23, 45), "小美", "继续"),
]
result = make_analyzer(msgs).late_night_analysis()
assert result["target_initiates_late_night"] >= 1
class TestSignalAnalyzer_TopicAnalysis:
def test_empty(self) -> None:
result = make_analyzer([]).topic_analysis()
assert result["target_follow_up_questions"] == 0
def test_question_follow_up(self) -> None:
msgs = [
make_msg("今天吃了什么?", "我", 10),
make_msg("火锅", "小美", 10, 5),
]
result = make_analyzer(msgs).topic_analysis()
assert result["target_follow_up_questions"] >= 0 # pass if no question
class TestSignalAnalyzer_LanguageFeatures:
def test_empty(self) -> None:
result = make_analyzer([]).language_features()
assert result["message_style"] == "混合型"
def test_short_msg_style(self) -> None:
msgs = [make_msg("hi", "小美") for _ in range(10)]
result = make_analyzer(msgs).language_features()
assert result["message_style"] == "短句连发型"
def test_particle_detection(self) -> None:
msgs = [Message(datetime(2024, 1, 1, 10), "小美", "哈哈很好笑哈哈哈") for _ in range(5)]
result = make_analyzer(msgs).language_features()
assert any(p == "哈哈" for p, _ in result["top_particles"])
class TestSignalAnalyzer_SignalScore:
def test_score_not_high_when_user_initiates_all(self) -> None:
msgs = [
Message(datetime(2024, 1, 1, 10, 0), "我", "hello"),
Message(datetime(2024, 1, 1, 10, 5), "小美", "hi"),
]
result = make_analyzer(msgs).signal_score()
assert result["score"] <= 0 # no positive initiative signal
def test_score_in_valid_range(self) -> None:
msgs = [make_msg("hello", "小美") for _ in range(20)]
result = make_analyzer(msgs).signal_score()
assert -3 <= result["score"] <= 25
assert result["max_score"] == 25
class TestSignalAnalyzer_SplitSessions:
def test_empty(self) -> None:
assert make_analyzer([])._split_sessions() == []
def test_single_message(self) -> None:
msgs = [make_msg("hi")]
sessions = make_analyzer(msgs)._split_sessions(gap_minutes=60)
assert len(sessions) == 1
def test_splits_on_gap(self) -> None:
msgs = [
make_msg("a", hour=10, minute=0),
make_msg("b", hour=10, minute=30),
make_msg("c", hour=12, minute=30),
]
sessions = make_analyzer(msgs)._split_sessions(gap_minutes=60)
assert len(sessions) == 2
assert len(sessions[0]) == 2
assert len(sessions[1]) == 1
# ─────────────────────────────────────────────
# generate_report
# ─────────────────────────────────────────────
class TestGenerateReport:
def test_empty_messages(self) -> None:
result = generate_report([], "小美", "我")
assert "未找到有效消息" in result
def test_returns_string(self) -> None:
msgs = [make_msg("hello", "小美")]
result = generate_report(msgs, "小美", "我")
assert isinstance(result, str)
assert "小美" in result
def test_output_path_writes_file(self, tmp_path: Path) -> None:
msgs = [make_msg("hi", "小美")]
out = tmp_path / "report.md"
generate_report(msgs, "小美", "我", str(out))
assert out.exists()
assert "聊天记录" in out.read_text(encoding="utf-8")
# ─────────────────────────────────────────────
# extract_time_data / --track-time
# ─────────────────────────────────────────────
import json as _json
from pathlib import Path as _Path
class TestTrackTime:
def test_extracts_interactions_from_messages(self, tmp_path: _Path) -> None:
chat_file = tmp_path / "chat.txt"
chat_file.write_text(
"2026-05-15 22:30:00\n\n问她周末有没有空\n"
"2026-05-15 22:32:00\n小美\n有空呀,你有什么安排吗\n"
"2026-05-15 22:33:00\n\n那一起去看展吧\n"
"2026-05-15 22:35:00\n小美\n好呀好呀!\n",
encoding="utf-8",
)
slug = "xiaomei"
crush_dir = tmp_path / "crushes" / slug
crush_dir.mkdir(parents=True)
(crush_dir / "interactions.jsonl").touch()
(crush_dir / "meta.json").write_text(
_json.dumps({"slug": slug, "interaction_count": 0}, ensure_ascii=False),
encoding="utf-8",
)
from tools.chat_parser import extract_time_data
messages = parse_chat(str(chat_file), "小美", "我")
extract_time_data(messages, "小美", "我", slug, base_dir=tmp_path / "crushes")
interactions = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines()
assert len(interactions) == 4
first = _json.loads(interactions[0])
assert first["type"] == "chat_sent"
assert first["data"]["hour"] == 22
assert first["data"]["day_of_week"] == "fri"
second = _json.loads(interactions[1])
assert second["type"] == "chat_received"
assert second["data"]["reply_delay_min"] == 2
def test_skip_if_gap_over_4h(self, tmp_path: _Path) -> None:
chat_file = tmp_path / "chat.txt"
chat_file.write_text(
"2026-05-15 22:30:00\n\n晚安\n"
"2026-05-16 10:00:00\n小美\n早呀\n",
encoding="utf-8",
)
slug = "xiaomei"
crush_dir = tmp_path / "crushes" / slug
crush_dir.mkdir(parents=True)
(crush_dir / "interactions.jsonl").touch()
(crush_dir / "meta.json").write_text(
_json.dumps({"slug": slug, "interaction_count": 0}, ensure_ascii=False),
encoding="utf-8",
)
from tools.chat_parser import extract_time_data
messages = parse_chat(str(chat_file), "小美", "我")
extract_time_data(messages, "小美", "我", slug, base_dir=tmp_path / "crushes")
interactions = (crush_dir / "interactions.jsonl").read_text(encoding="utf-8").strip().splitlines()
second = _json.loads(interactions[1])
assert "reply_delay_min" not in second["data"]