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.
- package/README.md +256 -0
- package/dist/affordance/auth.md +31 -0
- package/dist/affordance/bill.md +48 -0
- package/dist/affordance/biz-line-ai-strategy.md +35 -0
- package/dist/affordance/biz-line-metrics.md +47 -0
- package/dist/affordance/biz-line.md +84 -0
- package/dist/affordance/common-file.md +25 -0
- package/dist/affordance/dashboard.md +75 -0
- package/dist/affordance/dict.md +28 -0
- package/dist/affordance/document.md +67 -0
- package/dist/affordance/im.md +91 -0
- package/dist/affordance/invoice.md +49 -0
- package/dist/affordance/media.md +17 -0
- package/dist/affordance/organization.md +47 -0
- package/dist/affordance/project-analysis.md +95 -0
- package/dist/affordance/project.md +321 -0
- package/dist/affordance/quota.md +19 -0
- package/dist/affordance/reconciliation.md +41 -0
- package/dist/affordance/report.md +75 -0
- package/dist/affordance/sso-team-group.md +30 -0
- package/dist/affordance/task.md +148 -0
- package/dist/affordance/tenant.md +17 -0
- package/dist/affordance/user.md +41 -0
- package/dist/affordance/wallet.md +100 -0
- package/dist/api-doc/README.md +67 -0
- package/dist/api-doc/custom/template.md +0 -0
- package/dist/api-doc/oapi/auth.md +61 -0
- package/dist/api-doc/oapi/bill.md +130 -0
- package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
- package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
- package/dist/api-doc/oapi/biz-line.md +382 -0
- package/dist/api-doc/oapi/common-file.md +50 -0
- package/dist/api-doc/oapi/dashboard.md +218 -0
- package/dist/api-doc/oapi/dict.md +49 -0
- package/dist/api-doc/oapi/document.md +182 -0
- package/dist/api-doc/oapi/im.md +278 -0
- package/dist/api-doc/oapi/invoice.md +129 -0
- package/dist/api-doc/oapi/media.md +17 -0
- package/dist/api-doc/oapi/organization.md +94 -0
- package/dist/api-doc/oapi/project-analysis.md +364 -0
- package/dist/api-doc/oapi/project.md +1114 -0
- package/dist/api-doc/oapi/quota.md +21 -0
- package/dist/api-doc/oapi/reconciliation.md +98 -0
- package/dist/api-doc/oapi/report.md +254 -0
- package/dist/api-doc/oapi/sso-team-group.md +54 -0
- package/dist/api-doc/oapi/task.md +528 -0
- package/dist/api-doc/oapi/tenant.md +17 -0
- package/dist/api-doc/oapi/user.md +98 -0
- package/dist/api-doc/oapi/wallet.md +275 -0
- package/dist/generated/api-catalog.json +39681 -0
- package/dist/generated/api-directory-index.json +1509 -0
- package/dist/index.js +2 -0
- package/dist/skill-manifest.json +336 -0
- package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
- package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
- package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
- package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
- package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
- package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
- package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
- package/dist/skills/sino-bc-cli/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
- package/dist/skills/sino-bc-shared/SKILL.md +31 -0
- package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
- package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
- package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
- package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
- package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
- package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
- package/package.json +70 -0
- package/scripts/postinstall.cjs +36 -0
|
@@ -0,0 +1,1233 @@
|
|
|
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
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
sys.dont_write_bytecode = True
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
|
|
17
|
+
from cli_json import run_cli
|
|
18
|
+
from fetch_data import fetch_paged
|
|
19
|
+
from summarize import bid_changes, dim_layer_text
|
|
20
|
+
from cli_json import core_rule, metric_value, qoq_axis, cli_context, exec_cli_to_file, shim_entry
|
|
21
|
+
from cleanup import OwnedDirectory
|
|
22
|
+
from build_report import write_xlsx, validate_xlsx, prepare_layout, COLUMNS, DETAIL_COLUMNS
|
|
23
|
+
from run_workflow import run_batch, execute_business, publish, workflow_parser
|
|
24
|
+
from argparse import Namespace
|
|
25
|
+
from datetime import date
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def arguments(output, ids=None):
|
|
29
|
+
"""测试批次沿用真实入口参数,不访问业务接口。"""
|
|
30
|
+
return Namespace(id=ids or [1], name='', concurrency=3, output_dir=str(output), output_mode='excel', curr_start='', curr_end='', prev_start='', prev_end='', account_fb=[], account_gg=[], account_tt=[])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def small_payload():
|
|
34
|
+
"""只包含匿名样本的完整 16 列数据。"""
|
|
35
|
+
return {'rows': [{key: ('示例' if key == 'bizLine' else ('结论:待观察\n说明:数据不足,暂无法判断' if key.endswith('Learning') else label)) for key, label in COLUMNS}],
|
|
36
|
+
'details': [{'kind': '出价', 'action': '上调', 'channel': 'Meta', 'id': '1',
|
|
37
|
+
'name': '同名', 'extra': 'bid_amount 1 → 2'}]}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DataRules(unittest.TestCase):
|
|
41
|
+
"""防止缺失值和失败回包被当作有效业务数据。"""
|
|
42
|
+
|
|
43
|
+
def test_indicator_sheet_columns_and_derived_values(self):
|
|
44
|
+
"""29 个指标按源表顺序导出;比率先合计基础字段再计算。"""
|
|
45
|
+
from cli_json import REPORT_METRICS
|
|
46
|
+
from summarize import detail_metric_cells
|
|
47
|
+
self.assertEqual(len(REPORT_METRICS), 29)
|
|
48
|
+
self.assertEqual(len({code for code, _label, _percent in REPORT_METRICS}), 29)
|
|
49
|
+
for code, label, _percent in REPORT_METRICS:
|
|
50
|
+
self.assertTrue(core_rule(code)['usable'], code)
|
|
51
|
+
self.assertTrue(core_rule(label)['usable'], label)
|
|
52
|
+
self.assertFalse(core_rule('结账成本')['usable'])
|
|
53
|
+
self.assertFalse(core_rule('线索量/点击量')['usable'])
|
|
54
|
+
self.assertEqual([label for _key, label in DETAIL_COLUMNS[:5]],
|
|
55
|
+
['明细数据', '动作', '渠道', 'id', '名称'])
|
|
56
|
+
self.assertEqual([label for _key, label in DETAIL_COLUMNS[5:-1]],
|
|
57
|
+
[label for _code, label, _percent in REPORT_METRICS])
|
|
58
|
+
self.assertEqual(DETAIL_COLUMNS[-1], ('extra', '补充'))
|
|
59
|
+
current = [{'spend_usd': 30, 'impressions': 300, 'clicks': 10,
|
|
60
|
+
'installs': 2, 'complete_registration': 1},
|
|
61
|
+
{'spend_usd': 20, 'impressions': 200, 'clicks': 10,
|
|
62
|
+
'installs': 3, 'complete_registration': 1}]
|
|
63
|
+
previous = [{'spend_usd': 40, 'impressions': 400, 'clicks': 20,
|
|
64
|
+
'installs': 4, 'complete_registration': 2}]
|
|
65
|
+
cells = detail_metric_cells(current, previous)
|
|
66
|
+
self.assertEqual(cells['spend_usd'], '50.00(上期:40.00)')
|
|
67
|
+
self.assertEqual(cells['cpi'], '10.00(上期:10.00)')
|
|
68
|
+
self.assertEqual(cells['ctr'], '4.00%(上期:5.00%)')
|
|
69
|
+
self.assertEqual(cells['cpr'], '25.00(上期:20.00)')
|
|
70
|
+
self.assertEqual(cells['roas'], '-')
|
|
71
|
+
conflicted = detail_metric_cells(current, previous, {'installs'}, {'clicks'})
|
|
72
|
+
self.assertEqual(conflicted['cpi'], '-(上期:10.00)')
|
|
73
|
+
self.assertEqual(conflicted['ctr'], '4.00%')
|
|
74
|
+
self.assertEqual(conflicted['installs'], '-(上期:4)')
|
|
75
|
+
|
|
76
|
+
def test_indicator_sheet_api_field_aliases(self):
|
|
77
|
+
"""指标表原始驼峰 API 字段名也能识别,但表外公式仍不支持。"""
|
|
78
|
+
for alias, expected in {
|
|
79
|
+
'spendUsdUsd': 'spend_usd', 'spendUsd': 'spend_usd',
|
|
80
|
+
'videoViews': 'video_views', 'addsToCart': 'add_to_cart',
|
|
81
|
+
'checkoutsInitiated': 'checkouts', 'purchases': 'purchase',
|
|
82
|
+
'purchasesValue': 'purchase_value', 'appInstalls': 'installs',
|
|
83
|
+
'registrationsCompleted': 'complete_registration',
|
|
84
|
+
'engagements': 'engagement',
|
|
85
|
+
}.items():
|
|
86
|
+
self.assertEqual(core_rule(alias)['metric'], expected, alias)
|
|
87
|
+
self.assertFalse(core_rule('spendUsdUsd/checkoutsInitiated')['usable'])
|
|
88
|
+
|
|
89
|
+
def test_dimension_details_align_periods_by_id(self):
|
|
90
|
+
"""连续、新增、暂停按原始 ID 配对;暂停项本期为缺值。"""
|
|
91
|
+
from summarize import build_dimension_details
|
|
92
|
+
fixture = {
|
|
93
|
+
'dim_fb_curr_country_code': [{'country_code': 'US'}, {'country_code': '0'}],
|
|
94
|
+
'dim_fb_prev_country_code': [{'country_code': 'US'}, {'country_code': 'CA'}],
|
|
95
|
+
'met_fb_curr_country_code': [
|
|
96
|
+
{'country_code': 'US', 'spend_usd': 10, 'clicks': 2},
|
|
97
|
+
{'country_code': '0', 'spend_usd': 4, 'clicks': 1}],
|
|
98
|
+
'met_fb_prev_country_code': [
|
|
99
|
+
{'country_code': 'US', 'spend_usd': 8, 'clicks': 2},
|
|
100
|
+
{'country_code': 'CA', 'spend_usd': 6, 'clicks': 1}],
|
|
101
|
+
}
|
|
102
|
+
with patch('summarize.iter_stem_rows', side_effect=lambda _bl, stem: iter(fixture.get(stem, []))):
|
|
103
|
+
rows = build_dimension_details(Path('.'), ['fb'], {'fb': []})
|
|
104
|
+
country = {row['id']: row for row in rows if row['kind'] == '国家'}
|
|
105
|
+
self.assertEqual({ident: row['action'] for ident, row in country.items()},
|
|
106
|
+
{'0': '新增', 'CA': '暂停', 'US': '持续'})
|
|
107
|
+
self.assertEqual(country['US']['spend_usd'], '10.00(上期:8.00)')
|
|
108
|
+
self.assertEqual(country['CA']['spend_usd'], '-(上期:6.00)')
|
|
109
|
+
self.assertEqual(country['0']['spend_usd'], '4.00')
|
|
110
|
+
self.assertTrue(all(row['extra'] == '' for row in country.values()))
|
|
111
|
+
|
|
112
|
+
def test_account_column_grouping_and_rich_text(self):
|
|
113
|
+
"""账号列紧跟项目,渠道原生加粗,长名单不截断且完整回读。"""
|
|
114
|
+
from build_report import build_shared_strings
|
|
115
|
+
from xml.etree import ElementTree as ET
|
|
116
|
+
text = 'Meta:\n' + '\n'.join(f'act_{i}' for i in range(30)) + '\nGoogle:\n123-456-7890'
|
|
117
|
+
records, details = prepare_layout([{'bizLine': '样本', 'adAccounts': text}], [])
|
|
118
|
+
self.assertEqual(records[0]['adAccounts'], text)
|
|
119
|
+
self.assertEqual(len(records), 1)
|
|
120
|
+
self.assertEqual(COLUMNS[3:5], [('project', '项目名称'), ('adAccounts', '广告账号')])
|
|
121
|
+
self.assertEqual(len(COLUMNS), 16)
|
|
122
|
+
xml, _, indices, _, _, _ = build_shared_strings(records)
|
|
123
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
124
|
+
cell = list(ET.fromstring(xml))[indices[0][4]]
|
|
125
|
+
bold = [''.join(run.itertext()) for run in cell.findall('s:r', ns) if run.find('s:rPr/s:b', ns) is not None]
|
|
126
|
+
self.assertEqual(bold, ['Meta', 'Google'])
|
|
127
|
+
self.assertEqual(''.join(cell.itertext()), text)
|
|
128
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
129
|
+
output = str(Path(tmp) / 'accounts.xlsx')
|
|
130
|
+
write_xlsx(records, output, details)
|
|
131
|
+
validate_xlsx(output, records, details)
|
|
132
|
+
|
|
133
|
+
def test_excel_strips_invalid_xml_controls_before_validation(self):
|
|
134
|
+
"""广告名称含垂直制表符等控制字符时,导出与回读保持同一清洗口径。"""
|
|
135
|
+
from xml.etree import ElementTree as ET
|
|
136
|
+
|
|
137
|
+
payload = small_payload()
|
|
138
|
+
payload['rows'][0]['budgetStrategy'] = '【广告】\nMeta:正常\x0b名称\t换行\n保留\r文本'
|
|
139
|
+
payload['details'][0]['name'] = '广告\x00\x1f\x7f\x9f\ufffe名称'
|
|
140
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
141
|
+
output = str(Path(tmp) / 'controls.xlsx')
|
|
142
|
+
write_xlsx(payload['rows'], output, payload['details'])
|
|
143
|
+
self.assertEqual(validate_xlsx(output, payload['rows'], payload['details'])['details'], 1)
|
|
144
|
+
with zipfile.ZipFile(output) as archive:
|
|
145
|
+
strings = ''.join(ET.fromstring(archive.read('xl/sharedStrings.xml')).itertext())
|
|
146
|
+
self.assertIn('正常名称\t换行\n保留\r文本', strings)
|
|
147
|
+
self.assertIn('广告名称', strings)
|
|
148
|
+
self.assertNotIn('\x0b', strings)
|
|
149
|
+
self.assertNotIn('\x7f', strings)
|
|
150
|
+
|
|
151
|
+
def test_strategy_native_bold_and_zero_changes(self):
|
|
152
|
+
"""维度/渠道原生加粗,普通单元格不串格式;零动作和100%占比不制造噪声。"""
|
|
153
|
+
from build_report import build_shared_strings
|
|
154
|
+
from summarize import setting_line
|
|
155
|
+
from cli_json import budget_abs_share
|
|
156
|
+
from xml.etree import ElementTree as ET
|
|
157
|
+
self.assertEqual(setting_line('【国家】', {'US'}, {'US'}), '【国家】\n未出现变化')
|
|
158
|
+
self.assertEqual(setting_line('【国家】', {'US'}, set()), '【国家】\n新增 1 个:US')
|
|
159
|
+
text = '总结:消耗变化。\n【预算】\nMeta:$100 → $120;环比 +20.00%'
|
|
160
|
+
xml, _, rows, _, _, _ = build_shared_strings([{'budgetStrategy': text, 'budgetLearning': text}])
|
|
161
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
162
|
+
items = list(ET.fromstring(xml))
|
|
163
|
+
indices = {key: i for i, (key, _) in enumerate(COLUMNS)}
|
|
164
|
+
strategy = items[rows[0][indices['budgetStrategy']]]
|
|
165
|
+
bold = [''.join(run.itertext()) for run in strategy.findall('s:r', ns) if run.find('s:rPr/s:b', ns) is not None]
|
|
166
|
+
self.assertEqual(bold, ['【预算】', 'Meta'])
|
|
167
|
+
self.assertEqual(''.join(strategy.itertext()), text)
|
|
168
|
+
self.assertIsNone(items[rows[0][indices['budgetLearning']]].find('s:r', ns))
|
|
169
|
+
value = budget_abs_share('Meta', prev_abs=100, curr_abs=120, prev_share=100, curr_share=100)
|
|
170
|
+
self.assertNotIn('占比', value)
|
|
171
|
+
self.assertIn('环比 +20.00%', value)
|
|
172
|
+
|
|
173
|
+
def test_learning_explanation_and_override_parser(self):
|
|
174
|
+
"""规范化展示绝对值、相对变化和阈值,覆盖参数保留比较符并绑定ID。"""
|
|
175
|
+
from summarize import learning_comparison, describe_learning
|
|
176
|
+
from fetch_data import argument_parser
|
|
177
|
+
parsed = argument_parser(batch=True).parse_args(['--id', '1', '--id', '2',
|
|
178
|
+
'--core-index', '1=CPI <= 6.5', '--core-index', '2=注册成本 >= 3'])
|
|
179
|
+
self.assertEqual(parsed.core_index, [(1, 'CPI <= 6.5'), (2, '注册成本 >= 3')])
|
|
180
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
181
|
+
args = arguments(Path(tmp), [1])
|
|
182
|
+
for overrides in ([(2, 'CPI')], [(1, 'CPI'), (1, 'CTR')]):
|
|
183
|
+
args.core_index = overrides
|
|
184
|
+
with self.assertRaises(ValueError):
|
|
185
|
+
run_batch(args, temp_parent=tmp)
|
|
186
|
+
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
187
|
+
rule = core_rule('CPI <= 10')
|
|
188
|
+
comparison = learning_comparison(rule, 12, 15, '')
|
|
189
|
+
self.assertIn('本期 12.00,上期总体 15.00,环比 -20.00%', comparison)
|
|
190
|
+
text = describe_learning('结论:有效\n说明:策略有效', rule, [comparison])
|
|
191
|
+
self.assertIn('KPI目标 <= 10', text)
|
|
192
|
+
self.assertTrue(text.endswith('建议继续执行'))
|
|
193
|
+
multiple = describe_learning('结论:有效\n说明:策略有效', rule,
|
|
194
|
+
['国家新增合计:CPI 本期 8.00', '广告组新增合计:CPI 本期 7.00'])
|
|
195
|
+
self.assertIn('国家新增合计:CPI 本期 8.00\n广告组新增合计:CPI 本期 7.00', multiple)
|
|
196
|
+
from cli_json import budget_abs_share
|
|
197
|
+
share_text = budget_abs_share('Meta', prev_abs=608, curr_abs=591, prev_share=60.8, curr_share=59.1)
|
|
198
|
+
self.assertIn('(-1.7%)', share_text)
|
|
199
|
+
self.assertNotIn('个百分点', share_text)
|
|
200
|
+
self.assertEqual(text.count('\n'), 2)
|
|
201
|
+
self.assertIn('说明:策略有效\n', text)
|
|
202
|
+
self.assertIn('环比 不可计算', learning_comparison(rule, 1, 0, '国家新增合计:'))
|
|
203
|
+
|
|
204
|
+
def test_new_enum_compares_previous_business_total(self):
|
|
205
|
+
"""新增枚举没有上期同值仍可比总体;有总体记录时禁止回退到阈值。"""
|
|
206
|
+
from summarize import new_scope_learning
|
|
207
|
+
rows = [{'calendar_date': f'2026-09-{day}', 'spend_usd': 32.666666666666664, 'installs': 10}
|
|
208
|
+
for day in (10, 11, 12)]
|
|
209
|
+
current = metric_value(rows, 'CPI')
|
|
210
|
+
# 即使阈值不达标,相比上期总体改善2%仍然有效。
|
|
211
|
+
rule = core_rule('CPI <= 1')
|
|
212
|
+
self.assertEqual(new_scope_learning(rule, rows, previous_total=current / .98, has_previous=True)[0], '有效')
|
|
213
|
+
self.assertEqual(new_scope_learning(core_rule('CPI <= 100'), rows,
|
|
214
|
+
previous_total=current, has_previous=True)[0], '无效')
|
|
215
|
+
for previous in (None, 0):
|
|
216
|
+
self.assertEqual(new_scope_learning(core_rule('CPI <= 100'), rows,
|
|
217
|
+
previous_total=previous, has_previous=True)[0], '待观察')
|
|
218
|
+
self.assertEqual(new_scope_learning(rule, rows, has_previous=False)[0], '无效')
|
|
219
|
+
self.assertEqual(new_scope_learning(rule, rows[:2], previous_total=100, has_previous=True)[0], '有效')
|
|
220
|
+
|
|
221
|
+
def test_new_scope_sample_and_dimension_combination(self):
|
|
222
|
+
"""正消耗样本不受投放天数限制;仍按新增枚举消耗占比汇总。"""
|
|
223
|
+
from summarize import new_scope_learning, combine_scope_learning
|
|
224
|
+
rule = core_rule('注册成本 <= 3')
|
|
225
|
+
rows = [{'calendar_date': f'2026-09-{day}', 'spend_usd': 20, 'complete_registration': 10}
|
|
226
|
+
for day in (10, 11, 12)]
|
|
227
|
+
valid = new_scope_learning(rule, rows)
|
|
228
|
+
self.assertEqual(valid, ('有效', '结论:有效\n说明:核心指标达标'))
|
|
229
|
+
invalid = new_scope_learning(core_rule('注册成本 <= 1'), rows)
|
|
230
|
+
self.assertEqual(invalid[0], '无效')
|
|
231
|
+
# 低于50美元仍可按真实核心指标判断。
|
|
232
|
+
low_spend = [{**r, 'spend_usd': 10} for r in rows]
|
|
233
|
+
self.assertEqual(new_scope_learning(rule, low_spend), ('有效', '结论:有效\n说明:核心指标达标'))
|
|
234
|
+
zero_spend = [{**r, 'spend_usd': 0} for r in rows]
|
|
235
|
+
self.assertEqual(new_scope_learning(rule, zero_spend)[0], '待观察')
|
|
236
|
+
self.assertEqual(new_scope_learning(rule, rows[:2])[0], '有效')
|
|
237
|
+
self.assertEqual(new_scope_learning(rule, [{**r, 'calendar_date': '2026-09-10'} for r in rows])[0], '有效')
|
|
238
|
+
self.assertEqual(new_scope_learning(rule, [])[0], '待观察')
|
|
239
|
+
# CPI 只需安装量足以算出指标,1次安装也可按阈值判定。
|
|
240
|
+
self.assertEqual(new_scope_learning(core_rule('CPI <= 5'),
|
|
241
|
+
[{'calendar_date': '2026-09-10', 'spend_usd': 2, 'installs': 1}])[0], '有效')
|
|
242
|
+
self.assertEqual(new_scope_learning(core_rule('注册成本'), rows)[0], '待观察')
|
|
243
|
+
self.assertEqual(combine_scope_learning(rule, [(71, valid), (29, invalid)])[0], '有效')
|
|
244
|
+
self.assertEqual(combine_scope_learning(rule, [(70, valid), (30, invalid)])[0], '无效')
|
|
245
|
+
self.assertEqual(combine_scope_learning(rule, [(69, valid), (31, invalid)])[0], '无效')
|
|
246
|
+
unknown = ('待观察', '结论:待观察\n说明:数据不足,暂无法判断')
|
|
247
|
+
self.assertEqual(combine_scope_learning(rule, [(70, valid), (30, unknown)])[0], '待观察')
|
|
248
|
+
self.assertEqual(combine_scope_learning(rule, [(71, valid), (29, unknown)])[0], '有效')
|
|
249
|
+
self.assertEqual(combine_scope_learning(rule, [(0, valid)])[0], '待观察')
|
|
250
|
+
self.assertEqual(combine_scope_learning(rule, [(None, unknown), (71, valid)])[0], '待观察')
|
|
251
|
+
self.assertEqual(combine_scope_learning(rule, [])[0], '待观察')
|
|
252
|
+
|
|
253
|
+
def test_budget_sample_only_requires_positive_spend(self):
|
|
254
|
+
"""预算样本只需正消耗,零消耗及缺消耗仍待观察。"""
|
|
255
|
+
from summarize import learning_sample_sufficient
|
|
256
|
+
from cli_json import evaluate_learning
|
|
257
|
+
rule = core_rule('CPI <= 5')
|
|
258
|
+
self.assertTrue(learning_sample_sufficient(0.01))
|
|
259
|
+
self.assertEqual(evaluate_learning(rule, 3, None, False,
|
|
260
|
+
learning_sample_sufficient(0.01))[0], '有效')
|
|
261
|
+
self.assertFalse(learning_sample_sufficient(0))
|
|
262
|
+
self.assertFalse(learning_sample_sufficient(None))
|
|
263
|
+
|
|
264
|
+
def test_derived_event_cost_and_explicit_formula(self):
|
|
265
|
+
"""事件成本及明确的基础指标比值参与 learning,先汇总再相除。"""
|
|
266
|
+
from cli_json import metric_fields, metric_formula, evaluate_learning, rule_hit
|
|
267
|
+
rule = core_rule('注册成本 USD <= 25')
|
|
268
|
+
self.assertTrue(rule['usable'])
|
|
269
|
+
self.assertFalse(rule['higher_is_better'])
|
|
270
|
+
self.assertEqual(metric_fields(rule['metric']), {'spend_usd', 'complete_registration'})
|
|
271
|
+
rows = [{'spend_usd': 10, 'complete_registration': 1},
|
|
272
|
+
{'spend_usd': 90, 'complete_registration': 3}]
|
|
273
|
+
self.assertEqual(metric_value(rows, rule['metric']), 25)
|
|
274
|
+
self.assertTrue(rule_hit(25, rule))
|
|
275
|
+
self.assertEqual(evaluate_learning(rule, 24.5, 25, True)[0], '有效')
|
|
276
|
+
self.assertEqual(evaluate_learning(rule, 25, None, False)[0], '有效')
|
|
277
|
+
for rows in ([{'spend_usd': 100}], [{'spend_usd': 100, 'complete_registration': 0}]):
|
|
278
|
+
value = metric_value(rows, rule['metric'])
|
|
279
|
+
self.assertIsNone(value)
|
|
280
|
+
self.assertEqual(evaluate_learning(rule, value, 25, True)[1], '结论:待观察\n说明:数据不足,暂无法判断')
|
|
281
|
+
for name in ('线索成本', '购买成本', '加购成本', '视频观看成本', '互动成本', '客单价'):
|
|
282
|
+
self.assertTrue(core_rule(name)['usable'], name)
|
|
283
|
+
self.assertFalse(core_rule('结账成本')['usable'])
|
|
284
|
+
for text in ('注册量/点击量*100 >= 2%', '完成注册量 ÷ 点击量 × 100 >= 2%'):
|
|
285
|
+
rate = core_rule(text)
|
|
286
|
+
self.assertTrue(rate['usable'], text)
|
|
287
|
+
self.assertEqual(metric_value([{'complete_registration': 4, 'clicks': 100}], rate['metric']), 4)
|
|
288
|
+
for name in ('单次安装量成本', 'spend_usd/installs'):
|
|
289
|
+
self.assertEqual(metric_formula(core_rule(name)['metric']), ('spend_usd', 'installs', 1))
|
|
290
|
+
explicit = core_rule('spend_usd/complete_registration <= 25')
|
|
291
|
+
self.assertTrue(explicit['usable'])
|
|
292
|
+
self.assertFalse(explicit['higher_is_better'])
|
|
293
|
+
self.assertFalse(core_rule('次日留存成本')['usable'])
|
|
294
|
+
|
|
295
|
+
def test_keywords_always_show_counts_without_names(self):
|
|
296
|
+
"""关键词无论少于五个、恰好五个或更多,都不在策略中展开长文本。"""
|
|
297
|
+
from summarize import setting_line
|
|
298
|
+
for count in (0, 1, 5, 6):
|
|
299
|
+
current = {f'完整新增关键词-{i}' for i in range(count)}
|
|
300
|
+
previous = {'完整暂停关键词'}
|
|
301
|
+
text = setting_line('【关键词】', current, previous, show_names=False)
|
|
302
|
+
self.assertEqual(text, '【关键词】\n' + (f'新增 {count} 个;' if count else '') + '暂停 1 个')
|
|
303
|
+
self.assertEqual(setting_line('【关键词】', {'很长的关键词'}, set(), True, show_names=False),
|
|
304
|
+
'【关键词】\n当期 1 个')
|
|
305
|
+
|
|
306
|
+
def test_small_changes_show_names_and_large_objects_show_distribution(self):
|
|
307
|
+
"""新增/暂停独立计数,五个完整列出,六个收起;同名对象不能去重。"""
|
|
308
|
+
from summarize import setting_line, action_summary
|
|
309
|
+
current = {f'新增国家{i}' for i in range(5)}
|
|
310
|
+
previous = {'暂停国家甲', '暂停国家乙'}
|
|
311
|
+
text = setting_line('国家', current, previous)
|
|
312
|
+
for name in current | previous:
|
|
313
|
+
self.assertIn(name, text)
|
|
314
|
+
text = setting_line('国家', current | {'第六个国家'}, previous)
|
|
315
|
+
self.assertIn('新增 6 个', text)
|
|
316
|
+
self.assertNotIn('新增国家0', text)
|
|
317
|
+
self.assertIn('暂停国家甲', text)
|
|
318
|
+
for count in (1, 5):
|
|
319
|
+
names = [f'EN-广告{i}' for i in range(count)]
|
|
320
|
+
text = action_summary('新增', names, distribution=True)
|
|
321
|
+
self.assertNotIn('分布在', text)
|
|
322
|
+
for name in names:
|
|
323
|
+
self.assertIn(name, text)
|
|
324
|
+
text = action_summary('新增', [f'EN-广告{i}' for i in range(6)], distribution=True)
|
|
325
|
+
self.assertIn('分布在:EN(6个)', text)
|
|
326
|
+
self.assertNotIn('EN-广告0', text)
|
|
327
|
+
self.assertEqual(action_summary('暂停', [], distribution=True), '')
|
|
328
|
+
self.assertEqual(action_summary('新增', ['同名', '同名']), '新增 2 个:同名、同名')
|
|
329
|
+
self.assertIn('分布在', action_summary('新增', ['EN-广告1'], distribution=True, total=6))
|
|
330
|
+
|
|
331
|
+
def test_all_api_metrics_and_aliases_with_optional_thresholds(self):
|
|
332
|
+
"""十二项接口指标全部可识别、求和;衍生值维持先求和后计算。"""
|
|
333
|
+
from cli_json import BASE_METRICS, rule_hit
|
|
334
|
+
from fetch_data import INDICATORS
|
|
335
|
+
self.assertEqual(set(INDICATORS), set(BASE_METRICS))
|
|
336
|
+
for code, aliases in BASE_METRICS.items():
|
|
337
|
+
self.assertEqual(core_rule(code)['metric'], code)
|
|
338
|
+
self.assertTrue(core_rule(code)['usable'])
|
|
339
|
+
self.assertEqual(core_rule(aliases[0])['metric'], code)
|
|
340
|
+
rule = core_rule(code + ' >= 100')
|
|
341
|
+
self.assertTrue(rule_hit(100, rule))
|
|
342
|
+
self.assertFalse(rule_hit(99, rule))
|
|
343
|
+
self.assertEqual(metric_value(iter([{code: 30}, {code: 70}]), code), 100)
|
|
344
|
+
self.assertIsNone(metric_value([{code: 30}, {}], code))
|
|
345
|
+
self.assertEqual(metric_value([{code: 0}], code), 0)
|
|
346
|
+
self.assertEqual(core_rule('完成注册量不低于100次')['target'], 100)
|
|
347
|
+
self.assertTrue(core_rule('CPI')['usable'])
|
|
348
|
+
self.assertTrue(core_rule('CPI USD')['usable'])
|
|
349
|
+
self.assertTrue(core_rule('CTR >= 2%')['usable'])
|
|
350
|
+
self.assertEqual(core_rule('次日留存率 >= 20%')['reason'], '核心指标不支持')
|
|
351
|
+
self.assertEqual(core_rule('CPI 人民币 <= 30')['reason'], '核心指标无法识别')
|
|
352
|
+
self.assertEqual(core_rule('点击率 >= 2')['reason'], '核心指标无法识别')
|
|
353
|
+
|
|
354
|
+
def test_learning_trend_before_threshold_and_no_previous_fallback(self):
|
|
355
|
+
"""2%包含边界;有上期不看达标,无上期才看阈值;缺值与零不冒充无上期。"""
|
|
356
|
+
from cli_json import evaluate_learning
|
|
357
|
+
for metric, current, previous in [('CPI <= 1', 1.96, 2), ('安装量 >= 1000', 102, 100)]:
|
|
358
|
+
self.assertEqual(evaluate_learning(core_rule(metric), current, previous, True)[0], '有效')
|
|
359
|
+
for current in (98.001, 99, 100, 102):
|
|
360
|
+
self.assertEqual(evaluate_learning(core_rule('CPI <= 200'), current, 100, True)[0], '无效')
|
|
361
|
+
for previous in (None, 0):
|
|
362
|
+
self.assertEqual(evaluate_learning(core_rule('安装量 >= 100'), 200, previous, True)[0], '待观察')
|
|
363
|
+
self.assertEqual(evaluate_learning(core_rule('安装量 >= 100'), 100, None, False),
|
|
364
|
+
('有效', '结论:有效\n说明:核心指标达标'))
|
|
365
|
+
self.assertEqual(evaluate_learning(core_rule('安装量 >= 100'), 99, None, False),
|
|
366
|
+
('无效', '结论:无效\n说明:核心指标未达标'))
|
|
367
|
+
self.assertEqual(evaluate_learning(core_rule('安装量'), 200, None, False)[0], '待观察')
|
|
368
|
+
self.assertEqual(evaluate_learning(core_rule('CPI'), 1, 2, True, False)[0], '待观察')
|
|
369
|
+
self.assertEqual(evaluate_learning(core_rule('CPI <= 3'), 1, None, False, False)[0], '待观察')
|
|
370
|
+
|
|
371
|
+
def test_api_conflict_fields_preserved_for_analysis(self):
|
|
372
|
+
"""服务端静默忽略指标时保留证据,不能算成零或部分渠道的有效值。"""
|
|
373
|
+
with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', return_value={
|
|
374
|
+
'ok': True, 'data': {'data': [{'spend_usd': 30}], 'total': 1, 'conflictField': ['leads']}
|
|
375
|
+
}):
|
|
376
|
+
fetch_paged('+facebook-metrics', {}, 'fixture', Path(tmp))
|
|
377
|
+
meta = json.loads((Path(tmp) / 'fixture.pages.json').read_text(encoding='utf-8'))
|
|
378
|
+
self.assertEqual(meta['conflictField'], ['leads'])
|
|
379
|
+
|
|
380
|
+
def test_conflict_fields_missing_null_and_empty_are_accepted(self):
|
|
381
|
+
"""维度和成效分页均兼容缺失、null、空数组,不能将正常空值判为失败。"""
|
|
382
|
+
for shortcut in ('+facebook-dimension', '+facebook-metrics', '+google-dimension',
|
|
383
|
+
'+google-metrics', '+tiktok-dimension', '+tiktok-metrics'):
|
|
384
|
+
for fields in ({}, {'conflictField': None}, {'conflictField': []}):
|
|
385
|
+
with self.subTest(shortcut=shortcut, fields=fields), tempfile.TemporaryDirectory() as tmp:
|
|
386
|
+
env = {'ok': True, 'data': {'data': [{'id': 1}], 'total': 1, **fields}}
|
|
387
|
+
with patch('fetch_data.run_cli', return_value=env):
|
|
388
|
+
self.assertEqual(fetch_paged(shortcut, {}, 'fixture', Path(tmp)), 1)
|
|
389
|
+
meta = json.loads((Path(tmp) / 'fixture.pages.json').read_text(encoding='utf-8'))
|
|
390
|
+
self.assertNotIn('conflictField', meta)
|
|
391
|
+
|
|
392
|
+
def test_conflict_fields_invalid_types_still_fail(self):
|
|
393
|
+
"""不能通过 truthy/falsy 兜底把非法回包吞成无冲突;失败不发布分页索引。"""
|
|
394
|
+
for value in ('', 'leads', 0, False, {}, [None], [1]):
|
|
395
|
+
with self.subTest(value=value), tempfile.TemporaryDirectory() as tmp:
|
|
396
|
+
env = {'ok': True, 'data': {'data': [], 'total': 0, 'conflictField': value}}
|
|
397
|
+
with patch('fetch_data.run_cli', return_value=env):
|
|
398
|
+
with self.assertRaisesRegex(RuntimeError, 'conflictField 格式无效'):
|
|
399
|
+
fetch_paged('+facebook-dimension', {}, 'fixture', Path(tmp))
|
|
400
|
+
self.assertFalse((Path(tmp) / 'fixture.pages.json').exists())
|
|
401
|
+
|
|
402
|
+
def test_learning_distinguishes_empty_unrecognized_and_missing_data(self):
|
|
403
|
+
"""指标配置问题优先说明原因;指标有效但数据缺失保留原待观察文案。"""
|
|
404
|
+
from cli_json import learning_text
|
|
405
|
+
for raw in (None, '', ' '):
|
|
406
|
+
self.assertEqual(learning_text('待观察', core_rule(raw)), '结论:待观察\n说明:核心指标为空')
|
|
407
|
+
for raw in ('留存提升', 'CPA 人民币 <= 30'):
|
|
408
|
+
self.assertEqual(learning_text('待观察', core_rule(raw)), '结论:待观察\n说明:核心指标不支持')
|
|
409
|
+
self.assertEqual(learning_text('待观察', core_rule('CPI USD <= 3')),
|
|
410
|
+
'结论:待观察\n说明:数据不足,暂无法判断')
|
|
411
|
+
|
|
412
|
+
def test_distribution_counts_objects_and_two_percent_boundary(self):
|
|
413
|
+
"""分布一对象同词只计一次;不足2%稳定,达到2%即改变趋势。"""
|
|
414
|
+
from cli_json import naming_patterns, period_verdict
|
|
415
|
+
text = naming_patterns(['EN-EN-US-1001', 'EN-UK-1002'])
|
|
416
|
+
self.assertIn('EN(2个)', text)
|
|
417
|
+
self.assertNotIn('1001', text)
|
|
418
|
+
self.assertEqual(qoq_axis(101, 100)[0], '稳定')
|
|
419
|
+
self.assertEqual(qoq_axis(102, 100)[0], '变差')
|
|
420
|
+
self.assertEqual(qoq_axis(98, 100)[0], '变好')
|
|
421
|
+
self.assertEqual(period_verdict(first_period=False, qoq_label='稳定', kpi_usable=True, kpi_hit=False), '无效')
|
|
422
|
+
|
|
423
|
+
def test_no_default_cpm_kpi(self):
|
|
424
|
+
text, hit, _, _ = dim_layer_text("广告", [{"label": "A", "spend": 80, "imps": 10000, "cpm": 8}], [])
|
|
425
|
+
self.assertNotIn("未达标", text)
|
|
426
|
+
self.assertIsNone(hit)
|
|
427
|
+
|
|
428
|
+
def test_missing_bid_not_zero(self):
|
|
429
|
+
self.assertEqual(bid_changes([
|
|
430
|
+
{"adgroup_id": "1", "calendar_date": "2026-09-01", "bid_amount": None},
|
|
431
|
+
{"adgroup_id": "1", "calendar_date": "2026-09-02", "bid_amount": 2},
|
|
432
|
+
], ["bid_amount"]), [])
|
|
433
|
+
|
|
434
|
+
def test_failed_page_never_writes_merged(self):
|
|
435
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", return_value={"ok": False, "error": "失败"}):
|
|
436
|
+
with self.assertRaises(RuntimeError):
|
|
437
|
+
fetch_paged("+facebook-metrics", {}, "fixture", Path(tmp))
|
|
438
|
+
self.assertFalse((Path(tmp) / "fixture.merged.json").exists())
|
|
439
|
+
self.assertFalse((Path(tmp) / "fixture.pages.json").exists())
|
|
440
|
+
|
|
441
|
+
def test_paged_success_writes_page_files_not_merged(self):
|
|
442
|
+
"""拉数只把各页回包落到临时文件,不在内存拼全表、不写 merged.json。"""
|
|
443
|
+
pages = [
|
|
444
|
+
{"ok": True, "data": {"data": [{"id": 1}, {"id": 2}], "total": 3}},
|
|
445
|
+
{"ok": True, "data": {"data": [{"id": 3}], "total": 3}},
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
def api(_command, body, out):
|
|
449
|
+
env = pages[body["page"] - 1]
|
|
450
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
out.write_text(json.dumps(env, ensure_ascii=False), encoding="utf-8")
|
|
452
|
+
return env
|
|
453
|
+
|
|
454
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", api):
|
|
455
|
+
root = Path(tmp)
|
|
456
|
+
total = fetch_paged("+facebook-metrics", {}, "fixture", root)
|
|
457
|
+
self.assertEqual(total, 3)
|
|
458
|
+
self.assertFalse((root / "fixture.merged.json").exists())
|
|
459
|
+
self.assertEqual(
|
|
460
|
+
json.loads((root / "fixture.pages.json").read_text(encoding="utf-8")),
|
|
461
|
+
{"total": 3, "pages": 2, "counts": [2, 1]},
|
|
462
|
+
)
|
|
463
|
+
self.assertTrue((root / "fixture.p1.out.json").exists())
|
|
464
|
+
self.assertTrue((root / "fixture.p2.out.json").exists())
|
|
465
|
+
from summarize import load_merged
|
|
466
|
+
self.assertEqual(load_merged(root, "fixture"), [{"id": 1}, {"id": 2}, {"id": 3}])
|
|
467
|
+
from summarize import iter_stem_rows, share_map
|
|
468
|
+
self.assertEqual(list(iter_stem_rows(root, "fixture")), [{"id": 1}, {"id": 2}, {"id": 3}])
|
|
469
|
+
self.assertEqual(list(iter_stem_rows(root, "fixture")), [{"id": 1}, {"id": 2}, {"id": 3}])
|
|
470
|
+
|
|
471
|
+
def test_share_map_and_metric_value_accept_iterators(self):
|
|
472
|
+
from summarize import share_map
|
|
473
|
+
shares, total = share_map(
|
|
474
|
+
iter([
|
|
475
|
+
{"spend_usd": 10, "audience_type": "A"},
|
|
476
|
+
{"spend_usd": 30, "audience_type": "B"},
|
|
477
|
+
]),
|
|
478
|
+
"audience_type",
|
|
479
|
+
)
|
|
480
|
+
self.assertEqual(total, 40)
|
|
481
|
+
self.assertEqual(shares["A"], 25)
|
|
482
|
+
self.assertEqual(shares["B"], 75)
|
|
483
|
+
self.assertEqual(
|
|
484
|
+
metric_value(iter([{"spend_usd": 10, "installs": 1}, {"spend_usd": 90, "installs": 49}])),
|
|
485
|
+
2,
|
|
486
|
+
)
|
|
487
|
+
self.assertIsNone(metric_value(iter([{"spend_usd": 10, "installs": 0}])))
|
|
488
|
+
|
|
489
|
+
def test_bid_changes_accepts_iterator_without_full_row_list(self):
|
|
490
|
+
rows = iter([
|
|
491
|
+
{"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-01", "bid_amount": 1},
|
|
492
|
+
{"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-02", "bid_amount": 2},
|
|
493
|
+
{"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-03", "bid_amount": 1},
|
|
494
|
+
])
|
|
495
|
+
changes = bid_changes(rows, ["bid_amount"])
|
|
496
|
+
self.assertEqual(changes[0]["times"], 2)
|
|
497
|
+
self.assertEqual(changes[0]["from"], changes[0]["to"])
|
|
498
|
+
self.assertEqual(changes[0]["name"], "同名")
|
|
499
|
+
|
|
500
|
+
def test_new_items_only_materializes_new_rows(self):
|
|
501
|
+
from summarize import new_items
|
|
502
|
+
items, stops = new_items(
|
|
503
|
+
iter([
|
|
504
|
+
{"ad_id": "keep", "ad_name": "新", "spend_usd": 10, "installs": 1, "impressions": 1000},
|
|
505
|
+
{"ad_id": "old", "ad_name": "旧", "spend_usd": 99, "installs": 1, "impressions": 1000},
|
|
506
|
+
]),
|
|
507
|
+
iter([{"ad_id": "old", "ad_name": "旧"}]),
|
|
508
|
+
"ad_name",
|
|
509
|
+
"ad_id",
|
|
510
|
+
)
|
|
511
|
+
self.assertEqual(len(items), 1)
|
|
512
|
+
self.assertEqual(items[0]["id"], "keep")
|
|
513
|
+
self.assertEqual(len(items[0]["rows"]), 1)
|
|
514
|
+
self.assertEqual(stops, [])
|
|
515
|
+
|
|
516
|
+
def test_iter_stem_rows_rejects_extra_pages_without_index(self):
|
|
517
|
+
from summarize import iter_stem_rows
|
|
518
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
519
|
+
root = Path(tmp)
|
|
520
|
+
page = {"ok": True, "data": {"data": [{"id": 1}], "total": 2}}
|
|
521
|
+
(root / "fixture.p1.out.json").write_text(json.dumps(page), encoding="utf-8")
|
|
522
|
+
(root / "fixture.p2.out.json").write_text(json.dumps(page), encoding="utf-8")
|
|
523
|
+
with self.assertRaises(ValueError):
|
|
524
|
+
list(iter_stem_rows(root, "fixture"))
|
|
525
|
+
|
|
526
|
+
def test_failed_retry_unlinks_stale_pages_index(self):
|
|
527
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", return_value={"ok": False, "error": "失败"}):
|
|
528
|
+
root = Path(tmp)
|
|
529
|
+
(root / "fixture.pages.json").write_text(
|
|
530
|
+
json.dumps({"total": 3, "pages": 2, "counts": [2, 1]}), encoding="utf-8"
|
|
531
|
+
)
|
|
532
|
+
with self.assertRaises(RuntimeError):
|
|
533
|
+
fetch_paged("+facebook-metrics", {}, "fixture", root)
|
|
534
|
+
self.assertFalse((root / "fixture.pages.json").exists())
|
|
535
|
+
|
|
536
|
+
def test_empty_ad_name_stays_out_of_naming_patterns(self):
|
|
537
|
+
from summarize import object_name_maps
|
|
538
|
+
from cli_json import naming_patterns
|
|
539
|
+
names, raw = object_name_maps(
|
|
540
|
+
iter([{"ad_id": "camp_summer_01", "ad_name": ""}]),
|
|
541
|
+
"ad_id",
|
|
542
|
+
"ad_name",
|
|
543
|
+
)
|
|
544
|
+
self.assertEqual(names["camp_summer_01"], "camp_summer_01")
|
|
545
|
+
self.assertEqual(raw["camp_summer_01"], "")
|
|
546
|
+
self.assertEqual(naming_patterns([raw["camp_summer_01"]]), "未识别分类")
|
|
547
|
+
self.assertIn("camp", naming_patterns([names["camp_summer_01"]]))
|
|
548
|
+
|
|
549
|
+
def test_short_page_still_fetches_total(self):
|
|
550
|
+
pages = [{"ok": True, "data": {"data": [{"id": 1}], "total": 2}}, {"ok": True, "data": {"data": [], "total": 2}}]
|
|
551
|
+
with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", side_effect=pages):
|
|
552
|
+
with self.assertRaises(RuntimeError):
|
|
553
|
+
fetch_paged("+facebook-metrics", {}, "fixture", Path(tmp))
|
|
554
|
+
|
|
555
|
+
def test_stderr_and_nonzero_override_success(self):
|
|
556
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
557
|
+
# 替身使用真实子进程证明退出码和 stderr 不被吞掉。
|
|
558
|
+
command = [sys.executable, "-c", 'import sys; print(\'{"ok": true}\'); sys.stderr.write("fixture denied"); sys.exit(2)']
|
|
559
|
+
with patch("cli_json.resolve_cli", return_value=command):
|
|
560
|
+
result = run_cli([], None, Path(tmp) / "response.out.json")
|
|
561
|
+
self.assertFalse(result["ok"])
|
|
562
|
+
self.assertIn("fixture denied", str(result))
|
|
563
|
+
|
|
564
|
+
def test_metric_units_denominator_and_direction(self):
|
|
565
|
+
self.assertFalse(core_rule('人民币 CPA≤30,次留>20%')['usable'])
|
|
566
|
+
rule = core_rule('CPI USD <= 3')
|
|
567
|
+
self.assertTrue(rule['usable'])
|
|
568
|
+
self.assertEqual(metric_value([{'spend_usd': 10, 'installs': 1}, {'spend_usd': 90, 'installs': 49}]), 2)
|
|
569
|
+
self.assertIsNone(metric_value([{'spend_usd': 10, 'installs': 0}]))
|
|
570
|
+
self.assertIsNone(metric_value([{'spend_usd': 10, 'installs': 10}, {'spend_usd': 50}]))
|
|
571
|
+
self.assertEqual(qoq_axis(2, 1, True)[0], '变好')
|
|
572
|
+
|
|
573
|
+
def test_page_cap_is_failure(self):
|
|
574
|
+
page = {'ok': True, 'data': {'data': [{'id': 1}], 'total': 2}}
|
|
575
|
+
with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.PAGE_CAP', 1), patch('fetch_data.run_cli', return_value=page):
|
|
576
|
+
with self.assertRaises(RuntimeError):
|
|
577
|
+
fetch_paged('+facebook-metrics', {}, 'fixture', Path(tmp))
|
|
578
|
+
|
|
579
|
+
def test_page_cap_allows_more_than_eighty_pages(self):
|
|
580
|
+
"""101982 条级别的分页不能在第 80 页主动截断;测试只用 81 条轻量数据。"""
|
|
581
|
+
from fetch_data import PAGE_CAP
|
|
582
|
+
|
|
583
|
+
self.assertGreaterEqual(PAGE_CAP, 200)
|
|
584
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
585
|
+
root = Path(tmp)
|
|
586
|
+
|
|
587
|
+
def api(_command, body, out):
|
|
588
|
+
env = {'ok': True, 'data': {'data': [{'id': body['page']}], 'total': 81}}
|
|
589
|
+
out.write_text(json.dumps(env), encoding='utf-8')
|
|
590
|
+
return env
|
|
591
|
+
|
|
592
|
+
with patch('fetch_data.run_cli', side_effect=api), patch('fetch_data.report_progress'):
|
|
593
|
+
self.assertEqual(fetch_paged('+facebook-metrics', {}, 'large', root), 81)
|
|
594
|
+
meta = json.loads((root / 'large.pages.json').read_text(encoding='utf-8'))
|
|
595
|
+
self.assertEqual(meta['pages'], 81)
|
|
596
|
+
self.assertEqual(sum(meta['counts']), 81)
|
|
597
|
+
|
|
598
|
+
def test_zero_total_with_nonempty_rows_is_invalid(self):
|
|
599
|
+
with tempfile.TemporaryDirectory() as tmp, patch('fetch_data.run_cli', return_value={'ok': True, 'data': {'data': [{'id': 1}], 'total': 0}}):
|
|
600
|
+
with self.assertRaises(RuntimeError):
|
|
601
|
+
fetch_paged('+facebook-metrics', {}, 'fixture', Path(tmp))
|
|
602
|
+
|
|
603
|
+
def test_pnpm_shim_path(self):
|
|
604
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
605
|
+
root = Path(tmp)
|
|
606
|
+
entry = root / 'global/5/node_modules/sino-bc-cli/dist/index.js'
|
|
607
|
+
entry.parent.mkdir(parents=True)
|
|
608
|
+
entry.write_text('// fixture')
|
|
609
|
+
shim = root / 'sino-bc-cli.cmd'
|
|
610
|
+
shim.write_text('@node "%~dp0global/5/node_modules/sino-bc-cli/dist/index.js" %*')
|
|
611
|
+
self.assertEqual(shim_entry(shim), entry.resolve())
|
|
612
|
+
self.assertTrue(core_rule('CPI <= 2 usd')['usable'])
|
|
613
|
+
|
|
614
|
+
def test_multiple_bid_fields_count_changed_days_once(self):
|
|
615
|
+
changes = bid_changes([
|
|
616
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-01', 'cpc': 1, 'cpm': 5},
|
|
617
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-02', 'cpc': 2, 'cpm': 6},
|
|
618
|
+
{'adgroup_id': '1', 'calendar_date': '2026-09-03', 'cpc': 1, 'cpm': 5},
|
|
619
|
+
], ['cpc', 'cpm'])
|
|
620
|
+
self.assertEqual(changes[0]['times'], 2)
|
|
621
|
+
self.assertEqual(changes[0]['from'], changes[0]['to'])
|
|
622
|
+
|
|
623
|
+
def test_complete_fetch_analysis_and_id_details(self):
|
|
624
|
+
from fetch_data import fetch_business
|
|
625
|
+
from summarize import summarize_request
|
|
626
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
627
|
+
root = Path(tmp)
|
|
628
|
+
args = arguments(root / 'output')
|
|
629
|
+
args.id, args.request_dir = 1, str(root)
|
|
630
|
+
args.core_index = [(1, 'CPI <= 1'), (2, '安装量 >= 999')]
|
|
631
|
+
# 显式分析周期优先,接口周期故意不同,防止重新引入隐式回退。
|
|
632
|
+
args.curr_start, args.curr_end = '2026-09-08', '2026-09-14'
|
|
633
|
+
args.prev_start, args.prev_end = '2026-09-01', '2026-09-07'
|
|
634
|
+
biz = {'id': 1, 'name': '匿名样本', 'adCategoryIndustryIds': [11], 'periodStart': '2026-08-18', 'periodEnd': '2026-08-24', 'prevPeriodStart': '2026-09-01', 'prevPeriodEnd': '2026-09-07', 'coreIndex': 'CPI USD <= 3'}
|
|
635
|
+
def api(command, body, out):
|
|
636
|
+
# 替身核对生产 shortcut/请求体,并像 CLI 一样落盘基础回包。
|
|
637
|
+
self.assertEqual(command[0], 'biz-line-metrics' if command[1] in ('+facebook-dimension', '+facebook-metrics') else 'biz-line')
|
|
638
|
+
if command[1] == '+list':
|
|
639
|
+
self.assertEqual(body['idList'], [1])
|
|
640
|
+
env = {'ok': True, 'data': {'result': [biz], 'total': 1}}
|
|
641
|
+
elif command[1] == '+detail':
|
|
642
|
+
env = {'ok': True, 'data': biz}
|
|
643
|
+
elif command[1] == '+project-list':
|
|
644
|
+
env = {'ok': True, 'data': [{'projectName': '样本项目', 'adAccounts': [{'adAccountId': 'fixture', 'channelName': 'Facebook'}]}]}
|
|
645
|
+
else:
|
|
646
|
+
self.assertIn(command[1], ('+facebook-dimension', '+facebook-metrics'))
|
|
647
|
+
self.assertEqual(body['dimensionValues'], {'account_id': ['fixture']})
|
|
648
|
+
self.assertEqual(body['size'], 1000)
|
|
649
|
+
current = body['startDate'] == args.curr_start
|
|
650
|
+
self.assertIn((body['startDate'], body['endDate']), [(args.curr_start, args.curr_end), (args.prev_start, args.prev_end)])
|
|
651
|
+
dim = body['dimensions']
|
|
652
|
+
daily_scope = command[1] == '+facebook-metrics' and len(dim) > 1 and dim[-1] == 'calendar_date'
|
|
653
|
+
if command[1] == '+facebook-metrics' and dim not in (['account_id'], ['calendar_date']):
|
|
654
|
+
self.assertTrue(daily_scope)
|
|
655
|
+
dim = dim[:-1]
|
|
656
|
+
metrics = {'spend_usd': 100, 'installs': 50 if current else 25, 'impressions': 10000, 'clicks': 500, 'purchase': 10, 'purchase_value': 300}
|
|
657
|
+
rows = []
|
|
658
|
+
if dim == ['account_id']:
|
|
659
|
+
rows = [metrics]
|
|
660
|
+
elif dim == ['calendar_date']:
|
|
661
|
+
first, count = (8, 6) if current else (1, 7)
|
|
662
|
+
rows = [{**metrics, 'calendar_date': f'2026-09-{d:02d}'} for d in range(first, first + count)]
|
|
663
|
+
elif dim == ['ad_id', 'ad_name'] or dim == ['adgroup_id', 'adgroup_name']:
|
|
664
|
+
rows = [{**metrics, dim[0]: str(i), dim[1]: '同名'} for i in (1, 2)] if current else []
|
|
665
|
+
elif dim == ['objective']:
|
|
666
|
+
rows = [{**metrics, 'objective': '应用推广'}]
|
|
667
|
+
elif dim == ['country_code']:
|
|
668
|
+
rows = [{**metrics, 'country_code': 'US'}]
|
|
669
|
+
elif dim[0] == 'calendar_date':
|
|
670
|
+
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))]
|
|
671
|
+
if daily_scope:
|
|
672
|
+
# 按日拆分保留范围合计,证明真实请求携带日期且样本日来自新增行。
|
|
673
|
+
rows = [{**row, **{key: row[key] / 3 for key in metrics},
|
|
674
|
+
'calendar_date': f'2026-09-{day:02d}'}
|
|
675
|
+
for row in rows for day in ((8, 9, 10) if current else (1, 2, 3))]
|
|
676
|
+
env = {'ok': True, 'data': {'data': rows, 'total': len(rows)}}
|
|
677
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
678
|
+
out.write_text(json.dumps(env, ensure_ascii=False), encoding='utf-8')
|
|
679
|
+
return env
|
|
680
|
+
with patch('fetch_data.run_cli', api), cli_context(threading.Event(), lambda _: None):
|
|
681
|
+
fetch_business(args)
|
|
682
|
+
payload, summary = summarize_request(root)
|
|
683
|
+
self.assertEqual(summary['overall_cpi'], [2, 4])
|
|
684
|
+
self.assertEqual(payload['rows'][0]['adAccounts'], 'Meta:\nfixture')
|
|
685
|
+
self.assertEqual(summary['spend_verdict'], '有效')
|
|
686
|
+
budget_detail = next(item for item in payload['details'] if item['kind'] == '预算')
|
|
687
|
+
self.assertEqual((budget_detail['action'], budget_detail['id'], budget_detail['name']),
|
|
688
|
+
('持续', 'fb', 'Meta'))
|
|
689
|
+
self.assertEqual(budget_detail['cpi'], '2.00(上期:4.00)')
|
|
690
|
+
ad_detail = next(item for item in payload['details'] if item['kind'] == '广告' and item['id'] == '1')
|
|
691
|
+
self.assertEqual((ad_detail['action'], ad_detail['name'], ad_detail['cpi']),
|
|
692
|
+
('新增', '同名', '2.00'))
|
|
693
|
+
self.assertEqual(ad_detail['extra'], '')
|
|
694
|
+
# 有账号但无消耗仍展示;账号覆盖结果是渠道范围的唯一依据。
|
|
695
|
+
project_path = root / 'projects.json'
|
|
696
|
+
saved_projects = project_path.read_text(encoding='utf-8')
|
|
697
|
+
project_data = json.loads(saved_projects)
|
|
698
|
+
project_data['accounts']['tt'] = ['zero-spend-fixture']
|
|
699
|
+
project_path.write_text(json.dumps(project_data), encoding='utf-8')
|
|
700
|
+
zero_payload, _ = summarize_request(root)
|
|
701
|
+
self.assertIn('TikTok:$0 → $0', zero_payload['rows'][0]['budgetStrategy'])
|
|
702
|
+
# 分析账号覆盖不改变业务线的全量账号展示。
|
|
703
|
+
self.assertEqual(zero_payload['rows'][0]['adAccounts'], 'Meta:\nfixture')
|
|
704
|
+
project_path.write_text(saved_projects, encoding='utf-8')
|
|
705
|
+
|
|
706
|
+
row = payload['rows'][0]
|
|
707
|
+
for strategy in ('budget', 'product', 'audience', 'creative'):
|
|
708
|
+
self.assertTrue(row[strategy + 'Strategy'].startswith('总结:'))
|
|
709
|
+
self.assertNotIn('TikTok', row[strategy + 'Strategy'])
|
|
710
|
+
self.assertNotIn('Google', row[strategy + 'Strategy'])
|
|
711
|
+
self.assertNotIn('暂停 0 个', row[strategy + 'Strategy'])
|
|
712
|
+
self.assertNotIn('新增 0 个', row[strategy + 'Strategy'])
|
|
713
|
+
self.assertIn('预算分配', row['budgetStrategy'].split('\n')[0])
|
|
714
|
+
self.assertIn('广告目标', row['productStrategy'].split('\n')[0])
|
|
715
|
+
self.assertIn('定向', row['audienceStrategy'].split('\n')[0])
|
|
716
|
+
self.assertIn('创意', row['creativeStrategy'].split('\n')[0])
|
|
717
|
+
self.assertIn('本期合计消耗 $200.00,占该渠道同期消耗 200.0%', row['audienceStrategy'])
|
|
718
|
+
self.assertNotIn('100.0% → 100.0%', row['productStrategy'])
|
|
719
|
+
self.assertIn('Meta:\n', row['productStrategy'])
|
|
720
|
+
self.assertIn('KPI目标 <= 1', row['budgetLearning'])
|
|
721
|
+
self.assertIn('本期 2.00,上期总体 4.00,环比 -50.00%', row['budgetLearning'])
|
|
722
|
+
self.assertIn('广告组新增合计:CPI 本期 2.00', row['audienceLearning'])
|
|
723
|
+
self.assertIn('广告新增合计:CPI 本期 2.00', row['creativeLearning'])
|
|
724
|
+
self.assertNotIn('999', row['budgetLearning'])
|
|
725
|
+
# 覆盖配置只作用于相同ID;无匹配覆盖时退回业务线字段。
|
|
726
|
+
(root / 'analysis-settings.json').write_text(json.dumps({'bizLineId': 2, 'coreIndex': 'CPI <= 999'}), encoding='utf-8')
|
|
727
|
+
default_payload, _ = summarize_request(root)
|
|
728
|
+
self.assertIn('KPI目标 <= 3', default_payload['rows'][0]['budgetLearning'])
|
|
729
|
+
self.assertNotIn(biz['coreIndex'], json.dumps(payload, ensure_ascii=False))
|
|
730
|
+
for key in ('budget', 'product', 'audience', 'creative'):
|
|
731
|
+
expected = {'budget': '结论:有效\n说明:策略有效',
|
|
732
|
+
'product': '结论:待观察\n说明:数据不足,暂无法判断'}
|
|
733
|
+
self.assertTrue(row[key + 'Learning'].startswith(expected.get(key, '结论:有效\n说明:策略有效')))
|
|
734
|
+
self.assertEqual(row[key + 'Effect'], 3 if key == 'product' else 1)
|
|
735
|
+
for forbidden in ('本期新出现', '本期未出现', '命名规律', '跳过。', '完整内容见'):
|
|
736
|
+
self.assertNotIn(forbidden, row[key + 'Strategy'])
|
|
737
|
+
self.assertIn('环比', row['budgetStrategy'])
|
|
738
|
+
self.assertNotIn('同名', row['budgetStrategy'])
|
|
739
|
+
budget_block = row['budgetStrategy'].split('【预算】\n', 1)[1]
|
|
740
|
+
self.assertTrue(budget_block.startswith('消耗合计:'))
|
|
741
|
+
self.assertLess(budget_block.index('消耗合计:'), budget_block.index('Meta:'))
|
|
742
|
+
self.assertIn('新增 2 个:同名、同名', row['audienceStrategy'])
|
|
743
|
+
self.assertIn('新增 2 个:同名、同名', row['creativeStrategy'])
|
|
744
|
+
self.assertNotIn('分布在', row['creativeStrategy'])
|
|
745
|
+
# 集成验证:新增广告/广告组没有上期同值且阈值不达标,仍对比上期总体CPI=4。
|
|
746
|
+
(root / 'biz-line.json').write_text(json.dumps({**biz, 'coreIndex': 'CPI <= 1'}), encoding='utf-8')
|
|
747
|
+
benchmark_payload, _ = summarize_request(root)
|
|
748
|
+
for key in ('budget', 'audience', 'creative'):
|
|
749
|
+
self.assertEqual(benchmark_payload['rows'][0][key + 'Effect'], 1)
|
|
750
|
+
(root / 'biz-line.json').write_text(json.dumps(biz), encoding='utf-8')
|
|
751
|
+
# 总体改善不能覆盖新增素材的较差成效;仅修改素材范围保持其他范围原样。
|
|
752
|
+
ad_path = root / 'bl-1/met_fb_curr_ad_id+ad_name.p1.out.json'
|
|
753
|
+
original_ad = ad_path.read_text(encoding='utf-8')
|
|
754
|
+
ad_envelope = json.loads(original_ad)
|
|
755
|
+
for record in ad_envelope['data']['data']:
|
|
756
|
+
record['installs'] = 4
|
|
757
|
+
ad_path.write_text(json.dumps(ad_envelope), encoding='utf-8')
|
|
758
|
+
separate_payload, _ = summarize_request(root)
|
|
759
|
+
separate = separate_payload['rows'][0]
|
|
760
|
+
self.assertEqual(separate['budgetEffect'], 1)
|
|
761
|
+
self.assertEqual(separate['audienceEffect'], 1)
|
|
762
|
+
self.assertEqual(separate['creativeEffect'], 2)
|
|
763
|
+
self.assertTrue(separate['creativeLearning'].startswith('结论:无效\n说明:需关停'))
|
|
764
|
+
ad_path.write_text(original_ad, encoding='utf-8')
|
|
765
|
+
# 同一广告维度一半枚举单独不达标,合计基础值后 CPI 仍改善:按维度判有效。
|
|
766
|
+
mixed_ad = json.loads(original_ad)
|
|
767
|
+
for record in mixed_ad['data']['data']:
|
|
768
|
+
record['installs'] = (20 if record['ad_id'] == '1' else 100) / 3
|
|
769
|
+
ad_path.write_text(json.dumps(mixed_ad), encoding='utf-8')
|
|
770
|
+
mixed_payload, _ = summarize_request(root)
|
|
771
|
+
self.assertEqual(mixed_payload['rows'][0]['creativeEffect'], 1)
|
|
772
|
+
self.assertIn('广告新增合计:CPI 本期 1.67', mixed_payload['rows'][0]['creativeLearning'])
|
|
773
|
+
ad_path.write_text(original_ad, encoding='utf-8')
|
|
774
|
+
self.assertIn('消耗 $', row['productStrategy'])
|
|
775
|
+
self.assertTrue(any(item['kind'] == '广告组' for item in payload['details']))
|
|
776
|
+
self.assertTrue(all('/分布' not in item['kind'] and '新增成效' not in item['kind']
|
|
777
|
+
for item in payload['details']))
|
|
778
|
+
self.assertTrue(all(item['action'] in ('新增', '暂停', '持续')
|
|
779
|
+
for item in payload['details'] if item['kind'] != '出价'))
|
|
780
|
+
|
|
781
|
+
# 真实分析入口保证四类 learning 均使用配置失败原因,效果统一待观察。
|
|
782
|
+
for core, note in [('', '核心指标为空'), ('留存与激活组合目标', '核心指标不支持')]:
|
|
783
|
+
changed_biz = {**biz, 'coreIndex': core}
|
|
784
|
+
(root / 'biz-line.json').write_text(json.dumps(changed_biz), encoding='utf-8')
|
|
785
|
+
(root / 'biz-line-detail.out.json').write_text(json.dumps({'data': changed_biz}), encoding='utf-8')
|
|
786
|
+
changed_payload, _ = summarize_request(root)
|
|
787
|
+
for key in ('budget', 'product', 'audience', 'creative'):
|
|
788
|
+
self.assertTrue(changed_payload['rows'][0][key + 'Learning'].startswith(f'结论:待观察\n说明:{note}'))
|
|
789
|
+
self.assertEqual(changed_payload['rows'][0][key + 'Effect'], 3)
|
|
790
|
+
|
|
791
|
+
# 上期请求日期仍存在,但实际记录为空时四类均应使用本期阈值。
|
|
792
|
+
prev_stem = 'met_fb_prev_account_id'
|
|
793
|
+
(root / 'bl-1' / (prev_stem + '.p1.out.json')).write_text(
|
|
794
|
+
json.dumps({'ok': True, 'data': {'data': [], 'total': 0}}), encoding='utf-8')
|
|
795
|
+
(root / 'bl-1' / (prev_stem + '.pages.json')).write_text(
|
|
796
|
+
json.dumps({'total': 0, 'pages': 1, 'counts': [0]}), encoding='utf-8')
|
|
797
|
+
manifest_path = root / 'fetch-manifest.json'
|
|
798
|
+
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
|
|
799
|
+
manifest[prev_stem] = 0
|
|
800
|
+
manifest_path.write_text(json.dumps(manifest), encoding='utf-8')
|
|
801
|
+
for core, verdict, note, effect in [('安装量 >= 50', '有效', '核心指标达标', 1),
|
|
802
|
+
('安装量 >= 51', '无效', '核心指标未达标', 2),
|
|
803
|
+
('安装量', '待观察', '数据不足,暂无法判断', 3)]:
|
|
804
|
+
changed_biz = {**biz, 'coreIndex': core}
|
|
805
|
+
(root / 'biz-line.json').write_text(json.dumps(changed_biz), encoding='utf-8')
|
|
806
|
+
changed_payload, _ = summarize_request(root)
|
|
807
|
+
for key in ('budget',):
|
|
808
|
+
self.assertTrue(changed_payload['rows'][0][key + 'Learning'].startswith(f'结论:{verdict}\n说明:{note}'))
|
|
809
|
+
self.assertEqual(changed_payload['rows'][0][key + 'Effect'], effect)
|
|
810
|
+
|
|
811
|
+
self.assertIn('覆盖不完整', payload['rows'][0]['period'])
|
|
812
|
+
for kind in ('广告', '广告组'):
|
|
813
|
+
objects = [r for r in payload['details'] if r['kind'] == kind]
|
|
814
|
+
self.assertEqual(len(objects), 2)
|
|
815
|
+
self.assertEqual(objects[0]['id'], '1')
|
|
816
|
+
self.assertEqual(objects[1]['id'], '2')
|
|
817
|
+
self.assertTrue(all(not item['extra'] for item in objects))
|
|
818
|
+
bid = next(r for r in payload['details'] if r['kind'] == '出价')
|
|
819
|
+
self.assertNotEqual(bid['action'], '下调')
|
|
820
|
+
self.assertEqual(bid['id'], '1')
|
|
821
|
+
self.assertIn('bid_amount 1 → 1', bid['extra'])
|
|
822
|
+
# 汇总前损坏已完成的分页索引必须失败,不能重新退回第一页继续。
|
|
823
|
+
(root / "bl-1/met_fb_curr_account_id.pages.json").write_text(
|
|
824
|
+
json.dumps({"total": 0, "pages": 0, "counts": []}), encoding="utf-8"
|
|
825
|
+
)
|
|
826
|
+
with self.assertRaises(ValueError):
|
|
827
|
+
summarize_request(root)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
class CleanupAndExport(unittest.TestCase):
|
|
831
|
+
"""真实文件验证不同扩展名、链接目标、锁失败和最终 Excel 保护。"""
|
|
832
|
+
|
|
833
|
+
def test_all_types_and_final_protected(self):
|
|
834
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
835
|
+
root = Path(tmp)
|
|
836
|
+
owned = OwnedDirectory.create(root, 'owned-')
|
|
837
|
+
final = root / 'final.xlsx'
|
|
838
|
+
final.write_text('final')
|
|
839
|
+
history = root / 'history.json'
|
|
840
|
+
history.write_text('history')
|
|
841
|
+
for name in ('raw.json', 'preview.png', 'temp.xlsx', 'run.py', 'log.txt', 'no-extension'):
|
|
842
|
+
(owned.path / name).write_text('temp')
|
|
843
|
+
(owned.path / 'cache').mkdir()
|
|
844
|
+
(owned.path / 'cache/a.pyc').write_bytes(b'cache')
|
|
845
|
+
self.assertTrue(owned.cleanup(final)['ok'])
|
|
846
|
+
self.assertFalse(owned.path.exists())
|
|
847
|
+
self.assertTrue(final.exists() and history.exists())
|
|
848
|
+
|
|
849
|
+
def test_final_inside_and_forged_owner_refused(self):
|
|
850
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
851
|
+
owned = OwnedDirectory.create(Path(tmp), 'owned-')
|
|
852
|
+
self.assertFalse(owned.cleanup(owned.path / 'final.xlsx')['ok'])
|
|
853
|
+
fake = OwnedDirectory(owned.path, owned.parent, 'wrong')
|
|
854
|
+
self.assertFalse(fake.cleanup()['ok'])
|
|
855
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
856
|
+
|
|
857
|
+
def test_external_junction_or_symlink_target_survives(self):
|
|
858
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
859
|
+
root = Path(tmp)
|
|
860
|
+
outside = root / 'external'
|
|
861
|
+
outside.mkdir()
|
|
862
|
+
(outside / 'keep').write_text('keep')
|
|
863
|
+
owned = OwnedDirectory.create(root, 'owned-')
|
|
864
|
+
link = owned.path / 'link'
|
|
865
|
+
if os.name == 'nt':
|
|
866
|
+
# 仅建立受控测试路径的 junction,不通过 shell 执行任何删除。
|
|
867
|
+
subprocess.run(['cmd', '/c', 'mklink', '/J', str(link), str(outside)], check=True, stdout=subprocess.DEVNULL)
|
|
868
|
+
else:
|
|
869
|
+
link.symlink_to(outside, target_is_directory=True)
|
|
870
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
871
|
+
self.assertEqual((outside / 'keep').read_text(), 'keep')
|
|
872
|
+
|
|
873
|
+
def test_delete_error_reported(self):
|
|
874
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
875
|
+
owned = OwnedDirectory.create(Path(tmp), 'owned-')
|
|
876
|
+
(owned.path / 'locked').write_text('locked')
|
|
877
|
+
real_unlink = Path.unlink
|
|
878
|
+
def locked(path, *args, **kwargs):
|
|
879
|
+
if path.name == 'locked':
|
|
880
|
+
raise PermissionError('file in use')
|
|
881
|
+
return real_unlink(path, *args, **kwargs)
|
|
882
|
+
with patch.object(Path, 'unlink', locked):
|
|
883
|
+
result = owned.cleanup()
|
|
884
|
+
self.assertFalse(result['ok'])
|
|
885
|
+
self.assertIn('locked', str(result['residual']))
|
|
886
|
+
self.assertTrue(owned.cleanup()['ok'])
|
|
887
|
+
|
|
888
|
+
def test_long_text_full_export_and_no_overwrite(self):
|
|
889
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
890
|
+
payload = small_payload()
|
|
891
|
+
long_text = '完整说明与特殊字符 <&>\n' * 500
|
|
892
|
+
payload['details'][0]['extra'] = long_text
|
|
893
|
+
payload['rows'][0]['budgetStrategy'] = long_text
|
|
894
|
+
path = Path(tmp) / 'out.xlsx'
|
|
895
|
+
# 汇总不能静默截断;超长逐条信息必须放入明细。
|
|
896
|
+
prepared_long, _ = prepare_layout(payload['rows'], payload['details'])
|
|
897
|
+
self.assertEqual(''.join(r.get('budgetStrategy', '') for r in prepared_long), long_text)
|
|
898
|
+
self.assertEqual(len(prepared_long), 1)
|
|
899
|
+
# 超过可见行高仍只有一条业务线,保存值完整;不生成策略续行。
|
|
900
|
+
payload['rows'][0]['budgetStrategy'] = '枚举及消耗环比\n' * 60
|
|
901
|
+
single_row_path = Path(tmp) / 'single-row.xlsx'
|
|
902
|
+
write_xlsx(payload['rows'], str(single_row_path), payload['details'])
|
|
903
|
+
self.assertEqual(validate_xlsx(single_row_path, payload['rows'], payload['details'])['rows'], 1)
|
|
904
|
+
payload['rows'][0]['budgetStrategy'] = '【预算】\n' + ('渠道汇总:消耗 $100 → $120,占比 50% → 60%;环比 +20%\n' * 18) + '【调整出价】\n分布在:英语(3个)'
|
|
905
|
+
prepared, _ = prepare_layout(payload['rows'], payload['details'])
|
|
906
|
+
self.assertEqual(prepared[0]['budgetStrategy'], payload['rows'][0]['budgetStrategy'])
|
|
907
|
+
write_xlsx(payload['rows'], str(path), payload['details'])
|
|
908
|
+
counts = validate_xlsx(path, payload['rows'], payload['details'])
|
|
909
|
+
self.assertEqual(counts['details'], 1)
|
|
910
|
+
_, details = prepare_layout(payload['rows'], payload['details'])
|
|
911
|
+
self.assertEqual(''.join(r['extra'] for r in details if r['kind'] == '出价'), long_text)
|
|
912
|
+
self.assertNotIn('[续', details[0]['action'])
|
|
913
|
+
payload['details'][0]['extra'] = '过长' * 17000
|
|
914
|
+
with self.assertRaises(ValueError):
|
|
915
|
+
write_xlsx(payload['rows'], str(Path(tmp) / 'too-long.xlsx'), payload['details'])
|
|
916
|
+
payload['details'][0]['extra'] = long_text
|
|
917
|
+
with self.assertRaises(FileExistsError):
|
|
918
|
+
write_xlsx(payload['rows'], str(path), payload['details'])
|
|
919
|
+
final = Path(tmp) / 'final.xlsx'
|
|
920
|
+
final.write_bytes(b'existing')
|
|
921
|
+
with self.assertRaises(FileExistsError):
|
|
922
|
+
publish(path, final, payload)
|
|
923
|
+
self.assertEqual(final.read_bytes(), b'existing')
|
|
924
|
+
with self.assertRaises(ValueError):
|
|
925
|
+
validate_xlsx(path, small_payload()['rows'], [])
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
class BatchExecution(unittest.TestCase):
|
|
929
|
+
"""通过阻塞屏障验证实际重叠、即时交付以及任务隔离。"""
|
|
930
|
+
|
|
931
|
+
def setUp(self):
|
|
932
|
+
"""离线固定字典,业务线并发测试不触发网关。"""
|
|
933
|
+
mocked = patch('strategy_output.load_tags', return_value={key: [{'id': index, 'name': '其他'}]
|
|
934
|
+
for index, key in enumerate(('budget', 'product', 'audience'), 1)})
|
|
935
|
+
mocked.start()
|
|
936
|
+
self.addCleanup(mocked.stop)
|
|
937
|
+
# 离线工作流用已分析选择替代 Agent,语义交接协议另有真实文件测试。
|
|
938
|
+
review = patch('run_workflow.review_tags', side_effect=lambda row, dictionaries, *args, **kwargs:
|
|
939
|
+
{key: [{'id': labels[0]['id'], 'reason': '样本仅提供其他标签'}]
|
|
940
|
+
for key, labels in dictionaries.items()})
|
|
941
|
+
review.start()
|
|
942
|
+
self.addCleanup(review.stop)
|
|
943
|
+
# 批次调度旧测试只关心拉数/重试边界;AI 文件交接由独立测试覆盖。
|
|
944
|
+
ai_review = patch('run_workflow.review_ai_strategy', return_value=(
|
|
945
|
+
'一页速览\n- 样本。\n【预算策略】\n- 样本。\n【广告产品策略】\n- 样本。\n'
|
|
946
|
+
'【受众策略】\n- 样本。\n【创意策略】\n- 样本。'))
|
|
947
|
+
ai_review.start()
|
|
948
|
+
self.addCleanup(ai_review.stop)
|
|
949
|
+
|
|
950
|
+
def test_retries_wait_for_whole_round_and_stop_at_three(self):
|
|
951
|
+
"""快失败不能抢跑下一轮;恢复后不重跑,永久失败含首次只能三次。"""
|
|
952
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
953
|
+
completed, counts, lock = [], {}, threading.Lock()
|
|
954
|
+
events = []
|
|
955
|
+
def worker(target, args, batch, cancel, progress):
|
|
956
|
+
with lock:
|
|
957
|
+
counts[target] = counts.get(target, 0) + 1
|
|
958
|
+
attempt = counts[target]
|
|
959
|
+
self.assertEqual(args.attempt, attempt)
|
|
960
|
+
if attempt == 2:
|
|
961
|
+
self.assertTrue({(1, 1), (2, 1), (3, 1)} <= set(completed))
|
|
962
|
+
if attempt == 3:
|
|
963
|
+
self.assertIn((2, 2), completed)
|
|
964
|
+
time.sleep(0.05 if target == 3 else 0.001)
|
|
965
|
+
with lock:
|
|
966
|
+
completed.append((target, attempt))
|
|
967
|
+
return {'id': target, 'ok': target == 3 or (target == 2 and attempt == 2)}
|
|
968
|
+
result = run_batch(arguments(Path(tmp), [1, 2, 3]), worker=worker,
|
|
969
|
+
progress=events.append, temp_parent=Path(tmp))
|
|
970
|
+
self.assertEqual(counts, {1: 3, 2: 2, 3: 1})
|
|
971
|
+
self.assertEqual((result['success'], result['failed']), (2, 1))
|
|
972
|
+
self.assertEqual([r['attempts'] for r in result['results']], [3, 2, 1])
|
|
973
|
+
self.assertEqual(len(result['results'][0]['attempt_history']), 3)
|
|
974
|
+
self.assertEqual([e['targets'] for e in events if e.get('event') == 'retry_round'], [[1, 2], [1]])
|
|
975
|
+
|
|
976
|
+
def test_cancel_after_failure_does_not_start_retry(self):
|
|
977
|
+
"""用户取消优先于补跑;未启动条目不消失。"""
|
|
978
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
979
|
+
cancel = threading.Event()
|
|
980
|
+
args = arguments(Path(tmp), [1, 2])
|
|
981
|
+
args.concurrency = 1
|
|
982
|
+
def worker(target, args, batch, cancel, progress):
|
|
983
|
+
cancel.set()
|
|
984
|
+
return {'id': target, 'ok': False}
|
|
985
|
+
result = run_batch(args, cancel=cancel, worker=worker, progress=lambda _: None, temp_parent=Path(tmp))
|
|
986
|
+
self.assertEqual([r['attempts'] for r in result['results']], [1, 0])
|
|
987
|
+
self.assertEqual(result['failed'], 2)
|
|
988
|
+
|
|
989
|
+
def test_round_resets_only_failed_tag_cache(self):
|
|
990
|
+
"""真实标签缓存首轮失败后恢复;已成功的另一行业无需再查。"""
|
|
991
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
992
|
+
calls = []
|
|
993
|
+
def tags(root, condition):
|
|
994
|
+
calls.append(condition)
|
|
995
|
+
if condition == '11' and calls.count('11') == 1:
|
|
996
|
+
raise RuntimeError('临时错误')
|
|
997
|
+
return {'budget': []}
|
|
998
|
+
def worker(target, args, batch, cancel, progress):
|
|
999
|
+
try:
|
|
1000
|
+
args.tag_cache.get({'adCategoryIndustryIds': [22]})
|
|
1001
|
+
args.tag_cache.get({'adCategoryIndustryIds': [11]})
|
|
1002
|
+
return {'id': target, 'ok': True}
|
|
1003
|
+
except RuntimeError:
|
|
1004
|
+
return {'id': target, 'ok': False}
|
|
1005
|
+
with patch('strategy_output.load_tags', tags):
|
|
1006
|
+
result = run_batch(arguments(Path(tmp)), worker=worker, progress=lambda _: None, temp_parent=Path(tmp))
|
|
1007
|
+
self.assertTrue(result['ok'])
|
|
1008
|
+
self.assertEqual(calls, ['22', '11', '11'])
|
|
1009
|
+
self.assertEqual(result['results'][0]['attempts'], 2)
|
|
1010
|
+
|
|
1011
|
+
def test_unsplit_workers_keep_fetch_cap_and_deduplicate(self):
|
|
1012
|
+
"""未交接AI阶段的替代worker一直占拉数名额,仍应限制3条并去重。"""
|
|
1013
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1014
|
+
root = Path(tmp)
|
|
1015
|
+
args = arguments(root / 'output', list(range(1, 11)) + [1])
|
|
1016
|
+
barrier = threading.Barrier(3)
|
|
1017
|
+
lock = threading.Lock()
|
|
1018
|
+
active = peak = 0
|
|
1019
|
+
observed = []
|
|
1020
|
+
def worker(target, args, batch, cancel, progress):
|
|
1021
|
+
nonlocal active, peak
|
|
1022
|
+
with lock:
|
|
1023
|
+
active += 1
|
|
1024
|
+
peak = max(peak, active)
|
|
1025
|
+
owned = OwnedDirectory.create(batch.path, f'line-{target}-')
|
|
1026
|
+
if target <= 3:
|
|
1027
|
+
barrier.wait(timeout=5)
|
|
1028
|
+
time.sleep(0.02 if target != 1 else 0.12)
|
|
1029
|
+
cleanup = owned.cleanup()
|
|
1030
|
+
with lock:
|
|
1031
|
+
active -= 1
|
|
1032
|
+
return {'id': target, 'ok': True, 'cleanup': cleanup}
|
|
1033
|
+
result = run_batch(args, worker=worker, progress=observed.append, temp_parent=root)
|
|
1034
|
+
self.assertTrue(result['ok'])
|
|
1035
|
+
self.assertEqual(result['success'], 10)
|
|
1036
|
+
self.assertEqual(peak, 3)
|
|
1037
|
+
self.assertNotEqual(observed[0]['id'], 1)
|
|
1038
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
1039
|
+
|
|
1040
|
+
def test_mixed_real_worker_results_cleanup_immediate(self):
|
|
1041
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1042
|
+
root = Path(tmp)
|
|
1043
|
+
args = arguments(root / 'output', [1, 2, 3])
|
|
1044
|
+
def fetch(local):
|
|
1045
|
+
(Path(local.request_dir) / 'fixture.json').write_text('{}')
|
|
1046
|
+
if local.id == 2:
|
|
1047
|
+
raise RuntimeError('fixture fetch failed')
|
|
1048
|
+
return {'id': local.id, 'name': '同名', 'adCategoryIndustryIds': [11]}
|
|
1049
|
+
def summarize(root):
|
|
1050
|
+
payload = small_payload()
|
|
1051
|
+
(root / 'payload.json').write_text(json.dumps(payload, ensure_ascii=False), encoding='utf-8')
|
|
1052
|
+
return payload, {'detail_count': 1}
|
|
1053
|
+
with patch('run_workflow.fetch_business', fetch), patch('run_workflow.summarize_request', side_effect=summarize):
|
|
1054
|
+
result = run_batch(args, progress=lambda _: None, temp_parent=root)
|
|
1055
|
+
self.assertFalse(result['ok'])
|
|
1056
|
+
self.assertEqual(result['success'], 2)
|
|
1057
|
+
self.assertEqual(len(list((root / 'output').glob('*.xlsx'))), 2)
|
|
1058
|
+
self.assertFalse(list(root.rglob('batch-*')))
|
|
1059
|
+
self.assertIn('fixture fetch failed', str(result))
|
|
1060
|
+
|
|
1061
|
+
def test_invalid_concurrency_before_creating_directory(self):
|
|
1062
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1063
|
+
args = arguments(Path(tmp) / 'output')
|
|
1064
|
+
args.concurrency = 4
|
|
1065
|
+
with self.assertRaises(ValueError):
|
|
1066
|
+
run_batch(args, temp_parent=Path(tmp))
|
|
1067
|
+
self.assertFalse(list(Path(tmp).iterdir()))
|
|
1068
|
+
|
|
1069
|
+
def test_cleanup_failure_keeps_final_and_fails_batch(self):
|
|
1070
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1071
|
+
root = Path(tmp)
|
|
1072
|
+
real_cleanup = OwnedDirectory.cleanup
|
|
1073
|
+
def locked_cleanup(owned, final=None):
|
|
1074
|
+
if owned.path.name.startswith('line-1-'):
|
|
1075
|
+
return {'ok': False, 'residual': [{'path': str(owned.path), 'error': 'fixture locked'}]}
|
|
1076
|
+
return real_cleanup(owned, final)
|
|
1077
|
+
def summarize(root):
|
|
1078
|
+
payload = small_payload()
|
|
1079
|
+
(root / 'payload.json').write_text(json.dumps(payload, ensure_ascii=False), encoding='utf-8')
|
|
1080
|
+
return payload, {'detail_count': 1}
|
|
1081
|
+
with patch('run_workflow.fetch_business', return_value={'id': 1, 'name': '样本', 'adCategoryIndustryIds': [11]}), patch('run_workflow.summarize_request', side_effect=summarize), patch.object(OwnedDirectory, 'cleanup', locked_cleanup):
|
|
1082
|
+
result = run_batch(arguments(root / 'output'), progress=lambda _: None, temp_parent=root)
|
|
1083
|
+
self.assertFalse(result['ok'])
|
|
1084
|
+
self.assertTrue(Path(result['results'][0]['output_path']).is_file())
|
|
1085
|
+
self.assertTrue(result['results'][0]['cleanup_failed'])
|
|
1086
|
+
self.assertTrue(result['cleanup']['residual'])
|
|
1087
|
+
|
|
1088
|
+
def test_cancel_terminates_cli_and_cleans(self):
|
|
1089
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1090
|
+
cancel = threading.Event()
|
|
1091
|
+
timer = threading.Timer(0.15, cancel.set)
|
|
1092
|
+
timer.start()
|
|
1093
|
+
try:
|
|
1094
|
+
with cli_context(cancel, lambda _: None):
|
|
1095
|
+
with self.assertRaises(InterruptedError):
|
|
1096
|
+
exec_cli_to_file([sys.executable, '-c', 'import time; time.sleep(30)'], Path(tmp) / 'cancel.out.json')
|
|
1097
|
+
finally:
|
|
1098
|
+
timer.join()
|
|
1099
|
+
# 子进程退出后输出文件可立即独占打开,证明不持有清理所需句柄。
|
|
1100
|
+
with (Path(tmp) / 'cancel.out.json').open('wb') as stream:
|
|
1101
|
+
stream.write(b'closed')
|
|
1102
|
+
args = arguments(Path(tmp) / 'output', [1, 2, 3, 4])
|
|
1103
|
+
result = run_batch(args, cancel=cancel, progress=lambda _: None, temp_parent=Path(tmp))
|
|
1104
|
+
self.assertEqual(result['failed'], 4)
|
|
1105
|
+
self.assertFalse(list(Path(tmp).rglob('batch-*')))
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
class ConfigurationAndRoot(unittest.TestCase):
|
|
1109
|
+
"""配置失败不重跑,目录固定在明确的工作根内。"""
|
|
1110
|
+
|
|
1111
|
+
def test_missing_industry_saves_once_and_other_line_continues(self):
|
|
1112
|
+
"""使用真实fetch及批次入口,缺行业时不取项目,另一条仍正常预检。"""
|
|
1113
|
+
calls, saves = [], []
|
|
1114
|
+
def query(command, body, output):
|
|
1115
|
+
calls.append((command, body))
|
|
1116
|
+
ident = body['idList'][0]
|
|
1117
|
+
return {'ok': True, 'data': {'total': 1, 'result': [
|
|
1118
|
+
{'id': ident, 'name': '样本', 'projectIds': [],
|
|
1119
|
+
'adCategoryIndustryIds': [] if ident == 1 else [11]}]}}
|
|
1120
|
+
def save(root, body):
|
|
1121
|
+
saves.append(body)
|
|
1122
|
+
return 10 + body['bizLineId']
|
|
1123
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1124
|
+
args = arguments(Path(tmp)/'output', [1, 2])
|
|
1125
|
+
args.output_mode, args.output_dir = 'database', None
|
|
1126
|
+
args.curr_start, args.curr_end = '2026-09-07', '2026-09-13'
|
|
1127
|
+
with patch('fetch_data.run_cli', query), patch('strategy_output.load_tags', return_value={}), \
|
|
1128
|
+
patch('strategy_output.run_cli', return_value={'ok': True, 'data': {'total': 0, 'result': []}}) as projects, \
|
|
1129
|
+
patch('run_workflow.save_result', save):
|
|
1130
|
+
result = run_batch(args, temp_parent=Path(tmp), progress=lambda _: None)
|
|
1131
|
+
self.assertEqual(len(calls), 2)
|
|
1132
|
+
self.assertEqual(projects.call_count, 1)
|
|
1133
|
+
self.assertEqual(len(saves), 2)
|
|
1134
|
+
failed, other = result['results']
|
|
1135
|
+
self.assertEqual(failed['attempts'], 1)
|
|
1136
|
+
self.assertFalse(failed['ok'])
|
|
1137
|
+
self.assertFalse(failed['retryable'])
|
|
1138
|
+
self.assertEqual(failed['generateCode'], 'GENERATION_FAILED')
|
|
1139
|
+
self.assertIn('首个行业 ID', saves[0]['remark'] if saves[0]['bizLineId'] == 1 else saves[1]['remark'])
|
|
1140
|
+
self.assertTrue(other['database_saved'])
|
|
1141
|
+
self.assertEqual(other['generateCode'], 'NO_PROJECT')
|
|
1142
|
+
|
|
1143
|
+
def test_local_missing_industry_does_not_save(self):
|
|
1144
|
+
"""本地输出只报告配置错误,不能新增写库副作用。"""
|
|
1145
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1146
|
+
def query(command, body, output):
|
|
1147
|
+
return {'ok': True, 'data': {'total': 1, 'result': [
|
|
1148
|
+
{'id': 1, 'name': '样本', 'adCategoryIndustryIds': None}]}}
|
|
1149
|
+
with patch('fetch_data.run_cli', side_effect=query) as api, patch('run_workflow.save_result') as save:
|
|
1150
|
+
result=run_batch(arguments(Path(tmp)/'output'), temp_parent=Path(tmp), progress=lambda _: None)
|
|
1151
|
+
self.assertEqual(api.call_count, 1)
|
|
1152
|
+
self.assertEqual(result['results'][0]['attempts'], 1)
|
|
1153
|
+
save.assert_not_called()
|
|
1154
|
+
|
|
1155
|
+
def test_task_root_date_is_frozen_across_retry(self):
|
|
1156
|
+
"""跨午夜和业务线重试仍使用启动日期,同日期其他批次不被清理。"""
|
|
1157
|
+
seen=[]
|
|
1158
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1159
|
+
task_root=Path(tmp)/'durable'
|
|
1160
|
+
history=task_root/'strategy_task'/'2026-10-13'/'tmp'/'other-batch'
|
|
1161
|
+
history.mkdir(parents=True)
|
|
1162
|
+
(history/'keep.txt').write_text('保留',encoding='utf-8')
|
|
1163
|
+
args=arguments(Path(tmp)/'output')
|
|
1164
|
+
args.task_root=str(task_root)
|
|
1165
|
+
def worker(target,args,batch,cancel,progress):
|
|
1166
|
+
seen.append(batch.path)
|
|
1167
|
+
return {'id':target,'ok':args.attempt==2}
|
|
1168
|
+
with patch('run_workflow.date') as dates:
|
|
1169
|
+
dates.today.side_effect=[date(2026,10,13),date(2026,10,14)]
|
|
1170
|
+
result=run_batch(args,worker=worker,progress=lambda _:None)
|
|
1171
|
+
self.assertEqual(dates.today.call_count,1)
|
|
1172
|
+
self.assertTrue(result['ok'])
|
|
1173
|
+
self.assertEqual(seen[0],seen[1])
|
|
1174
|
+
self.assertEqual(seen[0].parent,history.parent.resolve())
|
|
1175
|
+
self.assertEqual([item.resolve() for item in history.parent.iterdir()],[history.resolve()])
|
|
1176
|
+
|
|
1177
|
+
def test_default_root_and_relative_override(self):
|
|
1178
|
+
"""默认cwd与相对工作根都解析成固定绝对路径。"""
|
|
1179
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1180
|
+
for override,expected in ((None,Path(tmp)),('persist',Path(tmp)/'persist')):
|
|
1181
|
+
args=arguments(Path(tmp)/'output')
|
|
1182
|
+
args.task_root=override
|
|
1183
|
+
roots=[]
|
|
1184
|
+
def worker(target,args,batch,cancel,progress):
|
|
1185
|
+
roots.append(batch.path)
|
|
1186
|
+
return {'id':target,'ok':True}
|
|
1187
|
+
with patch('run_workflow.Path.cwd',return_value=Path(tmp)):
|
|
1188
|
+
run_batch(args,worker=worker,progress=lambda _:None)
|
|
1189
|
+
self.assertEqual(roots[0].parents[3],expected.resolve())
|
|
1190
|
+
|
|
1191
|
+
def test_bad_root_does_not_fall_back_to_temp(self):
|
|
1192
|
+
"""空根或已存在普通文件的根不能悄悄回退系统TEMP。"""
|
|
1193
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1194
|
+
for bad in ('',str(Path(tmp)/'file')):
|
|
1195
|
+
Path(tmp,'file').write_text('普通文件',encoding='utf-8')
|
|
1196
|
+
args=arguments(Path(tmp)/'output')
|
|
1197
|
+
args.task_root=bad
|
|
1198
|
+
with self.assertRaises((ValueError,OSError)):
|
|
1199
|
+
run_batch(args,worker=lambda *a: {'ok':True},progress=lambda _:None)
|
|
1200
|
+
parsed=workflow_parser().parse_args(['--id','1','--task-root',tmp])
|
|
1201
|
+
self.assertEqual(parsed.task_root,tmp)
|
|
1202
|
+
|
|
1203
|
+
def test_task_root_cannot_write_inside_expert_skill(self):
|
|
1204
|
+
"""安装技能树保持只读,不能因在技能目录启动而写入缓存。"""
|
|
1205
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1206
|
+
args=arguments(Path(tmp)/'output')
|
|
1207
|
+
args.task_root=str(Path(__file__).resolve().parent.parent)
|
|
1208
|
+
with self.assertRaisesRegex(ValueError,'安装技能'):
|
|
1209
|
+
run_batch(args,worker=lambda *a:{'ok':True},progress=lambda _:None)
|
|
1210
|
+
|
|
1211
|
+
def test_task_root_link_cannot_redirect_into_expert_skill(self):
|
|
1212
|
+
"""解析完整任务路径,拒绝中间链接把临时文件引向安装技能树。"""
|
|
1213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1214
|
+
root = Path(tmp)
|
|
1215
|
+
task_root, skill_root = root / 'durable', root / 'skill'
|
|
1216
|
+
task_root.mkdir()
|
|
1217
|
+
skill_root.mkdir()
|
|
1218
|
+
link = task_root / 'strategy_task'
|
|
1219
|
+
if os.name == 'nt':
|
|
1220
|
+
# Windows junction 不要求开发者模式,验证真实目录重定向。
|
|
1221
|
+
import _winapi
|
|
1222
|
+
_winapi.CreateJunction(str(skill_root), str(link))
|
|
1223
|
+
else:
|
|
1224
|
+
link.symlink_to(skill_root, target_is_directory=True)
|
|
1225
|
+
args = arguments(root / 'output')
|
|
1226
|
+
args.task_root = str(task_root)
|
|
1227
|
+
with patch('run_workflow.__file__', str(skill_root / 'scripts' / 'run_workflow.py')):
|
|
1228
|
+
with self.assertRaisesRegex(ValueError, '安装技能'):
|
|
1229
|
+
run_batch(args, worker=lambda *a: {'ok': True}, progress=lambda _: None)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
if __name__ == "__main__":
|
|
1233
|
+
unittest.main()
|