feat: complete production workflow migration

This commit is contained in:
2026-08-06 14:29:57 +08:00
parent 7f215e79c4
commit 8df5266abb
448 changed files with 56937 additions and 14619 deletions
@@ -0,0 +1,18 @@
"""Test bootstrap for the flattened product-commerce module."""
from __future__ import annotations
import sys
from pathlib import Path
MODULE_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
# The migrated scripts intentionally retain their original sibling imports.
if str(MODULE_ROOT) not in sys.path:
sys.path.insert(0, str(MODULE_ROOT))
@@ -0,0 +1,74 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
import pytest
from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
PROJECT_ROOT = Path(__file__).resolve().parents[3]
COMMAND_PATH = (
PROJECT_ROOT
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "commands"
/ "run_alerts.py"
)
def _load_command(
monkeypatch: pytest.MonkeyPatch,
observed: dict[str, object],
) -> ModuleType:
legacy = ModuleType("run_alerts_with_retry")
def fake_main() -> int:
observed["argv"] = tuple(sys.argv)
return 0
legacy.main = fake_main # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "run_alerts_with_retry", legacy)
spec = importlib.util.spec_from_file_location("test_run_alerts_command", COMMAND_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_alert_command_uses_canonical_acceptance_recipient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: dict[str, object] = {}
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01")
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
monkeypatch.delenv("GYXX_ALERT_RECIPIENT_OPEN_ID", raising=False)
module = _load_command(monkeypatch, observed)
assert module.main() == 0
assert observed["argv"] == (
sys.argv[0],
"--end-date",
"2026-08-01",
"--openid",
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
)
def test_alert_command_accepts_canonical_production_recipient(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: dict[str, object] = {}
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01")
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.setenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "ou_configured")
monkeypatch.setenv("GYXX_ALERT_RECIPIENT_OPEN_ID", "ou_legacy")
module = _load_command(monkeypatch, observed)
assert module.main() == 0
assert observed["argv"][-1] == "ou_configured"
@@ -0,0 +1,362 @@
from __future__ import annotations
import subprocess
import sys
from datetime import date
from pathlib import Path
from types import ModuleType
import check_nine_day_decline as decline
import pytest
import run_alerts_with_retry as retry
PAIR = ("tm", "测试款")
def _segment(sales: int, *, days: int = 3) -> dict:
return {
"sales": sales,
"present_days": days,
"erp_style_codes": ["STYLE-1"],
}
def _event() -> dict:
return {
"style_name": "测试款",
"platform": "tm",
"erp_style_codes": ["STYLE-1"],
"window_start": "2026-07-24",
"window_end": "2026-08-01",
"seg1_sales": 300,
"seg2_sales": 210,
"seg3_sales": 90,
"drop_pct_1to2": 30.0,
"drop_pct_2to3": 57.1,
"drop_pct_1to3": 70.0,
}
def _prepare_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"check_nine_day_decline.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
"--data-root",
str(tmp_path),
],
)
monkeypatch.setattr(decline, "managed_data_path", lambda value: Path(value))
monkeypatch.setattr(decline, "append_log", lambda line: None)
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
def test_detect_decline_uses_three_inclusive_postgres_sum_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: list[list[tuple[date, date]]] = []
def fake_load(windows):
observed.append(windows)
return [
{PAIR: _segment(300)},
{PAIR: _segment(210)},
{PAIR: _segment(90)},
]
monkeypatch.setattr(decline, "_load_segment_snapshots", fake_load)
events = decline.detect_decline("2026-08-01", 9)
assert observed == [
[
(date(2026, 7, 24), date(2026, 7, 26)),
(date(2026, 7, 27), date(2026, 7, 29)),
(date(2026, 7, 30), date(2026, 8, 1)),
]
]
assert len(events) == 1
assert events[0]["window_start"] == "2026-07-24"
assert events[0]["window_end"] == "2026-08-01"
assert [events[0][f"seg{index}_sales"] for index in range(1, 4)] == [
300,
210,
90,
]
assert events[0]["segment_windows"] == [
{"start": "2026-07-24", "end": "2026-07-26", "days": 3},
{"start": "2026-07-27", "end": "2026-07-29", "days": 3},
{"start": "2026-07-30", "end": "2026-08-01", "days": 3},
]
def test_detect_decline_rejects_incomplete_three_day_segment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
decline,
"_load_segment_snapshots",
lambda windows: [
{PAIR: _segment(300)},
{PAIR: _segment(210, days=2)},
{PAIR: _segment(90)},
],
)
assert decline.detect_decline("2026-08-01", 9) == []
def test_segment_loader_uses_postgres_range_sum_on_one_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_db = ModuleType("db")
connection = object()
observed: list[tuple[object, str, str]] = []
class ConnectionContext:
def __enter__(self):
return connection
def __exit__(self, *args):
return False
def fake_fetch(conn, start, end):
observed.append((conn, start, end))
return {}
fake_db.get_conn = ConnectionContext # type: ignore[attr-defined]
fake_db.fetch_daily_for_range = fake_fetch # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "db", fake_db)
windows = decline._segment_windows("2026-08-01", 9)
assert decline._load_segment_snapshots(windows) == [{}, {}, {}]
assert observed == [
(connection, "2026-07-24", "2026-07-26"),
(connection, "2026-07-27", "2026-07-29"),
(connection, "2026-07-30", "2026-08-01"),
]
@pytest.mark.parametrize(
("response", "expected"),
[
({"id": "chatcmpl-not-a-receipt"}, None),
({"message_id": "om_direct123"}, "om_direct123"),
({"data": {"message_id": "om_nested123"}}, "om_nested123"),
(
{"choices": [{"message": {"content": "飞书回执 om_content123"}}]},
"om_content123",
),
(
{"choices": [{"message": {"content": "发送成功,但没有回执"}}]},
None,
),
],
)
def test_extract_feishu_message_id_requires_real_receipt_shape(
response: dict,
expected: str | None,
) -> None:
assert decline.extract_feishu_message_id(response) == expected
def test_main_fails_when_postgres_detection_read_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(
decline,
"detect_decline",
lambda *args: (_ for _ in ()).throw(RuntimeError("postgres unavailable")),
)
assert decline.main() == 1
def test_main_zero_events_has_explicit_postcondition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [])
assert decline.main() == 0
output = capsys.readouterr().out
assert "detection_source=postgresql events=0" in output
assert "notification=not_required persistence=not_required" in output
def test_main_dedup_query_failure_is_fail_closed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(
decline,
"_already_notified",
lambda *args: (_ for _ in ()).throw(RuntimeError("db timeout")),
)
notify = pytest.fail
monkeypatch.setattr(decline, "notify_hermes", notify)
assert decline.main() == 5
def test_main_does_not_accept_arbitrary_non_error_hermes_text(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {
"id": "chatcmpl-123",
"choices": [{"message": {"content": "发送成功"}}],
},
)
persist = pytest.fail
monkeypatch.setattr(decline, "_persist_events", persist)
assert decline.main() == 6
def test_main_returns_nonzero_when_receipt_persistence_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
monkeypatch.setattr(
decline,
"_persist_events",
lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("write failed")),
)
assert decline.main() == 4
def test_main_success_requires_and_persists_feishu_receipt(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
_prepare_main(monkeypatch, tmp_path)
observed: dict[str, object] = {}
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
def fake_persist(*args, **kwargs):
observed.update(kwargs)
return 1
monkeypatch.setattr(decline, "_persist_events", fake_persist)
assert decline.main() == 0
assert observed["message_id"] == "om_real123"
assert observed["openid"] == "ou_owner"
output = capsys.readouterr().out
assert "notification=sent feishu_message_id=om_real123" in output
assert "persistence=committed history_rows=1" in output
class _FailedCollector:
def __init__(self, *args):
self.pages: list[tuple] = []
def add_page(self, *args, **kwargs):
self.pages.append((args, kwargs))
def write(self):
return None
def summary_line(self):
return "failure"
def test_retry_wrapper_does_not_repeat_after_possible_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[list[str]] = []
monkeypatch.setattr(
sys,
"argv",
[
"run_alerts_with_retry.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
],
)
monkeypatch.setattr(retry, "FailedCollector", _FailedCollector)
monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None)
monkeypatch.setattr(retry, "build_child_environment", lambda *args: {})
monkeypatch.setattr(
retry.time,
"sleep",
lambda seconds: pytest.fail("non-retryable result must not sleep"),
)
def fake_run(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 4)
monkeypatch.setattr(retry.subprocess, "run", fake_run)
assert retry.main() == 4
assert len(calls) == 1
def test_retry_wrapper_can_retry_fail_closed_dedup_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
return_codes = iter((5, 0))
sleeps: list[int] = []
monkeypatch.setattr(
sys,
"argv",
[
"run_alerts_with_retry.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_owner",
],
)
monkeypatch.setattr(retry, "FailedCollector", _FailedCollector)
monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None)
monkeypatch.setattr(retry, "build_child_environment", lambda *args: {})
monkeypatch.setattr(retry.time, "sleep", sleeps.append)
monkeypatch.setattr(
retry.subprocess,
"run",
lambda command, **kwargs: subprocess.CompletedProcess(
command,
next(return_codes),
),
)
assert retry.main() == 0
assert sleeps == [retry.RUN_SLEEP_SECONDS]
@@ -0,0 +1,210 @@
from __future__ import annotations
import backfill_poseidon_sales as backfill
import pandas as pd
def test_backfill_uses_layered_runtime_roots():
assert backfill.DOWNLOAD_ROOT == backfill.RAW_DATA_ROOT
assert backfill.PLATFORM_OUTPUT_ROOT == backfill.NORMALIZED_DATA_ROOT
assert backfill.FINAL_OUTPUT_ROOT == backfill.CURATED_DATA_ROOT
assert backfill.REPORT_OUTPUT_ROOT == backfill.EXPORTS_DATA_ROOT
def test_extract_jd_uses_existing_pipeline_metric_definitions():
frame = pd.DataFrame(
[
{
"时间": "2026-07-01",
"商品ID": "10035289085755",
"访客数": "1,234",
"加购人数": "23",
"成交单量": "7",
"成交商品件数": "9",
"取消及售后退款单量": "2",
}
]
)
result = backfill.extract_metrics("jd", frame, ["10035289085755"], "2026-07-01")
assert result["matched_ids"] == ["10035289085755"]
assert result["metrics"] == {
"sales": 7,
"visitors": 1234,
"cart_users": 23,
"refund_orders": 2,
}
def test_extract_dy_ignores_weekly_period_and_uses_deal_people():
frame = pd.DataFrame(
[
{
"统计周期": "2026/07/01-2026/07/07",
"商品编码": "3830202762700915010",
"商品点击人数": "999",
"加购人数": "99",
"成交人数": "88",
"成交订单数": "100",
"退款订单数(支付时间)": "9",
},
{
"统计周期": "2026/07/07",
"商品编码": "3830202762700915010",
"商品点击人数": "42",
"加购人数": "10",
"成交人数": "3",
"成交订单数": "4",
"退款订单数(支付时间)": "1",
},
]
)
result = backfill.extract_metrics("dy", frame, ["3830202762700915010"], "2026-07-07")
assert result["matched_row_count"] == 1
assert result["metrics"] == {
"sales": 3,
"visitors": 42,
"cart_users": 10,
"refund_orders": 1,
}
def test_extract_tm_sums_configured_ids_and_uses_paid_items():
frame = pd.DataFrame(
[
{
"统计日期": "2026-07-25",
"商品ID": "1061408417997",
"商品访客数": "395",
"商品加购人数": "17",
"支付买家数": "3",
"支付件数": "4",
},
{
"统计日期": "2026-07-25",
"商品ID": "1063521443835",
"商品访客数": "5",
"商品加购人数": "1",
"支付买家数": "1",
"支付件数": "2",
},
]
)
result = backfill.extract_metrics(
"tm",
frame,
["1060420778652", "1061408417997", "1063521443835"],
"2026-07-25",
)
assert result["matched_ids"] == ["1061408417997", "1063521443835"]
assert result["missing_ids"] == ["1060420778652"]
assert result["metrics"] == {
"sales": 6,
"visitors": 400,
"cart_users": 18,
"refund_orders": 0,
}
def test_build_final_payload_preserves_excel_lineage():
parsed = {
"jd": {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": ["jd-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "jd.xlsx",
},
"dy": {
"metrics": {"sales": 2, "visitors": 20, "cart_users": 3, "refund_orders": 1},
"matched_ids": ["dy-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "dy.xlsx",
},
"tm": {
"metrics": {"sales": 3, "visitors": 30, "cart_users": 4, "refund_orders": 0},
"matched_ids": ["tm-1"],
"missing_ids": [],
"matched_row_count": 1,
"source_file": "tm.xls",
},
}
payload = backfill.build_final_payload("2026-07-01", parsed, ["10515"])
assert payload["style_name"] == backfill.STYLE_NAME
assert payload["erp"]["style_codes"] == ["10515"]
assert payload["jd"]["sales"] == 1
assert payload["dy"]["source"] == "dy.xlsx"
assert payload["tm"]["matched_product_ids"] == ["tm-1"]
def test_render_final_markdown_contains_all_three_platforms():
parsed = {
"jd": {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": ["jd-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "jd.xlsx",
},
"dy": {
"metrics": {"sales": 2, "visitors": 20, "cart_users": 3, "refund_orders": 1},
"matched_ids": ["dy-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "dy.xlsx",
},
"tm": {
"metrics": {"sales": 3, "visitors": 30, "cart_users": 4, "refund_orders": 0},
"matched_ids": ["tm-1"], "missing_ids": [], "matched_row_count": 1, "source_file": "tm.xls",
},
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
markdown = backfill.render_final_markdown(payload)
assert "| jd | 1 | 0 | 10 | 2 |" in markdown
assert "| dy | 2 | 1 | 20 | 3 |" in markdown
assert "| tm | 3 | 0 | 30 | 4 |" in markdown
def test_write_final_summary_updates_json_and_markdown(tmp_path, monkeypatch):
monkeypatch.setattr(backfill, "FINAL_OUTPUT_ROOT", tmp_path)
parsed = {
platform: {
"metrics": {"sales": 1, "visitors": 10, "cart_users": 2, "refund_orders": 0},
"matched_ids": [platform], "missing_ids": [], "matched_row_count": 1, "source_file": f"{platform}.xlsx",
}
for platform in ("jd", "dy", "tm")
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
json_path = backfill.write_final_summary(payload)
markdown_path = json_path.with_suffix(".md")
assert json_path.exists()
assert markdown_path.exists()
assert "| dy | 1 | 0 | 10 | 2 |" in markdown_path.read_text(encoding="utf-8-sig")
def test_build_db_rows_expands_every_platform():
parsed = {
platform: {
"metrics": {"sales": index, "visitors": 10 * index, "cart_users": index, "refund_orders": 0},
"matched_ids": [platform], "missing_ids": [], "matched_row_count": 1, "source_file": f"{platform}.xlsx",
}
for index, platform in enumerate(("jd", "dy", "tm"), start=1)
}
payload = backfill.build_final_payload("2026-07-16", parsed, ["10515"])
rows = backfill.build_db_rows([payload])
assert len(rows) == 3
assert {(row["metric_date"], row["platform"]) for row in rows} == {
("2026-07-16", "jd"),
("2026-07-16", "dy"),
("2026-07-16", "tm"),
}
assert next(row for row in rows if row["platform"] == "tm")["sales"] == 3
@@ -0,0 +1,207 @@
from __future__ import annotations
import inspect
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import collect_erp_yesterday_metrics as erp
import collect_jd_persona_to_bitable as jd_persona
from runtime_paths import playwright_launch_options
class _Chromium:
def __init__(self) -> None:
self.kwargs = None
def launch_persistent_context(self, **kwargs):
self.kwargs = kwargs
return object()
def test_jd_persona_launches_the_managed_persistent_profile(
monkeypatch,
tmp_path: Path,
) -> None:
profile = tmp_path / "jd-profile"
monkeypatch.setenv("GYXX_BROWSER_PROFILE_DIR", str(profile))
chromium = _Chromium()
context = jd_persona._launch_jd_context(chromium, headless=True)
assert context is not None
assert chromium.kwargs["user_data_dir"] == str(profile.resolve())
assert chromium.kwargs["headless"] is True
def test_jd_persona_reuses_logged_in_page_without_credentials() -> None:
calls: list[tuple[str, str]] = []
class Page:
url = "https://shop.jd.com/jdm/home"
def goto(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, _milliseconds):
return None
def locator(self, _selector):
return SimpleNamespace(
inner_text=lambda **_kwargs: "京东商家后台 商品明细",
)
jd_persona._ensure_jd_session(
Page(),
shop="",
password="",
step_login=lambda _page, shop, password: calls.append((shop, password)),
)
assert calls == []
def test_erp_uses_managed_profile_when_bound_cdp_is_not_running(
monkeypatch,
tmp_path: Path,
) -> None:
profile = tmp_path / "erp-profile"
monkeypatch.setattr(erp, "_cdp_is_listening", lambda _url: False)
cdp_url, user_data_dir = erp.resolve_browser_session(
{"cdp_url": "http://127.0.0.1:9222"},
{
"GYXX_BROWSER_CDP_URL": "http://127.0.0.1:22063",
"GYXX_BROWSER_PROFILE_DIR": str(profile),
},
)
assert cdp_url is None
assert user_data_dir == str(profile)
def test_erp_attaches_only_to_the_live_managed_cdp(monkeypatch) -> None:
monkeypatch.setattr(erp, "_cdp_is_listening", lambda _url: True)
cdp_url, user_data_dir = erp.resolve_browser_session(
{"cdp_url": "http://127.0.0.1:9222", "user_data_dir": "legacy"},
{
"GYXX_BROWSER_CDP_URL": "http://127.0.0.1:22063",
"GYXX_BROWSER_PROFILE_DIR": "managed",
},
)
assert cdp_url == "http://127.0.0.1:22063"
assert user_data_dir is None
def test_erp_main_fails_before_browser_when_no_style_is_collectable(monkeypatch) -> None:
args = SimpleNamespace(
config="unused.json",
target_date=date(2026, 8, 3),
mode="daily",
refresh_config=False,
input="unused-styles.json",
)
monkeypatch.setattr(erp, "parse_args", lambda: args)
monkeypatch.setattr(
erp,
"load_config",
lambda _path: {"login": {"url": "https://erp.test", "username": "placeholder", "password": "placeholder"}},
)
monkeypatch.setattr(
erp,
"load_erp_styles",
lambda *_args: ([], {"source": "lark", "collectable": [], "skipped": []}),
)
monkeypatch.setattr(erp, "print_skip_summary", lambda _report: None)
monkeypatch.setattr(erp, "write_erp_skip_report", lambda *_args: None)
monkeypatch.setattr(
erp.DynamicFetcher,
"fetch",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("browser must not start")
),
)
assert erp.main() == 1
def test_erp_main_fails_when_collector_summary_contains_failures(
monkeypatch,
tmp_path: Path,
) -> None:
args = SimpleNamespace(
config="unused.json",
target_date=date(2026, 8, 3),
mode="daily",
refresh_config=False,
input="unused-styles.json",
data_dir=str(tmp_path),
platform="all",
no_cdp=False,
timeout_ms=1000,
limit=0,
report_timeout=1,
report_attempts=1,
include_slow_codes=False,
hold_seconds=0,
headless=True,
)
styles = [{"style_name": "style-a", "erp_style_codes": ["erp-a"], "brand": erp.DEFAULT_BRAND}]
monkeypatch.setattr(erp, "parse_args", lambda: args)
monkeypatch.setattr(
erp,
"load_config",
lambda _path: {"login": {"url": "https://erp.test", "username": "placeholder", "password": "placeholder"}},
)
monkeypatch.setattr(
erp,
"load_erp_styles",
lambda *_args: (styles, {"source": "lark", "collectable": ["style-a"], "skipped": []}),
)
monkeypatch.setattr(erp, "print_skip_summary", lambda _report: None)
monkeypatch.setattr(erp, "write_erp_skip_report", lambda *_args: None)
monkeypatch.setattr(erp, "managed_data_path", lambda _path: tmp_path)
monkeypatch.setattr(erp, "resolve_browser_session", lambda _config: (None, None))
def fake_login_action(**kwargs):
state = kwargs["state"]
def action(_page):
state["ok"] = True
return action
monkeypatch.setattr(erp, "make_page_action", fake_login_action)
monkeypatch.setattr(
erp,
"collect_in_page",
lambda *_args, **_kwargs: {
"reports": 1,
"valid_codes": 1,
"has_failures": True,
},
)
monkeypatch.setattr(
erp.DynamicFetcher,
"fetch",
lambda _url, **kwargs: kwargs["page_action"](object()),
)
assert erp.main() == 1
def test_erp_date_inputs_are_required_and_read_back() -> None:
source = inspect.getsource(erp.set_platform_and_code)
assert "const begin = await waitFor('#order_date_begin')" in source
assert "const end = await waitFor('#order_date_end')" in source
assert "date range did not persist" in source
def test_playwright_channel_is_optional_and_environment_driven() -> None:
assert playwright_launch_options({}) == {}
assert playwright_launch_options({"GYXX_PLAYWRIGHT_CHANNEL": "chrome"}) == {
"channel": "chrome"
}
@@ -0,0 +1,81 @@
from __future__ import annotations
from types import SimpleNamespace
import insert_bitable_records as insert
import pytest
def test_record_id_parser_accepts_nested_upsert_receipt() -> None:
envelope = {
"ok": True,
"data": {"record": {"record_id_list": ["rec-confirmed"]}},
}
assert insert._record_id_from_envelope(envelope) == "rec-confirmed"
def test_lark_upsert_rejects_success_without_record_id(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
monkeypatch.setattr(insert.shutil, "which", lambda _name: "lark-cli.cmd")
monkeypatch.setattr(
insert.subprocess,
"run",
lambda *_args, **_kwargs: SimpleNamespace(
returncode=0,
stdout='{"ok": true, "data": {}}',
stderr="",
),
)
with pytest.raises(RuntimeError, match="without a confirmed record_id"):
insert.run_lark_upsert("base", "table", {"field": "value"})
def _daily_record(style: str, source_id: str) -> dict:
return {
"数据日期": "2026-08-03",
"平台": "京东",
"平台代码": "jd",
"款式": style,
"SourceID": source_id,
"metric": 1,
}
def test_any_feishu_insert_failure_makes_whole_command_fail(monkeypatch) -> None:
style_map = {
style: {
"base_token": "base",
"table_id": "table",
"enabled": True,
"field_map": {"metric": "metric", "SourceID": "SourceID"},
}
for style in ("style-a", "style-b")
}
monkeypatch.setattr(insert, "get_bitable_style_map", lambda: style_map)
monkeypatch.setattr(
insert,
"load_records",
lambda *_args, **_kwargs: [
_daily_record("style-a", "source-a"),
_daily_record("style-b", "source-b"),
],
)
monkeypatch.setattr(insert, "_find_record_id_by_source_id", lambda *_args, **_kwargs: None)
def upsert(_base, _table, fields, **_kwargs):
if fields["SourceID"] == "source-b":
raise RuntimeError("write failed")
return "rec-a"
monkeypatch.setattr(insert, "run_lark_upsert", upsert)
assert insert.main(["--date", "2026-08-03"]) == 1
def test_empty_feishu_export_is_not_success(monkeypatch) -> None:
monkeypatch.setattr(insert, "get_bitable_style_map", lambda: {"style-a": {}})
monkeypatch.setattr(insert, "load_records", lambda *_args, **_kwargs: [])
assert insert.main(["--date", "2026-08-03"]) == 1
@@ -0,0 +1,13 @@
from __future__ import annotations
import aggregate_daily_final as aggregate
import export_bitable_records as export
def test_daily_aggregate_and_export_share_curated_summary_root() -> None:
assert export.SUMMARY_ROOT == aggregate.CURATED_DATA_ROOT
def test_daily_export_keeps_platform_details_in_raw_root() -> None:
assert export.PLATFORM_ROOT == export.DATA_ROOT
assert export.PLATFORM_ROOT != export.SUMMARY_ROOT
@@ -0,0 +1,428 @@
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import aggregate_daily_final as aggregate
import db as product_db
import import_product_daily as product_import
import orchestrate_daily_collection as daily
import pytest
from commands import import_daily as import_daily_command
class _Context:
def __init__(self, value):
self.value = value
def __enter__(self):
return self.value
def __exit__(self, *_args):
return False
class _Cursor:
def __init__(self, rows):
self.rows = rows
self.execute_args = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, *args):
self.execute_args = args
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return list(self.rows)
class _Connection:
def __init__(self, rows):
self.cursor_instance = _Cursor(rows)
def cursor(self):
return self.cursor_instance
def _record() -> dict:
return {
"platform": "dy",
"stat_date": datetime(2026, 8, 3).date(),
"product_id": "product-1",
"source_file": "report.xlsx",
}
def _patch_valid_import(monkeypatch, *, upserted: int = 1) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [Path("report.xlsx")])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [_record()])
monkeypatch.setattr(product_import, "get_conn", lambda: _Context(object()))
monkeypatch.setattr(
product_import,
"upsert_product_daily_metrics",
lambda _conn, _records: upserted,
)
def test_selected_platform_collector_failure_stops_daily_aggregation(monkeypatch) -> None:
results = iter(
[
{"name": "jd", "code": 0},
{"name": "dy", "code": 1, "error": "collector failed"},
]
)
monkeypatch.setattr(daily, "run_step_with_retry", lambda *_args, **_kwargs: next(results))
monkeypatch.setattr(daily, "verify_platform_artifacts", lambda *_args: 1)
with pytest.raises(RuntimeError, match="required platform collector.*dy"):
daily.run_platforms(
parallel=False,
platforms=["jd", "dy"],
target_date="2026-08-03",
)
def test_platform_selection_keeps_omitted_collectors_optional(monkeypatch) -> None:
calls: list[str] = []
def fake_run(name, *_args, **_kwargs):
calls.append(name)
return {"name": name, "code": 0}
monkeypatch.setattr(daily, "run_step_with_retry", fake_run)
monkeypatch.setattr(daily, "verify_platform_artifacts", lambda *_args: 1)
results = daily.run_platforms(
parallel=False,
platforms=["jd"],
target_date="2026-08-03",
)
assert calls == ["jd"]
assert results == [{"name": "jd", "code": 0}]
def test_successful_collector_exit_still_requires_fresh_artifact(monkeypatch) -> None:
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *_args, **_kwargs: {"name": "jd", "code": 0},
)
monkeypatch.setattr(
daily,
"verify_platform_artifacts",
lambda *_args: (_ for _ in ()).throw(RuntimeError("missing-or-stale")),
)
with pytest.raises(RuntimeError, match="missing-or-stale"):
daily.run_platforms(
parallel=False,
platforms=["jd"],
target_date="2026-08-03",
)
@pytest.mark.parametrize(
("platform", "payload"),
[
(
"jd",
{
"采集日期": "2026-08-03",
"matched_row_count": 0,
"download_file": "jd.xlsx",
},
),
(
"dy",
{
"date_range": "2026-08-03 ~ 2026-08-03",
"matched_row_count": 0,
"download_file": "dy.xlsx",
},
),
(
"tm",
{
"date": "2026-08-03",
"source_file": "tm.xlsx",
"style": {"匹配商品ID数": 0},
},
),
],
)
def test_zero_matched_product_is_not_a_valid_artifact(platform, payload) -> None:
assert daily._artifact_has_valid_product(platform, payload, "2026-08-03") is False
def test_platform_artifact_verifier_rejects_missing_configured_style(
monkeypatch,
tmp_path,
) -> None:
class Loader:
def get_daily_styles(self):
return {"jd": {"collectable": ["style-a", "style-b"], "skipped": []}}
monkeypatch.setattr(daily, "StyleConfigLoader", Loader)
monkeypatch.setattr(daily, "DATA_ROOT", tmp_path)
marker_ns = time.time_ns()
path = daily._platform_artifact_path("jd", "style-a", "2026-08-03")
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({
"采集日期": "2026-08-03",
"matched_row_count": 1,
"download_file": "_downloads/2026-08-03/report.xlsx",
}),
encoding="utf-8",
)
with pytest.raises(RuntimeError, match="style-b:missing-or-stale"):
daily.verify_platform_artifacts("jd", "2026-08-03", marker_ns)
def test_platform_artifact_verifier_accepts_fresh_valid_product(
monkeypatch,
tmp_path,
) -> None:
class Loader:
def get_daily_styles(self):
return {"jd": {"collectable": ["style-a"], "skipped": []}}
monkeypatch.setattr(daily, "StyleConfigLoader", Loader)
monkeypatch.setattr(daily, "DATA_ROOT", tmp_path)
marker_ns = time.time_ns()
path = daily._platform_artifact_path("jd", "style-a", "2026-08-03")
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({
"采集日期": "2026-08-03",
"matched_row_count": 2,
"download_file": "_downloads/2026-08-03/report.xlsx",
}),
encoding="utf-8",
)
assert daily.verify_platform_artifacts("jd", "2026-08-03", marker_ns) == 1
def test_review_import_remains_explicitly_noncritical(monkeypatch) -> None:
args = SimpleNamespace(
skip_erp=True,
skip_platforms=False,
serial_platforms=False,
dry_run=False,
skip_reviews_import=False,
)
monkeypatch.setattr(daily, "run_platforms", lambda **_kwargs: [])
calls = []
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *args, **kwargs: calls.append((args, kwargs)) or {"name": args[0], "code": 1},
)
daily.run_collect(args, "2026-08-03", ["jd"], failed=None)
assert len(calls) == 1
assert calls[0][0][0] == "import_product_reviews"
assert calls[0][1]["critical"] is False
def test_formal_aggregation_fails_when_postgres_write_is_not_fresh(monkeypatch) -> None:
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
args = SimpleNamespace(dry_run=False)
monkeypatch.setattr(daily, "database_write_marker", lambda: marker)
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *_args, **_kwargs: {"name": "aggregate_daily_final", "code": 0},
)
monkeypatch.setattr(
daily,
"verify_daily_metrics_write",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
RuntimeError("daily aggregation produced no fresh PostgreSQL rows")
),
)
with pytest.raises(RuntimeError, match="no fresh PostgreSQL rows"):
daily.run_analyze(args, "2026-08-03", failed=None)
def test_dry_run_aggregation_does_not_require_postgres(monkeypatch) -> None:
args = SimpleNamespace(dry_run=True)
monkeypatch.setattr(
daily,
"database_write_marker",
lambda: pytest.fail("dry-run must not connect to PostgreSQL"),
)
monkeypatch.setattr(
daily,
"verify_daily_metrics_write",
lambda *_args: pytest.fail("dry-run must not verify PostgreSQL"),
)
calls = []
monkeypatch.setattr(
daily,
"run_step_with_retry",
lambda *args, **kwargs: calls.append((args, kwargs)) or {"name": args[0], "code": 0},
)
daily.run_analyze(args, "2026-08-03", failed=None)
assert calls[0][1]["dry_run"] is True
def test_aggregate_command_rejects_zero_style_outputs(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(aggregate, "collect_style_names", lambda *_args: [])
monkeypatch.setattr(aggregate, "managed_data_path", lambda _path: tmp_path)
assert aggregate.main([
"--date",
"2026-08-03",
"--data-root",
str(tmp_path),
]) == 1
def test_aggregate_command_rejects_partial_postgres_write(monkeypatch, tmp_path) -> None:
payload = {
"date": "2026-08-03",
"style_name": "style-a",
"erp": {"style_codes": ["erp-a"]},
"jd": {
"sales": 1,
"refund_orders": 0,
"visitors": 2,
"cart_users": 1,
"source": "jd.json",
},
"dy": {},
"tm": {},
}
monkeypatch.setattr(aggregate, "collect_style_names", lambda *_args: ["style-a"])
monkeypatch.setattr(aggregate, "managed_data_path", lambda _path: tmp_path)
monkeypatch.setattr(aggregate, "build_payload", lambda *_args: payload)
monkeypatch.setattr(aggregate, "write_outputs", lambda *_args: (tmp_path / "a", tmp_path / "b"))
monkeypatch.setattr(product_db, "get_conn", lambda: _Context(object()))
monkeypatch.setattr(product_db, "upsert_daily_metrics", lambda *_args: 0)
assert aggregate.main([
"--date",
"2026-08-03",
"--data-root",
str(tmp_path),
]) == 1
def test_postgres_verifier_rejects_stale_or_missing_rows(monkeypatch) -> None:
connection = _Connection([])
monkeypatch.setattr(daily, "get_conn", lambda: _Context(connection))
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
with pytest.raises(RuntimeError, match="missing fresh PostgreSQL rows"):
daily.verify_daily_metrics_write(
"2026-08-03",
marker,
required_platforms=["jd"],
expected_pairs={("jd", "style-a")},
)
assert connection.cursor_instance.execute_args[1] == (
"2026-08-03",
marker,
["jd"],
)
def test_postgres_verifier_requires_every_expected_platform_style(monkeypatch) -> None:
connection = _Connection([("jd", "style-a"), ("dy", "style-a")])
monkeypatch.setattr(daily, "get_conn", lambda: _Context(connection))
marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc)
with pytest.raises(RuntimeError, match="tm/style-a"):
daily.verify_daily_metrics_write(
"2026-08-03",
marker,
required_platforms=["jd", "dy", "tm"],
expected_pairs={
("jd", "style-a"),
("dy", "style-a"),
("tm", "style-a"),
},
)
def test_import_fails_when_selected_platform_has_no_target_directory(monkeypatch, capsys) -> None:
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "dy: 没有匹配所选日期范围的报表目录" in capsys.readouterr().err
def test_import_fails_when_target_directory_has_no_input_file(monkeypatch, capsys) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "目标目录中没有可导入的报表文件" in capsys.readouterr().err
def test_import_fails_when_input_has_no_valid_rows(monkeypatch, capsys) -> None:
target_dir = Path("2026-08-03")
monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir])
monkeypatch.setattr(product_import, "find_target_files", lambda _dir, _platform: [Path("report.xlsx")])
monkeypatch.setattr(product_import, "parse_records_for_date", lambda _platform, _dir: [])
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "报表中没有有效商品明细行" in capsys.readouterr().err
def test_import_fails_when_postgres_reports_zero_written_rows(monkeypatch, capsys) -> None:
_patch_valid_import(monkeypatch, upserted=0)
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 2
assert "PostgreSQL 未写入任何商品明细行" in capsys.readouterr().err
def test_valid_selected_platform_input_still_imports_successfully(monkeypatch) -> None:
_patch_valid_import(monkeypatch, upserted=1)
code = product_import.main(["--date", "2026-08-03", "--platforms", "dy"])
assert code == 0
def test_import_command_passes_target_date_and_propagates_failure(monkeypatch) -> None:
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-04")
invoked = []
monkeypatch.setattr(
import_daily_command,
"import_main",
lambda argv: invoked.append(argv) or 2,
)
assert import_daily_command.main() == 2
assert invoked == [["--date", "2026-08-03"]]
@@ -0,0 +1,42 @@
import unittest
from db import build_pg_config
class DatabaseConfigTests(unittest.TestCase):
def test_complete_config_is_parsed(self):
config = build_pg_config(
{
"PG_HOST": "db.example.test",
"PG_PORT": "5544",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "secret",
}
)
self.assertEqual(
config,
{
"host": "db.example.test",
"port": 5544,
"dbname": "warehouse",
"user": "collector",
"password": "secret",
},
)
def test_missing_final_config_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "PG_HOST"):
build_pg_config(
{
"PG_PORT": "5432",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "secret",
}
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,286 @@
import unittest
from unittest.mock import MagicMock, patch
from collect_dy_market_rank import (
TARGET_DY_MARKET_CATEGORIES,
append_feishu_category_table,
build_dy_category_table_xml,
build_dy_feishu_batch_xml,
build_dy_feishu_intro_xml,
is_excluded_product_title,
navigate_to_product_rank,
parse_market_product_rank_html,
parse_money_range_upper,
payment_metric_header_index,
require_payment_metric_header,
)
from collect_sycm_market_rank import build_feishu_batch_xml
SAMPLE_HTML = """
<table>
<thead>
<tr>
<th>排名</th><th>商品</th><th>店铺名称</th>
<th>用户支付金额</th><th>点击次数</th><th>成交件数</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr aria-hidden="true"><td>排名</td><td>商品</td><td></td><td></td></tr>
<tr class="aurora-table-row" data-row-key="3826298175627592082_1">
<td><div class="tagItem">首次上榜</div></td>
<td>
<img src="https://p9-aio.ecombdimg.com/product.jpg"/>
<div class="name-Gh2R04">测试旅行拉杆箱</div>
<div class="price-HH6TP1">价格带 <span>¥1889-¥2289</span></div>
</td>
<td>测试旗舰店</td>
<td><div>¥2,500万</div>-<div>¥5,000万</div></td>
<td>250万-500万</td><td>10万-25万</td><td>查看详情</td>
</tr>
<tr class="aurora-table-row" data-row-key="3826298175627592083_3">
<td><div>↑4</div></td>
<td>
<img src="https://p9-aio.ecombdimg.com/product-3.jpg"/>
<div elementtiming="pccp_element">第三名旅行箱</div>
<div>价格带 <span>¥999</span></div>
</td>
<td>第三名旗舰店</td>
<td><div>¥750万</div>-<div>¥1,000万</div></td>
<td>10万</td><td>1万</td><td>查看详情</td>
</tr>
</tbody>
</table>
"""
class DyMarketRankTests(unittest.TestCase):
def test_title_blacklist_filters_children_primary_and_middle_school(self):
self.assertTrue(is_excluded_product_title("儿童轻便双肩包"))
self.assertTrue(is_excluded_product_title("小学生护脊书包"))
self.assertTrue(is_excluded_product_title("初中生大容量背包"))
self.assertTrue(
is_excluded_product_title(
"TigerFamily【老爸抽检】学生生日礼物1-3年级男女护脊减负折叠书包"
)
)
self.assertTrue(
is_excluded_product_title(
"【官方旗舰】AIRJORDAN耐克青少年运动休闲男女大号双肩书包9300"
)
)
self.assertTrue(is_excluded_product_title("老人轻便防水随身包"))
self.assertTrue(is_excluded_product_title("高中生大容量双肩包"))
self.assertFalse(is_excluded_product_title("成人通勤双肩包"))
def test_default_categories_match_requested_eight_filters(self):
specs = {item["name"]: item for item in TARGET_DY_MARKET_CATEGORIES}
self.assertEqual(
set(specs),
{"运动包", "旅行箱", "托特包", "双肩包", "斜挎包", "胸包", "手机包", "电脑包"},
)
self.assertEqual((specs["运动包"]["min_price"], specs["运动包"]["max_price"]), (200, 1000))
self.assertEqual((specs["旅行箱"]["min_price"], specs["旅行箱"]["max_price"]), (400, 2000))
self.assertEqual((specs["托特包"]["min_price"], specs["托特包"]["max_price"]), (200, 3000))
self.assertEqual(specs["托特包"]["search_keyword"], "托特包")
self.assertEqual((specs["手机包"]["min_price"], specs["手机包"]["max_price"]), (100, 1000))
self.assertEqual(specs["手机包"]["paths"][0][-1], "耳机包")
self.assertEqual((specs["电脑包"]["min_price"], specs["电脑包"]["max_price"]), (200, 1000))
self.assertEqual(specs["电脑包"]["search_keyword"], "电脑包")
self.assertEqual(specs["电脑包"]["paths"][0][-2:], ("功能箱包", "全部"))
def test_parse_money_range_upper(self):
self.assertEqual(parse_money_range_upper("¥2,500万-¥5,000万"), 50_000_000)
self.assertEqual(parse_money_range_upper("¥750万"), 7_500_000)
def test_payment_metric_header_uses_exact_supported_aliases(self):
self.assertEqual(
payment_metric_header_index(["排名", "支付金额(元)", "成交人数"]),
1,
)
self.assertEqual(
payment_metric_header_index(["排名", "成交金额", "成交人数"]),
1,
)
self.assertIsNone(
payment_metric_header_index(["排名", "用户支付金额同比", "成交人数"])
)
def test_missing_payment_metric_reports_current_headers(self):
page = MagicMock()
page.locator.return_value.all_inner_texts.return_value = [
"排名",
"商品",
"成交人数",
]
with self.assertRaisesRegex(
RuntimeError,
"允许的精确候选.*当前表头.*成交人数",
):
require_payment_metric_header(page)
def test_login_probe_can_open_rank_page_without_waiting_for_metrics(self):
page = MagicMock()
with patch("collect_dy_market_rank.wait_for_product_rank_ready") as ready:
navigate_to_product_rank(page, wait_until_ready=False)
page.goto.assert_called_once()
ready.assert_not_called()
def test_parser_accepts_supported_payment_metric_alias(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML.replace("用户支付金额", "支付金额(元)"),
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual(len(rows), 2)
def test_parse_rank_html_extracts_requested_fields(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual(len(rows), 2)
row = rows[0]
self.assertEqual(row["rank"], 1)
self.assertEqual(row["product_id"], "3826298175627592082")
self.assertEqual(row["name"], "测试旅行拉杆箱")
self.assertEqual(row["image_url"], "https://p9-aio.ecombdimg.com/product.jpg")
self.assertEqual(row["price_range"], "¥1889-¥2289")
self.assertEqual(row["sales_amount_range"], "¥2,500万-¥5,000万")
self.assertEqual(row["sales_amount_upper"], 50_000_000)
self.assertEqual(
row["product_url"],
"https://haohuo.jinritemai.com/views/product/item2?id=3826298175627592082",
)
self.assertEqual(rows[1]["rank"], 3)
def test_parse_rank_html_drops_blacklisted_title(self):
rows = parse_market_product_rank_html(
SAMPLE_HTML.replace("测试旅行拉杆箱", "小学生测试旅行拉杆箱"),
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)
self.assertEqual([row["name"] for row in rows], ["第三名旅行箱"])
def test_dy_feishu_xml_contains_sales_image_and_link(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
xml = build_dy_feishu_batch_xml([product], start_rank=1)
self.assertIn(">销售额</th>", xml)
self.assertIn("¥2,500万-¥5,000万", xml)
self.assertIn('<img href="https://p9-aio.ecombdimg.com/product.jpg"', xml)
self.assertIn("https://haohuo.jinritemai.com/views/product/item2?id=3826298175627592082", xml)
def test_category_output_is_one_table_per_category(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
xml = build_dy_category_table_xml("旅行箱", [product, dict(product, rank=2)])
self.assertEqual(xml.count("<table>"), 1)
self.assertEqual(xml.count("<th "), 7)
self.assertIn("<h2>旅行箱(2条)</h2>", xml)
self.assertIn("<td>1</td>", xml)
self.assertIn("<td>2</td>", xml)
def test_category_upload_timeout_is_verified_before_continuing(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
with (
patch(
"collect_dy_market_rank.append_feishu_doc",
side_effect=RuntimeError("server time out error"),
),
patch(
"collect_dy_market_rank._feishu_doc_contains_keyword",
side_effect=[False, True, True],
) as verify,
):
append_feishu_category_table(
"https://example.feishu.cn/docx/test",
"旅行箱",
[product],
2,
)
self.assertEqual(verify.call_count, 3)
def test_category_upload_skips_complete_existing_table(self):
product = parse_market_product_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="鞋靴箱包 > 箱包 > 功能箱包 > 旅行箱/拉杆箱",
)[0]
with (
patch("collect_dy_market_rank.append_feishu_doc") as append,
patch(
"collect_dy_market_rank._feishu_doc_contains_keyword",
return_value=True,
),
):
append_feishu_category_table(
"https://example.feishu.cn/docx/test",
"旅行箱",
[product],
2,
)
append.assert_not_called()
def test_dy_feishu_intro_records_price_and_search_filters(self):
xml = build_dy_feishu_intro_xml(
title="抖音市场榜单",
period="近30天",
products=[
{
"category_name": "托特包",
"filter_min_price": 200,
"filter_max_price": 3000,
"search_keyword": "托特包",
}
],
)
self.assertIn("托特包 ¥200-¥3000(搜索:托特包)", xml)
self.assertIn("标题过滤", xml)
for keyword in ("儿童", "学生", "青少年", "老人", "中学", "书包"):
self.assertIn(keyword, xml)
def test_tmall_feishu_table_also_has_sales_column(self):
xml = build_feishu_batch_xml(
[{
"category_name": "旅行箱",
"rank": 1,
"name": "测试商品",
"buyer_range": "2500~5000",
"buyer_max": 5000,
"price": "¥418",
"product_url": "https://item.taobao.com/item.htm?id=1",
"image_url": "https://img.alicdn.com/test.jpg",
}],
start_rank=1,
)
self.assertIn(">销售额</th>", xml)
self.assertIn(">未获取</td>", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,89 @@
import json
import sys
from pathlib import Path
VENDOR_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "vendors"
/ "dy-data-flow"
)
sys.path.insert(0, str(VENDOR_ROOT))
from dy_store_competitor_store_scraping import ( # noqa: E402
_check_login_status,
_choose_session_root,
_looks_like_login_url,
)
def _write_cookies(root: Path, domains: list[str]) -> None:
root.mkdir(parents=True, exist_ok=True)
cookies = [{"name": f"c{index}", "value": "x", "domain": domain, "path": "/"}
for index, domain in enumerate(domains)]
(root / "compass_cookies.json").write_text(
json.dumps(cookies), encoding="utf-8"
)
def test_choose_session_root_prefers_complete_sso_bundle(tmp_path):
project = tmp_path / "project"
legacy = tmp_path / "legacy"
_write_cookies(project, [".compass.jinritemai.com"])
_write_cookies(
legacy,
[
".compass.jinritemai.com",
".doudian-sso.jinritemai.com",
".fxg.jinritemai.com",
],
)
assert _choose_session_root([project, legacy]) == legacy
def test_choose_session_root_honors_explicit_root(tmp_path):
project = tmp_path / "project"
explicit = tmp_path / "explicit"
_write_cookies(project, [".compass.jinritemai.com", ".fxg.jinritemai.com"])
assert _choose_session_root([project], explicit=explicit) == explicit
def test_login_redirect_url_detection():
assert _looks_like_login_url("https://compass.jinritemai.com/login?redirect=x")
assert _looks_like_login_url("https://doudian-sso.jinritemai.com/login")
assert _looks_like_login_url("https://fxg.jinritemai.com/passport/sso")
assert not _looks_like_login_url(
"https://compass.jinritemai.com/shop/commodity/product-list"
)
class _EmptyLocator:
def count(self):
return 0
class _FakeContext:
pages = []
class _FakeShopPage:
url = "https://compass.jinritemai.com/shop/commodity/product-list"
context = _FakeContext()
def __init__(self):
self.context.pages = [self]
def is_closed(self):
return False
def locator(self, _selector):
return _EmptyLocator()
def test_shop_url_without_authenticated_dom_is_not_logged_in():
assert not _check_login_status(_FakeShopPage())
@@ -0,0 +1,92 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import erp_metric_overrides as overrides
class ErpMetricOverridesTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.data_root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def _write_erp(self, code_results):
data_dir = self.data_root / "dy" / "apollo_2026-07-22"
data_dir.mkdir(parents=True)
(data_dir / "data.json").write_text(
json.dumps(
{
"style_name": "阿波罗x1",
"erp_style_codes": ["10388"],
"date_start": "2026-07-22",
"yesterday_sales": 0,
"yesterday_returns": 0,
"code_results": code_results,
},
ensure_ascii=False,
),
encoding="utf-8",
)
def _apply(self, platform_sales, platform_refunds):
with patch.object(overrides, "DATA_ROOT", self.data_root), patch.dict(
overrides.PLATFORM_DIRS, {"dy": "dy"}
):
return overrides.apply_erp_override(
"dy", "阿波罗x1", 394, 46,
target_date="2026-07-22",
platform_sales=platform_sales,
platform_refunds=platform_refunds,
)
def test_error_makes_ostensibly_ok_result_unavailable(self):
self._write_erp([{
"erp_style_code": "10388",
"ok": True,
"reason": "no_data",
"error": "timeout waiting for report result: 10388 | allsku iframe not found",
"yesterday_sales": 0,
"yesterday_returns": 0,
}])
result = self._apply(platform_sales=8, platform_refunds=2)
self.assertTrue(result["erp_unavailable"])
self.assertEqual(result["销量"], 8)
self.assertEqual(result["退款订单数"], 2)
def test_any_failed_code_disables_partial_erp_override(self):
self._write_erp([
{"erp_style_code": "1", "ok": True, "yesterday_sales": 3, "yesterday_returns": 1},
{"erp_style_code": "2", "ok": False, "error": "timeout", "yesterday_sales": 0, "yesterday_returns": 0},
])
result = self._apply(platform_sales=7, platform_refunds=2)
self.assertTrue(result["erp_unavailable"])
self.assertEqual(result["销量"], 7)
self.assertEqual(result["退款订单数"], 2)
def test_clean_no_data_is_a_reliable_zero(self):
self._write_erp([{
"erp_style_code": "10388",
"ok": True,
"reason": "no_data",
"yesterday_sales": 0,
"yesterday_returns": 0,
}])
result = self._apply(platform_sales=8, platform_refunds=2)
self.assertFalse(result["erp_unavailable"])
self.assertEqual(result["销量"], 0)
self.assertEqual(result["退款订单数"], 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,42 @@
import unittest
from collect_erp_yesterday_metrics import _no_data_report_is_fresh
class ErpNoDataFreshnessTest(unittest.TestCase):
def test_rejects_unchanged_no_data_page_from_previous_query(self):
self.assertFalse(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=old",
current_url="https://bi.erp321.com/allsku.aspx?r=old",
previous_body="NO_DATA",
current_body="NO_DATA",
reloaded=False,
)
)
def test_accepts_no_data_after_forced_reload(self):
self.assertTrue(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=old",
current_url="https://bi.erp321.com/allsku.aspx?r=new",
previous_body="NO_DATA",
current_body="NO_DATA",
reloaded=True,
)
)
def test_accepts_no_data_when_report_body_changed(self):
self.assertTrue(
_no_data_report_is_fresh(
previous_url="https://bi.erp321.com/allsku.aspx?r=same",
current_url="https://bi.erp321.com/allsku.aspx?r=same",
previous_body="OLD REPORT",
current_body="NO_DATA",
reloaded=False,
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
import ast
import unittest
from pathlib import Path
PROJECT_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
ERP_COLLECTOR = PROJECT_ROOT / "collect_erp_yesterday_metrics.py"
def load_slow_skip_codes() -> set[str]:
tree = ast.parse(ERP_COLLECTOR.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == "ERP_SLOW_SKIP_CODES":
if (
isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Name)
and node.value.func.id == "set"
and not node.value.args
and not node.value.keywords
):
return set()
return set(ast.literal_eval(node.value))
raise AssertionError("ERP_SLOW_SKIP_CODES not found")
class ErpSlowSkipCodesTest(unittest.TestCase):
def test_no_styles_are_permanently_skipped(self):
self.assertEqual(load_slow_skip_codes(), set())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,52 @@
import pytest
from feishu_doc_native import (
FeishuNativeDocClient,
FeishuNativeDocError,
append_native_category_table,
parse_category_table_xml,
)
def test_parse_category_table_xml_keeps_text_link_and_image():
table = parse_category_table_xml(
'<h2>旅行箱(1条)</h2><table><colgroup><col width="80"/>'
'<col width="120"/></colgroup><thead><tr><th>商品图</th><th>商品</th></tr>'
'<tbody><tr><td><img href="https://img.test/a.jpg"/></td>'
'<td><a href="https://item.test/1">打开商品</a></td></tr></tbody></table>'
)
assert table.heading == "旅行箱(1条)"
assert table.widths == [80, 120]
assert table.rows[1][0].image_url == "https://img.test/a.jpg"
assert table.rows[1][1].link_url == "https://item.test/1"
def test_batch_update_splits_image_resources_into_twenty(monkeypatch):
client = FeishuNativeDocClient("test-token")
calls = []
def fake_request(method, path, **kwargs):
calls.append(kwargs["json_body"]["requests"])
return {}
monkeypatch.setattr(client, "request", fake_request)
requests_ = [
{"block_id": f"img-{index}", "replace_image": {"token": f"t-{index}"}}
for index in range(45)
]
client.batch_update("doc", requests_)
assert [len(batch) for batch in calls] == [20, 20, 5]
def test_rejects_table_over_feishu_2000_cell_limit_before_api_call():
rows = "".join("<tr>" + "<td>x</td>" * 8 + "</tr>" for _ in range(251))
xml = "<h2>超大类目</h2><table><tbody>" + rows + "</tbody></table>"
with pytest.raises(FeishuNativeDocError, match="超过飞书单表 2000"):
append_native_category_table(
"https://example.feishu.cn/docx/test",
xml,
client=object(),
)
@@ -0,0 +1,469 @@
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
from upload_video_to_guanghe import (
GuangHeUploader,
_match_tm_item_ids_from_product_titles,
download_videos,
)
class GuangHeMetadataTests(unittest.TestCase):
def test_downloads_with_same_attachment_name_are_isolated_by_record(self):
attachment = {"file_token": "file-token", "name": "same-name.mp4"}
with TemporaryDirectory() as tmp_dir, patch(
"upload_video_to_guanghe.DOWNLOAD_DIR", Path(tmp_dir)
), patch("upload_video_to_guanghe.run_lark") as run_lark_mock:
def fake_download(args):
output_dir = Path(args[args.index("--output") + 1])
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / attachment["name"]).write_bytes(b"video")
return 0, "", ""
run_lark_mock.side_effect = fake_download
first = download_videos("record-one", [attachment])
second = download_videos("record-two", [attachment])
self.assertEqual(first[0].parent.name, "record-one")
self.assertEqual(second[0].parent.name, "record-two")
self.assertNotEqual(first[0], second[0])
def test_unresolved_style_uses_exact_database_product_title_match(self):
result = _match_tm_item_ids_from_product_titles(
["宙斯3", "未建档款"],
[
(
"986141740958",
"GYXX/光影行星宙斯3双肩包男士背包机能大容量",
),
("655191144133", "GYXX光影行星宙斯双肩包男士电脑包"),
],
)
self.assertEqual(result, {"宙斯3": ["986141740958"]})
def test_product_search_term_removes_series_suffix(self):
self.assertEqual(
GuangHeUploader._normalize_product_search_term("宙斯系列"),
"宙斯",
)
self.assertEqual(
GuangHeUploader._normalize_product_search_term(" 宙斯 系列款 "),
"宙斯",
)
self.assertEqual(
GuangHeUploader._normalize_product_search_term("星迹2"),
"星迹2",
)
def test_cover_generation_pending_marker_is_not_upload_done(self):
self.assertTrue(GuangHeUploader._cover_generation_is_pending("封面生成中"))
self.assertFalse(GuangHeUploader._cover_generation_is_pending("智能封面图"))
def test_content_label_option_allows_browse_count_but_rejects_longer_label(self):
self.assertTrue(
GuangHeUploader._content_label_text_matches("# 斜挎包 41673031次浏览", "斜挎包")
)
self.assertTrue(
GuangHeUploader._content_label_text_matches("商品展示", "商品展示")
)
self.assertTrue(
GuangHeUploader._content_label_text_matches("#斜挎包", "斜挎包")
)
self.assertFalse(
GuangHeUploader._content_label_text_matches("# 单肩斜挎包 32871826次浏览", "斜挎包")
)
def test_topic_result_card_requires_topic_and_activity_statistics(self):
self.assertTrue(
GuangHeUploader._topic_result_card_text_matches(
"# 在淘宝种草一夏 小二推荐 作品数469.5万 参与数16.2万",
"在淘宝种草一夏",
)
)
self.assertFalse(
GuangHeUploader._topic_result_card_text_matches(
"在淘宝种草一夏",
"在淘宝种草一夏",
)
)
self.assertTrue(GuangHeUploader._topic_card_box_is_clickable(620, 120))
self.assertFalse(GuangHeUploader._topic_card_box_is_clickable(620, 45))
self.assertFalse(GuangHeUploader._topic_card_box_is_clickable(720, 600))
def test_poseidon_edge_uses_authoritative_tote_category(self):
self.assertEqual(
GuangHeUploader._resolve_title_category(
"波塞冬edge",
"双肩包、电脑包、托特包、大容量",
),
"托特包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("星迹2", ""),
"斜挎包",
)
def test_invalid_online_info_is_rejected_when_main_category_conflicts(self):
result = GuangHeUploader._validate_online_product_info(
"波塞冬edge",
"主包型:双肩包;尺寸:39cm;容量/适配:16寸;材质:尼龙;卖点:分区",
)
self.assertEqual(result, "")
def test_verified_fallback_info_contains_all_product_fields(self):
info = GuangHeUploader._verified_product_info("星迹2")
for field in ["主包型:", "尺寸:", "容量/适配:", "材质:", "卖点:"]:
self.assertIn(field, info)
self.assertIn("斜挎包", info)
def test_title_category_uses_name_then_search_features(self):
self.assertEqual(
GuangHeUploader._resolve_title_category("盖亚斜挎", "轻量、大容量"),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("宙斯", "双肩包、大容量、笔电仓"),
"双肩包",
)
def test_title_normalization_keeps_search_structure_and_uses_computer_bag_term(self):
title = GuangHeUploader._normalize_generated_title(
"宙斯笔电大容量通勤真能装",
["宙斯"],
"双肩包",
)
self.assertTrue(title.startswith("宙斯双肩包,"))
self.assertTrue(title.endswith("!"))
self.assertIn("电脑包", title)
self.assertNotIn("笔电", title)
self.assertNotIn("笔记本电脑", title)
self.assertNotIn("电脑包仓", title)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_title_normalization_removes_compact_duplicate_model_and_broken_tail(self):
title = GuangHeUploader._normalize_generated_title(
"光影行星宙斯zair双肩包,宙斯zair,大容量分区收纳,从容应",
["宙斯z air"],
"双肩包",
)
self.assertTrue(title.startswith("宙斯z air双肩包,"))
self.assertEqual(title.replace(" ", "").count("宙斯zair"), 1)
self.assertNotIn("从容应!", title)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_same_style_multiple_videos_get_distinct_titles_when_hermes_repeats(self):
uploader = object.__new__(GuangHeUploader)
repeated = "光影行星宙斯双肩包科学分区收纳轻量通勤出行"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
first = uploader._generate_title(["宙斯"], video_index=1, video_total=2)
second = uploader._generate_title(["宙斯"], video_index=2, video_total=2)
self.assertNotEqual(first, second)
self.assertTrue(first.startswith("宙斯双肩包,"))
self.assertTrue(second.startswith("宙斯双肩包,"))
def test_same_style_repeated_hermes_copy_gets_low_similarity_bodies(self):
uploader = object.__new__(GuangHeUploader)
repeated = "光影行星宙斯双肩包,科学分区收纳,轻量通勤出行!"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
titles = [
uploader._generate_title(["宙斯"], video_index=index, video_total=4)
for index in range(1, 5)
]
bodies = [GuangHeUploader._title_body(title) for title in titles]
self.assertEqual(len(set(bodies)), 4)
for index, body in enumerate(bodies):
for other in bodies[index + 1:]:
self.assertLess(
GuangHeUploader._title_similarity(body, other),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_different_styles_cannot_reuse_same_title_skeleton(self):
uploader = object.__new__(GuangHeUploader)
repeated = "科学分区收纳,轻装通勤出行"
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})),
):
first = uploader._generate_title(["宙斯"], video_index=1)
second = uploader._generate_title(["星云2"], video_index=1)
first_body = GuangHeUploader._title_body(first)
second_body = GuangHeUploader._title_body(second)
self.assertLess(
GuangHeUploader._title_similarity(first_body, second_body),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_title_similarity_ignores_product_prefix(self):
first = "宙斯双肩包,科学分区收纳,轻装通勤出行!"
second = "星云2斜挎包,科学分区收纳,轻装通勤出行!"
self.assertEqual(GuangHeUploader._title_similarity(first, second), 1.0)
def test_full_batch_can_claim_48_globally_distinct_title_bodies(self):
uploader = object.__new__(GuangHeUploader)
titles = []
for index in range(48):
product_names = [f"款式{index % 12 + 1}"]
video_index = index // 12 + 1
candidate = uploader._fallback_title(
product_names,
"双肩包",
variant_index=video_index,
)
titles.append(
uploader._claim_unique_title(
candidate,
product_names,
"双肩包",
variant_index=video_index,
)
)
self.assertEqual(len(set(titles)), 48)
self.assertTrue(all(26 <= len(title) <= 30 for title in titles))
for index, title in enumerate(titles):
for other in titles[index + 1:]:
self.assertLess(
GuangHeUploader._title_similarity(title, other),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_two_store_uploaders_can_share_one_global_title_registry(self):
shared_global = []
shared_styles = {}
shared_usage = {}
first_uploader = object.__new__(GuangHeUploader)
second_uploader = object.__new__(GuangHeUploader)
for uploader in (first_uploader, second_uploader):
uploader._used_titles_global = shared_global
uploader._used_titles_by_products = shared_styles
uploader._title_variant_usage = shared_usage
first = first_uploader._claim_unique_title(
first_uploader._fallback_title(["宙斯"], "双肩包", 1),
["宙斯"],
"双肩包",
1,
)
second = second_uploader._claim_unique_title(
second_uploader._fallback_title(["星云2"], "斜挎包", 1),
["星云2"],
"斜挎包",
1,
)
self.assertLess(
GuangHeUploader._title_similarity(first, second),
GuangHeUploader.TITLE_SIMILARITY_LIMIT,
)
def test_product_search_specs_prefer_tm_item_ids_and_fallback_to_keyword(self):
specs = GuangHeUploader._build_product_search_specs(
["星云2", "未建档系列款"],
{"星云2": ["900176117674", "865283623976"]},
)
self.assertEqual(
specs,
[
{
"product_name": "星云2",
"search_term": "900176117674",
"exact_id": True,
},
{
"product_name": "未建档系列款",
"search_term": "未建档",
"exact_id": False,
},
{
"product_name": "星云2",
"search_term": "865283623976",
"exact_id": True,
},
],
)
def test_exact_id_searches_get_one_store_keyword_fallback(self):
specs = GuangHeUploader._build_product_search_specs(
["极星双肩", "未建档款"],
{"极星双肩": ["972285625931", "966753757238"]},
)
self.assertEqual(
GuangHeUploader._build_exact_search_fallback_specs(specs),
[
{
"product_name": "极星双肩",
"search_term": "极星双肩",
"exact_id": False,
}
],
)
def test_title_fallback_contains_product_category_solution_and_result(self):
title = GuangHeUploader._fallback_title(["盖亚斜挎"], "斜挎包")
self.assertTrue(title.startswith("盖亚斜挎包,"))
self.assertIn(",", title)
self.assertTrue(title.endswith("!"))
self.assertIn("钥匙卡包各归其位", title)
self.assertIn("早高峰取物", title)
self.assertGreaterEqual(title.count(","), 2)
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_generated_title_replaces_generic_category_with_model_category(self):
uploader = object.__new__(GuangHeUploader)
with (
patch.object(uploader, "_search_product_features", return_value=""),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch(
"upload_video_to_guanghe._call_hermes",
return_value=("光影行星宙斯双肩包大容量笔记本电脑轻量通勤", {}),
),
):
title = uploader._generate_title(["宙斯"])
self.assertTrue(title.startswith("宙斯双肩包,"))
self.assertNotIn("包袋双肩包", title)
def test_generated_poseidon_title_cannot_be_changed_to_backpack_by_hermes(self):
uploader = object.__new__(GuangHeUploader)
with (
patch.object(
uploader,
"_search_product_features",
return_value="双肩包、电脑包、托特包、大容量",
),
patch("upload_video_to_guanghe.HAS_HERMES", True),
patch(
"upload_video_to_guanghe._call_hermes",
return_value=(
"光影行星波塞冬edge双肩包暴雨通勤防泼水电脑仓从容出行",
{},
),
),
):
title = uploader._generate_title(["波塞冬edge"])
self.assertTrue(title.startswith("波塞冬edge托特包,"))
self.assertNotIn("双肩包", title)
self.assertTrue(title.endswith("!"))
self.assertGreaterEqual(len(title), 26)
self.assertLessEqual(len(title), 30)
def test_primary_content_label_uses_first_product_bag_type(self):
self.assertEqual(
GuangHeUploader._content_label_for_products(["盖亚斜挎", "极星双肩"]),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["盖亚微单"]),
"相机包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["波塞冬edge"]),
"托特包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["波塞冬手提电脑包"]),
"电脑包",
)
def test_catalog_product_titles_supply_style_category_and_verified_info(self):
try:
GuangHeUploader.configure_catalog_product_titles(
{
"星云2": [
"GYXX光影行星星云2斜挎包男士单肩包平板胸包",
"光影行星星云斜挎包男款邮差单肩包",
],
"宙斯": ["GYXX光影行星宙斯双肩包男大容量通勤背包"],
"宙斯z air": ["光影行星宙斯zair双肩包电脑包通勤背包"],
"黑曜石托特": ["光影行星黑曜石双肩托特多用包"],
}
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["星云2"]),
"斜挎包",
)
self.assertEqual(
GuangHeUploader._resolve_title_category("宙斯"),
"双肩包",
)
self.assertIn(
"主包型:斜挎包",
GuangHeUploader._verified_product_info("星云2"),
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["宙斯z air"]),
"双肩包",
)
self.assertEqual(
GuangHeUploader._content_label_for_products(["黑曜石托特"]),
"托特包",
)
finally:
GuangHeUploader.configure_catalog_product_titles({})
def test_metadata_selects_style_category_and_product_showcase_before_topic(self):
cases = [
(["盖亚斜挎"], ["斜挎包", "商品展示"]),
(["极星双肩"], ["双肩包", "商品展示"]),
]
for product_names, expected_labels in cases:
with self.subTest(product_names=product_names):
uploader = object.__new__(GuangHeUploader)
calls = []
uploader._pick_content_labels = Mock(
side_effect=lambda labels: calls.append(("labels", list(labels)))
)
uploader._pick_topic = Mock(
side_effect=lambda topic: calls.append(("topic", topic))
)
uploader._snapshot = Mock()
uploader._prepare_publish_metadata(product_names)
self.assertEqual(
calls,
[
("labels", expected_labels),
("topic", "在淘宝种草一夏"),
],
)
def test_fixed_content_labels_only_keep_product_showcase(self):
self.assertEqual(GuangHeUploader.FIXED_CONTENT_LABELS, ("商品展示",))
self.assertNotIn("户外", GuangHeUploader.FIXED_CONTENT_LABELS)
def test_fixed_topic_is_not_a_recommended_first_result(self):
self.assertEqual(GuangHeUploader.FIXED_TOPIC, "在淘宝种草一夏")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,118 @@
import unittest
from upload_video_to_guanghe import (
STORE_FLAGSHIP,
STORE_LUGGAGE,
TM_UPLOAD_FIELD,
select_pending_records,
)
def record(
*,
brand="光影行星",
gender="",
uploaded=False,
attachments=True,
date=None,
):
return {
"品牌": [brand] if brand is not None else None,
"性别": [gender] if gender is not None else None,
TM_UPLOAD_FIELD: uploaded,
"代拍附件": [{"name": "video.mp4"}] if attachments else [],
"日期": date,
}
class GuangHeStoreRoutingTests(unittest.TestCase):
def test_tm_upload_field_matches_current_feishu_schema(self):
self.assertEqual(TM_UPLOAD_FIELD, "天猫上传")
def test_flagship_requires_brand_gender_attachment_and_unchecked_status(self):
records = [
record(gender=""),
record(gender=""),
record(gender="", uploaded=True),
record(brand="OZKO"),
record(brand=None),
record(gender=None),
record(attachments=False),
]
selected = select_pending_records(records, STORE_FLAGSHIP)
self.assertEqual(selected, records[:2])
def test_luggage_uploads_female_brand_records_regardless_of_tm_status(self):
unchecked = record(gender="", uploaded=False)
checked = record(gender="", uploaded=True)
unchecked["代拍产品"] = "极星双肩"
checked["代拍产品"] = "极星双肩"
records = [
unchecked,
checked,
record(gender=""),
record(brand="OZKO", gender=""),
record(gender=None),
record(gender="", attachments=False),
]
selected = select_pending_records(
records,
STORE_LUGGAGE,
luggage_style_whitelist={"极星双肩", "觅光"},
)
self.assertEqual(selected, [unchecked, checked])
def test_luggage_rejects_styles_outside_whitelist_and_trims_names(self):
allowed = record()
allowed["代拍产品"] = " 黑曜石电脑包 "
blocked = record()
blocked["代拍产品"] = "宙斯z air"
multi_with_blocked_style = record()
multi_with_blocked_style["代拍产品"] = "极星双肩 / 宙斯z air"
selected = select_pending_records(
[allowed, blocked, multi_with_blocked_style],
STORE_LUGGAGE,
luggage_style_whitelist={"黑曜石电脑包", "极星双肩"},
)
self.assertEqual(selected, [allowed])
def test_luggage_requires_a_loaded_whitelist(self):
with self.assertRaisesRegex(ValueError, "款式白名单"):
select_pending_records([record()], STORE_LUGGAGE)
def test_flagship_republish_from_date_includes_uploaded_records_in_range(self):
before = record(uploaded=True, date="2026-06-02 00:00:00")
first = record(uploaded=True, date="2026-06-03 00:00:00")
later = record(uploaded=True, date="2026-07-26 00:00:00")
missing_date = record(uploaded=True, date=None)
selected = select_pending_records(
[before, first, later, missing_date],
STORE_FLAGSHIP,
republish_from_date="2026-06-03",
)
self.assertEqual(selected, [first, later])
def test_republish_from_date_is_rejected_for_luggage_store(self):
with self.assertRaisesRegex(ValueError, "旗舰店"):
select_pending_records(
[record(date="2026-06-03 00:00:00")],
STORE_LUGGAGE,
luggage_style_whitelist={"极星双肩"},
republish_from_date="2026-06-03",
)
def test_unknown_store_is_rejected(self):
with self.assertRaisesRegex(ValueError, "未知光合店铺"):
select_pending_records([record()], "unknown")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
import unittest
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import import_product_daily
TM_31_HEADERS = [
"统计日期", "商品ID", "商品名称", "货号", "商品状态", "商品访客数",
"商品浏览量", "平均停留时长", "商品详情页跳出率", "商品收藏人数",
"商品加购件数", "商品加购人数", "下单买家数", "下单件数", "下单金额",
"下单转化率", "支付买家数", "支付件数", "支付金额", "商品支付转化率",
"支付新买家数", "支付老买家数", "老买家支付金额", "聚划算支付金额",
"访客平均价值", "成功退款金额", "搜索引导支付转化率", "搜索引导访客数",
"搜索引导支付买家数", "结构化详情引导转化率", "结构化详情引导成交占比",
]
TM_38_HEADERS = [
"统计日期", "商品ID", "商品名称", "主商品ID", "商品类型", "货号", "商品状态",
"商品标签", "商品访客数", "商品浏览量", "平均停留时长",
"商品详情页跳出率", "商品收藏人数", "商品加购件数", "商品加购人数",
"下单买家数", "下单件数", "下单金额", "下单转化率", "支付买家数",
"支付件数", "支付金额", "商品支付转化率", "支付新买家数", "支付老买家数",
"老买家支付金额", "聚划算支付金额", "访客平均价值", "成功退款金额",
"竞争力评分", "年累计支付金额", "月累计支付金额", "月累计支付件数",
"搜索引导支付转化率", "搜索引导访客数", "搜索引导支付买家数",
"结构化详情引导转化率", "结构化详情引导成交占比",
]
class _FakeSheet:
def __init__(self, headers, row):
self.nrows = 6
self._headers = headers
self._row = row
def row_values(self, row_index):
if row_index == 4:
return self._headers
if row_index == 5:
return self._row
return []
class _FakeWorkbook:
def __init__(self, headers, row):
self._sheet = _FakeSheet(headers, row)
def sheet_by_index(self, _sheet_index):
return self._sheet
def _tm_row(headers):
values = {
"统计日期": "2026-07-03",
"商品ID": "123456",
"商品名称": "测试商品",
"商品类型": "普通商品",
"货号": "SKU-001",
"商品访客数": "100",
"商品浏览量": "200",
"支付买家数": "8",
"支付件数": "10",
"支付金额": "1234.56",
"商品支付转化率": "8%",
"访客平均价值": "12.35",
"成功退款金额": "23.45",
"竞争力评分": "95",
"年累计支付金额": "9999.99",
"月累计支付金额": "5678.90",
"结构化详情引导成交占比": "45%",
}
row = [values.get(header, "") for header in headers]
return row
class TmallProductDailyImportTests(unittest.TestCase):
def test_parse_31_column_export(self):
row = _tm_row(TM_31_HEADERS)
with patch.object(
import_product_daily.xlrd,
"open_workbook",
return_value=_FakeWorkbook(TM_31_HEADERS, row),
):
records = list(import_product_daily.parse_tm_xls(Path("31-columns.xls")))
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["product_no"], "SKU-001")
self.assertEqual(records[0]["visitors"], 100)
self.assertEqual(records[0]["paid_qty"], 10)
self.assertEqual(records[0]["paid_amount"], Decimal("1234.56"))
self.assertEqual(records[0]["refund_amount"], Decimal("23.45"))
self.assertNotIn("月累计支付金额", records[0]["raw_data"])
def test_parse_38_column_export_keeps_extended_fields(self):
row = _tm_row(TM_38_HEADERS)
with patch.object(
import_product_daily.xlrd,
"open_workbook",
return_value=_FakeWorkbook(TM_38_HEADERS, row),
):
records = list(import_product_daily.parse_tm_xls(Path("38-columns.xls")))
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["raw_data"]["月累计支付金额"], "5678.90")
self.assertEqual(records[0]["raw_data"]["结构化详情引导成交占比"], "45%")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,319 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
PROJECT_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
JD_VENDOR_DIR = PROJECT_ROOT / "vendors" / "jd-data-flow"
MODULE_PATH = JD_VENDOR_DIR / "jd_data_collector.py"
sys.path.insert(0, str(JD_VENDOR_DIR))
try:
spec = importlib.util.spec_from_file_location("jd_data_collector_under_test", MODULE_PATH)
jd = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(jd)
finally:
sys.path.remove(str(JD_VENDOR_DIR))
class FakeKeyboard:
def __init__(self, page):
self.page = page
self.presses = []
def press(self, key):
self.presses.append(key)
class FakeLocator:
def __init__(self, visible=False, on_click=None):
self.visible = visible
self.on_click = on_click
self.clicks = 0
@property
def first(self):
return self
def is_visible(self, timeout=None):
return self.visible
def click(self, timeout=None):
self.clicks += 1
if self.on_click:
self.on_click()
class FakeOverlayPage:
def __init__(self, close_button_works, overlay_visible=True, blocks_entry=True):
self.overlay_visible = overlay_visible
self.close_button_works = close_button_works
self.blocks_entry = blocks_entry
self.keyboard = FakeKeyboard(self)
self.evaluate_calls = []
self.waits = []
def locator(self, selector, **kwargs):
if selector == "div.all-dialog:visible":
return FakeLocator(visible=self.overlay_visible)
if self.close_button_works and "close-btn" in selector:
return FakeLocator(visible=True, on_click=self._close_overlay)
return FakeLocator(visible=False)
def _close_overlay(self):
self.overlay_visible = False
def evaluate(self, script, *args):
self.evaluate_calls.append(script)
if "elementFromPoint" in script and "setProperty('display'" in script:
if self.overlay_visible and self.blocks_entry:
self.overlay_visible = False
return 1
return 0
if "elementFromPoint" in script:
return self.overlay_visible and self.blocks_entry
if "all-dialog" in script and self.blocks_entry:
self.overlay_visible = False
return 1
if "all-dialog" in script:
# The old broad fallback would hide this non-blocking business dialog.
if "elementFromPoint" not in script:
self.overlay_visible = False
return 1
return 0
def wait_for_timeout(self, milliseconds):
self.waits.append(milliseconds)
class FakePage:
def __init__(
self,
url="https://shop.jd.com/jdm/home",
click_actions=None,
wait_actions=None,
):
self.url = url
self.closed = False
self.click_actions = list(click_actions or [])
self.entry_clicks = 0
self.keyboard = FakeKeyboard(self)
self.locator_calls = []
self.wait_actions = list(wait_actions or [])
def is_closed(self):
return self.closed
def locator(self, selector, **kwargs):
self.locator_calls.append((selector, kwargs))
def click_entry():
self.entry_clicks += 1
if self.click_actions:
action = self.click_actions.pop(0)
action()
is_precise_shangzhi_entry = (
selector == '.shop-menu-navigate__menu-name:has-text("商智"):visible'
)
return FakeLocator(visible=is_precise_shangzhi_entry, on_click=click_entry)
def wait_for_timeout(self, milliseconds):
if self.wait_actions:
self.wait_actions.pop(0)()
return None
def wait_for_load_state(self, *args, **kwargs):
return None
def goto(self, url, **kwargs):
self.url = url
class FakeContext:
def __init__(self, page):
self.pages = [page]
def new_page(self):
page = FakePage()
self.pages.append(page)
return page
class JdEnterShangzhiTests(unittest.TestCase):
def test_dismiss_clicks_real_close_button_before_dom_fallback(self):
page = FakeOverlayPage(close_button_works=True)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertFalse(page.overlay_visible)
self.assertFalse(
any("setProperty('display'" in script for script in page.evaluate_calls)
)
def test_dismiss_hides_stuck_pointer_blocking_overlay(self):
page = FakeOverlayPage(close_button_works=False)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertFalse(page.overlay_visible)
self.assertTrue(page.keyboard.presses)
self.assertTrue(any("all-dialog" in script for script in page.evaluate_calls))
def test_dismiss_does_not_touch_dom_when_no_overlay_is_visible(self):
page = FakeOverlayPage(close_button_works=False, overlay_visible=False)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertEqual(page.keyboard.presses, [])
self.assertEqual(page.evaluate_calls, [])
def test_dismiss_does_not_hide_nonblocking_business_dialog(self):
page = FakeOverlayPage(
close_button_works=False,
overlay_visible=True,
blocks_entry=False,
)
self.assertTrue(jd.dismiss_shop_home_overlays(page))
self.assertTrue(page.overlay_visible)
self.assertFalse(any("setProperty('display'" in script for script in page.evaluate_calls))
def test_enter_retries_after_first_click_is_intercepted(self):
source = FakePage()
context = FakeContext(source)
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
def fail_first_click():
raise RuntimeError("all-dialog intercepts pointer events")
def open_destination():
context.pages.append(destination)
source.click_actions = [fail_first_click, open_destination]
dismiss_calls = []
with (
patch.object(
jd,
"dismiss_shop_home_overlays",
side_effect=lambda page: dismiss_calls.append(page) or True,
),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
self.assertGreaterEqual(len(dismiss_calls), 2)
self.assertFalse(any(selector == "text=商智" for selector, _ in source.locator_calls))
def test_enter_accepts_current_page_navigation(self):
source = FakePage()
context = FakeContext(source)
source.click_actions = [
lambda: setattr(source, "url", "https://sz.jd.com/szweb/index.html")
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, source)
self.assertEqual(source.entry_clicks, 1)
def test_enter_rejects_unrelated_new_page_and_retries(self):
source = FakePage()
context = FakeContext(source)
advertisement = FakePage(url="https://pro.jd.com/promotion.html")
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
source.click_actions = [
lambda: context.pages.append(advertisement),
lambda: context.pages.append(destination),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
def test_enter_rejects_login_page_whose_return_url_mentions_shangzhi(self):
source = FakePage()
context = FakeContext(source)
login_page = FakePage(
url=(
"https://passport.jd.com/login?returnUrl="
"https%3A%2F%2Fsz.jd.com%2Fszweb%2Findex.html"
)
)
destination = FakePage(url="https://sz.jd.com/szweb/index.html")
source.click_actions = [
lambda: context.pages.append(login_page),
lambda: context.pages.append(destination),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(source.entry_clicks, 2)
def test_shangzhi_url_match_uses_hostname_not_query_or_similar_domain(self):
self.assertTrue(jd._is_shangzhi_url("https://sz.jd.com/szweb/index.html"))
self.assertTrue(jd._is_shangzhi_url("https://sub.sz.jd.com/path"))
self.assertTrue(
jd._is_shangzhi_url(
"https://jdsz.jd.com/szweb/view/index/home.html"
)
)
self.assertFalse(
jd._is_shangzhi_url(
"https://passport.jd.com/login?returnUrl=https://sz.jd.com/szweb/"
)
)
self.assertFalse(jd._is_shangzhi_url("https://evil-sz.jd.com/szweb/"))
self.assertFalse(jd._is_shangzhi_url("https://evil-jdsz.jd.com/szweb/"))
def test_enter_waits_for_about_blank_popup_to_navigate_to_shangzhi(self):
source = FakePage()
context = FakeContext(source)
destination = FakePage(url="about:blank")
source.click_actions = [lambda: context.pages.append(destination)]
source.wait_actions = [
lambda: None,
lambda: setattr(
destination,
"url",
"https://jdsz.jd.com/szweb/view/index/home.html",
),
]
with (
patch.object(jd, "dismiss_shop_home_overlays", return_value=True),
patch.object(jd, "close_popup_if_present", return_value=None),
):
result = jd.step_enter_shangzhi(context, source)
self.assertIs(result, destination)
self.assertEqual(
result.url,
"https://jdsz.jd.com/szweb/view/index/home.html",
)
self.assertEqual(source.entry_clicks, 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,295 @@
import unittest
from unittest.mock import MagicMock, patch
from collect_jd_market_rank import (
NEXT_PAGE_SELECTORS,
TARGET_JD_MARKET_CATEGORIES,
build_jd_feishu_batch_xml,
build_jd_feishu_category_xml,
build_jd_feishu_intro_xml,
category_selection_matches,
exact_category_label_pattern,
group_products_by_category,
high_resolution_jd_image_url,
inspect_feishu_table_images,
parse_jd_market_rank_html,
parse_money_range_upper,
resize_feishu_table_xml,
select_category_paths,
)
SAMPLE_HTML = """
<table class="jmtd-table-main">
<thead>
<tr>
<th>排名</th>
<th>商品信息</th>
<th>成交金额</th>
<th>成交单量</th>
<th>访客数</th>
<th>搜索点击次数</th>
<th>关注人数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="jmtd-table-formatter-rank-icon-1"></span><span>↑3名</span></td>
<td>
<div class="prod-info">
<a class="prod-name" href="//item.jd.com/10012345678901.html?pcdk=tracking"
title="测试高颜值拉杆箱">测试高颜值拉杆箱</a>
<a class="shop-name">测试旗舰店</a>
<img data-src="//img10.360buyimg.com/n1/jfs/test.jpg"/>
</div>
</td>
<td>¥ 50万 ¥ 75万</td>
<td>4,000~6,000</td>
<td>4万~6万</td>
<td>4万~6万</td>
<td>357</td>
<td>详情</td>
</tr>
<tr>
<td><span class="jmtd-table-formatter-rank-number">4</span></td>
<td>
<div class="prod-info">
<a class="prod-name" href="https://item.jd.com/10012345678902.html">
测试电脑包
</a>
<a class="shop-name">电脑包旗舰店</a>
<img src="https://img11.360buyimg.com/n1/jfs/test2.jpg"/>
</div>
</td>
<td>¥10万~¥25万</td>
<td>50~100</td>
<td>4,000~6,000</td>
<td>1,000~2,000</td>
<td>20</td>
<td>详情</td>
</tr>
</tbody>
</table>
"""
class JdMarketRankTests(unittest.TestCase):
def test_default_categories_match_requested_seven_filters(self):
specs = {item["name"]: item for item in TARGET_JD_MARKET_CATEGORIES}
self.assertEqual(
set(specs),
{"旅行箱", "运动包", "双肩包", "斜挎包", "胸包", "手机包", "电脑包"},
)
self.assertEqual(specs["旅行箱"]["paths"], (("功能箱包", "行李箱"),))
self.assertEqual(specs["运动包"]["paths"], (("功能箱包", "休闲运动包"),))
self.assertEqual(
specs["双肩包"]["paths"],
(("男包", "双肩包"), ("男包", "男士双肩包")),
)
self.assertEqual(specs["手机包"]["paths"], (("男包", "男士手机包"),))
self.assertEqual(specs["电脑包"]["paths"], (("功能箱包", "电脑包"),))
def test_category_label_matching_is_exact_not_contains(self):
pattern = exact_category_label_pattern("双肩包")
self.assertIsNotNone(pattern.fullmatch(" 双肩包 "))
self.assertIsNone(pattern.fullmatch("男士双肩包"))
self.assertIsNone(pattern.fullmatch("双肩包配件"))
def test_selected_category_verification_accepts_only_exact_display_forms(self):
path = ("男包", "双肩包")
self.assertTrue(category_selection_matches(("双肩包",), path))
self.assertTrue(category_selection_matches(("男包 > 双肩包",), path))
self.assertFalse(category_selection_matches(("男士双肩包",), path))
self.assertFalse(category_selection_matches(("双肩包配件",), path))
def test_category_candidates_fall_back_only_after_explicit_failure(self):
page = MagicMock()
paths = (("男包", "双肩包"), ("男包", "男士双肩包"))
with patch(
"collect_jd_market_rank.select_category_path",
side_effect=[RuntimeError("当前页面无此精确类目"), paths[1]],
) as select_one:
selected = select_category_paths(page, paths)
self.assertEqual(selected, paths[1])
self.assertEqual(
[call.args[1] for call in select_one.call_args_list],
list(paths),
)
def test_all_missing_category_candidates_fail_with_diagnostics(self):
page = MagicMock()
paths = (("男包", "双肩包"), ("男包", "男士双肩包"))
with (
patch(
"collect_jd_market_rank.select_category_path",
side_effect=[RuntimeError("无双肩包"), RuntimeError("无男士双肩包")],
),
self.assertRaisesRegex(RuntimeError, "候选类目路径.*无双肩包.*无男士双肩包"),
):
select_category_paths(page, paths)
def test_parse_money_range_upper(self):
self.assertEqual(parse_money_range_upper("¥50万~¥75万"), 750_000)
self.assertEqual(parse_money_range_upper("¥ 1亿以上"), 100_000_000)
def test_jd_thumbnail_is_upgraded_to_n0_original(self):
self.assertEqual(
high_resolution_jd_image_url(
"https://img10.360buyimg.com/n5/jfs/t1/test/image.jpg"
),
"https://img10.360buyimg.com/n0/jfs/t1/test/image.jpg",
)
def test_real_jd_next_page_selector_is_supported(self):
self.assertIn(".jmtd-pagination-item-next", NEXT_PAGE_SELECTORS)
def test_inspect_feishu_table_images_finds_incomplete_table(self):
content = """
<title>测试</title>
<table id="table-1">
<thead><tr><th>商品图</th></tr></thead>
<tbody>
<tr><td><img src="token-1"/></td></tr>
<tr><td></td></tr>
</tbody>
</table>
<table id="table-2">
<thead><tr><th>商品图</th></tr></thead>
<tbody><tr><td><img src="token-2"/></td></tr></tbody>
</table>
"""
stats = inspect_feishu_table_images(content)
self.assertEqual(
stats,
[
{"block_id": "table-1", "rows": 2, "images": 1},
{"block_id": "table-2", "rows": 1, "images": 1},
],
)
def test_resize_feishu_table_xml_reuses_media_token_at_compact_size(self):
table_xml = """
<table id="table-1">
<tbody>
<tr id="row-1">
<td>
<img id="image-1" src="image-token" href="https://internal/image"
width="220" height="800"/>
</td>
<td><a href="https://item.jd.com/1.html">打开商品</a></td>
</tr>
</tbody>
</table>
"""
resized = resize_feishu_table_xml(table_xml, image_size=160)
self.assertNotIn('id="table-1"', resized)
self.assertNotIn('id="image-1"', resized)
self.assertIn(
'<img src="image-token" width="160" height="160"/>',
resized,
)
self.assertIn(
'<a href="https://item.jd.com/1.html">打开商品</a>',
resized,
)
def test_parse_html_extracts_image_name_link_and_sales(self):
rows = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)
self.assertEqual(len(rows), 2)
first = rows[0]
self.assertEqual(first["platform"], "jd")
self.assertEqual(first["rank"], 1)
self.assertEqual(first["product_id"], "10012345678901")
self.assertEqual(first["name"], "测试高颜值拉杆箱")
self.assertEqual(
first["image_url"],
"https://img10.360buyimg.com/n0/jfs/test.jpg",
)
self.assertEqual(
first["product_url"],
"https://item.jd.com/10012345678901.html",
)
self.assertEqual(first["sales_amount_range"], "¥50万~¥75万")
self.assertEqual(first["sales_amount_upper"], 750_000)
self.assertEqual(rows[1]["rank"], 4)
def test_feishu_xml_adds_sales_column_and_product_assets(self):
product = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)[0]
xml = build_jd_feishu_batch_xml([product], start_rank=1)
self.assertIn(">销售额</th>", xml)
self.assertIn("<b>¥50万~¥75万</b>", xml)
self.assertIn(
'<img href="https://img10.360buyimg.com/n0/jfs/test.jpg" '
'width="160" height="160"',
xml,
)
self.assertIn(
'<a href="https://item.jd.com/10012345678901.html">打开商品</a>',
xml,
)
def test_category_xml_uses_one_named_table_for_the_category(self):
products = parse_jd_market_rank_html(
SAMPLE_HTML,
category_name="旅行箱",
category_path="功能箱包 > 行李箱",
)
xml = build_jd_feishu_category_xml("旅行箱", products)
self.assertIn("<h2>旅行箱(2条)</h2>", xml)
self.assertEqual(xml.count("<table>"), 1)
self.assertEqual(xml.count("<tbody>"), 1)
self.assertEqual(xml.count("<tr>"), 3)
def test_group_products_by_category_preserves_category_order(self):
grouped = group_products_by_category(
[
{"category_name": "旅行箱", "rank": 1},
{"category_name": "运动包", "rank": 1},
{"category_name": "旅行箱", "rank": 2},
]
)
self.assertEqual(
[(name, len(items)) for name, items in grouped],
[("旅行箱", 2), ("运动包", 1)],
)
def test_intro_records_period_and_category_path(self):
xml = build_jd_feishu_intro_xml(
title="京东市场榜单",
period="近30天",
products=[
{
"category_name": "运动包",
"category_path": "功能箱包 > 休闲运动包",
"search_keyword": "",
}
],
)
self.assertIn("<h1>京东</h1>", xml)
self.assertIn("近30天", xml)
self.assertIn("运动包(功能箱包 &gt; 休闲运动包)", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,449 @@
import io
import json
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
import db as product_db
import run_weekly_jd_main_image as jd_runner
import run_weekly_main_image as tmall_runner
from main_image_db import persist_main_image_records, validate_main_image_schema
from main_image_paths import collect_images_dir, resolve_images_dir
from scripts import insert_jd_main_image_records as jd_insert
from scripts import insert_main_image_records as tmall_insert
from taobao_wanxiang_ai_creative_report import cleanup_images_after_visual_merge
class MainImagePathTests(unittest.TestCase):
def test_collectors_use_platform_owned_directories(self):
style_dir = Path("style")
self.assertEqual(collect_images_dir(style_dir, "tm"), style_dir / "tm_images")
self.assertEqual(collect_images_dir(style_dir, "jd"), style_dir / "jd_images")
def test_insert_prefers_platform_directory_and_supports_legacy_data(self):
with tempfile.TemporaryDirectory() as tmp:
style_dir = Path(tmp)
legacy = style_dir / "images"
legacy.mkdir()
self.assertEqual(resolve_images_dir(style_dir, "jd"), legacy)
current = style_dir / "jd_images"
current.mkdir()
self.assertEqual(resolve_images_dir(style_dir, "jd"), current)
class MainImageWorkflowPreflightTests(unittest.TestCase):
def test_zero_valid_styles_is_a_formal_failure(self):
for runner in (tmall_runner, jd_runner):
with self.subTest(runner=runner.__name__):
with self.assertRaisesRegex(RuntimeError, "拒绝按成功退出"):
runner.require_valid_output(0, "2026-08-04")
def test_dynamic_style_read_failure_is_not_silently_ignored(self):
with patch.object(
tmall_runner,
"get_main_image_styles",
side_effect=RuntimeError("Feishu unavailable"),
):
with self.assertRaisesRegex(RuntimeError, "款式读取失败"):
tmall_runner.load_dynamic_styles()
with patch.object(
jd_runner,
"get_jd_spu_groups",
side_effect=RuntimeError("Feishu unavailable"),
):
with self.assertRaisesRegex(RuntimeError, "款式读取失败"):
jd_runner.load_dynamic_styles()
def test_empty_dynamic_style_set_is_a_formal_failure(self):
with patch.object(tmall_runner, "get_main_image_styles", return_value=[]):
with self.assertRaisesRegex(RuntimeError, "未返回任何天猫"):
tmall_runner.load_dynamic_styles()
with patch.object(jd_runner, "get_jd_spu_groups", return_value=[]):
with self.assertRaisesRegex(RuntimeError, "未返回任何京东"):
jd_runner.load_dynamic_styles()
class TmallCleanupTests(unittest.TestCase):
def test_locked_duplicate_is_retried_without_console_encoding_failure(self):
with tempfile.TemporaryDirectory() as tmp:
images_dir = Path(tmp)
keep = images_dir / "keep.png"
locked = images_dir / "locked.png"
keep.write_bytes(b"keep")
locked.write_bytes(b"duplicate")
creatives = [{"local_image_path": str(keep), "local_image_paths": [str(keep), str(locked)]}]
original_unlink = Path.unlink
attempts = 0
def flaky_unlink(path, *args, **kwargs):
nonlocal attempts
if path == locked:
attempts += 1
if attempts < 3:
raise PermissionError("temporarily locked")
return original_unlink(path, *args, **kwargs)
raw = io.BytesIO()
strict_gbk_stdout = io.TextIOWrapper(raw, encoding="gbk", errors="strict")
with patch.object(Path, "unlink", new=flaky_unlink):
with redirect_stdout(strict_gbk_stdout):
cleanup_images_after_visual_merge(
images_dir,
creatives,
retry_attempts=3,
retry_delay=0,
)
strict_gbk_stdout.flush()
self.assertEqual(attempts, 3)
self.assertFalse(locked.exists())
self.assertTrue(keep.exists())
class MainImageDatabaseTests(unittest.TestCase):
@staticmethod
def _schema_connection(columns):
class Cursor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, _sql):
return None
def fetchone(self):
return (list(columns),)
class Connection:
@staticmethod
def cursor():
return Cursor()
class ConnectionContext:
def __enter__(self):
return Connection()
def __exit__(self, exc_type, exc, tb):
return False
return ConnectionContext
def test_schema_preflight_accepts_platform_aware_primary_key(self):
columns = ("collect_date", "style_name", "platform", "image_key")
self.assertEqual(
validate_main_image_schema(get_conn_fn=self._schema_connection(columns)),
columns,
)
def test_schema_preflight_rejects_legacy_primary_key_before_external_writes(self):
legacy = ("collect_date", "style_name", "image_key")
with self.assertRaisesRegex(RuntimeError, "primary key mismatch"):
validate_main_image_schema(get_conn_fn=self._schema_connection(legacy))
def test_empty_batch_does_not_open_database_connection(self):
def unexpected_connection():
raise AssertionError("database should not be opened")
self.assertEqual(
persist_main_image_records([], get_conn_fn=unexpected_connection),
0,
)
def test_database_connection_is_opened_only_when_records_are_persisted(self):
events = []
connection = object()
class ConnectionContext:
def __enter__(self):
events.append("connect")
return connection
def __exit__(self, exc_type, exc, tb):
events.append("close")
def upsert(conn, records):
self.assertIs(conn, connection)
events.append(("upsert", len(records)))
return len(records)
self.assertEqual(
persist_main_image_records(
[{"image_key": "a"}, {"image_key": "b"}],
get_conn_fn=ConnectionContext,
upsert_fn=upsert,
),
2,
)
self.assertEqual(events, ["connect", ("upsert", 2), "close"])
def test_upsert_identity_contains_platform_and_preserves_both_platform_rows(self):
class CursorContext:
def __enter__(self):
return object()
def __exit__(self, exc_type, exc, tb):
return False
class Connection:
@staticmethod
def cursor():
return CursorContext()
records = [
{
"collect_date": "2026-08-02",
"style_name": "同款",
"platform": platform,
"image_key": "main.jpg",
}
for platform in ("jd", "tm")
]
with patch.object(product_db, "execute_values") as execute_values:
count = product_db.upsert_main_image_creatives(Connection(), records)
self.assertEqual(count, 2)
sql = execute_values.call_args.args[1]
rows = execute_values.call_args.args[2]
self.assertIn(
"ON CONFLICT (collect_date, style_name, platform, image_key)",
sql,
)
self.assertEqual([row[2] for row in rows], ["jd", "tm"])
def test_schema_migrates_existing_primary_key_to_platform_identity(self):
schema = (Path(product_db.__file__).with_name("schema.sql")).read_text(
encoding="utf-8"
)
expected = "PRIMARY KEY (collect_date, style_name, platform, image_key)"
self.assertGreaterEqual(schema.count(expected), 2)
self.assertIn("current_pk_columns IS DISTINCT FROM", schema)
self.assertIn("DROP CONSTRAINT", schema)
class MainImageSinkIsolationTests(unittest.TestCase):
@staticmethod
def _write_source(
root: Path,
*,
platform: str,
date_str: str = "2026-08-02",
style: str = "同款",
) -> tuple[Path, Path]:
style_dir = root / date_str / style
images_dir = style_dir / f"{platform}_images"
images_dir.mkdir(parents=True)
image_path = images_dir / "main.jpg"
image_path.write_bytes(b"image")
if platform == "tm":
payload = {
"creatives": [
{
"image_key": image_path.name,
"impressions": 10,
"clicks": 2,
}
]
}
filename = "wanxiang_creative_main_images.json"
else:
payload = {
"records": [
{
"image_key": image_path.name,
"impressions": 20,
"clicks": 3,
}
]
}
filename = "jd_main_images.json"
(style_dir / filename).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
return style_dir, image_path
def test_tmall_pg_payload_survives_feishu_discovery_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="tm")
pg_records = []
with (
patch.object(
tmall_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(
tmall_insert,
"discover_fields",
side_effect=RuntimeError("Feishu unavailable"),
),
):
result = tmall_insert.process_product(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertTrue(result["errors"])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "tm")
self.assertFalse(pg_records[0]["uploaded_to_bitable"])
def test_jd_pg_payload_survives_feishu_discovery_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="jd")
pg_records = []
with (
patch.object(
jd_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(
jd_insert,
"discover_fields",
side_effect=RuntimeError("Feishu unavailable"),
),
):
result = jd_insert.process_style(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertTrue(result["errors"])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "jd")
self.assertFalse(pg_records[0]["uploaded_to_bitable"])
def test_tmall_pg_payload_survives_feishu_record_lookup_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="tm")
pg_records = []
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
with (
patch.object(
tmall_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(tmall_insert, "discover_fields", return_value=fields),
patch.object(
tmall_insert,
"list_existing_records",
side_effect=RuntimeError("Feishu record-list unavailable"),
),
):
result = tmall_insert.process_product(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertIn("record-list 失败", result["errors"][0])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "tm")
def test_jd_pg_payload_survives_feishu_record_lookup_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self._write_source(root, platform="jd")
pg_records = []
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
with (
patch.object(
jd_insert,
"artifact_relative_path",
return_value="raw/product_commerce/main.jpg",
),
patch.object(jd_insert, "discover_fields", return_value=fields),
patch.object(
jd_insert,
"list_existing_records",
side_effect=RuntimeError("Feishu record-list unavailable"),
),
):
result = jd_insert.process_style(
"同款",
{"base_token": "base", "table_id": "table"},
"2026-08-02",
root,
False,
pg_records,
)
self.assertIn("record-list 失败", result["errors"][0])
self.assertEqual(len(pg_records), 1)
self.assertEqual(pg_records[0]["platform"], "jd")
def test_feishu_existing_record_lookup_is_platform_scoped(self):
fields = {
"时间": "time",
"平台": "platform",
"曝光量": "impressions",
"点击量": "clicks",
"主图": "image",
}
payload = {
"data": {
"field_id_list": list(fields.values()),
"record_id_list": ["tm-record", "jd-record"],
"data": [
["本周", ["天猫"], 10, 2, [{"name": "main.jpg"}]],
["本周", ["京东"], 20, 3, [{"name": "main.jpg"}]],
],
}
}
with patch.object(
tmall_insert,
"run_lark",
return_value=(0, json.dumps(payload, ensure_ascii=False), ""),
) as run_lark:
by_image, metric_tuples, duplicates = (
tmall_insert.list_existing_records(
"base", "table", fields, "本周", "京东"
)
)
command = run_lark.call_args.args[0]
filter_json = json.loads(command[command.index("--filter-json") + 1])
self.assertIn(["平台", "==", "京东"], filter_json["conditions"])
self.assertEqual(by_image["main.jpg"]["record_id"], "jd-record")
self.assertEqual(metric_tuples, {(20, 3)})
self.assertEqual(duplicates, {})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
from datetime import date, datetime
from unittest.mock import MagicMock, patch
import market_rank_hermes_notification as notification
REPORTS = [
{
"platform": "tm",
"feishu_doc_url": "https://example.com/tm",
"product_count": 1178,
"report_title": "天猫九品类市场排行",
},
{
"platform": "jd",
"feishu_doc_url": "https://example.com/jd",
"product_count": 1050,
"report_title": "京东市场排行",
},
{
"platform": "dy",
"feishu_doc_url": "https://example.com/dy",
"product_count": 1285,
"report_title": "抖音市场排行",
},
]
def test_build_notification_message_contains_three_plain_links():
message = notification.build_notification_message(date(2026, 7, 26), REPORTS)
assert "三平台市场排行" in message
assert "天猫:https://example.com/tm" in message
assert "京东:https://example.com/jd" in message
assert "抖音:https://example.com/dy" in message
assert "1178" in message
def test_build_notification_message_supports_partial_platform_success():
message = notification.build_notification_message(
date(2026, 7, 26),
[REPORTS[1]],
)
assert "京东:https://example.com/jd" in message
assert "天猫:https://" not in message
assert "抖音:https://" not in message
assert "当前成功平台 1 个" in message
def test_build_hermes_payload_requires_direct_message_to_recipient():
payload = notification.build_hermes_payload(
recipient_open_id="ou_wangyunlong",
message="三个链接",
)
encoded = json.dumps(payload, ensure_ascii=False)
assert "ou_wangyunlong" in encoded
assert "飞书私聊" in encoded
assert "三个链接" in encoded
def test_extracts_real_feishu_message_id_from_hermes_completion():
response = {
"id": "api-response-id",
"choices": [
{
"message": {
"content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323"
}
}
],
}
assert (
notification.extract_feishu_message_id(response)
== "om_x100b696f1169f0a0b1f9c311e8dc323"
)
assert notification.hermes_send_succeeded(response) is True
def test_hermes_success_text_without_real_message_id_is_not_success():
response = {
"choices": [{"message": {"content": "发送成功,但没有返回消息回执"}}]
}
assert notification.extract_feishu_message_id(response) is None
assert notification.hermes_send_succeeded(response) is False
def test_extracts_nested_feishu_message_id_and_accepts_safe_suffixes():
response = {"data": {"message_id": "om_real-id_123"}}
assert notification.extract_feishu_message_id(response) == "om_real-id_123"
assert notification.hermes_send_succeeded(response) is True
def test_notify_skips_when_same_day_recipient_was_already_sent():
conn = MagicMock()
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS),
patch.object(notification, "market_rank_notification_was_sent", return_value=True),
patch.object(notification, "post_hermes") as post_hermes,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "skipped"
assert result["reason"] == "already_sent"
post_hermes.assert_not_called()
def test_notify_sends_only_after_all_three_links_exist_and_persists_success():
conn = MagicMock()
response = {
"id": "hermes-response-id",
"choices": [
{
"message": {
"content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323"
}
}
],
}
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "market_rank_notification_was_sent", return_value=False),
patch.object(
notification,
"get_market_rank_reports_for_date",
return_value=REPORTS,
) as get_reports,
patch.object(notification, "post_hermes", return_value=response) as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
cutoff = datetime(2026, 7, 26, 10, 0, 0)
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
generated_after=cutoff,
)
assert result["status"] == "sent"
get_reports.assert_called_once_with(
conn,
date(2026, 7, 26),
notification.PLATFORM_ORDER,
updated_after=cutoff,
)
post_hermes.assert_called_once()
saved = upsert.call_args.args[1]
assert saved["status"] == "sent"
assert saved["platform_links"] == {
"tm": "https://example.com/tm",
"jd": "https://example.com/jd",
"dy": "https://example.com/dy",
}
def test_notify_sends_available_links_when_other_platforms_are_missing():
conn = MagicMock()
response = {
"choices": [{"message": {"content": "发送成功,消息ID om_partial123"}}]
}
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "market_rank_notification_was_sent", return_value=False),
patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS[:2]),
patch.object(notification, "post_hermes", return_value=response) as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "sent"
assert result["missing_platforms"] == ["dy"]
post_hermes.assert_called_once()
assert upsert.call_args.args[1]["status"] == "sent"
def test_notify_skips_only_when_current_run_has_no_links():
conn = MagicMock()
with (
patch.object(notification, "get_conn") as get_conn,
patch.object(notification, "ensure_market_rank_notification_table"),
patch.object(notification, "get_market_rank_reports_for_date", return_value=[]),
patch.object(notification, "market_rank_notification_was_sent") as was_sent,
patch.object(notification, "post_hermes") as post_hermes,
patch.object(notification, "upsert_market_rank_notification") as upsert,
):
get_conn.return_value.__enter__.return_value = conn
result = notification.notify_market_rank_reports(
report_date=date(2026, 7, 26),
recipient_open_id="ou_wangyunlong",
)
assert result["status"] == "skipped"
assert result["reason"] == "no_platform_reports"
was_sent.assert_not_called()
post_hermes.assert_not_called()
assert upsert.call_args.args[1]["status"] == "incomplete"
@@ -0,0 +1,61 @@
import pytest
from gyxx_flow.modules.product_commerce.market_rank_limits import (
category_rank_limit,
remaining_category_rank_limit,
require_valid_market_rank_products,
)
def test_tmall_and_douyin_collect_top_200_per_category():
assert category_rank_limit("tm", "旅行箱") == 200
assert category_rank_limit("tm", "双肩包") == 200
assert category_rank_limit("dy", "旅行箱") == 200
assert category_rank_limit("dy", "电脑包") == 200
def test_jd_collects_top_150_for_luggage_and_top_100_for_other_categories():
assert category_rank_limit("jd", "旅行箱") == 150
assert category_rank_limit("jd", "运动包") == 100
assert category_rank_limit("jd", "双肩包") == 100
assert category_rank_limit("jd", "电脑包") == 100
def test_remaining_limit_is_per_category_and_respects_optional_global_limit():
assert remaining_category_rank_limit("tm", "双肩包", category_count=80) == 120
assert remaining_category_rank_limit("jd", "旅行箱", category_count=120) == 30
assert remaining_category_rank_limit("jd", "电脑包", category_count=100) == 0
assert remaining_category_rank_limit(
"dy",
"双肩包",
category_count=20,
total_count=490,
global_limit=500,
) == 10
@pytest.mark.parametrize(
("platform", "rows"),
[
("tm", ["not-a-product-row"]),
("jd", [{"name": "", "rank": 1, "sales_amount_upper": 10}]),
("dy", [{"name": "商品", "rank": 1, "sales_amount_upper": 0}]),
],
)
def test_zero_valid_platform_results_are_never_success(platform, rows):
with pytest.raises(RuntimeError, match="0 条有效商品.*拒绝标记成功"):
require_valid_market_rank_products(platform, rows)
@pytest.mark.parametrize(
("platform", "metric_name"),
[
("tm", "buyer_max"),
("jd", "sales_amount_upper"),
("dy", "sales_amount_upper"),
],
)
def test_valid_platform_result_passes_gate(platform, metric_name):
row = {"name": "有效商品", "rank": 1, metric_name: 100}
assert require_valid_market_rank_products(platform, [row]) == 1
@@ -0,0 +1,123 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from gyxx_flow.modules.product_commerce.market_rank_product_import import (
build_product_snapshot,
extract_document_labels,
normalize_scene_name,
normalize_style_name,
)
RUNTIME_ROOT = (
Path(__file__).resolve().parents[3]
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
)
def test_known_label_aliases_are_normalized_without_guessing_unknown_styles():
assert normalize_style_name("都是新旅") == "都市新旅"
assert normalize_style_name("智性通勤") == "智性通勤"
assert normalize_style_name("都市运动") == "都市运动"
assert normalize_style_name("都市通勤") is None
assert normalize_scene_name("上班通勤") == "通勤上班"
assert normalize_scene_name("摄影爱好") == "摄影爱好"
def test_tmall_snapshot_uses_buyer_upper_times_cached_unit_price():
snapshot = build_product_snapshot(
"tm",
{
"rank": 1,
"name": "旅行箱",
"buyer_range": "100 ~ 250",
"buyer_max": 250,
"product_url": "https://detail.tmall.com/item.htm?id=123",
"category_name": "旅行箱",
"category_path": "箱包 > 旅行箱",
},
price_cache={"123": "¥418"},
)
assert snapshot["platform_product_id"] == "123"
assert snapshot["unit_price_min"] == Decimal("418")
assert snapshot["unit_price_max"] == Decimal("418")
assert snapshot["estimated_gmv"] == Decimal("104500")
assert snapshot["estimate_method"] == "buyer_upper_x_unit_price"
assert snapshot["quality_status"] == "complete"
def test_douyin_and_jd_snapshots_use_reported_sales_upper():
for platform in ("dy", "jd"):
snapshot = build_product_snapshot(
platform,
{
"product_id": "9988",
"name": "商品",
"sales_amount_range": "¥100万-¥250万",
"sales_amount_upper": 2_500_000,
"price_range": "¥398-¥617",
"product_url": "https://example.com/9988",
"category_name": "双肩包",
},
)
assert snapshot["estimated_gmv"] == Decimal("2500000")
assert snapshot["estimate_method"] == "reported_sales_upper"
assert snapshot["quality_status"] == "complete"
def test_missing_tmall_price_keeps_product_but_excludes_it_from_amount():
snapshot = build_product_snapshot(
"tm",
{
"buyer_max": 100,
"name": "没有价格的商品",
"product_url": "https://detail.tmall.com/item.htm?id=456",
"category_name": "相机包",
},
price_cache={},
)
assert snapshot["estimated_gmv"] is None
assert snapshot["quality_status"] == "missing_price"
def test_feishu_document_labels_keep_raw_values_and_normalized_values():
content = """
<title>京东榜单</title>
<h2>旅行箱(1条)</h2>
<table>
<thead><tr>
<th>风格(运营手动区分)</th><th>场景用途(运营手动区分)</th>
<th>品类</th><th>排名</th><th>商品图</th><th>商品名称</th>
<th>销售额</th><th>商品价格</th><th>商品链接</th>
</tr></thead>
<tbody><tr>
<td><b>都是新旅</b></td><td>上班通勤</td><td>旅行箱</td><td>1</td>
<td></td><td>测试商品</td><td>¥50万~¥75万</td><td>¥399</td>
<td><a href="https://item.jd.com/123456.html">打开商品</a></td>
</tr></tbody>
</table>
"""
labels = extract_document_labels(content, "jd")
assert labels["123456"]["raw_style_name"] == "都是新旅"
assert labels["123456"]["style_name"] == "都市新旅"
assert labels["123456"]["raw_scene_name"] == "上班通勤"
assert labels["123456"]["scene_name"] == "通勤上班"
assert labels["123456"]["unit_price_text"] == "¥399"
def test_collector_schema_contains_idempotent_market_snapshot_tables():
schema = (RUNTIME_ROOT / "db" / "schema.sql").read_text(encoding="utf-8")
assert "CREATE TABLE IF NOT EXISTS market_product" in schema
assert "CREATE TABLE IF NOT EXISTS fact_market_product_snapshot" in schema
assert "CREATE TABLE IF NOT EXISTS market_product_classification" in schema
assert "UNIQUE (report_id, market_product_id, category_name)" in schema
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import date
from unittest.mock import patch
from db import upsert_market_rank_report
from market_rank_report_archive import (
archive_market_rank_report,
extract_feishu_doc_id,
normalize_market_rank_platform,
)
class FakeCursor:
def __init__(self):
self.sql = ""
self.params = ()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params):
self.sql = sql
self.params = params
def fetchone(self):
return (37,)
class FakeConnection:
def __init__(self):
self.cursor_instance = FakeCursor()
def cursor(self):
return self.cursor_instance
def test_extract_feishu_doc_id_from_docx_url():
assert (
extract_feishu_doc_id(
"https://bu0zgpibak.feishu.cn/docx/ZPCYdLtvfoksGmxy5zcc8E7anzg"
)
== "ZPCYdLtvfoksGmxy5zcc8E7anzg"
)
def test_platform_names_are_normalized_to_database_codes():
assert normalize_market_rank_platform("天猫") == "tm"
assert normalize_market_rank_platform("jd") == "jd"
assert normalize_market_rank_platform("抖音") == "dy"
def test_upsert_market_rank_report_is_idempotent_per_platform_and_day():
conn = FakeConnection()
report_id = upsert_market_rank_report(
conn,
{
"platform": "tm",
"report_date": date(2026, 7, 24),
"period": "30天",
"category_scope": "九品类",
"category_names": ["旅行箱", "运动包"],
"report_title": "天猫九品类市场排行 30天 2026-07-24",
"feishu_doc_id": "doc-token",
"feishu_doc_url": "https://example.feishu.cn/docx/doc-token",
"product_count": 146,
"source_json_path": "data/sycm_market_rank/result.json",
"run_metadata": {"min_price": 400, "max_price": 2000},
},
)
assert report_id == 37
assert "ON CONFLICT (platform, report_date)" in conn.cursor_instance.sql
assert "ON CONFLICT (platform, report_date, period, category_scope)" not in conn.cursor_instance.sql
assert conn.cursor_instance.params[0] == "tm"
assert conn.cursor_instance.params[4] == ["旅行箱", "运动包"]
assert conn.cursor_instance.params[7] == "https://example.feishu.cn/docx/doc-token"
assert conn.cursor_instance.params[8] == 146
def test_archive_market_rank_report_builds_and_saves_normalized_record():
with (
patch("market_rank_report_archive.get_conn") as get_conn,
patch(
"market_rank_report_archive.upsert_market_rank_report",
return_value=88,
) as upsert,
):
conn = object()
get_conn.return_value.__enter__.return_value = conn
report_id = archive_market_rank_report(
platform="京东",
report_date=date(2026, 7, 24),
period="近30天",
category_scope="七品类",
category_names=["旅行箱", "双肩包"],
report_title="京东七品类市场商品榜单 近30天 2026-07-24",
feishu_doc_url="https://example.feishu.cn/docx/jd-doc-token",
product_count=70,
source_json_path="data/jd_market_rank/result.json",
run_metadata={"max_pages": 20},
)
assert report_id == 88
record = upsert.call_args.args[1]
assert upsert.call_args.args[0] is conn
assert record["platform"] == "jd"
assert record["feishu_doc_id"] == "jd-doc-token"
assert record["category_names"] == ["旅行箱", "双肩包"]
assert record["run_metadata"] == {"max_pages": 20}
@@ -0,0 +1,298 @@
from __future__ import annotations
import json
import sys
from datetime import date, timedelta
from pathlib import Path
from threading import Barrier
import orchestrate_market_rank_collection as workflow
import pytest
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
PROJECT_ROOT = (
REPOSITORY_ROOT / "src" / "gyxx_flow" / "modules" / "product_commerce"
)
HISTORY_ROOT = REPOSITORY_ROOT / "docs" / "history" / "product-commerce"
def test_default_platform_order_and_commands():
assert workflow.DEFAULT_PLATFORMS == ("tm", "jd", "dy")
report_date = date(2026, 8, 4)
tm_command = workflow.build_platform_command(
"tm", report_date=report_date, python_executable=sys.executable
)
jd_command = workflow.build_platform_command(
"jd", report_date=report_date, python_executable=sys.executable
)
dy_command = workflow.build_platform_command(
"dy", report_date=report_date, python_executable=sys.executable
)
assert tm_command[0] == sys.executable
assert Path(tm_command[1]).name == "collect_sycm_market_rank.py"
assert tm_command[-2:] == ["--risk-mode", "manual"]
assert jd_command[0] == sys.executable
assert Path(jd_command[1]).name == "collect_jd_market_rank.py"
assert dy_command[0] == sys.executable
assert Path(dy_command[1]).name == "collect_dy_market_rank.py"
for command in (tm_command, jd_command, dy_command):
assert command[command.index("--report-date") + 1] == "2026-08-04"
def test_historical_live_snapshot_date_is_rejected():
today = date(2026, 8, 4)
with pytest.raises(ValueError, match="拒绝错标"):
workflow.validate_live_snapshot_date(
today - timedelta(days=1),
current_date=today,
)
def test_notification_recipient_comes_only_from_runtime_environment():
assert workflow.notification_recipient_from_env({}) == ("", "")
assert workflow.notification_recipient_from_env(
{
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": "ou_runtime",
"GYXX_NOTIFICATION_RECIPIENT_NAME": "runtime-owner",
}
) == ("ou_runtime", "runtime-owner")
assert workflow.notification_recipient_from_env(
{
"MARKET_RANK_NOTIFY_OPEN_ID": "ou_market",
"MARKET_RANK_NOTIFY_NAME": "market-owner",
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": "ou_generic",
}
) == ("ou_market", "market-owner")
def test_child_environment_forces_utf8_unbuffered_output():
env = workflow.build_subprocess_env({"EXISTING": "kept"})
assert env["EXISTING"] == "kept"
assert env["PYTHONIOENCODING"] == "utf-8"
assert env["PYTHONUNBUFFERED"] == "1"
def test_console_text_replaces_characters_not_supported_by_gbk():
assert workflow.console_safe_text("prefix \ufffd suffix", "gbk") == "prefix ? suffix"
def test_workflow_continues_after_one_platform_fails(tmp_path):
calls: list[str] = []
def fake_executor(*, platform, command, log_path, dry_run):
calls.append(platform)
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 1 if platform == "tm" else 0,
"status": "failed" if platform == "tm" else "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="test-run",
log_dir=tmp_path,
executor=fake_executor,
)
assert set(calls) == {"tm", "jd", "dy"}
assert [item["platform"] for item in summary["steps"]] == ["tm", "jd", "dy"]
assert summary["exit_code"] == 1
assert [item["status"] for item in summary["steps"]] == [
"failed", "success", "success"
]
saved = json.loads((tmp_path / "test-run_summary.json").read_text(encoding="utf-8"))
assert saved["run_id"] == "test-run"
assert saved["exit_code"] == 1
def test_workflow_continues_when_executor_raises(tmp_path):
calls: list[str] = []
def raising_executor(*, platform, command, log_path, dry_run):
calls.append(platform)
if platform == "tm":
raise RuntimeError("browser startup failed")
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="raised-run",
log_dir=tmp_path,
executor=raising_executor,
)
assert set(calls) == {"tm", "jd", "dy"}
assert summary["exit_code"] == 1
assert summary["steps"][0]["status"] == "failed"
assert "browser startup failed" in summary["steps"][0]["error"]
def test_three_platforms_start_in_parallel(tmp_path):
all_started = Barrier(3)
def synchronized_executor(*, platform, command, log_path, dry_run):
all_started.wait(timeout=1)
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="parallel-run",
log_dir=tmp_path,
executor=synchronized_executor,
)
assert summary["exit_code"] == 0
assert [item["platform"] for item in summary["steps"]] == ["tm", "jd", "dy"]
def test_workflow_notifies_after_all_parallel_steps_finish(tmp_path):
all_started = Barrier(3)
notifications: list[dict] = []
report_date = date.today()
def synchronized_executor(*, platform, command, log_path, dry_run):
all_started.wait(timeout=1)
return {
"platform": platform,
"label": workflow.PLATFORM_LABELS[platform],
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
def fake_notifier(**kwargs):
notifications.append(kwargs)
return {"status": "sent", "recipient_open_id": kwargs["recipient_open_id"]}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="notify-run",
log_dir=tmp_path,
executor=synchronized_executor,
report_date=report_date,
notify_open_id="ou_wangyunlong",
notify_name="runtime-owner",
notifier=fake_notifier,
)
assert len(notifications) == 1
assert notifications[0]["report_date"] == report_date
assert notifications[0]["recipient_open_id"] == "ou_wangyunlong"
assert notifications[0]["recipient_name"] == "runtime-owner"
assert notifications[0]["generated_after"] is not None
for step in summary["steps"]:
command = step["command"]
assert command[command.index("--report-date") + 1] == report_date.isoformat()
assert summary["notification"]["status"] == "sent"
saved = json.loads((tmp_path / "notify-run_summary.json").read_text(encoding="utf-8"))
assert saved["notification"]["status"] == "sent"
def test_partial_current_run_links_can_still_send_successfully(tmp_path):
def successful_executor(*, platform, command, log_path, dry_run):
return {
"platform": platform,
"command": command,
"log_path": str(log_path),
"started_at": "2026-07-26T10:00:00",
"finished_at": "2026-07-26T10:01:00",
"duration_seconds": 60.0,
"exit_code": 0,
"status": "success",
}
summary = workflow.execute_workflow(
workflow.DEFAULT_PLATFORMS,
run_id="missing-link-run",
log_dir=tmp_path,
executor=successful_executor,
notify_open_id="ou_wangyunlong",
notifier=lambda **kwargs: {
"status": "sent",
"platform_links": {"jd": "https://example.com/jd"},
"missing_platforms": ["tm", "dy"],
},
)
assert summary["exit_code"] == 0
assert summary["status"] == "success"
def test_platform_parser_deduplicates_while_preserving_order():
assert workflow.parse_platforms("dy,tm,dy,jd") == ("dy", "tm", "jd")
def test_main_requires_runtime_recipient_unless_notification_disabled(monkeypatch):
monkeypatch.setattr(workflow, "DEFAULT_NOTIFY_OPEN_ID", "")
monkeypatch.setattr(sys, "argv", ["orchestrate_market_rank_collection.py", "--dry-run"])
with pytest.raises(SystemExit) as exc_info:
workflow.main()
assert exc_info.value.code == 2
def test_main_allows_explicit_no_notify_without_recipient(monkeypatch, tmp_path):
monkeypatch.setattr(workflow, "DEFAULT_NOTIFY_OPEN_ID", "")
observed = {}
def fake_execute(*args, **kwargs):
observed.update(kwargs)
return {"exit_code": 0}
monkeypatch.setattr(workflow, "execute_workflow", fake_execute)
monkeypatch.setattr(workflow, "managed_data_path", Path)
monkeypatch.setattr(
sys,
"argv",
[
"orchestrate_market_rank_collection.py",
"--dry-run",
"--no-notify",
"--log-dir",
str(tmp_path),
],
)
assert workflow.main() == 0
assert observed["notify_open_id"] is None
def test_weekly_launcher_uses_market_rank_orchestrator():
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_weekly_market_rank.bat"
).read_text(encoding="utf-8")
assert ".venv\\Scripts\\python.exe" in launcher
assert "orchestrate_market_rank_collection.py" in launcher
@@ -0,0 +1,319 @@
from __future__ import annotations
import json
import sys
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import collect_dy_persona_to_bitable as dy_worker
import collect_jd_persona_to_bitable as jd_worker
import collect_persona_to_bitable as tm_worker
import dy_audience_profile_collect as dy_collector
import pytest
import taobao_dmp_item_crowd_insight_screenshots as dmp
from gyxx_flow.adapters import acceptance_policy
WORKERS = (tm_worker, dy_worker, jd_worker)
BUSINESS_DATE = date(2026, 8, 4)
@pytest.mark.parametrize("worker", WORKERS)
def test_platform_collector_failure_forces_nonzero(worker) -> None:
assert worker.combined_result_exit_code(
[{"status": "ok"}],
collection_ok=False,
) == 2
@pytest.mark.parametrize("worker", WORKERS)
def test_formal_base_upsert_requires_response_record_id(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: False)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: SimpleNamespace(
returncode=0,
stdout=json.dumps({"ok": True, "data": {}}),
stderr="",
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is False
assert "record_id" in message
@pytest.mark.parametrize("worker", WORKERS)
def test_formal_base_upsert_accepts_verified_response_record_id(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: False)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: SimpleNamespace(
returncode=0,
stdout=json.dumps(
{
"ok": True,
"data": {"record": {"record_id_list": ["rec12345678"]}},
}
),
stderr="",
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is True
assert message == "record_id=rec12345678"
@pytest.mark.parametrize("worker", WORKERS)
def test_acceptance_skip_is_not_formal_success(monkeypatch, worker) -> None:
monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: True)
monkeypatch.setattr(
worker.subprocess,
"run",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("lark subprocess must not run after acceptance skip")
),
)
ok, message = worker.upsert_record(
"bas_test",
"tbl_test",
{"时间": "8.4", "男性比例": 0.5},
)
assert ok is False
assert "acceptance-skipped" in message
def test_tm_payload_must_match_requested_business_date(monkeypatch, tmp_path: Path) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
(style_dir / f"{style}_123_chart_values.json").write_text(
json.dumps(
{
"style_name": style,
"item_id": "123",
"business_date": "2026-08-03",
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(tm_worker, "DMP_OUTPUT_ROOT", tmp_path)
assert tm_worker.load_latest_chart_values(style, BUSINESS_DATE) is None
@pytest.mark.parametrize("worker", (dy_worker, jd_worker))
def test_profile_payload_must_match_requested_business_date(
monkeypatch,
tmp_path: Path,
worker,
) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
(style_dir / f"{style}_123_profile.json").write_text(
json.dumps(
{
"style_name": style,
"product_id": "123",
"business_date": "2026-08-03",
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(worker, "OUTPUT_ROOT", tmp_path)
assert worker.load_latest_profile(style, BUSINESS_DATE) is None
@pytest.mark.parametrize("worker", (tm_worker, dy_worker))
def test_current_run_does_not_reuse_old_artifact(monkeypatch, tmp_path: Path, worker) -> None:
style = "款A"
style_dir = tmp_path / BUSINESS_DATE.isoformat() / style
style_dir.mkdir(parents=True)
if worker is tm_worker:
path = style_dir / f"{style}_123_chart_values.json"
payload = {
"style_name": style,
"item_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
}
monkeypatch.setattr(worker, "DMP_OUTPUT_ROOT", tmp_path)
load = worker.load_latest_chart_values
else:
path = style_dir / f"{style}_123_profile.json"
payload = {
"style_name": style,
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
}
monkeypatch.setattr(worker, "OUTPUT_ROOT", tmp_path)
load = worker.load_latest_profile
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
assert load(
style,
BUSINESS_DATE,
artifact_not_before=path.stat().st_mtime + 10,
) is None
@pytest.mark.parametrize("worker", WORKERS)
def test_database_failure_stops_before_base_write(monkeypatch, worker) -> None:
if worker is tm_worker:
monkeypatch.setattr(
worker,
"load_latest_chart_values",
lambda *_a, **_k: {
"style_name": "款A",
"item_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"chart_values": {"用户性别": [{"category": "男性用户", "分析人群占比": 50}]},
},
)
monkeypatch.setattr(
worker,
"chart_values_to_fields",
lambda *_a, **_k: {"时间": "8.4", "男性比例": 0.5},
)
normalize_name = "normalize_tm_payload"
elif worker is dy_worker:
monkeypatch.setattr(
worker,
"load_latest_profile",
lambda *_a, **_k: {
"style_name": "款A",
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"gender_distribution": [{"name": "", "value": 50}]},
},
)
monkeypatch.setattr(worker, "profile_has_positive_distribution", lambda _profile: True)
monkeypatch.setattr(worker, "get_subtable_fields", lambda *_a, **_k: [])
monkeypatch.setattr(worker, "profile_to_fields", lambda *_a, **_k: {"男性比例": 0.5})
normalize_name = "normalize_dy_payload"
else:
monkeypatch.setattr(
worker,
"load_latest_profile",
lambda *_a, **_k: {
"style_name": "款A",
"product_id": "123",
"business_date": BUSINESS_DATE.isoformat(),
"profile": {"性别": [{"name": "", "value": 50}]},
},
)
monkeypatch.setattr(worker, "get_subtable_fields", lambda *_a, **_k: [])
monkeypatch.setattr(worker, "map_profile_to_fields", lambda *_a, **_k: {"男性比例": 0.5})
normalize_name = "normalize_jd_payload"
fake_db = SimpleNamespace(
get_conn=lambda: (_ for _ in ()).throw(RuntimeError("database unavailable")),
upsert_persona_metrics=lambda *_a, **_k: 1,
**{normalize_name: lambda _payload: {"valid": True}},
)
monkeypatch.setitem(sys.modules, "db", fake_db)
monkeypatch.setattr(
worker,
"upsert_record",
lambda *_a, **_k: (_ for _ in ()).throw(
AssertionError("Base write must not run after DB failure")
),
)
result = worker.upsert_one_style(
"款A",
{"base_token": "bas_test", "table_id": "tbl_test"},
"8.4",
"user",
False,
BUSINESS_DATE,
)
assert result["status"] == "db_failed"
def test_zero_distributions_are_not_valid_persona() -> None:
assert dy_worker.profile_has_positive_distribution(
{
"gender_distribution": [{"name": "", "value": 0}],
"age_distribution": [],
"strategy_crowd_distribution": [{"name": "Z世代", "value": "0%"}],
}
) is False
def test_dmp_skips_are_not_valid_style_results() -> None:
styles = {"款A": ["123"]}
assert dmp.records_exit_code(
styles,
[{"style_name": "款A", "status": "skipped", "skip_reason": "无图表"}],
) == 2
assert dmp.records_exit_code(
styles,
[
{
"style_name": "款A",
"status": "ok",
"chart_values": {
"用户性别": [{"category": "男性用户", "分析人群占比": 50}]
},
}
],
) == 0
@pytest.mark.parametrize(
("detector", "url", "body", "expected"),
(
(dmp.blocking_page_reason, "https://dmp.taobao.com/", "暂无权限", "权限"),
(dy_collector.blocking_page_reason, "https://compass.test/", "请登录", "登录"),
(jd_worker.blocking_page_reason, "https://passport.jd.com/login", "", "登录"),
),
)
def test_login_and_permission_pages_have_explicit_diagnostics(
detector,
url: str,
body: str,
expected: str,
) -> None:
assert expected in detector(url, body)
def test_persona_search_selectors_are_finite_and_evidence_based() -> None:
assert 1 <= len(dy_collector.PRODUCT_SEARCH_INPUT_SELECTORS) <= 6
assert 1 <= len(jd_worker.JD_SEARCH_INPUT_SELECTORS) <= 6
assert all("input" in selector for selector in dy_collector.PRODUCT_SEARCH_INPUT_SELECTORS)
assert all("input" in selector for selector in jd_worker.JD_SEARCH_INPUT_SELECTORS)
def test_tm_collector_command_redacts_credentials() -> None:
rendered = tm_worker.redacted_command(
["python", "collector.py", "--account", "secret-user", "--password", "secret-pass"]
)
assert "secret-user" not in rendered
assert "secret-pass" not in rendered
assert rendered.count("***") == 2
@@ -0,0 +1,57 @@
import unittest
from datetime import date
from pathlib import Path
import collect_dy_persona_to_bitable as dy_worker
import collect_jd_persona_to_bitable as jd_worker
import collect_persona_to_bitable as tm_worker
import run_daily_persona
class PersonaLauncherTests(unittest.TestCase):
def test_any_platform_failure_makes_launcher_fail(self):
self.assertEqual(run_daily_persona.combined_exit_code({"tm": 0, "dy": 2, "jd": 0}), 2)
def test_all_platforms_success_makes_launcher_success(self):
self.assertEqual(run_daily_persona.combined_exit_code({"tm": 0, "dy": 0, "jd": 0}), 0)
def test_launcher_passes_business_date_to_each_worker(self):
command = run_daily_persona.build_child_command(
Path("collect_persona_to_bitable.py"),
date(2026, 8, 4),
)
self.assertEqual(command[-2:], ["--date", "2026-08-04"])
def test_workers_fail_when_permission_is_skipped(self):
results = [{"status": "ok"}, {"status": "permission_skipped"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code(results), 2)
def test_workers_fail_when_no_styles_were_processed(self):
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code([]), 2)
def test_workers_succeed_only_when_all_styles_are_successful(self):
results = [{"status": "ok"}, {"status": "dry-run"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(worker.combined_result_exit_code(results), 0)
def test_workers_fail_when_collector_itself_failed(self):
results = [{"status": "ok"}]
for worker in (tm_worker, dy_worker, jd_worker):
with self.subTest(worker=worker.__name__):
self.assertEqual(
worker.combined_result_exit_code(results, collection_ok=False),
2,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,204 @@
from __future__ import annotations
import io
import json
from pathlib import Path
from types import SimpleNamespace
import check_nine_day_decline as decline
import insert_bitable_records as insert_records
import lark_cli_runtime
import market_rank_hermes_notification as market_notification
import pytest
from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
def _enable_acceptance(monkeypatch, tmp_path: Path) -> Path:
evidence = tmp_path / "product-evidence.jsonl"
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
monkeypatch.delenv("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", raising=False)
return evidence
@pytest.mark.parametrize(
"arguments",
[
["base", "+record-upsert"],
["base", "+record-delete"],
["sheets", "+values-batch-update"],
],
)
def test_product_shared_lark_cli_skips_table_mutations(
monkeypatch,
tmp_path: Path,
arguments: list[str],
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
def fail_if_spawned(*args, **kwargs):
raise AssertionError("lark-cli subprocess must not start")
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fail_if_spawned)
result = lark_cli_runtime.run_lark_cli(arguments)
assert result["acceptance_skipped"] is True
def test_legacy_product_upsert_bypass_is_physically_skipped(
monkeypatch,
tmp_path: Path,
) -> None:
evidence = _enable_acceptance(monkeypatch, tmp_path)
def fail_if_spawned(*args, **kwargs):
raise AssertionError("legacy lark-cli subprocess must not start")
monkeypatch.setattr(insert_records.subprocess, "run", fail_if_spawned)
insert_records.run_lark_upsert("base_secret", "tbl_test", {"field": 1})
payload = json.loads(evidence.read_text(encoding="utf-8"))
assert payload["operation"].endswith("record-upsert")
assert payload["details"]["base_token"] == "<redacted>"
def test_product_shared_lark_cli_keeps_production_write_behavior(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
calls = []
def fake_run(command, **kwargs):
calls.append(command)
return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="")
monkeypatch.setattr(lark_cli_runtime, "_node_cli_entry", lambda: ("node", "run.js"))
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fake_run)
result = lark_cli_runtime.run_lark_cli(["base", "+record-upsert"])
assert result == {"ok": True}
assert len(calls) == 1
assert calls[0][:4] == ["node", "run.js", "--profile", "hermes-analyzer"]
def test_product_shared_lark_cli_allows_safe_profile_override(monkeypatch) -> None:
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
calls = []
def fake_run(command, **kwargs):
calls.append(command)
return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="")
monkeypatch.setattr(lark_cli_runtime, "_node_cli_entry", lambda: ("node", "run.js"))
monkeypatch.setattr(lark_cli_runtime.subprocess, "run", fake_run)
lark_cli_runtime.run_lark_cli(
["auth", "status", "--json"],
env={"GYXX_LARK_CLI_PROFILE": "product-analyzer.test"},
)
assert calls[0][:4] == [
"node",
"run.js",
"--profile",
"product-analyzer.test",
]
def test_decline_hermes_prompt_forces_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "HERMES_API_KEY", "test-only")
captured = {}
class Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return json.dumps({"choices": [{"message": {"content": "ok"}}]}).encode()
def fake_urlopen(request, timeout):
captured["body"] = json.load(io.BytesIO(request.data))
return Response()
monkeypatch.setattr(decline.urllib.request, "urlopen", fake_urlopen)
decline.notify_hermes(
[{"style_name": "test", "platform": "tm"}],
"2026-08-01",
9,
openid="ou_someone_else",
)
encoded = json.dumps(captured["body"], ensure_ascii=False)
assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded
assert "ou_someone_else" not in encoded
def test_market_rank_notification_forces_wang_yunlong(
monkeypatch,
tmp_path: Path,
) -> None:
_enable_acceptance(monkeypatch, tmp_path)
captured = {}
class ConnectionContext:
def __enter__(self):
return object()
def __exit__(self, *args):
return False
monkeypatch.setattr(market_notification, "get_conn", ConnectionContext)
monkeypatch.setattr(
market_notification,
"ensure_market_rank_notification_table",
lambda conn: None,
)
monkeypatch.setattr(
market_notification,
"get_market_rank_reports_for_date",
lambda *args, **kwargs: [
{
"platform": "tm",
"feishu_doc_url": "https://example.test/tm",
"product_count": 1,
}
],
)
monkeypatch.setattr(
market_notification,
"market_rank_notification_was_sent",
lambda *args, **kwargs: False,
)
def fake_post(payload):
captured["payload"] = payload
return {"message_id": "om_test"}
def fake_archive(conn, record):
captured["record"] = record
monkeypatch.setattr(market_notification, "post_hermes", fake_post)
monkeypatch.setattr(
market_notification,
"upsert_market_rank_notification",
fake_archive,
)
result = market_notification.notify_market_rank_reports(
report_date=decline.date(2026, 8, 1),
recipient_open_id="ou_someone_else",
)
encoded = json.dumps(captured["payload"], ensure_ascii=False)
assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded
assert "ou_someone_else" not in encoded
assert result["recipient_open_id"] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
assert (
captured["record"]["recipient_open_id"]
== WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID
)
@@ -0,0 +1,232 @@
from __future__ import annotations
import subprocess
from datetime import date
from pathlib import Path
import backfill_collect
import backfill_one_day
import collect_dy_persona_to_bitable as dy_persona_worker
import collect_persona_to_bitable as tm_persona_worker
import orchestrate_daily_collection as daily
import orchestrate_market_rank_collection as market_rank
import orchestrate_review_collection as reviews
import run_alerts_with_retry as alerts
import run_daily_persona as persona
import run_weekly_jd_main_image as jd_main_image
import run_weekly_main_image as tm_main_image
def _fake_rebind(observed: dict[str, object]):
def rebind(target, base_environment):
observed["target"] = Path(target)
observed["base"] = dict(base_environment)
return {
**base_environment,
"GYXX_SCRIPT_ID": f"product_commerce:{Path(target).name}",
"GYXX_BROWSER_CDP_PORT": "child-port",
"GYXX_BROWSER_PROFILE_DIR": "child-profile",
"GYXX_BROWSER_COOKIE_FILE": "child-cookie",
"GYXX_BROWSER_STORAGE_STATE_FILE": "child-storage",
}
return rebind
def _assert_child_binding(environment: dict[str, str]) -> None:
assert environment["GYXX_BROWSER_CDP_PORT"] == "child-port"
assert environment["GYXX_BROWSER_PROFILE_DIR"] == "child-profile"
assert environment["GYXX_BROWSER_COOKIE_FILE"] == "child-cookie"
assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == "child-storage"
def test_persona_builds_one_target_bound_environment_per_collector(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(persona, "environment_for_child_script", _fake_rebind(observed))
target = persona.PROJECT_ROOT / "collect_persona_to_bitable.py"
environment = persona.build_child_environment(target, {"KEEP": "yes"})
assert observed["target"] == target
assert environment["KEEP"] == "yes"
_assert_child_binding(environment)
def test_persona_workers_rebind_their_nested_browser_collectors(monkeypatch) -> None:
cases = (
(
tm_persona_worker,
tm_persona_worker.DMP_SCRIPT,
lambda: tm_persona_worker.run_dmp_collect_all(
"parent-profile",
False,
"",
"",
date(2026, 8, 4),
),
),
(
dy_persona_worker,
dy_persona_worker.COLLECT_SCRIPT,
lambda: dy_persona_worker.run_collect_all(
"parent-profile",
False,
date(2026, 8, 4),
),
),
)
for worker, target, invoke in cases:
observed: dict[str, object] = {}
monkeypatch.setattr(
worker,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["command"] = command
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(worker.subprocess, "run", fake_run)
assert invoke() is True
assert observed["target"] == target
command = observed["command"]
assert command[command.index("--date") + 1] == "2026-08-04"
_assert_child_binding(observed["environment"])
def test_daily_run_step_rebinds_to_command_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(daily, "environment_for_child_script", _fake_rebind(observed))
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(daily.subprocess, "run", fake_run)
target = daily.PROJECT_ROOT / "dy_product_scraping.py"
result = daily.run_step("dy", ["python", str(target)], critical=True)
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_market_rank_rebinds_to_each_platform_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
market_rank,
"environment_for_child_script",
_fake_rebind(observed),
)
target = market_rank.PROJECT_ROOT / "collect_jd_market_rank.py"
environment = market_rank.build_subprocess_env(
{"KEEP": "yes"},
target=target,
)
assert observed["target"] == target
assert environment["KEEP"] == "yes"
assert environment["PYTHONIOENCODING"] == "utf-8"
assert environment["PYTHONUNBUFFERED"] == "1"
_assert_child_binding(environment)
def test_review_run_step_rebinds_to_command_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(reviews, "environment_for_child_script", _fake_rebind(observed))
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(reviews.subprocess, "run", fake_run)
target = reviews.PROJECT_ROOT / "dy_product_scraping.py"
result = reviews.run_step("dy", ["python", str(target)], critical=True)
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_main_image_launchers_rebind_to_command_target(monkeypatch) -> None:
for launcher in (tm_main_image, jd_main_image):
observed: dict[str, object] = {}
monkeypatch.setattr(
launcher,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(launcher.subprocess, "run", fake_run)
target = launcher.PROJECT_ROOT / "collector.py"
result = launcher.run_step("collect", ["python", str(target)])
assert result["code"] == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
def test_alert_wrapper_exposes_target_bound_environment(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(alerts, "environment_for_child_script", _fake_rebind(observed))
target = alerts.PROJECT_ROOT / "check_nine_day_decline.py"
environment = alerts.build_child_environment(target, {"KEEP": "yes"})
assert observed["target"] == target
assert environment["KEEP"] == "yes"
_assert_child_binding(environment)
def test_backfill_range_rebinds_daily_orchestrator_after_overrides(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
backfill_collect,
"environment_for_child_script",
_fake_rebind(observed),
)
target = backfill_collect.PROJECT_ROOT / "orchestrate_daily_collection.py"
environment = backfill_collect._subprocess_env(target)
assert observed["target"] == target
assert observed["base"]["KEEP_BROWSER_OPEN"] == "0"
assert environment["KEEP_BROWSER_OPEN"] == "0"
_assert_child_binding(environment)
def test_backfill_day_rebinds_each_stage_target(monkeypatch) -> None:
observed: dict[str, object] = {}
monkeypatch.setattr(
backfill_one_day,
"environment_for_child_script",
_fake_rebind(observed),
)
def fake_run(command, **kwargs):
observed["environment"] = kwargs["env"]
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(backfill_one_day.subprocess, "run", fake_run)
target = backfill_one_day.PROJECT_ROOT / "dy_product_scraping.py"
code, _elapsed = backfill_one_day.run_subprocess(
"dy",
["python", str(target)],
"2026-07-31",
)
assert code == 0
assert observed["target"] == target
_assert_child_binding(observed["environment"])
@@ -0,0 +1,35 @@
from __future__ import annotations
import json
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
def _workflow(workflow_id: str) -> dict:
catalog = json.loads(
(REPOSITORY_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
)
return next(item for item in catalog["workflows"] if item["id"] == workflow_id)
def test_product_workflows_forward_the_selected_business_date() -> None:
persona = _workflow("product.persona.daily")["execution"]["steps"][0]
style = _workflow("product.style_analysis.interval")["execution"]["steps"][0]
main_image = _workflow("product.main_image.weekly")["execution"]["steps"]
market_rank = _workflow("product.market_rank")["execution"]["steps"][0]
monthly_sales = _workflow("product.sales_sheet.daily")["execution"]["steps"][0]
assert persona["args"] == ["--date", "{business_date}"]
assert style["args"][-2:] == ["--end-date", "{business_date}"]
assert all(
step["args"][:2] == ["--date", "{business_date}"]
for step in main_image
)
assert market_rank["args"] == ["--report-date", "{business_date}"]
assert monthly_sales["args"] == [
"--month",
"{business_date}",
"--execute",
"--allow-missing-erp-as-zero",
]
@@ -0,0 +1,695 @@
import json
import os
import tempfile
import unittest
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import analyze_style_with_hermes as analysis
import orchestrate_daily_collection as orchestrator
from config.style_config_loader import StyleConfigLoader
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
HISTORY_ROOT = REPOSITORY_ROOT / "docs" / "history" / "product-commerce"
class StyleAnalysisConfigTests(unittest.TestCase):
def test_platform_analysis_url_is_aggregated_by_style(self):
loader = StyleConfigLoader()
payload = loader._aggregate([
{
"platform": "天猫",
"style": "盖亚斜挎",
"style_analysis_bitable_url": (
"[分析表](https://example.feishu.cn/base/base_token"
"?table=table_id&view=view_id)"
),
}
])
self.assertEqual(
payload["styles"]["盖亚斜挎"]["style_analysis_bitable"],
{"base_token": "base_token", "table_id": "table_id", "view_id": "view_id"},
)
class StyleAnalysisWindowTests(unittest.TestCase):
def test_main_image_analysis_remains_tmall_scoped_after_jd_merge(self):
class Cursor:
sql = ""
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params):
self.sql = sql
self.params = params
@staticmethod
def fetchall():
return []
class Connection:
def __init__(self, cursor):
self._cursor = cursor
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def cursor(self):
return self._cursor
cursor = Cursor()
with patch.object(analysis, "get_conn", return_value=Connection(cursor)):
rows = analysis.pull_main_image(
"同款", date(2026, 7, 1), date(2026, 7, 3)
)
self.assertEqual(rows, [])
self.assertIn("platform='tm'", cursor.sql)
def test_three_day_label_uses_compact_end_date(self):
self.assertEqual(
analysis.analysis_period_label(date(2026, 7, 13), date(2026, 7, 15)),
"2026-07-13~07-15",
)
def test_due_styles_wait_three_full_days(self):
styles = ["A", "B", "C"]
latest = {"A": date(2026, 7, 15), "B": date(2026, 7, 12)}
due = analysis.select_due_styles(styles, latest, date(2026, 7, 15), 3)
self.assertEqual(due, ["B", "C"])
def test_prompt_keeps_all_dimensions_inside_current_three_day_window(self):
payload = {
"total": {"sales": 30, "visitors": 1000, "cart": 80},
"platforms": {"tm": {"sales": 20}, "jd": {"sales": 10}},
"daily": [{"date": "2026-07-14", "plat": "tm", "sales": 10}],
"persona": [{"date": "2026-07-14", "plat": "tm", "gender": []}],
"product_reviews": {"tm|negative": {"n": 2, "samples": ["肩带偏硬"]}},
"main_image": [{"date": "2026-07-14", "key": "hero", "ctr": "2.1"}],
"marketing": {
"notes_by_platform_all": [{"platform": "xiaohongshu", "views": 1000}],
"note_comments_sample": [{"content": "求尺寸"}],
},
}
prompt = analysis.build_user_prompt(
"盖亚斜挎", date(2026, 7, 14), date(2026, 7, 16), payload,
)
self.assertIn("2026-07-14 ~ 2026-07-16", prompt)
self.assertNotIn("对比基期", prompt)
self.assertNotIn("紧邻上期", prompt)
self.assertNotIn("最近 6 周", prompt)
self.assertNotIn("近 60d", prompt)
self.assertIn("逐日经营数据", prompt)
self.assertIn("人物画像", prompt)
self.assertIn("商品评价", prompt)
self.assertIn("主图表现", prompt)
self.assertIn("营销笔记、曝光互动与评论", prompt)
self.assertIn("肩带偏硬", prompt)
self.assertIn("求尺寸", prompt)
self.assertIn('"sales": 30', prompt)
def test_system_prompt_requires_each_business_dimension(self):
for dimension in ("访客", "加购", "人物画像", "营销笔记", "笔记评论", "商品评价", "主图"):
self.assertIn(dimension, analysis.SYSTEM)
def test_system_prompt_requires_fixed_seven_section_report(self):
headings = [
"## 一、访客分析",
"## 二、加购率分析",
"## 三、点击率分析",
"## 四、转化率分析",
"## 五、退货率分析",
"## 六、销量分析",
"## 七、总评",
]
for heading in headings:
self.assertIn(heading, analysis.SYSTEM)
def test_plain_seven_section_titles_are_normalized_to_markdown(self):
report = """盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)
一、访客分析
内容
二、加购率分析
内容
三、点击率分析
内容
四、转化率分析
内容
五、退货率分析
内容
六、销量分析
内容
七、总评
内容
"""
normalized = analysis.normalize_report_markdown(report)
self.assertTrue(normalized.startswith("# 盖亚斜挎"))
self.assertTrue(analysis.has_exact_report_headings(normalized))
def test_report_gets_clear_subheadings_and_key_emphasis(self):
blocks = ["盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)"]
for heading in analysis.REPORT_HEADINGS[:-1]:
blocks.extend([
heading.removeprefix("## "),
"数据快照:三日访客2692,转化率0.71%。其余数据。",
"问题判断:天猫访客高但转化偏低。需要重点处理。",
"可执行建议:P0,24小时内完成核查,目标转化率1.00%",
])
blocks.extend([
"七、总评",
"核心结论:当前核心问题是高访客、低转化。需要优先修复。",
"链路表现为:曝光到访客再到成交。",
"P0,48小时内完成详情页整改,目标转化率1.00%",
])
normalized = analysis.normalize_report_markdown("\n\n".join(blocks))
self.assertTrue(analysis.has_clear_report_hierarchy(normalized))
self.assertTrue(analysis.has_key_emphasis(normalized))
self.assertIn("### 数据快照", normalized)
self.assertIn("### 核心判断", normalized)
self.assertIn("### 优先级行动", normalized)
self.assertIn("**P0**", normalized)
self.assertIn("**24小时内**", normalized)
def test_extra_helpful_h3_does_not_reject_complete_required_hierarchy(self):
required = "\n".join(analysis.REPORT_SUBHEADINGS)
report = required.replace(
"### 核心判断\n",
"### 平台明细\n补充内容\n### 核心判断\n",
1,
)
self.assertTrue(analysis.has_clear_report_hierarchy(report))
def test_missing_calendar_day_is_reported(self):
rows = [
{"date": "2026-07-14", "plat": "tm"},
{"date": "2026-07-16", "plat": "jd"},
]
missing = analysis.missing_window_dates(
rows, date(2026, 7, 14), date(2026, 7, 16),
)
self.assertEqual(missing, [date(2026, 7, 15)])
class LarkEnvironmentTests(unittest.TestCase):
def test_legacy_lark_config_is_used_when_default_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
legacy = home / ".lark-cli" / "hermes"
legacy.mkdir(parents=True)
(legacy / "config.json").write_text("{}", encoding="utf-8")
with patch.dict(os.environ, {}, clear=True), patch.object(Path, "home", return_value=home):
env = analysis.lark_cli_env()
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(legacy))
def test_root_config_with_analyzer_profile_wins_over_legacy_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
config_root = home / ".lark-cli"
legacy = config_root / "hermes"
legacy.mkdir(parents=True)
(config_root / "config.json").write_text(
json.dumps({"apps": [{"name": "hermes-analyzer"}]}),
encoding="utf-8",
)
(legacy / "config.json").write_text("{}", encoding="utf-8")
with patch.dict(os.environ, {}, clear=True), patch.object(Path, "home", return_value=home):
env = analysis.lark_cli_env()
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(config_root))
def test_empty_root_config_falls_back_to_legacy_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
config_root = home / ".lark-cli"
legacy = config_root / "hermes"
legacy.mkdir(parents=True)
(config_root / "config.json").write_text("{}", encoding="utf-8")
(legacy / "config.json").write_text("{}", encoding="utf-8")
env = analysis.lark_cli_env(base_env={}, home=home)
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(legacy))
def test_explicit_config_directory_is_preserved(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
explicit = home / "explicit-lark-config"
config_root = home / ".lark-cli"
config_root.mkdir(parents=True)
(config_root / "config.json").write_text(
json.dumps({"profiles": {"hermes-analyzer": {}}}),
encoding="utf-8",
)
env = analysis.lark_cli_env(
base_env={"LARKSUITE_CLI_CONFIG_DIR": str(explicit)},
home=home,
)
self.assertEqual(env["LARKSUITE_CLI_CONFIG_DIR"], str(explicit))
class FeishuDocumentWriteTests(unittest.TestCase):
def test_preflight_refreshes_and_requires_both_identities(self):
response = {
"verified": True,
"identities": {
"bot": {"available": True, "verified": True},
"user": {"available": True, "verified": True},
},
}
with patch.object(analysis, "run_lark_cli", return_value=response) as run:
analysis.ensure_lark_cli_ready()
run.assert_called_once_with(["auth", "status", "--json", "--verify"])
def test_preflight_rejects_missing_user_identity(self):
response = {
"verified": True,
"identities": {
"bot": {"available": True, "verified": True},
"user": {"available": False, "verified": False},
},
}
with patch.object(analysis, "run_lark_cli", return_value=response):
with self.assertRaisesRegex(RuntimeError, "user"):
analysis.ensure_lark_cli_ready()
def test_bot_created_document_is_granted_to_current_user(self):
responses = [
{"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}}},
{"identities": {"user": {"openId": "ou_current_user"}}},
{"ok": True, "data": {"perm": "full_access"}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
create_args = run.call_args_list[0].args[0]
self.assertEqual(create_args[create_args.index("--as") + 1], "bot")
content_arg = create_args[create_args.index("--content") + 1]
self.assertFalse(Path(content_arg.removeprefix("@")).is_absolute())
self.assertEqual(
run.call_args_list[0].kwargs["cwd"],
analysis.report_cache_path("测试分析").parent,
)
grant_args = run.call_args_list[2].args[0]
self.assertIn("+member-add", grant_args)
self.assertIn("ou_current_user", grant_args)
self.assertIn("full_access", grant_args)
self.assertIn("--yes", grant_args)
def test_confirmed_automatic_permission_grant_skips_duplicate_member_add(self):
response = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "granted", "perm": "full_access"},
}
with patch.object(analysis, "run_lark_cli", return_value=response) as run:
url, doc_id = analysis.write_feishu_doc("测试分析", "# 内容")
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
run.assert_called_once()
def test_missing_current_user_identity_stops_before_base_write(self):
responses = [
{"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}}},
{"identities": {"user": {"ready": False}}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses):
with self.assertRaisesRegex(RuntimeError, "open_id"):
analysis.write_feishu_doc("测试分析", "# 内容")
class LocalReportRecoveryTests(unittest.TestCase):
def test_only_complete_exact_window_report_is_reused(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
title = "测试款 单款式多维度分析 2026-08-01~2026-08-03"
report_lines: list[str] = []
for index, heading in enumerate(analysis.REPORT_HEADINGS):
report_lines.append(heading)
for subheading in analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3]:
report_lines.extend([subheading, "**重点**"])
report = "\n".join(report_lines)
cache = analysis.report_cache_path(title)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(report, encoding="utf-8")
self.assertEqual(
analysis.load_validated_cached_report(title),
analysis.normalize_report_markdown(report),
)
cache.write_text("## 一、访客分析\n### 数据快照\n**重点**", encoding="utf-8")
self.assertIsNone(analysis.load_validated_cached_report(title))
class ExternalEffectCheckpointTests(unittest.TestCase):
@staticmethod
def checkpoint_spec(report: str = "# 内容"):
return analysis.StyleAnalysisCheckpointSpec.for_report(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
"测试款 单款式多维度分析 2026-08-01~2026-08-03",
report,
)
@staticmethod
def complete_report() -> str:
lines: list[str] = []
for index, heading in enumerate(analysis.REPORT_HEADINGS):
lines.append(heading)
for subheading in analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3]:
lines.extend([subheading, "**重点**"])
return analysis.normalize_report_markdown("\n".join(lines))
def test_permission_failure_resumes_same_created_document(self):
created = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "failed"},
}
auth = {"identities": {"user": {"openId": "ou_current_user"}}}
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
spec = self.checkpoint_spec()
with patch.object(
analysis,
"run_lark_cli",
side_effect=[created, auth, RuntimeError("grant failed")],
):
with self.assertRaisesRegex(RuntimeError, "grant failed"):
analysis.write_feishu_doc(spec.title, "# 内容", spec)
state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(state["phase"], "doc_created")
self.assertEqual(state["document"]["id"], "doc_token")
with patch.object(
analysis,
"run_lark_cli",
side_effect=[auth, {"ok": True}],
) as resumed:
url, doc_id = analysis.write_feishu_doc(spec.title, "# 内容", spec)
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
self.assertFalse(any(
call.args[0][:2] == ["docs", "+create"]
for call in resumed.call_args_list
))
self.assertEqual(
analysis.load_style_analysis_checkpoint(spec)["phase"],
"permission_granted",
)
def test_crash_after_automatic_grant_resumes_without_member_add(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
spec = self.checkpoint_spec()
analysis.claim_style_analysis_checkpoint(spec)
analysis.advance_style_analysis_checkpoint(
spec,
"doc_created",
document={
"id": "doc_token",
"url": "https://example.feishu.cn/docx/doc_token",
},
automatic_permission_status="granted",
)
with patch.object(analysis, "run_lark_cli") as run:
url, doc_id = analysis.write_feishu_doc(spec.title, "# 内容", spec)
self.assertEqual(url, "https://example.feishu.cn/docx/doc_token")
self.assertEqual(doc_id, "doc_token")
run.assert_not_called()
self.assertEqual(
analysis.load_style_analysis_checkpoint(spec)["phase"],
"permission_granted",
)
def test_ambiguous_and_corrupt_checkpoints_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
ambiguous = self.checkpoint_spec()
state, owns_creation = analysis.claim_style_analysis_checkpoint(ambiguous)
self.assertTrue(owns_creation)
self.assertEqual(state["phase"], "doc_creating")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"doc_creating",
):
analysis.write_feishu_doc(ambiguous.title, "# 内容", ambiguous)
run.assert_not_called()
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp) / "corrupt"):
corrupt = self.checkpoint_spec()
corrupt.path().parent.mkdir(parents=True, exist_ok=True)
corrupt.path().write_text("{", encoding="utf-8")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"损坏",
):
analysis.write_feishu_doc(corrupt.title, "# 内容", corrupt)
run.assert_not_called()
def test_report_hash_mismatch_fails_closed(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)):
original = self.checkpoint_spec("# 原报告")
analysis.claim_style_analysis_checkpoint(original)
changed = self.checkpoint_spec("# 新报告")
with patch.object(analysis, "run_lark_cli") as run:
with self.assertRaisesRegex(
analysis.StyleAnalysisCheckpointError,
"哈希不匹配",
):
analysis.write_feishu_doc(changed.title, "# 新报告", changed)
run.assert_not_called()
def test_base_and_postgres_phases_resume_without_recreating_document(self):
class Connection:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
report = self.complete_report()
daily = [
{"date": "2026-08-01"},
{"date": "2026-08-02"},
{"date": "2026-08-03"},
]
created = {
"data": {"document": {
"url": "https://example.feishu.cn/docx/doc_token",
"document_id": "doc_token",
}},
"permission_grant": {"status": "granted", "perm": "full_access"},
}
target = {"base_token": "base", "table_id": "table"}
with tempfile.TemporaryDirectory() as tmp:
with (
patch.object(analysis, "CURATED_DATA_ROOT", Path(tmp)),
patch.object(analysis, "pull_daily", return_value=daily),
patch.object(analysis, "aggregate_period", return_value={"tm": {"sales": 1}}),
patch.object(analysis, "aggregate_style_total", return_value={"sales": 1}),
patch.object(analysis, "pull_persona", return_value=[]),
patch.object(analysis, "pull_reviews", return_value={}),
patch.object(analysis, "pull_main_image", return_value=[]),
patch.object(analysis, "pull_seeding", return_value={}),
patch.object(analysis, "build_user_prompt", return_value="prompt"),
patch.object(analysis, "run_lark_cli", return_value=created) as run,
patch.object(
analysis,
"write_analysis_link_to_base",
return_value="rec_1",
) as write_base,
patch.object(analysis, "get_conn", return_value=Connection()),
patch.object(
analysis,
"upsert_style_analysis_report",
side_effect=[RuntimeError("pg unavailable"), 42],
) as write_pg,
):
title = "测试款 单款式多维度分析 2026-08-01~2026-08-03"
cache = analysis.report_cache_path(title)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(report, encoding="utf-8")
spec = self.checkpoint_spec(report)
with self.assertRaisesRegex(RuntimeError, "pg unavailable"):
analysis.analyze_one_style(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
False,
target,
)
base_state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(base_state["phase"], "base_written")
self.assertEqual(base_state["base_record_id"], "rec_1")
ok, _ = analysis.analyze_one_style(
"测试款",
date(2026, 8, 1),
date(2026, 8, 3),
3,
False,
target,
)
self.assertTrue(ok)
self.assertEqual(run.call_count, 1)
write_base.assert_called_once()
self.assertEqual(write_pg.call_count, 2)
final_state = analysis.load_style_analysis_checkpoint(spec)
self.assertEqual(final_state["phase"], "pg_written")
self.assertEqual(final_state["pg_row_id"], 42)
class TargetBaseWriteTests(unittest.TestCase):
def test_existing_period_record_is_updated(self):
responses = [
{"data": {"fields": [
{"name": "时间", "type": "select"},
{"name": "笔记分析汇总", "type": "text"},
]}},
{"data": {"field": {
"id": "fld_time", "name": "时间", "type": "select",
"multiple": False, "options": [{"name": "2026-07-13~07-15"}],
}}},
{"data": {
"data": [[['2026-07-13~07-15'], "old link"]],
"fields": ["时间", "笔记分析汇总"],
"record_id_list": ["rec_existing"],
}},
{"ok": True, "data": {"record_id": "rec_existing"}},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
record_id = analysis.write_analysis_link_to_base(
{"base_token": "base", "table_id": "table"},
"2026-07-13~07-15",
"https://example.feishu.cn/docx/doc",
"盖亚斜挎",
)
self.assertEqual(record_id, "rec_existing")
upsert_args = run.call_args_list[3].args[0]
self.assertIn("--record-id", upsert_args)
self.assertIn("rec_existing", upsert_args)
payload = json.loads(upsert_args[upsert_args.index("--json") + 1])
self.assertIn("笔记分析汇总", payload)
def test_missing_period_is_appended_without_dropping_existing_options(self):
responses = [
{"data": {"field": {
"id": "fld_time", "name": "时间", "type": "select",
"multiple": False, "options": [{"name": "7月第二周", "hue": "Blue"}],
}}},
{"ok": True},
]
with patch.object(analysis, "run_lark_cli", side_effect=responses) as run:
analysis.ensure_select_option(
{"base_token": "base", "table_id": "table"},
"时间", "2026-07-13~07-15", "盖亚斜挎",
)
update_args = run.call_args_list[1].args[0]
definition = json.loads(update_args[update_args.index("--json") + 1])
self.assertEqual(
[option["name"] for option in definition["options"]],
["7月第二周", "2026-07-13~07-15"],
)
self.assertIn("--yes", update_args)
class OrchestratorTests(unittest.TestCase):
def test_style_analysis_stage_runs_three_day_batch(self):
args = SimpleNamespace(dry_run=False)
with patch.object(orchestrator, "run_step_with_retry") as run:
orchestrator.run_style_analysis(args, "2026-07-15", failed=None)
command = run.call_args.args[1]
self.assertIn("analyze_style_with_hermes.py", [Path(item).name for item in command])
self.assertIn("--all-styles", command)
self.assertEqual(command[command.index("--days") + 1], "3")
self.assertEqual(command[command.index("--min-interval-days") + 1], "3")
self.assertFalse(run.call_args.kwargs["critical"])
class LauncherScheduleTests(unittest.TestCase):
def test_daily_launcher_does_not_run_style_analysis(self):
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_daily_collect.bat"
).read_text(encoding="utf-8")
command_lines = [line for line in launcher.splitlines() if "orchestrate_daily_collection.py" in line]
self.assertEqual(len(command_lines), 1)
self.assertNotIn("style_analysis", command_lines[0])
def test_three_day_launcher_runs_only_style_analysis(self):
launcher = (
HISTORY_ROOT / "launchers_reference" / "run_style_analysis_3d.bat"
).read_text(encoding="utf-8")
self.assertIn("analyze_style_with_hermes.py", launcher)
self.assertIn("--all-styles", launcher)
self.assertIn("--days 3", launcher)
self.assertIn("--skip-existing", launcher)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
from __future__ import annotations
import io
from contextlib import redirect_stdout
from config.style_config_loader import StyleConfigLoader
def test_erp_codes_use_tm_record_even_when_jd_record_comes_first() -> None:
loader = StyleConfigLoader()
output = io.StringIO()
with redirect_stdout(output):
payload = loader._aggregate(
[
{"platform": "京东旗舰店", "style": "极星托特", "erp_codes": "10513001"},
{"platform": "天猫", "style": "极星托特", "erp_codes": "10513"},
]
)
assert payload["styles"]["极星托特"]["erp_codes"] == ["10513"]
assert "ERP 编码冲突" in output.getvalue()
assert "10513001" in output.getvalue()
def test_non_tm_erp_code_is_not_used_when_tm_code_is_missing() -> None:
loader = StyleConfigLoader()
output = io.StringIO()
with redirect_stdout(output):
payload = loader._aggregate(
[
{"platform": "京东旗舰店", "style": "极星托特", "erp_codes": "10513001"},
{"platform": "天猫", "style": "极星托特", "erp_codes": ""},
]
)
assert payload["styles"]["极星托特"]["erp_codes"] == []
assert "天猫分组未填写" in output.getvalue()
def test_exact_style_field_wins_over_style_content_prefix(monkeypatch) -> None:
loader = StyleConfigLoader()
monkeypatch.setattr(
loader,
"_run_lark",
lambda _args: {
"data": {
"fields": ["款式内容", "款式", "ERP款式编码", "平台"],
"data": [["营销文案", "盖世m1", "10416,10455", ["天猫"]]],
}
},
)
records = loader._load_records_from_lark()
assert records == [
{
"style": "盖世m1",
"erp_codes": "10416,10455",
"platform": "天猫",
}
]
@@ -0,0 +1,564 @@
import inspect
import unittest
from unittest.mock import MagicMock, patch
from collect_sycm_market_rank import (
TARGET_MARKET_CATEGORIES,
_feishu_append_xml_path,
_original_taobao_image_url,
_product_item_id,
_risk_control_reason,
build_feishu_category_xml,
build_feishu_doc_xml,
close_market_rank_browser,
collect_all_rank_pages,
create_feishu_doc,
detect_risk_control_text,
ensure_market_rank_login,
group_products_by_category,
inspect_feishu_batch_tables,
inspect_feishu_category_tables,
parse_market_rank_html,
parse_range_upper,
prepare_market_rank_filters,
prepare_market_rank_period,
)
SAMPLE_HTML = """
<table>
<thead>
<tr>
<th>排名</th><th>商品</th><th>商品关键词</th>
<th>店铺</th><th>支付买家数</th><th>访客数</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>
<a href="//detail.tmall.com/item.htm?id=123">
<img data-src="//img.alicdn.com/bao/uploaded/demo.jpg"/>
【新品】CECE大容量旅行箱
</a>
</td>
<td>旅行</td><td>cece旗舰店</td><td>2500 ~ 5000</td><td>10万 ~ 25万</td>
</tr>
</tbody>
</table>
"""
class SycmMarketRankTests(unittest.TestCase):
def test_expired_login_without_credentials_fails_clearly(self):
with (
patch("collect_sycm_market_rank.sycm.ACCOUNT", ""),
patch("collect_sycm_market_rank.sycm.PASSWORD", ""),
patch(
"collect_sycm_market_rank.sycm.ensure_logged_in",
return_value=None,
),
self.assertRaisesRegex(
RuntimeError,
"登录态失效.*SYCM_ACCOUNT/SYCM_PASSWORD",
),
):
ensure_market_rank_login(MagicMock(), MagicMock())
def test_expired_login_does_not_swallow_login_exception(self):
with (
patch("collect_sycm_market_rank.sycm.ACCOUNT", "configured"),
patch("collect_sycm_market_rank.sycm.PASSWORD", "configured"),
patch(
"collect_sycm_market_rank.sycm.ensure_logged_in",
side_effect=RuntimeError("login rejected"),
),
self.assertRaisesRegex(RuntimeError, "自动登录失败: RuntimeError"),
):
ensure_market_rank_login(MagicMock(), MagicMock())
def test_parallel_documents_use_isolated_append_xml_files(self):
first = _feishu_append_xml_path("https://example.feishu.cn/docx/first", 1)
second = _feishu_append_xml_path("https://example.feishu.cn/docx/second", 1)
self.assertNotEqual(first, second)
self.assertEqual(first.parent, second.parent)
def test_title_with_question_mark_is_rejected_before_creating_document(self):
with self.assertRaisesRegex(ValueError, "编码损坏"):
create_feishu_doc("??九品类市场排行", "<p>test</p>")
def test_category_groups_are_one_table_each(self):
products = [
{"category_name": "旅行箱", "rank": 1, "name": "A"},
{"category_name": "双肩包", "rank": 1, "name": "B"},
{"category_name": "旅行箱", "rank": 2, "name": "C"},
]
groups = group_products_by_category(products)
self.assertEqual([name for name, _ in groups], ["旅行箱", "双肩包"])
self.assertEqual([len(items) for _, items in groups], [2, 1])
category_xml = build_feishu_category_xml(*groups[0])
self.assertEqual(category_xml.count("<table>"), 1)
self.assertEqual(category_xml.count("<th "), 7)
self.assertNotIn(">品类</th>", category_xml)
def test_category_table_inspection_keeps_heading_and_row_count(self):
content = """
<h2>旅行箱(2条)</h2><table id="t1"><tbody>
<tr><td><img src="1"/></td></tr><tr><td><img src="2"/></td></tr>
</tbody></table>
<h2>双肩包(1条)</h2><table id="t2"><tbody><tr><td>缺图</td></tr></tbody></table>
"""
self.assertEqual(
inspect_feishu_category_tables(content),
[
{"block_id": "t1", "category": "旅行箱", "declared_rows": 2, "rows": 2, "images": 2},
{"block_id": "t2", "category": "双肩包", "declared_rows": 1, "rows": 1, "images": 0},
],
)
def test_detect_risk_control_text_matches_actual_sycm_popup(self):
popup_text = """
请稍等片刻再点击刷新哦
操作太频繁了,请稍后再试
反馈码:a777b03e7183a9564215ff465acad489
"""
self.assertEqual(
detect_risk_control_text(popup_text),
"操作太频繁了,请稍后再试",
)
def test_detect_risk_control_text_ignores_normal_rank_page(self):
self.assertEqual(
detect_risk_control_text("市场排行 支付买家数 访客数 下一页"),
"",
)
def test_detect_risk_control_text_matches_baxia_mask(self):
self.assertEqual(
detect_risk_control_text('<div class="baxia-dialog-mask"></div>'),
"生意参谋 Baxia 风控遮罩",
)
def test_hidden_baxia_dialog_is_not_treated_as_active_captcha(self):
page = MagicMock()
dialog = MagicMock()
dialog.count.return_value = 1
dialog.nth.return_value.is_visible.return_value = False
body = MagicMock()
body.inner_text.return_value = "市场排行 支付买家数"
page.locator.side_effect = lambda selector: (
dialog if selector == ".baxia-dialog" else body
)
page.content.return_value = (
'<div style="display:none" class="baxia-dialog">X</div>'
)
self.assertEqual(_risk_control_reason(page), "")
def test_owned_tmall_browser_has_profile_scoped_fallback_cleanup(self):
session = MagicMock()
with (
patch("collect_sycm_market_rank.sys.platform", "win32"),
patch("collect_sycm_market_rank.time.sleep"),
patch("collect_sycm_market_rank.subprocess.run") as run,
):
close_market_rank_browser(
session,
user_data_dir="portable-tm-profile",
cdp_url=None,
)
session.close.assert_called_once_with()
run.assert_called_once()
self.assertEqual(
run.call_args.kwargs["env"]["TM_PROFILE_TO_CLOSE"],
"portable-tm-profile",
)
def test_cdp_browser_is_not_force_closed(self):
session = MagicMock()
with patch("collect_sycm_market_rank.subprocess.run") as run:
close_market_rank_browser(
session,
user_data_dir="portable-tm-profile",
cdp_url="http://127.0.0.1:9222",
)
session.close.assert_called_once_with()
run.assert_not_called()
def test_prepare_market_period_waits_in_same_session_before_clicking_period(self):
context = MagicMock()
original_page = object()
with (
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank.select_market_period") as select_period,
):
result = prepare_market_rank_period(
context,
original_page,
period="30天",
risk_mode="manual",
cooldown_seconds=0,
max_retries=2,
)
self.assertIs(result, original_page)
context.clear_cookies.assert_not_called()
wait_for_clearance.assert_called_once_with(
original_page,
"生意参谋 Baxia 风控遮罩",
)
select_period.assert_called_once_with(original_page, "30天")
def test_prepare_market_filters_retries_same_step_after_manual_captcha(self):
page = object()
with (
patch(
"collect_sycm_market_rank.select_market_period",
) as select_period,
patch(
"collect_sycm_market_rank.select_market_category_path",
side_effect=[RuntimeError("mask intercepts pointer events"), None],
) as select_category,
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["", "生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank.apply_custom_price") as apply_price,
):
result = prepare_market_rank_filters(
page,
period="30天",
category_path=("箱包皮具/热销女包/男包", "旅行箱"),
min_price=400,
max_price=2000,
risk_mode="manual",
cooldown_seconds=0,
max_retries=2,
)
self.assertIs(result, page)
self.assertEqual(select_period.call_count, 2)
self.assertEqual(select_category.call_count, 2)
apply_price.assert_called_once_with(page, 400, 2000)
wait_for_clearance.assert_called_once_with(
page,
"生意参谋 Baxia 风控遮罩",
)
def test_manual_captcha_reapplies_filters_before_parsing_current_page(self):
page = MagicMock()
page.content.side_effect = [
'<div class="baxia-dialog">captcha</div>',
SAMPLE_HTML,
]
recover = MagicMock(return_value=page)
with (
patch(
"collect_sycm_market_rank._risk_control_reason",
side_effect=["生意参谋 Baxia 风控遮罩", ""],
),
patch(
"collect_sycm_market_rank.wait_for_manual_risk_clearance"
) as wait_for_clearance,
patch("collect_sycm_market_rank._save_checkpoint"),
):
rows = collect_all_rank_pages(
page,
max_pages=1,
category_name="旅行箱",
category_path="箱包 > 旅行箱",
recover_from_risk=recover,
manual_risk_wait=True,
)
self.assertEqual(len(rows), 1)
wait_for_clearance.assert_called_once_with(
page,
"生意参谋 Baxia 风控遮罩",
)
recover.assert_called_once_with(
page,
1,
1,
"生意参谋 Baxia 风控遮罩",
)
def test_target_market_categories_contains_the_nine_requested_paths(self):
self.assertEqual(len(TARGET_MARKET_CATEGORIES), 9)
self.assertEqual(
[
(
item["name"],
item["min_price"],
item["max_price"],
tuple(tuple(path) for path in item["paths"]),
)
for item in TARGET_MARKET_CATEGORIES
],
[
(
"旅行箱",
400,
2000,
(("箱包皮具/热销女包/男包", "旅行箱"),),
),
(
"运动包",
200,
1000,
(("箱包皮具/热销女包/男包", "旅行袋"),),
),
(
"托特包",
200,
3000,
(("箱包皮具/热销女包/男包", "女士包袋新", "托特包"),),
),
(
"双肩包",
200,
2000,
(("箱包皮具/热销女包/男包", "双肩背包"),),
),
(
"斜挎包",
200,
2000,
(
("箱包皮具/热销女包/男包", "男士包袋"),
(
"箱包皮具/热销女包/男包",
"女士包袋新",
"通用款女包",
),
),
),
(
"胸包",
200,
1000,
(("箱包皮具/热销女包/男包", "胸包"),),
),
(
"手机包",
100,
1000,
(("箱包皮具/热销女包/男包", "功能小包", "手机包"),),
),
(
"电脑包",
200,
1000,
(("3C数码配件", "笔记本电脑配件", "笔记本电脑包"),),
),
(
"相机包",
200,
2000,
(("3C数码配件", "数码相机配件", "数码相机包"),),
),
],
)
def test_original_taobao_image_url_removes_rank_thumbnail_suffix(self):
thumbnail = (
"https://img.alicdn.com/bao/uploaded/demo-item_pic.jpg_36x36.jpg"
)
self.assertEqual(
_original_taobao_image_url(thumbnail),
"https://img.alicdn.com/bao/uploaded/demo-item_pic.jpg",
)
def test_product_identity_ignores_changing_mi_id(self):
first = _product_item_id(
"https://detail.tmall.com/item.htm?id=123&mi_id=first"
)
second = _product_item_id(
"https://detail.tmall.com/item.htm?mi_id=second&id=123"
)
self.assertEqual(first, "123")
self.assertEqual(first, second)
def test_parse_range_upper_supports_plain_and_wan_units(self):
self.assertEqual(parse_range_upper("2500 ~ 5000"), 5000)
self.assertEqual(parse_range_upper("7.5万~10万"), 100000)
self.assertEqual(parse_range_upper("500"), 500)
def test_parse_market_rank_html_extracts_name_image_link_and_buyer_max(self):
rows = parse_market_rank_html(SAMPLE_HTML)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["rank"], 1)
self.assertEqual(rows[0]["name"], "【新品】CECE大容量旅行箱")
self.assertEqual(rows[0]["buyer_range"], "2500 ~ 5000")
self.assertEqual(rows[0]["buyer_max"], 5000)
self.assertEqual(
rows[0]["image_url"],
"https://img.alicdn.com/bao/uploaded/demo.jpg",
)
self.assertEqual(
rows[0]["product_url"],
"https://detail.tmall.com/item.htm?id=123",
)
def test_parse_market_rank_html_skips_image_only_link_for_product_name(self):
source = """
<table>
<thead><tr>
<th>排名</th><th>商品</th><th>支付买家数</th>
</tr></thead>
<tbody><tr>
<td>9 降1名</td>
<td>
<a href="//detail.tmall.com/item.htm?id=639">
<img src="//img.alicdn.com/demo.jpg"/>
</a>
<p class="goodsName">
<a title="MOFT电脑支架收纳包"
href="//detail.tmall.com/item.htm?id=639">MOFT电脑支架收纳包</a>
</p>
</td>
<td>50 ~ 100</td>
</tr></tbody>
</table>
"""
rows = parse_market_rank_html(source)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["name"], "MOFT电脑支架收纳包")
self.assertEqual(rows[0]["rank"], 9)
self.assertEqual(rows[0]["buyer_max"], 100)
def test_rank_page_collector_has_no_detail_page_or_price_interface(self):
parameters = inspect.signature(collect_all_rank_pages).parameters
source = inspect.getsource(collect_all_rank_pages)
self.assertNotIn("context", parameters)
self.assertNotIn("collect_prices", parameters)
self.assertNotIn("price_cache", parameters)
self.assertNotIn("_collect_price_from_rank_link", source)
self.assertNotIn("expect_page", source)
def test_inspect_feishu_batch_tables_reports_start_rows_and_images(self):
content = """
<h2 id="heading-1">榜单记录 12</h2>
<table id="table-1">
<tbody>
<tr><td><img src="img-1"/></td></tr>
<tr><td>缺图</td></tr>
</tbody>
</table>
<h2 id="heading-2">榜单记录 33</h2>
<table id="table-2">
<tbody><tr><td><img src="img-2"/></td></tr></tbody>
</table>
"""
self.assertEqual(
inspect_feishu_batch_tables(content),
[
{
"block_id": "table-1",
"start_rank": 1,
"rows": 2,
"images": 1,
},
{
"block_id": "table-2",
"start_rank": 3,
"rows": 1,
"images": 1,
},
],
)
def test_build_doc_contains_image_metrics_and_link_without_product_price(self):
xml = build_feishu_doc_xml(
[
{
"rank": 1,
"category_name": "旅行箱",
"name": "CECE旅行箱",
"buyer_range": "2500 ~ 5000",
"buyer_max": 5000,
"image_url": "https://img.alicdn.com/demo.jpg",
"product_url": "https://detail.tmall.com/item.htm?id=123",
}
],
min_price=400,
max_price=2000,
title="市场排行测试",
)
self.assertIn("<title>市场排行测试</title>", xml)
self.assertIn('img href="https://img.alicdn.com/demo.jpg"', xml)
self.assertIn("5000", xml)
self.assertIn("https://detail.tmall.com/item.htm?id=123", xml)
self.assertIn("商品链接", xml)
self.assertNotIn("商品价格", xml)
self.assertNotIn("¥418", xml)
self.assertIn("风格(运营手动区分)", xml)
self.assertIn("场景用途(运营手动区分)", xml)
self.assertIn(">品类</th>", xml)
self.assertIn("<tr><td></td><td></td><td>旅行箱</td>", xml)
self.assertIn(
'<a href="https://detail.tmall.com/item.htm?id=123">打开商品</a>',
xml,
)
self.assertNotIn(
'<a href="https://detail.tmall.com/item.htm?id=123">CECE旅行箱</a>',
xml,
)
self.assertEqual(
xml.count('href="https://detail.tmall.com/item.htm?id=123"'),
1,
)
self.assertIn('width="160"', xml)
self.assertIn('height="160"', xml)
self.assertIn('<col width="180"/>', xml)
def test_build_doc_uses_original_image_instead_of_thumbnail(self):
xml = build_feishu_doc_xml(
[
{
"rank": 1,
"name": "CECE旅行箱",
"buyer_range": "2500 ~ 5000",
"buyer_max": 5000,
"image_url": (
"https://img.alicdn.com/demo.jpg_36x36.jpg"
),
"product_url": "https://detail.tmall.com/item.htm?id=123",
}
],
min_price=400,
max_price=2000,
title="大图测试",
)
self.assertIn('href="https://img.alicdn.com/demo.jpg"', xml)
self.assertNotIn("_36x36.jpg", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,343 @@
from __future__ import annotations
from datetime import date
import pytest
from gyxx_flow.modules.product_commerce import (
collect_erp_monthly_metrics as erp_monthly,
)
from gyxx_flow.modules.product_commerce.collect_erp_monthly_metrics import (
monthly_report_interval,
set_monthly_filters,
)
from gyxx_flow.modules.product_commerce.sync_monthly_sales_sheet import (
ErpStylePlan,
MonthlyMetric,
MonthlySalesConfig,
ProductTarget,
SheetInfo,
_write_rows,
build_parser,
choose_template,
fetch_product_targets,
match_metrics,
merge_product_catalog,
month_bounds,
month_option,
month_sheet_title,
normalized_name,
parse_month,
plan_erp_styles,
product_text,
require_complete_erp_mappings,
)
def test_month_contract_uses_natural_month_and_existing_title_convention() -> None:
month = parse_month("2026-08-19")
assert month == date(2026, 8, 1)
assert month_bounds(month) == (date(2026, 8, 1), date(2026, 8, 31))
assert month_option(month) == "2026.08"
assert month_sheet_title(month) == "26年8月"
def test_erp_month_interval_uses_inclusive_natural_month_for_day_input() -> None:
assert monthly_report_interval(parse_month("2026-07-02")) == (
"2026-07-01",
"2026-07-31",
)
assert monthly_report_interval(parse_month("2026-12-31")) == (
"2026-12-01",
"2026-12-31",
)
assert monthly_report_interval(parse_month("2024-02-15")) == (
"2024-02-01",
"2024-02-29",
)
def test_selected_month_is_the_only_month_argument() -> None:
arguments = build_parser().parse_args(["--month", "2026-07"])
assert parse_month(arguments.month) == date(2026, 7, 1)
with pytest.raises(SystemExit):
build_parser().parse_args(
["--month", "2026-07", "--budget-month", "2026-08"]
)
def test_missing_erp_zero_override_is_explicit() -> None:
default = build_parser().parse_args(["--month", "2026-07"])
override = build_parser().parse_args(
["--month", "2026-07", "--allow-missing-erp-as-zero"]
)
assert default.allow_missing_erp_as_zero is False
assert override.allow_missing_erp_as_zero is True
def test_product_targets_are_read_without_a_month_filter() -> None:
config = MonthlySalesConfig(
spreadsheet_url="https://example.test/sheets/token",
base_url="https://example.test/base/token",
base_token="base",
table_id="table",
view_id="view",
product_field="品名",
target_field="目标销量",
month_field="月份",
product_aliases={},
)
calls: list[list[str]] = []
def lark(args: list[str]) -> dict:
calls.append(args)
return {
"data": {
"fields": ["品名", "目标销量"],
"data": [["布谷", 500], ["白鹭", 350], ["蓝鹊", 400]],
"has_more": False,
}
}
result = fetch_product_targets(
config,
lark=lark,
)
assert result == [
ProductTarget("布谷", 500),
ProductTarget("白鹭", 350),
ProductTarget("蓝鹊", 400),
]
assert "--filter-json" not in calls[0]
def test_full_catalog_unions_style_base_and_targets_and_sums_duplicates() -> None:
targets = [
ProductTarget("布谷", 500),
ProductTarget("星迹&星迹2", 1000),
ProductTarget("星迹2", 500),
ProductTarget("预算孤儿", 200),
]
styles = {
"布谷": {"erp_codes": ["10352", "10496"]},
"星迹2": {"erp_codes": ["10504"]},
"白鹭": {"erp_codes": ["10310"]},
"蓝鹊": {"erp_codes": ["10279"]},
}
result = merge_product_catalog(
targets,
styles,
{"星迹&星迹2": "星迹2"},
)
assert result == [
ProductTarget("布谷", 500),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("预算孤儿", 200),
ProductTarget("白鹭", 0),
ProductTarget("蓝鹊", 0),
]
def test_product_text_turns_base_markdown_links_into_plain_product_names() -> None:
assert (
product_text(
"[盖亚微单Pro生命进程(新)](https://example.feishu.cn/base/token)"
)
== "盖亚微单Pro"
)
assert product_text("阿波罗X1生命进程(新)") == "阿波罗X1"
assert product_text("拾影相机包(新) 副本") == "拾影相机包"
def test_metric_matching_uses_explicit_aliases_and_casefolded_exact_names() -> None:
targets = [
ProductTarget("盖世M1", 300),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("尚未入库", 200),
]
metrics = {
"盖世m1": MonthlyMetric("盖世m1", 271, 36, 12),
"星迹2": MonthlyMetric("星迹2", 1858, 451, 12),
}
rows, unmatched = match_metrics(
targets,
metrics,
{"星迹&星迹2": "星迹2"},
)
assert normalized_name("星迹&星迹2") == normalized_name("星迹+星迹2")
assert rows[0]["sales"] == 271
assert rows[1]["returns"] == 451
assert rows[2]["sales"] == 0
assert unmatched == ["尚未入库"]
def test_template_is_latest_strictly_earlier_year_month_sheet() -> None:
sheets = [
SheetInfo("a", "26年6月", 0, 198),
SheetInfo("b", "26年7月", 1, 198),
SheetInfo("c", "7月销量", 2, 200),
]
assert choose_template(sheets, date(2026, 8, 1)).sheet_id == "b"
def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
targets = [
ProductTarget("盖世M1", 300),
ProductTarget("星迹&星迹2", 1500),
ProductTarget("云栖相机双肩包", 200),
]
styles = {
"盖世m1": {"erp_codes": ["10416", "10455", "10416"]},
"星迹2": {"erp_codes": ["10504", "10394"]},
"云栖": {"erp_codes": []},
}
plans, missing = plan_erp_styles(
targets,
styles,
{
"星迹&星迹2": "星迹2",
"云栖相机双肩包": "云栖",
},
)
assert plans == [
ErpStylePlan("盖世m1", ("10416", "10455")),
ErpStylePlan("星迹2", ("10504", "10394")),
]
assert missing == ["云栖相机双肩包"]
def test_execute_guard_rejects_incomplete_erp_mapping_before_sheet_write() -> None:
with pytest.raises(RuntimeError, match="目标表未清空"):
require_complete_erp_mappings(["云栖相机双肩包", "轻风双肩包"])
require_complete_erp_mappings([])
def test_monthly_filter_sets_natural_month_exact_shop_labels_and_erp_code() -> None:
class Frame:
def __init__(self) -> None:
self.script = ""
self.args = {}
def evaluate(self, script, args):
self.script = script
self.args = args
return {"selected_shop_ids": ["shop_1"], "selected_shop_labels": ["甲店"]}
frame = Frame()
result = set_monthly_filters(
frame,
erp_code="10439",
start_date="2026-07-01",
end_date="2026-07-31",
shop_labels=["甲店"],
)
assert frame.args == {
"erpCode": "10439",
"startDate": "2026-07-01",
"endDate": "2026-07-31",
"shopLabels": ["甲店"],
}
assert 'input[name="shop_id"]' in frame.script
assert "#i_id" in frame.script
assert result["selected_shop_labels"] == ["甲店"]
def test_monthly_collector_sums_all_erp_codes_for_one_style(monkeypatch) -> None:
monkeypatch.setattr(erp_monthly.erp, "find_report_frames", lambda _page: (object(), object()))
observed_ranges: list[tuple[str, str, str]] = []
def run_one_code(_page, _frame, code, start_date, end_date, *_args):
observed_ranges.append((code, start_date, end_date))
return {
"ok": True,
"yesterday_sales": {"10416": 1185, "10455": 904}[code],
"yesterday_returns": {"10416": 279, "10455": 159}[code],
}
monkeypatch.setattr(
erp_monthly,
"_run_one_code",
run_one_code,
)
result = erp_monthly.collect_monthly_in_page(
object(),
[{"style_name": "盖世m1", "erp_style_codes": ["10416", "10455"]}],
date(2026, 7, 1),
shop_labels=["甲店"],
)
assert result["盖世m1"].sales == 2089
assert result["盖世m1"].returns == 438
assert observed_ranges == [
("10416", "2026-07-01", "2026-07-31"),
("10455", "2026-07-01", "2026-07-31"),
]
def test_monthly_collector_rejects_partial_erp_code_failure(monkeypatch) -> None:
monkeypatch.setattr(erp_monthly.erp, "find_report_frames", lambda _page: (object(), object()))
monkeypatch.setattr(
erp_monthly,
"_run_one_code",
lambda _page, _frame, code, *_args: {
"ok": code == "10416",
"yesterday_sales": 10 if code == "10416" else 0,
"yesterday_returns": 1 if code == "10416" else 0,
"error": "timeout" if code != "10416" else "",
},
)
with pytest.raises(RuntimeError, match="10455"):
erp_monthly.collect_monthly_in_page(
object(),
[{"style_name": "盖世m1", "erp_style_codes": ["10416", "10455"]}],
date(2026, 7, 1),
shop_labels=["甲店"],
)
def test_write_rows_repeats_first_data_row_format_without_overwriting_values() -> None:
calls: list[list[str]] = []
config = MonthlySalesConfig(
spreadsheet_url="https://example.test/sheets/token",
base_url="https://example.test/base/token",
base_token="base",
table_id="table",
view_id="view",
product_field="品名",
target_field="目标销量",
month_field="月份",
product_aliases={},
)
rows = [
{"product": "", "target_sales": 10, "sales": 8, "returns": 1},
{"product": "", "target_sales": 20, "sales": 9, "returns": 2},
]
_write_rows(
config,
SheetInfo("sheet", "26年7月", 0, 198),
rows,
lark=lambda args: calls.append(args) or {},
)
format_call = next(args for args in calls if "+range-copy" in args)
assert format_call[format_call.index("--source-range") + 1] == "A2:G2"
assert format_call[format_call.index("--target-range") + 1] == "A3:G3"
assert format_call[format_call.index("--paste-type") + 1] == "formats"
@@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
import taobao_sycm_products as tm
class _Session:
def __init__(self) -> None:
self.context = object()
self.closed = False
def close(self) -> None:
self.closed = True
def test_tm_main_fails_when_browser_connection_is_missing(monkeypatch) -> None:
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (None, None))
assert tm.main(["--target-date", "2026-08-03"]) == 1
def test_tm_main_fails_when_login_does_not_complete(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: None)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_target_date_cannot_be_selected(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: False)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_download_is_missing(monkeypatch) -> None:
session = _Session()
page = object()
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: True)
monkeypatch.setattr(tm, "click_download", lambda _page: None)
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
def test_tm_main_fails_when_download_has_no_valid_style(
monkeypatch,
tmp_path: Path,
) -> None:
session = _Session()
page = object()
report = tmp_path / "report.xlsx"
monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (session, page))
monkeypatch.setattr(tm, "ensure_logged_in", lambda *_args: page)
monkeypatch.setattr(tm, "navigate_to_products", lambda _page: None)
monkeypatch.setattr(tm, "select_yesterday", lambda _page: True)
monkeypatch.setattr(tm, "click_download", lambda _page: report)
monkeypatch.setattr(tm, "export_excel_to_json_and_md", lambda _path: (None, None))
monkeypatch.setattr(tm, "summarize_styles_from_excel", lambda _path: [])
monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0)
assert tm.main(["--target-date", "2026-08-03"]) == 1
assert session.closed is True
@@ -0,0 +1,69 @@
import unittest
from pathlib import Path
from unittest.mock import Mock
import taobao_dmp_item_crowd_insight_screenshots as dmp
class TmPersonaRecoveryTests(unittest.TestCase):
def test_zero_chart_skip_is_not_platform_success(self):
self.assertEqual(
dmp.records_exit_code(
{"款A": ["123"]},
[{"style_name": "款A", "status": "skipped"}],
),
2,
)
def test_switch_drawer_failure_retries_page_then_rebuilds_session(self):
failed = {
"status": "failed",
"error": "切换分析单品弹窗未加载(未出现搜索框)",
}
succeeded = {"status": "ok", "chart_values": {"用户性别": []}}
collect = Mock(side_effect=[failed, failed, succeeded])
goto = Mock()
reopen = Mock(return_value=("new-session", "new-page"))
session, page, record = dmp.collect_item_with_recovery(
"old-session",
"old-page",
"盖亚斜挎",
"921336775006",
Path("data/tm人物画像"),
collect_fn=collect,
goto_fn=goto,
reopen_session=reopen,
)
self.assertEqual(record["status"], "ok")
self.assertEqual((session, page), ("new-session", "new-page"))
self.assertEqual(collect.call_count, 3)
goto.assert_called_once_with("old-page")
reopen.assert_called_once_with("old-session", "old-page")
def test_non_navigation_failure_is_not_retried(self):
skipped = {"status": "skipped", "skip_reason": "分析人群规模过小"}
collect = Mock(return_value=skipped)
goto = Mock()
reopen = Mock()
_, _, record = dmp.collect_item_with_recovery(
"session",
"page",
"盖亚斜挎",
"921336775006",
Path("data/tm人物画像"),
collect_fn=collect,
goto_fn=goto,
reopen_session=reopen,
)
self.assertEqual(record, skipped)
self.assertEqual(collect.call_count, 1)
goto.assert_not_called()
reopen.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,152 @@
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import Mock, patch
import taobao_wanxiang_ai_creative_report as wanxiang
from taobao_wanxiang_ai_creative_report import select_report_template
class WanxiangReportTemplateTests(unittest.TestCase):
def test_authenticated_account_chooser_on_login_route_is_valid_session(self):
page = Mock()
page.url = "https://one.alimama.com/index.html#!/login/index"
page.locator.return_value.inner_text.return_value = (
"欢迎登录\nHi,测试店铺\n进入后台\n退出账户"
)
self.assertTrue(wanxiang.is_logged_in(page))
def test_credential_form_on_login_route_is_not_valid_session(self):
page = Mock()
page.url = "https://one.alimama.com/index.html#!/login/index"
page.locator.return_value.inner_text.return_value = "账号名\n请输入登录密码"
self.assertFalse(wanxiang.is_logged_in(page))
def test_supports_actual_wanxiang_template_label_spelling(self):
self.assertIn("报表模板", wanxiang.REPORT_TEMPLATE_LABELS)
self.assertIn("报表模版", wanxiang.REPORT_TEMPLATE_LABELS)
def test_already_selected_keyword_template_does_not_click(self):
page = Mock()
page.evaluate.return_value = {
"found": True,
"current": "关键词推广",
}
select_report_template(page, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 1)
page.wait_for_timeout.assert_not_called()
def test_waits_for_async_template_filter_render(self):
page = Mock()
page.evaluate.side_effect = [
{"found": False, "current": ""},
{"found": True, "current": "关键词推广"},
]
select_report_template(page, find_attempts=2, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 2)
page.wait_for_timeout.assert_called_once_with(1000)
def test_missing_template_filter_saves_debug_and_stops(self):
page = Mock()
page.evaluate.return_value = {"found": False, "current": ""}
with (
patch.object(wanxiang, "dump_report_template_debug") as dump,
self.assertRaisesRegex(RuntimeError, "报表模板"),
):
select_report_template(page, find_attempts=2, verify_attempts=1)
dump.assert_called_once_with(page, "filter_not_found")
def test_switches_from_all_plans_to_keyword_template(self):
page = Mock()
page.evaluate.side_effect = [
{"found": True, "current": "全部计划"},
True,
True,
{"found": True, "current": "关键词推广"},
]
with patch.object(wanxiang, "wait_table_change") as wait_table:
select_report_template(page, verify_attempts=1)
self.assertEqual(page.evaluate.call_count, 4)
self.assertGreaterEqual(page.wait_for_timeout.call_count, 2)
wait_table.assert_called_once_with(page, seconds=2.0)
def test_raises_when_keyword_template_cannot_be_verified(self):
page = Mock()
page.evaluate.side_effect = [
{"found": True, "current": "全部计划"},
True,
True,
{"found": True, "current": "全部计划"},
{"found": True, "current": "全部计划"},
]
with (
patch.object(wanxiang, "dump_report_template_debug"),
self.assertRaisesRegex(RuntimeError, "关键词推广"),
):
select_report_template(page, verify_attempts=2)
def test_run_selects_keyword_template_before_collecting_styles(self):
events = []
page = Mock()
session = Mock()
args = Namespace(
output=str(Path("data") / "test_wanxiang_keyword"),
styles="星云2",
date="2026-07-26",
headless=True,
user_data_dir="test-profile",
account="account",
password="placeholder",
max_pages=1,
report_template="关键词推广",
keep_open=False,
)
with (
patch.object(wanxiang, "start_stealthy_session", return_value=(session, page)),
patch.object(wanxiang, "ensure_logged_in"),
patch.object(wanxiang, "goto_wanxiang_from_seller"),
patch.object(wanxiang, "goto_creative_report"),
patch.object(
wanxiang,
"select_report_template",
side_effect=lambda *_args, **_kwargs: events.append("select"),
) as select,
patch.object(
wanxiang,
"collect_style",
side_effect=lambda *_args, **_kwargs: (
events.append("collect") or {"style_name": "星云2"}
),
),
patch.object(
wanxiang,
"write_style_outputs",
return_value=(Path("result.json"), Path("result.md")),
),
patch.object(
wanxiang,
"write_overall_outputs",
return_value=Path("overall.json"),
),
):
wanxiang.run(args)
select.assert_called_once_with(page, "关键词推广")
self.assertEqual(events, ["select", "collect"])
session.close.assert_called_once()
if __name__ == "__main__":
unittest.main()