@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.
@@ -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
+ })