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.
@@ -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<&> &amp; &#13;"
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sino-bc-cli",
3
- "version": "1.8.19",
3
+ "version": "1.8.21",
4
4
  "description": "BeyondClick CLI - 命令行工具集合,提供快捷访问 BeyondClick 平台的命令行接口",
5
5
  "preferGlobal": true,
6
6
  "bin": {