sino-bc-cli 1.0.0-beta.0

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 (123) hide show
  1. package/README.md +256 -0
  2. package/dist/affordance/auth.md +31 -0
  3. package/dist/affordance/bill.md +48 -0
  4. package/dist/affordance/biz-line-ai-strategy.md +35 -0
  5. package/dist/affordance/biz-line-metrics.md +47 -0
  6. package/dist/affordance/biz-line.md +84 -0
  7. package/dist/affordance/common-file.md +25 -0
  8. package/dist/affordance/dashboard.md +75 -0
  9. package/dist/affordance/dict.md +28 -0
  10. package/dist/affordance/document.md +67 -0
  11. package/dist/affordance/im.md +91 -0
  12. package/dist/affordance/invoice.md +49 -0
  13. package/dist/affordance/media.md +17 -0
  14. package/dist/affordance/organization.md +47 -0
  15. package/dist/affordance/project-analysis.md +95 -0
  16. package/dist/affordance/project.md +321 -0
  17. package/dist/affordance/quota.md +19 -0
  18. package/dist/affordance/reconciliation.md +41 -0
  19. package/dist/affordance/report.md +75 -0
  20. package/dist/affordance/sso-team-group.md +30 -0
  21. package/dist/affordance/task.md +148 -0
  22. package/dist/affordance/tenant.md +17 -0
  23. package/dist/affordance/user.md +41 -0
  24. package/dist/affordance/wallet.md +100 -0
  25. package/dist/api-doc/README.md +67 -0
  26. package/dist/api-doc/custom/template.md +0 -0
  27. package/dist/api-doc/oapi/auth.md +61 -0
  28. package/dist/api-doc/oapi/bill.md +130 -0
  29. package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
  30. package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
  31. package/dist/api-doc/oapi/biz-line.md +382 -0
  32. package/dist/api-doc/oapi/common-file.md +50 -0
  33. package/dist/api-doc/oapi/dashboard.md +218 -0
  34. package/dist/api-doc/oapi/dict.md +49 -0
  35. package/dist/api-doc/oapi/document.md +182 -0
  36. package/dist/api-doc/oapi/im.md +278 -0
  37. package/dist/api-doc/oapi/invoice.md +129 -0
  38. package/dist/api-doc/oapi/media.md +17 -0
  39. package/dist/api-doc/oapi/organization.md +94 -0
  40. package/dist/api-doc/oapi/project-analysis.md +364 -0
  41. package/dist/api-doc/oapi/project.md +1114 -0
  42. package/dist/api-doc/oapi/quota.md +21 -0
  43. package/dist/api-doc/oapi/reconciliation.md +98 -0
  44. package/dist/api-doc/oapi/report.md +254 -0
  45. package/dist/api-doc/oapi/sso-team-group.md +54 -0
  46. package/dist/api-doc/oapi/task.md +528 -0
  47. package/dist/api-doc/oapi/tenant.md +17 -0
  48. package/dist/api-doc/oapi/user.md +98 -0
  49. package/dist/api-doc/oapi/wallet.md +275 -0
  50. package/dist/generated/api-catalog.json +39681 -0
  51. package/dist/generated/api-directory-index.json +1509 -0
  52. package/dist/index.js +2 -0
  53. package/dist/skill-manifest.json +336 -0
  54. package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
  55. package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
  56. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
  57. package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
  58. package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
  59. package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
  60. package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
  61. package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
  62. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
  63. package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
  64. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
  65. package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
  66. package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
  67. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
  68. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
  69. package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
  70. package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
  71. package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
  72. package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
  73. package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
  74. package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
  75. package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
  76. package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
  77. package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
  78. package/dist/skills/sino-bc-cli/SKILL.md +62 -0
  79. package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
  80. package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
  81. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
  82. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
  83. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
  84. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
  85. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
  86. package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
  87. package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
  88. package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
  89. package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
  90. package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
  91. package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
  92. package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
  93. package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
  94. package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
  95. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
  96. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
  97. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
  98. package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
  99. package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
  100. package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
  101. package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
  102. package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
  103. package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
  104. package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
  105. package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
  106. package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
  107. package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
  108. package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
  109. package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
  110. package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
  111. package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
  112. package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
  113. package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
  114. package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
  115. package/dist/skills/sino-bc-shared/SKILL.md +31 -0
  116. package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
  117. package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
  118. package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
  119. package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
  120. package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
  121. package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
  122. package/package.json +70 -0
  123. package/scripts/postinstall.cjs +36 -0
@@ -0,0 +1,253 @@
1
+ """使用真实单条工作流验证拉数/AI滑动交接,不请求生产接口。"""
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+ import tempfile
6
+ import threading
7
+ import unittest
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from unittest.mock import patch
10
+
11
+ sys.dont_write_bytecode = True
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ from run_workflow import run_batch
14
+ from strategy_output import submit_tag_choices
15
+ from test_workflow import arguments, small_payload
16
+ from test_ai_strategy import AI_TEXT
17
+
18
+
19
+ class SlidingPipeline(unittest.TestCase):
20
+ """用事件控制先后,不靠耗时猜测滑动补位是否发生。"""
21
+
22
+ def execute(self, root, targets, fetch, progress, cancel=None, concurrency=3):
23
+ """仅替代业务拉数与固定分析,标签、AI文件协议、Excel和清理走真实实现。"""
24
+ args = arguments(root / 'output', targets)
25
+ args.concurrency = concurrency
26
+ dictionaries = {key: [{'id': 11, 'name': '其他', 'isOther': 1}]
27
+ for key in ('budget', 'product', 'audience')}
28
+ def analyze(directory):
29
+ payload = small_payload()
30
+ (directory / 'payload.json').write_text(json.dumps(payload), encoding='utf-8')
31
+ return payload, {'detail_count': 1, 'curr_total': 100}
32
+ with patch('run_workflow.fetch_business', side_effect=fetch), \
33
+ patch('run_workflow.summarize_request', side_effect=analyze), \
34
+ patch('strategy_output.TagCache.get', return_value=dictionaries):
35
+ return run_batch(args, progress=progress, cancel=cancel, temp_parent=root)
36
+
37
+ def reply(self, event):
38
+ """只模拟AI决策,提交/身份校验及工作簿消费沿用生产入口。"""
39
+ if event.get('event') not in ('tag_review_required', 'ai_strategy_required'):
40
+ return
41
+ packet = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
42
+ if event['event'] == 'tag_review_required':
43
+ choices = {'selections': {key: [{'choiceKey': item['labels'][0]['choiceKey'], 'reason': '样本无适合具体标签'}]
44
+ for key, item in packet['strategies'].items()}}
45
+ submit_tag_choices(event['request_path'], event['reviewId'], choices)
46
+ else:
47
+ reply = {key: packet[key] for key in ('bizLineId', 'attempt', 'requestId')}
48
+ Path(event['response_path']).write_text(json.dumps({**reply, 'text': AI_TEXT}), encoding='utf-8')
49
+
50
+ def biz(self, ident):
51
+ """真实上下文由当前目标生成,不从上一个业务线继承。"""
52
+ return {'id': ident, 'name': f'样本{ident}', 'adCategoryIndustryIds': [11]}
53
+
54
+ def test_one_line_enters_ai_refills_fetch_before_other_two_finish(self):
55
+ """第一条开始审核时第四条已拉数,第二/三条仍被刻意阻塞。"""
56
+ first_three = threading.Barrier(3)
57
+ slow_fetches = threading.Event()
58
+ next_fetch = threading.Event()
59
+ release_ai = threading.Event()
60
+ seen = []
61
+ lock = threading.Lock()
62
+ def fetch(args):
63
+ with lock:
64
+ seen.append(args.id)
65
+ if args.id <= 3:
66
+ first_three.wait(timeout=5)
67
+ if args.id in (2, 3):
68
+ if not slow_fetches.wait(5):
69
+ raise RuntimeError('等待控制放行拉数超时')
70
+ if args.id == 4:
71
+ next_fetch.set()
72
+ return self.biz(args.id)
73
+ def progress(event):
74
+ if event.get('event') == 'ai_strategy_required' and event['id'] == 1:
75
+ if not release_ai.wait(5):
76
+ raise RuntimeError('等待控制放行AI超时')
77
+ self.reply(event)
78
+ with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
79
+ root = Path(tmp)
80
+ future = executor.submit(self.execute, root, [1, 2, 3, 4], fetch, progress)
81
+ try:
82
+ self.assertTrue(next_fetch.wait(3), '第一条转入AI后没有逐条补拉第四条')
83
+ self.assertFalse(slow_fetches.is_set())
84
+ self.assertFalse(release_ai.is_set())
85
+ with lock:
86
+ self.assertEqual(set(seen), {1, 2, 3, 4})
87
+ finally:
88
+ slow_fetches.set()
89
+ release_ai.set()
90
+ result = future.result(timeout=10)
91
+ self.assertTrue(result['ok'], result)
92
+ self.assertFalse(list(root.rglob('batch-*')))
93
+
94
+ def test_stage_caps_and_global_six_bound_queue_before_tag_request(self):
95
+ """AI三条全部占用时,只预取另三条;等待者尚未生成审核请求。"""
96
+ self.check_capacity(3)
97
+
98
+ def test_smaller_concurrency_limits_both_stages_and_total(self):
99
+ """参数1/2同样滑动,分别限制总在途2/4,不沿用固定六条。"""
100
+ for capacity in (1, 2):
101
+ with self.subTest(capacity=capacity):
102
+ self.check_capacity(capacity)
103
+
104
+ def check_capacity(self, capacity):
105
+ """通过真实请求文件及拉数/AI阻塞观察阶段上限,不检查内部计数实现。"""
106
+ lock = threading.Lock()
107
+ first_fetches = threading.Barrier(capacity)
108
+ ready = threading.Event()
109
+ release_ai = threading.Event()
110
+ ai_started, waiting, fetched, tagged = set(), set(), set(), set()
111
+ fetch_active = fetch_peak = ai_active = ai_peak = 0
112
+ def fetch(args):
113
+ nonlocal fetch_active, fetch_peak
114
+ with lock:
115
+ fetched.add(args.id)
116
+ fetch_active += 1
117
+ fetch_peak = max(fetch_peak, fetch_active)
118
+ if args.id <= capacity:
119
+ first_fetches.wait(timeout=5)
120
+ with lock:
121
+ fetch_active -= 1
122
+ return self.biz(args.id)
123
+ def progress(event):
124
+ nonlocal ai_active, ai_peak
125
+ with lock:
126
+ if event.get('stage') == 'wait_ai_slot':
127
+ waiting.add(event['id'])
128
+ if event.get('event') == 'tag_review_required':
129
+ tagged.add(event['id'])
130
+ if event.get('event') == 'ai_strategy_required':
131
+ ai_started.add(event['id'])
132
+ ai_active += 1
133
+ ai_peak = max(ai_peak, ai_active)
134
+ if len(ai_started) >= capacity and len(waiting) >= capacity * 2:
135
+ ready.set()
136
+ if event.get('event') == 'ai_strategy_required':
137
+ if not release_ai.wait(5):
138
+ raise RuntimeError('等待控制放行AI超时')
139
+ with lock:
140
+ ai_active -= 1
141
+ self.reply(event)
142
+ with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
143
+ root = Path(tmp)
144
+ future = executor.submit(self.execute, root, list(range(1, 11)), fetch, progress,
145
+ concurrency=capacity)
146
+ try:
147
+ self.assertTrue(ready.wait(3), '没有形成3条AI+3条已预取等待')
148
+ with lock:
149
+ self.assertEqual(len(fetched), capacity * 2)
150
+ self.assertEqual(len(tagged), capacity)
151
+ self.assertEqual((fetch_peak, ai_peak), (capacity, capacity))
152
+ self.assertEqual(len(list(root.rglob('line-*'))), capacity * 2)
153
+ self.assertEqual(len(list(root.rglob('tag-review.1.request.json'))), capacity)
154
+ finally:
155
+ release_ai.set()
156
+ result = future.result(timeout=15)
157
+ self.assertTrue(result['ok'], result)
158
+ self.assertEqual(result['success'], 10)
159
+ self.assertLessEqual(fetch_peak, capacity)
160
+ self.assertLessEqual(ai_peak, capacity)
161
+ self.assertTrue(all('wait_ai_slot' in row['timings'] for row in result['results']))
162
+ self.assertFalse(list(root.rglob('batch-*')))
163
+
164
+ def test_fetch_retry_and_ai_exhaustion_release_slots_without_crossing_rounds(self):
165
+ """拉数技术失败只在首轮结束后重试,AI三次失败不重拉,后续条目继续。"""
166
+ counts, completed, ai_attempts = {}, set(), []
167
+ lock = threading.Lock()
168
+ def fetch(args):
169
+ with lock:
170
+ counts[args.id] = counts.get(args.id, 0) + 1
171
+ if args.attempt > 1:
172
+ self.assertEqual(completed, {1, 2, 3, 4})
173
+ if args.id == 2 and args.attempt == 1:
174
+ raise OSError('本条首次拉数失败')
175
+ return self.biz(args.id)
176
+ def progress(event):
177
+ if event.get('event') == 'completed' and event['attempt'] == 1:
178
+ with lock:
179
+ completed.add(event['id'])
180
+ if event.get('event') == 'ai_strategy_required' and event['id'] == 1:
181
+ ai_attempts.append(event['reviewAttempt'])
182
+ packet = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
183
+ reply = {key: packet[key] for key in ('bizLineId', 'attempt', 'requestId')}
184
+ Path(event['response_path']).write_text(json.dumps({**reply, 'text': '缺少四段标题'}), encoding='utf-8')
185
+ else:
186
+ self.reply(event)
187
+ with tempfile.TemporaryDirectory() as tmp:
188
+ root = Path(tmp)
189
+ result = self.execute(root, [1, 2, 3, 4], fetch, progress, concurrency=1)
190
+ self.assertEqual(counts, {1: 1, 2: 2, 3: 1, 4: 1})
191
+ self.assertEqual(ai_attempts, [1, 2, 3])
192
+ self.assertEqual(result['success'], 3)
193
+ self.assertEqual([row['attempts'] for row in result['results']], [1, 2, 1, 1])
194
+ self.assertTrue(result['results'][0]['ai_strategy_exhausted'])
195
+ self.assertFalse(list(root.rglob('batch-*')))
196
+
197
+ def test_cancel_while_waiting_for_ai_does_not_issue_request_or_leak(self):
198
+ """concurrency=1为一拉一AI,第二条排队取消后不能发审核或启动第三条。"""
199
+ cancel = threading.Event()
200
+ queued = threading.Event()
201
+ ai_started = threading.Event()
202
+ fetched, tagged = [], []
203
+ def fetch(args):
204
+ fetched.append(args.id)
205
+ return self.biz(args.id)
206
+ def progress(event):
207
+ if event.get('stage') == 'wait_ai_slot' and event['id'] == 2:
208
+ queued.set()
209
+ if event.get('event') == 'tag_review_required':
210
+ tagged.append(event['id'])
211
+ if event.get('event') == 'ai_strategy_required':
212
+ ai_started.set()
213
+ cancel.wait(5)
214
+ return
215
+ self.reply(event)
216
+ with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=1) as executor:
217
+ root = Path(tmp)
218
+ future = executor.submit(self.execute, root, [1, 2, 3], fetch, progress, cancel, 1)
219
+ try:
220
+ self.assertTrue(ai_started.wait(3))
221
+ self.assertTrue(queued.wait(3), '第二条没有在第一条AI期间完成预取')
222
+ finally:
223
+ cancel.set()
224
+ result = future.result(timeout=10)
225
+ self.assertFalse(result['ok'])
226
+ self.assertEqual(fetched, [1, 2])
227
+ self.assertEqual(tagged, [1])
228
+ self.assertEqual([row['attempts'] for row in result['results']], [1, 1, 0])
229
+ self.assertTrue(result['cleanup']['ok'])
230
+ self.assertFalse(list(root.rglob('batch-*')))
231
+
232
+ def test_outer_worker_exception_releases_reserved_fetch_slot(self):
233
+ """原单条try之外抛错也不能占住唯一拉数名额或被盲目重放。"""
234
+ with tempfile.TemporaryDirectory() as tmp:
235
+ root = Path(tmp)
236
+ args = arguments(root / 'output', [1, 2, 3])
237
+ args.concurrency = 1
238
+ seen = []
239
+ def worker(target, args, batch, cancel, progress):
240
+ seen.append(target)
241
+ if target == 1:
242
+ raise OSError('worker外层错误')
243
+ return {'id': target, 'ok': True}
244
+ result = run_batch(args, worker=worker, temp_parent=root, progress=lambda _: None)
245
+ self.assertEqual(seen, [1, 2, 3])
246
+ self.assertEqual(result['success'], 2)
247
+ self.assertEqual(result['results'][0]['failed_stage'], 'worker')
248
+ self.assertEqual(result['results'][0]['attempts'], 1)
249
+ self.assertTrue(result['cleanup']['ok'])
250
+
251
+
252
+ if __name__ == '__main__':
253
+ unittest.main()
@@ -0,0 +1,224 @@
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
+ # 大业务线广告数据超过 10 万行,80 页上限会截断。
57
+ assert PAGE_CAP >= 200
58
+
59
+
60
+ def test_period_verdict_follows_qoq_not_kpi():
61
+ # 有上期时仅以 2% 改善判断,与是否达到阈值无关。
62
+ from cli_json import learning_text, amount_change
63
+ from strategy_output import effect_code
64
+ cases = [(hit, trend, '有效' if trend == '变好' else '无效',
65
+ '策略有效' if trend == '变好' else '需关停', 1 if trend == '变好' else 2)
66
+ for hit in (True, False) for trend in ('变好', '稳定', '变差')]
67
+ for hit, trend, verdict, note, effect in cases:
68
+ actual = period_verdict(first_period=False, qoq_label=trend, kpi_usable=True, kpi_hit=hit)
69
+ assert actual == verdict
70
+ assert learning_text(actual) == f'结论:{verdict}\n说明:{note}'
71
+ assert effect_code(actual) == effect
72
+ for hit in (None, True, False):
73
+ assert period_verdict(first_period=False, qoq_label='无法对比', kpi_usable=True, kpi_hit=hit) == '待观察'
74
+ assert period_verdict(first_period=False, qoq_label='变好', kpi_usable=False) == '待观察'
75
+ assert amount_change(150, 100) == '环比 +50.00%'
76
+ assert '不可计算' in amount_change(100, 0)
77
+
78
+
79
+ def test_is_first_period_uses_prev_dates():
80
+ assert is_first_period({}) is True
81
+ assert is_first_period({"prevPeriodStart": "2026-09-07", "prevPeriodEnd": "2026-09-13"}) is False
82
+
83
+
84
+ def test_ghost_enum_zero_share_is_not_stop():
85
+ assert is_ghost_enum(0.0, 0.0) is True
86
+ assert is_ghost_enum(6.0, 0.0) is False
87
+
88
+
89
+ def test_region_bucket_non_sea_before_sea():
90
+ assert region_bucket("两性-恋爱攻略-全球非东南亚-自制-英语-install") == "other"
91
+ assert region_bucket("KPOP测试-舞蹈二创-东南亚仅马泰") == "sea"
92
+
93
+
94
+ def test_bid_adjust_stats_aggregates_and_lists_when_few():
95
+ items = [
96
+ {"name": "A", "from": 5.0, "to": 8.0, "times": 2},
97
+ {"name": "B", "from": 10.0, "to": 7.0, "times": 1},
98
+ {"name": "C", "from": 3.0, "to": 3.0, "times": 1},
99
+ ]
100
+ stats = bid_adjust_stats(items)
101
+ assert stats["raise_n"] == 1
102
+ assert stats["lower_n"] == 1
103
+ assert stats["flat_n"] == 1
104
+ assert stats["median_abs"] == 3.0
105
+ assert stats["list_all"] is True
106
+ assert len(stats["details"]) == 3
107
+ many = [{"name": str(i), "from": 1.0, "to": 2.0, "times": 1} for i in range(12)]
108
+ crowded = bid_adjust_stats(many)
109
+ assert crowded["raise_n"] == 12
110
+ assert crowded["list_all"] is False
111
+ assert crowded["details"] == []
112
+
113
+
114
+ def test_budget_abs_share_flags_contradiction():
115
+ line = budget_abs_share("FB", prev_abs=28886, curr_abs=25194, prev_share=8.8, curr_share=11.6)
116
+ assert "$28,886 → $25,194" in line
117
+ assert "8.8% → 11.6%" in line
118
+ assert "占比升但金额降" in line
119
+
120
+
121
+ def test_naming_patterns_picks_short_tokens():
122
+ names = [
123
+ "KPOP测试-舞蹈二创-802-东南亚仅越南-自制-英语-激活-2609",
124
+ "KPOP测试-线下周边-803-东南亚仅马泰-自制-英语-登录-2609",
125
+ "彩妆测试-整体妆容-东南亚-自制-英语-install-260916",
126
+ ]
127
+ text = naming_patterns(names, limit=10)
128
+ assert "KPOP" in text or "舞蹈" in text
129
+ assert "彩妆" in text or "整体妆容" in text
130
+ assert len(text) < 200
131
+
132
+
133
+ def test_summarize_countries_aggregates_when_over_50():
134
+ countries = [f"国{i}" for i in range(60)]
135
+ main, extra = summarize_countries(countries)
136
+ assert "超过 50" in main or "区域" in main or "大洲" in main
137
+ assert len(extra) == 60
138
+
139
+
140
+ def test_build_report_writes_strategy_and_detail_sheets():
141
+ from build_report import write_xlsx
142
+
143
+ with tempfile.TemporaryDirectory() as tmp:
144
+ out = os.path.join(tmp, "out.xlsx")
145
+ rows = [
146
+ {
147
+ "group": "APP-华南组",
148
+ "bizLine": "示例",
149
+ "period": "本期:2026-09-14~2026-09-20\n上期:2026-09-07~2026-09-13",
150
+ "project": "项目A",
151
+ "budgetStrategy": "【预算】",
152
+ "budgetLearning": "结论:有效",
153
+ "productStrategy": "【广告目标】",
154
+ "productLearning": "结论:待观察",
155
+ "audienceStrategy": "【国家】",
156
+ "audienceLearning": "结论:有效",
157
+ "creativeStrategy": "【广告】",
158
+ "creativeLearning": "结论:有效",
159
+ }
160
+ ]
161
+ details = [
162
+ {
163
+ "bizLine": "示例",
164
+ "kind": "国家",
165
+ "action": "新增",
166
+ "channel": "TikTok",
167
+ "name": "西撒哈拉",
168
+ "extra": "消耗 $0",
169
+ }
170
+ ]
171
+ write_xlsx(rows, out, details=details)
172
+ with zipfile.ZipFile(out) as zf:
173
+ names = zf.namelist()
174
+ assert "xl/worksheets/sheet1.xml" in names
175
+ assert "xl/worksheets/sheet2.xml" in names
176
+ wb = zf.read("xl/workbook.xml").decode("utf-8")
177
+ assert "策略" in wb
178
+ assert "明细" in wb
179
+ sheet2 = zf.read("xl/worksheets/sheet2.xml").decode("utf-8")
180
+ sst = zf.read("xl/sharedStrings.xml").decode("utf-8")
181
+ assert "西撒哈拉" in sst
182
+ assert "sheetData" in sheet2
183
+
184
+
185
+ def test_build_report_preserves_carriage_returns():
186
+ """客户 KPI 与广告名中的 CR/CRLF 必须逐字保留,不能被 XML 换行归一化。"""
187
+ from build_report import write_xlsx, validate_xlsx
188
+
189
+ text = "客户 KPI\r\n第二行\r第三行\n<&> &amp; &#13;"
190
+ rows = [{"bizLine": "换行验证", "budgetLearning": text}]
191
+ details = [{"bizLine": "换行验证", "name": text, "extra": text * 100}]
192
+ with tempfile.TemporaryDirectory() as tmp:
193
+ path = Path(tmp) / "line-endings.xlsx"
194
+ write_xlsx(rows, str(path), details)
195
+ assert validate_xlsx(path, rows, details)["rows"] == 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ tests = [
200
+ test_list_query_uses_data_body_not_flags,
201
+ test_exec_cli_to_file_keeps_payload_over_64kb,
202
+ test_page_cap_covers_tiktok_bid_pages,
203
+ test_period_verdict_follows_qoq_not_kpi,
204
+ test_is_first_period_uses_prev_dates,
205
+ test_ghost_enum_zero_share_is_not_stop,
206
+ test_region_bucket_non_sea_before_sea,
207
+ test_bid_adjust_stats_aggregates_and_lists_when_few,
208
+ test_budget_abs_share_flags_contradiction,
209
+ test_naming_patterns_picks_short_tokens,
210
+ test_summarize_countries_aggregates_when_over_50,
211
+ test_build_report_writes_strategy_and_detail_sheets,
212
+ test_build_report_preserves_carriage_returns,
213
+ ]
214
+ failed = 0
215
+ for fn in tests:
216
+ try:
217
+ fn()
218
+ print("ok", fn.__name__)
219
+ except Exception as e:
220
+ failed += 1
221
+ print("FAIL", fn.__name__, type(e).__name__, e)
222
+ if failed:
223
+ raise SystemExit(1)
224
+ print("DONE")