@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.
- package/dist/ai-tools.d.ts +4 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +9 -1
- package/dist/ai-tools.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/nested-module-render.d.ts +170 -0
- package/dist/nested-module-render.d.ts.map +1 -0
- package/dist/nested-module-render.js +219 -0
- package/dist/nested-module-render.js.map +1 -0
- package/dist/preview-compose.d.ts +35 -5
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +148 -57
- package/dist/preview-compose.js.map +1 -1
- package/dist/snapshots.d.ts +1 -0
- package/dist/snapshots.d.ts.map +1 -1
- package/dist/snapshots.js +3 -0
- package/dist/snapshots.js.map +1 -1
- package/dist/template-engine.d.ts +15 -6
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +33 -19
- package/dist/template-engine.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +9 -1
- package/src/index.ts +1 -0
- package/src/nested-module-render.test.ts +887 -0
- package/src/nested-module-render.ts +424 -0
- package/src/preview-compose-nested.test.ts +181 -0
- package/src/preview-compose.ts +208 -62
- package/src/snapshots.ts +3 -0
- package/src/template-engine.test.ts +3 -0
- package/src/template-engine.ts +52 -26
- package/src/version.ts +1 -1
|
@@ -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
|
+
});
|