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.
278 lines
9.3 KiB
Python
278 lines
9.3 KiB
Python
# -*- 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()
|