sino-bc-cli 1.8.20 → 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.
Files changed (22) hide show
  1. package/dist/SKILL.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
  4. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
  5. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
  6. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
  7. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
  8. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
  9. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
  10. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +98 -9
  11. package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
  12. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
  13. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
  14. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
  15. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +158 -148
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
  18. package/package.json +1 -1
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/summarize.cpython-314.pyc +0 -0
@@ -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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sino-bc-cli",
3
- "version": "1.8.20",
3
+ "version": "1.8.21",
4
4
  "description": "BeyondClick CLI - 命令行工具集合,提供快捷访问 BeyondClick 平台的命令行接口",
5
5
  "preferGlobal": true,
6
6
  "bin": {