@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,367 @@
1
+ /**
2
+ * Drag-resize for slots that declare a `resize` axis.
3
+ *
4
+ * The host owns placement, so the host owns the splitters: a MicroDot must
5
+ * not reach into sibling panes, and a handle inside a tabbed slot would
6
+ * vanish when that tab is hidden. Each handle is therefore attached to the
7
+ * chrome ancestor that is a direct child of the section — the grid-area box
8
+ * — and writes a CSS custom property on the section. Overrides survive a
9
+ * reload in the same tab through sessionStorage. Named layout presets clear
10
+ * both representations; neither is ever written back to the topology (the
11
+ * Platform must not rewrite the screen it is standing on).
12
+ *
13
+ * Prototype clamps (`MicroDots Wiring.dc.html:774-783`): drawer 300–980,
14
+ * pane 46–(shell − 190). Copy on the handle is the prototype's
15
+ * "Drag to resize".
16
+ */
17
+ import type { SlotResizeAxis, SlotSpec } from './slots.ts'
18
+
19
+ export const SLOT_RESIZE_HANDLE_CLASS = 'slot-resize-handle'
20
+
21
+ export const slotSizeVar = (slotId: string): string => `--slot-size-${slotId}`
22
+
23
+ const RESIZED_DATASET = 'resized'
24
+ const AXIS_X = 'x'
25
+ const AXIS_Y = 'y'
26
+ const X_MIN = 300
27
+ const X_MAX = 980
28
+ const Y_MIN = 46
29
+ const Y_HEADROOM = 190
30
+ const KEY_STEP = 8
31
+ const KEY_STEP_FAST = 40
32
+ const STORAGE_VERSION = 1
33
+ const STORAGE_PREFIX = 'microdots-slot-resize-v1:'
34
+
35
+ export type SlotResizeStorage = Pick<
36
+ Storage,
37
+ 'getItem' | 'removeItem' | 'setItem'
38
+ >
39
+
40
+ type StoredSlotSize = {
41
+ readonly slotId: string
42
+ readonly axis: SlotResizeAxis
43
+ readonly px: number
44
+ }
45
+
46
+ const isAxis = (value: string): value is SlotResizeAxis =>
47
+ value === AXIS_X || value === AXIS_Y
48
+
49
+ const clamp = (value: number, lo: number, hi: number): number =>
50
+ Math.min(hi, Math.max(lo, value))
51
+
52
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
53
+ typeof value === 'object' && value !== null
54
+
55
+ export const slotResizeStorageKey = (sectionId: string): string =>
56
+ `${STORAGE_PREFIX}${sectionId}`
57
+
58
+ const storageFor = (
59
+ supplied: SlotResizeStorage | null | undefined,
60
+ ): SlotResizeStorage | undefined => {
61
+ if (supplied !== undefined) return supplied ?? undefined
62
+ try {
63
+ return typeof window === 'undefined' ? undefined : window.sessionStorage
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ const storageKeyFor = (section: HTMLElement): string | undefined =>
70
+ section.id === '' ? undefined : slotResizeStorageKey(section.id)
71
+
72
+ const decodeStoredSizes = (
73
+ raw: string | null,
74
+ ): ReadonlyArray<StoredSlotSize> => {
75
+ if (raw === null) return []
76
+ try {
77
+ const decoded: unknown = JSON.parse(raw)
78
+ if (
79
+ !isRecord(decoded) ||
80
+ decoded['version'] !== STORAGE_VERSION ||
81
+ !Array.isArray(decoded['sizes'])
82
+ ) {
83
+ return []
84
+ }
85
+ return decoded['sizes'].flatMap((candidate): Array<StoredSlotSize> => {
86
+ if (!isRecord(candidate)) return []
87
+ const slotId = candidate['slotId']
88
+ const axis = candidate['axis']
89
+ const px = candidate['px']
90
+ return typeof slotId === 'string' &&
91
+ slotId !== '' &&
92
+ typeof axis === 'string' &&
93
+ isAxis(axis) &&
94
+ typeof px === 'number' &&
95
+ Number.isFinite(px)
96
+ ? [{ slotId, axis, px }]
97
+ : []
98
+ })
99
+ } catch {
100
+ return []
101
+ }
102
+ }
103
+
104
+ const readStoredSizes = (
105
+ section: HTMLElement,
106
+ supplied: SlotResizeStorage | null | undefined,
107
+ ): ReadonlyArray<StoredSlotSize> => {
108
+ const storage = storageFor(supplied)
109
+ const key = storageKeyFor(section)
110
+ if (storage === undefined || key === undefined) return []
111
+ try {
112
+ return decodeStoredSizes(storage.getItem(key))
113
+ } catch {
114
+ return []
115
+ }
116
+ }
117
+
118
+ const writeStoredSizes = (
119
+ section: HTMLElement,
120
+ sizes: ReadonlyArray<StoredSlotSize>,
121
+ supplied: SlotResizeStorage | null | undefined,
122
+ ): void => {
123
+ const storage = storageFor(supplied)
124
+ const key = storageKeyFor(section)
125
+ if (storage === undefined || key === undefined) return
126
+ try {
127
+ storage.setItem(key, JSON.stringify({ version: STORAGE_VERSION, sizes }))
128
+ } catch {
129
+ // Storage can be disabled, full, or denied by an embedding sandbox. The
130
+ // live CSS override remains useful even when persistence is unavailable.
131
+ }
132
+ }
133
+
134
+ const removeStoredSizes = (
135
+ section: HTMLElement,
136
+ supplied: SlotResizeStorage | null | undefined,
137
+ ): void => {
138
+ const storage = storageFor(supplied)
139
+ const key = storageKeyFor(section)
140
+ if (storage === undefined || key === undefined) return
141
+ try {
142
+ storage.removeItem(key)
143
+ } catch {
144
+ // A preset still clears the DOM override when storage is unavailable.
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The grid-area box: walk from the slot to the section's direct child.
150
+ * Nested tab panes (`#wiring-inspector-slot` inside `.wiring-drawer`)
151
+ * resize the drawer, not the hidden tab.
152
+ */
153
+ export const chromeOfSlot = (
154
+ slot: HTMLElement,
155
+ section: HTMLElement,
156
+ ): HTMLElement => {
157
+ let node: HTMLElement | null = slot
158
+ while (node !== null && node.parentElement !== section) {
159
+ node = node.parentElement
160
+ }
161
+ return node ?? slot
162
+ }
163
+
164
+ const minFor = (axis: SlotResizeAxis): number =>
165
+ axis === AXIS_X ? X_MIN : Y_MIN
166
+
167
+ const maxFor = (axis: SlotResizeAxis, section: HTMLElement): number =>
168
+ axis === AXIS_X ? X_MAX : Math.max(Y_MIN, section.clientHeight - Y_HEADROOM)
169
+
170
+ const resizedAxesOf = (section: HTMLElement): ReadonlyArray<SlotResizeAxis> => {
171
+ const raw = section.dataset[RESIZED_DATASET]
172
+ if (raw === undefined || raw === '') return []
173
+ return raw.split(/\s+/).filter(isAxis)
174
+ }
175
+
176
+ const writeResized = (
177
+ section: HTMLElement,
178
+ axes: ReadonlyArray<SlotResizeAxis>,
179
+ ): void => {
180
+ if (axes.length === 0) {
181
+ delete section.dataset[RESIZED_DATASET]
182
+ return
183
+ }
184
+ const ordered: Array<SlotResizeAxis> = []
185
+ if (axes.includes(AXIS_X)) ordered.push(AXIS_X)
186
+ if (axes.includes(AXIS_Y)) ordered.push(AXIS_Y)
187
+ section.dataset[RESIZED_DATASET] = ordered.join(' ')
188
+ }
189
+
190
+ export const applySlotSize = (
191
+ section: HTMLElement,
192
+ slotId: string,
193
+ axis: SlotResizeAxis,
194
+ px: number,
195
+ storage?: SlotResizeStorage | null,
196
+ ): number => {
197
+ const next = clamp(Math.round(px), minFor(axis), maxFor(axis, section))
198
+ section.style.setProperty(slotSizeVar(slotId), `${String(next)}px`)
199
+ const axes = resizedAxesOf(section)
200
+ writeResized(section, axes.includes(axis) ? axes : [...axes, axis])
201
+ const stored = readStoredSizes(section, storage).filter(
202
+ entry => entry.slotId !== slotId,
203
+ )
204
+ writeStoredSizes(section, [...stored, { slotId, axis, px: next }], storage)
205
+ return next
206
+ }
207
+
208
+ /** Restore only entries that still name a declared resizable slot and axis. */
209
+ export const restoreSlotResize = (
210
+ section: HTMLElement,
211
+ slots: ReadonlyArray<SlotSpec>,
212
+ storage?: SlotResizeStorage | null,
213
+ ): void => {
214
+ const declared = new Map(
215
+ slots.flatMap(slot =>
216
+ slot.resize === undefined ? [] : [[slot.id, slot.resize] as const],
217
+ ),
218
+ )
219
+ readStoredSizes(section, storage).forEach(entry => {
220
+ if (declared.get(entry.slotId) !== entry.axis) return
221
+ const rounded = Math.round(entry.px)
222
+ const maximum =
223
+ entry.axis === AXIS_Y && section.clientHeight === 0
224
+ ? Math.max(Y_MIN, rounded)
225
+ : maxFor(entry.axis, section)
226
+ const next = clamp(rounded, minFor(entry.axis), maximum)
227
+ section.style.setProperty(slotSizeVar(entry.slotId), `${String(next)}px`)
228
+ const axes = resizedAxesOf(section)
229
+ writeResized(
230
+ section,
231
+ axes.includes(entry.axis) ? axes : [...axes, entry.axis],
232
+ )
233
+ })
234
+ }
235
+
236
+ export const clearSlotResize = (
237
+ section: HTMLElement,
238
+ slots: ReadonlyArray<SlotSpec>,
239
+ storage?: SlotResizeStorage | null,
240
+ ): void => {
241
+ slots.forEach(slot => {
242
+ if (slot.resize === undefined) return
243
+ section.style.removeProperty(slotSizeVar(slot.id))
244
+ })
245
+ delete section.dataset[RESIZED_DATASET]
246
+ removeStoredSizes(section, storage)
247
+ }
248
+
249
+ type Drag = {
250
+ readonly axis: SlotResizeAxis
251
+ readonly slotId: string
252
+ readonly startPointer: number
253
+ readonly startSize: number
254
+ }
255
+
256
+ export const attachSlotResize = (input: {
257
+ readonly section: HTMLElement
258
+ readonly slots: ReadonlyArray<SlotSpec>
259
+ readonly storage?: SlotResizeStorage | null
260
+ }): { readonly detach: () => void } => {
261
+ const { section, slots, storage } = input
262
+ restoreSlotResize(section, slots, storage)
263
+ const handles: Array<HTMLButtonElement> = []
264
+ const attached = new WeakMap<HTMLElement, Set<SlotResizeAxis>>()
265
+ let drag: Drag | undefined
266
+ let previousUserSelect = ''
267
+
268
+ const onPointerMove = (event: PointerEvent): void => {
269
+ if (drag === undefined) return
270
+ const pointer = drag.axis === AXIS_X ? event.clientX : event.clientY
271
+ applySlotSize(
272
+ section,
273
+ drag.slotId,
274
+ drag.axis,
275
+ drag.startSize - (pointer - drag.startPointer),
276
+ storage,
277
+ )
278
+ }
279
+
280
+ const stopDrag = (): void => {
281
+ if (drag === undefined) return
282
+ drag = undefined
283
+ document.body.style.userSelect = previousUserSelect
284
+ document.removeEventListener('pointermove', onPointerMove)
285
+ document.removeEventListener('pointerup', stopDrag)
286
+ document.removeEventListener('pointercancel', stopDrag)
287
+ }
288
+
289
+ slots.forEach(slot => {
290
+ const axis = slot.resize
291
+ if (axis === undefined) return
292
+ const slotEl = document.getElementById(slot.id)
293
+ if (slotEl === null) return
294
+ const chrome = chromeOfSlot(slotEl, section)
295
+ const seen = attached.get(chrome)
296
+ if (seen?.has(axis) === true) return
297
+ const nextSeen = seen ?? new Set<SlotResizeAxis>()
298
+ nextSeen.add(axis)
299
+ attached.set(chrome, nextSeen)
300
+
301
+ if (getComputedStyle(chrome).position === 'static') {
302
+ chrome.style.position = 'relative'
303
+ }
304
+
305
+ const handle = document.createElement('button')
306
+ handle.type = 'button'
307
+ handle.className = SLOT_RESIZE_HANDLE_CLASS
308
+ handle.dataset['axis'] = axis
309
+ handle.title = 'Drag to resize'
310
+ handle.setAttribute('aria-label', 'Drag to resize')
311
+ handle.setAttribute(
312
+ 'aria-orientation',
313
+ axis === AXIS_X ? 'vertical' : 'horizontal',
314
+ )
315
+
316
+ handle.addEventListener('pointerdown', event => {
317
+ if (event.button !== 0) return
318
+ event.preventDefault()
319
+ const startSize =
320
+ axis === AXIS_X ? chrome.offsetWidth : chrome.offsetHeight
321
+ drag = {
322
+ axis,
323
+ slotId: slot.id,
324
+ startPointer: axis === AXIS_X ? event.clientX : event.clientY,
325
+ startSize,
326
+ }
327
+ previousUserSelect = document.body.style.userSelect
328
+ document.body.style.userSelect = 'none'
329
+ document.addEventListener('pointermove', onPointerMove)
330
+ document.addEventListener('pointerup', stopDrag)
331
+ document.addEventListener('pointercancel', stopDrag)
332
+ if (typeof handle.setPointerCapture === 'function') {
333
+ handle.setPointerCapture(event.pointerId)
334
+ }
335
+ })
336
+
337
+ handle.addEventListener('keydown', event => {
338
+ const step = event.shiftKey ? KEY_STEP_FAST : KEY_STEP
339
+ const grow =
340
+ axis === AXIS_X ? event.key === 'ArrowLeft' : event.key === 'ArrowUp'
341
+ const shrink =
342
+ axis === AXIS_X ? event.key === 'ArrowRight' : event.key === 'ArrowDown'
343
+ if (!grow && !shrink) return
344
+ event.preventDefault()
345
+ const current = axis === AXIS_X ? chrome.offsetWidth : chrome.offsetHeight
346
+ applySlotSize(
347
+ section,
348
+ slot.id,
349
+ axis,
350
+ current + (grow ? step : -step),
351
+ storage,
352
+ )
353
+ })
354
+
355
+ chrome.insertBefore(handle, chrome.firstChild)
356
+ handles.push(handle)
357
+ })
358
+
359
+ return {
360
+ detach: () => {
361
+ stopDrag()
362
+ handles.forEach(handle => {
363
+ handle.remove()
364
+ })
365
+ },
366
+ }
367
+ }
package/src/slots.test.ts CHANGED
@@ -34,6 +34,25 @@ describe('HostSlotManifest', () => {
34
34
  expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
35
35
  })
36
36
 
37
+ test('an absent layouts key stays absent — hosts without presets do not invent four', () => {
38
+ const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
39
+ const decoded = decodeManifest(parsed)
40
+ expect(Option.isSome(decoded)).toBe(true)
41
+ if (Option.isSome(decoded)) {
42
+ expect('layouts' in decoded.value).toBe(false)
43
+ }
44
+ })
45
+
46
+ test('decodes named layout presets without inventing a hint', () => {
47
+ const parsed: unknown = JSON.parse(
48
+ JSON.stringify({
49
+ ...RAW_MANIFEST,
50
+ layouts: [{ id: 'split' }, { id: 'map', hint: 'Map — canvas full height' }],
51
+ }),
52
+ )
53
+ expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
54
+ })
55
+
37
56
  test('rejects a slot whose kind is not bar, band, rail or grid', () => {
38
57
  const parsed: unknown = JSON.parse(
39
58
  JSON.stringify({
@@ -43,4 +62,38 @@ describe('HostSlotManifest', () => {
43
62
  )
44
63
  expect(Option.isNone(decodeManifest(parsed))).toBe(true)
45
64
  })
65
+
66
+ test('an absent resize key stays absent — a slot is not resizable by default', () => {
67
+ const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
68
+ const decoded = decodeManifest(parsed)
69
+ expect(Option.isSome(decoded)).toBe(true)
70
+ if (Option.isSome(decoded)) {
71
+ decoded.value.slots.forEach(slot => {
72
+ expect('resize' in slot).toBe(false)
73
+ })
74
+ }
75
+ })
76
+
77
+ test('decodes a resize axis without inventing the other', () => {
78
+ const parsed: unknown = JSON.parse(
79
+ JSON.stringify({
80
+ ...RAW_MANIFEST,
81
+ slots: [
82
+ { id: 'app-list-slot', kind: 'rail', row: 2, width: '280px', resize: 'x' },
83
+ { id: 'hero-band', kind: 'band', row: 1, resize: 'y' },
84
+ ],
85
+ }),
86
+ )
87
+ expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
88
+ })
89
+
90
+ test('rejects a resize axis that is not x or y', () => {
91
+ const parsed: unknown = JSON.parse(
92
+ JSON.stringify({
93
+ theme: RAW_MANIFEST.theme,
94
+ slots: [{ id: 'app-list-slot', kind: 'rail', row: 2, resize: 'both' }],
95
+ }),
96
+ )
97
+ expect(Option.isNone(decodeManifest(parsed))).toBe(true)
98
+ })
46
99
  })
package/src/slots.ts CHANGED
@@ -27,13 +27,25 @@ import { Schema as S } from 'effect'
27
27
  export const SlotKind = S.Literals(['bar', 'band', 'rail', 'grid'])
28
28
  export type SlotKind = typeof SlotKind.Type
29
29
 
30
+ /**
31
+ * Which edge of a slot the host may drag. `x` is the inline axis (a trailing
32
+ * rail's inner edge — the Wiring drawer); `y` is the block axis (a bottom
33
+ * band's top edge — the Wiring table pane). Absent means the slot is not
34
+ * resizable. The handle sits on the start edge of the chrome ancestor, which
35
+ * is the inner edge of a trailing pane; a leading rail does not set this
36
+ * until an edge field exists.
37
+ */
38
+ export const SlotResizeAxis = S.Literals(['x', 'y'])
39
+ export type SlotResizeAxis = typeof SlotResizeAxis.Type
40
+
30
41
  /**
31
42
  * One declared slot. `row` is the vertical band the slot occupies — slots
32
43
  * sharing a row sit side by side (the fixture's sidebar / main / aside).
33
44
  * `width` is a CSS length for a fixed `rail` (`'280px'`); `capacity` is how
34
- * many placements the slot is meant to hold. Both `optionalKey` (not
35
- * `optional`) so the decoded Type is exact-optional under
36
- * `exactOptionalPropertyTypes`, matching the rest of the topology schemas.
45
+ * many placements the slot is meant to hold. `resize` is the drag axis, if
46
+ * any. All three of those are `optionalKey` (not `optional`) so the decoded
47
+ * Type is exact-optional under `exactOptionalPropertyTypes`, matching the
48
+ * rest of the topology schemas.
37
49
  */
38
50
  export const SlotSpec = S.Struct({
39
51
  id: S.String,
@@ -41,6 +53,7 @@ export const SlotSpec = S.Struct({
41
53
  row: S.Number,
42
54
  width: S.optionalKey(S.String),
43
55
  capacity: S.optionalKey(S.Number),
56
+ resize: S.optionalKey(SlotResizeAxis),
44
57
  })
45
58
  export type SlotSpec = typeof SlotSpec.Type
46
59
 
@@ -51,8 +64,22 @@ export type SlotSpec = typeof SlotSpec.Type
51
64
  * degrade honestly — slot checks are reported unverifiable, never silently
52
65
  * green (see `./placementChecks.ts`).
53
66
  */
67
+ /**
68
+ * A named layout preset the host may apply to a screen. `id` is the
69
+ * `data-layout` value; `hint` is chrome copy from the declaring host, not a
70
+ * human name for a MicroDot. Absent `layouts` is a host that has not
71
+ * declared presets — the Wiring toolbar hides the control rather than
72
+ * inventing four.
73
+ */
74
+ export const SlotLayoutPreset = S.Struct({
75
+ id: S.String,
76
+ hint: S.optionalKey(S.String),
77
+ })
78
+ export type SlotLayoutPreset = typeof SlotLayoutPreset.Type
79
+
54
80
  export const HostSlotManifest = S.Struct({
55
81
  theme: S.Struct({ name: S.String, version: S.String }),
56
82
  slots: S.Array(SlotSpec),
83
+ layouts: S.optionalKey(S.Array(SlotLayoutPreset)),
57
84
  })
58
85
  export type HostSlotManifest = typeof HostSlotManifest.Type
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import corpusInput from '../evals/topology-regressions.p5.v1.json' with { type: 'json' }
4
+ import {
5
+ decodeTopologyRegressionCorpusSync,
6
+ observeTopologyRegressionCase,
7
+ topologyRegressionSurfaces,
8
+ } from './topologyRegression.ts'
9
+
10
+ describe('Phase 5 topology regression corpus', () => {
11
+ test('keeps the promoted layout and wire slices exact', () => {
12
+ const corpus = decodeTopologyRegressionCorpusSync(corpusInput)
13
+ const surfaces = topologyRegressionSurfaces(corpus)
14
+ expect(corpus.cases).toHaveLength(3)
15
+ for (const item of corpus.cases) {
16
+ expect(
17
+ observeTopologyRegressionCase({ item, surfaces }),
18
+ item.id,
19
+ ).toEqual(item.expected)
20
+ }
21
+ })
22
+
23
+ test('rejects arbitrary corpus and tool identity labels', () => {
24
+ expect(() =>
25
+ decodeTopologyRegressionCorpusSync({
26
+ ...corpusInput,
27
+ corpusVersion: 'p5.future',
28
+ }),
29
+ ).toThrow()
30
+ expect(() =>
31
+ decodeTopologyRegressionCorpusSync({
32
+ ...corpusInput,
33
+ toolVersion: 'compose-from-selection@99',
34
+ }),
35
+ ).toThrow()
36
+ })
37
+ })
@@ -0,0 +1,143 @@
1
+ import { Schema as S } from 'effect'
2
+
3
+ import type { ManifestTag } from '@bespokeagentics/microdots-element'
4
+
5
+ import {
6
+ type ComposeFromSelectionInput,
7
+ composeFromSelection,
8
+ } from './composeFromSelection.ts'
9
+ import { SlotKind } from './slots.ts'
10
+
11
+ const WireValueType = S.Literals(['string', 'number', 'boolean', 'json'])
12
+ const Ownership = S.Literals(['dot', 'host-input', 'environment'])
13
+
14
+ export const TOPOLOGY_REGRESSION_CORPUS_ID =
15
+ 'microdots.topology-regressions.p5' as const
16
+ export const TOPOLOGY_REGRESSION_CORPUS_VERSION = 'p5.1' as const
17
+ export const TOPOLOGY_REGRESSION_TOOL_VERSION =
18
+ 'compose-from-selection@1' as const
19
+
20
+ const TopologyRegressionSurfaceV1 = S.Struct({
21
+ tag: S.NonEmptyString,
22
+ attributes: S.Array(
23
+ S.Struct({ name: S.NonEmptyString, ownership: Ownership }),
24
+ ),
25
+ events: S.Array(
26
+ S.Struct({
27
+ name: S.NonEmptyString,
28
+ fields: S.Record(S.String, WireValueType),
29
+ }),
30
+ ),
31
+ })
32
+
33
+ const TopologyRegressionInputV1 = S.Struct({
34
+ brief: S.optionalKey(S.NonEmptyString),
35
+ hostId: S.NonEmptyString,
36
+ hostLabel: S.NonEmptyString,
37
+ tags: S.NonEmptyArray(S.NonEmptyString),
38
+ layout: S.Literals(['together', 'per-tag']),
39
+ slotKind: SlotKind,
40
+ wires: S.Array(
41
+ S.Struct({
42
+ from: S.NonEmptyString,
43
+ event: S.NonEmptyString,
44
+ field: S.NonEmptyString,
45
+ to: S.NonEmptyString,
46
+ input: S.NonEmptyString,
47
+ }),
48
+ ),
49
+ })
50
+
51
+ export const TopologyRegressionObservationV1 = S.Struct({
52
+ hostId: S.NonEmptyString,
53
+ hostLabel: S.NonEmptyString,
54
+ generateCount: S.Natural,
55
+ routePaths: S.Array(S.NonEmptyString),
56
+ mountedTags: S.Array(S.NonEmptyString),
57
+ slotKinds: S.Array(SlotKind),
58
+ wirePlain: S.Array(S.NonEmptyString),
59
+ })
60
+ export type TopologyRegressionObservationV1 =
61
+ typeof TopologyRegressionObservationV1.Type
62
+
63
+ export const TopologyRegressionCaseV1 = S.Struct({
64
+ schemaVersion: S.Literal(1),
65
+ id: S.NonEmptyString,
66
+ title: S.NonEmptyString,
67
+ input: TopologyRegressionInputV1,
68
+ expected: TopologyRegressionObservationV1,
69
+ })
70
+ export type TopologyRegressionCaseV1 = typeof TopologyRegressionCaseV1.Type
71
+
72
+ export const TopologyRegressionCorpusV1 = S.Struct({
73
+ schemaVersion: S.Literal(1),
74
+ id: S.Literal(TOPOLOGY_REGRESSION_CORPUS_ID),
75
+ corpusVersion: S.Literal(TOPOLOGY_REGRESSION_CORPUS_VERSION),
76
+ toolVersion: S.Literal(TOPOLOGY_REGRESSION_TOOL_VERSION),
77
+ surfaces: S.NonEmptyArray(TopologyRegressionSurfaceV1),
78
+ cases: S.NonEmptyArray(TopologyRegressionCaseV1),
79
+ }).check(
80
+ S.makeFilter(
81
+ corpus =>
82
+ new Set(corpus.cases.map(item => item.id)).size === corpus.cases.length,
83
+ { expected: 'unique topology regression case ids' },
84
+ ),
85
+ )
86
+ export type TopologyRegressionCorpusV1 = typeof TopologyRegressionCorpusV1.Type
87
+
88
+ export const decodeTopologyRegressionCorpusSync = S.decodeUnknownSync(
89
+ TopologyRegressionCorpusV1,
90
+ { onExcessProperty: 'error' },
91
+ )
92
+
93
+ export const topologyRegressionSurfaces = (
94
+ corpus: TopologyRegressionCorpusV1,
95
+ ): ReadonlyArray<ManifestTag> =>
96
+ corpus.surfaces.map(surface => ({
97
+ tag: surface.tag,
98
+ attributes: surface.attributes.map(attribute => ({
99
+ name: attribute.name,
100
+ type: 'string',
101
+ required: false,
102
+ live: true,
103
+ ownership: attribute.ownership,
104
+ })),
105
+ events: surface.events.map(event => ({
106
+ name: event.name,
107
+ payload: {
108
+ ref: `@microdots/topology-regression/contract#${event.name}`,
109
+ jsonSchema: {
110
+ dialect: 'draft-2020-12',
111
+ schema: {
112
+ type: 'object',
113
+ properties: Object.fromEntries(
114
+ Object.entries(event.fields).map(([name, type]) => [
115
+ name,
116
+ { type },
117
+ ]),
118
+ ),
119
+ },
120
+ definitions: {},
121
+ },
122
+ },
123
+ })),
124
+ }))
125
+
126
+ export const observeTopologyRegressionCase = (input: {
127
+ readonly item: TopologyRegressionCaseV1
128
+ readonly surfaces: ReadonlyArray<ManifestTag>
129
+ }): TopologyRegressionObservationV1 => {
130
+ const selection: ComposeFromSelectionInput = input.item.input
131
+ const spec = composeFromSelection(selection, input.surfaces)
132
+ return {
133
+ hostId: spec.topology.host.id,
134
+ hostLabel: spec.topology.host.label,
135
+ generateCount: spec.generate.length,
136
+ routePaths: spec.topology.routes.map(route => route.path),
137
+ mountedTags: spec.topology.routes.flatMap(route =>
138
+ route.mounts.map(mount => mount.tag),
139
+ ),
140
+ slotKinds: spec.topology.slotManifest?.slots.map(slot => slot.kind) ?? [],
141
+ wirePlain: spec.topology.wires.map(wire => wire.plain),
142
+ }
143
+ }