Files
re-xxm/tests/test_analytics.py
T
kairee 39a6537f67 fix(growth): rank the rate board by a smoothed score
Show the real percentage on the card, but sort with delta / (baseline + prior) so a small baseline cannot dominate. Same-day publishes stay off the rate board.
2026-11-02 22:06:41 +08:00

278 lines
9.3 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.
# -*- 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,
smoothed_growth_rate,
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
same_day = compute_growth(100, 90, 0, allow_zero_gap=True)
assert same_day.available is True
assert same_day.delta == 10
assert same_day.per_day is None
assert same_day.gap_days == 0
assert abs(same_day.rate - 10 / 90) < 1e-12
from_zero_same_day = compute_growth(50, 0, 0, allow_zero_gap=True)
assert from_zero_same_day.delta == 50
assert from_zero_same_day.rate == 50.0
assert from_zero_same_day.per_day is None
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_smoothed_growth_rate():
# 80→512:真实增速 5.4;prior=500 时排序分 432/580
assert abs(smoothed_growth_rate(432, 80, 500) - 432 / 580) < 1e-12
assert abs(smoothed_growth_rate(432, 5000, 500) - 432 / 5500) < 1e-12
assert abs(smoothed_growth_rate(432, 80, 1000) - 432 / 1080) < 1e-12
# prior=0 时排序分退回真实相对增速(基线 ≥ 1)
assert smoothed_growth_rate(432, 80, 0) == 432 / 80
assert smoothed_growth_rate(None, 80, 500) is None
assert smoothed_growth_rate(432, None, 500) is None
assert smoothed_growth_rate(10, 0, 0) is None
assert smoothed_growth_rate(10, 80, -1) is None
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_smoothed_growth_rate()
test_normalize_cover_and_hosts()
test_content_efficiency_helpers()
test_age_and_maturity()
test_shrinkage_helpers()
print("OK: analytics 热度/增长/互动率/时长效率/收缩测试全部通过")
if __name__ == "__main__":
main_test()