sino-bc-cli 1.8.38 → 1.8.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ """本条任务的明细落盘协议;多次导出和校验按行重读,不保留完整列表。"""
2
+ import json
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+
7
+ def _check_record(record):
8
+ if not isinstance(record, dict) or any(
9
+ not isinstance(key, str) or not isinstance(value, (str, int, float, bool, type(None)))
10
+ for key, value in record.items()):
11
+ raise ValueError('明细记录必须是字段到单元格值的对象')
12
+
13
+
14
+ class DetailRows:
15
+ """只保存路径和行数;每次遍历重新打开,不能复用一次性迭代器。"""
16
+
17
+ def __init__(self, path, count):
18
+ if type(count) is not int or count < 0:
19
+ raise ValueError('明细行数必须是非负整数')
20
+ self.path = Path(path)
21
+ self.count = count
22
+
23
+ def __len__(self):
24
+ return self.count
25
+
26
+ def __iter__(self):
27
+ count = 0
28
+ with self.path.open(encoding='utf-8') as stream:
29
+ for line in stream:
30
+ count += 1
31
+ if count > self.count:
32
+ raise ValueError('明细实际行数超过登记数量')
33
+ record = json.loads(line)
34
+ _check_record(record)
35
+ yield record
36
+ if count != self.count:
37
+ raise ValueError('明细实际行数少于登记数量')
38
+
39
+ def reference(self, root):
40
+ return {'version': 1, 'path': str(self.path.relative_to(root)), 'count': self.count}
41
+
42
+
43
+ def write_detail_rows(root, records):
44
+ """完成全部记录后才发布引用;失败时关闭生成器并移除半成品。"""
45
+ final = Path(root) / f'details-{uuid4().hex}.jsonl'
46
+ partial = final.with_suffix('.part')
47
+ count = 0
48
+ try:
49
+ with partial.open('x', encoding='utf-8') as stream:
50
+ for record in records:
51
+ _check_record(record)
52
+ stream.write(json.dumps(record, ensure_ascii=False) + '\n')
53
+ count += 1
54
+ partial.replace(final)
55
+ return DetailRows(final, count)
56
+ finally:
57
+ close = getattr(records, 'close', None)
58
+ try:
59
+ if close is not None:
60
+ close()
61
+ finally:
62
+ partial.unlink(missing_ok=True)
63
+
64
+
65
+ def details_from_payload(payload, root):
66
+ """新引用只读取本条目录;旧诊断数组保持兼容,不将损坏引用当空明细。"""
67
+ if 'detailSource' not in payload:
68
+ details = payload.get('details', [])
69
+ if not isinstance(details, list):
70
+ raise ValueError('payload.details 必须是数组')
71
+ return details
72
+ source = payload['detailSource']
73
+ if 'details' in payload or not isinstance(source, dict) or set(source) != {'version', 'path', 'count'}:
74
+ raise ValueError('明细引用格式无效或与内嵌明细冲突')
75
+ if type(source['version']) is not int or source['version'] != 1 or not isinstance(source['path'], str):
76
+ raise ValueError('明细引用版本或路径无效')
77
+ relative = Path(source['path'])
78
+ root = Path(root).resolve()
79
+ path = (root / relative).resolve()
80
+ if relative.is_absolute() or '..' in relative.parts or not path.is_relative_to(root) or path == root:
81
+ raise ValueError('明细文件必须位于本条任务目录内')
82
+ return DetailRows(path, source['count'])
@@ -14,6 +14,7 @@ SCRIPTS = Path(__file__).resolve().parent
14
14
  if str(SCRIPTS) not in sys.path:
15
15
  sys.path.insert(0, str(SCRIPTS))
16
16
 
17
+ from detail_store import write_detail_rows
17
18
  from cli_json import BASE_METRICS, REPORT_METRICS, amount_change, evaluate_learning, metric_fields, metric_formula, learning_text
18
19
  from cli_json import (
19
20
  bid_adjust_stats,
@@ -380,7 +381,6 @@ def build_dimension_details(bl: Path, active_channels, bids):
380
381
  ('广告', 'ad_id+ad_name', tuple(CH_LABEL), 'ad_id'),
381
382
  ('创意类型', 'ad_format', tuple(CH_LABEL), None),
382
383
  ]
383
- details = []
384
384
  # 预算按渠道列成效;渠道编码就是此枚举的稳定 ID。
385
385
  for ch in active_channels:
386
386
  totals = {'curr': {}, 'prev': {}}
@@ -390,11 +390,11 @@ def build_dimension_details(bl: Path, active_channels, bids):
390
390
  conflicts[period] = detail_conflicts(bl, stem)
391
391
  for row in iter_stem_rows(bl, stem):
392
392
  add_detail_metric_row(totals[period], row)
393
- details.append({'kind': '预算', 'action': '持续' if totals['prev'] else '新增',
394
- 'channel': CH_LABEL[ch], 'id': ch, 'name': CH_LABEL[ch],
395
- **detail_metric_cells([totals['curr']] if totals['curr'] else [],
396
- [totals['prev']] if totals['prev'] else [],
397
- conflicts['curr'], conflicts['prev']), 'extra': ''})
393
+ yield {'kind': '预算', 'action': '持续' if totals['prev'] else '新增',
394
+ 'channel': CH_LABEL[ch], 'id': ch, 'name': CH_LABEL[ch],
395
+ **detail_metric_cells([totals['curr']] if totals['curr'] else [],
396
+ [totals['prev']] if totals['prev'] else [],
397
+ conflicts['curr'], conflicts['prev']), 'extra': ''}
398
398
 
399
399
  for kind, stem, channels, id_key in dimensions:
400
400
  key = stem.split('+')[-1] if id_key else stem
@@ -425,12 +425,12 @@ def build_dimension_details(bl: Path, active_channels, bids):
425
425
  for ident in sorted(set(names['curr']) | set(names['prev'])):
426
426
  action = ('持续' if ident in names['curr'] and ident in names['prev'] else
427
427
  '新增' if ident in names['curr'] else '暂停')
428
- details.append({'kind': kind, 'action': action, 'channel': CH_LABEL[ch],
429
- 'id': ident, 'name': names['curr'].get(ident) or names['prev'][ident],
430
- **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
431
- [values['prev'][ident]] if values['prev'][ident] else [],
432
- conflicts['curr'], conflicts['prev']),
433
- 'extra': ''})
428
+ yield {'kind': kind, 'action': action, 'channel': CH_LABEL[ch],
429
+ 'id': ident, 'name': names['curr'].get(ident) or names['prev'][ident],
430
+ **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
431
+ [values['prev'][ident]] if values['prev'][ident] else [],
432
+ conflicts['curr'], conflicts['prev']),
433
+ 'extra': ''}
434
434
 
435
435
  # 出价沿用既有上调/下调/持平动作;金额取广告组同 ID 的成效,补充只列首末出价。
436
436
  for ch in active_channels:
@@ -451,13 +451,12 @@ def build_dimension_details(bl: Path, active_channels, bids):
451
451
  action = item.get('action') or ('上调' if end > start else '下调' if end < start else '期内变化但首末持平')
452
452
  changes = ';'.join(f"{field['field']} {field['from']:g} → {field['to']:g}"
453
453
  for field in item.get('fields', []))
454
- details.append({'kind': '出价', 'action': action, 'channel': CH_LABEL[ch],
455
- 'id': ident, 'name': item.get('name') or ident,
456
- **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
457
- [values['prev'][ident]] if values['prev'][ident] else [],
458
- conflicts['curr'], conflicts['prev']),
459
- 'extra': changes})
460
- return details
454
+ yield {'kind': '出价', 'action': action, 'channel': CH_LABEL[ch],
455
+ 'id': ident, 'name': item.get('name') or ident,
456
+ **detail_metric_cells([values['curr'][ident]] if values['curr'][ident] else [],
457
+ [values['prev'][ident]] if values['prev'][ident] else [],
458
+ conflicts['curr'], conflicts['prev']),
459
+ 'extra': changes}
461
460
 
462
461
 
463
462
  def summarize_request(req: Path):
@@ -898,8 +897,8 @@ def summarize_request(req: Path):
898
897
  if audience_type_moved:
899
898
  evidence['audience'].append('受众类型调整')
900
899
  # 旧的覆盖、分布及逐枚举成效说明不再进入 Excel;明细统一按维度和指标重建。
901
- details = build_dimension_details(bl, active_channels, bids)
902
- payload = {"rows": [row], "details": details}
900
+ details = write_detail_rows(req, build_dimension_details(bl, active_channels, bids))
901
+ payload = {"rows": [row], "detailSource": details.reference(req)}
903
902
  (req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
904
903
  summary = {
905
904
  "bizLineId": biz_id,
@@ -916,7 +915,8 @@ def summarize_request(req: Path):
916
915
  "request_dir": str(req),
917
916
  }
918
917
  (req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
919
- return payload, summary
918
+ # 内存引用仅包含路径和行数,兼容工作流的多次遍历;磁盘 payload 不嵌入明细。
919
+ return {**payload, 'details': details}, summary
920
920
 
921
921
 
922
922
  def main() -> int:
@@ -0,0 +1,122 @@
1
+ """明细落盘协议:完整、可重复读取,失败不发布半成品。"""
2
+ import importlib.util
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import weakref
7
+ from pathlib import Path
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+
13
+ class DetailStoreTests(unittest.TestCase):
14
+ def store(self):
15
+ self.assertIsNotNone(importlib.util.find_spec('detail_store'), '尚未提供落盘明细读取器')
16
+ import detail_store
17
+ return detail_store
18
+
19
+ def test_repeated_iteration_and_serializable_reference(self):
20
+ store = self.store()
21
+ with tempfile.TemporaryDirectory() as tmp:
22
+ root = Path(tmp)
23
+ rows = [{'kind': '广告', 'id': str(i), 'name': '中文\n名称'} for i in range(3)]
24
+ source = store.write_detail_rows(root, iter(rows))
25
+ self.assertEqual(len(source), 3)
26
+ self.assertEqual(list(source), rows)
27
+ self.assertEqual(list(source), rows)
28
+ packet = json.loads(json.dumps({'detailSource': source.reference(root)}))
29
+ self.assertEqual(list(store.details_from_payload(packet, root)), rows)
30
+ self.assertFalse(any(root.glob('*.part')))
31
+ self.assertEqual(list(store.write_detail_rows(root, iter([]))), [])
32
+
33
+ def test_count_corruption_and_ambiguous_source_fail(self):
34
+ store = self.store()
35
+ with tempfile.TemporaryDirectory() as tmp:
36
+ root = Path(tmp)
37
+ source = store.write_detail_rows(root, iter([{'id': '1'}]))
38
+ for count in (0, 2):
39
+ with self.assertRaises(ValueError):
40
+ list(store.DetailRows(source.path, count))
41
+ source.path.write_text('{bad}\n', encoding='utf-8')
42
+ with self.assertRaises(ValueError):
43
+ list(source)
44
+ for value in ([], {'version': 2, 'path': source.path.name, 'count': 1},
45
+ {'version': 1, 'path': '../outside.jsonl', 'count': 1},
46
+ {'version': 1, 'path': source.path.name, 'count': True}):
47
+ with self.assertRaises(ValueError):
48
+ store.details_from_payload({'detailSource': value}, root)
49
+ with self.assertRaises(ValueError):
50
+ store.details_from_payload({'detailSource': source.reference(root), 'details': []}, root)
51
+ self.assertEqual(store.details_from_payload({'details': [{'id': 'old'}]}, root), [{'id': 'old'}])
52
+
53
+ def test_partial_write_closes_generator_and_publishes_nothing(self):
54
+ store = self.store()
55
+ closed = []
56
+ def records():
57
+ try:
58
+ yield {'id': '1'}
59
+ raise OSError('模拟磁盘写入失败')
60
+ finally:
61
+ closed.append(True)
62
+ with tempfile.TemporaryDirectory() as tmp:
63
+ with self.assertRaises(OSError):
64
+ store.write_detail_rows(Path(tmp), records())
65
+ self.assertEqual(closed, [True])
66
+ self.assertEqual(list(Path(tmp).iterdir()), [])
67
+
68
+ def test_disk_failure_closes_suspended_generator(self):
69
+ store = self.store()
70
+ closed = []
71
+ def records():
72
+ try:
73
+ yield {'id': '1'}
74
+ yield {'id': '2'}
75
+ finally:
76
+ closed.append(True)
77
+ with tempfile.TemporaryDirectory() as tmp:
78
+ with patch.object(store.json, 'dumps', side_effect=OSError('磁盘故障模拟')):
79
+ with self.assertRaises(OSError):
80
+ store.write_detail_rows(Path(tmp), records())
81
+ self.assertEqual(closed, [True])
82
+ self.assertEqual(list(Path(tmp).iterdir()), [])
83
+
84
+ def test_previous_source_stays_valid_after_another_summary(self):
85
+ store = self.store()
86
+ with tempfile.TemporaryDirectory() as tmp:
87
+ old = store.write_detail_rows(Path(tmp), iter([{'id': 'old'}]))
88
+ new = store.write_detail_rows(Path(tmp), iter([{'id': 'new'}]))
89
+ self.assertEqual(list(old), [{'id': 'old'}])
90
+ self.assertEqual(list(new), [{'id': 'new'}])
91
+
92
+
93
+ def test_closed_generator_releases_analysis_objects(self):
94
+ store = self.store()
95
+ references = []
96
+ class Aggregate:
97
+ pass
98
+ def records():
99
+ aggregate = Aggregate()
100
+ references.append(weakref.ref(aggregate))
101
+ yield {'id': '1'}
102
+ with tempfile.TemporaryDirectory() as tmp:
103
+ source = store.write_detail_rows(Path(tmp), records())
104
+ self.assertIsNone(references[0]())
105
+ self.assertEqual(set(vars(source)), {'path', 'count'})
106
+
107
+ def test_diagnostic_incomplete_source_does_not_publish(self):
108
+ store = self.store()
109
+ script = Path(__file__).with_name('build_report.py')
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ root = Path(tmp)
112
+ source = store.write_detail_rows(root, iter([{'kind': '广告', 'id': '1'}]))
113
+ source.path.write_text('', encoding='utf-8')
114
+ packet = {'rows': [{'bizLine': '样本'}], 'detailSource': source.reference(root)}
115
+ output = root / 'broken.xlsx'
116
+ result = subprocess.run([sys.executable, '-B', str(script), '--input', '-', '--output', str(output)],
117
+ input=json.dumps(packet), text=True, capture_output=True, cwd=root)
118
+ self.assertNotEqual(result.returncode, 0)
119
+ self.assertFalse(output.exists())
120
+
121
+ if __name__ == '__main__':
122
+ unittest.main()
@@ -0,0 +1,223 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ import zipfile
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+ import build_report as report
10
+ from detail_store import write_detail_rows
11
+
12
+
13
+ class ExcelStreamingTests(unittest.TestCase):
14
+ def test_export_and_validation_do_not_materialize_xml(self):
15
+ with tempfile.TemporaryDirectory() as directory:
16
+ path = str(Path(directory) / 'test.xlsx')
17
+ details = [{'id': str(i), 'name': '名称'} for i in range(50)]
18
+ original_write = zipfile.ZipFile.writestr
19
+ def write(archive, name, *args, **kwargs):
20
+ if 'sharedStrings' in name or 'worksheets/sheet' in name:
21
+ raise AssertionError('整表 XML 写入')
22
+ return original_write(archive, name, *args, **kwargs)
23
+ with patch.object(zipfile.ZipFile, 'writestr', write):
24
+ report.write_xlsx([{'bizLine': '样本'}], path, details)
25
+ original = zipfile.ZipFile.read
26
+ def read(archive, name, *args, **kwargs):
27
+ if 'sharedStrings' in name or 'worksheets/sheet' in name:
28
+ raise AssertionError('整表 XML')
29
+ return original(archive, name, *args, **kwargs)
30
+ with patch.object(zipfile.ZipFile, 'read', read):
31
+ self.assertEqual(report.validate_xlsx(path, [{'bizLine': '样本'}], details)['details'], 50)
32
+
33
+ def test_bad_coordinate_is_rejected(self):
34
+ with tempfile.TemporaryDirectory() as directory:
35
+ path = Path(directory) / 'test.xlsx'
36
+ report.write_xlsx([{'bizLine': '样本'}], path, [{'id': '1'}])
37
+ with zipfile.ZipFile(path) as archive:
38
+ contents = {name: archive.read(name) for name in archive.namelist()}
39
+ contents['xl/worksheets/sheet2.xml'] = contents['xl/worksheets/sheet2.xml'].replace(b'r="A2"', b'r="Z9"')
40
+ with zipfile.ZipFile(path, 'w') as archive:
41
+ for name, content in contents.items():
42
+ archive.writestr(name, content)
43
+ with self.assertRaises(ValueError):
44
+ report.validate_xlsx(path, [{'bizLine': '样本'}], [{'id': '1'}])
45
+
46
+ def test_index_keeps_format_keys_and_bounded_hot_cache(self):
47
+ with tempfile.TemporaryDirectory() as directory:
48
+ with report._string_index(directory) as connection:
49
+ strings = report._Strings(connection)
50
+ self.assertEqual(strings.intern('【预算】'), 0)
51
+ self.assertEqual(strings.intern('【预算】', True), 1)
52
+ self.assertEqual(strings.intern('【预算】', (('【预算】', True),)), 2)
53
+ self.assertEqual(strings.intern('【预算】'), 0)
54
+ for index in range(4000):
55
+ strings.intern(str(index))
56
+ strings.intern('长' * 32767)
57
+ self.assertLessEqual(len(strings.cache), 512)
58
+ self.assertTrue(all(isinstance(key[1], bool) and len(key[0]) <= 512 for key in strings.cache))
59
+ self.assertEqual(list(Path(directory).iterdir()), [])
60
+
61
+ def test_hot_string_avoids_json_serialization(self):
62
+ with tempfile.TemporaryDirectory() as directory, report._string_index(directory) as connection:
63
+ strings = report._Strings(connection)
64
+ strings.intern('重复文字')
65
+ with patch.object(report.json, 'dumps', side_effect=AssertionError('命中缓存仍序列化')):
66
+ self.assertEqual(strings.intern('重复文字'), 0)
67
+
68
+ def test_xml_items_reject_invalid_hierarchy(self):
69
+ ns = report._NS['s']
70
+ cases = [
71
+ ('row', f'<worksheet xmlns="{ns}"><notSheetData><row/></notSheetData></worksheet>'),
72
+ ('row', f'<wrong xmlns="{ns}"><sheetData><row/></sheetData></wrong>'),
73
+ ('row', f'<worksheet xmlns="{ns}"><sheetData/><sheetData/></worksheet>'),
74
+ ('row', f'<worksheet xmlns="{ns}"><extra><sheetData><row/></sheetData></extra></worksheet>'),
75
+ ('si', f'<wrong xmlns="{ns}"><si><t>文字</t></si></wrong>'),
76
+ ('si', f'<sst xmlns="{ns}"><extra><si><t>文字</t></si></extra></sst>'),
77
+ ]
78
+ for tag, xml in cases:
79
+ with self.subTest(xml=xml), tempfile.TemporaryDirectory() as directory:
80
+ path = Path(directory) / 'bad.zip'
81
+ with zipfile.ZipFile(path, 'w') as archive:
82
+ archive.writestr('part.xml', xml)
83
+ with zipfile.ZipFile(path) as archive:
84
+ with self.assertRaises(ValueError):
85
+ list(report._xml_items(archive, 'part.xml', tag))
86
+
87
+ def test_validation_queries_missing_strings_by_row(self):
88
+ with tempfile.TemporaryDirectory() as directory:
89
+ path = Path(directory) / 'rows.xlsx'
90
+ details = [{key: f'{key}-{i}' for key, _ in report.DETAIL_COLUMNS} for i in range(20)]
91
+ report.write_detail_workbook(details, path)
92
+ with zipfile.ZipFile(path) as archive, report._string_index(directory) as connection:
93
+ report._load_strings(archive, connection)
94
+ queries = []
95
+ connection.set_trace_callback(lambda sql: queries.append(sql) if sql.startswith('SELECT') else None)
96
+ report._validate_sheet(archive, 'xl/worksheets/sheet1.xml', details, report.DETAIL_COLUMNS, connection)
97
+ self.assertLessEqual(len(queries), len(details) + 1)
98
+
99
+ def test_failed_write_removes_partial_file_and_index(self):
100
+ for exception in (OSError('磁盘写入失败'), KeyboardInterrupt()):
101
+ with self.subTest(exception=type(exception).__name__), tempfile.TemporaryDirectory() as directory:
102
+ path = Path(directory) / 'partial.xlsx'
103
+ with patch.object(report._Strings, 'write', side_effect=exception):
104
+ with self.assertRaises(type(exception)):
105
+ report.write_detail_workbook([{'id': '1'}], path)
106
+ self.assertEqual(list(Path(directory).iterdir()), [])
107
+
108
+ def test_index_creation_failure_and_corrupt_zip_fail(self):
109
+ with tempfile.TemporaryDirectory() as directory:
110
+ path = Path(directory) / 'test.xlsx'
111
+ with patch.object(report.sqlite3, 'connect', side_effect=report.sqlite3.OperationalError('无法创建索引')):
112
+ with self.assertRaises(report.sqlite3.OperationalError):
113
+ report.write_detail_workbook([{'id': '1'}], path)
114
+ self.assertEqual(list(Path(directory).iterdir()), [])
115
+ report.write_detail_workbook([{'id': '1'}], path)
116
+ path.write_bytes(path.read_bytes()[:-22])
117
+ with self.assertRaises(zipfile.BadZipFile):
118
+ report.validate_detail_workbook(path, [{'id': '1'}])
119
+ self.assertFalse(list(Path(directory).glob('.excel-index-*')))
120
+
121
+ def test_late_corruption_and_missing_or_extra_rows(self):
122
+ cases = {
123
+ '末行错值': lambda xml: xml.replace(b'<v>48</v>', b'<v>0</v>'),
124
+ '缺行': lambda xml: __import__('re').sub(rb'<row r="31".*?</row>', b'', xml),
125
+ '多行': lambda xml: xml.replace(b'</sheetData>', b'<row r="32"></row></sheetData>'),
126
+ '越界': lambda xml: xml.replace(b'<v>48</v>', b'<v>999999</v>'),
127
+ '负索引': lambda xml: xml.replace(b'<v>48</v>', b'<v>-1</v>'),
128
+ 'XML末尾损坏': lambda xml: xml[:-3],
129
+ '错误明细父节点': lambda xml: xml.replace(b'sheetData', b'notSheetData'),
130
+ '重复明细容器': lambda xml: xml.replace(b'</worksheet>', b'<sheetData/></worksheet>'),
131
+ '错误根节点': lambda xml: xml.replace(b'worksheet', b'wrongRoot'),
132
+ }
133
+ details = [{'id': str(i)} for i in range(30)]
134
+ for label, mutate in cases.items():
135
+ with self.subTest(label=label), tempfile.TemporaryDirectory() as directory:
136
+ path = Path(directory) / 'test.xlsx'
137
+ report.write_detail_workbook(details, path)
138
+ with zipfile.ZipFile(path) as archive:
139
+ content = {name: archive.read(name) for name in archive.namelist()}
140
+ original = content['xl/worksheets/sheet1.xml']
141
+ content['xl/worksheets/sheet1.xml'] = mutate(original)
142
+ self.assertNotEqual(original, content['xl/worksheets/sheet1.xml'])
143
+ with zipfile.ZipFile(path, 'w') as archive:
144
+ for name, value in content.items():
145
+ archive.writestr(name, value)
146
+ with self.assertRaises((ValueError, report.ET.ParseError)):
147
+ report.validate_detail_workbook(path, details)
148
+
149
+ def test_ai_read_scans_shared_strings_to_end_without_reading_whole_part(self):
150
+ ai = '速览 **重点**\n【预算策略】\n预算\n【广告产品策略】\n产品\n【受众策略】\n受众\n【创意策略】\n创意'
151
+ with tempfile.TemporaryDirectory() as directory:
152
+ path = Path(directory) / 'ai.xlsx'
153
+ report.write_xlsx([{'bizLine': '样本'}], path, [{'name': str(i)} for i in range(2000)], ai_strategy=ai)
154
+ original_read = zipfile.ZipFile.read
155
+ def read(archive, name, *args, **kwargs):
156
+ if name == 'xl/sharedStrings.xml':
157
+ raise AssertionError('整表共享字符串')
158
+ return original_read(archive, name, *args, **kwargs)
159
+ with patch.object(zipfile.ZipFile, 'read', read):
160
+ self.assertEqual(report.read_ai_sheet(path)['一页速览'], '速览 重点')
161
+ with zipfile.ZipFile(path) as archive:
162
+ contents = {name: archive.read(name) for name in archive.namelist()}
163
+ contents['xl/sharedStrings.xml'] += b'<broken'
164
+ with zipfile.ZipFile(path, 'w') as archive:
165
+ for name, value in contents.items():
166
+ archive.writestr(name, value)
167
+ with self.assertRaises(report.ET.ParseError):
168
+ report.read_ai_sheet(path)
169
+
170
+ def test_diagnostic_file_and_stdin_support_both_detail_formats(self):
171
+ with tempfile.TemporaryDirectory() as directory:
172
+ root = Path(directory)
173
+ source = write_detail_rows(root, [{'id': '1', 'name': '明细'}])
174
+ payloads = [
175
+ {'rows': [{'bizLine': '样本'}], 'details': [{'id': '1', 'name': '明细'}]},
176
+ {'rows': [{'bizLine': '样本'}], 'detailSource':
177
+ {'version': 1, 'path': source.path.name, 'count': 1}},
178
+ ]
179
+ for number, payload in enumerate(payloads):
180
+ for mode in ('file', 'stdin'):
181
+ path = root / f'{number}-{mode}.xlsx'
182
+ input_path = root / 'payload.json'
183
+ input_path.write_text(json.dumps(payload), encoding='utf-8')
184
+ result = subprocess.run([sys.executable, '-B', str(Path(report.__file__).resolve()),
185
+ '--input', str(input_path) if mode == 'file' else '-',
186
+ '--output', str(path)], input=json.dumps(payload), text=True,
187
+ capture_output=True, cwd=root)
188
+ self.assertEqual(result.returncode, 0, result.stderr)
189
+ self.assertEqual(report.validate_xlsx(path, payload['rows'], source)['details'], 1)
190
+ payloads[1]['detailSource']['version'] = 999
191
+ payloads[1]['details'] = payloads[0]['details']
192
+ result = subprocess.run([sys.executable, '-B', str(Path(report.__file__).resolve()),
193
+ '--input', '-', '--output', str(root / 'bad.xlsx')],
194
+ input=json.dumps(payloads[1]), text=True, capture_output=True, cwd=root)
195
+ self.assertNotEqual(result.returncode, 0)
196
+ self.assertFalse((root / 'bad.xlsx').exists())
197
+
198
+ def test_file_source_index_stays_with_source_and_existing_output_is_preserved(self):
199
+ with tempfile.TemporaryDirectory() as directory:
200
+ root = Path(directory)
201
+ source_root, output_root = root / 'source', root / 'published'
202
+ source_root.mkdir()
203
+ output_root.mkdir()
204
+ source = write_detail_rows(source_root, [{'id': '1'}])
205
+ output = output_root / 'test.xlsx'
206
+ original = report._string_index
207
+ observed = []
208
+ def index(path):
209
+ observed.append(Path(path))
210
+ return original(path)
211
+ with patch.object(report, '_string_index', index):
212
+ report.write_detail_workbook(source, output)
213
+ report.validate_detail_workbook(output, source)
214
+ self.assertEqual(observed, [source_root, source_root])
215
+ before = output.read_bytes()
216
+ with self.assertRaises(FileExistsError):
217
+ report.write_detail_workbook(source, output)
218
+ self.assertEqual(output.read_bytes(), before)
219
+ self.assertEqual(list(source_root.glob('.excel-index-*')), [])
220
+
221
+
222
+ if __name__ == '__main__':
223
+ unittest.main()
@@ -11,6 +11,7 @@ from unittest.mock import patch
11
11
  sys.dont_write_bytecode = True
12
12
  sys.path.insert(0, str(Path(__file__).resolve().parent))
13
13
  from run_workflow import run_batch
14
+ from detail_store import write_detail_rows
14
15
  from strategy_output import submit_tag_choices
15
16
  from test_workflow import arguments, small_payload
16
17
  from test_ai_strategy import AI_TEXT
@@ -27,8 +28,10 @@ class SlidingPipeline(unittest.TestCase):
27
28
  for key in ('budget', 'product', 'audience')}
28
29
  def analyze(directory):
29
30
  payload = small_payload()
31
+ details = write_detail_rows(directory, iter(payload.pop('details')))
32
+ payload['detailSource'] = details.reference(directory)
30
33
  (directory / 'payload.json').write_text(json.dumps(payload), encoding='utf-8')
31
- return payload, {'detail_count': 1, 'curr_total': 100}
34
+ return {**payload, 'details': details}, {'detail_count': len(details), 'curr_total': 100}
32
35
  with patch('run_workflow.fetch_business', side_effect=fetch), \
33
36
  patch('run_workflow.summarize_request', side_effect=analyze), \
34
37
  patch('strategy_output.TagCache.get', return_value=dictionaries):