# -*- 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.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 # --------------------------------------------------------------------------- # 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_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_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, {})