# -*- coding: utf-8 -*- """看板指标纯函数测试(不联网): uv run python tests/test_analytics.py""" import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.stdout.reconfigure(encoding="utf-8") from reaction_board.analytics import ( # noqa: E402 DEFAULT_HEAT_WEIGHTS, METRICS, age_days, compute_growth, content_hours, eb_rate, effective_shrink_minutes, heat_scores, interactions_per_hour, interaction_rate, is_loopback, is_mature, median_of, normalize_cover, normalized_weights, percentile, robust_center_scale, score_percentiles, views_per_hour, ) def row(aid, **metrics): base = {metric: 0 for metric in METRICS} base.update(metrics) base["aid"] = aid return base def test_percentile_and_robust_scale(): assert percentile([], 0.5) == 0.0 assert percentile([7], 0.25) == 7.0 assert percentile([1, 2, 3, 4], 0.5) == 2.5 assert percentile([1, 2, 3, 4], 0) == 1.0 assert percentile([1, 2, 3, 4], 1) == 4.0 center, scale = robust_center_scale([1, 2, 3, 4, 5]) assert center == 3.0 assert scale > 0 # IQR 为 0 时退化为总体标准差 center, scale = robust_center_scale([10, 10, 10, 20]) assert center == 10.0 assert scale > 0 # 全相同则 scale=0,调用方把 z 视为 0 assert robust_center_scale([5, 5, 5]) == (5.0, 0.0) assert robust_center_scale([]) == (0.0, 0.0) def test_normalized_weights(): weights = normalized_weights() assert abs(sum(weights.values()) - 1.0) < 1e-12 assert abs(weights["view"] - DEFAULT_HEAT_WEIGHTS["view"]) < 1e-12 doubled = normalized_weights({metric: value * 2 for metric, value in DEFAULT_HEAT_WEIGHTS.items()}) assert doubled == weights zero = normalized_weights({metric: 0 for metric in METRICS}) assert all(value == 0 for value in zero.values()) def test_heat_scores_order_and_empty(): rows = [ row(1, view=100, like=10, coin=5, favorite=4, share=1, reply=1, danmaku=2), row(2, view=1000, like=100, coin=50, favorite=40, share=10, reply=10, danmaku=20), row(3, view=10_000, like=1000, coin=500, favorite=400, share=100, reply=100, danmaku=200), ] scores = heat_scores(rows) assert set(scores) == {1, 2, 3} assert scores[1] < scores[2] < scores[3] assert heat_scores([]) == {} # 所有指标完全相同:所有热度为 0,而不是除零 same = heat_scores([row(1, view=5), row(2, view=5)]) assert same == {1: 0.0, 2: 0.0} def test_heat_scores_weights_and_missing_values(): rows = [ row(1, view=100), row(2, view=200), ] view_only = heat_scores(rows, {"view": 1.0}) assert view_only[2] > view_only[1] assert all(isinstance(value, float) for value in view_only.values()) # 缺失/非法指标按 0 处理,不抛异常 rows = [ {"aid": 1, "view": None, "like": "bad"}, {"aid": 2, "view": 10}, ] scores = heat_scores(rows) assert scores[2] > scores[1] def test_score_percentiles(): assert score_percentiles({}) == {} assert score_percentiles({5: 1.0}) == {5: 100.0} result = score_percentiles({1: 0.0, 2: 1.0, 3: 2.0}) assert result[1] == 0.0 assert result[2] == 50.0 assert result[3] == 100.0 def test_interaction_rate_thresholds(): assert interaction_rate({"view": 99, "like": 1, "coin": 1, "favorite": 1}) is None assert interaction_rate({"view": 100, "like": 1, "coin": 1, "favorite": 2}) == 0.04 assert interaction_rate({"view": 1000, "like": 10}) == 0.01 assert interaction_rate({"view": 0, "like": 10}) is None assert interaction_rate({"view": None}) is None assert interaction_rate({"view": 100, "like": None, "coin": "x"}) == 0.0 def test_compute_growth(): unavailable = compute_growth(100, None, 7) assert unavailable.available is False assert unavailable.delta is None no_gap = compute_growth(100, 90, 0) assert no_gap.available is False growth = compute_growth(1000, 800, 7) assert growth.available is True assert growth.delta == 200 assert growth.per_day == 200 / 7 assert abs(growth.rate - 0.25) < 1e-12 assert growth.gap_days == 7 # 基线为 0 时用 1 做分母,避免除零 from_zero = compute_growth(50, 0, 3) assert from_zero.delta == 50 assert from_zero.rate == 50.0 # 累计指标倒退(接口修正)时保留负值,由调用方决定是否展示 negative = compute_growth(90, 100, 2) assert negative.delta == -10 assert negative.per_day == -5.0 def test_normalize_cover_and_hosts(): assert normalize_cover(None) is None assert normalize_cover("") is None assert normalize_cover(" ") is None assert normalize_cover(123) is None assert normalize_cover("//i0.hdslb.com/a.jpg") == "https://i0.hdslb.com/a.jpg" assert normalize_cover("http://i0.hdslb.com/a.jpg") == "https://i0.hdslb.com/a.jpg" assert normalize_cover("https://i0.hdslb.com/a.jpg") == "https://i0.hdslb.com/a.jpg" assert is_loopback("127.0.0.1") assert is_loopback("localhost") assert is_loopback("::1") assert not is_loopback("0.0.0.0") assert not is_loopback("192.168.1.2") def test_content_efficiency_helpers(): assert content_hours(3600) == 1.0 assert content_hours(7200) == 2.0 assert content_hours(0) is None assert content_hours(-1) is None assert content_hours(None) is None assert content_hours(True) is None assert content_hours("bad") is None assert views_per_hour(1000, 3600) == 1000.0 assert views_per_hour(1000, 7200) == 500.0 assert views_per_hour(None, 3600) == 0.0 assert views_per_hour(1000, None) is None assert views_per_hour(1000, 0) is None metrics = {"like": 30, "coin": 10, "favorite": 20, "view": 999} assert interactions_per_hour(metrics, 3600) == 60.0 assert interactions_per_hour(metrics, 7200) == 30.0 assert interactions_per_hour(None, 3600) is None assert interactions_per_hour(metrics, None) is None # 单项缺失按 0 处理,不抛异常 assert interactions_per_hour({"like": None, "coin": "bad"}, 3600) == 0.0 def test_age_and_maturity(): reference = 1_800_000_000 assert age_days(reference - 86400 * 10, reference) == 10 assert age_days(reference, reference) == 0 # 未来时间不产生负年龄 assert age_days(reference + 86400, reference) == 0 assert age_days(None, reference) is None assert age_days(0, reference) is None assert age_days("bad", reference) is None assert is_mature(reference - 86400 * 14, reference, 14) is True assert is_mature(reference - 86400 * 13, reference, 14) is False assert is_mature(None, reference, 14) is False assert is_mature(reference - 86400 * 14, reference, 0) is True def test_shrinkage_helpers(): assert median_of([]) is None assert median_of([None, "bad", True]) is None assert median_of([1, 3, 2]) == 2.0 # 自适应 κ 取各 UP 总分钟的中位数 assert effective_shrink_minutes([10, 30, 20]) == 20.0 assert effective_shrink_minutes([0, -5, None]) == 120.0 assert effective_shrink_minutes([]) == 120.0 assert effective_shrink_minutes([], fallback=60) == 60.0 # κ 越大越靠近先验;exposure 为 0 时直接返回先验 assert abs(eb_rate(0, 0, 0.5, 100) - 0.5) < 1e-12 assert eb_rate(100, 100, 1.0, 0) == 1.0 assert eb_rate(100, 0, 1.0, 0) is None # (100 + 100*0.5) / (100 + 100) = 0.75 assert abs(eb_rate(100, 100, 0.5, 100) - 0.75) < 1e-12 assert eb_rate(None, 100, 0.5, 100) is None try: eb_rate(1, 1, 0.5, -1) except ValueError: pass else: raise AssertionError("负 prior_strength 必须报错") def main_test(): test_percentile_and_robust_scale() test_normalized_weights() test_heat_scores_order_and_empty() test_heat_scores_weights_and_missing_values() test_score_percentiles() test_interaction_rate_thresholds() test_compute_growth() test_normalize_cover_and_hosts() test_content_efficiency_helpers() test_age_and_maturity() test_shrinkage_helpers() print("OK: analytics 热度/增长/互动率/时长效率/收缩测试全部通过") if __name__ == "__main__": main_test()