@bespokeagentics/microdots-host 0.1.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.
- package/LICENSE +202 -0
- package/package.json +25 -0
- package/src/index.ts +144 -0
- package/src/loader.ts +119 -0
- package/src/mounting.test.ts +214 -0
- package/src/mounting.ts +90 -0
- package/src/placementChecks.test.ts +593 -0
- package/src/placementChecks.ts +428 -0
- package/src/registry.ts +63 -0
- package/src/routes.ts +156 -0
- package/src/rules.test.ts +598 -0
- package/src/rules.ts +448 -0
- package/src/slots.test.ts +46 -0
- package/src/slots.ts +58 -0
- package/src/wire.test.ts +631 -0
- package/src/wire.ts +560 -0
- package/src/wireEngine.test.ts +810 -0
- package/src/wireEngine.ts +468 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { Option } from 'effect'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { resolvePlacements, ruleMatches } from './rules.ts'
|
|
5
|
+
import type {
|
|
6
|
+
HostTopology,
|
|
7
|
+
RouteRule,
|
|
8
|
+
TopologyPlacement,
|
|
9
|
+
TopologyRoute,
|
|
10
|
+
} from './wire.ts'
|
|
11
|
+
|
|
12
|
+
/* ============================================================
|
|
13
|
+
Fixture builders — the smallest topology that can host a contest: one or
|
|
14
|
+
two routes, zero or more rules, placements with explicit ids so the
|
|
15
|
+
override assertions can name winners and losers without re-deriving the
|
|
16
|
+
synthesized fallback.
|
|
17
|
+
============================================================ */
|
|
18
|
+
|
|
19
|
+
const route = (
|
|
20
|
+
path: string,
|
|
21
|
+
mounts: ReadonlyArray<TopologyPlacement>,
|
|
22
|
+
): TopologyRoute => ({
|
|
23
|
+
path,
|
|
24
|
+
label: path,
|
|
25
|
+
title: path,
|
|
26
|
+
sectionIds: [],
|
|
27
|
+
mounts,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const topologyOf = (
|
|
31
|
+
routes: readonly [TopologyRoute, ...Array<TopologyRoute>],
|
|
32
|
+
rules?: ReadonlyArray<RouteRule>,
|
|
33
|
+
): HostTopology => ({
|
|
34
|
+
host: { id: 'test-host', label: 'Test host', ownedInputs: [] },
|
|
35
|
+
routes,
|
|
36
|
+
...(rules === undefined ? {} : { rules }),
|
|
37
|
+
wires: [],
|
|
38
|
+
watch: [],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const patternRule = (
|
|
42
|
+
id: string,
|
|
43
|
+
pattern: string,
|
|
44
|
+
placements: ReadonlyArray<TopologyPlacement>,
|
|
45
|
+
): RouteRule => ({
|
|
46
|
+
id,
|
|
47
|
+
kind: 'pattern',
|
|
48
|
+
label: `Rule ${id}`,
|
|
49
|
+
pattern,
|
|
50
|
+
placements,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const groupRule = (
|
|
54
|
+
id: string,
|
|
55
|
+
paths: ReadonlyArray<string>,
|
|
56
|
+
placements: ReadonlyArray<TopologyPlacement>,
|
|
57
|
+
): RouteRule => ({ id, kind: 'group', label: `Rule ${id}`, paths, placements })
|
|
58
|
+
|
|
59
|
+
const dynamicRule = (
|
|
60
|
+
id: string,
|
|
61
|
+
template: string,
|
|
62
|
+
placements: ReadonlyArray<TopologyPlacement>,
|
|
63
|
+
): RouteRule => ({
|
|
64
|
+
id,
|
|
65
|
+
kind: 'dynamic',
|
|
66
|
+
label: `Rule ${id}`,
|
|
67
|
+
template,
|
|
68
|
+
placements,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// The failure mode: this is THE matcher (the phase's ruling 1) — the runtime
|
|
72
|
+
// resolution and the Pages screen's per-rule match counts both call it, so a
|
|
73
|
+
// wrong match here is simultaneously a dot mounted on the wrong page and a
|
|
74
|
+
// screen count nobody can reconcile with what actually renders.
|
|
75
|
+
describe('ruleMatches', () => {
|
|
76
|
+
test('the bare /* glob matches every path', () => {
|
|
77
|
+
const rule = patternRule('r1', '/*', [])
|
|
78
|
+
expect(ruleMatches(rule, '/')).toBe(true)
|
|
79
|
+
expect(ruleMatches(rule, '/apps')).toBe(true)
|
|
80
|
+
expect(ruleMatches(rule, '/docs/rpc-contracts')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('a prefixed glob matches strict descendants of the prefix only', () => {
|
|
84
|
+
const rule = patternRule('r1', '/blog/*', [])
|
|
85
|
+
expect(ruleMatches(rule, '/blog/effect-first-ports')).toBe(true)
|
|
86
|
+
expect(ruleMatches(rule, '/blog/2026/august')).toBe(true)
|
|
87
|
+
expect(ruleMatches(rule, '/blog')).toBe(false)
|
|
88
|
+
expect(ruleMatches(rule, '/blogging')).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a pattern without the wildcard is an exact match', () => {
|
|
92
|
+
const rule = patternRule('r1', '/pricing', [])
|
|
93
|
+
expect(ruleMatches(rule, '/pricing')).toBe(true)
|
|
94
|
+
expect(ruleMatches(rule, '/pricing/plans')).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('a group rule matches exactly its listed paths', () => {
|
|
98
|
+
const rule = groupRule('r1', ['/apps', '/builds', '/deploys'], [])
|
|
99
|
+
expect(ruleMatches(rule, '/apps')).toBe(true)
|
|
100
|
+
expect(ruleMatches(rule, '/deploys')).toBe(true)
|
|
101
|
+
expect(ruleMatches(rule, '/wiring')).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a dynamic template matches segment-wise, one segment per param', () => {
|
|
105
|
+
const rule = dynamicRule('r1', '/docs/:slug', [])
|
|
106
|
+
expect(ruleMatches(rule, '/docs/rpc-contracts')).toBe(true)
|
|
107
|
+
expect(ruleMatches(rule, '/docs')).toBe(false)
|
|
108
|
+
expect(ruleMatches(rule, '/docs/a/b')).toBe(false)
|
|
109
|
+
expect(ruleMatches(rule, '/blog/rpc-contracts')).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/* ============================================================
|
|
114
|
+
resolvePlacements — layering, the reading-B contest, provenance.
|
|
115
|
+
============================================================ */
|
|
116
|
+
|
|
117
|
+
// The failure mode: both shells' `activate` mounts THIS function's output
|
|
118
|
+
// instead of raw `route.mounts` (the ruling-A dogfood), so a resolution that
|
|
119
|
+
// invents, drops or mis-attributes a placement is a page rendering the wrong
|
|
120
|
+
// dots — and the Pages screen's override badges narrating a contest that
|
|
121
|
+
// never happened.
|
|
122
|
+
describe('resolvePlacements', () => {
|
|
123
|
+
test('a no-rules topology resolves a single mount to the fully-defaulted identity', () => {
|
|
124
|
+
const topology = topologyOf([
|
|
125
|
+
route('/price', [{ tag: 'price-ticker', slotId: 'price-slot' }]),
|
|
126
|
+
])
|
|
127
|
+
// The whole record, pinned: defaults made explicit (condition axes to
|
|
128
|
+
// 'any', envs to all three, values to {}), route provenance, and the
|
|
129
|
+
// synthesized stable id Phase-4 records get — screen selection and check
|
|
130
|
+
// issues depend on that id never changing shape.
|
|
131
|
+
expect(resolvePlacements(topology, '/price', 'dev')).toEqual([
|
|
132
|
+
{
|
|
133
|
+
id: '/price|0|route:price-ticker@price-slot#0',
|
|
134
|
+
tag: 'price-ticker',
|
|
135
|
+
slotId: 'price-slot',
|
|
136
|
+
source: { _tag: 'route' },
|
|
137
|
+
condition: { who: 'any', device: 'any', locale: 'any' },
|
|
138
|
+
envs: ['dev', 'preview', 'prod'],
|
|
139
|
+
values: {},
|
|
140
|
+
span: Option.none(),
|
|
141
|
+
order: Option.none(),
|
|
142
|
+
state: 'active',
|
|
143
|
+
overriddenBy: [],
|
|
144
|
+
},
|
|
145
|
+
])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a no-rules topology resolves to exactly the route’s own mounts — the Phase-4 identity', () => {
|
|
149
|
+
const mounts: ReadonlyArray<TopologyPlacement> = [
|
|
150
|
+
{ tag: 'price-ticker', slotId: 'price-slot' },
|
|
151
|
+
{ tag: 'fleet-health', slotId: 'fleet-slot' },
|
|
152
|
+
]
|
|
153
|
+
const topology = topologyOf([route('/all', mounts)])
|
|
154
|
+
const resolved = resolvePlacements(topology, '/all', 'dev')
|
|
155
|
+
// The backward-compat pin: what the shells mount is the (tag, slotId)
|
|
156
|
+
// projection, and with no rules it must equal `route.mounts` byte for
|
|
157
|
+
// byte — Phase 4 behaviour, unchanged.
|
|
158
|
+
expect(
|
|
159
|
+
resolved.map(item => ({ tag: item.tag, slotId: item.slotId })),
|
|
160
|
+
).toEqual(mounts)
|
|
161
|
+
expect(resolved.map(item => item.state)).toEqual(['active', 'active'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('layers matching rules in declared order with the route’s own placements last', () => {
|
|
165
|
+
const topology = topologyOf(
|
|
166
|
+
[
|
|
167
|
+
route('/apps', [
|
|
168
|
+
{ id: 'from-route', tag: 'workbench-brief', slotId: 'brief-slot' },
|
|
169
|
+
]),
|
|
170
|
+
],
|
|
171
|
+
[
|
|
172
|
+
patternRule('r0', '/blog/*', [
|
|
173
|
+
{ id: 'never-here', tag: 'blog-toc', slotId: 'side-slot' },
|
|
174
|
+
]),
|
|
175
|
+
patternRule('r1', '/*', [
|
|
176
|
+
{ id: 'from-r1', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
177
|
+
]),
|
|
178
|
+
groupRule(
|
|
179
|
+
'r2',
|
|
180
|
+
['/apps'],
|
|
181
|
+
[{ id: 'from-r2', tag: 'nav-crumbs', slotId: 'top-bar' }],
|
|
182
|
+
),
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
const resolved = resolvePlacements(topology, '/apps', 'dev')
|
|
186
|
+
expect(resolved.map(item => item.id)).toEqual([
|
|
187
|
+
'from-r1',
|
|
188
|
+
'from-r2',
|
|
189
|
+
'from-route',
|
|
190
|
+
])
|
|
191
|
+
expect(resolved.map(item => item.source)).toEqual([
|
|
192
|
+
{ _tag: 'rule', ruleId: 'r1', label: 'Rule r1' },
|
|
193
|
+
{ _tag: 'rule', ruleId: 'r2', label: 'Rule r2' },
|
|
194
|
+
{ _tag: 'route' },
|
|
195
|
+
])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('the route’s own placement overrides a rule’s entirely when it covers its whole condition', () => {
|
|
199
|
+
const topology = topologyOf(
|
|
200
|
+
[
|
|
201
|
+
route('/home', [
|
|
202
|
+
{ id: 'winner', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
203
|
+
]),
|
|
204
|
+
],
|
|
205
|
+
[
|
|
206
|
+
patternRule('r1', '/*', [
|
|
207
|
+
{ id: 'loser', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
208
|
+
]),
|
|
209
|
+
],
|
|
210
|
+
)
|
|
211
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
212
|
+
// The loser is KEPT — the spec renders it struck-through, not deleted —
|
|
213
|
+
// with provenance naming who beat it and where. A resolution that drops
|
|
214
|
+
// losers passes every "what renders" assertion and fails here.
|
|
215
|
+
expect(resolved.map(item => ({ id: item.id, state: item.state }))).toEqual([
|
|
216
|
+
{ id: 'loser', state: 'overridden' },
|
|
217
|
+
{ id: 'winner', state: 'active' },
|
|
218
|
+
])
|
|
219
|
+
expect(resolved.map(item => item.overriddenBy)).toEqual([
|
|
220
|
+
[
|
|
221
|
+
{
|
|
222
|
+
by: { _tag: 'route' },
|
|
223
|
+
winnerId: 'winner',
|
|
224
|
+
where: { who: 'any', device: 'any', locale: 'any' },
|
|
225
|
+
entire: true,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
[],
|
|
229
|
+
])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('a conditioned winner overrides only where the conditions overlap, and the badge names that region', () => {
|
|
233
|
+
const topology = topologyOf(
|
|
234
|
+
[
|
|
235
|
+
route('/home', [
|
|
236
|
+
{
|
|
237
|
+
id: 'winner',
|
|
238
|
+
tag: 'promo-banner',
|
|
239
|
+
slotId: 'hero-slot',
|
|
240
|
+
condition: { locale: 'fr' },
|
|
241
|
+
},
|
|
242
|
+
]),
|
|
243
|
+
],
|
|
244
|
+
[
|
|
245
|
+
patternRule('r1', '/*', [
|
|
246
|
+
{ id: 'loser', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
247
|
+
]),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
251
|
+
// Reading B: the unconditioned loser still renders for everyone outside
|
|
252
|
+
// `fr`, so it is partially overridden — and `where` carries the exact
|
|
253
|
+
// overlap so the screen's badge can say "overridden for fr".
|
|
254
|
+
expect(
|
|
255
|
+
resolved.map(item => ({
|
|
256
|
+
id: item.id,
|
|
257
|
+
state: item.state,
|
|
258
|
+
overriddenBy: item.overriddenBy,
|
|
259
|
+
})),
|
|
260
|
+
).toEqual([
|
|
261
|
+
{
|
|
262
|
+
id: 'loser',
|
|
263
|
+
state: 'partially-overridden',
|
|
264
|
+
overriddenBy: [
|
|
265
|
+
{
|
|
266
|
+
by: { _tag: 'route' },
|
|
267
|
+
winnerId: 'winner',
|
|
268
|
+
where: { who: 'any', device: 'any', locale: 'fr' },
|
|
269
|
+
entire: false,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
{ id: 'winner', state: 'active', overriddenBy: [] },
|
|
274
|
+
])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('disjoint conditions do not contest — both placements stay active', () => {
|
|
278
|
+
const topology = topologyOf(
|
|
279
|
+
[
|
|
280
|
+
route('/home', [
|
|
281
|
+
{
|
|
282
|
+
id: 'english',
|
|
283
|
+
tag: 'promo-banner',
|
|
284
|
+
slotId: 'hero-slot',
|
|
285
|
+
condition: { locale: 'en' },
|
|
286
|
+
},
|
|
287
|
+
]),
|
|
288
|
+
],
|
|
289
|
+
[
|
|
290
|
+
patternRule('r1', '/*', [
|
|
291
|
+
{
|
|
292
|
+
id: 'french',
|
|
293
|
+
tag: 'promo-banner',
|
|
294
|
+
slotId: 'hero-slot',
|
|
295
|
+
condition: { locale: 'fr' },
|
|
296
|
+
},
|
|
297
|
+
]),
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
301
|
+
// The overlap-inversion kill: `fr` vs `en` differ on one axis, so under
|
|
302
|
+
// the real rule (every axis either-side-'any'-or-equal) there is NO
|
|
303
|
+
// overlap and NO override. An inverted overlap predicate would contest
|
|
304
|
+
// exactly this pair and fail here.
|
|
305
|
+
expect(resolved.map(item => ({ id: item.id, state: item.state }))).toEqual([
|
|
306
|
+
{ id: 'french', state: 'active' },
|
|
307
|
+
{ id: 'english', state: 'active' },
|
|
308
|
+
])
|
|
309
|
+
expect(resolved.map(item => item.overriddenBy)).toEqual([[], []])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test("the literal string 'any' on an axis overlaps exactly like an absent axis", () => {
|
|
313
|
+
const topology = topologyOf(
|
|
314
|
+
[
|
|
315
|
+
route('/home', [
|
|
316
|
+
{
|
|
317
|
+
id: 'winner',
|
|
318
|
+
tag: 'promo-banner',
|
|
319
|
+
slotId: 'hero-slot',
|
|
320
|
+
condition: { who: 'any', device: 'any' },
|
|
321
|
+
},
|
|
322
|
+
]),
|
|
323
|
+
],
|
|
324
|
+
[
|
|
325
|
+
patternRule('r1', '/*', [
|
|
326
|
+
{
|
|
327
|
+
id: 'loser',
|
|
328
|
+
tag: 'promo-banner',
|
|
329
|
+
slotId: 'hero-slot',
|
|
330
|
+
condition: { who: 'admin', locale: 'any' },
|
|
331
|
+
},
|
|
332
|
+
]),
|
|
333
|
+
],
|
|
334
|
+
)
|
|
335
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
336
|
+
// Every axis pair here mixes a literal 'any' with an absent axis or a
|
|
337
|
+
// narrow value — if the literal were treated as its own value ('any' ≠
|
|
338
|
+
// absence), no axis would equal its counterpart and the contest would
|
|
339
|
+
// never fire.
|
|
340
|
+
expect(resolved.map(item => ({ id: item.id, state: item.state }))).toEqual([
|
|
341
|
+
{ id: 'loser', state: 'overridden' },
|
|
342
|
+
{ id: 'winner', state: 'active' },
|
|
343
|
+
])
|
|
344
|
+
expect(resolved.map(item => item.overriddenBy)).toEqual([
|
|
345
|
+
[
|
|
346
|
+
{
|
|
347
|
+
by: { _tag: 'route' },
|
|
348
|
+
winnerId: 'winner',
|
|
349
|
+
where: { who: 'admin', device: 'any', locale: 'any' },
|
|
350
|
+
entire: true,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
[],
|
|
354
|
+
])
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('filters placements to the requested environment', () => {
|
|
358
|
+
const topology = topologyOf([
|
|
359
|
+
route('/home', [
|
|
360
|
+
{ id: 'everywhere', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
361
|
+
{
|
|
362
|
+
id: 'dev-only',
|
|
363
|
+
tag: 'debug-panel',
|
|
364
|
+
slotId: 'side-slot',
|
|
365
|
+
envs: ['dev'],
|
|
366
|
+
},
|
|
367
|
+
]),
|
|
368
|
+
])
|
|
369
|
+
expect(
|
|
370
|
+
resolvePlacements(topology, '/home', 'dev').map(item => item.id),
|
|
371
|
+
).toEqual(['everywhere', 'dev-only'])
|
|
372
|
+
expect(
|
|
373
|
+
resolvePlacements(topology, '/home', 'prod').map(item => item.id),
|
|
374
|
+
).toEqual(['everywhere'])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('a placement absent from the environment cannot override there either', () => {
|
|
378
|
+
const topology = topologyOf(
|
|
379
|
+
[
|
|
380
|
+
route('/home', [
|
|
381
|
+
{
|
|
382
|
+
id: 'dev-winner',
|
|
383
|
+
tag: 'promo-banner',
|
|
384
|
+
slotId: 'hero-slot',
|
|
385
|
+
envs: ['dev'],
|
|
386
|
+
},
|
|
387
|
+
]),
|
|
388
|
+
],
|
|
389
|
+
[
|
|
390
|
+
patternRule('r1', '/*', [
|
|
391
|
+
{ id: 'base', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
392
|
+
]),
|
|
393
|
+
],
|
|
394
|
+
)
|
|
395
|
+
// In dev the route placement exists and wins; in prod it does not exist,
|
|
396
|
+
// so the rule's placement must come back ACTIVE — an override by a
|
|
397
|
+
// placement that cannot render here would blank the slot for real users.
|
|
398
|
+
expect(
|
|
399
|
+
resolvePlacements(topology, '/home', 'dev').map(item => ({
|
|
400
|
+
id: item.id,
|
|
401
|
+
state: item.state,
|
|
402
|
+
})),
|
|
403
|
+
).toEqual([
|
|
404
|
+
{ id: 'base', state: 'overridden' },
|
|
405
|
+
{ id: 'dev-winner', state: 'active' },
|
|
406
|
+
])
|
|
407
|
+
expect(
|
|
408
|
+
resolvePlacements(topology, '/home', 'prod').map(item => ({
|
|
409
|
+
id: item.id,
|
|
410
|
+
state: item.state,
|
|
411
|
+
})),
|
|
412
|
+
).toEqual([{ id: 'base', state: 'active' }])
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('rule order is load-bearing: the later-declared rule wins the contest', () => {
|
|
416
|
+
const topology = topologyOf(
|
|
417
|
+
[route('/apps', [])],
|
|
418
|
+
[
|
|
419
|
+
patternRule('r1', '/*', [
|
|
420
|
+
{ id: 'first', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
421
|
+
]),
|
|
422
|
+
groupRule(
|
|
423
|
+
'r2',
|
|
424
|
+
['/apps'],
|
|
425
|
+
[{ id: 'second', tag: 'promo-banner', slotId: 'hero-slot' }],
|
|
426
|
+
),
|
|
427
|
+
],
|
|
428
|
+
)
|
|
429
|
+
const resolved = resolvePlacements(topology, '/apps', 'dev')
|
|
430
|
+
// The order-mutation kill: ignoring declared order puts both rules in one
|
|
431
|
+
// layer (no override at all), and reversing it flips winner and loser —
|
|
432
|
+
// either way this exact pairing fails.
|
|
433
|
+
expect(resolved.map(item => ({ id: item.id, state: item.state }))).toEqual([
|
|
434
|
+
{ id: 'first', state: 'overridden' },
|
|
435
|
+
{ id: 'second', state: 'active' },
|
|
436
|
+
])
|
|
437
|
+
expect(
|
|
438
|
+
resolved.flatMap(item =>
|
|
439
|
+
item.overriddenBy.map(override => override.winnerId),
|
|
440
|
+
),
|
|
441
|
+
).toEqual(['second'])
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
/* ============================================================
|
|
445
|
+
Resolved ids. The failure mode: EVERY downstream reference to a
|
|
446
|
+
placement is its id — `overriddenBy.winnerId`, every check issue's
|
|
447
|
+
`placementId`, and the Pages screen's selection across polls and env
|
|
448
|
+
switches. Two placements sharing one id is therefore three bugs at
|
|
449
|
+
once: the inspector shows the first while merging BOTH placements'
|
|
450
|
+
issues onto it, the canvas highlights two cards for one selection, and
|
|
451
|
+
`overridePair` (placementChecks.ts) matches `winnerId === other.id` and
|
|
452
|
+
SUPPRESSES a legitimate check-4 warning. None of it is caught by
|
|
453
|
+
anything downstream — this is the only place it can be.
|
|
454
|
+
============================================================ */
|
|
455
|
+
describe('resolved ids are unique and stable', () => {
|
|
456
|
+
test('a rule placement matching two routes gets a DIFFERENT id on each', () => {
|
|
457
|
+
const topology = topologyOf(
|
|
458
|
+
[route('/price', []), route('/fleet', [])],
|
|
459
|
+
[
|
|
460
|
+
patternRule('r1', '/*', [
|
|
461
|
+
{ tag: 'promo-banner', slotId: 'hero-slot' },
|
|
462
|
+
]),
|
|
463
|
+
],
|
|
464
|
+
)
|
|
465
|
+
// The demo host's derived overview resolves as the UNION of every
|
|
466
|
+
// component route's resolution, so a `/*` rule's one placement lands in
|
|
467
|
+
// that union once per route. Without the path component every one of
|
|
468
|
+
// them carries the same id and the union is a pile of collisions.
|
|
469
|
+
const onPrice = resolvePlacements(topology, '/price', 'dev').map(
|
|
470
|
+
item => item.id,
|
|
471
|
+
)
|
|
472
|
+
const onFleet = resolvePlacements(topology, '/fleet', 'dev').map(
|
|
473
|
+
item => item.id,
|
|
474
|
+
)
|
|
475
|
+
expect(onPrice).toEqual(['/price|0|r1:promo-banner@hero-slot#0'])
|
|
476
|
+
expect(onFleet).toEqual(['/fleet|0|r1:promo-banner@hero-slot#0'])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('a rule whose id is literally "route" does not collide with the route layer', () => {
|
|
480
|
+
const topology = topologyOf(
|
|
481
|
+
[route('/home', [{ tag: 'promo-banner', slotId: 'hero-slot' }])],
|
|
482
|
+
[
|
|
483
|
+
patternRule('route', '/*', [
|
|
484
|
+
{ tag: 'promo-banner', slotId: 'hero-slot' },
|
|
485
|
+
]),
|
|
486
|
+
],
|
|
487
|
+
)
|
|
488
|
+
// Same tag, same slot, index 0 in both layers, and the rule's id IS the
|
|
489
|
+
// route layer's source key — everything but the layer number is equal,
|
|
490
|
+
// which is why the layer number is in the synthesized key.
|
|
491
|
+
const ids = resolvePlacements(topology, '/home', 'dev').map(
|
|
492
|
+
item => item.id,
|
|
493
|
+
)
|
|
494
|
+
expect(new Set(ids).size).toBe(2)
|
|
495
|
+
expect(ids).toEqual([
|
|
496
|
+
'/home|0|route:promo-banner@hero-slot#0',
|
|
497
|
+
'/home|1|route:promo-banner@hero-slot#0',
|
|
498
|
+
])
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('two placements declaring the SAME id are both disambiguated, neither keeping the bare id', () => {
|
|
502
|
+
const topology = topologyOf(
|
|
503
|
+
[
|
|
504
|
+
route('/home', [
|
|
505
|
+
{ id: 'dup', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
506
|
+
]),
|
|
507
|
+
],
|
|
508
|
+
[
|
|
509
|
+
patternRule('r1', '/*', [
|
|
510
|
+
{ id: 'dup', tag: 'nav-crumbs', slotId: 'top-bar' },
|
|
511
|
+
]),
|
|
512
|
+
],
|
|
513
|
+
)
|
|
514
|
+
const ids = resolvePlacements(topology, '/home', 'dev').map(
|
|
515
|
+
item => item.id,
|
|
516
|
+
)
|
|
517
|
+
// Symmetric on purpose: a first-wins rule would let one of the pair keep
|
|
518
|
+
// `dup`, which reads as authoritative and hides that a second placement
|
|
519
|
+
// is masquerading as it.
|
|
520
|
+
expect(ids).toEqual([
|
|
521
|
+
'dup~/home|0|r1:nav-crumbs@top-bar#0',
|
|
522
|
+
'dup~/home|1|route:promo-banner@hero-slot#0',
|
|
523
|
+
])
|
|
524
|
+
expect(new Set(ids).size).toBe(2)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('a UNIQUE declared id is handed back verbatim — the screen selects by it', () => {
|
|
528
|
+
const topology = topologyOf([
|
|
529
|
+
route('/home', [
|
|
530
|
+
{ id: 'hero', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
531
|
+
{ id: 'crumbs', tag: 'nav-crumbs', slotId: 'top-bar' },
|
|
532
|
+
]),
|
|
533
|
+
])
|
|
534
|
+
expect(
|
|
535
|
+
resolvePlacements(topology, '/home', 'dev').map(item => item.id),
|
|
536
|
+
).toEqual(['hero', 'crumbs'])
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test('an id does not move when the ENV filter drops a sibling', () => {
|
|
540
|
+
const topology = topologyOf([
|
|
541
|
+
route('/home', [
|
|
542
|
+
{ tag: 'debug-panel', slotId: 'side-slot', envs: ['dev'] },
|
|
543
|
+
{ tag: 'promo-banner', slotId: 'hero-slot' },
|
|
544
|
+
]),
|
|
545
|
+
])
|
|
546
|
+
// The Pages screen keeps its selection across an env switch, so the
|
|
547
|
+
// synthesized index must be the index within the UNFILTERED layer — a
|
|
548
|
+
// running index over the filtered set would renumber the banner from #1
|
|
549
|
+
// to #0 the moment the reader switched to prod.
|
|
550
|
+
const inDev = resolvePlacements(topology, '/home', 'dev')
|
|
551
|
+
const inProd = resolvePlacements(topology, '/home', 'prod')
|
|
552
|
+
expect(inProd.map(item => item.id)).toEqual([
|
|
553
|
+
'/home|0|route:promo-banner@hero-slot#1',
|
|
554
|
+
])
|
|
555
|
+
expect(
|
|
556
|
+
inDev.filter(item => item.tag === 'promo-banner').map(item => item.id),
|
|
557
|
+
).toEqual(inProd.map(item => item.id))
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('the override contest names the winner by its RESOLVED id, collisions included', () => {
|
|
561
|
+
const topology = topologyOf(
|
|
562
|
+
[
|
|
563
|
+
route('/home', [
|
|
564
|
+
{ id: 'dup', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
565
|
+
]),
|
|
566
|
+
],
|
|
567
|
+
[
|
|
568
|
+
patternRule('r1', '/*', [
|
|
569
|
+
{ id: 'dup', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
570
|
+
]),
|
|
571
|
+
],
|
|
572
|
+
)
|
|
573
|
+
const resolved = resolvePlacements(topology, '/home', 'dev')
|
|
574
|
+
const winnerIds = resolved.flatMap(item =>
|
|
575
|
+
item.overriddenBy.map(override => override.winnerId),
|
|
576
|
+
)
|
|
577
|
+
// A `winnerId` that matched more than one member of the output would
|
|
578
|
+
// make `overridePair` true for pairs that never contested.
|
|
579
|
+
expect(winnerIds).toEqual(['dup~/home|1|route:promo-banner@hero-slot#0'])
|
|
580
|
+
expect(resolved.map(item => item.id)).toContain(winnerIds[0])
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('two placements in the same layer never override each other', () => {
|
|
585
|
+
const topology = topologyOf([
|
|
586
|
+
route('/home', [
|
|
587
|
+
{ id: 'twin-a', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
588
|
+
{ id: 'twin-b', tag: 'promo-banner', slotId: 'hero-slot' },
|
|
589
|
+
]),
|
|
590
|
+
])
|
|
591
|
+
// Peers at one specificity are the CHECKS' business (checks 4/7), not the
|
|
592
|
+
// contest's — resolving them as overrides would silence the duplicate
|
|
593
|
+
// warning the spec renders for exactly this mistake.
|
|
594
|
+
expect(
|
|
595
|
+
resolvePlacements(topology, '/home', 'dev').map(item => item.state),
|
|
596
|
+
).toEqual(['active', 'active'])
|
|
597
|
+
})
|
|
598
|
+
})
|