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.
- package/dist/SKILL.md +1 -1
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
- 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 +98 -9
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
- 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 +158 -148
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
- 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
|
Binary file
|
package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc
DELETED
|
Binary file
|