sino-bc-cli 1.8.19 → 1.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SKILL.md +1 -1
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +53 -63
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +6 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +19 -17
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -280
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +9 -53
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +172 -35
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -80
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +288 -18
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +84 -46
- package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +315 -238
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +215 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""不发网:会议口径 + 实跑踩坑(直写文件、出价统计、环比结论、双 sheet)。"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
15
|
+
if str(SCRIPTS) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
17
|
+
|
|
18
|
+
from cli_json import ( # noqa: E402
|
|
19
|
+
bid_adjust_stats,
|
|
20
|
+
budget_abs_share,
|
|
21
|
+
exec_cli_to_file,
|
|
22
|
+
is_first_period,
|
|
23
|
+
is_ghost_enum,
|
|
24
|
+
list_query_body,
|
|
25
|
+
naming_patterns,
|
|
26
|
+
period_verdict,
|
|
27
|
+
region_bucket,
|
|
28
|
+
summarize_countries,
|
|
29
|
+
)
|
|
30
|
+
from fetch_data import PAGE_CAP # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_list_query_uses_data_body_not_flags():
|
|
34
|
+
named = list_query_body(name="华南-S级-rednote小红书")
|
|
35
|
+
assert named["name"] == "华南-S级-rednote小红书"
|
|
36
|
+
assert named["sortType"] == "PERIOD"
|
|
37
|
+
assert "idList" not in named
|
|
38
|
+
by_id = list_query_body(biz_id=1164)
|
|
39
|
+
assert by_id["idList"] == [1164]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_exec_cli_to_file_keeps_payload_over_64kb():
|
|
43
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
44
|
+
out = Path(tmp) / "big.out.json"
|
|
45
|
+
# 在子进程内构造大回包,避免 Windows argv 长度上限干扰本测试。
|
|
46
|
+
script = 'import json; print(json.dumps({"ok": True, "pad": "x" * 80000}))'
|
|
47
|
+
rc, raw = exec_cli_to_file([sys.executable, "-c", script], out)
|
|
48
|
+
assert rc == 0
|
|
49
|
+
assert out.stat().st_size > 65536
|
|
50
|
+
parsed = json.loads(raw)
|
|
51
|
+
assert parsed["ok"] is True
|
|
52
|
+
assert len(parsed["pad"]) == 80000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_page_cap_covers_tiktok_bid_pages():
|
|
56
|
+
# 实跑 TT 上期出价 48941 行 / 1000 ≈ 49 页,旧上限 30 会截断
|
|
57
|
+
assert PAGE_CAP >= 80
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_period_verdict_follows_qoq_not_kpi():
|
|
61
|
+
# 第二期:KPI 未达标也不能单独打成无效
|
|
62
|
+
assert period_verdict(first_period=False, qoq_label="变好", kpi_usable=True, kpi_hit=False) == "有效"
|
|
63
|
+
assert period_verdict(first_period=False, qoq_label="变差", kpi_usable=True, kpi_hit=False) == "待观察(预警)"
|
|
64
|
+
assert period_verdict(first_period=False, qoq_label="稳定", kpi_usable=True, kpi_hit=True) == "待观察"
|
|
65
|
+
# 首期:KPI 认不出 → 待观察
|
|
66
|
+
assert period_verdict(first_period=True, qoq_label="无法对比", kpi_usable=False, kpi_hit=None) == "待观察"
|
|
67
|
+
assert period_verdict(first_period=True, qoq_label="无法对比", kpi_usable=True, kpi_hit=True) == "有效"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_is_first_period_uses_prev_dates():
|
|
71
|
+
assert is_first_period({}) is True
|
|
72
|
+
assert is_first_period({"prevPeriodStart": "2026-09-07", "prevPeriodEnd": "2026-09-13"}) is False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_ghost_enum_zero_share_is_not_stop():
|
|
76
|
+
assert is_ghost_enum(0.0, 0.0) is True
|
|
77
|
+
assert is_ghost_enum(6.0, 0.0) is False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_region_bucket_non_sea_before_sea():
|
|
81
|
+
assert region_bucket("两性-恋爱攻略-全球非东南亚-自制-英语-install") == "other"
|
|
82
|
+
assert region_bucket("KPOP测试-舞蹈二创-东南亚仅马泰") == "sea"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_bid_adjust_stats_aggregates_and_lists_when_few():
|
|
86
|
+
items = [
|
|
87
|
+
{"name": "A", "from": 5.0, "to": 8.0, "times": 2},
|
|
88
|
+
{"name": "B", "from": 10.0, "to": 7.0, "times": 1},
|
|
89
|
+
{"name": "C", "from": 3.0, "to": 3.0, "times": 1},
|
|
90
|
+
]
|
|
91
|
+
stats = bid_adjust_stats(items)
|
|
92
|
+
assert stats["raise_n"] == 1
|
|
93
|
+
assert stats["lower_n"] == 1
|
|
94
|
+
assert stats["flat_n"] == 1
|
|
95
|
+
assert stats["median_abs"] == 3.0
|
|
96
|
+
assert stats["list_all"] is True
|
|
97
|
+
assert len(stats["details"]) == 3
|
|
98
|
+
many = [{"name": str(i), "from": 1.0, "to": 2.0, "times": 1} for i in range(12)]
|
|
99
|
+
crowded = bid_adjust_stats(many)
|
|
100
|
+
assert crowded["raise_n"] == 12
|
|
101
|
+
assert crowded["list_all"] is False
|
|
102
|
+
assert crowded["details"] == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_budget_abs_share_flags_contradiction():
|
|
106
|
+
line = budget_abs_share("FB", prev_abs=28886, curr_abs=25194, prev_share=8.8, curr_share=11.6)
|
|
107
|
+
assert "$28,886 → $25,194" in line
|
|
108
|
+
assert "8.8% → 11.6%" in line
|
|
109
|
+
assert "占比升但金额降" in line
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_naming_patterns_picks_short_tokens():
|
|
113
|
+
names = [
|
|
114
|
+
"KPOP测试-舞蹈二创-802-东南亚仅越南-自制-英语-激活-2609",
|
|
115
|
+
"KPOP测试-线下周边-803-东南亚仅马泰-自制-英语-登录-2609",
|
|
116
|
+
"彩妆测试-整体妆容-东南亚-自制-英语-install-260916",
|
|
117
|
+
]
|
|
118
|
+
text = naming_patterns(names)
|
|
119
|
+
assert "KPOP" in text or "舞蹈" in text
|
|
120
|
+
assert "彩妆" in text or "整体妆容" in text
|
|
121
|
+
assert len(text) < 200
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_summarize_countries_aggregates_when_over_50():
|
|
125
|
+
countries = [f"国{i}" for i in range(60)]
|
|
126
|
+
main, extra = summarize_countries(countries)
|
|
127
|
+
assert "超过 50" in main or "区域" in main or "大洲" in main
|
|
128
|
+
assert len(extra) == 60
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_build_report_writes_strategy_and_detail_sheets():
|
|
132
|
+
from build_report import write_xlsx
|
|
133
|
+
|
|
134
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
135
|
+
out = os.path.join(tmp, "out.xlsx")
|
|
136
|
+
rows = [
|
|
137
|
+
{
|
|
138
|
+
"group": "APP-华南组",
|
|
139
|
+
"bizLine": "示例",
|
|
140
|
+
"period": "本期:2026-09-14~2026-09-20\n上期:2026-09-07~2026-09-13",
|
|
141
|
+
"project": "项目A",
|
|
142
|
+
"budgetStrategy": "【预算】",
|
|
143
|
+
"budgetLearning": "结论:有效",
|
|
144
|
+
"productStrategy": "【广告目标】",
|
|
145
|
+
"productLearning": "结论:待观察",
|
|
146
|
+
"audienceStrategy": "【国家】",
|
|
147
|
+
"audienceLearning": "结论:有效",
|
|
148
|
+
"creativeStrategy": "【广告】",
|
|
149
|
+
"creativeLearning": "结论:有效",
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
details = [
|
|
153
|
+
{
|
|
154
|
+
"bizLine": "示例",
|
|
155
|
+
"kind": "国家",
|
|
156
|
+
"action": "新增",
|
|
157
|
+
"channel": "TikTok",
|
|
158
|
+
"name": "西撒哈拉",
|
|
159
|
+
"extra": "消耗 $0",
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
write_xlsx(rows, out, details=details)
|
|
163
|
+
with zipfile.ZipFile(out) as zf:
|
|
164
|
+
names = zf.namelist()
|
|
165
|
+
assert "xl/worksheets/sheet1.xml" in names
|
|
166
|
+
assert "xl/worksheets/sheet2.xml" in names
|
|
167
|
+
wb = zf.read("xl/workbook.xml").decode("utf-8")
|
|
168
|
+
assert "策略" in wb
|
|
169
|
+
assert "明细" in wb
|
|
170
|
+
sheet2 = zf.read("xl/worksheets/sheet2.xml").decode("utf-8")
|
|
171
|
+
sst = zf.read("xl/sharedStrings.xml").decode("utf-8")
|
|
172
|
+
assert "西撒哈拉" in sst
|
|
173
|
+
assert "sheetData" in sheet2
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_build_report_preserves_carriage_returns():
|
|
177
|
+
"""客户 KPI 与广告名中的 CR/CRLF 必须逐字保留,不能被 XML 换行归一化。"""
|
|
178
|
+
from build_report import write_xlsx, validate_xlsx
|
|
179
|
+
|
|
180
|
+
text = "客户 KPI\r\n第二行\r第三行\n<&> & "
|
|
181
|
+
rows = [{"bizLine": "换行验证", "budgetLearning": text}]
|
|
182
|
+
details = [{"bizLine": "换行验证", "name": text, "extra": text * 100}]
|
|
183
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
184
|
+
path = Path(tmp) / "line-endings.xlsx"
|
|
185
|
+
write_xlsx(rows, str(path), details)
|
|
186
|
+
assert validate_xlsx(path, rows, details)["rows"] == 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
tests = [
|
|
191
|
+
test_list_query_uses_data_body_not_flags,
|
|
192
|
+
test_exec_cli_to_file_keeps_payload_over_64kb,
|
|
193
|
+
test_page_cap_covers_tiktok_bid_pages,
|
|
194
|
+
test_period_verdict_follows_qoq_not_kpi,
|
|
195
|
+
test_is_first_period_uses_prev_dates,
|
|
196
|
+
test_ghost_enum_zero_share_is_not_stop,
|
|
197
|
+
test_region_bucket_non_sea_before_sea,
|
|
198
|
+
test_bid_adjust_stats_aggregates_and_lists_when_few,
|
|
199
|
+
test_budget_abs_share_flags_contradiction,
|
|
200
|
+
test_naming_patterns_picks_short_tokens,
|
|
201
|
+
test_summarize_countries_aggregates_when_over_50,
|
|
202
|
+
test_build_report_writes_strategy_and_detail_sheets,
|
|
203
|
+
test_build_report_preserves_carriage_returns,
|
|
204
|
+
]
|
|
205
|
+
failed = 0
|
|
206
|
+
for fn in tests:
|
|
207
|
+
try:
|
|
208
|
+
fn()
|
|
209
|
+
print("ok", fn.__name__)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
failed += 1
|
|
212
|
+
print("FAIL", fn.__name__, type(e).__name__, e)
|
|
213
|
+
if failed:
|
|
214
|
+
raise SystemExit(1)
|
|
215
|
+
print("DONE")
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""离线回归:指标口径、分页、并发、最终文件和清理边界,不请求业务网关。"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
sys.dont_write_bytecode = True
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from cli_json import run_cli
|
|
17
|
+
from fetch_data import fetch_paged
|
|
18
|
+
from summarize import bid_changes, dim_layer_text
|
|
19
|
+
from cli_json import core_rule, metric_value, qoq_axis, cli_context, exec_cli_to_file, shim_entry
|
|
20
|
+
from cleanup import OwnedDirectory
|
|
21
|
+
from build_report import write_xlsx, validate_xlsx, prepare_layout, COLUMNS
|
|
22
|
+
from run_workflow import run_batch, execute_business, publish
|
|
23
|
+
from argparse import Namespace
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def arguments(output, ids=None):
|
|
27
|
+
"""测试批次沿用真实入口参数,不访问业务接口。"""
|
|
28
|
+
return Namespace(id=ids or [1], name='', concurrency=3, output_dir=str(output), curr_start='', curr_end='', prev_start='', prev_end='', account_fb=[], account_gg=[], account_tt=[])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def small_payload():
|
|
32
|
+
"""只包含匿名样本的完整 12 列数据。"""
|
|
33
|
+
return {'rows': [{key: ('示例' if key == 'bizLine' else label) for key, label in COLUMNS}],
|
|
34
|
+
'details': [{'bizLine': '示例', 'kind': '广告', 'action': '新增', 'name': '同名', 'extra': 'ID=1'}]}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DataRules(unittest.TestCase):
|
|
38
|
+
"""防止缺失值和失败回包被当作有效业务数据。"""
|
|
39
|
+
|
|
40
|
+
def test_no_default_cpm_kpi(self):
|
|
41
|
+
text, hit, _, _ = dim_layer_text("广告", [{"label": "A", "spend": 80, "imps": 10000, "cpm": 8}], [])
|
|
42
|
+
self.assertNotIn("未达标", text)
|
|
43
|
+
self.assertIsNone(hit)
|
|
44
|
+
|
|
45
|
+
def test_missing_bid_not_zero(self):
|
|
46
|
+
self.assertEqual(bid_changes([
|
|
47
|
+
{"adgroup_id": "1", "calendar_date": "2026-09-01", "bid_amount": None},
|
|
48
|
+
{"adgroup_id": "1", "calendar_date": "2026-09-02", "bid_amount": 2},
|
|
49
|
+
], ["bid_amount"]), [])
|
|
50
|
+
|
|
51
|
+
def test_failed_page_never_writes_merged(self):
|
|
52
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", return_value={"ok": False, "error": "失败"}):
|
|
53
|
+
with self.assertRaises(RuntimeError):
|
|
54
|
+
fetch_paged("+facebook-metrics", {}, "fixture", Path(tmp))
|
|
55
|
+
self.assertFalse((Path(tmp) / "fixture.merged.json").exists())
|
|
56
|
+
|
|
57
|
+
def test_short_page_still_fetches_total(self):
|
|
58
|
+
pages = [{"ok": True, "data": {"data": [{"id": 1}], "total": 2}}, {"ok": True, "data": {"data": [], "total": 2}}]
|
|
59
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", side_effect=pages):
|
|
60
|
+
with self.assertRaises(RuntimeError):
|
|
61
|
+
fetch_paged("+facebook-metrics", {}, "fixture", Path(tmp))
|
|
62
|
+
|
|
63
|
+
def test_stderr_and_nonzero_override_success(self):
|
|
64
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
65
|
+
# 替身使用真实子进程证明退出码和 stderr 不被吞掉。
|
|
66
|
+
command = [sys.executable, "-c", 'import sys; print(\'{"ok": true}\'); sys.stderr.write("fixture denied"); sys.exit(2)']
|
|
67
|
+
with patch("cli_json.resolve_cli", return_value=command):
|
|
68
|
+
result = run_cli([], None, Path(tmp) / "response.out.json")
|
|
69
|
+
self.assertFalse(result["ok"])
|
|
70
|
+
self.assertIn("fixture denied", str(result))
|
|
71
|
+
|
|
72
|
+
def test_metric_units_denominator_and_direction(self):
|
|
73
|
+
self.assertFalse(core_rule('人民币 CPA≤30,次留>20%')['usable'])
|
|
74
|
+
rule = core_rule('CPI USD <= 3')
|
|
75
|
+
self.assertTrue(rule['usable'])
|
|
76
|
+
self.assertEqual(metric_value([{'spend_usd': 10, 'installs': 1}, {'spend_usd': 90, 'installs': 49}]), 2)
|
|
77
|
+
self.assertIsNone(metric_value([{'spend_usd': 10, 'installs': 0}]))
|
|
78
|
+
self.assertIsNone(metric_value([{'spend_usd': 10, 'installs': 10}, {'spend_usd': 50}]))
|
|
79
|
+
self.assertEqual(qoq_axis(2, 1, True)[0], '变好')
|
|
80
|
+
|
|
81
|
+
def test_page_cap_is_failure(self):
|
|
82
|
+
page = {'ok': True, 'data': {'data': [{'id': 1}], 'total': 2}}
|
|
83
|
+
with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.PAGE_CAP', 1), patch('fetch_data.run_cli', return_value=page):
|
|
84
|
+
with self.assertRaises(RuntimeError):
|
|
85
|
+
fetch_paged('+facebook-metrics', {}, 'fixture', Path(tmp))
|
|
86
|
+
|
|
87
|
+
def test_zero_total_with_nonempty_rows_is_invalid(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', return_value={'ok': True, 'data': {'data': [{'id': 1}], 'total': 0}}):
|
|
89
|
+
with self.assertRaises(RuntimeError):
|
|
90
|
+
fetch_paged('+facebook-metrics', {}, 'fixture', Path(tmp))
|
|
91
|
+
|
|
92
|
+
def test_pnpm_shim_path(self):
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
root = Path(tmp)
|
|
95
|
+
entry = root / 'global/5/node_modules/sino-bc-cli/dist/index.js'
|
|
96
|
+
entry.parent.mkdir(parents=True)
|
|
97
|
+
entry.write_text('// fixture')
|
|
98
|
+
shim = root / 'sino-bc-cli.cmd'
|
|
99
|
+
shim.write_text('@node "%~dp0global/5/node_modules/sino-bc-cli/dist/index.js" %*')
|
|
100
|
+
self.assertEqual(shim_entry(shim), entry.resolve())
|
|
101
|
+
self.assertTrue(core_rule('CPI <= 2 usd')['usable'])
|
|
102
|
+
|
|
103
|
+
def test_multiple_bid_fields_count_changed_days_once(self):
|
|
104
|
+
changes = bid_changes([
|
|
105
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-01', 'cpc': 1, 'cpm': 5},
|
|
106
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-02', 'cpc': 2, 'cpm': 6},
|
|
107
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-03', 'cpc': 1, 'cpm': 5},
|
|
108
|
+
], ['cpc', 'cpm'])
|
|
109
|
+
self.assertEqual(changes[0]['times'], 2)
|
|
110
|
+
self.assertEqual(changes[0]['from'], changes[0]['to'])
|
|
111
|
+
|
|
112
|
+
def test_complete_fetch_analysis_and_id_details(self):
|
|
113
|
+
from fetch_data import fetch_business
|
|
114
|
+
from summarize import summarize_request
|
|
115
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
116
|
+
root = Path(tmp)
|
|
117
|
+
args = arguments(root / 'output')
|
|
118
|
+
args.id, args.request_dir = 1, str(root)
|
|
119
|
+
biz = {'id': 1, 'name': '匿名样本', 'periodStart': '2026-09-08', 'periodEnd': '2026-09-14', 'prevPeriodStart': '2026-09-01', 'prevPeriodEnd': '2026-09-07', 'coreIndex': 'CPI USD <= 3'}
|
|
120
|
+
def api(command, body, out):
|
|
121
|
+
# 替身核对生产 shortcut/请求体,并像 CLI 一样落盘基础回包。
|
|
122
|
+
self.assertEqual(command[0], 'biz-line')
|
|
123
|
+
if command[1] == '+list':
|
|
124
|
+
self.assertEqual(body['idList'], [1])
|
|
125
|
+
env = {'ok': True, 'data': {'result': [biz], 'total': 1}}
|
|
126
|
+
elif command[1] == '+detail':
|
|
127
|
+
env = {'ok': True, 'data': biz}
|
|
128
|
+
elif command[1] == '+project-list':
|
|
129
|
+
env = {'ok': True, 'data': [{'projectName': '样本项目', 'adAccounts': [{'adAccountId': 'fixture', 'channelName': 'Facebook'}]}]}
|
|
130
|
+
else:
|
|
131
|
+
self.assertIn(command[1], ('+facebook-dimension', '+facebook-metrics'))
|
|
132
|
+
self.assertEqual(body['dimensionValues'], {'account_id': ['fixture']})
|
|
133
|
+
self.assertEqual(body['size'], 1000)
|
|
134
|
+
current = body['startDate'] == biz['periodStart']
|
|
135
|
+
dim = body['dimensions']
|
|
136
|
+
metrics = {'spend_usd': 100, 'installs': 50 if current else 25, 'impressions': 10000, 'clicks': 500, 'purchase': 10, 'purchase_value': 300}
|
|
137
|
+
rows = []
|
|
138
|
+
if dim == ['account_id']:
|
|
139
|
+
rows = [metrics]
|
|
140
|
+
elif dim == ['calendar_date']:
|
|
141
|
+
first, count = (8, 6) if current else (1, 7)
|
|
142
|
+
rows = [{**metrics, 'calendar_date': f'2026-09-{d:02d}'} for d in range(first, first + count)]
|
|
143
|
+
elif dim == ['ad_id', 'ad_name'] or dim == ['adgroup_id', 'adgroup_name']:
|
|
144
|
+
rows = [{**metrics, dim[0]: str(i), dim[1]: '同名'} for i in (1, 2)] if current else []
|
|
145
|
+
elif dim == ['country_code']:
|
|
146
|
+
rows = [{**metrics, 'country_code': 'US'}]
|
|
147
|
+
elif dim[0] == 'calendar_date':
|
|
148
|
+
rows = [{'adgroup_id': '1', 'adgroup_name': '同名', 'calendar_date': f'2026-09-{d:02d}', 'bid_amount': bid} for d, bid in ((8, 1), (9, 2), (10, 1))]
|
|
149
|
+
env = {'ok': True, 'data': {'data': rows, 'total': len(rows)}}
|
|
150
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
out.write_text(json.dumps(env, ensure_ascii=False), encoding='utf-8')
|
|
152
|
+
return env
|
|
153
|
+
with patch('fetch_data.run_cli', api), cli_context(threading.Event(), lambda _: None):
|
|
154
|
+
fetch_business(args)
|
|
155
|
+
payload, summary = summarize_request(root)
|
|
156
|
+
self.assertEqual(summary['overall_cpi'], [2, 4])
|
|
157
|
+
self.assertEqual(summary['spend_verdict'], '有效')
|
|
158
|
+
self.assertIn('覆盖不完整', payload['rows'][0]['period'])
|
|
159
|
+
for kind in ('广告', '广告组'):
|
|
160
|
+
objects = [r for r in payload['details'] if r['kind'] == kind]
|
|
161
|
+
self.assertEqual(len(objects), 2)
|
|
162
|
+
self.assertIn('ID=1', objects[0]['extra'])
|
|
163
|
+
self.assertIn('ID=2', objects[1]['extra'])
|
|
164
|
+
bid = next(r for r in payload['details'] if r['kind'] == '出价')
|
|
165
|
+
self.assertNotEqual(bid['action'], '下调')
|
|
166
|
+
# 汇总前损坏已完成文件必须失败,不能重新退回第一页继续。
|
|
167
|
+
(root / 'bl-1/met_fb_curr_account_id.merged.json').write_text('[]')
|
|
168
|
+
with self.assertRaises(ValueError):
|
|
169
|
+
summarize_request(root)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CleanupAndExport(unittest.TestCase):
|
|
173
|
+
"""真实文件验证不同扩展名、链接目标、锁失败和最终 Excel 保护。"""
|
|
174
|
+
|
|
175
|
+
def test_all_types_and_final_protected(self):
|
|
176
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
177
|
+
root = Path(tmp)
|
|
178
|
+
owned = OwnedDirectory.create(root, 'owned-')
|
|
179
|
+
final = root / 'final.xlsx'
|
|
180
|
+
final.write_text('final')
|
|
181
|
+
history = root / 'history.json'
|
|
182
|
+
history.write_text('history')
|
|
183
|
+
for name in ('raw.json', 'preview.png', 'temp.xlsx', 'run.py', 'log.txt', 'no-extension'):
|
|
184
|
+
(owned.path / name).write_text('temp')
|
|
185
|
+
(owned.path / 'cache').mkdir()
|
|
186
|
+
(owned.path / 'cache/a.pyc').write_bytes(b'cache')
|
|
187
|
+
self.assertTrue(owned.cleanup(final)['ok'])
|
|
188
|
+
self.assertFalse(owned.path.exists())
|
|
189
|
+
self.assertTrue(final.exists() and history.exists())
|
|
190
|
+
|
|
191
|
+
def test_final_inside_and_forged_owner_refused(self):
|
|
192
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
193
|
+
owned = OwnedDirectory.create(Path(tmp), 'owned-')
|
|
194
|
+
self.assertFalse(owned.cleanup(owned.path / 'final.xlsx')['ok'])
|
|
195
|
+
fake = OwnedDirectory(owned.path, owned.parent, 'wrong')
|
|
196
|
+
self.assertFalse(fake.cleanup()['ok'])
|
|
197
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
198
|
+
|
|
199
|
+
def test_external_junction_or_symlink_target_survives(self):
|
|
200
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
201
|
+
root = Path(tmp)
|
|
202
|
+
outside = root / 'external'
|
|
203
|
+
outside.mkdir()
|
|
204
|
+
(outside / 'keep').write_text('keep')
|
|
205
|
+
owned = OwnedDirectory.create(root, 'owned-')
|
|
206
|
+
link = owned.path / 'link'
|
|
207
|
+
if os.name == 'nt':
|
|
208
|
+
# 仅建立受控测试路径的 junction,不通过 shell 执行任何删除。
|
|
209
|
+
subprocess.run(['cmd', '/c', 'mklink', '/J', str(link), str(outside)], check=True, stdout=subprocess.DEVNULL)
|
|
210
|
+
else:
|
|
211
|
+
link.symlink_to(outside, target_is_directory=True)
|
|
212
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
213
|
+
self.assertEqual((outside / 'keep').read_text(), 'keep')
|
|
214
|
+
|
|
215
|
+
def test_delete_error_reported(self):
|
|
216
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
217
|
+
owned = OwnedDirectory.create(Path(tmp), 'owned-')
|
|
218
|
+
(owned.path / 'locked').write_text('locked')
|
|
219
|
+
real_unlink = Path.unlink
|
|
220
|
+
def locked(path, *args, **kwargs):
|
|
221
|
+
if path.name == 'locked':
|
|
222
|
+
raise PermissionError('file in use')
|
|
223
|
+
return real_unlink(path, *args, **kwargs)
|
|
224
|
+
with patch.object(Path, 'unlink', locked):
|
|
225
|
+
result = owned.cleanup()
|
|
226
|
+
self.assertFalse(result['ok'])
|
|
227
|
+
self.assertIn('locked', str(result['residual']))
|
|
228
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
229
|
+
|
|
230
|
+
def test_long_text_full_export_and_no_overwrite(self):
|
|
231
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
232
|
+
payload = small_payload()
|
|
233
|
+
long_text = '完整说明与特殊字符 <&>\n' * 5000
|
|
234
|
+
payload['details'][0]['extra'] = long_text
|
|
235
|
+
payload['rows'][0]['budgetStrategy'] = long_text
|
|
236
|
+
path = Path(tmp) / 'out.xlsx'
|
|
237
|
+
write_xlsx(payload['rows'], str(path), payload['details'])
|
|
238
|
+
counts = validate_xlsx(path, payload['rows'], payload['details'])
|
|
239
|
+
self.assertGreater(counts['details'], 1)
|
|
240
|
+
_, details = prepare_layout(payload['rows'], payload['details'])
|
|
241
|
+
self.assertEqual(''.join(r['extra'] for r in details if r['kind'] == '广告'), long_text)
|
|
242
|
+
with self.assertRaises(FileExistsError):
|
|
243
|
+
write_xlsx(payload['rows'], str(path), payload['details'])
|
|
244
|
+
final = Path(tmp) / 'final.xlsx'
|
|
245
|
+
final.write_bytes(b'existing')
|
|
246
|
+
with self.assertRaises(FileExistsError):
|
|
247
|
+
publish(path, final, payload)
|
|
248
|
+
self.assertEqual(final.read_bytes(), b'existing')
|
|
249
|
+
with self.assertRaises(ValueError):
|
|
250
|
+
validate_xlsx(path, small_payload()['rows'], [])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class BatchExecution(unittest.TestCase):
|
|
254
|
+
"""通过阻塞屏障验证实际重叠、即时交付以及任务隔离。"""
|
|
255
|
+
|
|
256
|
+
def test_ten_jobs_peak_three_and_deduplicate(self):
|
|
257
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
258
|
+
root = Path(tmp)
|
|
259
|
+
args = arguments(root / 'output', list(range(1, 11)) + [1])
|
|
260
|
+
barrier = threading.Barrier(3)
|
|
261
|
+
lock = threading.Lock()
|
|
262
|
+
active = peak = 0
|
|
263
|
+
observed = []
|
|
264
|
+
def worker(target, args, batch, cancel, progress):
|
|
265
|
+
nonlocal active, peak
|
|
266
|
+
with lock:
|
|
267
|
+
active += 1
|
|
268
|
+
peak = max(peak, active)
|
|
269
|
+
owned = OwnedDirectory.create(batch.path, f'line-{target}-')
|
|
270
|
+
if target <= 3:
|
|
271
|
+
barrier.wait(timeout=5)
|
|
272
|
+
time.sleep(0.02 if target != 1 else 0.12)
|
|
273
|
+
cleanup = owned.cleanup()
|
|
274
|
+
with lock:
|
|
275
|
+
active -= 1
|
|
276
|
+
return {'id': target, 'ok': True, 'cleanup': cleanup}
|
|
277
|
+
result = run_batch(args, worker=worker, progress=observed.append, temp_parent=root)
|
|
278
|
+
self.assertTrue(result['ok'])
|
|
279
|
+
self.assertEqual(result['success'], 10)
|
|
280
|
+
self.assertEqual(peak, 3)
|
|
281
|
+
self.assertNotEqual(observed[0]['id'], 1)
|
|
282
|
+
self.assertFalse(list((root / 'sino-bc-strategy').iterdir()))
|
|
283
|
+
|
|
284
|
+
def test_mixed_real_worker_results_cleanup_immediate(self):
|
|
285
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
286
|
+
root = Path(tmp)
|
|
287
|
+
args = arguments(root / 'output', [1, 2, 3])
|
|
288
|
+
def fetch(local):
|
|
289
|
+
(Path(local.request_dir) / 'fixture.json').write_text('{}')
|
|
290
|
+
if local.id == 2:
|
|
291
|
+
raise RuntimeError('fixture fetch failed')
|
|
292
|
+
return {'id': local.id, 'name': '同名'}
|
|
293
|
+
with patch('run_workflow.fetch_business', fetch), patch('run_workflow.summarize_request', return_value=(small_payload(), {'detail_count': 1})):
|
|
294
|
+
result = run_batch(args, progress=lambda _: None, temp_parent=root)
|
|
295
|
+
self.assertFalse(result['ok'])
|
|
296
|
+
self.assertEqual(result['success'], 2)
|
|
297
|
+
self.assertEqual(len(list((root / 'output').glob('*.xlsx'))), 2)
|
|
298
|
+
self.assertFalse(list((root / 'sino-bc-strategy').iterdir()))
|
|
299
|
+
self.assertIn('fixture fetch failed', str(result))
|
|
300
|
+
|
|
301
|
+
def test_invalid_concurrency_before_creating_directory(self):
|
|
302
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
303
|
+
args = arguments(Path(tmp) / 'output')
|
|
304
|
+
args.concurrency = 4
|
|
305
|
+
with self.assertRaises(ValueError):
|
|
306
|
+
run_batch(args, temp_parent=Path(tmp))
|
|
307
|
+
self.assertFalse(list(Path(tmp).iterdir()))
|
|
308
|
+
|
|
309
|
+
def test_cleanup_failure_keeps_final_and_fails_batch(self):
|
|
310
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
311
|
+
root = Path(tmp)
|
|
312
|
+
real_cleanup = OwnedDirectory.cleanup
|
|
313
|
+
def locked_cleanup(owned, final=None):
|
|
314
|
+
if owned.path.name.startswith('line-1-'):
|
|
315
|
+
return {'ok': False, 'residual': [{'path': str(owned.path), 'error': 'fixture locked'}]}
|
|
316
|
+
return real_cleanup(owned, final)
|
|
317
|
+
with patch('run_workflow.fetch_business', return_value={'id': 1, 'name': '样本'}), patch('run_workflow.summarize_request', return_value=(small_payload(), {'detail_count': 1})), patch.object(OwnedDirectory, 'cleanup', locked_cleanup):
|
|
318
|
+
result = run_batch(arguments(root / 'output'), progress=lambda _: None, temp_parent=root)
|
|
319
|
+
self.assertFalse(result['ok'])
|
|
320
|
+
self.assertTrue(Path(result['results'][0]['output_path']).is_file())
|
|
321
|
+
self.assertTrue(result['results'][0]['cleanup_failed'])
|
|
322
|
+
self.assertTrue(result['cleanup']['residual'])
|
|
323
|
+
|
|
324
|
+
def test_cancel_terminates_cli_and_cleans(self):
|
|
325
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
326
|
+
cancel = threading.Event()
|
|
327
|
+
timer = threading.Timer(0.15, cancel.set)
|
|
328
|
+
timer.start()
|
|
329
|
+
try:
|
|
330
|
+
with cli_context(cancel, lambda _: None):
|
|
331
|
+
with self.assertRaises(InterruptedError):
|
|
332
|
+
exec_cli_to_file([sys.executable, '-c', 'import time; time.sleep(30)'], Path(tmp) / 'cancel.out.json')
|
|
333
|
+
finally:
|
|
334
|
+
timer.join()
|
|
335
|
+
# 子进程退出后输出文件可立即独占打开,证明不持有清理所需句柄。
|
|
336
|
+
with (Path(tmp) / 'cancel.out.json').open('wb') as stream:
|
|
337
|
+
stream.write(b'closed')
|
|
338
|
+
args = arguments(Path(tmp) / 'output', [1, 2, 3, 4])
|
|
339
|
+
result = run_batch(args, cancel=cancel, progress=lambda _: None, temp_parent=Path(tmp))
|
|
340
|
+
self.assertEqual(result['failed'], 4)
|
|
341
|
+
self.assertFalse(list((Path(tmp) / 'sino-bc-strategy').iterdir()))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
unittest.main()
|