@lark-apaas/coding-steering 0.1.18-dev.ec88bb5 → 0.1.18-dev.fb5e270

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 (29) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +62 -26
  4. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  5. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  6. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  7. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2123 -264
  8. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
  9. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +504 -160
  11. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  12. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  13. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +161 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +503 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +208 -0
  22. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +28 -16
  23. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  24. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
  25. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +10 -0
  26. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
  27. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  28. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  29. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for the consumer-facing layout slot CSS contract."""
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 check_v2 import Pack, rule_v2_16 # noqa: E402
11
+ from draft import (attach_leftover_image_marks, contact_sheets, draft_layouts, # noqa: E402
12
+ emit_layouts, emit_manifest, fullscreen_overlay_media,
13
+ has_small_image_cluster, needs_asset_judgment,
14
+ slide_image_marks, slot_style)
15
+ from package import (FONTSIZE_RE, Fail, apply_asset_decisions, # noqa: E402
16
+ build_layouts_md, resolve_asset_candidate_lines,
17
+ split_top_blocks)
18
+
19
+
20
+ class LayoutCssTest(unittest.TestCase):
21
+ def test_text_slot_emits_rendering_style_as_css_only(self):
22
+ shape = {
23
+ 'text': {
24
+ 'bodyPr': {
25
+ 'anchor': 'ctr',
26
+ 'insets_px': {'lIns': 12, 'tIns': 8, 'rIns': 10, 'bIns': 6},
27
+ 'rot': '900000',
28
+ },
29
+ 'lstStyle': {
30
+ 'lvl1pPr': {
31
+ 'sz_px': 48,
32
+ 'weight': 600,
33
+ 'italic': True,
34
+ 'underline': 'sng',
35
+ 'strike': 'sngStrike',
36
+ 'spc_px': 1.5,
37
+ 'color': {'resolved': '#123456'},
38
+ 'algn': 'ctr',
39
+ 'lnSpc': {'mult': 1.0},
40
+ },
41
+ },
42
+ 'paragraphs': [],
43
+ },
44
+ }
45
+ slot = {
46
+ 'role': 'title',
47
+ 'type': 'title',
48
+ 'box': [100, 80, 800, 160],
49
+ 'sz': 48,
50
+ 'txt': '标题',
51
+ }
52
+ slot.update(slot_style(shape))
53
+ archetype = {
54
+ 'name': 'layout-1',
55
+ 'zh': '标题页',
56
+ 'role': 'content',
57
+ 'bg': None,
58
+ 'slots': [slot],
59
+ 'decor': [],
60
+ 'pages': [1],
61
+ 'rep': 1,
62
+ 'pic_n': 0,
63
+ 'confidence': 'high',
64
+ }
65
+
66
+ with tempfile.TemporaryDirectory() as output_dir:
67
+ emit_layouts([archetype], output_dir)
68
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
69
+ layouts_yaml = stream.read()
70
+ layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
71
+
72
+ self.assertIn(
73
+ 'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
74
+ 'font-size: 48px; font-weight: 600; font-style: italic; '
75
+ 'text-decoration: underline line-through; letter-spacing: 1.5px; color: #123456; '
76
+ 'text-align: center; line-height: 1.2; display: flex; '
77
+ 'flex-direction: column; justify-content: center; rotate: 15deg"',
78
+ layouts_md,
79
+ )
80
+ for legacy_key in ('size', 'weight', 'color', 'align', 'valign', 'insets_px'):
81
+ self.assertNotRegex(layouts_md, rf'[,{{]\s*{legacy_key}:')
82
+ self.assertEqual(FONTSIZE_RE.findall(layouts_md), ['48'])
83
+
84
+ def test_layout_gate_rejects_legacy_slot_style_keys(self):
85
+ with tempfile.TemporaryDirectory() as pack_dir:
86
+ with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
87
+ stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n\nRead layouts.md.\n')
88
+ with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
89
+ stream.write(
90
+ '---\ncanvas: 1920x1080\nlayouts:\n cover:\n role: cover\n'
91
+ ' slots:\n - {role: title, type: title, box: [0, 0, 800, 100], '
92
+ 'size: 48, align: center}\n confidence: high\n---\n'
93
+ )
94
+
95
+ result = rule_v2_16(Pack(pack_dir))
96
+
97
+ self.assertEqual(result.level, 'FAIL')
98
+ self.assertEqual(len(result.fails), 1)
99
+ self.assertIn('旧样式键 align/size', result.fails[0])
100
+
101
+ def test_normautofit_fontscale_shrinks_emitted_font_size(self):
102
+ # 章节大号数字:160px 字号靠 normAutofit fontScale 0.9 装进 144px 的框。
103
+ # 不乘 fontScale,消费端拿到 160px,字比框高,渐变裁切把底部切成透明。
104
+ shape = {
105
+ 'text': {
106
+ 'bodyPr': {'anchor': 't', 'font_scale': 0.9, 'ln_spc_reduction': 0.1},
107
+ 'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
108
+ 'paragraphs': [{'runs': [{'text': '01.'}]}],
109
+ },
110
+ }
111
+ style = slot_style(shape)
112
+ self.assertIn('font-size: 144px', style['css'])
113
+ self.assertNotIn('font-size: 160px', style['css'])
114
+ # lnSpcReduction 0.1 把 1.0*1.2 的行高压到 1.08
115
+ self.assertIn('line-height: 1.08', style['css'])
116
+
117
+ def test_missing_autofit_leaves_font_size_untouched(self):
118
+ # 无 normAutofit(或无 fontScale)时零影响:字号原样、行高不缩。
119
+ shape = {
120
+ 'text': {
121
+ 'bodyPr': {'anchor': 't'},
122
+ 'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
123
+ 'paragraphs': [{'runs': [{'text': '01.'}]}],
124
+ },
125
+ }
126
+ style = slot_style(shape)
127
+ self.assertIn('font-size: 160px', style['css'])
128
+ self.assertIn('line-height: 1.2', style['css'])
129
+
130
+ def test_run_level_style_is_emitted_from_flat_ooxml_record(self):
131
+ # read_txbody 把 a:rPr 的属性直接展开到 run;没有嵌套 rPr 字段。
132
+ shape = {
133
+ 'text': {
134
+ 'bodyPr': {'anchor': 't'},
135
+ 'paragraphs': [{
136
+ 'runs': [{
137
+ 'text': '品牌标题',
138
+ 'sz_px': 52,
139
+ 'weight': 700,
140
+ 'latin': 'Brand Sans',
141
+ 'color': {'resolved': '#C1121F'},
142
+ }],
143
+ }],
144
+ },
145
+ }
146
+
147
+ style = slot_style(shape)
148
+
149
+ self.assertIn('font-size: 52px', style['css'])
150
+ self.assertIn(
151
+ 'font-family: "Brand Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
152
+ style['css'],
153
+ )
154
+ self.assertIn('font-weight: 700', style['css'])
155
+ self.assertIn('color: #C1121F', style['css'])
156
+
157
+ def test_run_level_style_overrides_inherited_list_style(self):
158
+ shape = {
159
+ 'text': {
160
+ 'lstStyle': {
161
+ 'lvl1pPr': {
162
+ 'sz_px': 20,
163
+ 'latin': 'Inherited Sans',
164
+ 'color': {'resolved': '#111111'},
165
+ },
166
+ },
167
+ 'paragraphs': [{
168
+ 'runs': [{
169
+ 'text': '直接格式',
170
+ 'sz_px': 52,
171
+ 'latin': 'Direct Sans',
172
+ 'color': {'resolved': '#C1121F'},
173
+ }],
174
+ }],
175
+ },
176
+ }
177
+
178
+ style = slot_style(shape)
179
+
180
+ self.assertIn('font-size: 52px', style['css'])
181
+ self.assertIn('font-family: "Direct Sans"', style['css'])
182
+ self.assertIn('color: #C1121F', style['css'])
183
+ self.assertNotIn('font-size: 20px', style['css'])
184
+ self.assertNotIn('Inherited Sans', style['css'])
185
+
186
+ def test_asset_decisions_keep_content_slots_and_bind_texture_slots(self):
187
+ manifest = {
188
+ 'assets': [],
189
+ 'asset_decisions': [
190
+ {'source_media': 'chart.png', 'decision': 'content'},
191
+ {'source_media': 'ornament.png', 'decision': 'texture'},
192
+ ],
193
+ }
194
+
195
+ decisions, asset_ids = apply_asset_decisions(manifest)
196
+ lines = resolve_asset_candidate_lines([
197
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
198
+ 'source_media: chart.png}',
199
+ ' - {role: asset-candidate, box: [20, 30, 80, 80], type: pic, '
200
+ 'source_media: ornament.png}',
201
+ ], decisions, asset_ids)
202
+
203
+ self.assertEqual(len(lines), 2)
204
+ self.assertNotIn('chart.png', lines[0])
205
+ self.assertNotIn('source_media', lines[0])
206
+ self.assertNotIn('asset:', lines[0])
207
+ self.assertIn('role: pic', lines[0])
208
+ self.assertNotIn('source_media', lines[1])
209
+ self.assertIn('role: texture', lines[1])
210
+ self.assertIn('asset: texture-1', lines[1])
211
+ self.assertEqual(manifest['assets'], [{
212
+ 'id': 'texture-1',
213
+ 'source_media': 'ornament.png',
214
+ 'kind': 'texture',
215
+ }])
216
+
217
+ def test_asset_decisions_do_not_reuse_an_existing_asset_id(self):
218
+ manifest = {
219
+ 'assets': [
220
+ {
221
+ 'id': 'logo-primary',
222
+ 'source_media': 'candidate.png',
223
+ 'kind': 'logo',
224
+ },
225
+ {
226
+ 'id': 'texture-1',
227
+ 'source_media': 'existing.png',
228
+ 'kind': 'texture',
229
+ },
230
+ ],
231
+ 'asset_decisions': [
232
+ {'source_media': 'candidate.png', 'decision': 'texture'},
233
+ ],
234
+ }
235
+
236
+ _, asset_ids = apply_asset_decisions(manifest)
237
+
238
+ self.assertEqual(asset_ids['existing.png'], 'texture-1')
239
+ self.assertEqual(asset_ids['candidate.png'], 'texture-2')
240
+ self.assertEqual(
241
+ [asset['id'] for asset in manifest['assets']],
242
+ ['texture-2', 'texture-1'],
243
+ )
244
+
245
+ def test_asset_decisions_reject_candidates_without_layout_slots(self):
246
+ manifest = {
247
+ 'assets': [],
248
+ 'asset_decisions': [
249
+ {'source_media': 'orphan-decoration.png', 'decision': 'texture'},
250
+ ],
251
+ }
252
+
253
+ with self.assertRaisesRegex(Fail, '没有对应图片槽'):
254
+ apply_asset_decisions(manifest, bound_sources=set())
255
+
256
+ def test_translucent_fullscreen_decoration_requires_model_judgment(self):
257
+ candidate = {
258
+ 'file': 'glow.png',
259
+ 'fullscreen': True,
260
+ 'slides': [2],
261
+ 'probe': {'alpha_mean': 80},
262
+ }
263
+
264
+ with tempfile.TemporaryDirectory() as output_dir:
265
+ emit_manifest({'theme_topology': {}}, [], [candidate], output_dir)
266
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
267
+ manifest = stream.read()
268
+
269
+ self.assertIn('source_media: glow.png', manifest)
270
+ self.assertIn('decision: TODO-kind-1', manifest)
271
+
272
+ def test_deterministic_fullscreen_images_skip_model_judgment(self):
273
+ self.assertFalse(needs_asset_judgment({
274
+ 'fullscreen': True,
275
+ 'probe': {'alpha_mean': 255, 'near_blank': False},
276
+ }))
277
+ self.assertFalse(needs_asset_judgment({
278
+ 'fullscreen': True,
279
+ 'probe': {'alpha_mean': 5, 'near_blank': True},
280
+ }))
281
+
282
+ def test_shape_fill_images_are_exposed_as_slide_marks(self):
283
+ data = {
284
+ 'images': [{
285
+ 'media': 'ppt/media/fill.png',
286
+ 'fullscreen': False,
287
+ 'boxes': [{
288
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
289
+ 'parts': ['ppt/slides/slide2.xml'],
290
+ }],
291
+ }],
292
+ }
293
+
294
+ marks = slide_image_marks(data)
295
+
296
+ self.assertEqual(marks['ppt/slides/slide2.xml'], [{
297
+ 'media': 'ppt/media/fill.png',
298
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
299
+ }])
300
+
301
+ def test_form3_layouts_include_instance_image_fills(self):
302
+ layouts = []
303
+ shapes = []
304
+ for index in range(1, 4):
305
+ part = 'ppt/slideLayouts/slideLayout%d.xml' % index
306
+ layouts.append({
307
+ 'part': part,
308
+ 'name': 'Layout %d' % index,
309
+ 'used_by_slides': 1,
310
+ })
311
+ shapes.append({
312
+ 'part': part,
313
+ 'layer': 'layout',
314
+ 'kind': 'sp',
315
+ 'box': {'x': 10, 'y': 10, 'w': 500, 'h': 100},
316
+ 'ph': {'type': 'title', 'idx': str(index)},
317
+ 'text': {'paragraphs': [{'runs': [{
318
+ 'text': 'Title %d' % index,
319
+ 'sz_px': 40,
320
+ }]}]},
321
+ })
322
+ data = {
323
+ 'canvas': {'px': [1920, 1080]},
324
+ 'form_hint': {'form': 3},
325
+ 'layouts': layouts,
326
+ 'images': [{
327
+ 'media': 'ppt/media/fill.png',
328
+ 'fullscreen': False,
329
+ 'boxes': [{
330
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
331
+ 'parts': ['ppt/slides/slide1.xml'],
332
+ }],
333
+ }],
334
+ 'media': [],
335
+ 'theme_topology': {
336
+ 'themes': ['single'],
337
+ 'per_master': [],
338
+ 'default': 'single',
339
+ },
340
+ 'reference_graph': {
341
+ 'master_of_layout': {},
342
+ 'layout_of_slide': {
343
+ 'ppt/slides/slide1.xml': 'ppt/slideLayouts/slideLayout1.xml',
344
+ },
345
+ },
346
+ 'background_composites': {},
347
+ }
348
+
349
+ with tempfile.TemporaryDirectory() as output_dir:
350
+ os.makedirs(os.path.join(output_dir, 'ref'))
351
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
352
+ encoding='utf-8') as stream:
353
+ import json
354
+ json.dump({'shapes': shapes}, stream)
355
+ archetypes, _, _ = draft_layouts(data, output_dir)
356
+
357
+ self.assertTrue(any(
358
+ slot.get('media') == 'ppt/media/fill.png'
359
+ for archetype in archetypes
360
+ for slot in archetype['slots']
361
+ ))
362
+
363
+ def test_shape_opacity_marks_opaque_fullscreen_media_as_overlay(self):
364
+ data = {
365
+ 'media': [{
366
+ 'media': 'ppt/media/overlay.png',
367
+ 'out': 'media-out/overlay.png',
368
+ }],
369
+ 'images': [{
370
+ 'media': 'ppt/media/overlay.png',
371
+ 'fullscreen': True,
372
+ }],
373
+ }
374
+ shapes = [{
375
+ 'kind': 'pic',
376
+ 'media': 'ppt/media/overlay.png',
377
+ 'w_pct': 100,
378
+ 'h_pct': 100,
379
+ 'opacity': 0.25,
380
+ }]
381
+
382
+ with tempfile.TemporaryDirectory() as output_dir:
383
+ media_dir = os.path.join(output_dir, 'media-out')
384
+ os.makedirs(media_dir)
385
+ try:
386
+ from PIL import Image
387
+ except ImportError:
388
+ self.skipTest('Pillow unavailable')
389
+ Image.new('RGB', (16, 9), (20, 40, 60)).save(
390
+ os.path.join(media_dir, 'overlay.png'))
391
+
392
+ overlays = fullscreen_overlay_media(data, output_dir, shapes)
393
+
394
+ self.assertEqual(overlays, {'ppt/media/overlay.png'})
395
+
396
+ def test_page_with_multiple_small_images_is_kept_for_logo_wall_judgment(self):
397
+ page = {
398
+ 'marks': [
399
+ {'media': 'ppt/media/logo-%d.png' % index,
400
+ 'box': {'x': index * 180, 'y': 300, 'w': 120, 'h': 100}}
401
+ for index in range(3)
402
+ ],
403
+ }
404
+
405
+ self.assertTrue(has_small_image_cluster([page], (1920, 1080)))
406
+ self.assertFalse(has_small_image_cluster([{
407
+ 'marks': page['marks'][:2],
408
+ }], (1920, 1080)))
409
+
410
+ def test_single_image_orphan_reuses_nearest_layout_without_new_archetype(self):
411
+ archetypes = [{
412
+ 'name': 'layout-1',
413
+ 'slots': [],
414
+ 'rep': 2,
415
+ '_source_layouts': ['ppt/slideLayouts/slideLayout1.xml'],
416
+ '_source_backgrounds': ['ppt/media/bg.png'],
417
+ '_text_n': 4,
418
+ }, {
419
+ 'name': 'layout-2',
420
+ 'slots': [],
421
+ 'rep': 8,
422
+ '_source_layouts': ['ppt/slideLayouts/slideLayout2.xml'],
423
+ '_source_backgrounds': ['ppt/media/other-bg.png'],
424
+ '_text_n': 12,
425
+ }]
426
+ pages = [{
427
+ 'part': 'ppt/slides/slide11.xml',
428
+ 'no': 11,
429
+ 'layout': 'ppt/slideLayouts/slideLayout1.xml',
430
+ 'rendered_bg': 'ppt/media/bg.png',
431
+ 'bg_media': None,
432
+ 'bg_color': None,
433
+ 'texts': [{}, {}, {}, {}, {}],
434
+ 'marks': [{
435
+ 'media': 'ppt/media/ornament.png',
436
+ 'box': {'x': 40, 'y': 120, 'w': 80, 'h': 240},
437
+ }],
438
+ }]
439
+
440
+ attach_leftover_image_marks(archetypes, pages, kept_parts=set())
441
+
442
+ self.assertEqual(len(archetypes), 2)
443
+ self.assertEqual(archetypes[0]['slots'], [{
444
+ 'role': 'logo',
445
+ 'type': 'pic',
446
+ 'sz': 0,
447
+ 'txt': '',
448
+ 'media': 'ppt/media/ornament.png',
449
+ 'box': [40, 120, 80, 240],
450
+ }])
451
+ self.assertEqual(archetypes[1]['slots'], [])
452
+
453
+ def test_contact_sheets_cover_all_candidates_without_truncation(self):
454
+ try:
455
+ from PIL import Image
456
+ except ImportError:
457
+ self.skipTest('Pillow unavailable')
458
+
459
+ with tempfile.TemporaryDirectory() as output_dir:
460
+ media_dir = os.path.join(output_dir, 'media-out')
461
+ lout_dir = os.path.join(output_dir, 'l-out')
462
+ os.makedirs(media_dir)
463
+ os.makedirs(lout_dir)
464
+ candidates = []
465
+ for index in range(13):
466
+ filename = 'image-%02d.png' % index
467
+ Image.new('RGBA', (8, 8), (index, 20, 30, 255)).save(
468
+ os.path.join(media_dir, filename))
469
+ candidates.append({
470
+ 'file': filename,
471
+ 'out': 'media-out/' + filename,
472
+ 'n': 1,
473
+ 'probe': {'w': 8, 'h': 8},
474
+ })
475
+
476
+ sheets = contact_sheets(output_dir, candidates, lout_dir)
477
+ legacy_exists = os.path.exists(os.path.join(lout_dir, 'contact-sheet.png'))
478
+
479
+ self.assertEqual(len(sheets), 2)
480
+ self.assertEqual(
481
+ [os.path.basename(path) for path in sheets],
482
+ ['contact-sheet-1.png', 'contact-sheet-2.png'],
483
+ )
484
+ self.assertTrue(legacy_exists)
485
+
486
+ def test_content_candidates_at_same_geometry_collapse_to_one_slot(self):
487
+ lines = resolve_asset_candidate_lines([
488
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
489
+ 'source_media: chart-a.png}',
490
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
491
+ 'source_media: chart-b.png}',
492
+ ], {
493
+ 'chart-a.png': 'content',
494
+ 'chart-b.png': 'content',
495
+ }, {})
496
+
497
+ self.assertEqual(lines, [
498
+ ' - {role: pic, box: [10, 20, 300, 200], type: pic}',
499
+ ])
500
+
501
+
502
+ if __name__ == '__main__':
503
+ unittest.main()
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for preserving per-container radii."""
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ from draft import emit_body, emit_frontmatter
8
+ from query import _recipe_css
9
+
10
+
11
+ def render_frontmatter(radii):
12
+ data = {
13
+ 'radii_census': radii,
14
+ 'spacing_candidates': {},
15
+ }
16
+ tokens = [('surface', {'hex': '#FFFFFF'})]
17
+
18
+ with tempfile.TemporaryDirectory() as output_dir:
19
+ emit_frontmatter(
20
+ data,
21
+ tokens,
22
+ [],
23
+ {},
24
+ [],
25
+ [],
26
+ output_dir,
27
+ )
28
+ with open(os.path.join(output_dir, 'frontmatter.yaml'), encoding='utf-8') as stream:
29
+ return stream.read()
30
+
31
+
32
+ def render_body():
33
+ data = {
34
+ 'canvas': {'px': [1920, 1080]},
35
+ 'form_hint': {'form': 2},
36
+ 'counts': {'slides': 1},
37
+ }
38
+
39
+ with tempfile.TemporaryDirectory() as output_dir:
40
+ emit_body(
41
+ data,
42
+ [],
43
+ [],
44
+ {},
45
+ [],
46
+ [],
47
+ [],
48
+ {},
49
+ output_dir,
50
+ )
51
+ with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
52
+ return stream.read()
53
+
54
+
55
+ class RoundedContractTest(unittest.TestCase):
56
+ def test_multiple_radius_tiers_are_not_collapsed_into_one_card_token(self):
57
+ frontmatter = render_frontmatter(
58
+ [
59
+ {'px': 6.9, 'n': 2},
60
+ {'px': 11.9, 'n': 2},
61
+ {'px': 14.4, 'n': 3},
62
+ ]
63
+ )
64
+
65
+ self.assertNotIn('rounded:', frontmatter)
66
+
67
+ def test_single_radius_tier_can_remain_a_global_token(self):
68
+ frontmatter = render_frontmatter([{'px': 12, 'n': 9}])
69
+
70
+ self.assertIn('rounded:\n card: 12px', frontmatter)
71
+
72
+ def test_rare_rounded_exceptions_do_not_override_a_zero_radius_majority(self):
73
+ frontmatter = render_frontmatter(
74
+ [
75
+ {'px': 0, 'n': 241},
76
+ {'px': 3.4, 'n': 4},
77
+ {'px': 6.9, 'n': 3},
78
+ {'px': 50.5, 'n': 1},
79
+ ]
80
+ )
81
+
82
+ self.assertNotIn('rounded:', frontmatter)
83
+
84
+ def test_generated_usage_defaults_unspecified_container_radius_to_zero(self):
85
+ body = render_body()
86
+
87
+ self.assertIn('没有 `border-radius` 就按 `0`', body)
88
+ self.assertIn('不得自行补圆角', body)
89
+
90
+ def test_recipe_does_not_promote_one_rounded_exception_to_the_whole_group(self):
91
+ css = _recipe_css(
92
+ {'type': 'solid', 'color': {'hex': '#FFFFFF'}},
93
+ None,
94
+ [0] * 70 + [50.5],
95
+ None,
96
+ )
97
+
98
+ self.assertFalse(any('border-radius' in declaration for declaration in css))
99
+
100
+ def test_recipe_keeps_a_radius_shared_by_the_whole_group(self):
101
+ css = _recipe_css(
102
+ {'type': 'solid', 'color': {'hex': '#FFFFFF'}},
103
+ None,
104
+ [6.9] * 17,
105
+ None,
106
+ )
107
+
108
+ self.assertIn('border-radius: 6.9px', css)
109
+
110
+
111
+ if __name__ == '__main__':
112
+ unittest.main()