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,235 @@
1
+ """AI 文案交接的离线回归:只核对请求归属与回包,不执行投放分析。"""
2
+ import json
3
+ import sys
4
+ import tempfile
5
+ import threading
6
+ import unittest
7
+ import zipfile
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree as ET
10
+
11
+ sys.dont_write_bytecode = True
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+
14
+ from ai_strategy import AiStrategyExhausted, review_ai_strategy
15
+ from build_report import read_ai_sheet, validate_detail_workbook, validate_xlsx, write_detail_workbook, write_xlsx
16
+
17
+ AI_TEXT = ('一页速览\n- 本期观察。\n'
18
+ '【预算策略】\n【策略总结】\n- **有效**:消耗 **$12.00**。\n'
19
+ '【广告产品策略】\n【调整方向】\n- 保持观察。\n'
20
+ '【受众策略】\n【关键数据】\n- 样本。\n'
21
+ '【创意策略】\n【Learning】\n- 待观察。')
22
+
23
+
24
+ class AiStrategyReview(unittest.TestCase):
25
+ """复用已完成的明细-only 工作簿,三次请求各有独立身份。"""
26
+
27
+ def fixture(self, root):
28
+ """请求中的提示词与工作簿路径均指向本次目录的真实文件。"""
29
+ workbook = root / 'ai-detail.xlsx'
30
+ prompt = root / 'prompt.md'
31
+ workbook.write_bytes(b'workbook fixture')
32
+ prompt.write_text('请根据 Excel 明细表生成复盘。', encoding='utf-8')
33
+ return workbook, prompt
34
+
35
+ def test_bound_reply_reads_current_prompt(self):
36
+ """Agent 只把当前请求的身份写回,脚本不制造策略文本。"""
37
+ with tempfile.TemporaryDirectory() as tmp:
38
+ root = Path(tmp)
39
+ workbook, prompt = self.fixture(root)
40
+ seen = []
41
+
42
+ def progress(event):
43
+ seen.append(event)
44
+ if event.get('event') != 'ai_strategy_required':
45
+ return
46
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
47
+ self.assertEqual(request['prompt'], prompt.read_text(encoding='utf-8'))
48
+ self.assertEqual(request['workbookPath'], str(workbook))
49
+ self.assertNotIn('payloadPath', request)
50
+ self.assertEqual(request['bizLineId'], 321)
51
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'requestId')}
52
+ reply['text'] = AI_TEXT
53
+ Path(event['response_path']).write_text(json.dumps(reply, ensure_ascii=False), encoding='utf-8')
54
+
55
+ value = review_ai_strategy(root, workbook, prompt, threading.Event(), progress,
56
+ biz_line_id=321, attempt=2, wait_seconds=1)
57
+ self.assertIn('消耗', value)
58
+ self.assertEqual([item['event'] for item in seen],
59
+ ['ai_strategy_required', 'ai_strategy_accepted'])
60
+
61
+ def test_wrong_identity_then_valid_reply_retries_only_ai(self):
62
+ """第一次错线被拒,第二次使用新请求 ID;草稿不重建。"""
63
+ with tempfile.TemporaryDirectory() as tmp:
64
+ root = Path(tmp)
65
+ workbook, prompt = self.fixture(root)
66
+ requests = []
67
+
68
+ def progress(event):
69
+ if event.get('event') != 'ai_strategy_required':
70
+ return
71
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
72
+ requests.append(request)
73
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'requestId')}
74
+ reply['text'] = AI_TEXT
75
+ if len(requests) == 1:
76
+ reply['bizLineId'] = 999
77
+ Path(event['response_path']).write_text(json.dumps(reply), encoding='utf-8')
78
+
79
+ value = review_ai_strategy(root, workbook, prompt, threading.Event(), progress,
80
+ biz_line_id=321, attempt=1, wait_seconds=1)
81
+ self.assertEqual(value, AI_TEXT)
82
+ self.assertEqual(len(requests), 2)
83
+ self.assertNotEqual(requests[0]['requestId'], requests[1]['requestId'])
84
+ self.assertEqual(workbook.read_bytes(), b'workbook fixture')
85
+
86
+ def test_empty_and_timeout_exhaust_three_attempts(self):
87
+ """无效文案与未响应只消耗 AI 尝试,第三次后抛终态错误。"""
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ root = Path(tmp)
90
+ workbook, prompt = self.fixture(root)
91
+ issued = []
92
+
93
+ def progress(event):
94
+ if event.get('event') != 'ai_strategy_required':
95
+ return
96
+ issued.append(event['requestId'])
97
+ if len(issued) == 1:
98
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
99
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'requestId')}
100
+ reply['text'] = ' '
101
+ Path(event['response_path']).write_text(json.dumps(reply), encoding='utf-8')
102
+
103
+ with self.assertRaises(AiStrategyExhausted):
104
+ review_ai_strategy(root, workbook, prompt, threading.Event(), progress,
105
+ biz_line_id=321, attempt=1, wait_seconds=0.01)
106
+ self.assertEqual(len(issued), 3)
107
+
108
+ def test_missing_section_retries_only_ai(self):
109
+ """缺少任一策略列时不能接受不完整工作簿。"""
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ root = Path(tmp)
112
+ workbook, prompt = self.fixture(root)
113
+ rejected = []
114
+
115
+ def progress(event):
116
+ if event['event'] == 'ai_strategy_rejected':
117
+ rejected.append(event['reason'])
118
+ if event['event'] == 'ai_strategy_required':
119
+ request = json.loads(Path(event['request_path']).read_text(encoding='utf-8'))
120
+ reply = {key: request[key] for key in ('bizLineId', 'attempt', 'requestId')}
121
+ reply['text'] = AI_TEXT.replace('【创意策略】', '创意策略')
122
+ Path(event['response_path']).write_text(json.dumps(reply, ensure_ascii=False), encoding='utf-8')
123
+
124
+ with self.assertRaises(AiStrategyExhausted):
125
+ review_ai_strategy(root, workbook, prompt, threading.Event(), progress,
126
+ biz_line_id=321, attempt=1, wait_seconds=1)
127
+ self.assertEqual(len(rejected), 3)
128
+
129
+
130
+ class AiStrategyWorkbook(unittest.TestCase):
131
+ """第三张表把固定策略标签与 AI 文案放在同一行,前两张表保持不变。"""
132
+
133
+ def test_ai_sheet_preserves_draft_and_native_bold(self):
134
+ """三类标签复制到各策略左列,素材无标签,正文保留换行和加粗。"""
135
+ rows = [{'bizLine': '样本', 'budgetTags': '预算标签',
136
+ 'productTags': '产品标签', 'audienceTags': '受众标签'}]
137
+ details = [{'kind': '预算', 'channel': 'Meta', 'id': 'fb', 'name': 'Meta'}]
138
+ ai_text = AI_TEXT
139
+ with tempfile.TemporaryDirectory() as tmp:
140
+ draft = Path(tmp) / 'draft.xlsx'
141
+ final = Path(tmp) / 'final.xlsx'
142
+ write_xlsx(rows, str(draft), details)
143
+ validate_xlsx(draft, rows, details)
144
+ write_xlsx(rows, str(final), details, ai_strategy=ai_text)
145
+ self.assertEqual(validate_xlsx(final, rows, details, ai_strategy=ai_text)['ai_columns'], 8)
146
+ with zipfile.ZipFile(draft) as old, zipfile.ZipFile(final) as new:
147
+ self.assertEqual(old.read('xl/worksheets/sheet1.xml'), new.read('xl/worksheets/sheet1.xml'))
148
+ self.assertEqual(old.read('xl/worksheets/sheet2.xml'), new.read('xl/worksheets/sheet2.xml'))
149
+ workbook = ET.fromstring(new.read('xl/workbook.xml'))
150
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
151
+ self.assertEqual([item.attrib['name'] for item in workbook.findall('s:sheets/s:sheet', ns)],
152
+ ['策略', '明细', 'AI策略'])
153
+ strings = list(ET.fromstring(new.read('xl/sharedStrings.xml')))
154
+ ai = ET.fromstring(new.read('xl/worksheets/sheet3.xml'))
155
+ sheet_rows = ai.findall('s:sheetData/s:row', ns)
156
+ self.assertEqual(len(sheet_rows), 2)
157
+ cells = sheet_rows[1].findall('s:c', ns)
158
+ self.assertEqual(len(cells), 8)
159
+ self.assertTrue(all(cell.get('t') == 's' for row in sheet_rows for cell in row))
160
+ self.assertNotIn(b'inlineStr', new.read('xl/worksheets/sheet3.xml'))
161
+ self.assertEqual([''.join(strings[int(cell.find('s:v', ns).text)].itertext()) for cell in cells],
162
+ ['一页速览\n- 本期观察。', '预算标签',
163
+ '【策略总结】\n- 有效:消耗 $12.00。', '产品标签',
164
+ '【调整方向】\n- 保持观察。', '受众标签',
165
+ '【关键数据】\n- 样本。', '【Learning】\n- 待观察。'])
166
+ self.assertEqual([''.join(strings[int(cell.find('s:v', ns).text)].itertext()) for cell in sheet_rows[0].findall('s:c', ns)],
167
+ ['一页速览', '预算策略标签', '预算策略', '广告产品策略标签',
168
+ '广告产品策略', '受众策略标签', '受众策略', '创意策略'])
169
+ runs = strings[int(cells[2].find('s:v', ns).text)].findall('s:r', ns)
170
+ self.assertEqual([''.join(run.itertext()) for run in runs if run.find('s:rPr/s:b', ns) is not None],
171
+ ['有效', '$12.00'])
172
+
173
+ def test_readback_rejects_extra_rows_columns_and_wrong_sheet(self):
174
+ """回读拒绝错表、多行业和错列,不能用位置猜入库内容。"""
175
+ mutations = (
176
+ ('xl/workbook.xml', lambda text: text.replace('name="AI策略"', 'name="其他"')),
177
+ ('xl/worksheets/sheet3.xml', lambda text: text.replace('</sheetData>', '<row r="3"/></sheetData>')),
178
+ ('xl/worksheets/sheet3.xml', lambda text: text.replace('r="C2"', 'r="D2"')),
179
+ ('xl/sharedStrings.xml', lambda text: text.replace('预算策略标签', '错误标签列')),
180
+ ('xl/worksheets/sheet3.xml', lambda text: text.replace('t="s"', 't="inlineStr"')),
181
+ ('xl/_rels/workbook.xml.rels', lambda text: text.replace('Target="worksheets/sheet3.xml"',
182
+ 'Target="worksheets/sheet1.xml"')),
183
+ )
184
+ for name, mutate in mutations:
185
+ with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
186
+ source = Path(tmp) / 'source.xlsx'
187
+ damaged = Path(tmp) / 'damaged.xlsx'
188
+ write_xlsx([{'bizLine': '样本'}], str(source), [], ai_strategy=AI_TEXT)
189
+ with zipfile.ZipFile(source) as archive, zipfile.ZipFile(damaged, 'x') as target:
190
+ for member in archive.namelist():
191
+ value = archive.read(member)
192
+ if member == name:
193
+ value = mutate(value.decode('utf-8')).encode('utf-8')
194
+ target.writestr(member, value)
195
+ with self.assertRaises(ValueError):
196
+ read_ai_sheet(damaged)
197
+
198
+ def test_ai_line_over_excel_limit_rejected(self):
199
+ """不能为保住长文本而静默截断 AI 句子。"""
200
+ with tempfile.TemporaryDirectory() as tmp:
201
+ with self.assertRaises(ValueError):
202
+ write_xlsx([{'bizLine': '样本'}], str(Path(tmp) / 'out.xlsx'), [],
203
+ ai_strategy=AI_TEXT.replace('样本。', '字' * 32768))
204
+
205
+ def test_missing_fixed_tag_stays_empty(self):
206
+ """第一张表标签为空时,AI sheet 不自行补标签或改变策略正文。"""
207
+ rows = [{'bizLine': '样本', 'budgetTags': '其他', 'productTags': '', 'audienceTags': ''}]
208
+ with tempfile.TemporaryDirectory() as tmp:
209
+ path = Path(tmp) / 'final.xlsx'
210
+ write_xlsx(rows, str(path), [], ai_strategy=AI_TEXT)
211
+ self.assertEqual(validate_xlsx(path, rows, [], ai_strategy=AI_TEXT)['ai_columns'], 8)
212
+ with zipfile.ZipFile(path) as workbook:
213
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
214
+ sheet = ET.fromstring(workbook.read('xl/worksheets/sheet3.xml'))
215
+ cells = sheet.findall('s:sheetData/s:row', ns)[1].findall('s:c', ns)
216
+ strings = list(ET.fromstring(workbook.read('xl/sharedStrings.xml')))
217
+ self.assertEqual([''.join(strings[int(cells[index].find('s:v', ns).text)].itertext()) for index in (1, 3, 5)],
218
+ ['其他', '', ''])
219
+
220
+ def test_ai_input_contains_only_detail_sheet(self):
221
+ """交给 AI 的工作簿不含固定策略正文或其他 sheet。"""
222
+ details = [{'kind': '预算', 'channel': 'Meta', 'id': 'fb', 'name': '测试维度'}]
223
+ with tempfile.TemporaryDirectory() as tmp:
224
+ path = Path(tmp) / 'ai-detail.xlsx'
225
+ write_detail_workbook(details, str(path))
226
+ self.assertEqual(validate_detail_workbook(path, details)['details'], 1)
227
+ with zipfile.ZipFile(path) as workbook:
228
+ xml = ET.fromstring(workbook.read('xl/workbook.xml'))
229
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
230
+ self.assertEqual([item.attrib['name'] for item in xml.findall('s:sheets/s:sheet', ns)], ['明细'])
231
+ self.assertNotIn('策略内容', workbook.read('xl/sharedStrings.xml').decode('utf-8'))
232
+
233
+
234
+ if __name__ == '__main__':
235
+ unittest.main()
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """不发网:验证信封解包、词包截断、双维度判定。"""
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ SCRIPTS = Path(__file__).resolve().parent
10
+ if str(SCRIPTS) not in sys.path:
11
+ sys.path.insert(0, str(SCRIPTS))
12
+
13
+ from cli_json import dual_verdict, parse_json_text, short_label, unwrap_page, zh
14
+ from summarize import advice_of, dim_layer_text, fmt_share, pct_pair, setting_line
15
+
16
+
17
+ def test_unwrap_metrics_uses_data_data():
18
+ env = {"ok": True, "data": {"data": [{"spend_usd": 1}], "total": 1808, "result": None}}
19
+ rows, total = unwrap_page(env)
20
+ assert len(rows) == 1 and total == 1808
21
+
22
+
23
+ def test_unwrap_list_uses_result():
24
+ env = {"ok": True, "data": {"result": [{"id": 1258, "name": "x"}], "total": 1}}
25
+ rows, total = unwrap_page(env)
26
+ assert rows[0]["id"] == 1258 and total == 1
27
+
28
+
29
+ def test_unwrap_project_list_is_array():
30
+ env = {"ok": True, "data": [{"projectName": "A"}]}
31
+ rows, total = unwrap_page(env)
32
+ assert total == 1 and rows[0]["projectName"] == "A"
33
+
34
+
35
+ def test_parse_array_not_first_object():
36
+ raw = '[{"spend_usd": 10}, {"spend_usd": 20}]'
37
+ parsed = parse_json_text(raw)
38
+ assert isinstance(parsed, list) and len(parsed) == 2
39
+
40
+
41
+ def test_short_label_truncates_keyword_pack():
42
+ pack = "3d,a3,battle camp," + ",".join(["genshin"] * 80)
43
+ out = short_label(pack)
44
+ assert "genshin" in out or "3d" in out
45
+ assert len(out) < 80
46
+ assert pack not in out
47
+
48
+
49
+ def test_dual_verdict_table():
50
+ assert dual_verdict(True, "变好") == "有效"
51
+ assert dual_verdict(False, "变好") == "有效"
52
+ assert dual_verdict(False, "稳定") == "无效"
53
+ assert dual_verdict(True, "无法对比") == "待观察"
54
+
55
+
56
+ def test_share_uses_arrow():
57
+ assert pct_pair(50, 55) == "50.0% → 55.0%"
58
+ out = fmt_share({"应用推广": 100}, {"应用推广": 100})
59
+ assert ">>" not in out
60
+ assert "100.0% → 100.0%" in out
61
+
62
+
63
+ def test_setting_and_learning_are_multiline():
64
+ text = setting_line("【国家】", {"美国"}, {"越南"})
65
+ assert "\n" in text and text.startswith("【国家】")
66
+ items = [
67
+ {"label": f"广告{i}", "spend": 80, "imps": 10000, "cpm": 8.0} for i in range(12)
68
+ ]
69
+ body, _hit, _hs, _ts = dim_layer_text("广告", items, [])
70
+ assert body.startswith("广告")
71
+ assert body.count("· 新增") == 8
72
+ assert "另有 4 个新增未展开" in body
73
+ assert "{" not in body
74
+ assert advice_of("有效") == "建议:继续执行"
75
+
76
+
77
+ def test_zh_web_conversions():
78
+ assert zh("WEB_CONVERSIONS") == "网站转化"
79
+ assert zh("CUSTOM&LOOKALIKE") == "自定义/相似受众"
80
+
81
+
82
+ if __name__ == "__main__":
83
+ tests = [
84
+ test_unwrap_metrics_uses_data_data,
85
+ test_unwrap_list_uses_result,
86
+ test_unwrap_project_list_is_array,
87
+ test_parse_array_not_first_object,
88
+ test_short_label_truncates_keyword_pack,
89
+ test_dual_verdict_table,
90
+ test_share_uses_arrow,
91
+ test_setting_and_learning_are_multiline,
92
+ test_zh_web_conversions,
93
+ ]
94
+ for fn in tests:
95
+ fn()
96
+ print("ok", fn.__name__)
97
+ print("DONE")