gitlink-cli/scripts/research/test_visual.py

407 lines
14 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.

"""test_visual.py — S6 科研成果可视化的纯单元测试(不联网、不调 gitlink-cli
把「取数」与「算法」分离:算法函数接收已构造好的 Python 数据结构mock commits/issues/...
测试只覆盖 bin_weekly / contribution_heatmap / extract_paper_links / classify_artifacts
不测 plotly 渲染。
运行:`python scripts/research/test_visual.py`
"""
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import visual as V # noqa: E402
# ---------------------------------------------------------------------------
# 辅助:构造「最近 N 天」的时间戳/ISO 字符串
# ---------------------------------------------------------------------------
def _days_ago_ts(days: int) -> float:
return (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
def _days_ago_iso(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# _to_timestamp
# ---------------------------------------------------------------------------
def test_to_timestamp_int_seconds():
assert V._to_timestamp(0) == 0.0
assert V._to_timestamp(1719500000) == 1719500000.0
assert V._to_timestamp("1719500000") == 1719500000.0
def test_to_timestamp_millis():
# 13 位 → 视为毫秒
assert abs(V._to_timestamp(1719500000000) - 1719500000.0) < 1e-3
def test_to_timestamp_iso():
ts = V._to_timestamp("2024-06-15T12:00:00Z")
assert ts > 0
# 无效字符串 → 0
assert V._to_timestamp("not-a-date") == 0.0
assert V._to_timestamp(None) == 0.0
assert V._to_timestamp("") == 0.0
# ---------------------------------------------------------------------------
# bin_weekly
# ---------------------------------------------------------------------------
def test_bin_weekly_shape_and_sum():
weeks = 4
commits = [
{"timestamp": _days_ago_iso(2)}, # 本周
{"timestamp": _days_ago_iso(9)}, # 上周
{"timestamp": _days_ago_iso(9)},
{"timestamp": _days_ago_iso(20)}, # 3 周前
]
out = V.bin_weekly(commits, weeks, V._commit_time)
assert set(out.keys()) == {"labels", "counts"}
assert len(out["labels"]) == weeks
assert len(out["counts"]) == weeks
assert sum(out["counts"]) == len(commits)
# 标签是 ISO 周(形如 2026-Wxx
assert all("-W" in lab for lab in out["labels"])
def test_bin_weekly_drops_out_of_window_and_invalid():
weeks = 3
commits = [
{"timestamp": _days_ago_iso(1)}, # 窗口内
{"timestamp": _days_ago_iso(365)}, # 太早(窗口外)→ 丢弃
{"timestamp": "garbage"}, # 非法 → 丢弃
{}, # 无时间字段 → 丢弃
]
out = V.bin_weekly(commits, weeks, V._commit_time)
assert sum(out["counts"]) == 1
def test_bin_weekly_issues_uses_created_at():
weeks = 2
issues = [
{"created_at": _days_ago_iso(3)}, # 窗口内
{"created_at": _days_ago_iso(40)}, # 窗口外(>2 周)→ 丢弃
]
out = V.bin_weekly(issues, weeks, V._issue_time)
assert sum(out["counts"]) == 1 # 只有 3 天前那条进窗口
def test_bin_weekly_prs_uses_pr_created_unix():
weeks = 2
prs = [
{"pr_created_unix": int(_days_ago_ts(2))},
{"pr_created_unix": int(_days_ago_ts(50))},
]
out = V.bin_weekly(prs, weeks, V._pr_time)
assert sum(out["counts"]) == 1
def test_bin_weekly_empty():
out = V.bin_weekly([], 5, V._commit_time)
assert len(out["labels"]) == 5
assert out["counts"] == [0, 0, 0, 0, 0]
# ---------------------------------------------------------------------------
# contribution_heatmap
# ---------------------------------------------------------------------------
def test_heatmap_matrix_shape():
weeks = 3
contributors = [
{"login": "alice", "contributions": 100},
{"login": "bob", "contributions": 5},
]
commits = [
{"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
{"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
{"timestamp": _days_ago_iso(9), "author": {"login": "bob"}},
]
hm = V.contribution_heatmap(contributors, commits, weeks)
assert set(hm.keys()) == {"users", "weeks", "matrix"}
assert hm["users"][:2] == ["alice", "bob"]
assert len(hm["matrix"]) == len(hm["users"])
for row in hm["matrix"]:
assert len(row) == weeks
# alice 行总和 = 2
alice_row = hm["matrix"][hm["users"].index("alice")]
assert sum(alice_row) == 2
bob_row = hm["matrix"][hm["users"].index("bob")]
assert sum(bob_row) == 1
def test_heatmap_includes_commit_authors_not_in_contributors():
weeks = 2
contributors = [{"login": "alice", "contributions": 1}]
commits = [
{"timestamp": _days_ago_iso(2), "author": {"login": "alice"}},
{"timestamp": _days_ago_iso(3), "author": {"login": "carol"}}, # 不在 contributors
]
hm = V.contribution_heatmap(contributors, commits, weeks)
assert "carol" in hm["users"]
def test_heatmap_top_users_cap():
weeks = 2
contributors = [{"login": f"u{i}", "contributions": i} for i in range(20)]
commits = [{"timestamp": _days_ago_iso(1), "author": {"login": f"u{i}"}}
for i in range(20)]
hm = V.contribution_heatmap(contributors, commits, weeks, top_users=5)
assert len(hm["users"]) <= 5
assert len(hm["matrix"]) == len(hm["users"])
def test_heatmap_empty():
hm = V.contribution_heatmap([], [], 4)
assert hm["users"] == []
assert hm["matrix"] == []
assert len(hm["weeks"]) == 4
# ---------------------------------------------------------------------------
# extract_paper_links
# ---------------------------------------------------------------------------
def test_extract_arxiv_url():
text = "See https://arxiv.org/abs/2401.00012 for details."
links = V.extract_paper_links(text)
assert len(links) == 1
assert links[0]["type"] == "arxiv"
assert links[0]["target"] == "https://arxiv.org/abs/2401.00012"
# snippet 截取自原文上下文(窗口较窄,断言前缀即可)
assert links[0]["source_text_snippet"].startswith("See https://arxiv.org")
def test_extract_arxiv_pdf_url():
text = "paper: https://arxiv.org/pdf/2305.12345.pdf"
links = V.extract_paper_links(text)
assert len(links) == 1
# 归一成 abs 形式
assert links[0]["target"] == "https://arxiv.org/abs/2305.12345"
def test_extract_arxiv_bare():
text = "We use arXiv:2103.07018 in our method."
links = V.extract_paper_links(text)
assert any(l["target"] == "https://arxiv.org/abs/2103.07018" for l in links)
def test_extract_doi_url():
text = "Cited from https://doi.org/10.1000/182"
links = V.extract_paper_links(text)
assert len(links) == 1
assert links[0]["type"] == "doi"
assert links[0]["target"] == "https://doi.org/10.1000/182"
def test_extract_doi_bare():
text = "Reference 10.1109/5.771073 shows that."
links = V.extract_paper_links(text)
assert len(links) == 1
assert links[0]["target"] == "https://doi.org/10.1109/5.771073"
def test_extract_dedup_same_id():
text = ("arxiv 1 https://arxiv.org/abs/2401.00012 "
"and again https://arxiv.org/abs/2401.00012")
links = V.extract_paper_links(text)
assert len(links) == 1
def test_extract_dedup_across_doi_forms():
# doi.org 形式与裸 DOI 视为同一条
text = "https://doi.org/10.1000/182 and bare 10.1000/182 again"
links = V.extract_paper_links(text)
# 同一 DOI 只出现一次
targets = [l["target"] for l in links]
assert targets.count("https://doi.org/10.1000/182") == 1
def test_extract_multiple_and_order():
text = ("first https://arxiv.org/abs/2401.00012 "
"then https://doi.org/10.1000/182")
links = V.extract_paper_links(text)
assert len(links) == 2
# 按出现位置排序
assert links[0]["type"] == "arxiv"
assert links[1]["type"] == "doi"
def test_extract_none_in_text():
assert V.extract_paper_links("no links here at all") == []
assert V.extract_paper_links("") == []
def test_extract_strips_trailing_punct_from_doi():
text = "see 10.1000/abc123, then more."
links = V.extract_paper_links(text)
assert links[0]["target"].endswith("/abc123") # 末尾逗号/句号被清掉
assert not links[0]["target"].rstrip().endswith(",")
# ---------------------------------------------------------------------------
# classify_artifacts
# ---------------------------------------------------------------------------
def test_classify_paper_and_ipynb():
tree = [
{"path": "docs/paper.pdf"},
{"path": "notebooks/demo.ipynb"},
]
out = V.classify_artifacts(tree)
cats = {a["path"]: a["category"] for a in out}
assert cats["docs/paper.pdf"] == "paper"
assert cats["notebooks/demo.ipynb"] == "paper"
def test_classify_dataset():
tree = [
{"path": "data/train.csv"},
{"path": "datasets/x.parquet"},
]
out = V.classify_artifacts(tree)
cats = {a["path"]: a["category"] for a in out}
assert cats["data/train.csv"] == "dataset"
def test_classify_model():
tree = [
{"path": "model/best.ckpt"},
{"path": "models/v2.onnx"},
]
out = V.classify_artifacts(tree)
cats = {a["path"]: a["category"] for a in out}
assert cats["model/best.ckpt"] == "model"
assert cats["models/v2.onnx"] == "model"
def test_classify_benchmark():
tree = [{"path": "benchmark/glue/run.py"}]
out = V.classify_artifacts(tree)
assert out[0]["category"] == "benchmark"
def test_classify_ignores_unrelated():
tree = [
{"path": "src/main.py"},
{"path": "README.md"},
{"path": "tools/util.go"},
]
out = V.classify_artifacts(tree)
assert out == [] # 都不命中任何类别
def test_classify_dedup_same_path():
tree = [
{"path": "data/a.csv"},
{"path": "data/a.csv"}, # 重复
]
out = V.classify_artifacts(tree)
assert len(out) == 1
def test_classify_handles_name_only():
# 没有 path 只有 name 的条目也能处理
tree = [{"name": "paper.pdf"}]
out = V.classify_artifacts(tree)
assert len(out) == 1
assert out[0]["category"] == "paper"
def test_classify_empty_and_non_dict():
assert V.classify_artifacts([]) == []
assert V.classify_artifacts(None) == []
assert V.classify_artifacts(["str", 123, None]) == []
def test_artifact_summary():
arts = [
{"path": "a.pdf", "category": "paper"},
{"path": "b.ipynb", "category": "paper"},
{"path": "x.csv", "category": "dataset"},
{"path": "m.ckpt", "category": "model"},
]
s = V.artifact_summary(arts)
assert s == {"paper": 2, "dataset": 1, "model": 1, "benchmark": 0}
# ---------------------------------------------------------------------------
# render_report不渲染 plotly只验证 markdown 结构)
# ---------------------------------------------------------------------------
def test_render_report_has_sections():
result = {
"repo": "o/r", "weeks": 4,
"timeline": {"labels": ["W1", "W2", "W3", "W4"],
"commits": [1, 2, 3, 4], "issues": [0, 1, 0, 2],
"prs": [0, 0, 1, 0]},
"heatmap": {"users": ["alice", "bob"], "weeks": ["W1", "W2"],
"matrix": [[1, 2], [0, 1]]},
"languages": {"Python": "99%"},
"milestones": [],
"paper_links": [{"type": "arxiv", "target": "https://arxiv.org/abs/2401.00012",
"source_text_snippet": "see arxiv"}],
"artifacts": [{"path": "p.pdf", "category": "paper", "name": "p.pdf"}],
"artifact_summary": {"paper": 1, "dataset": 0, "model": 0, "benchmark": 0},
"meta": {"commit_count": 10, "issue_count": 3, "pr_count": 1,
"contributor_count": 2, "milestone_count": 0},
}
md = V.render_report(result)
assert "科研成果可视化" in md
assert "o/r" in md
assert "alice" in md
assert "arxiv.org/abs/2401.00012" in md
# 含周快照表头
assert "commits" in md
# ---------------------------------------------------------------------------
# 端到端算法层run() 复用 raw dict不联网
# ---------------------------------------------------------------------------
def test_run_with_mock_raw():
raw = {
"commits": [{"timestamp": _days_ago_iso(2), "author": {"login": "alice"},
"message": "see https://arxiv.org/abs/2401.00012"}],
"issues": [{"created_at": _days_ago_iso(3)}],
"prs": [{"pr_created_unix": int(_days_ago_ts(4))}],
"milestones": [{"name": "v1.0", "due_on": _days_ago_iso(30)}],
"languages": {"Python": "99%"},
"contributors": [{"login": "alice", "contributions": 1}],
"readme": "ref https://doi.org/10.1000/182 here",
"tree": [{"path": "data/x.csv"}, {"path": "paper.pdf"}],
}
result = V.run("owner", "repo", weeks=4, raw=raw)
assert result["scenario"] == "S6_research_visualization"
assert result["repo"] == "owner/repo"
assert result["weeks"] == 4
# 论文链接同时来自 readme 和 commit message
targets = {p["target"] for p in result["paper_links"]}
assert "https://arxiv.org/abs/2401.00012" in targets
assert "https://doi.org/10.1000/182" in targets
# 产物分类
cats = {a["category"] for a in result["artifacts"]}
assert cats == {"dataset", "paper"}
# 时间线长度 = weeks
assert len(result["timeline"]["labels"]) == 4
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for fn in fns:
fn()
print(f"PASS {fn.__name__}")
print(f"\nAll {len(fns)} visual tests passed.")
if __name__ == "__main__":
_run_all()