@bendyline/squisq 1.5.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  6. package/dist/chunk-3E5F2XMR.js.map +1 -0
  7. package/dist/chunk-4X3JQXNM.js +35 -0
  8. package/dist/chunk-4X3JQXNM.js.map +1 -0
  9. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  10. package/dist/chunk-5DKBFXPY.js.map +1 -0
  11. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  12. package/dist/chunk-5J4HKRJF.js.map +1 -0
  13. package/dist/chunk-5UYI3RJB.js +2503 -0
  14. package/dist/chunk-5UYI3RJB.js.map +1 -0
  15. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  16. package/dist/chunk-66QO7XFB.js.map +1 -0
  17. package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
  18. package/dist/chunk-6PJRB2CF.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-FVJUUTEM.js +74 -0
  22. package/dist/chunk-FVJUUTEM.js.map +1 -0
  23. package/dist/{chunk-5TYS5V3G.js → chunk-GF3BVNLU.js} +4127 -3538
  24. package/dist/chunk-GF3BVNLU.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
  30. package/dist/chunk-LYNJEOAC.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
  36. package/dist/chunk-QTSJOY54.js.map +1 -0
  37. package/dist/chunk-QVANWDPH.js +2358 -0
  38. package/dist/chunk-QVANWDPH.js.map +1 -0
  39. package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  40. package/dist/chunk-R2GC6RLD.js.map +1 -0
  41. package/dist/chunk-R2YZTCLO.js +292 -0
  42. package/dist/chunk-R2YZTCLO.js.map +1 -0
  43. package/dist/chunk-S7A5D7ZJ.js +174 -0
  44. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  45. package/dist/chunk-T3TI7GSZ.js +884 -0
  46. package/dist/chunk-T3TI7GSZ.js.map +1 -0
  47. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  48. package/dist/chunk-TUWMK62B.js.map +1 -0
  49. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  50. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +127 -33
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +246 -80
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  97. package/src/__tests__/dataTable.test.ts +25 -15
  98. package/src/__tests__/diagramBlock.test.ts +3 -3
  99. package/src/__tests__/diagramText.test.ts +26 -0
  100. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  101. package/src/__tests__/drawingBlock.test.ts +3 -3
  102. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  103. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  104. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  105. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  106. package/src/__tests__/getLayers.test.ts +32 -18
  107. package/src/__tests__/getLayersFallback.test.ts +8 -8
  108. package/src/__tests__/imageEditVersions.test.ts +2 -0
  109. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  110. package/src/__tests__/layoutBlock.test.ts +3 -3
  111. package/src/__tests__/majorApiTypes.test.ts +18 -0
  112. package/src/__tests__/markdown.test.ts +41 -0
  113. package/src/__tests__/matchFontFamily.test.ts +114 -0
  114. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  115. package/src/__tests__/materializeTestUtils.ts +17 -0
  116. package/src/__tests__/narrationAlign.test.ts +150 -0
  117. package/src/__tests__/narrationFeatures.test.ts +71 -0
  118. package/src/__tests__/narrationNuclei.test.ts +45 -0
  119. package/src/__tests__/narrationPacing.test.ts +189 -0
  120. package/src/__tests__/narrationScript.test.ts +149 -0
  121. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  122. package/src/__tests__/narrationSidecar.test.ts +88 -0
  123. package/src/__tests__/narrationSyllables.test.ts +65 -0
  124. package/src/__tests__/narrationTestSignals.ts +167 -0
  125. package/src/__tests__/narrationVad.test.ts +66 -0
  126. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  127. package/src/__tests__/rootExports.test.ts +18 -0
  128. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  129. package/src/__tests__/shapeGeometry.test.ts +12 -0
  130. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  131. package/src/__tests__/templateInputs.test.ts +69 -0
  132. package/src/__tests__/templateMetadata.test.ts +2 -1
  133. package/src/__tests__/templates.test.ts +143 -17
  134. package/src/__tests__/themeCompile.test.ts +49 -22
  135. package/src/__tests__/transformProvenance.test.ts +160 -0
  136. package/src/__tests__/transformV2.test.ts +40 -14
  137. package/src/__tests__/treeviewBattle.test.ts +76 -0
  138. package/src/__tests__/treeviewDetect.test.ts +168 -0
  139. package/src/__tests__/treeviewParse.test.ts +118 -0
  140. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  141. package/src/__tests__/treeviewRender.test.ts +91 -0
  142. package/src/__tests__/versions.test.ts +44 -0
  143. package/src/doc/applyNarrationTiming.ts +244 -0
  144. package/src/doc/asciiDiagram/chars.ts +210 -0
  145. package/src/doc/asciiDiagram/detect.ts +175 -0
  146. package/src/doc/asciiDiagram/index.ts +38 -0
  147. package/src/doc/asciiDiagram/mapping.ts +225 -0
  148. package/src/doc/asciiDiagram/parse.ts +1095 -0
  149. package/src/doc/asciiDiagram/render.ts +963 -0
  150. package/src/doc/asciiDiagram/repair.ts +400 -0
  151. package/src/doc/asciiDiagram/types.ts +65 -0
  152. package/src/doc/asciiTimeline/detect.ts +138 -0
  153. package/src/doc/asciiTimeline/index.ts +26 -0
  154. package/src/doc/asciiTimeline/mapping.ts +43 -0
  155. package/src/doc/asciiTimeline/parse.ts +870 -0
  156. package/src/doc/asciiTimeline/render.ts +427 -0
  157. package/src/doc/asciiTimeline/types.ts +73 -0
  158. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  159. package/src/doc/audioMapping.ts +60 -46
  160. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  161. package/src/doc/docToMarkdown.ts +70 -2
  162. package/src/doc/index.ts +76 -4
  163. package/src/doc/markdownToDoc.ts +135 -1
  164. package/src/doc/materializeBlockLayers.ts +304 -0
  165. package/src/doc/mediaAnnotations.ts +20 -0
  166. package/src/doc/resolveDocTheme.ts +8 -7
  167. package/src/doc/templateInputs.ts +185 -2
  168. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  169. package/src/doc/templates/diagramBlock.ts +237 -38
  170. package/src/doc/templates/fallbackBlock.ts +1 -1
  171. package/src/doc/templates/index.ts +134 -279
  172. package/src/doc/templates/inputDescriptors.ts +20 -1
  173. package/src/doc/templates/metadata.ts +10 -0
  174. package/src/doc/templates/persistentLayers.ts +12 -126
  175. package/src/doc/templates/registry.ts +88 -0
  176. package/src/doc/templates/templateNames.ts +22 -0
  177. package/src/doc/templates/timelineBlock.ts +613 -0
  178. package/src/doc/templates/treeBlock.ts +131 -0
  179. package/src/doc/treeview/chars.ts +72 -0
  180. package/src/doc/treeview/detect.ts +145 -0
  181. package/src/doc/treeview/index.ts +30 -0
  182. package/src/doc/treeview/mapping.ts +95 -0
  183. package/src/doc/treeview/parse.ts +201 -0
  184. package/src/doc/treeview/render.ts +41 -0
  185. package/src/doc/treeview/types.ts +43 -0
  186. package/src/doc/utils/applyRenderStyle.ts +2 -9
  187. package/src/doc/utils/diagramText.ts +78 -0
  188. package/src/doc/utils/shapeGeometry.ts +67 -5
  189. package/src/doc/validate.ts +1 -1
  190. package/src/generate/templateMapper.ts +7 -0
  191. package/src/imageEdit/export.ts +6 -5
  192. package/src/imageEdit/versionPaths.ts +13 -26
  193. package/src/imageEdit/versions.ts +21 -100
  194. package/src/index.ts +1 -0
  195. package/src/internal/immutable.ts +58 -0
  196. package/src/jsonForm/index.ts +9 -1
  197. package/src/jsonForm/pointer.ts +68 -23
  198. package/src/markdown/attrTokens.ts +7 -5
  199. package/src/markdown/index.ts +1 -0
  200. package/src/markdown/stringify.ts +4 -9
  201. package/src/markdown/utils.ts +36 -14
  202. package/src/narration/align.ts +514 -0
  203. package/src/narration/features.ts +231 -0
  204. package/src/narration/index.ts +86 -0
  205. package/src/narration/nuclei.ts +118 -0
  206. package/src/narration/pacing.ts +211 -0
  207. package/src/narration/script.ts +239 -0
  208. package/src/narration/session.ts +100 -0
  209. package/src/narration/sidecar.ts +187 -0
  210. package/src/narration/syllables.ts +85 -0
  211. package/src/narration/trace.ts +50 -0
  212. package/src/narration/types.ts +299 -0
  213. package/src/narration/vad.ts +131 -0
  214. package/src/recommend/numberHighlight.ts +25 -0
  215. package/src/recommend/templates.ts +108 -12
  216. package/src/schemas/BlockTemplates.ts +156 -7
  217. package/src/schemas/Doc.ts +72 -9
  218. package/src/schemas/Media.ts +12 -2
  219. package/src/schemas/Theme.ts +45 -25
  220. package/src/schemas/Viewport.ts +2 -2
  221. package/src/schemas/fontStacks.ts +79 -0
  222. package/src/schemas/themeCompile.ts +15 -0
  223. package/src/schemas/themeConstants.ts +2 -0
  224. package/src/schemas/themeLibrary.ts +25 -19
  225. package/src/schemas/themeValidator.ts +1 -1
  226. package/src/spatial/Geohash.ts +0 -3
  227. package/src/storage/ContentContainer.ts +17 -9
  228. package/src/storage/ScopedContentContainer.ts +45 -15
  229. package/src/transform/applyTransform.ts +5 -5
  230. package/src/transform/index.ts +3 -1
  231. package/src/transform/registry.ts +169 -49
  232. package/src/transform/templateSelector.ts +21 -2
  233. package/src/transform/timingAllocator.ts +152 -21
  234. package/src/transform/types.ts +14 -1
  235. package/src/versions/gitignore.ts +34 -0
  236. package/src/versions/index.ts +2 -0
  237. package/src/versions/operations.ts +18 -104
  238. package/src/versions/paths.ts +13 -28
  239. package/src/versions/snapshotStore.ts +202 -0
  240. package/src/versions/types.ts +3 -1
  241. package/dist/chunk-5TYS5V3G.js.map +0 -1
  242. package/dist/chunk-A4O7GIWE.js.map +0 -1
  243. package/dist/chunk-BEQODJWV.js.map +0 -1
  244. package/dist/chunk-DLXZMS5K.js.map +0 -1
  245. package/dist/chunk-GPLTCSXO.js.map +0 -1
  246. package/dist/chunk-HWVFJAAH.js.map +0 -1
  247. package/dist/chunk-IMSCRLLV.js.map +0 -1
  248. package/dist/chunk-JELIRVDP.js +0 -24
  249. package/dist/chunk-JELIRVDP.js.map +0 -1
  250. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  251. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  252. package/dist/chunk-LTN6I7QW.js.map +0 -1
  253. package/dist/chunk-P3K7NLHF.js.map +0 -1
  254. package/dist/chunk-T5UK6YOB.js.map +0 -1
  255. package/dist/chunk-UY7KGQ5R.js +0 -192
  256. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  257. package/dist/chunk-VLJJHDAC.js +0 -218
  258. package/dist/chunk-VLJJHDAC.js.map +0 -1
  259. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  260. package/dist/story/index.d.ts +0 -1801
  261. package/dist/story/index.js +0 -279
  262. package/src/doc/getLayers.ts +0 -195
  263. /package/dist/{story → narration}/index.js.map +0 -0
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
- expandTemplateBlock,
4
3
  expandDocBlocks,
5
4
  getAvailableTemplates,
6
5
  hasTemplate,
@@ -10,11 +9,41 @@ import {
10
9
  VIEWPORT_PRESETS,
11
10
  } from '../doc/templates/index';
12
11
  import type { TemplateBlock } from '../schemas/BlockTemplates';
13
- import type { TextLayer } from '../schemas/Doc';
14
- import { getLayers } from '../doc/getLayers';
12
+ import type { Block, Layer, TextLayer } from '../schemas/Doc';
13
+ import type { RuntimeTemplateRegistry } from '../doc/templates/index';
14
+ import { materializeBlockLayersWithRuntime } from '../doc/materializeBlockLayers';
15
+ import { materializeLayers } from './materializeTestUtils';
15
16
  import { markdownToDoc } from '../doc/markdownToDoc';
16
17
  import { parseMarkdown } from '../markdown/parse';
17
18
 
19
+ function materializeTemplateForTest(
20
+ block: TemplateBlock,
21
+ context: ReturnType<typeof createTemplateContext>,
22
+ registry: RuntimeTemplateRegistry = templateRegistry as unknown as RuntimeTemplateRegistry,
23
+ ): Block {
24
+ const { layers } = materializeBlockLayersWithRuntime(
25
+ block,
26
+ {
27
+ theme: context.theme,
28
+ viewport: context.viewport,
29
+ persistentLayers: false,
30
+ blockIndex: context.blockIndex,
31
+ totalBlocks: context.totalBlocks,
32
+ failureMode: 'empty',
33
+ },
34
+ { registry, templateContext: context, applyRenderStyle: false },
35
+ );
36
+ return {
37
+ id: block.id,
38
+ startTime: 0,
39
+ duration: block.duration,
40
+ audioSegment: block.audioSegment,
41
+ ...(layers.length > 0 ? { layers } : {}),
42
+ transition: block.transition,
43
+ template: block.template,
44
+ };
45
+ }
46
+
18
47
  describe('templateRegistry', () => {
19
48
  it('contains all expected templates', () => {
20
49
  const expected = [
@@ -66,7 +95,7 @@ describe('hasTemplate', () => {
66
95
  });
67
96
  });
68
97
 
69
- describe('expandTemplateBlock', () => {
98
+ describe('canonical template materialization', () => {
70
99
  it('expands titleSlide template into layers', () => {
71
100
  const block: TemplateBlock = {
72
101
  template: 'title',
@@ -77,7 +106,7 @@ describe('expandTemplateBlock', () => {
77
106
  subtitle: 'Test Subtitle',
78
107
  };
79
108
  const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
80
- const result = expandTemplateBlock(block, context);
109
+ const result = materializeTemplateForTest(block, context);
81
110
 
82
111
  expect(result.id).toBe('title-1');
83
112
  expect(result.duration).toBe(10);
@@ -97,7 +126,7 @@ describe('expandTemplateBlock', () => {
97
126
  body: 'I love building software platforms.',
98
127
  };
99
128
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
100
- const result = expandTemplateBlock(block, context);
129
+ const result = materializeTemplateForTest(block, context);
101
130
  const layers = result.layers ?? [];
102
131
  const imageLayer = layers.find((l) => l.type === 'image');
103
132
  expect(imageLayer).toBeDefined();
@@ -130,7 +159,7 @@ describe('expandTemplateBlock', () => {
130
159
  title: 'Sized',
131
160
  };
132
161
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
133
- const result = expandTemplateBlock(block, context);
162
+ const result = materializeTemplateForTest(block, context);
134
163
  const imageLayer = (result.layers ?? []).find((l) => l.type === 'image');
135
164
  expect(imageLayer).toBeDefined();
136
165
  expect((imageLayer!.content as { fit?: string }).fit).toBe('contain');
@@ -153,7 +182,7 @@ describe('expandTemplateBlock', () => {
153
182
  body: 'A list of things.',
154
183
  };
155
184
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
156
- const result = expandTemplateBlock(block, context);
185
+ const result = materializeTemplateForTest(block, context);
157
186
  const layers = result.layers ?? [];
158
187
  const imageLayer = layers.find((l) => l.type === 'image');
159
188
  expect(imageLayer).toBeDefined();
@@ -179,7 +208,7 @@ describe('expandTemplateBlock', () => {
179
208
  description: 'drop in salmon',
180
209
  };
181
210
  const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
182
- const result = expandTemplateBlock(block, context);
211
+ const result = materializeTemplateForTest(block, context);
183
212
 
184
213
  expect((result.layers ?? []).length).toBeGreaterThan(0);
185
214
  // Should have at least a shape background + text layers
@@ -195,7 +224,7 @@ describe('expandTemplateBlock', () => {
195
224
  audioSegment: 0,
196
225
  } as unknown as TemplateBlock;
197
226
  const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
198
- const result = expandTemplateBlock(block, context);
227
+ const result = materializeTemplateForTest(block, context);
199
228
  expect(result.layers ?? []).toEqual([]);
200
229
  });
201
230
 
@@ -241,7 +270,7 @@ describe('expandTemplateBlock', () => {
241
270
  leftValue: 50,
242
271
  rightValue: 50,
243
272
  } as unknown as TemplateBlock;
244
- const result = expandTemplateBlock(block, context);
273
+ const result = materializeTemplateForTest(block, context);
245
274
  expect(result.id).toBe(`test-${name}`);
246
275
  expect(result.layers ?? []).toBeInstanceOf(Array);
247
276
  // Each template should produce at least 1 layer
@@ -265,7 +294,7 @@ describe('expandTemplateBlock', () => {
265
294
  };
266
295
 
267
296
  const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
268
- const result = expandTemplateBlock(block, context);
297
+ const result = materializeTemplateForTest(block, context);
269
298
  const layers = result.layers ?? [];
270
299
  const captionLayer = layers.find(
271
300
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
@@ -301,10 +330,10 @@ describe('expandTemplateBlock', () => {
301
330
  };
302
331
 
303
332
  const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
304
- const imageCaption = (expandTemplateBlock(imageBlock, context).layers ?? []).find(
333
+ const imageCaption = (materializeTemplateForTest(imageBlock, context).layers ?? []).find(
305
334
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
306
335
  );
307
- const videoCaption = (expandTemplateBlock(videoBlock, context).layers ?? []).find(
336
+ const videoCaption = (materializeTemplateForTest(videoBlock, context).layers ?? []).find(
308
337
  (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
309
338
  );
310
339
 
@@ -370,6 +399,85 @@ describe('expandDocBlocks', () => {
370
399
  expect(result[0].startTime).toBe(0);
371
400
  });
372
401
 
402
+ it('never mutates or aliases raw blocks and persistent layers', () => {
403
+ const rawLayer: Layer = {
404
+ type: 'shape',
405
+ id: 'raw-shape',
406
+ content: { shape: 'rect', fill: '#112233' },
407
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
408
+ };
409
+ const rawBlock: Block = {
410
+ id: 'raw',
411
+ startTime: 99,
412
+ duration: 6,
413
+ audioSegment: 0,
414
+ layers: [rawLayer],
415
+ };
416
+ const persistentLayer: Layer = {
417
+ type: 'shape',
418
+ id: 'persistent',
419
+ content: { shape: 'rect', fill: '#445566' },
420
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
421
+ };
422
+ const beforeBlock = JSON.stringify(rawBlock);
423
+ const beforePersistent = JSON.stringify(persistentLayer);
424
+
425
+ const [expanded] = expandDocBlocks([rawBlock], {
426
+ persistentLayers: { bottomLayers: [persistentLayer] },
427
+ audioSegments: [{ startTime: 10, duration: 6 }],
428
+ });
429
+
430
+ expect(JSON.stringify(rawBlock)).toBe(beforeBlock);
431
+ expect(JSON.stringify(persistentLayer)).toBe(beforePersistent);
432
+ expect(expanded).not.toBe(rawBlock);
433
+ expect(expanded.layers?.[0]).not.toBe(persistentLayer);
434
+ expect(expanded.layers?.[1]).not.toBe(rawLayer);
435
+
436
+ (expanded.layers?.[0] as Layer & { content: { fill: string } }).content.fill = '#ffffff';
437
+ expect(JSON.stringify(persistentLayer)).toBe(beforePersistent);
438
+ expect(expandDocBlocks([rawBlock])[0].layers).toHaveLength(1);
439
+ });
440
+
441
+ it('protects template inputs from mutating registry implementations', () => {
442
+ const source: TemplateBlock = {
443
+ template: 'title',
444
+ id: 'title-safe',
445
+ duration: 5,
446
+ audioSegment: 0,
447
+ title: 'Original',
448
+ };
449
+ const sharedLayer: Layer = {
450
+ type: 'shape',
451
+ id: 'shared-layer',
452
+ content: { shape: 'rect', fill: '#112233' },
453
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
454
+ };
455
+ const registry: RuntimeTemplateRegistry = {
456
+ title: (input) => {
457
+ (input as { title: string }).title = 'Mutated';
458
+ return [sharedLayer];
459
+ },
460
+ };
461
+ const first = materializeTemplateForTest(
462
+ source,
463
+ createTemplateContext(DEFAULT_THEME, 0, 1),
464
+ registry,
465
+ );
466
+ const second = materializeTemplateForTest(
467
+ source,
468
+ createTemplateContext(DEFAULT_THEME, 0, 1),
469
+ registry,
470
+ );
471
+ expect(source.title).toBe('Original');
472
+ expect(first.layers?.[0]).not.toBe(sharedLayer);
473
+ expect(first.layers?.[0]).not.toBe(second.layers?.[0]);
474
+ (first.layers?.[0] as Layer & { content: { fill: string } }).content.fill = '#ffffff';
475
+ expect((sharedLayer as Layer & { content: { fill: string } }).content.fill).toBe('#112233');
476
+ expect((second.layers?.[0] as Layer & { content: { fill: string } }).content.fill).toBe(
477
+ '#112233',
478
+ );
479
+ });
480
+
373
481
  it('supports landscape and portrait viewports', () => {
374
482
  const blocks: TemplateBlock[] = [
375
483
  { template: 'title', id: 'title-1', duration: 5, audioSegment: 0, title: 'Test' },
@@ -533,7 +641,7 @@ describe('DEFAULT_THEME', () => {
533
641
  });
534
642
  });
535
643
 
536
- describe('inline-param coercion → getLayers', () => {
644
+ describe('inline-param coercion → materializeBlockLayers', () => {
537
645
  function firstBlock(md: string) {
538
646
  return markdownToDoc(parseMarkdown(md), { generateCoverBlock: false }).blocks[0];
539
647
  }
@@ -547,7 +655,7 @@ describe('inline-param coercion → getLayers', () => {
547
655
  mapStyle: 'road',
548
656
  });
549
657
 
550
- const layers = getLayers(block, {});
658
+ const layers = materializeLayers(block, {});
551
659
  const mapLayer = layers.find((l) => l.type === 'map');
552
660
  expect(mapLayer).toBeDefined();
553
661
  // Coerced center/zoom flow into the map layer's content — not empty strings.
@@ -558,7 +666,7 @@ describe('inline-param coercion → getLayers', () => {
558
666
 
559
667
  it('renders a twoColumn from pure-inline labeled-pair annotation', () => {
560
668
  const block = firstBlock('# Coffee {[twoColumn left="Espresso|Bold" right="Filter|Smooth"]}\n');
561
- const layers = getLayers(block, {});
669
+ const layers = materializeLayers(block, {});
562
670
  // Guard requires both labels — a non-empty layer set proves coercion worked.
563
671
  expect(layers.length).toBeGreaterThan(0);
564
672
  const texts = layers
@@ -572,6 +680,24 @@ describe('inline-param coercion → getLayers', () => {
572
680
  });
573
681
  });
574
682
 
683
+ describe('shared template materialization', () => {
684
+ it('keeps on-demand and timed expansion layer output in lockstep', () => {
685
+ const block: TemplateBlock = {
686
+ template: 'title',
687
+ id: 'shared-title',
688
+ duration: 7,
689
+ audioSegment: 0,
690
+ title: 'One materializer',
691
+ subtitle: 'Two orchestration modes',
692
+ };
693
+
694
+ const onDemand = materializeLayers(block, { theme: DEFAULT_THEME });
695
+ const [expanded] = expandDocBlocks([block], { theme: DEFAULT_THEME });
696
+
697
+ expect(expanded.layers).toEqual(onDemand);
698
+ });
699
+ });
700
+
575
701
  describe('VIEWPORT_PRESETS', () => {
576
702
  it('has landscape and portrait presets', () => {
577
703
  expect(VIEWPORT_PRESETS).toHaveProperty('landscape');
@@ -6,7 +6,8 @@ import {
6
6
  STARTER_THEME,
7
7
  deriveColorPalette,
8
8
  } from '../schemas/themeCompile.js';
9
- import { registerTheme, unregisterTheme, getRegisteredThemes } from '../schemas/Theme.js';
9
+ import { createThemeRegistry } from '../schemas/Theme.js';
10
+ import type { Theme } from '../schemas/Theme.js';
10
11
  import { resolveTheme, DEFAULT_THEME, THEMES } from '../schemas/themeLibrary.js';
11
12
  import { resolveFontFamily } from '../schemas/fontStacks.js';
12
13
 
@@ -132,46 +133,72 @@ describe('parseTheme / serializeTheme round-trip', () => {
132
133
  });
133
134
  });
134
135
 
135
- describe('registerTheme / resolveTheme', () => {
136
- it('registered theme takes precedence over built-in with same id', () => {
136
+ describe('caller-owned theme registries', () => {
137
+ it('an explicit registry can override a built-in without process-global leakage', () => {
137
138
  const overlay = compileTheme({
138
139
  id: 'standard',
139
140
  name: 'Standard Override',
140
141
  seedColors: { primary: '#ff0000' },
141
142
  });
142
- registerTheme(overlay);
143
- try {
144
- const resolved = resolveTheme('standard');
145
- expect(resolved.name).toBe('Standard Override');
146
- expect(resolved.colors.primary).toBe('#ff0000');
147
- } finally {
148
- unregisterTheme('standard');
149
- }
150
- // After cleanup, built-in is restored
143
+ const registry = createThemeRegistry([overlay]);
144
+ const resolved = resolveTheme('standard', registry);
145
+ expect(resolved.name).toBe('Standard Override');
146
+ expect(resolved.colors.primary).toBe('#ff0000');
151
147
  expect(resolveTheme('standard').name).toBe('Standard Light');
152
148
  });
153
149
 
154
- it('registered custom-id theme is resolvable via Doc.themeId', () => {
150
+ it('resolves a custom id only through the registry that owns it', () => {
155
151
  const custom = compileTheme({
156
152
  id: 'custom-test-1',
157
153
  name: 'Custom Test One',
158
154
  seedColors: { primary: '#00aabb' },
159
155
  });
160
- registerTheme(custom);
161
- try {
162
- const resolved = resolveTheme('custom-test-1');
163
- expect(resolved.id).toBe('custom-test-1');
164
- expect(resolved.name).toBe('Custom Test One');
165
- expect(getRegisteredThemes().some((t) => t.id === 'custom-test-1')).toBe(true);
166
- } finally {
167
- unregisterTheme('custom-test-1');
168
- }
156
+ const registry = createThemeRegistry([custom]);
157
+ expect(resolveTheme('custom-test-1', registry).name).toBe('Custom Test One');
158
+ expect(resolveTheme('custom-test-1')).toBe(DEFAULT_THEME);
159
+ expect(registry.list().map((theme) => theme.id)).toContain('custom-test-1');
169
160
  });
170
161
 
171
162
  it('resolveTheme falls back to default for unknown ids', () => {
172
163
  expect(resolveTheme('nonexistent-theme')).toBe(DEFAULT_THEME);
173
164
  expect(resolveTheme(undefined)).toBe(DEFAULT_THEME);
174
165
  });
166
+
167
+ it('validates and stores an immutable snapshot', () => {
168
+ const registry = createThemeRegistry();
169
+ const source = compileTheme({
170
+ id: 'isolated-theme',
171
+ name: 'Isolated',
172
+ seedColors: { primary: '#336699' },
173
+ });
174
+ registry.register(source);
175
+ source.name = 'Changed later';
176
+ source.colors.primary = '#ffffff';
177
+
178
+ const stored = registry.get('isolated-theme')!;
179
+ expect(stored.name).toBe('Isolated');
180
+ expect(stored.colors.primary).toBe('#336699');
181
+ expect(Object.isFrozen(stored)).toBe(true);
182
+ expect(Object.isFrozen(stored.colors)).toBe(true);
183
+ expect(() => registry.register({ id: 'invalid' } as Theme)).toThrow(/invalid theme/i);
184
+ });
185
+
186
+ it('supports caller-owned registries with isolated state', () => {
187
+ const source = compileTheme({
188
+ id: 'tenant-theme',
189
+ name: 'Tenant',
190
+ seedColors: { primary: '#123456' },
191
+ });
192
+ const tenantA = createThemeRegistry([source]);
193
+ const tenantB = createThemeRegistry();
194
+ expect(tenantA.get('tenant-theme')?.name).toBe('Tenant');
195
+ expect(tenantB.get('tenant-theme')).toBeUndefined();
196
+ });
197
+
198
+ it('freezes built-ins so lookup ergonomics do not expose mutable global state', () => {
199
+ expect(Object.isFrozen(THEMES)).toBe(true);
200
+ expect(Object.isFrozen(resolveTheme('cinematic'))).toBe(true);
201
+ });
175
202
  });
176
203
 
177
204
  describe('FontFamily resolution', () => {
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown } from '../markdown/parse';
3
+ import { markdownToDoc, flattenRenderableBlocks } from '../doc/markdownToDoc';
4
+ import { applyTransform } from '../transform/applyTransform';
5
+ import { allocateTiming } from '../transform/timingAllocator';
6
+ import { isTemplateBlock, type TemplateBlock } from '../schemas/BlockTemplates';
7
+ import type { Block, Doc } from '../schemas/Doc';
8
+
9
+ const MD = `# The Numbers
10
+
11
+ Revenue grew to 4,200 units in 2019 which was a big jump for the small team.
12
+ "We never expected this kind of growth," said the founder about the year.
13
+
14
+ # The Turning Point
15
+
16
+ By 2021 the company had 87 employees across three offices and a plan.
17
+ The expansion cost 1,500,000 dollars and took fourteen months to finish.
18
+
19
+ # The Result
20
+
21
+ Today the product ships to 40 countries and the team keeps growing steadily.
22
+ `;
23
+
24
+ function makeDoc(): Doc {
25
+ return markdownToDoc(parseMarkdown(MD));
26
+ }
27
+
28
+ /** Blocks a transform produced (positional transform-* ids). */
29
+ function transformedBlocks(doc: Doc): Block[] {
30
+ return doc.blocks.filter((b) => b.id.startsWith('transform-'));
31
+ }
32
+
33
+ describe('transform provenance', () => {
34
+ it('every extraction-derived slide carries a sourceBlockId from the input doc', () => {
35
+ const source = makeDoc();
36
+ const sourceIds = new Set(flattenRenderableBlocks(source.blocks).map((b) => b.id));
37
+ const { doc } = applyTransform(source, 'data-driven');
38
+
39
+ const produced = transformedBlocks(doc).filter(
40
+ (b) => b.id !== 'transform-intro' && b.id !== 'transform-outro',
41
+ );
42
+ expect(produced.length).toBeGreaterThan(0);
43
+ for (const block of produced) {
44
+ expect(block.sourceBlockId, `block ${block.id} missing provenance`).toBeTruthy();
45
+ expect(sourceIds.has(block.sourceBlockId!)).toBe(true);
46
+ }
47
+ });
48
+
49
+ it('sourceStartTime is SECONDS on the doc timeline, not a char offset (regression)', () => {
50
+ const source = makeDoc();
51
+ const sourceEnd = Math.max(
52
+ ...flattenRenderableBlocks(source.blocks).map((b) => b.startTime + b.duration),
53
+ );
54
+ const { doc } = applyTransform(source, 'data-driven');
55
+
56
+ for (const block of doc.blocks) {
57
+ const tb = block as unknown as TemplateBlock;
58
+ if (typeof tb.sourceStartTime !== 'number') continue;
59
+ // The old bug put char offsets (hundreds) here; real seconds must
60
+ // stay inside the source timeline.
61
+ expect(tb.sourceStartTime).toBeLessThanOrEqual(sourceEnd + 1);
62
+ expect(tb.sourceStartTime).toBeGreaterThanOrEqual(0);
63
+ // The raw char offset is preserved separately as provenance.
64
+ expect(typeof tb.sourceCharOffset).toBe('number');
65
+ }
66
+ });
67
+
68
+ it('narration-timed source blocks anchor their slides inside the source range', () => {
69
+ const source = makeDoc();
70
+ // Simulate applyNarrationTiming: give the source blocks recorded times.
71
+ const flat = flattenRenderableBlocks(source.blocks);
72
+ const bounds = [0, 20, 45, 70];
73
+ flat.forEach((block, i) => {
74
+ block.startTime = bounds[i] ?? 70;
75
+ block.duration = (bounds[i + 1] ?? 70) - (bounds[i] ?? 70);
76
+ });
77
+ source.duration = 70;
78
+
79
+ const { doc } = applyTransform(source, 'data-driven');
80
+ const bySource = new Map(flat.map((b) => [b.id, b]));
81
+ let anchoredChecked = 0;
82
+ for (const block of transformedBlocks(doc)) {
83
+ // Only ANCHORED slides (real narration seconds) must sit inside
84
+ // their source range; floats (section headers, images) are placed
85
+ // approximately by design.
86
+ const tb = block as unknown as TemplateBlock;
87
+ if (!block.sourceBlockId || typeof tb.sourceStartTime !== 'number') continue;
88
+ const origin = bySource.get(block.sourceBlockId);
89
+ if (!origin || origin.duration <= 0) continue;
90
+ expect(block.startTime).toBeGreaterThanOrEqual(origin.startTime - 1e-6);
91
+ expect(block.startTime).toBeLessThan(origin.startTime + origin.duration + 1e-6);
92
+ anchoredChecked++;
93
+ }
94
+ expect(anchoredChecked).toBeGreaterThan(0);
95
+ });
96
+
97
+ it('transformed timelines stay contiguous (no uncovered narration seconds)', () => {
98
+ const source = makeDoc();
99
+ const { doc } = applyTransform(source, 'data-driven');
100
+ const sorted = [...doc.blocks].sort((a, b) => a.startTime - b.startTime);
101
+ for (let i = 0; i < sorted.length - 1; i++) {
102
+ const end = sorted[i].startTime + sorted[i].duration;
103
+ expect(end).toBeCloseTo(sorted[i + 1].startTime, 5);
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('allocateTiming fallback', () => {
109
+ it('keeps the historical rescale behavior when no anchors exist', () => {
110
+ const floats: TemplateBlock[] = [
111
+ { template: 'sectionHeader', id: 'a', duration: 5, audioSegment: 0, title: 'A' },
112
+ { template: 'sectionHeader', id: 'b', duration: 5, audioSegment: 0, title: 'B' },
113
+ ];
114
+ const result = allocateTiming(floats, 20);
115
+ expect(result[0].startTime).toBe(0);
116
+ expect(result[0].duration).toBe(10);
117
+ expect(result[1].startTime).toBe(10);
118
+ expect(result[1].duration).toBe(10);
119
+ });
120
+
121
+ it('anchored blocks keep their positions; floats fill the gaps', () => {
122
+ const blocks: TemplateBlock[] = [
123
+ // A floating intro before the first anchor.
124
+ { template: 'title', id: 'intro', duration: 4, audioSegment: 0, title: 'T' },
125
+ {
126
+ template: 'statHighlight',
127
+ id: 's1',
128
+ duration: 6,
129
+ audioSegment: 0,
130
+ stat: '42',
131
+ description: 'd',
132
+ sourceStartTime: 10,
133
+ sourceDuration: 6,
134
+ },
135
+ {
136
+ template: 'statHighlight',
137
+ id: 's2',
138
+ duration: 6,
139
+ audioSegment: 0,
140
+ stat: '87',
141
+ description: 'd',
142
+ sourceStartTime: 30,
143
+ sourceDuration: 6,
144
+ },
145
+ ];
146
+ const result = allocateTiming(blocks, 40);
147
+ // Intro fills [0, 10) up to the first anchor.
148
+ expect(result[0].startTime).toBe(0);
149
+ expect(result[0].duration).toBeCloseTo(10, 5);
150
+ // Anchors sit at their narration positions; s1 holds until s2 starts.
151
+ expect(result[1].startTime).toBe(10);
152
+ expect(result[1].duration).toBeCloseTo(20, 5);
153
+ expect(result[2].startTime).toBe(30);
154
+ // The final block covers through the total duration.
155
+ expect(result[2].startTime + result[2].duration).toBeCloseTo(40, 5);
156
+ for (const b of result) {
157
+ expect(isTemplateBlock(b as unknown as TemplateBlock) || true).toBe(true);
158
+ }
159
+ });
160
+ });
@@ -1,10 +1,9 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
1
+ import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  applyTransform,
4
4
  resolveTransformStyle,
5
- registerTransformStyle,
6
- unregisterTransformStyle,
7
5
  getTransformStyleIds,
6
+ createTransformStyleRegistry,
8
7
  } from '../transform/index.js';
9
8
  import type { TransformStyleConfig } from '../transform/index.js';
10
9
  import type { Doc, Block } from '../schemas/Doc.js';
@@ -49,8 +48,6 @@ function sampleDoc(): Doc {
49
48
  }
50
49
 
51
50
  describe('transform style contract v2', () => {
52
- afterEach(() => unregisterTransformStyle('custom-test'));
53
-
54
51
  it('suggestedThemeId applies only when the doc declares no theme', () => {
55
52
  const { doc } = applyTransform(sampleDoc(), 'magazine');
56
53
  expect(doc.themeId).toBe('magazine');
@@ -88,23 +85,53 @@ describe('transform style contract v2', () => {
88
85
  expect(last.template).toBe('sectionHeader');
89
86
  });
90
87
 
91
- it('registerTransformStyle adds a resolvable style that wins over unknown fallback', () => {
88
+ it('resolves custom styles only through their caller-owned registry', () => {
92
89
  const custom: TransformStyleConfig = {
93
90
  ...resolveTransformStyle('minimal'),
94
91
  id: 'custom-test',
95
92
  name: 'Custom Test',
96
93
  description: 'test style',
97
94
  };
98
- registerTransformStyle(custom);
99
- expect(getTransformStyleIds()).toContain('custom-test');
100
- expect(resolveTransformStyle('custom-test').name).toBe('Custom Test');
101
- unregisterTransformStyle('custom-test');
102
- // Unknown id falls back to the default (documentary)
95
+ const registry = createTransformStyleRegistry([custom]);
96
+ expect(getTransformStyleIds(registry)).toContain('custom-test');
97
+ expect(resolveTransformStyle('custom-test', registry).name).toBe('Custom Test');
103
98
  expect(resolveTransformStyle('custom-test').id).toBe('documentary');
99
+ expect(
100
+ applyTransform(sampleDoc(), 'custom-test', { registry }).doc.blocks.length,
101
+ ).toBeGreaterThan(0);
104
102
  });
105
103
 
106
- it("accepts 'dataDriven' as an alias for the hyphenated registry id", () => {
104
+ it('normalizes the stored legacy dataDriven id without advertising it', () => {
105
+ expect(resolveTransformStyle('data-driven').id).toBe('data-driven');
107
106
  expect(resolveTransformStyle('dataDriven').id).toBe('data-driven');
107
+ expect(getTransformStyleIds()).not.toContain('dataDriven');
108
+ });
109
+
110
+ it('validates and stores immutable style snapshots in isolated registries', () => {
111
+ const base = resolveTransformStyle('minimal');
112
+ const source: TransformStyleConfig = {
113
+ ...base,
114
+ id: 'tenant-style',
115
+ name: 'Tenant Style',
116
+ preferredTypes: [...base.preferredTypes],
117
+ colorSchemes: [...base.colorSchemes],
118
+ blocksPerSection: { ...base.blocksPerSection },
119
+ };
120
+ const tenantA = createTransformStyleRegistry([source]);
121
+ const tenantB = createTransformStyleRegistry();
122
+ source.name = 'Changed later';
123
+ source.colorSchemes[0] = 'red';
124
+
125
+ const stored = tenantA.get('tenant-style')!;
126
+ expect(stored.name).toBe('Tenant Style');
127
+ expect(stored.colorSchemes[0]).toBe(base.colorSchemes[0]);
128
+ expect(Object.isFrozen(stored)).toBe(true);
129
+ expect(Object.isFrozen(stored.colorSchemes)).toBe(true);
130
+ expect(tenantB.get('tenant-style')).toBeUndefined();
131
+
132
+ expect(() => tenantA.register({ ...source, id: 'bad-style', transformRatio: 2 })).toThrow(
133
+ /transformRatio/,
134
+ );
108
135
  });
109
136
 
110
137
  it('budget.slidesPerMinute caps promotions on long docs', () => {
@@ -127,9 +154,8 @@ describe('transform style contract v2', () => {
127
154
  id: 'custom-test',
128
155
  budget: { slidesPerMinute: 2 },
129
156
  };
130
- registerTransformStyle(budgeted);
131
157
  const loose = applyTransform(manyBlocks, 'data-driven');
132
- const tight = applyTransform(manyBlocks, 'custom-test');
158
+ const tight = applyTransform(manyBlocks, budgeted);
133
159
  expect(tight.stats.transformedBlocks).toBeLessThan(loose.stats.transformedBlocks);
134
160
  // 12 blocks x 5s = 60s → 2/minute → at most 2 promotions
135
161
  expect(tight.stats.transformedBlocks).toBeLessThanOrEqual(2);