@bespokeagentics/microdots-host 0.1.2 → 0.1.3
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/package.json +2 -2
- package/src/anchorMount.test.ts +124 -0
- package/src/anchorMount.ts +153 -0
- package/src/fillComposition.ts +1 -1
- package/src/index.ts +45 -3
- package/src/mountedRegistry.test.ts +1 -0
- package/src/mounting.test.ts +23 -0
- package/src/mounting.ts +10 -1
- package/src/placementChecks.test.ts +180 -4
- package/src/placementChecks.ts +132 -7
- package/src/renderLayout.test.ts +263 -0
- package/src/renderLayout.ts +345 -0
- package/src/rules.test.ts +85 -0
- package/src/rules.ts +9 -0
- package/src/slotLayout.test.ts +127 -0
- package/src/slotLayout.ts +113 -0
- package/src/slots.test.ts +37 -3
- package/src/slots.ts +10 -1
- package/src/wire.test.ts +80 -0
- package/src/wire.ts +48 -7
- package/src/wireEngine.test.ts +1 -1
package/src/placementChecks.ts
CHANGED
|
@@ -9,14 +9,17 @@ import {
|
|
|
9
9
|
} from './rules.ts'
|
|
10
10
|
import type { HostSlotManifest } from './slots.ts'
|
|
11
11
|
import type { WireEnv } from './wire.ts'
|
|
12
|
+
import { ANCHOR_SLOT_ID } from './wire.ts'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* The placement CHECKS —
|
|
15
|
+
* The placement CHECKS — the spec's seven, run as a pure lint engine
|
|
15
16
|
* over `resolvePlacements`' output. Phase 5 work item 4 of
|
|
16
17
|
* `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`;
|
|
17
18
|
* the seven checks' severities and sentences are the handoff spec's, lifted
|
|
18
|
-
* verbatim where the fixture wrote them. Check 5 (anchor selector
|
|
19
|
-
*
|
|
19
|
+
* verbatim where the fixture wrote them. Check 5 (anchor selector resolution)
|
|
20
|
+
* has an id and a reporter but NO verifier: no crawl exists, so every rendered
|
|
21
|
+
* anchor is reported unverifiable — a check that cannot run is never silently
|
|
22
|
+
* green (the Pages write-mode plan's D5).
|
|
20
23
|
*
|
|
21
24
|
* Reading B's qualifier lands here exactly as
|
|
22
25
|
* `question-does-the-override-contest-consider-condition.md` predicted: a
|
|
@@ -32,8 +35,11 @@ export type PlacementCheckId =
|
|
|
32
35
|
| 'slot-not-in-theme'
|
|
33
36
|
| 'required-attribute-unset'
|
|
34
37
|
| 'mounts-twice-overlapping'
|
|
38
|
+
| 'anchor-selector-unresolved'
|
|
35
39
|
| 'over-weight-budget'
|
|
36
40
|
| 'placed-twice-disjoint'
|
|
41
|
+
| 'over-slot-capacity'
|
|
42
|
+
| 'span-in-non-grid'
|
|
37
43
|
|
|
38
44
|
export type PlacementCheckSeverity = 'error' | 'warning' | 'note'
|
|
39
45
|
|
|
@@ -188,6 +194,9 @@ const checkSlotInTheme = (
|
|
|
188
194
|
const theme = `${slotManifest.theme.name}@${slotManifest.theme.version}`
|
|
189
195
|
const issues = Array.getSomes(
|
|
190
196
|
Array.map(rendered, (placement): Option.Option<PlacementIssue> =>
|
|
197
|
+
// An anchor is deliberately OUTSIDE the theme — `@anchor` is the
|
|
198
|
+
// escape hatch, not a missing declaration. Check 5 owns anchors.
|
|
199
|
+
placement.slotId === ANCHOR_SLOT_ID ||
|
|
191
200
|
Array.some(slotManifest.slots, slot => slot.id === placement.slotId)
|
|
192
201
|
? Option.none()
|
|
193
202
|
: Option.some({
|
|
@@ -253,6 +262,39 @@ const checkRequiredAttributes = (
|
|
|
253
262
|
return { issues, unverifiable }
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
/* ============================================================
|
|
266
|
+
Check 5 — anchor selector resolution. UNVERIFIABLE, deliberately.
|
|
267
|
+
|
|
268
|
+
No crawl exists, so whether a selector matches anything in the host's
|
|
269
|
+
painted DOM cannot be checked from here. The honest report is one
|
|
270
|
+
unverifiable entry per distinct selector among the rendered anchors —
|
|
271
|
+
never a green row, never a stored guess (the write-mode plan's D5).
|
|
272
|
+
The verifier lands with the crawl, under this same check id.
|
|
273
|
+
============================================================ */
|
|
274
|
+
|
|
275
|
+
const checkAnchors = (
|
|
276
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
277
|
+
): PlacementCheckReport => {
|
|
278
|
+
const selectors = Array.dedupe(
|
|
279
|
+
Array.getSomes(
|
|
280
|
+
Array.map(rendered, placement =>
|
|
281
|
+
placement.slotId === ANCHOR_SLOT_ID
|
|
282
|
+
? placement.selector
|
|
283
|
+
: Option.none<string>(),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
return {
|
|
288
|
+
issues: [],
|
|
289
|
+
unverifiable: selectors.map(
|
|
290
|
+
(selector): UnverifiableCheck => ({
|
|
291
|
+
check: 'anchor-selector-unresolved',
|
|
292
|
+
reason: `no crawl has run — whether "${selector}" matches anything in the host cannot be verified`,
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
256
298
|
/* ============================================================
|
|
257
299
|
Checks 4 and 7 — the same dot twice on the route. Warning / note.
|
|
258
300
|
|
|
@@ -396,15 +438,95 @@ const checkWeightBudget = (
|
|
|
396
438
|
})
|
|
397
439
|
}
|
|
398
440
|
|
|
441
|
+
/* ============================================================
|
|
442
|
+
Checks 8 and 9 — the generated-layout widening
|
|
443
|
+
(`wiki/plans/active/microdots-platform-pages-generated-layout.md` §E).
|
|
444
|
+
DIAGNOSTICS, not runtime enforcement: no renderer refuses the state
|
|
445
|
+
either check reports, and both claim only what a manifest can make
|
|
446
|
+
verifiable — with no manifest they are silent (the existing check-2
|
|
447
|
+
unverifiable entry already covers that state; a second entry would
|
|
448
|
+
repeat it).
|
|
449
|
+
============================================================ */
|
|
450
|
+
|
|
451
|
+
/* Check 8 — over slot capacity. Warning.
|
|
452
|
+
|
|
453
|
+
Fires ONCE per overflowing slot, on the first placement past the declared
|
|
454
|
+
capacity in stacking order. The claim is DISAGREEMENT between the
|
|
455
|
+
declaration and the placements — deliberately not "will not render", so
|
|
456
|
+
the sentence stays true whether or not any renderer ever enforces
|
|
457
|
+
capacity (today none does; the generated host stacks everything). */
|
|
458
|
+
|
|
459
|
+
const checkSlotCapacity = (
|
|
460
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
461
|
+
slotManifest: HostSlotManifest | undefined,
|
|
462
|
+
): PlacementCheckReport => {
|
|
463
|
+
if (slotManifest === undefined) return { issues: [], unverifiable: [] }
|
|
464
|
+
const issues = Array.getSomes(
|
|
465
|
+
slotManifest.slots.map((slot): Option.Option<PlacementIssue> => {
|
|
466
|
+
if (slot.capacity === undefined) return Option.none()
|
|
467
|
+
const capacity = slot.capacity
|
|
468
|
+
const occupants = Array.sort(
|
|
469
|
+
Array.filter(rendered, placement => placement.slotId === slot.id),
|
|
470
|
+
Order.mapInput(Order.Number, (placement: ResolvedPlacement) =>
|
|
471
|
+
Option.getOrElse(placement.order, () => 0),
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
if (occupants.length <= capacity) return Option.none()
|
|
475
|
+
const first = occupants[capacity]
|
|
476
|
+
if (first === undefined) return Option.none()
|
|
477
|
+
return Option.some({
|
|
478
|
+
check: 'over-slot-capacity',
|
|
479
|
+
severity: 'warning',
|
|
480
|
+
placementId: first.id,
|
|
481
|
+
consequence: `Slot ${slot.id} declares capacity ${String(capacity)} and ${String(occupants.length)} placements resolve into it. The declaration and the placements disagree; nothing enforces capacity at runtime, so all ${String(occupants.length)} render.`,
|
|
482
|
+
action: 'Move a placement to another slot, or raise the capacity',
|
|
483
|
+
})
|
|
484
|
+
}),
|
|
485
|
+
)
|
|
486
|
+
return { issues, unverifiable: [] }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* Check 9 — a span on a placement in a non-grid slot. Note.
|
|
490
|
+
|
|
491
|
+
The legal residue of a kind edit: spans go dormant on grid→band and
|
|
492
|
+
return if the slot becomes a grid again, so this is stated as dormancy,
|
|
493
|
+
never as an error demanding cleanup. */
|
|
494
|
+
|
|
495
|
+
const checkSpanInNonGrid = (
|
|
496
|
+
rendered: ReadonlyArray<ResolvedPlacement>,
|
|
497
|
+
slotManifest: HostSlotManifest | undefined,
|
|
498
|
+
): PlacementCheckReport => {
|
|
499
|
+
if (slotManifest === undefined) return { issues: [], unverifiable: [] }
|
|
500
|
+
const kinds = new Map(
|
|
501
|
+
slotManifest.slots.map(slot => [slot.id, slot.kind] as const),
|
|
502
|
+
)
|
|
503
|
+
const issues = Array.getSomes(
|
|
504
|
+
Array.map(rendered, (placement): Option.Option<PlacementIssue> => {
|
|
505
|
+
if (Option.isNone(placement.span)) return Option.none()
|
|
506
|
+
const kind = kinds.get(placement.slotId)
|
|
507
|
+
if (kind === undefined || kind === 'grid') return Option.none()
|
|
508
|
+
return Option.some({
|
|
509
|
+
check: 'span-in-non-grid',
|
|
510
|
+
severity: 'note',
|
|
511
|
+
placementId: placement.id,
|
|
512
|
+
consequence: `A ${String(placement.span.value)}/12 span is set but ${placement.slotId} is a ${kind} slot, so the span is dormant. It returns the moment the slot becomes a grid again.`,
|
|
513
|
+
action: 'No action needed',
|
|
514
|
+
})
|
|
515
|
+
}),
|
|
516
|
+
)
|
|
517
|
+
return { issues, unverifiable: [] }
|
|
518
|
+
}
|
|
519
|
+
|
|
399
520
|
/* ============================================================
|
|
400
521
|
The engine.
|
|
401
522
|
============================================================ */
|
|
402
523
|
|
|
403
524
|
/**
|
|
404
|
-
* Runs
|
|
405
|
-
* inputs, same report. Placements
|
|
406
|
-
* any check runs — they render for
|
|
407
|
-
* are checked like any other, the
|
|
525
|
+
* Runs all seven checks over a resolution's output — check 5 as its
|
|
526
|
+
* unverifiable reporter only. Pure: same inputs, same report. Placements
|
|
527
|
+
* overridden ENTIRELY are excluded before any check runs — they render for
|
|
528
|
+
* nobody — while partially-overridden ones are checked like any other, the
|
|
529
|
+
* reading-B qualifier.
|
|
408
530
|
*/
|
|
409
531
|
export const checkPlacements = (
|
|
410
532
|
resolved: ReadonlyArray<ResolvedPlacement>,
|
|
@@ -419,7 +541,10 @@ export const checkPlacements = (
|
|
|
419
541
|
checkSlotInTheme(rendered, context.slotManifest),
|
|
420
542
|
checkRequiredAttributes(rendered, context.manifests),
|
|
421
543
|
checkTwins(rendered),
|
|
544
|
+
checkAnchors(rendered),
|
|
422
545
|
checkWeightBudget(rendered, context),
|
|
546
|
+
checkSlotCapacity(rendered, context.slotManifest),
|
|
547
|
+
checkSpanInNonGrid(rendered, context.slotManifest),
|
|
423
548
|
]
|
|
424
549
|
return {
|
|
425
550
|
issues: Array.flatMap(reports, report => report.issues),
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { renderLayout } from './renderLayout.ts'
|
|
4
|
+
import { ensureSection, ensureSlot, pruneGeneratedContainers } from './slotDom.ts'
|
|
5
|
+
import { generatedSectionId } from './slotLayout.ts'
|
|
6
|
+
import type { SlotSpec } from './slots.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The reconciler's contract, clause by clause — every one of these fails
|
|
10
|
+
* silently in the browser when broken: a slot div without an id renders
|
|
11
|
+
* nothing on a clean console; a rebuild instead of a reconcile restarts
|
|
12
|
+
* every mounted custom element on every poll; a prune of a non-empty slot
|
|
13
|
+
* throws away a user's mounted dot.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const slot = (
|
|
17
|
+
overrides: Partial<SlotSpec> & Pick<SlotSpec, 'id'>,
|
|
18
|
+
): SlotSpec => ({
|
|
19
|
+
kind: 'band',
|
|
20
|
+
row: 0,
|
|
21
|
+
...overrides,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const manifestOf = (slots: ReadonlyArray<SlotSpec>): { slots: ReadonlyArray<SlotSpec> } => ({
|
|
25
|
+
slots,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
let root: HTMLElement
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
document.body.replaceChildren()
|
|
32
|
+
root = document.createElement('div')
|
|
33
|
+
root.id = 'generated-layout'
|
|
34
|
+
document.body.appendChild(root)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const MARKETING = [
|
|
38
|
+
slot({ id: 'site-header', kind: 'bar', row: 0, label: 'Header' }),
|
|
39
|
+
slot({ id: 'hero', kind: 'band', row: 1, label: 'Hero', description: 'The lead.' }),
|
|
40
|
+
slot({ id: 'sidebar', kind: 'rail', row: 2, width: '150px', label: 'Sidebar' }),
|
|
41
|
+
slot({ id: 'main', kind: 'grid', row: 2, label: 'Main' }),
|
|
42
|
+
slot({ id: 'aside', kind: 'rail', row: 2, width: '172px', label: 'Aside' }),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
describe('renderLayout — creation', () => {
|
|
46
|
+
test('every created slot div carries its declared id — the invariant', () => {
|
|
47
|
+
const report = renderLayout(manifestOf(MARKETING), root)
|
|
48
|
+
expect(report.created).toBe(5)
|
|
49
|
+
MARKETING.forEach(s => {
|
|
50
|
+
const div = document.getElementById(s.id)
|
|
51
|
+
expect(div).not.toBeNull()
|
|
52
|
+
expect(div?.getAttribute('data-microdots-layout')).toBe('slot')
|
|
53
|
+
expect(document.getElementById(generatedSectionId(s.id))).not.toBeNull()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rows group as declared: three row divs, the middle row holds rail/grid/rail', () => {
|
|
58
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
59
|
+
const rows = Array.from(root.children).filter(
|
|
60
|
+
el => el.getAttribute('data-microdots-layout') === 'row',
|
|
61
|
+
)
|
|
62
|
+
expect(rows.length).toBe(3)
|
|
63
|
+
const middle = rows[2]
|
|
64
|
+
expect(
|
|
65
|
+
Array.from(middle?.children ?? []).map(el => el.id),
|
|
66
|
+
).toEqual(['section-sidebar', 'section-main', 'section-aside'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('label and description render in section chrome, not in the slot div', () => {
|
|
70
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
71
|
+
const heroSection = document.getElementById('section-hero')
|
|
72
|
+
expect(heroSection?.textContent).toContain('Hero')
|
|
73
|
+
expect(heroSection?.textContent).toContain('The lead.')
|
|
74
|
+
expect(document.getElementById('hero')?.childElementCount).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('geometry lands as inline styles: fixed rail width via the resize var, grid columns', () => {
|
|
78
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
79
|
+
const sidebarSection = document.getElementById('section-sidebar')
|
|
80
|
+
expect(sidebarSection?.getAttribute('style')).toContain(
|
|
81
|
+
'var(--slot-size-sidebar,150px)',
|
|
82
|
+
)
|
|
83
|
+
const mainSlot = document.getElementById('main')
|
|
84
|
+
expect(mainSlot?.getAttribute('style')).toContain(
|
|
85
|
+
'repeat(12, minmax(0, 1fr))',
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a generated host with no manifest renders an empty layout (D6)', () => {
|
|
90
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
91
|
+
const report = renderLayout(manifestOf([]), root)
|
|
92
|
+
expect(report.removed).toBe(5)
|
|
93
|
+
expect(root.querySelectorAll('section').length).toBe(0)
|
|
94
|
+
expect(root.children.length).toBe(0)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('renderLayout — idempotence and reconciliation', () => {
|
|
99
|
+
test('equal input keeps node identity and reports all zeros', () => {
|
|
100
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
101
|
+
const heroBefore = document.getElementById('section-hero')
|
|
102
|
+
const slotBefore = document.getElementById('hero')
|
|
103
|
+
const report = renderLayout(manifestOf(MARKETING), root)
|
|
104
|
+
expect(report).toEqual({
|
|
105
|
+
created: 0,
|
|
106
|
+
relabelled: 0,
|
|
107
|
+
moved: 0,
|
|
108
|
+
removed: 0,
|
|
109
|
+
deferred: [],
|
|
110
|
+
})
|
|
111
|
+
expect(document.getElementById('section-hero')).toBe(heroBefore)
|
|
112
|
+
expect(document.getElementById('hero')).toBe(slotBefore)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('a copy edit reconciles textContent in place — no remount of the mounted child', () => {
|
|
116
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
117
|
+
const mounted = document.createElement('div')
|
|
118
|
+
mounted.textContent = 'a mounted dot'
|
|
119
|
+
document.getElementById('hero')?.appendChild(mounted)
|
|
120
|
+
|
|
121
|
+
const edited = MARKETING.map(s =>
|
|
122
|
+
s.id === 'hero' ? { ...s, label: 'Big hero', description: 'New copy.' } : s,
|
|
123
|
+
)
|
|
124
|
+
const report = renderLayout(manifestOf(edited), root)
|
|
125
|
+
expect(report.relabelled).toBe(1)
|
|
126
|
+
expect(report.moved).toBe(0)
|
|
127
|
+
expect(document.getElementById('section-hero')?.textContent).toContain(
|
|
128
|
+
'Big hero',
|
|
129
|
+
)
|
|
130
|
+
// The mounted element is the SAME node — copy never remounts.
|
|
131
|
+
expect(document.getElementById('hero')?.firstElementChild).toBe(mounted)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('a row move relocates only the affected section and reports it', () => {
|
|
135
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
136
|
+
const heroSection = document.getElementById('section-hero')
|
|
137
|
+
const moved = MARKETING.map(s => (s.id === 'hero' ? { ...s, row: 2 } : s))
|
|
138
|
+
const report = renderLayout(manifestOf(moved), root)
|
|
139
|
+
expect(report.moved).toBe(1)
|
|
140
|
+
expect(report.created).toBe(0)
|
|
141
|
+
// Same node, new row div — the move is a reparent, not a rebuild.
|
|
142
|
+
expect(document.getElementById('section-hero')).toBe(heroSection)
|
|
143
|
+
const rows = Array.from(root.children).filter(
|
|
144
|
+
el => el.getAttribute('data-microdots-layout') === 'row',
|
|
145
|
+
)
|
|
146
|
+
expect(rows[1]?.querySelector('#section-hero')).not.toBeNull()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('an in-row order swap moves exactly one section', () => {
|
|
150
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
151
|
+
const swapped = [
|
|
152
|
+
MARKETING[0],
|
|
153
|
+
MARKETING[1],
|
|
154
|
+
MARKETING[3], // main before sidebar
|
|
155
|
+
MARKETING[2],
|
|
156
|
+
MARKETING[4],
|
|
157
|
+
].flatMap(s => (s === undefined ? [] : [s]))
|
|
158
|
+
const report = renderLayout(manifestOf(swapped), root)
|
|
159
|
+
expect(report.moved).toBe(1)
|
|
160
|
+
const middle = Array.from(root.children).filter(
|
|
161
|
+
el => el.getAttribute('data-microdots-layout') === 'row',
|
|
162
|
+
)[2]
|
|
163
|
+
expect(Array.from(middle?.children ?? []).map(el => el.id)).toEqual([
|
|
164
|
+
'section-main',
|
|
165
|
+
'section-sidebar',
|
|
166
|
+
'section-aside',
|
|
167
|
+
])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('a kind flip clears the stale grid properties from the slot div', () => {
|
|
171
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
172
|
+
const flipped = MARKETING.map(s =>
|
|
173
|
+
s.id === 'main' ? { ...s, kind: 'band' as const } : s,
|
|
174
|
+
)
|
|
175
|
+
renderLayout(manifestOf(flipped), root)
|
|
176
|
+
const style = document.getElementById('main')?.getAttribute('style') ?? ''
|
|
177
|
+
expect(style).not.toContain('grid-template-columns')
|
|
178
|
+
expect(style).toContain('flex-direction: column')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a resize variable set on the section survives re-render', () => {
|
|
182
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
183
|
+
const section = document.getElementById('section-sidebar')
|
|
184
|
+
section?.style.setProperty('--slot-size-sidebar', '220px')
|
|
185
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
186
|
+
expect(
|
|
187
|
+
document
|
|
188
|
+
.getElementById('section-sidebar')
|
|
189
|
+
?.style.getPropertyValue('--slot-size-sidebar'),
|
|
190
|
+
).toBe('220px')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('renderLayout — markup wins', () => {
|
|
195
|
+
test('a slot id owned by non-layout markup is untouched and deferred, with a warning', () => {
|
|
196
|
+
const stranger = document.createElement('div')
|
|
197
|
+
stranger.id = 'hero'
|
|
198
|
+
stranger.textContent = 'hand-written'
|
|
199
|
+
document.body.appendChild(stranger)
|
|
200
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
201
|
+
const report = renderLayout(manifestOf(MARKETING), root)
|
|
202
|
+
expect(report.deferred).toContain('hero')
|
|
203
|
+
expect(report.created).toBe(4)
|
|
204
|
+
expect(document.getElementById('hero')).toBe(stranger)
|
|
205
|
+
expect(stranger.textContent).toBe('hand-written')
|
|
206
|
+
expect(warn).toHaveBeenCalled()
|
|
207
|
+
warn.mockRestore()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('a section id owned by non-layout markup defers the same way', () => {
|
|
211
|
+
const stranger = document.createElement('section')
|
|
212
|
+
stranger.id = 'section-hero'
|
|
213
|
+
document.body.appendChild(stranger)
|
|
214
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
215
|
+
const report = renderLayout(manifestOf(MARKETING), root)
|
|
216
|
+
expect(report.deferred).toContain('hero')
|
|
217
|
+
expect(document.getElementById('section-hero')).toBe(stranger)
|
|
218
|
+
warn.mockRestore()
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('renderLayout — pruning', () => {
|
|
223
|
+
test('a stale empty section is pruned; a non-empty one is deferred until unmounted', () => {
|
|
224
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
225
|
+
const mounted = document.createElement('div')
|
|
226
|
+
document.getElementById('hero')?.appendChild(mounted)
|
|
227
|
+
|
|
228
|
+
const withoutHeroAndHeader = MARKETING.filter(
|
|
229
|
+
s => s.id !== 'hero' && s.id !== 'site-header',
|
|
230
|
+
)
|
|
231
|
+
const report = renderLayout(manifestOf(withoutHeroAndHeader), root)
|
|
232
|
+
// site-header (empty) pruned; hero (mounted) deferred.
|
|
233
|
+
expect(report.removed).toBe(1)
|
|
234
|
+
expect(report.deferred).toEqual(['hero'])
|
|
235
|
+
expect(document.getElementById('section-site-header')).toBeNull()
|
|
236
|
+
expect(document.getElementById('section-hero')).not.toBeNull()
|
|
237
|
+
|
|
238
|
+
// The mount loop unmounts, the next pass completes the prune.
|
|
239
|
+
mounted.remove()
|
|
240
|
+
const second = renderLayout(manifestOf(withoutHeroAndHeader), root)
|
|
241
|
+
expect(second.removed).toBe(1)
|
|
242
|
+
expect(document.getElementById('section-hero')).toBeNull()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('pruneGeneratedContainers never deletes a declared-but-empty generated slot', () => {
|
|
246
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
247
|
+
// The slotDom pruner looks for data-generated, which layout nodes never
|
|
248
|
+
// carry — the two generation paths are provably disjoint.
|
|
249
|
+
const removed = pruneGeneratedContainers(root)
|
|
250
|
+
expect(removed).toBe(0)
|
|
251
|
+
expect(document.getElementById('hero')).not.toBeNull()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('slotDom-generated containers and layout sections coexist', () => {
|
|
255
|
+
renderLayout(manifestOf(MARKETING), root)
|
|
256
|
+
const section = ensureSection('legacy-section', root, 'x')
|
|
257
|
+
ensureSlot('legacy-slot', section, 'x')
|
|
258
|
+
const report = renderLayout(manifestOf(MARKETING), root)
|
|
259
|
+
// The reconciler leaves slotDom's containers alone entirely.
|
|
260
|
+
expect(report).toMatchObject({ created: 0, removed: 0 })
|
|
261
|
+
expect(document.getElementById('legacy-slot')).not.toBeNull()
|
|
262
|
+
})
|
|
263
|
+
})
|