为 build_studio_urls.py 添加指标和维度的可配置支持,包括: - 新增 metrics.json 和 dimensions.json 映射文件加载 - 支持中文/英文别名映射及指标代码透传 - 列别名扩展以识别"指标"和"维度"列 - 空值时自动回退到 CONFIG 默认值
570 lines
26 KiB
Python
570 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""build_studio_urls.py 单元测试。
|
||
|
||
覆盖纯逻辑函数(不落盘、不起子进程):
|
||
ts() 日期 -> 日界线毫秒(锚点 + 整日偏移)
|
||
parse_period() 数据周期解析(多种写法、含首尾日)
|
||
normalize_columns() 输入列名别名映射
|
||
load_country_map() 国家映射加载
|
||
parse_countries() 国家列解析(中文名/ISO 码/多分隔符)
|
||
resolve_entity_type() 实体类型解析
|
||
build_url() URL 拼接(参数完整性、编码、缺字段错误)
|
||
|
||
CLI 端到端流程见 test_build_studio_urls_cli.py。
|
||
"""
|
||
|
||
import math
|
||
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
from conftest import COUNTRIES_JSON, DIMENSIONS_JSON, METRICS_JSON
|
||
|
||
# 文档化锚点:2026-06-15 = 1781506800000(见 docs/adr/0001,脚本内 ANCHOR_MS)
|
||
ANCHOR_MS = 1781506800000
|
||
MS_PER_DAY = 86400000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ts:日界线毫秒
|
||
# ---------------------------------------------------------------------------
|
||
class TestTs:
|
||
def test_anchor_date(self, url_builder):
|
||
assert url_builder.ts(2026, 6, 15) == ANCHOR_MS
|
||
|
||
def test_next_day(self, url_builder):
|
||
assert url_builder.ts(2026, 6, 16) == ANCHOR_MS + MS_PER_DAY
|
||
|
||
def test_prev_day(self, url_builder):
|
||
assert url_builder.ts(2026, 6, 14) == ANCHOR_MS - MS_PER_DAY
|
||
|
||
def test_month_boundary(self, url_builder):
|
||
assert url_builder.ts(2026, 7, 1) == url_builder.ts(2026, 6, 30) + MS_PER_DAY
|
||
|
||
def test_year_boundary(self, url_builder):
|
||
assert url_builder.ts(2027, 1, 1) == url_builder.ts(2026, 12, 31) + MS_PER_DAY
|
||
|
||
def test_anchor_matches_module_constant(self, url_builder):
|
||
assert url_builder.ts(2026, 6, 15) == url_builder.ANCHOR_MS
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# parse_period:数据周期
|
||
# ---------------------------------------------------------------------------
|
||
class TestParsePeriod:
|
||
def test_standard_dot_format(self, url_builder):
|
||
start, end = url_builder.parse_period("2026.07.01-2026.08.01")
|
||
assert start == url_builder.ts(2026, 7, 1)
|
||
assert end == url_builder.ts(2026, 8, 1) + MS_PER_DAY # 结束日包含
|
||
|
||
def test_single_digit_month_day(self, url_builder):
|
||
assert url_builder.parse_period("2026.7.1-2026.8.1") == \
|
||
url_builder.parse_period("2026.07.01-2026.08.01")
|
||
|
||
def test_dash_date_with_tilde(self, url_builder):
|
||
start, end = url_builder.parse_period("2026-07-01~2026-08-01")
|
||
assert start == url_builder.ts(2026, 7, 1)
|
||
assert end == url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||
|
||
def test_full_width_tilde(self, url_builder):
|
||
start, _ = url_builder.parse_period("2026.07.01~2026.08.01")
|
||
assert start == url_builder.ts(2026, 7, 1)
|
||
|
||
def test_spaces_around_separator(self, url_builder):
|
||
start, _ = url_builder.parse_period("2026.07.01 - 2026.08.01")
|
||
assert start == url_builder.ts(2026, 7, 1)
|
||
|
||
def test_same_day_period(self, url_builder):
|
||
start, end = url_builder.parse_period("2026.07.01-2026.07.01")
|
||
assert start == url_builder.ts(2026, 7, 1)
|
||
assert end == start + MS_PER_DAY # 单日区间跨度正好一天
|
||
|
||
def test_end_date_inclusive(self, url_builder):
|
||
"""结束日包含在范围内:time_period 结束值 = 结束日次日日界线。"""
|
||
_, end = url_builder.parse_period("2026.07.01-2026.07.31")
|
||
assert end == url_builder.ts(2026, 8, 1)
|
||
|
||
@pytest.mark.parametrize("bad", [
|
||
"", # 空
|
||
"2026.07.01", # 只有起始日
|
||
"20260701-20260801", # 无分隔符
|
||
"abc-def", # 非日期
|
||
"2026.07-2026.08", # 缺日
|
||
"2026.07.01 至 2026.08.01", # 不支持的连接词
|
||
])
|
||
def test_invalid_format_raises(self, url_builder, bad):
|
||
with pytest.raises(ValueError):
|
||
url_builder.parse_period(bad)
|
||
|
||
def test_invalid_month_raises(self, url_builder):
|
||
with pytest.raises(ValueError):
|
||
url_builder.parse_period("2026.13.01-2026.08.01")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# normalize_columns:列名别名映射
|
||
# ---------------------------------------------------------------------------
|
||
class TestNormalizeColumns:
|
||
def test_chinese_aliases(self, url_builder):
|
||
df = pd.DataFrame(columns=["所有者名称", "所有者ID", "群组名称", "群组ID",
|
||
"实体类型", "数据周期", "国家"])
|
||
mapping, unknown = url_builder.normalize_columns(df)
|
||
assert mapping == {
|
||
"owner_name": "所有者名称", "owner_id": "所有者ID",
|
||
"entity_name": "群组名称", "entity_id": "群组ID",
|
||
"entity_type": "实体类型", "period": "数据周期", "countries": "国家",
|
||
}
|
||
assert unknown == []
|
||
|
||
def test_english_aliases(self, url_builder):
|
||
df = pd.DataFrame(columns=["owner_name", "owner_id", "group_name", "group_id",
|
||
"entity_type", "period", "country"])
|
||
mapping, unknown = url_builder.normalize_columns(df)
|
||
assert mapping == {
|
||
"owner_name": "owner_name", "owner_id": "owner_id",
|
||
"entity_name": "group_name", "entity_id": "group_id",
|
||
"entity_type": "entity_type", "period": "period", "countries": "country",
|
||
}
|
||
assert unknown == []
|
||
|
||
def test_short_aliases(self, url_builder):
|
||
"""所有者ID 的超短别名 o、实体ID 的别名 id。"""
|
||
df = pd.DataFrame(columns=["o", "id", "period"])
|
||
mapping, _ = url_builder.normalize_columns(df)
|
||
assert mapping["owner_id"] == "o"
|
||
assert mapping["entity_id"] == "id"
|
||
|
||
def test_column_name_normalized_before_lookup(self, url_builder):
|
||
"""列名先去空白、转小写再匹配:' Owner_ID ' 可识别。"""
|
||
df = pd.DataFrame(columns=[" Owner_ID ", "Period"])
|
||
mapping, _ = url_builder.normalize_columns(df)
|
||
assert mapping["owner_id"] == " Owner_ID "
|
||
assert mapping["period"] == "Period"
|
||
|
||
def test_unknown_columns_reported(self, url_builder):
|
||
df = pd.DataFrame(columns=["所有者ID", "数据周期", "备注", "extra"])
|
||
mapping, unknown = url_builder.normalize_columns(df)
|
||
assert "owner_id" in mapping and "period" in mapping
|
||
assert unknown == ["备注", "extra"]
|
||
|
||
def test_country_slash_alias(self, url_builder):
|
||
df = pd.DataFrame(columns=["国家/地区"])
|
||
mapping, _ = url_builder.normalize_columns(df)
|
||
assert mapping["countries"] == "国家/地区"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# load_country_map:国家映射
|
||
# ---------------------------------------------------------------------------
|
||
class TestLoadCountryMap:
|
||
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||
path = tmp_path / "not_exists.json"
|
||
assert url_builder.load_country_map(str(path)) == {}
|
||
assert "未找到国家映射文件" in capsys.readouterr().err
|
||
|
||
def test_bom_file_loads(self, url_builder, tmp_path):
|
||
"""utf-8-sig(Excel 另存带 BOM)可正常加载。"""
|
||
p = tmp_path / "c.json"
|
||
p.write_bytes('{"美国": "us"}'.encode("utf-8-sig"))
|
||
assert url_builder.load_country_map(str(p)) == {"美国": "US"}
|
||
|
||
def test_values_trimmed_and_uppercased(self, url_builder, tmp_path):
|
||
p = tmp_path / "c.json"
|
||
p.write_text('{ "美国" : " us " , "日本" : "jp" }', encoding="utf-8")
|
||
assert url_builder.load_country_map(str(p)) == {"美国": "US", "日本": "JP"}
|
||
|
||
def test_real_countries_json(self, real_countries):
|
||
assert real_countries["美国"] == "US"
|
||
assert real_countries["日本"] == "JP"
|
||
assert real_countries["中国台湾"] == "TW"
|
||
assert real_countries["香港"] == "HK"
|
||
# 真实映射规模足够覆盖常用地区
|
||
assert len(real_countries) > 80
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# load_metric_map / parse_metric:指标映射与解析
|
||
# ---------------------------------------------------------------------------
|
||
class TestLoadMetricMap:
|
||
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||
path = tmp_path / "not_exists.json"
|
||
assert url_builder.load_metric_map(str(path)) == {}
|
||
assert "未找到指标映射文件" in capsys.readouterr().err
|
||
|
||
def test_bom_file_loads(self, url_builder, tmp_path):
|
||
p = tmp_path / "m.json"
|
||
p.write_bytes('{"订阅净增长": "subscribers_net_change"}'.encode("utf-8-sig"))
|
||
assert url_builder.load_metric_map(str(p))["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
|
||
|
||
def test_known_code_passthrough_added(self, url_builder, tmp_path):
|
||
p = tmp_path / "m.json"
|
||
p.write_text("{}", encoding="utf-8")
|
||
m = url_builder.load_metric_map(str(p))
|
||
assert m["subscribers_net_change"] == "SUBSCRIBERS_NET_CHANGE"
|
||
assert m["total_estimated_earnings"] == "TOTAL_ESTIMATED_EARNINGS"
|
||
|
||
def test_real_metrics_json(self, real_metrics):
|
||
assert real_metrics["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
|
||
assert real_metrics["观看时长"] == "EXTERNAL_WATCH_TIME"
|
||
assert real_metrics["预计收益"] == "TOTAL_ESTIMATED_EARNINGS"
|
||
# 全量:估算的合作伙伴收入 + 新增「税前收益/税前收入」别名
|
||
assert real_metrics["估算的合作伙伴收入"] == "TOTAL_ESTIMATED_EARNINGS"
|
||
assert real_metrics["税前收益"] == "TOTAL_ESTIMATED_EARNINGS"
|
||
assert real_metrics["税前收入"] == "TOTAL_ESTIMATED_EARNINGS"
|
||
# 覆盖足够多的指标(>50 条)
|
||
assert len(real_metrics) > 50
|
||
|
||
|
||
class TestParseMetric:
|
||
@pytest.mark.parametrize("empty", [None, "", float("nan")])
|
||
def test_blank_returns_none(self, url_builder, empty):
|
||
assert url_builder.parse_metric(empty, {}) is None
|
||
|
||
def test_known_code_passthrough_case_insensitive(self, url_builder):
|
||
assert url_builder.parse_metric("SUBSCRIBERS_NET_CHANGE", {}) == "SUBSCRIBERS_NET_CHANGE"
|
||
assert url_builder.parse_metric("subscribers_net_change", {}) == "SUBSCRIBERS_NET_CHANGE"
|
||
|
||
def test_chinese_name_mapped(self, url_builder):
|
||
assert url_builder.parse_metric("订阅净增长", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
|
||
== "SUBSCRIBERS_NET_CHANGE"
|
||
|
||
def test_english_synonym_mapped(self, url_builder):
|
||
assert url_builder.parse_metric("watch_time", {"watch_time": "EXTERNAL_WATCH_TIME"}) \
|
||
== "EXTERNAL_WATCH_TIME"
|
||
|
||
def test_quotes_stripped(self, url_builder):
|
||
assert url_builder.parse_metric("'订阅净增长'", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
|
||
== "SUBSCRIBERS_NET_CHANGE"
|
||
|
||
def test_unknown_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的指标"):
|
||
url_builder.parse_metric("神秘指标", {})
|
||
|
||
def test_real_map_common(self, url_builder, real_metrics):
|
||
assert url_builder.parse_metric("平均观看时长", real_metrics) == "AVERAGE_WATCH_TIME"
|
||
assert url_builder.parse_metric("收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||
|
||
def test_estimated_partner_revenue_aliases(self, url_builder, real_metrics):
|
||
assert url_builder.parse_metric("估算的合作伙伴收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||
assert url_builder.parse_metric("税前收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||
assert url_builder.parse_metric("税前收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# load_dimension_map / parse_dimension:维度映射与解析
|
||
# ---------------------------------------------------------------------------
|
||
class TestLoadDimensionMap:
|
||
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||
path = tmp_path / "not_exists.json"
|
||
assert url_builder.load_dimension_map(str(path)) == {}
|
||
assert "未找到维度映射文件" in capsys.readouterr().err
|
||
|
||
def test_known_code_passthrough_added(self, url_builder, tmp_path):
|
||
p = tmp_path / "d.json"
|
||
p.write_text("{}", encoding="utf-8")
|
||
d = url_builder.load_dimension_map(str(p))
|
||
assert d["user"] == "USER"
|
||
|
||
def test_real_dimensions_json(self, real_dimensions):
|
||
assert real_dimensions["内容"] == "VIDEO"
|
||
assert real_dimensions["地理位置"] == "COUNTRY"
|
||
assert real_dimensions["频道"] == "USER"
|
||
assert len(real_dimensions) >= 30
|
||
|
||
|
||
class TestParseDimension:
|
||
@pytest.mark.parametrize("empty", [None, "", float("nan")])
|
||
def test_blank_returns_none(self, url_builder, empty):
|
||
assert url_builder.parse_dimension(empty, {}) is None
|
||
|
||
def test_known_code_passthrough_case_insensitive(self, url_builder):
|
||
# 仅 CONFIG 默认维度(USER)在无映射时也能透传
|
||
assert url_builder.parse_dimension("USER", {}) == "USER"
|
||
assert url_builder.parse_dimension("user", {}) == "USER"
|
||
|
||
def test_chinese_name_mapped(self, url_builder):
|
||
assert url_builder.parse_dimension("地理位置", {"地理位置": "COUNTRY"}) == "COUNTRY"
|
||
|
||
def test_quotes_stripped(self, url_builder):
|
||
assert url_builder.parse_dimension("'内容'", {"内容": "VIDEO"}) == "VIDEO"
|
||
|
||
def test_unknown_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的维度"):
|
||
url_builder.parse_dimension("神秘维度", {})
|
||
|
||
def test_real_map_common(self, url_builder, real_dimensions):
|
||
assert url_builder.parse_dimension("内容", real_dimensions) == "VIDEO"
|
||
assert url_builder.parse_dimension("设备类型", real_dimensions) == "DEVICE_PLATFORM_TYPE"
|
||
|
||
|
||
|
||
class TestParseCountries:
|
||
def test_none_returns_empty(self, url_builder):
|
||
assert url_builder.parse_countries(None, {}) == []
|
||
|
||
def test_nan_returns_empty(self, url_builder):
|
||
assert url_builder.parse_countries(float("nan"), {}) == []
|
||
|
||
def test_empty_string_returns_empty(self, url_builder):
|
||
assert url_builder.parse_countries("", {}) == []
|
||
|
||
def test_single_iso_code(self, url_builder):
|
||
assert url_builder.parse_countries("US", {}) == ["US"]
|
||
|
||
def test_lowercase_iso_uppercased(self, url_builder):
|
||
assert url_builder.parse_countries("us", {}) == ["US"]
|
||
|
||
def test_chinese_name_mapped(self, url_builder):
|
||
assert url_builder.parse_countries("美国", {"美国": "US"}) == ["US"]
|
||
|
||
@pytest.mark.parametrize("sep", [",", ",", "、", ";", ";", " ", "|"])
|
||
def test_multiple_separators(self, url_builder, sep):
|
||
text = sep.join(["美国", "日本"])
|
||
assert url_builder.parse_countries(text, {"美国": "US", "日本": "JP"}) == ["US", "JP"]
|
||
|
||
def test_mixed_code_and_chinese(self, url_builder):
|
||
assert url_builder.parse_countries("US,日本", {"日本": "JP"}) == ["US", "JP"]
|
||
|
||
def test_quotes_stripped(self, url_builder):
|
||
assert url_builder.parse_countries("'US' \"JP\"", {}) == ["US", "JP"]
|
||
|
||
def test_unknown_name_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的国家"):
|
||
url_builder.parse_countries("亚特兰蒂斯", {"美国": "US"})
|
||
|
||
def test_chinese_name_without_map_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的国家"):
|
||
url_builder.parse_countries("美国", {})
|
||
|
||
def test_real_map_common_countries(self, url_builder, real_countries):
|
||
assert url_builder.parse_countries("美国,日本,英国", real_countries) == ["US", "JP", "GB"]
|
||
assert url_builder.parse_countries("中国台湾、香港", real_countries) == ["TW", "HK"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# resolve_entity_type:实体类型
|
||
# ---------------------------------------------------------------------------
|
||
class TestResolveEntityType:
|
||
@pytest.mark.parametrize("text,expected", [
|
||
("群组", "GROUP"), ("GROUP", "GROUP"),
|
||
("所有者", "CONTENT_OWNER"), ("账号", "CONTENT_OWNER"), ("CONTENT_OWNER", "CONTENT_OWNER"),
|
||
("频道", "CHANNEL"), ("CHANNEL", "CHANNEL"),
|
||
("节目", "VIDEO"), ("视频", "VIDEO"), ("VIDEO", "VIDEO"),
|
||
])
|
||
def test_known_types(self, url_builder, text, expected):
|
||
assert url_builder.resolve_entity_type(text) == expected
|
||
|
||
@pytest.mark.parametrize("empty", ["", None, float("nan")])
|
||
def test_empty_defaults_to_group(self, url_builder, empty):
|
||
assert url_builder.resolve_entity_type(empty) == "GROUP"
|
||
|
||
def test_whitespace_only_defaults_to_group(self, url_builder):
|
||
assert url_builder.resolve_entity_type(" ") == "GROUP"
|
||
|
||
def test_unknown_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的实体类型"):
|
||
url_builder.resolve_entity_type("星球")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_url:URL 拼接
|
||
# ---------------------------------------------------------------------------
|
||
def make_row(**overrides):
|
||
row = {
|
||
"owner_name": "示例所有者",
|
||
"owner_id": "MC123",
|
||
"entity_name": "示例群组",
|
||
"entity_id": "NCy9C2QPQ1E",
|
||
"entity_type": "",
|
||
"period": "2026.07.01-2026.08.01",
|
||
"countries": "",
|
||
}
|
||
row.update(overrides)
|
||
return row
|
||
|
||
|
||
class TestBuildUrl:
|
||
def test_full_url_exact(self, url_builder):
|
||
"""完整 URL 精确匹配(含参数顺序与编码)。"""
|
||
row = make_row(countries="美国,日本")
|
||
url, status, msg = url_builder.build_url(row, {"美国": "US", "日本": "JP"})
|
||
assert (status, msg) == ("ok", "")
|
||
|
||
c = url_builder.CONFIG
|
||
start = url_builder.ts(2026, 7, 1)
|
||
end = url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||
expected = (
|
||
"https://studio.youtube.com/owner/MC123/analytics/tab-overview/period-default/explore"
|
||
f"?o=MC123&entity_type=GROUP&entity_id=NCy9C2QPQ1E"
|
||
f"&ur_dimensions=COUNTRY&ur_values=%27US%27%7C%27JP%27"
|
||
f"&ur_inclusive_starts=&ur_exclusive_ends="
|
||
f"&time_period={start}%2C{end}"
|
||
f"&explore_type={c['explore_type']}&metric={c['metric']}&granularity={c['granularity']}"
|
||
+ "".join(f"&t_metrics={m}" for m in c["t_metrics"])
|
||
+ f"&dimension={c['dimension']}&o_column={c['o_column']}"
|
||
f"&o_direction={c['o_direction']}&comparison_type={c['comparison_type']}"
|
||
)
|
||
assert url == expected
|
||
|
||
def test_url_parts(self, url_builder):
|
||
url, status, _ = url_builder.build_url(make_row(countries="US"), {})
|
||
assert status == "ok"
|
||
assert url.startswith(
|
||
"https://studio.youtube.com/owner/MC123/analytics/tab-overview/period-default/explore?")
|
||
assert "o=MC123" in url
|
||
assert "entity_type=GROUP" in url # 空实体类型默认 GROUP
|
||
assert "entity_id=NCy9C2QPQ1E" in url
|
||
assert "ur_dimensions=COUNTRY" in url
|
||
assert "ur_values=%27US%27" in url # 单国同样用 %27 包裹
|
||
assert "granularity=DAY" in url
|
||
assert "dimension=USER" in url
|
||
assert "o_direction=ANALYTICS_ORDER_DIRECTION_DESC" in url
|
||
|
||
def test_time_period_values(self, url_builder):
|
||
url, _, _ = url_builder.build_url(make_row(), {})
|
||
start = url_builder.ts(2026, 7, 1)
|
||
end = url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||
assert "time_period=%d%%2C%d" % (start, end) in url # 逗号编码为 %2C
|
||
|
||
def test_t_metrics_repeat_per_config(self, url_builder):
|
||
url, _, _ = url_builder.build_url(make_row(), {})
|
||
assert url.count("t_metrics=") == len(url_builder.CONFIG["t_metrics"])
|
||
for m in url_builder.CONFIG["t_metrics"]:
|
||
assert "t_metrics=%s" % m in url
|
||
|
||
# ---- 指标(每行可选)----
|
||
def test_metric_blank_uses_config_default(self, url_builder):
|
||
"""指标列留空:主指标与排序字段回退 CONFIG 默认(向后兼容)。"""
|
||
url, status, _ = url_builder.build_url(make_row(), {})
|
||
assert status == "ok"
|
||
assert "metric=%s" % url_builder.CONFIG["metric"] in url
|
||
assert "o_column=%s" % url_builder.CONFIG["o_column"] in url
|
||
|
||
def test_metric_chinese_sets_metric_and_o_column(self, url_builder, real_metrics):
|
||
"""指标列选中"预计收益":metric 与 o_column 都跟随所选指标。"""
|
||
url, status, _ = url_builder.build_url(
|
||
make_row(metric="预计收益"), {}, real_metrics)
|
||
assert status == "ok"
|
||
assert "metric=TOTAL_ESTIMATED_EARNINGS" in url
|
||
assert "o_column=TOTAL_ESTIMATED_EARNINGS" in url
|
||
|
||
def test_metric_code_passthrough(self, url_builder):
|
||
"""指标列填已知代码:原样透传。"""
|
||
url, status, _ = url_builder.build_url(
|
||
make_row(metric="EXTERNAL_VIEWS"), {})
|
||
assert status == "ok"
|
||
assert "metric=EXTERNAL_VIEWS" in url
|
||
assert "o_column=EXTERNAL_VIEWS" in url
|
||
|
||
def test_metric_unknown_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的指标"):
|
||
url_builder.build_url(make_row(metric="神秘指标"), {}, {})
|
||
|
||
def test_metric_case_insensitive_code(self, url_builder):
|
||
url, status, _ = url_builder.build_url(make_row(metric="external_views"), {})
|
||
assert status == "ok"
|
||
assert "metric=EXTERNAL_VIEWS" in url
|
||
|
||
# ---- 维度(每行可选)----
|
||
def test_dimension_blank_uses_config_default(self, url_builder):
|
||
"""维度列留空:回退 CONFIG 默认(向后兼容)。"""
|
||
url, status, _ = url_builder.build_url(make_row(), {})
|
||
assert status == "ok"
|
||
assert "dimension=%s" % url_builder.CONFIG["dimension"] in url
|
||
|
||
def test_dimension_chinese_sets_dimension(self, url_builder, real_dimensions):
|
||
url, status, _ = url_builder.build_url(
|
||
make_row(dimension="地理位置"), {}, dimension_map=real_dimensions)
|
||
assert status == "ok"
|
||
assert "dimension=COUNTRY" in url
|
||
|
||
def test_dimension_code_passthrough(self, url_builder, real_dimensions):
|
||
url, status, _ = url_builder.build_url(
|
||
make_row(dimension="video"), {}, dimension_map=real_dimensions)
|
||
assert status == "ok"
|
||
assert "dimension=VIDEO" in url
|
||
|
||
def test_dimension_unknown_raises(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的维度"):
|
||
url_builder.build_url(make_row(dimension="神秘维度"), {}, {})
|
||
|
||
def test_no_country_omits_country_params(self, url_builder):
|
||
url, _, _ = url_builder.build_url(make_row(), {})
|
||
assert "ur_dimensions" not in url
|
||
assert "ur_values" not in url
|
||
assert "ur_inclusive_starts" not in url
|
||
|
||
def test_entity_type_channel(self, url_builder):
|
||
url, _, _ = url_builder.build_url(
|
||
make_row(entity_type="频道", entity_id="UCxyz"), {})
|
||
assert "entity_type=CHANNEL" in url
|
||
assert "entity_id=UCxyz" in url
|
||
|
||
def test_content_owner_falls_back_to_owner_id(self, url_builder):
|
||
"""所有者整体场景:实体ID留空时回退用所有者ID。"""
|
||
row = make_row(entity_type="CONTENT_OWNER", entity_id="")
|
||
url, status, msg = url_builder.build_url(row, {})
|
||
assert (status, msg) == ("ok", "")
|
||
assert "entity_type=CONTENT_OWNER" in url
|
||
assert "entity_id=MC123" in url
|
||
|
||
def test_missing_owner_id(self, url_builder):
|
||
url, status, msg = url_builder.build_url(make_row(owner_id=""), {})
|
||
assert url is None
|
||
assert status == "error"
|
||
assert msg == "缺少所有者ID"
|
||
|
||
def test_missing_entity_id_for_group(self, url_builder):
|
||
url, status, msg = url_builder.build_url(make_row(entity_id=""), {})
|
||
assert url is None
|
||
assert status == "error"
|
||
assert msg == "缺少实体ID"
|
||
|
||
def test_missing_entity_id_for_channel(self, url_builder):
|
||
url, status, msg = url_builder.build_url(
|
||
make_row(entity_type="频道", entity_id=""), {})
|
||
assert url is None
|
||
assert status == "error"
|
||
assert msg == "缺少实体ID"
|
||
|
||
def test_missing_period(self, url_builder):
|
||
url, status, msg = url_builder.build_url(make_row(period=""), {})
|
||
assert url is None
|
||
assert status == "error"
|
||
assert msg == "缺少数据周期"
|
||
|
||
def test_bad_period_raises_value_error(self, url_builder):
|
||
with pytest.raises(ValueError):
|
||
url_builder.build_url(make_row(period="not-a-period"), {})
|
||
|
||
def test_unknown_country_raises_value_error(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的国家"):
|
||
url_builder.build_url(make_row(countries="火星"), {"美国": "US"})
|
||
|
||
def test_unknown_entity_type_raises_value_error(self, url_builder):
|
||
with pytest.raises(ValueError, match="未识别的实体类型"):
|
||
url_builder.build_url(make_row(entity_type="星球"), {})
|
||
|
||
def test_owner_id_checked_before_entity(self, url_builder):
|
||
"""所有者ID 缺失时报错优先于实体ID。"""
|
||
_, status, msg = url_builder.build_url(make_row(owner_id="", entity_id=""), {})
|
||
assert (status, msg) == ("error", "缺少所有者ID")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 其他:nan 判断辅助行为(build_url 入参可能来自 pandas)
|
||
# ---------------------------------------------------------------------------
|
||
class TestNanHandling:
|
||
def test_period_nan_string_is_error(self, url_builder):
|
||
"""main() 会把 NaN 转 '',这里验证空串路径报「缺少数据周期」。"""
|
||
_, status, msg = url_builder.build_url(make_row(period=""), {})
|
||
assert msg == "缺少数据周期"
|
||
|
||
def test_parse_countries_nan(self, url_builder):
|
||
assert url_builder.parse_countries(float("nan"), {}) == []
|
||
|
||
def test_plain_float_treated_as_text(self, url_builder):
|
||
"""普通 float(如 3.14)不是 NaN:走字符串解析分支,非两位代码则报未识别。"""
|
||
with pytest.raises(ValueError, match="未识别的国家"):
|
||
url_builder.parse_countries(3.14, {})
|