feat: complete production workflow migration
This commit is contained in:
@@ -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("运动包(功能箱包 > 休闲运动包)", 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">榜单记录 1–2</h2>
|
||||
<table id="table-1">
|
||||
<tbody>
|
||||
<tr><td><img src="img-1"/></td></tr>
|
||||
<tr><td>缺图</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 id="heading-2">榜单记录 3–3</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()
|
||||
Reference in New Issue
Block a user