Files
StudioLift/tests/test_build_studio_urls.py
Sidney Zhang b8bd749f43 feat(scripts): 支持动态指标和维度参数配置
为 build_studio_urls.py 添加指标和维度的可配置支持,包括:
- 新增 metrics.json 和 dimensions.json 映射文件加载
- 支持中文/英文别名映射及指标代码透传
- 列别名扩展以识别"指标"和"维度"列
- 空值时自动回退到 CONFIG 默认值
2026-08-24 15:28:48 +08:00

570 lines
26 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 -*-
"""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.012026.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-sigExcel 另存带 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_urlURL 拼接
# ---------------------------------------------------------------------------
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, {})