@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.0f5e9dd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +15 -4
  3. package/steering/design-html/skills/data-viz/SKILL.md +1 -1
  4. package/steering/design-html/skills/hi-fi-design/SKILL.md +1 -1
  5. package/steering/design-html/skills/interactive-prototype/SKILL.md +1 -17
  6. package/steering/design-html/skills/pptx-style-extract/SKILL.md +71 -26
  7. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  8. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
  9. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  10. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2831 -293
  11. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
  12. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  13. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  14. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
  15. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  16. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +76 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  22. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1814 -0
  23. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -0
  24. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  25. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  26. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
  27. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  28. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  29. package/steering/design-html/skills/slide-deck/SKILL.md +5 -3
  30. package/steering/design-html/skills/social-card/SKILL.md +263 -0
  31. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +4 -0
  32. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  33. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  34. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  35. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +38 -113
  36. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +15 -2
  37. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +13 -7
  38. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +205 -38
  39. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  40. package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +67 -4
  41. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +254 -0
  42. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +75 -0
  43. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +164 -543
  44. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +72 -15
  45. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  46. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  47. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  48. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
  49. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/eslint.mcp-ui.config.cjs +19 -0
  50. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/ui-tsconfig.json +19 -0
  51. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +90 -0
  52. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
  53. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +197 -0
  54. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +248 -0
  55. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  56. package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +3 -1
  57. package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +2 -0
  58. package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +6 -0
  59. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +49 -22
  60. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +11 -3
  61. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +57 -14
  62. package/steering/vite-react/skills/plugin-guide/SKILL.md +4 -1
  63. package/steering/vite-react/skills/react-three-fiber/SKILL.md +5 -0
  64. package/steering/vite-react/tech.md +3 -0
  65. package/steering/design-html/skills/preflight/SKILL.md +0 -51
  66. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -108
  67. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  68. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  69. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  70. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  71. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  72. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  73. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  74. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  75. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  76. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  77. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  78. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  79. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  80. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  81. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  82. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for model-selected flow layouts and grouped card columns."""
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from draft import draft_flow, draft_layouts, emit_layouts
11
+ from check_v2 import Pack, rule_v2_14, rule_v2_16
12
+ from package import (Fail, apply_asset_decisions, build_layouts_md,
13
+ split_top_blocks)
14
+
15
+
16
+ def text_slot(role, box, text):
17
+ return {
18
+ 'role': role,
19
+ 'type': 'title' if role == 'title' else 'body',
20
+ 'box': box,
21
+ 'sz': 36,
22
+ 'txt': text,
23
+ 'css': 'font-size: 36px',
24
+ }
25
+
26
+
27
+ class FlowLayoutContractTest(unittest.TestCase):
28
+ def test_sample_page_keeps_every_text_slot_for_card_grouping(self):
29
+ slide_part = 'ppt/slides/slide2.xml'
30
+ layout_part = 'ppt/slideLayouts/slideLayout2.xml'
31
+ shapes = []
32
+ for index in range(8):
33
+ shapes.append({
34
+ 'part': slide_part,
35
+ 'layer': 'slide',
36
+ 'id': str(index + 1),
37
+ 'kind': 'sp',
38
+ 'name': 'Text',
39
+ 'ph': None,
40
+ 'box': {'x': 100 + (index % 3) * 560, 'y': 100 + index * 80,
41
+ 'w': 480, 'h': 60},
42
+ 'text': {
43
+ 'bodyPr': {},
44
+ 'lstStyle': {'lvl1pPr': {'sz_px': 32}},
45
+ 'paragraphs': [{'runs': [{'text': 'Text %d' % index}]}],
46
+ },
47
+ })
48
+ data = {
49
+ 'canvas': {'px': [1920, 1080]},
50
+ 'form_hint': {'form': 0},
51
+ 'slides': [{'part': slide_part, 'layout': layout_part, 'background': None}],
52
+ 'background_composites': {},
53
+ }
54
+
55
+ with tempfile.TemporaryDirectory() as output_dir:
56
+ os.makedirs(os.path.join(output_dir, 'ref'))
57
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
58
+ encoding='utf-8') as stream:
59
+ import json
60
+ json.dump({'shapes': shapes}, stream)
61
+ archetypes, _, _ = draft_layouts(data, output_dir)
62
+
63
+ self.assertEqual(len(archetypes[0]['slots']), 8)
64
+
65
+ def test_layout_mode_selects_flow_without_leaking_draft_control(self):
66
+ draft = """names:
67
+ layout-1: 内容页
68
+ roles:
69
+ layout-1: content
70
+ layout_modes:
71
+ layout-1: flow
72
+ layouts:
73
+ layout-1:
74
+ flow:
75
+ top: 100
76
+ margin: [80, 80]
77
+ gap: 40
78
+ regions:
79
+ - kind: stack
80
+ gap: 20
81
+ items:
82
+ - {role: title, type: title}
83
+ - {role: body, type: body}
84
+ slots:
85
+ - {role: title, box: [80, 100, 1760, 80], type: title}
86
+ - {role: body, box: [80, 220, 1760, 400], type: body}
87
+ confidence: high
88
+ """
89
+
90
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
91
+
92
+ self.assertIn(' flow:', layouts_md)
93
+ self.assertNotIn(' slots:', layouts_md)
94
+ self.assertNotIn('layout_modes:', layouts_md)
95
+
96
+ slots_md = build_layouts_md(
97
+ split_top_blocks(draft.replace('layout-1: flow', 'layout-1: slots')),
98
+ (1920, 1080),
99
+ )
100
+
101
+ self.assertIn(' slots:', slots_md)
102
+ self.assertNotIn(' flow:', slots_md)
103
+ self.assertNotIn('layout_modes:', slots_md)
104
+
105
+ def test_layout_mode_defaults_to_slots_when_both_forms_exist(self):
106
+ draft = """names:
107
+ layout-1: 内容页
108
+ roles:
109
+ layout-1: content
110
+ layouts:
111
+ layout-1:
112
+ flow:
113
+ top: 100
114
+ margin: [80, 80]
115
+ gap: 40
116
+ regions:
117
+ - kind: stack
118
+ items:
119
+ - {role: title, type: title}
120
+ - {role: body, type: body}
121
+ slots:
122
+ - {role: title, box: [80, 100, 1760, 80], type: title}
123
+ - {role: body, box: [80, 220, 1760, 400], type: body}
124
+ confidence: high
125
+ """
126
+
127
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
128
+
129
+ self.assertIn(' slots:', layouts_md)
130
+ self.assertNotIn(' flow:', layouts_md)
131
+
132
+ def test_flow_instance_override_uses_source_box_without_leaking_it(self):
133
+ manifest = {
134
+ 'assets': [],
135
+ 'asset_vision_groups': [{
136
+ 'id': 'vision-1',
137
+ 'source_media': '[shared.png]',
138
+ 'visual_kind': 'content-image',
139
+ }],
140
+ 'asset_decisions': [{
141
+ 'source_media': 'shared.png',
142
+ 'box': '[100, 200, 80, 80]',
143
+ 'visual_kind': 'decorative',
144
+ }],
145
+ }
146
+ decisions, asset_ids = apply_asset_decisions(manifest)
147
+ draft = """names:
148
+ layout-1: 内容页
149
+ roles:
150
+ layout-1: content
151
+ layout_modes:
152
+ layout-1: flow
153
+ layouts:
154
+ layout-1:
155
+ flow:
156
+ top: 100
157
+ margin: [80, 80]
158
+ gap: 40
159
+ regions:
160
+ - kind: stack
161
+ gap: 20
162
+ items:
163
+ - {role: asset-candidate, type: pic, source_media: shared.png, source_box: [100, 200, 80, 80]}
164
+ slots:
165
+ - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, source_media: shared.png}
166
+ confidence: high
167
+ """
168
+
169
+ layouts_md = build_layouts_md(
170
+ split_top_blocks(draft), (1920, 1080),
171
+ asset_decisions=decisions, asset_ids=asset_ids,
172
+ )
173
+
174
+ self.assertIn('- {role: texture, type: pic, asset: texture-1}', layouts_md)
175
+ self.assertNotIn('source_media', layouts_md)
176
+ self.assertNotIn('source_box', layouts_md)
177
+
178
+ def test_layout_mode_rejects_unknown_value(self):
179
+ draft = """names:
180
+ layout-1: 内容页
181
+ roles:
182
+ layout-1: content
183
+ layout_modes:
184
+ layout-1: auto
185
+ layouts:
186
+ layout-1:
187
+ flow:
188
+ top: 100
189
+ margin: [80, 80]
190
+ gap: 40
191
+ regions:
192
+ - kind: stack
193
+ items:
194
+ - {role: title, type: title}
195
+ - {role: body, type: body}
196
+ slots:
197
+ - {role: title, box: [80, 100, 1760, 80], type: title}
198
+ - {role: body, box: [80, 220, 1760, 400], type: body}
199
+ confidence: high
200
+ """
201
+
202
+ with self.assertRaisesRegex(Fail, "layout-1 的 layout_modes 判断是 'auto'"):
203
+ build_layouts_md(split_top_blocks(draft), (1920, 1080))
204
+
205
+ def test_model_identified_footer_moves_to_positioned_free_region(self):
206
+ draft = """names:
207
+ layout-1: 内容页
208
+ roles:
209
+ layout-1: content
210
+ text_roles:
211
+ layout-1-text-1: title
212
+ layout-1-text-2: body
213
+ layout-1-text-3: footer
214
+ layout_modes:
215
+ layout-1: flow
216
+ layouts:
217
+ layout-1:
218
+ flow:
219
+ top: 100
220
+ margin: [80, 80]
221
+ gap: 40
222
+ regions:
223
+ - kind: stack
224
+ gap: 20
225
+ items:
226
+ # text-role: layout-1-text-1
227
+ - {role: body, type: body}
228
+ # text-role: layout-1-text-2
229
+ - {role: body, type: body}
230
+ # text-role: layout-1-text-3
231
+ - {role: body, type: body}
232
+ slots:
233
+ # text-role: layout-1-text-1
234
+ - {role: body, box: [80, 100, 1760, 80], type: body}
235
+ # text-role: layout-1-text-2
236
+ - {role: body, box: [80, 220, 1760, 400], type: body}
237
+ # text-role: layout-1-text-3
238
+ - {role: body, box: [80, 1000, 1760, 40], type: body}
239
+ confidence: high
240
+ """
241
+
242
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
243
+
244
+ self.assertIn(' - kind: free', layouts_md)
245
+ self.assertIn(
246
+ '- {role: footer, box: [80, 1000, 1760, 40], type: footer}',
247
+ layouts_md,
248
+ )
249
+ stack = layouts_md.split(' - kind: stack', 1)[1].split(
250
+ ' - kind: free', 1,
251
+ )[0]
252
+ self.assertNotIn('role: footer', stack)
253
+
254
+ def test_multicolumn_cards_emit_one_level_groups(self):
255
+ archetype = {
256
+ 'name': 'layout-1',
257
+ 'zh': '三列问题页',
258
+ 'role': 'content',
259
+ 'bg': None,
260
+ 'slots': [
261
+ text_slot('title', [120, 80, 1680, 80], '三大关键问题'),
262
+ text_slot('summary', [120, 200, 1680, 80], '本季度核心问题摘要'),
263
+ text_slot('card-title', [150, 360, 480, 60], '毛利承压'),
264
+ text_slot('body', [150, 440, 480, 180], '毛利率低于目标'),
265
+ text_slot('card-title', [720, 360, 480, 60], '收入延期'),
266
+ text_slot('body', [720, 440, 480, 180], '重点项目验收延后'),
267
+ text_slot('card-title', [1290, 360, 480, 60], '续约下滑'),
268
+ text_slot('body', [1290, 440, 480, 180], '中小客户流失增加'),
269
+ ],
270
+ 'decor': [
271
+ {
272
+ 'box': [120, 330, 540, 420],
273
+ 'geom': 'rect',
274
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
275
+ },
276
+ {
277
+ 'box': [690, 330, 540, 420],
278
+ 'geom': 'rect',
279
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
280
+ },
281
+ {
282
+ 'box': [1260, 330, 540, 420],
283
+ 'geom': 'rect',
284
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
285
+ },
286
+ {
287
+ 'box': [115, 660, 550, 95],
288
+ 'geom': 'rect',
289
+ 'css': 'background: #0A84FF',
290
+ },
291
+ {
292
+ 'box': [685, 660, 550, 95],
293
+ 'geom': 'rect',
294
+ 'css': 'background: #0A84FF',
295
+ },
296
+ {
297
+ 'box': [1255, 660, 550, 95],
298
+ 'geom': 'rect',
299
+ 'css': 'background: #0A84FF',
300
+ },
301
+ ],
302
+ 'pages': [2],
303
+ 'rep': 2,
304
+ 'pic_n': 0,
305
+ 'confidence': 'medium',
306
+ }
307
+ archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
308
+
309
+ with tempfile.TemporaryDirectory() as output_dir:
310
+ emit_layouts([archetype], output_dir)
311
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
312
+ layouts_yaml = stream.read()
313
+
314
+ self.assertNotIn('\nlayout_modes:\n', layouts_yaml)
315
+ self.assertIn('默认保留 slots', layouts_yaml)
316
+ self.assertIn(' - kind: grid\n cols: 3', layouts_yaml)
317
+ self.assertEqual(layouts_yaml.count(' - role: group'), 3)
318
+ self.assertEqual(layouts_yaml.count(' items:'), 3)
319
+ self.assertEqual(layouts_yaml.count('role: container'), 3)
320
+
321
+ default_layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
322
+
323
+ self.assertNotIn(' flow:', default_layouts_md)
324
+ self.assertIn(' slots:', default_layouts_md)
325
+
326
+ decided = layouts_yaml.replace(
327
+ 'layouts:',
328
+ 'layout_modes:\n layout-1: flow\nlayouts:',
329
+ )
330
+ layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
331
+
332
+ self.assertNotIn('layout_modes:', layouts_md)
333
+ self.assertNotIn(' slots:', layouts_md)
334
+ self.assertIn(' flow:', layouts_md)
335
+ self.assertEqual(layouts_md.count(' - role: group'), 3)
336
+ self.assertEqual(layouts_md.count(' items:'), 3)
337
+
338
+ with tempfile.TemporaryDirectory() as pack_dir:
339
+ with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
340
+ stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n')
341
+ with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
342
+ stream.write(layouts_md)
343
+ pack = Pack(pack_dir)
344
+
345
+ self.assertEqual(rule_v2_14(pack).level, 'PASS')
346
+ self.assertEqual(rule_v2_16(pack).level, 'PASS')
347
+
348
+ def test_title_and_single_card_row_gap_form_two_flow_regions(self):
349
+ archetype = {
350
+ 'name': 'layout-1',
351
+ 'zh': '标题加三列卡片',
352
+ 'role': 'content',
353
+ 'bg': None,
354
+ 'slots': [
355
+ text_slot('title', [228, 73, 1465, 68], '核心能力'),
356
+ ],
357
+ 'decor': [],
358
+ 'pages': [2],
359
+ 'rep': 2,
360
+ 'pic_n': 0,
361
+ 'confidence': 'high',
362
+ }
363
+ for left in (70, 671, 1270):
364
+ archetype['decor'].append({
365
+ 'box': [left, 212, 580, 660],
366
+ 'geom': 'rect',
367
+ 'css': 'background: rgba(255,255,255,0.7)',
368
+ })
369
+ for top in (270, 374, 498, 621, 797):
370
+ archetype['slots'].append(
371
+ text_slot('body', [left + 80, top, 405, 41], '卡片内容'),
372
+ )
373
+
374
+ flow = draft_flow(archetype, {}, (1920, 1080))
375
+
376
+ self.assertIsNotNone(flow)
377
+ self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
378
+ self.assertEqual(flow['regions'][1]['cols'], 3)
379
+ self.assertEqual(
380
+ [item['role'] for item in flow['regions'][1]['items']],
381
+ ['group', 'group', 'group'],
382
+ )
383
+
384
+ def test_centered_card_grid_carries_its_own_symmetric_margin(self):
385
+ # 短标题贴左(x=120..900),三张卡片居中(左右各 360)。整页 margin 取「最左槽 +
386
+ # 最右槽」的外包络 = [120, 360],直接套给居中卡片组会把它拉偏成左对齐(左缝小、
387
+ # 右缝大)。卡片区带应带自己的对称 margin,还原居中。
388
+ archetype = {
389
+ 'name': 'layout-1',
390
+ 'zh': '标题加三列卡片',
391
+ 'role': 'content',
392
+ 'bg': None,
393
+ 'slots': [
394
+ text_slot('title', [120, 73, 780, 68], '核心能力'),
395
+ ],
396
+ 'decor': [],
397
+ 'pages': [2],
398
+ 'rep': 2,
399
+ 'pic_n': 0,
400
+ 'confidence': 'high',
401
+ }
402
+ for left in (360, 760, 1160):
403
+ archetype['decor'].append({
404
+ 'box': [left, 430, 400, 300],
405
+ 'geom': 'rect',
406
+ 'css': 'background: rgba(255,255,255,0.7)',
407
+ })
408
+ for top in (490, 570):
409
+ archetype['slots'].append(
410
+ text_slot('body', [left + 40, top, 320, 41], '卡片内容'),
411
+ )
412
+
413
+ flow = draft_flow(archetype, {}, (1920, 1080))
414
+
415
+ self.assertIsNotNone(flow)
416
+ self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
417
+ self.assertEqual(flow['margin'], [120, 360])
418
+ grid = flow['regions'][1]
419
+ self.assertEqual(grid.get('margin'), [360, 360])
420
+
421
+ archetype['flow'] = flow
422
+ with tempfile.TemporaryDirectory() as output_dir:
423
+ emit_layouts([archetype], output_dir)
424
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
425
+ layouts_yaml = stream.read()
426
+ decided = layouts_yaml.replace(
427
+ 'layouts:',
428
+ 'layout_modes:\n layout-1: flow\nlayouts:',
429
+ )
430
+ layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
431
+ self.assertRegex(layouts_md, r'- kind: grid\n\s+cols: 3\n\s+gap: \[\d+, \d+\]\n\s+margin: \[360, 360\]')
432
+
433
+ def test_symmetric_card_grid_omits_region_margin(self):
434
+ # 卡片组横向范围已和整页一致(都对称),不该冒出多余的区带 margin——保证对已有
435
+ # 对称模板零回归、layouts.md 不膨胀。
436
+ archetype = {
437
+ 'name': 'layout-1',
438
+ 'zh': '标题加三列卡片',
439
+ 'role': 'content',
440
+ 'bg': None,
441
+ 'slots': [
442
+ text_slot('title', [120, 73, 1680, 68], '核心能力'),
443
+ ],
444
+ 'decor': [],
445
+ 'pages': [2],
446
+ 'rep': 2,
447
+ 'pic_n': 0,
448
+ 'confidence': 'high',
449
+ }
450
+ for left in (120, 720, 1320):
451
+ archetype['decor'].append({
452
+ 'box': [left, 430, 480, 300],
453
+ 'geom': 'rect',
454
+ 'css': 'background: rgba(255,255,255,0.7)',
455
+ })
456
+ for top in (490, 570):
457
+ archetype['slots'].append(
458
+ text_slot('body', [left + 40, top, 400, 41], '卡片内容'),
459
+ )
460
+
461
+ flow = draft_flow(archetype, {}, (1920, 1080))
462
+
463
+ self.assertIsNotNone(flow)
464
+ self.assertEqual(flow['margin'], [120, 120])
465
+ grid = flow['regions'][1]
466
+ self.assertEqual(grid['kind'], 'grid')
467
+ self.assertNotIn('margin', grid)
468
+
469
+ archetype['flow'] = flow
470
+ with tempfile.TemporaryDirectory() as output_dir:
471
+ emit_layouts([archetype], output_dir)
472
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
473
+ layouts_yaml = stream.read()
474
+ grid_block = layouts_yaml.split('- kind: grid', 1)[1].split('items:', 1)[0]
475
+ self.assertNotIn('margin:', grid_block)
476
+
477
+ def test_fixed_template_anchors_stay_in_a_positioned_free_region(self):
478
+ archetype = {
479
+ 'name': 'layout-1',
480
+ 'zh': '内容页',
481
+ 'role': 'content',
482
+ 'bg': None,
483
+ 'slots': [
484
+ text_slot('title', [120, 100, 1680, 80], '标题'),
485
+ text_slot('body', [120, 260, 1680, 280], '正文'),
486
+ {
487
+ 'role': 'logo',
488
+ 'type': 'pic',
489
+ 'box': [1740, 40, 100, 60],
490
+ 'sz': 0,
491
+ 'txt': '',
492
+ 'asset': 'logo-primary',
493
+ },
494
+ {
495
+ 'role': 'slide-number',
496
+ 'type': 'slide-number',
497
+ 'box': [1780, 1000, 60, 40],
498
+ 'sz': 24,
499
+ 'txt': '2',
500
+ 'css': 'font-size: 24px',
501
+ },
502
+ ],
503
+ 'decor': [],
504
+ 'pages': [2],
505
+ 'rep': 2,
506
+ 'pic_n': 1,
507
+ 'confidence': 'medium',
508
+ }
509
+ archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
510
+
511
+ with tempfile.TemporaryDirectory() as output_dir:
512
+ emit_layouts([archetype], output_dir)
513
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
514
+ layouts_yaml = stream.read()
515
+
516
+ self.assertIn(' - kind: free', layouts_yaml)
517
+ self.assertIn(
518
+ '- {role: logo, type: pic, box: [1740, 40, 100, 60], asset: logo-primary}',
519
+ layouts_yaml,
520
+ )
521
+ self.assertIn(
522
+ '- {role: slide-number, type: slide-number, box: [1780, 1000, 60, 40]',
523
+ layouts_yaml,
524
+ )
525
+
526
+
527
+ if __name__ == '__main__':
528
+ unittest.main()