@bespokeagentics/microdots-host 0.1.0 → 0.1.2
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/evals/topology-regressions.p5.v1.json +105 -0
- package/package.json +6 -4
- package/src/composeFromSelection.test.ts +277 -0
- package/src/composeFromSelection.ts +376 -0
- package/src/compositionSpec.test.ts +311 -0
- package/src/compositionSpec.ts +802 -0
- package/src/compositionSpec.v2.test.ts +216 -0
- package/src/fillComposition.test.ts +240 -0
- package/src/fillComposition.ts +159 -0
- package/src/index.ts +140 -2
- package/src/mountedRegistry.test.ts +196 -0
- package/src/mounting.test.ts +8 -8
- package/src/mounting.ts +116 -0
- package/src/placementChecks.test.ts +12 -12
- package/src/rules.test.ts +4 -4
- package/src/rules.ts +1 -1
- package/src/slotDom.test.ts +151 -0
- package/src/slotDom.ts +111 -0
- package/src/slotResize.test.ts +392 -0
- package/src/slotResize.ts +367 -0
- package/src/slots.test.ts +53 -0
- package/src/slots.ts +30 -3
- package/src/topologyRegression.test.ts +37 -0
- package/src/topologyRegression.ts +143 -0
- package/src/topologySource.test.ts +137 -0
- package/src/topologySource.ts +97 -0
- package/src/tracePublisher.test.ts +95 -0
- package/src/tracePublisher.ts +117 -0
- package/src/wire.test.ts +13 -11
- package/src/wireEngine.test.ts +60 -36
- package/src/wireEngine.ts +13 -3
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { CatalogLockV1 } from '@bespokeagentics/microdots-authoring'
|
|
2
|
+
import type {
|
|
3
|
+
ManifestEvent,
|
|
4
|
+
ManifestTag,
|
|
5
|
+
} from '@bespokeagentics/microdots-element'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type CompositionIssue,
|
|
9
|
+
type CompositionSpec,
|
|
10
|
+
type CompositionSpecV2,
|
|
11
|
+
migrateCompositionSpecV1,
|
|
12
|
+
validateComposition,
|
|
13
|
+
} from './compositionSpec.ts'
|
|
14
|
+
import { FillCompositionError } from './fillComposition.ts'
|
|
15
|
+
import { type SlotKind } from './slots.ts'
|
|
16
|
+
import {
|
|
17
|
+
type HostTopology,
|
|
18
|
+
type TopologyPlacement,
|
|
19
|
+
type TopologyRoute,
|
|
20
|
+
type Wire,
|
|
21
|
+
type WireValueType,
|
|
22
|
+
} from './wire.ts'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Layout of catalogued tags in the topology. `together` is one route with
|
|
26
|
+
* every selected tag; `per-tag` is one route per tag. Both are closed over
|
|
27
|
+
* the catalog — a tag that is not a surface cannot be represented.
|
|
28
|
+
*/
|
|
29
|
+
export type CompositionLayout = 'together' | 'per-tag'
|
|
30
|
+
|
|
31
|
+
export type CompositionWirePick = {
|
|
32
|
+
readonly from: string
|
|
33
|
+
readonly event: string
|
|
34
|
+
readonly field: string
|
|
35
|
+
readonly to: string
|
|
36
|
+
readonly input: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ComposeFromSelectionInput = {
|
|
40
|
+
readonly brief?: string
|
|
41
|
+
readonly hostId: string
|
|
42
|
+
readonly hostLabel: string
|
|
43
|
+
readonly tags: ReadonlyArray<string>
|
|
44
|
+
readonly layout: CompositionLayout
|
|
45
|
+
readonly slotKind: SlotKind
|
|
46
|
+
readonly wires: ReadonlyArray<CompositionWirePick>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const THEME: { readonly name: string; readonly version: string } = {
|
|
50
|
+
name: '@bespokeagentics/microdots-theme',
|
|
51
|
+
version: '0.1.1',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALL_ENVS: Wire['envs'] = ['dev', 'preview', 'prod']
|
|
55
|
+
|
|
56
|
+
const issue = (path: string, message: string): CompositionIssue => ({
|
|
57
|
+
path,
|
|
58
|
+
message,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
62
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
63
|
+
|
|
64
|
+
const propertiesOf = (
|
|
65
|
+
jsonSchema: unknown,
|
|
66
|
+
): Readonly<Record<string, unknown>> | undefined => {
|
|
67
|
+
if (!isRecord(jsonSchema)) {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
const nested = jsonSchema['schema']
|
|
71
|
+
if (isRecord(nested) && isRecord(nested['properties'])) {
|
|
72
|
+
return nested['properties']
|
|
73
|
+
}
|
|
74
|
+
if (isRecord(jsonSchema['properties'])) {
|
|
75
|
+
return jsonSchema['properties']
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const jsonTypeOf = (spec: unknown): WireValueType => {
|
|
81
|
+
if (!isRecord(spec)) {
|
|
82
|
+
return 'string'
|
|
83
|
+
}
|
|
84
|
+
const declared = spec['type']
|
|
85
|
+
if (declared === 'number' || declared === 'integer') {
|
|
86
|
+
return 'number'
|
|
87
|
+
}
|
|
88
|
+
if (declared === 'boolean') {
|
|
89
|
+
return 'boolean'
|
|
90
|
+
}
|
|
91
|
+
if (declared === 'object' || declared === 'array') {
|
|
92
|
+
return 'json'
|
|
93
|
+
}
|
|
94
|
+
return 'string'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Payload field names a picker can offer for this event. */
|
|
98
|
+
export const eventFieldsOf = (
|
|
99
|
+
event: ManifestEvent,
|
|
100
|
+
): ReadonlyArray<{ readonly name: string; readonly type: WireValueType }> => {
|
|
101
|
+
const properties = propertiesOf(event.payload.jsonSchema)
|
|
102
|
+
if (properties === undefined) {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
return Object.entries(properties).map(([name, spec]) => ({
|
|
106
|
+
name,
|
|
107
|
+
type: jsonTypeOf(spec),
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Attributes a wire may target — environment attrs are host-owned. */
|
|
112
|
+
export const wireableAttributesOf = (
|
|
113
|
+
surface: ManifestTag,
|
|
114
|
+
): ManifestTag['attributes'] =>
|
|
115
|
+
surface.attributes.filter(attribute => attribute.ownership !== 'environment')
|
|
116
|
+
|
|
117
|
+
const uniqueTags = (tags: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
118
|
+
const seen = new Set<string>()
|
|
119
|
+
const ordered: Array<string> = []
|
|
120
|
+
for (const tag of tags) {
|
|
121
|
+
if (seen.has(tag)) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
seen.add(tag)
|
|
125
|
+
ordered.push(tag)
|
|
126
|
+
}
|
|
127
|
+
return ordered
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const titleCase = (tag: string): string =>
|
|
131
|
+
tag
|
|
132
|
+
.split('-')
|
|
133
|
+
.filter(part => part.length > 0)
|
|
134
|
+
.map(part => {
|
|
135
|
+
const head = part.slice(0, 1)
|
|
136
|
+
const tail = part.slice(1)
|
|
137
|
+
return `${head.toUpperCase()}${tail}`
|
|
138
|
+
})
|
|
139
|
+
.join(' ')
|
|
140
|
+
|
|
141
|
+
const slotIdFor = (tag: string): string => `${tag}-slot`
|
|
142
|
+
|
|
143
|
+
const surfaceMap = (
|
|
144
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
145
|
+
): ReadonlyMap<string, ManifestTag> => {
|
|
146
|
+
const map = new Map<string, ManifestTag>()
|
|
147
|
+
for (const surface of surfaces) {
|
|
148
|
+
map.set(surface.tag, surface)
|
|
149
|
+
}
|
|
150
|
+
return map
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const placement = (tag: string, order: number): TopologyPlacement => ({
|
|
154
|
+
tag,
|
|
155
|
+
slotId: slotIdFor(tag),
|
|
156
|
+
order,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const routeTogether = (tags: ReadonlyArray<string>): TopologyRoute => ({
|
|
160
|
+
path: '/',
|
|
161
|
+
label: 'Home',
|
|
162
|
+
title: 'Home',
|
|
163
|
+
sectionIds: ['section-home'],
|
|
164
|
+
mounts: tags.map((tag, index) => placement(tag, index)),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const routeForTag = (tag: string): TopologyRoute => {
|
|
168
|
+
const label = titleCase(tag)
|
|
169
|
+
return {
|
|
170
|
+
path: `/${tag}`,
|
|
171
|
+
label,
|
|
172
|
+
title: label,
|
|
173
|
+
sectionIds: [`section-${tag}`],
|
|
174
|
+
mounts: [placement(tag, 0)],
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resolveTags = (
|
|
179
|
+
tags: ReadonlyArray<string>,
|
|
180
|
+
catalog: ReadonlyMap<string, ManifestTag>,
|
|
181
|
+
): {
|
|
182
|
+
readonly tags: ReadonlyArray<string>
|
|
183
|
+
readonly issues: ReadonlyArray<CompositionIssue>
|
|
184
|
+
} => {
|
|
185
|
+
const unique = uniqueTags(tags)
|
|
186
|
+
const issues: Array<CompositionIssue> = []
|
|
187
|
+
if (unique.length === 0) {
|
|
188
|
+
issues.push(issue('tags', 'Pick at least one MicroDot from the catalog.'))
|
|
189
|
+
}
|
|
190
|
+
for (const [index, tag] of unique.entries()) {
|
|
191
|
+
if (!catalog.has(tag)) {
|
|
192
|
+
issues.push(
|
|
193
|
+
issue(
|
|
194
|
+
`tags[${index.toString()}]`,
|
|
195
|
+
`"${tag}" is not in the catalog. Pick from the chips.`,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { tags: unique, issues }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const resolveWires = (
|
|
204
|
+
picks: ReadonlyArray<CompositionWirePick>,
|
|
205
|
+
selected: ReadonlySet<string>,
|
|
206
|
+
catalog: ReadonlyMap<string, ManifestTag>,
|
|
207
|
+
): {
|
|
208
|
+
readonly wires: ReadonlyArray<Wire>
|
|
209
|
+
readonly issues: ReadonlyArray<CompositionIssue>
|
|
210
|
+
} => {
|
|
211
|
+
const wires: Array<Wire> = []
|
|
212
|
+
const issues: Array<CompositionIssue> = []
|
|
213
|
+
for (const [index, pick] of picks.entries()) {
|
|
214
|
+
const path = `wires[${index.toString()}]`
|
|
215
|
+
if (!selected.has(pick.from)) {
|
|
216
|
+
issues.push(
|
|
217
|
+
issue(
|
|
218
|
+
`${path}.from`,
|
|
219
|
+
`"${pick.from}" is not one of the selected MicroDots.`,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (!selected.has(pick.to)) {
|
|
225
|
+
issues.push(
|
|
226
|
+
issue(
|
|
227
|
+
`${path}.to`,
|
|
228
|
+
`"${pick.to}" is not one of the selected MicroDots.`,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const fromSurface = catalog.get(pick.from)
|
|
234
|
+
const toSurface = catalog.get(pick.to)
|
|
235
|
+
if (fromSurface === undefined || toSurface === undefined) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
const event = fromSurface.events.find(
|
|
239
|
+
candidate => candidate.name === pick.event,
|
|
240
|
+
)
|
|
241
|
+
if (event === undefined) {
|
|
242
|
+
issues.push(
|
|
243
|
+
issue(`${path}.event`, `"${pick.from}" has no event "${pick.event}".`),
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
const field = eventFieldsOf(event).find(
|
|
248
|
+
candidate => candidate.name === pick.field,
|
|
249
|
+
)
|
|
250
|
+
if (field === undefined) {
|
|
251
|
+
issues.push(
|
|
252
|
+
issue(
|
|
253
|
+
`${path}.field`,
|
|
254
|
+
`event "${pick.event}" has no field "${pick.field}".`,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const input = wireableAttributesOf(toSurface).find(
|
|
260
|
+
candidate => candidate.name === pick.input,
|
|
261
|
+
)
|
|
262
|
+
if (input === undefined) {
|
|
263
|
+
issues.push(
|
|
264
|
+
issue(
|
|
265
|
+
`${path}.input`,
|
|
266
|
+
`"${pick.to}" has no wireable input "${pick.input}".`,
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
wires.push({
|
|
272
|
+
id: `w${(index + 1).toString()}`,
|
|
273
|
+
from: pick.from,
|
|
274
|
+
event: pick.event,
|
|
275
|
+
field: pick.field,
|
|
276
|
+
fieldType: field.type,
|
|
277
|
+
to: pick.to,
|
|
278
|
+
input: pick.input,
|
|
279
|
+
inputType: input.type,
|
|
280
|
+
transform: { _tag: 'direct' },
|
|
281
|
+
envs: ALL_ENVS,
|
|
282
|
+
plain: `When ${pick.event} on ${pick.from}, set ${pick.input} on ${pick.to}.`,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
return { wires, issues }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a CompositionSpec from a catalog picker. `generate[]` is always
|
|
290
|
+
* empty: unknown tags cannot be introduced here. Fail closed if a pick is
|
|
291
|
+
* not a catalogued surface, event, field, or wireable attribute.
|
|
292
|
+
*/
|
|
293
|
+
export const composeFromSelection = (
|
|
294
|
+
input: ComposeFromSelectionInput,
|
|
295
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
296
|
+
): CompositionSpec => {
|
|
297
|
+
const catalog = surfaceMap(surfaces)
|
|
298
|
+
const resolved = resolveTags(input.tags, catalog)
|
|
299
|
+
const selected = new Set(resolved.tags)
|
|
300
|
+
const wired = resolveWires(input.wires, selected, catalog)
|
|
301
|
+
const issues = [...resolved.issues, ...wired.issues]
|
|
302
|
+
if (issues.length > 0) {
|
|
303
|
+
throw new FillCompositionError({ _tag: 'invalid', issues })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const tags = resolved.tags
|
|
307
|
+
const first = tags[0]
|
|
308
|
+
if (first === undefined) {
|
|
309
|
+
throw new FillCompositionError({
|
|
310
|
+
_tag: 'invalid',
|
|
311
|
+
issues: [issue('tags', 'Pick at least one MicroDot from the catalog.')],
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const routes: readonly [TopologyRoute, ...Array<TopologyRoute>] =
|
|
316
|
+
input.layout === 'together'
|
|
317
|
+
? [routeTogether(tags)]
|
|
318
|
+
: [routeForTag(first), ...tags.slice(1).map(routeForTag)]
|
|
319
|
+
|
|
320
|
+
const topology: HostTopology = {
|
|
321
|
+
host: {
|
|
322
|
+
id: input.hostId,
|
|
323
|
+
label: input.hostLabel,
|
|
324
|
+
ownedInputs: [],
|
|
325
|
+
},
|
|
326
|
+
routes,
|
|
327
|
+
slotManifest: {
|
|
328
|
+
theme: THEME,
|
|
329
|
+
slots: tags.map((tag, index) => ({
|
|
330
|
+
id: slotIdFor(tag),
|
|
331
|
+
kind: input.slotKind,
|
|
332
|
+
row: input.layout === 'together' ? 1 : index + 1,
|
|
333
|
+
capacity: 1,
|
|
334
|
+
})),
|
|
335
|
+
},
|
|
336
|
+
wires: [...wired.wires],
|
|
337
|
+
watch: [],
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const trimmed = input.brief?.trim() ?? ''
|
|
341
|
+
const spec: CompositionSpec = {
|
|
342
|
+
schemaVersion: 1,
|
|
343
|
+
generate: [],
|
|
344
|
+
topology,
|
|
345
|
+
...(trimmed.length > 0 ? { brief: trimmed } : {}),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const lint = validateComposition(spec, surfaces)
|
|
349
|
+
if (lint.length > 0) {
|
|
350
|
+
throw new FillCompositionError({ _tag: 'invalid', issues: lint })
|
|
351
|
+
}
|
|
352
|
+
return spec
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Current write-side adapter for the existing-surface lane. The picker remains
|
|
357
|
+
* topology-only, so its established v1 result has the one lossless migration:
|
|
358
|
+
* attach the service-owned catalog lock and emit v2. Legacy callers may still
|
|
359
|
+
* call `composeFromSelection` while saved v1 data is being read.
|
|
360
|
+
*/
|
|
361
|
+
export const composeExistingSurfacesFromSelection = (
|
|
362
|
+
input: ComposeFromSelectionInput,
|
|
363
|
+
surfaces: ReadonlyArray<ManifestTag>,
|
|
364
|
+
catalogLock: CatalogLockV1,
|
|
365
|
+
): CompositionSpecV2 => {
|
|
366
|
+
const result = migrateCompositionSpecV1(
|
|
367
|
+
composeFromSelection(input, surfaces),
|
|
368
|
+
catalogLock,
|
|
369
|
+
)
|
|
370
|
+
if (result._tag === 'migrated') {
|
|
371
|
+
return result.value
|
|
372
|
+
}
|
|
373
|
+
throw new Error(
|
|
374
|
+
'topology-only existing-surface composition unexpectedly required migration input',
|
|
375
|
+
)
|
|
376
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ManifestAttribute,
|
|
6
|
+
ManifestEvent,
|
|
7
|
+
ManifestTag,
|
|
8
|
+
} from '@bespokeagentics/microdots-element'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type CompositionSpec,
|
|
12
|
+
compositionJsonSchema,
|
|
13
|
+
decodeCompositionSpecSync,
|
|
14
|
+
encodeTopologyJson,
|
|
15
|
+
schemaEventsFor,
|
|
16
|
+
schemaInputsFor,
|
|
17
|
+
validateComposition,
|
|
18
|
+
} from './compositionSpec.ts'
|
|
19
|
+
import { HostTopology, type TopologyRoute, type Wire } from './wire.ts'
|
|
20
|
+
|
|
21
|
+
const attribute = (
|
|
22
|
+
name: string,
|
|
23
|
+
type: ManifestAttribute['type'] = 'string',
|
|
24
|
+
): ManifestAttribute => ({
|
|
25
|
+
name,
|
|
26
|
+
type,
|
|
27
|
+
required: false,
|
|
28
|
+
live: true,
|
|
29
|
+
ownership: 'dot',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const manifestEvent = (
|
|
33
|
+
name: string,
|
|
34
|
+
properties: Readonly<Record<string, unknown>>,
|
|
35
|
+
): ManifestEvent => ({
|
|
36
|
+
name,
|
|
37
|
+
payload: {
|
|
38
|
+
ref: `@microdots/test/contract#${name}`,
|
|
39
|
+
jsonSchema: {
|
|
40
|
+
dialect: 'draft-2020-12',
|
|
41
|
+
schema: { type: 'object', properties },
|
|
42
|
+
definitions: {},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const PRICE: ManifestTag = {
|
|
48
|
+
tag: 'readout-view',
|
|
49
|
+
attributes: [attribute('symbol')],
|
|
50
|
+
events: [manifestEvent('quote-changed', { symbol: { type: 'string' } })],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const FLEET: ManifestTag = {
|
|
54
|
+
tag: 'fleet-health',
|
|
55
|
+
attributes: [attribute('region')],
|
|
56
|
+
events: [],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SURFACES: ReadonlyArray<ManifestTag> = [PRICE, FLEET]
|
|
60
|
+
|
|
61
|
+
const route = (path: string, tag: string, slotId: string): TopologyRoute => ({
|
|
62
|
+
path,
|
|
63
|
+
label: path,
|
|
64
|
+
title: path,
|
|
65
|
+
sectionIds: [`section-${tag}`],
|
|
66
|
+
mounts: [{ tag, slotId }],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const lookupWire: Wire = {
|
|
70
|
+
id: 'w1',
|
|
71
|
+
from: 'readout-view',
|
|
72
|
+
event: 'quote-changed',
|
|
73
|
+
field: 'symbol',
|
|
74
|
+
fieldType: 'string',
|
|
75
|
+
to: 'fleet-health',
|
|
76
|
+
input: 'region',
|
|
77
|
+
inputType: 'string',
|
|
78
|
+
transform: {
|
|
79
|
+
_tag: 'lookup',
|
|
80
|
+
rows: { FOLD: 'us-east' },
|
|
81
|
+
fallback: 'us-east',
|
|
82
|
+
},
|
|
83
|
+
envs: ['dev', 'preview', 'prod'],
|
|
84
|
+
plain:
|
|
85
|
+
"When the ticker's quote changes, set the fleet widget's region to the region that trades that symbol.",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const topologyOf = (wires: ReadonlyArray<Wire>): HostTopology => ({
|
|
89
|
+
host: { id: 'demo-host', label: 'Demo', ownedInputs: [] },
|
|
90
|
+
routes: [
|
|
91
|
+
route('/price', 'readout-view', 'price-slot'),
|
|
92
|
+
route('/fleet', 'fleet-health', 'fleet-slot'),
|
|
93
|
+
],
|
|
94
|
+
slotManifest: {
|
|
95
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.0' },
|
|
96
|
+
slots: [
|
|
97
|
+
{ id: 'price-slot', kind: 'band', row: 1, capacity: 1 },
|
|
98
|
+
{ id: 'fleet-slot', kind: 'band', row: 1, capacity: 1 },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
wires,
|
|
102
|
+
watch: [],
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const specOf = (
|
|
106
|
+
topology: HostTopology,
|
|
107
|
+
generate: CompositionSpec['generate'] = [],
|
|
108
|
+
): CompositionSpec => ({
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
generate,
|
|
111
|
+
topology,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('CompositionSpec', () => {
|
|
115
|
+
test('decodes a reuse-only composition', () => {
|
|
116
|
+
const spec = specOf(topologyOf([lookupWire]))
|
|
117
|
+
expect(decodeCompositionSpecSync(spec)).toEqual(spec)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('rejects a slug that is not lower-kebab', () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
decodeCompositionSpecSync(
|
|
123
|
+
specOf(topologyOf([]), [
|
|
124
|
+
{ slug: 'Nope', tag: 'nope-panel', port: 3990 },
|
|
125
|
+
]),
|
|
126
|
+
),
|
|
127
|
+
).toThrow()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('rejects a generated app that claims a reference identity', () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
decodeCompositionSpecSync(
|
|
133
|
+
specOf(topologyOf([]), [
|
|
134
|
+
{ slug: 'dossier', tag: 'dossier-panel', port: 3990 },
|
|
135
|
+
]),
|
|
136
|
+
),
|
|
137
|
+
).toThrow()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('validateComposition', () => {
|
|
142
|
+
test('accepts the price→fleet lookup against catalogued surfaces', () => {
|
|
143
|
+
expect(
|
|
144
|
+
validateComposition(specOf(topologyOf([lookupWire])), SURFACES),
|
|
145
|
+
).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('rejects a wire whose event does not exist on the from tag', () => {
|
|
149
|
+
const bogus: Wire = { ...lookupWire, event: 'bid-placed' }
|
|
150
|
+
const issues = validateComposition(specOf(topologyOf([bogus])), SURFACES)
|
|
151
|
+
expect(issues).toEqual([
|
|
152
|
+
{
|
|
153
|
+
path: 'topology.wires[0]',
|
|
154
|
+
message:
|
|
155
|
+
'wire "w1" derives draft — a tag, event, field, input or type pairing is missing from the surface catalog',
|
|
156
|
+
},
|
|
157
|
+
])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('rejects a mount tag that is neither catalogued nor generated', () => {
|
|
161
|
+
const topology = topologyOf([])
|
|
162
|
+
const extra: HostTopology = {
|
|
163
|
+
...topology,
|
|
164
|
+
routes: [
|
|
165
|
+
...topology.routes,
|
|
166
|
+
route('/ghost', 'ghost-panel', 'price-slot'),
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
const issues = validateComposition(specOf(extra), SURFACES)
|
|
170
|
+
expect(issues).toContainEqual({
|
|
171
|
+
path: 'topology.routes[2].mounts[0].tag',
|
|
172
|
+
message:
|
|
173
|
+
'tag "ghost-panel" is neither a catalogued surface nor in generate[]',
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('rejects a slot id the slot manifest does not declare', () => {
|
|
178
|
+
const topology = topologyOf([])
|
|
179
|
+
const first = topology.routes[0]
|
|
180
|
+
if (first === undefined) {
|
|
181
|
+
throw new Error('fixture routes are non-empty')
|
|
182
|
+
}
|
|
183
|
+
const broken: HostTopology = {
|
|
184
|
+
...topology,
|
|
185
|
+
routes: [
|
|
186
|
+
{
|
|
187
|
+
...first,
|
|
188
|
+
mounts: [{ tag: 'readout-view', slotId: 'missing-slot' }],
|
|
189
|
+
},
|
|
190
|
+
...topology.routes.slice(1),
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
const issues = validateComposition(specOf(broken), SURFACES)
|
|
194
|
+
expect(issues).toContainEqual({
|
|
195
|
+
path: 'topology.routes[0].mounts[0].slotId',
|
|
196
|
+
message: 'slot "missing-slot" is not in topology.slotManifest',
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('rejects a generated tag that is never placed', () => {
|
|
201
|
+
const issues = validateComposition(
|
|
202
|
+
specOf(topologyOf([lookupWire]), [
|
|
203
|
+
{ slug: 'inventory', tag: 'inventory-panel', port: 3991 },
|
|
204
|
+
]),
|
|
205
|
+
SURFACES,
|
|
206
|
+
)
|
|
207
|
+
expect(issues).toContainEqual({
|
|
208
|
+
path: 'generate[0].tag',
|
|
209
|
+
message:
|
|
210
|
+
'generated tag "inventory-panel" is never placed in the topology',
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('skips draft checks for wires that touch a generate[] tag', () => {
|
|
215
|
+
const generated: Wire = {
|
|
216
|
+
...lookupWire,
|
|
217
|
+
id: 'w-new',
|
|
218
|
+
to: 'inventory-panel',
|
|
219
|
+
input: 'refresh-token',
|
|
220
|
+
}
|
|
221
|
+
const topology = topologyOf([generated])
|
|
222
|
+
const withSlot: HostTopology = {
|
|
223
|
+
...topology,
|
|
224
|
+
slotManifest: {
|
|
225
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.0' },
|
|
226
|
+
slots: [
|
|
227
|
+
...(topology.slotManifest?.slots ?? []),
|
|
228
|
+
{ id: 'inventory-slot', kind: 'band', row: 2, capacity: 1 },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
routes: [
|
|
232
|
+
...topology.routes,
|
|
233
|
+
route('/inventory', 'inventory-panel', 'inventory-slot'),
|
|
234
|
+
],
|
|
235
|
+
}
|
|
236
|
+
expect(
|
|
237
|
+
validateComposition(
|
|
238
|
+
specOf(withSlot, [
|
|
239
|
+
{ slug: 'inventory', tag: 'inventory-panel', port: 3991 },
|
|
240
|
+
]),
|
|
241
|
+
SURFACES,
|
|
242
|
+
),
|
|
243
|
+
).toEqual([])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('rejects duplicate generate slugs', () => {
|
|
247
|
+
const topology = topologyOf([])
|
|
248
|
+
const withSlots: HostTopology = {
|
|
249
|
+
...topology,
|
|
250
|
+
slotManifest: {
|
|
251
|
+
theme: { name: '@bespokeagentics/microdots-theme', version: '0.1.0' },
|
|
252
|
+
slots: [
|
|
253
|
+
...(topology.slotManifest?.slots ?? []),
|
|
254
|
+
{ id: 'a-slot', kind: 'band', row: 2, capacity: 1 },
|
|
255
|
+
{ id: 'b-slot', kind: 'band', row: 3, capacity: 1 },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
routes: [
|
|
259
|
+
...topology.routes,
|
|
260
|
+
route('/a', 'a-panel', 'a-slot'),
|
|
261
|
+
route('/b', 'b-panel', 'b-slot'),
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
const issues = validateComposition(
|
|
265
|
+
specOf(withSlots, [
|
|
266
|
+
{ slug: 'same', tag: 'a-panel', port: 3991 },
|
|
267
|
+
{ slug: 'same', tag: 'b-panel', port: 3992 },
|
|
268
|
+
]),
|
|
269
|
+
SURFACES,
|
|
270
|
+
)
|
|
271
|
+
expect(issues).toContainEqual({
|
|
272
|
+
path: 'generate[1].slug',
|
|
273
|
+
message: 'slug "same" duplicates generate[0]',
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('compositionJsonSchema', () => {
|
|
279
|
+
test('admits quote-changed on readout-view and not bid-placed', () => {
|
|
280
|
+
const schema = compositionJsonSchema(SURFACES)
|
|
281
|
+
expect(schemaEventsFor(schema, 'readout-view')).toEqual(['quote-changed'])
|
|
282
|
+
expect(schemaEventsFor(schema, 'readout-view')).not.toContain('bid-placed')
|
|
283
|
+
expect(schemaInputsFor(schema, 'fleet-health')).toEqual(['region'])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('closes the transform vocabulary to the three tags that exist', () => {
|
|
287
|
+
const schema = compositionJsonSchema(SURFACES)
|
|
288
|
+
const wires = schema.properties?.['topology']?.properties?.['wires']?.items
|
|
289
|
+
const base = wires?.allOf?.[0] ?? wires
|
|
290
|
+
const transform = base?.properties?.['transform']
|
|
291
|
+
const tags = (transform?.oneOf ?? []).map(
|
|
292
|
+
variant => variant.properties?.['_tag']?.const,
|
|
293
|
+
)
|
|
294
|
+
expect(tags).toEqual(['direct', 'lookup', 'condition'])
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('lists legal tags, events, attrs and transforms in $comment so the schema is the prompt', () => {
|
|
298
|
+
const schema = compositionJsonSchema(SURFACES)
|
|
299
|
+
expect(schema.$comment).toContain('ManifestTag')
|
|
300
|
+
expect(schema.properties?.['schemaVersion']?.const).toBe(1)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('encodeTopologyJson', () => {
|
|
305
|
+
test('round-trips through JSON and HostTopology', () => {
|
|
306
|
+
const topology = topologyOf([lookupWire])
|
|
307
|
+
const json = encodeTopologyJson(topology)
|
|
308
|
+
const decoded = S.decodeSync(S.fromJsonString(HostTopology))(json)
|
|
309
|
+
expect(decoded).toEqual(topology)
|
|
310
|
+
})
|
|
311
|
+
})
|