Files
StudioLift/tests/test_build_studio_urls.py

401 lines
18 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
# 文档化锚点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
# ---------------------------------------------------------------------------
# parse_countries国家列解析
# ---------------------------------------------------------------------------
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_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, {})