@caelo-cms/shared 0.10.34 → 0.10.36

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,887 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Tier-1 unit tests for `renderModuleWithContent` (nested-module-render.ts).
5
+ *
6
+ * The recursive renderer is the §2.1 hot path during every preview
7
+ * render and carries two safety invariants the integration tests can
8
+ * only provoke obliquely:
9
+ *
10
+ * 1. MAX_RECURSION_DEPTH=8 — beyond that, emit an HTML-comment
11
+ * failure marker and a `missingSlots` entry; never silently
12
+ * truncate.
13
+ * 2. Cycle detection — `(moduleId, contentInstanceId)` revisits on
14
+ * the same path produce a comment + missingSlots entry.
15
+ *
16
+ * Both are easier to pin in isolation with synthetic in-memory
17
+ * resolvers than to construct in the full preview pipeline. Pure
18
+ * function, no DB, no compose stack — sub-second.
19
+ */
20
+
21
+ import { describe, expect, it } from "bun:test";
22
+ import {
23
+ collectNestedRefs,
24
+ type NestedContentInstanceResource,
25
+ type NestedModuleResource,
26
+ type NestedRefValue,
27
+ type NestedRenderResolver,
28
+ renderModuleWithContent,
29
+ renderPlacedModule,
30
+ } from "./nested-module-render.js";
31
+
32
+ function buildResolver(
33
+ modules: NestedModuleResource[],
34
+ cis: NestedContentInstanceResource[],
35
+ ): NestedRenderResolver {
36
+ const modByIdx = new Map(modules.map((m) => [m.moduleId, m]));
37
+ const ciByIdx = new Map(cis.map((c) => [c.id, c]));
38
+ return {
39
+ getModule(id) {
40
+ return modByIdx.get(id) ?? null;
41
+ },
42
+ getContentInstance(id) {
43
+ return ciByIdx.get(id) ?? null;
44
+ },
45
+ };
46
+ }
47
+
48
+ const PARENT_MOD_ID = "00000000-0000-0000-0000-00000000aaa1";
49
+ const CHILD_MOD_ID = "00000000-0000-0000-0000-00000000aaa2";
50
+ const GRAND_MOD_ID = "00000000-0000-0000-0000-00000000aaa3";
51
+
52
+ const PARENT_CI_ID = "00000000-0000-0000-0000-00000000bbb1";
53
+ const CHILD_CI_ID = "00000000-0000-0000-0000-00000000bbb2";
54
+ const GRAND_CI_ID = "00000000-0000-0000-0000-00000000bbb3";
55
+
56
+ describe("renderModuleWithContent — primitive substitution", () => {
57
+ it("replaces {{name}} with values[name] and tracks the touched module", () => {
58
+ const resolver = buildResolver(
59
+ [
60
+ {
61
+ moduleId: PARENT_MOD_ID,
62
+ slug: "hero",
63
+ html: "<h1>{{title}}</h1><p>{{body}}</p>",
64
+ css: "",
65
+ js: "",
66
+ fields: [
67
+ { name: "title", kind: "text" },
68
+ { name: "body", kind: "text" },
69
+ ],
70
+ },
71
+ ],
72
+ [
73
+ {
74
+ id: PARENT_CI_ID,
75
+ moduleId: PARENT_MOD_ID,
76
+ values: { title: "Hello", body: "World." },
77
+ deletedAt: null,
78
+ },
79
+ ],
80
+ );
81
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
82
+ expect(r.html).toBe("<h1>Hello</h1><p>World.</p>");
83
+ expect(r.missingSlots).toEqual([]);
84
+ expect(r.touchedModuleIds.has(PARENT_MOD_ID)).toBe(true);
85
+ });
86
+
87
+ it("falls back to field.default when the value is absent", () => {
88
+ const resolver = buildResolver(
89
+ [
90
+ {
91
+ moduleId: PARENT_MOD_ID,
92
+ slug: "hero",
93
+ html: "<h1>{{title}}</h1>",
94
+ css: "",
95
+ js: "",
96
+ fields: [{ name: "title", kind: "text", default: "Fallback" }],
97
+ },
98
+ ],
99
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
100
+ );
101
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
102
+ expect(r.html).toBe("<h1>Fallback</h1>");
103
+ });
104
+ });
105
+
106
+ describe("renderModuleWithContent — nested module slots", () => {
107
+ it("composes a 3-level tree via {{>fieldName}}", () => {
108
+ const resolver = buildResolver(
109
+ [
110
+ {
111
+ moduleId: PARENT_MOD_ID,
112
+ slug: "parent",
113
+ html: "<section>{{>child}}</section>",
114
+ css: "",
115
+ js: "",
116
+ fields: [{ name: "child", kind: "module" }],
117
+ },
118
+ {
119
+ moduleId: CHILD_MOD_ID,
120
+ slug: "child",
121
+ html: "<div>{{>grand}}</div>",
122
+ css: "",
123
+ js: "",
124
+ fields: [{ name: "grand", kind: "module" }],
125
+ },
126
+ {
127
+ moduleId: GRAND_MOD_ID,
128
+ slug: "grand",
129
+ html: "<span>{{label}}</span>",
130
+ css: "",
131
+ js: "",
132
+ fields: [{ name: "label", kind: "text" }],
133
+ },
134
+ ],
135
+ [
136
+ {
137
+ id: PARENT_CI_ID,
138
+ moduleId: PARENT_MOD_ID,
139
+ values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
140
+ deletedAt: null,
141
+ },
142
+ {
143
+ id: CHILD_CI_ID,
144
+ moduleId: CHILD_MOD_ID,
145
+ values: { grand: { moduleId: GRAND_MOD_ID, contentInstanceId: GRAND_CI_ID } },
146
+ deletedAt: null,
147
+ },
148
+ {
149
+ id: GRAND_CI_ID,
150
+ moduleId: GRAND_MOD_ID,
151
+ values: { label: "deep" },
152
+ deletedAt: null,
153
+ },
154
+ ],
155
+ );
156
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
157
+ expect(r.html).toBe("<section><div><span>deep</span></div></section>");
158
+ expect(r.missingSlots).toEqual([]);
159
+ // All three modules touched — caller uses this set for CSS/JS dedup.
160
+ expect(r.touchedModuleIds.has(PARENT_MOD_ID)).toBe(true);
161
+ expect(r.touchedModuleIds.has(CHILD_MOD_ID)).toBe(true);
162
+ expect(r.touchedModuleIds.has(GRAND_MOD_ID)).toBe(true);
163
+ });
164
+
165
+ it("renders module-list slots {{#field}}…{{/field}} for each array element", () => {
166
+ const ITEM_CI_A = "00000000-0000-0000-0000-00000000ccca";
167
+ const ITEM_CI_B = "00000000-0000-0000-0000-00000000cccb";
168
+ const resolver = buildResolver(
169
+ [
170
+ {
171
+ moduleId: PARENT_MOD_ID,
172
+ slug: "list-parent",
173
+ html: "<ul>{{#items}}{{/items}}</ul>",
174
+ css: "",
175
+ js: "",
176
+ fields: [{ name: "items", kind: "module-list" }],
177
+ },
178
+ {
179
+ moduleId: CHILD_MOD_ID,
180
+ slug: "item",
181
+ html: "<li>{{label}}</li>",
182
+ css: "",
183
+ js: "",
184
+ fields: [{ name: "label", kind: "text" }],
185
+ },
186
+ ],
187
+ [
188
+ {
189
+ id: PARENT_CI_ID,
190
+ moduleId: PARENT_MOD_ID,
191
+ values: {
192
+ items: [
193
+ { moduleId: CHILD_MOD_ID, contentInstanceId: ITEM_CI_A },
194
+ { moduleId: CHILD_MOD_ID, contentInstanceId: ITEM_CI_B },
195
+ ],
196
+ },
197
+ deletedAt: null,
198
+ },
199
+ { id: ITEM_CI_A, moduleId: CHILD_MOD_ID, values: { label: "A" }, deletedAt: null },
200
+ { id: ITEM_CI_B, moduleId: CHILD_MOD_ID, values: { label: "B" }, deletedAt: null },
201
+ ],
202
+ );
203
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
204
+ expect(r.html).toBe("<ul><li>A</li><li>B</li></ul>");
205
+ expect(r.missingSlots).toEqual([]);
206
+ });
207
+ });
208
+
209
+ describe("renderModuleWithContent — list-of-primitive slots", () => {
210
+ it("text-list iterates {{.}} per string element", () => {
211
+ const resolver = buildResolver(
212
+ [
213
+ {
214
+ moduleId: PARENT_MOD_ID,
215
+ slug: "tag-cloud",
216
+ html: "<div>{{#tags}}<span>{{.}}</span>{{/tags}}</div>",
217
+ css: "",
218
+ js: "",
219
+ fields: [{ name: "tags", kind: "text-list" }],
220
+ },
221
+ ],
222
+ [
223
+ {
224
+ id: PARENT_CI_ID,
225
+ moduleId: PARENT_MOD_ID,
226
+ values: { tags: ["alpha", "beta", "gamma"] },
227
+ deletedAt: null,
228
+ },
229
+ ],
230
+ );
231
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
232
+ expect(r.html).toBe("<div><span>alpha</span><span>beta</span><span>gamma</span></div>");
233
+ expect(r.missingSlots).toEqual([]);
234
+ });
235
+
236
+ it("text-list also accepts the {{item}} alias", () => {
237
+ const resolver = buildResolver(
238
+ [
239
+ {
240
+ moduleId: PARENT_MOD_ID,
241
+ slug: "bullets",
242
+ html: "<ul>{{#bullets}}<li>{{item}}</li>{{/bullets}}</ul>",
243
+ css: "",
244
+ js: "",
245
+ fields: [{ name: "bullets", kind: "text-list" }],
246
+ },
247
+ ],
248
+ [
249
+ {
250
+ id: PARENT_CI_ID,
251
+ moduleId: PARENT_MOD_ID,
252
+ values: { bullets: ["one", "two"] },
253
+ deletedAt: null,
254
+ },
255
+ ],
256
+ );
257
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
258
+ expect(r.html).toBe("<ul><li>one</li><li>two</li></ul>");
259
+ });
260
+
261
+ it("text-list falls back to field.default when values entry is absent", () => {
262
+ const resolver = buildResolver(
263
+ [
264
+ {
265
+ moduleId: PARENT_MOD_ID,
266
+ slug: "tag-cloud",
267
+ html: "<div>{{#tags}}<span>{{.}}</span>{{/tags}}</div>",
268
+ css: "",
269
+ js: "",
270
+ fields: [
271
+ {
272
+ name: "tags",
273
+ kind: "text-list",
274
+ default: ["alpha", "beta"],
275
+ },
276
+ ],
277
+ },
278
+ ],
279
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
280
+ );
281
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
282
+ expect(r.html).toBe("<div><span>alpha</span><span>beta</span></div>");
283
+ });
284
+
285
+ it("link-list iterates {{label}} + {{href}} per element", () => {
286
+ const resolver = buildResolver(
287
+ [
288
+ {
289
+ moduleId: PARENT_MOD_ID,
290
+ slug: "primary-nav",
291
+ html: '<nav>{{#menu}}<a href="{{href}}">{{label}}</a>{{/menu}}</nav>',
292
+ css: "",
293
+ js: "",
294
+ fields: [{ name: "menu", kind: "link-list" }],
295
+ },
296
+ ],
297
+ [
298
+ {
299
+ id: PARENT_CI_ID,
300
+ moduleId: PARENT_MOD_ID,
301
+ values: {
302
+ menu: [
303
+ { label: "Home", href: "/" },
304
+ { label: "About", href: "/about" },
305
+ { label: "Contact", href: "/contact" },
306
+ ],
307
+ },
308
+ deletedAt: null,
309
+ },
310
+ ],
311
+ );
312
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
313
+ expect(r.html).toBe(
314
+ '<nav><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav>',
315
+ );
316
+ expect(r.missingSlots).toEqual([]);
317
+ });
318
+
319
+ it("link-list malformed element gets a per-element marker, others render", () => {
320
+ const resolver = buildResolver(
321
+ [
322
+ {
323
+ moduleId: PARENT_MOD_ID,
324
+ slug: "primary-nav",
325
+ html: '<nav>{{#menu}}<a href="{{href}}">{{label}}</a>{{/menu}}</nav>',
326
+ css: "",
327
+ js: "",
328
+ fields: [{ name: "menu", kind: "link-list" }],
329
+ },
330
+ ],
331
+ [
332
+ {
333
+ id: PARENT_CI_ID,
334
+ moduleId: PARENT_MOD_ID,
335
+ values: {
336
+ menu: [
337
+ { label: "Home", href: "/" },
338
+ { label: "missing-href" }, // malformed
339
+ { label: "About", href: "/about" },
340
+ ],
341
+ },
342
+ deletedAt: null,
343
+ },
344
+ ],
345
+ );
346
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
347
+ expect(r.html).toContain('<a href="/">Home</a>');
348
+ expect(r.html).toContain('<a href="/about">About</a>');
349
+ expect(r.html).toContain("link-list-malformed");
350
+ expect(r.missingSlots.some((s) => s.startsWith("link-list-malformed"))).toBe(true);
351
+ });
352
+ });
353
+
354
+ describe("renderModuleWithContent — safety invariants", () => {
355
+ it("emits depth-limit comment when recursion exceeds MAX_RECURSION_DEPTH=8", () => {
356
+ // Build a chain of 12 modules each referencing the next, blowing
357
+ // past the 8-level cap. Modules and CIs share the index suffix.
358
+ const modules: NestedModuleResource[] = [];
359
+ const cis: NestedContentInstanceResource[] = [];
360
+ for (let i = 0; i < 12; i += 1) {
361
+ const nextIdx = i + 1;
362
+ modules.push({
363
+ moduleId: `00000000-0000-0000-0000-${String(i).padStart(12, "0")}`,
364
+ slug: `m${i}`,
365
+ html: nextIdx < 12 ? "<x>{{>next}}</x>" : "<x>leaf</x>",
366
+ css: "",
367
+ js: "",
368
+ fields: nextIdx < 12 ? [{ name: "next", kind: "module" }] : [],
369
+ });
370
+ cis.push({
371
+ id: `00000000-0000-0000-0000-${String(i).padStart(12, "f")}`,
372
+ moduleId: `00000000-0000-0000-0000-${String(i).padStart(12, "0")}`,
373
+ values:
374
+ nextIdx < 12
375
+ ? {
376
+ next: {
377
+ moduleId: `00000000-0000-0000-0000-${String(nextIdx).padStart(12, "0")}`,
378
+ contentInstanceId: `00000000-0000-0000-0000-${String(nextIdx).padStart(12, "f")}`,
379
+ },
380
+ }
381
+ : {},
382
+ deletedAt: null,
383
+ });
384
+ }
385
+ const resolver = buildResolver(modules, cis);
386
+ const root = modules[0];
387
+ const rootCi = cis[0];
388
+ if (!root || !rootCi) throw new Error("fixture broken");
389
+ const r = renderModuleWithContent(root.moduleId, rootCi.id, resolver);
390
+ expect(r.html).toContain("caelo:missing");
391
+ expect(r.html).toContain("depth-limit-8");
392
+ expect(r.missingSlots.some((s) => s.startsWith("depth-limit"))).toBe(true);
393
+ });
394
+
395
+ it("emits cycle comment when a (moduleId, contentInstanceId) pair revisits its own path", () => {
396
+ // A → B → A cycle: parent references child references parent. The
397
+ // recursion path Set should fire on the second visit to PARENT_CI_ID.
398
+ const resolver = buildResolver(
399
+ [
400
+ {
401
+ moduleId: PARENT_MOD_ID,
402
+ slug: "parent",
403
+ html: "<a>{{>child}}</a>",
404
+ css: "",
405
+ js: "",
406
+ fields: [{ name: "child", kind: "module" }],
407
+ },
408
+ {
409
+ moduleId: CHILD_MOD_ID,
410
+ slug: "child",
411
+ html: "<b>{{>back}}</b>",
412
+ css: "",
413
+ js: "",
414
+ fields: [{ name: "back", kind: "module" }],
415
+ },
416
+ ],
417
+ [
418
+ {
419
+ id: PARENT_CI_ID,
420
+ moduleId: PARENT_MOD_ID,
421
+ values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
422
+ deletedAt: null,
423
+ },
424
+ {
425
+ id: CHILD_CI_ID,
426
+ moduleId: CHILD_MOD_ID,
427
+ values: { back: { moduleId: PARENT_MOD_ID, contentInstanceId: PARENT_CI_ID } },
428
+ deletedAt: null,
429
+ },
430
+ ],
431
+ );
432
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
433
+ expect(r.html).toContain("caelo:missing");
434
+ expect(r.html).toContain("cycle");
435
+ expect(r.missingSlots.some((s) => s.startsWith("cycle:"))).toBe(true);
436
+ });
437
+
438
+ it("emits missing-module comment when a referenced module is gone", () => {
439
+ const resolver = buildResolver(
440
+ [
441
+ {
442
+ moduleId: PARENT_MOD_ID,
443
+ slug: "parent",
444
+ html: "<a>{{>child}}</a>",
445
+ css: "",
446
+ js: "",
447
+ fields: [{ name: "child", kind: "module" }],
448
+ },
449
+ ],
450
+ [
451
+ {
452
+ id: PARENT_CI_ID,
453
+ moduleId: PARENT_MOD_ID,
454
+ values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
455
+ deletedAt: null,
456
+ },
457
+ ],
458
+ );
459
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
460
+ expect(r.html).toContain("module-missing");
461
+ expect(r.missingSlots.some((s) => s.startsWith("module-missing"))).toBe(true);
462
+ });
463
+
464
+ it("emits content-instance-missing comment when the CI is soft-deleted", () => {
465
+ const resolver = buildResolver(
466
+ [
467
+ {
468
+ moduleId: PARENT_MOD_ID,
469
+ slug: "parent",
470
+ html: "<a>{{>child}}</a>",
471
+ css: "",
472
+ js: "",
473
+ fields: [{ name: "child", kind: "module" }],
474
+ },
475
+ {
476
+ moduleId: CHILD_MOD_ID,
477
+ slug: "child",
478
+ html: "<b>x</b>",
479
+ css: "",
480
+ js: "",
481
+ fields: [],
482
+ },
483
+ ],
484
+ [
485
+ {
486
+ id: PARENT_CI_ID,
487
+ moduleId: PARENT_MOD_ID,
488
+ values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
489
+ deletedAt: null,
490
+ },
491
+ {
492
+ id: CHILD_CI_ID,
493
+ moduleId: CHILD_MOD_ID,
494
+ values: {},
495
+ deletedAt: "2026-01-01T00:00:00.000Z",
496
+ },
497
+ ],
498
+ );
499
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
500
+ expect(r.html).toContain("content-instance-missing");
501
+ expect(r.missingSlots.some((s) => s.startsWith("content-instance-missing"))).toBe(true);
502
+ });
503
+ });
504
+
505
+ describe("renderModuleWithContent — engine-routing parity (#71)", () => {
506
+ // The substitution + section iteration moved into the shared
507
+ // template engine. These tests pin the behaviour-change cases the
508
+ // plan §8.1 calls out, plus the literal failure-marker strings the
509
+ // chat-runner diag pass + editor missing-content surface depend on.
510
+
511
+ it("AC #3 — unknown {{name}} leaves raw markers AND tracks field-not-declared", () => {
512
+ // Behaviour change from legacy: the old primitive branch silently
513
+ // returned the raw `{{name}}` text without pushing to missingSlots.
514
+ // The new uniform engine pushes `field-not-declared:<name>` for
515
+ // truly-unknown placeholders (declared-but-empty stays silent —
516
+ // the operator is still authoring). The chat-runner diag surface
517
+ // benefits from the richer signal.
518
+ const resolver = buildResolver(
519
+ [
520
+ {
521
+ moduleId: PARENT_MOD_ID,
522
+ slug: "broken",
523
+ html: "<p>{{ghost}}</p>",
524
+ css: "",
525
+ js: "",
526
+ fields: [],
527
+ },
528
+ ],
529
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
530
+ );
531
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
532
+ expect(r.html).toBe("<p>{{ghost}}</p>");
533
+ expect(r.missingSlots).toContain("field-not-declared:ghost");
534
+ });
535
+
536
+ it("AC #3 — unknown {{#unknown}}…{{/unknown}} leaves raw markers AND tracks field-not-declared", () => {
537
+ // Deliberate HTML-shape change from legacy: today's hand-rolled
538
+ // path emitted `<!-- caelo:missing reason=field-not-declared … -->`
539
+ // inline; the new uniform behaviour leaves the raw markers per
540
+ // the AC #3 literal text. The structured missingSlots channel
541
+ // callers depend on stays populated.
542
+ const resolver = buildResolver(
543
+ [
544
+ {
545
+ moduleId: PARENT_MOD_ID,
546
+ slug: "broken-block",
547
+ html: "<p>{{#unknown}}x{{/unknown}}</p>",
548
+ css: "",
549
+ js: "",
550
+ fields: [],
551
+ },
552
+ ],
553
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
554
+ );
555
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
556
+ expect(r.html).toBe("<p>{{#unknown}}x{{/unknown}}</p>");
557
+ expect(r.missingSlots).toContain("field-not-declared:unknown");
558
+ });
559
+
560
+ it("{{#name}} over a primitive field is a CONDITIONAL, not a kind-mismatch (renders when set)", () => {
561
+ // Regression: a scalar text/image field in a `{{#name}}…{{/name}}` section
562
+ // used to fall through to kind-mismatch and SILENTLY DROP the content.
563
+ const resolver = buildResolver(
564
+ [
565
+ {
566
+ moduleId: PARENT_MOD_ID,
567
+ slug: "conditional",
568
+ html: "<h1>{{#title}}{{title}}{{/title}}</h1>",
569
+ css: "",
570
+ js: "",
571
+ fields: [{ name: "title", kind: "text" }],
572
+ },
573
+ ],
574
+ [
575
+ {
576
+ id: PARENT_CI_ID,
577
+ moduleId: PARENT_MOD_ID,
578
+ values: { title: "Willkommen" },
579
+ deletedAt: null,
580
+ },
581
+ ],
582
+ );
583
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
584
+ expect(r.html).toContain("<h1>Willkommen</h1>");
585
+ expect(r.missingSlots.some((s) => s.includes("kind-mismatch"))).toBe(false);
586
+ });
587
+
588
+ it("kind-mismatch on {{>name}} against a list field emits the exact legacy string", () => {
589
+ const expected = "kind-mismatch:items expected=module actual=text-list";
590
+ const resolver = buildResolver(
591
+ [
592
+ {
593
+ moduleId: PARENT_MOD_ID,
594
+ slug: "mismatch-partial",
595
+ html: "<aside>{{>items}}</aside>",
596
+ css: "",
597
+ js: "",
598
+ fields: [{ name: "items", kind: "text-list" }],
599
+ },
600
+ ],
601
+ [{ id: PARENT_CI_ID, moduleId: PARENT_MOD_ID, values: {}, deletedAt: null }],
602
+ );
603
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
604
+ expect(r.html).toContain(`<!-- caelo:missing reason=${expected} -->`);
605
+ expect(r.missingSlots).toContain(expected);
606
+ });
607
+
608
+ it("module-ref-malformed on {{>name}} when the value is not a NestedRef", () => {
609
+ const resolver = buildResolver(
610
+ [
611
+ {
612
+ moduleId: PARENT_MOD_ID,
613
+ slug: "malformed-ref",
614
+ html: "<aside>{{>hero}}</aside>",
615
+ css: "",
616
+ js: "",
617
+ fields: [{ name: "hero", kind: "module" }],
618
+ },
619
+ ],
620
+ [
621
+ {
622
+ id: PARENT_CI_ID,
623
+ moduleId: PARENT_MOD_ID,
624
+ values: { hero: "not-a-ref" },
625
+ deletedAt: null,
626
+ },
627
+ ],
628
+ );
629
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
630
+ expect(r.html).toContain("<!-- caelo:missing reason=module-ref-malformed hero -->");
631
+ expect(r.missingSlots).toContain("module-ref-malformed:hero");
632
+ });
633
+
634
+ it("text-list-malformed per element preserves the exact legacy marker", () => {
635
+ const resolver = buildResolver(
636
+ [
637
+ {
638
+ moduleId: PARENT_MOD_ID,
639
+ slug: "text-list-malformed",
640
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
641
+ css: "",
642
+ js: "",
643
+ fields: [{ name: "items", kind: "text-list" }],
644
+ },
645
+ ],
646
+ [
647
+ {
648
+ id: PARENT_CI_ID,
649
+ moduleId: PARENT_MOD_ID,
650
+ values: { items: ["a", { bogus: true }, "c"] },
651
+ deletedAt: null,
652
+ },
653
+ ],
654
+ );
655
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
656
+ expect(r.html).toContain("<li>a</li>");
657
+ expect(r.html).toContain("<li>c</li>");
658
+ expect(r.html).toContain("<!-- caelo:missing reason=text-list-malformed items[1] -->");
659
+ expect(r.missingSlots).toContain("text-list-malformed:items[1]");
660
+ });
661
+
662
+ it("module-list-malformed per element preserves the exact legacy marker", () => {
663
+ const resolver = buildResolver(
664
+ [
665
+ {
666
+ moduleId: PARENT_MOD_ID,
667
+ slug: "module-list-malformed",
668
+ html: "<ul>{{#cards}}x{{/cards}}</ul>",
669
+ css: "",
670
+ js: "",
671
+ fields: [{ name: "cards", kind: "module-list" }],
672
+ },
673
+ {
674
+ moduleId: CHILD_MOD_ID,
675
+ slug: "card",
676
+ html: "<article>{{title}}</article>",
677
+ css: "",
678
+ js: "",
679
+ fields: [{ name: "title", kind: "text" }],
680
+ },
681
+ ],
682
+ [
683
+ {
684
+ id: PARENT_CI_ID,
685
+ moduleId: PARENT_MOD_ID,
686
+ values: {
687
+ cards: [{ moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID }, "not-a-ref"],
688
+ },
689
+ deletedAt: null,
690
+ },
691
+ {
692
+ id: CHILD_CI_ID,
693
+ moduleId: CHILD_MOD_ID,
694
+ values: { title: "OK" },
695
+ deletedAt: null,
696
+ },
697
+ ],
698
+ );
699
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
700
+ expect(r.html).toContain("<article>OK</article>");
701
+ expect(r.html).toContain("<!-- caelo:missing reason=module-list-malformed cards[1] -->");
702
+ expect(r.missingSlots).toContain("module-list-malformed:cards[1]");
703
+ });
704
+ });
705
+
706
+ describe("collectNestedRefs", () => {
707
+ it("returns single + list refs from a values bag, ignoring primitives", () => {
708
+ const refA: NestedRefValue = { moduleId: "m-a", contentInstanceId: "c-a" };
709
+ const refB: NestedRefValue = { moduleId: "m-b", contentInstanceId: "c-b" };
710
+ const refC: NestedRefValue = { moduleId: "m-c", contentInstanceId: "c-c" };
711
+ const refs = collectNestedRefs({
712
+ title: "Hello",
713
+ cta: refA,
714
+ items: [refB, refC, "not-a-ref"],
715
+ enabled: true,
716
+ });
717
+ expect(refs).toHaveLength(3);
718
+ expect(refs).toEqual(expect.arrayContaining([refA, refB, refC]));
719
+ });
720
+ });
721
+
722
+ describe("renderPlacedModule — the composer's entry point", () => {
723
+ const resolver = buildResolver(
724
+ [
725
+ {
726
+ moduleId: CHILD_MOD_ID,
727
+ slug: "plan",
728
+ html: "<div>{{name}}{{>cta}}</div>",
729
+ css: "",
730
+ js: "",
731
+ fields: [
732
+ { name: "name", kind: "text" },
733
+ { name: "cta", kind: "module" },
734
+ ],
735
+ },
736
+ {
737
+ moduleId: GRAND_MOD_ID,
738
+ slug: "button",
739
+ html: "<a>{{label}}</a>",
740
+ css: "",
741
+ js: "",
742
+ fields: [{ name: "label", kind: "text" }],
743
+ },
744
+ ],
745
+ [
746
+ {
747
+ id: CHILD_CI_ID,
748
+ moduleId: CHILD_MOD_ID,
749
+ values: {
750
+ name: "Pro",
751
+ cta: { moduleId: GRAND_MOD_ID, contentInstanceId: GRAND_CI_ID },
752
+ },
753
+ deletedAt: null,
754
+ },
755
+ { id: GRAND_CI_ID, moduleId: GRAND_MOD_ID, values: { label: "Buy" }, deletedAt: null },
756
+ {
757
+ id: PARENT_CI_ID,
758
+ moduleId: CHILD_MOD_ID,
759
+ values: {
760
+ name: "Broken",
761
+ cta: {
762
+ moduleId: GRAND_MOD_ID,
763
+ contentInstanceId: "00000000-0000-0000-0000-00000000dead",
764
+ },
765
+ },
766
+ deletedAt: null,
767
+ },
768
+ ],
769
+ );
770
+ const placed = {
771
+ html: "<section>{{#plans}}{{/plans}}</section>",
772
+ fields: [{ name: "plans", kind: "module-list" as const }],
773
+ };
774
+
775
+ it("renders values that belong to no instance (e.g. a content variant)", () => {
776
+ const r = renderPlacedModule(
777
+ {
778
+ ...placed,
779
+ values: { plans: [{ moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID }] },
780
+ },
781
+ resolver,
782
+ {},
783
+ );
784
+ expect(r.html).toBe("<section><div>Pro<a>Buy</a></div></section>");
785
+ expect(r.failures).toEqual([]);
786
+ expect([...r.touchedModuleIds].sort()).toEqual([CHILD_MOD_ID, GRAND_MOD_ID].sort());
787
+ });
788
+
789
+ it("attributes a failure deep in the tree to its field trail", () => {
790
+ const r = renderPlacedModule(
791
+ {
792
+ ...placed,
793
+ values: {
794
+ plans: [
795
+ { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID },
796
+ { moduleId: CHILD_MOD_ID, contentInstanceId: PARENT_CI_ID },
797
+ ],
798
+ },
799
+ },
800
+ resolver,
801
+ {},
802
+ );
803
+ expect(r.failures).toEqual([
804
+ {
805
+ field: "plans[1] > cta",
806
+ reason: "content-instance-missing:00000000-0000-0000-0000-00000000dead",
807
+ },
808
+ ]);
809
+ expect(r.missingSlots).toEqual([
810
+ "content-instance-missing:00000000-0000-0000-0000-00000000dead",
811
+ ]);
812
+ });
813
+ });
814
+
815
+ describe("renderPlacedModule — only template-referenced nested fields render", () => {
816
+ const GONE_CI = "00000000-0000-0000-0000-00000000dead";
817
+ const child: NestedModuleResource = {
818
+ moduleId: CHILD_MOD_ID,
819
+ slug: "card",
820
+ html: "<article>{{title}}</article>",
821
+ css: "",
822
+ js: "",
823
+ fields: [{ name: "title", kind: "text" }],
824
+ };
825
+ const resolver = buildResolver(
826
+ [child],
827
+ [{ id: CHILD_CI_ID, moduleId: CHILD_MOD_ID, values: { title: "Pro" }, deletedAt: null }],
828
+ );
829
+
830
+ it("does not resolve a declared module / module-list field the HTML never references", () => {
831
+ const r = renderPlacedModule(
832
+ {
833
+ html: "<section>{{>featured}}</section>",
834
+ fields: [
835
+ { name: "featured", kind: "module" },
836
+ { name: "legacy_card", kind: "module" },
837
+ { name: "legacy_cards", kind: "module-list" },
838
+ ],
839
+ values: {
840
+ featured: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID },
841
+ // Stale refs in fields the template no longer uses.
842
+ legacy_card: { moduleId: CHILD_MOD_ID, contentInstanceId: GONE_CI },
843
+ legacy_cards: [
844
+ { moduleId: "00000000-0000-0000-0000-0000000000ff", contentInstanceId: GONE_CI },
845
+ ],
846
+ },
847
+ },
848
+ resolver,
849
+ {},
850
+ );
851
+ expect(r.html).toBe("<section><article>Pro</article></section>");
852
+ expect(r.failures).toEqual([]);
853
+ expect(r.missingSlots).toEqual([]);
854
+ expect([...r.touchedModuleIds]).toEqual([CHILD_MOD_ID]);
855
+ });
856
+
857
+ it("does not resolve a module ref inside a conditional section that renders empty", () => {
858
+ const r = renderPlacedModule(
859
+ {
860
+ html: "<section>{{#show}}{{>card}}{{/show}}</section>",
861
+ fields: [
862
+ { name: "show", kind: "text" },
863
+ { name: "card", kind: "module" },
864
+ ],
865
+ values: { show: "", card: { moduleId: CHILD_MOD_ID, contentInstanceId: GONE_CI } },
866
+ },
867
+ resolver,
868
+ {},
869
+ );
870
+ expect(r.html).toBe("<section></section>");
871
+ expect(r.failures).toEqual([]);
872
+ expect(r.touchedModuleIds.size).toBe(0);
873
+ });
874
+
875
+ it("renders a ref referenced twice once, reporting its failure once", () => {
876
+ const r = renderPlacedModule(
877
+ {
878
+ html: "<a>{{>card}}</a><b>{{>card}}</b>",
879
+ fields: [{ name: "card", kind: "module" }],
880
+ values: { card: { moduleId: CHILD_MOD_ID, contentInstanceId: GONE_CI } },
881
+ },
882
+ resolver,
883
+ {},
884
+ );
885
+ expect(r.failures).toEqual([{ field: "card", reason: `content-instance-missing:${GONE_CI}` }]);
886
+ });
887
+ });