tosijs-floorplan 0.3.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.
@@ -0,0 +1,737 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import { schematicSVG, schematic } from './index'
3
+ import { SchematicDescription } from './index'
4
+
5
+ const description: SchematicDescription = {
6
+ exposure: 'introspection',
7
+ roots: { app: 'object' },
8
+ actions: ['app.restock'],
9
+ wiring: [
10
+ {
11
+ tag: 'input',
12
+ label: 'filter stock…',
13
+ value: 'milk ⟷ app.filter',
14
+ bounds: { x: 10, y: 20, width: 200, height: 30 },
15
+ style: {
16
+ background: 'rgb(255, 255, 255)',
17
+ borderColor: 'rgb(128, 128, 128)',
18
+ color: 'rgb(17, 17, 17)',
19
+ },
20
+ },
21
+ {
22
+ tag: 'span',
23
+ text: '3 ⟵ app.total',
24
+ bounds: { x: 220, y: 20, width: 40, height: 30 },
25
+ },
26
+ {
27
+ // hidden: zero-size — must not be drawn
28
+ tag: 'div',
29
+ text: 'invisible',
30
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
31
+ },
32
+ {
33
+ tag: 'button',
34
+ text: 'a & "b" <c>',
35
+ on: { click: 'app.restock' },
36
+ bounds: { x: 10, y: 60, width: 80, height: 8 }, // too short for a caption
37
+ },
38
+ ],
39
+ }
40
+
41
+ describe('schematicSVG — pure, DOM-free rendering of the map', () => {
42
+ test('draws visible wired elements at their true geometry', () => {
43
+ const svg = schematicSVG(description)
44
+ expect(svg.startsWith('<svg xmlns=')).toBe(true)
45
+ expect(svg).toContain('<rect x="10" y="20" width="200" height="30"')
46
+ expect(svg).toContain('<rect x="220" y="20" width="40" height="30"')
47
+ // the input HOLDS a value — name and value both speak: "label: value" —
48
+ // and editability is a SEPARATE right-edge ↔ badge (rasterizer-safe)
49
+ expect(svg).toContain('filter stock…: milk</text>')
50
+ expect(svg).toContain('>↔</text>')
51
+ })
52
+
53
+ test('root svg carries explicit width/height (intrinsic size; Firefox canvas-draw requires it)', () => {
54
+ const svg = schematicSVG(description)
55
+ // bbox: x 10-260 (+8 pad both sides), y 20-68 (+8 both sides) — PLUS
56
+ // the 14px legend footer: the 8px button is cramped (its caption lives
57
+ // in the legend), and the image must confess that
58
+ expect(svg).toContain('width="266"')
59
+ expect(svg).toContain('height="78"')
60
+ expect(svg).toContain('details in legend')
61
+ })
62
+
63
+ test('zero-size records (hidden elements) are not drawn', () => {
64
+ const svg = schematicSVG(description)
65
+ expect(svg).not.toContain('invisible')
66
+ expect(svg).not.toContain('width="0"')
67
+ })
68
+
69
+ test('cramped records: bare box, auto stamp, metadata in the legend', () => {
70
+ const { svg, legend } = schematic(description)
71
+ // the 8px button (record 3): no caption drawn, but STAMPED unbidden
72
+ expect(svg).toContain('data-index-backdrop')
73
+ expect(svg).toContain('>3</text>')
74
+ const entry = legend.find((e) => e.index === 3)!
75
+ expect(entry).toBeDefined()
76
+ expect(entry.caption).toBe('a & "b" <c>') // unescaped truth, for JSON
77
+ expect(entry.tag).toBe('button')
78
+ // machine-readable confession rides the svg itself
79
+ expect(svg).toContain('<desc>')
80
+ expect(svg).toContain('pair this image with its legend JSON')
81
+ })
82
+
83
+ test('nothing elided = no footer, no desc, byte-stable output', () => {
84
+ const roomy: SchematicDescription = {
85
+ wiring: [
86
+ {
87
+ tag: 'input',
88
+ label: 'name',
89
+ value: 'ada ⟷ a.name',
90
+ bounds: { x: 10, y: 10, width: 200, height: 30 },
91
+ },
92
+ ],
93
+ }
94
+ const { svg, legend } = schematic(roomy)
95
+ expect(legend).toEqual([])
96
+ expect(svg).not.toContain('<desc>')
97
+ expect(svg).not.toContain('details in legend')
98
+ expect(svg).toBe(schematicSVG(roomy))
99
+ })
100
+
101
+ test('undersized interactive elements: amber bar + measured legend fact; toggles exempt', () => {
102
+ const tiny: SchematicDescription = {
103
+ wiring: [
104
+ {
105
+ tag: 'button',
106
+ text: 'x',
107
+ on: { click: 'app.del' },
108
+ bounds: { x: 10, y: 10, width: 18, height: 18 },
109
+ },
110
+ {
111
+ tag: 'input',
112
+ type: 'checkbox',
113
+ checked: true,
114
+ value: 'true ⟷ a.on',
115
+ bounds: { x: 40, y: 10, width: 13, height: 13 },
116
+ },
117
+ {
118
+ tag: 'span',
119
+ text: 'ok',
120
+ bounds: { x: 60, y: 10, width: 18, height: 18 },
121
+ },
122
+ ],
123
+ }
124
+ const { svg, legend } = schematic(tiny)
125
+ expect(svg).toContain('data-flag="target-size"')
126
+ const bar = legend.find((e) => e.undersized != null)!
127
+ expect(bar.index).toBe(0)
128
+ expect(bar.undersized).toBe('18×18 — below 24×24 (WCAG 2.5.8)')
129
+ // the checkbox (user-agent-sized) and the inert span are NOT flagged
130
+ expect(legend.filter((e) => e.undersized != null).length).toBe(1)
131
+ // raising the bar to the platform touch standard is one option away
132
+ const strict = schematic(tiny, { targetSize: 44 })
133
+ expect(strict.legend.find((e) => e.index === 0)!.undersized).toContain('44×44')
134
+ // and 0 disables the audit
135
+ expect(schematic(tiny, { targetSize: 0 }).legend.filter((e) => e.undersized).length).toBe(0)
136
+ })
137
+
138
+ test('truncated captions land whole in the legend', () => {
139
+ const longText =
140
+ 'a caption far too long for the box it lives in, which keeps going'
141
+ const { legend } = schematic({
142
+ wiring: [
143
+ { tag: 'span', text: longText, bounds: { x: 0, y: 0, width: 80, height: 20 } },
144
+ ],
145
+ })
146
+ expect(legend.length).toBe(1)
147
+ expect(legend[0].caption).toBe(longText)
148
+ })
149
+
150
+ test('each group indexes back into description.wiring — the image as index', () => {
151
+ const svg = schematicSVG(description)
152
+ expect(svg).toContain('data-record="0"')
153
+ expect(svg).toContain('data-record="1"')
154
+ expect(svg).toContain('data-record="3"')
155
+ expect(svg).not.toContain('data-record="2"') // the hidden one
156
+ })
157
+
158
+ test('styles are worn when present, defaults when not', () => {
159
+ const svg = schematicSVG(description)
160
+ expect(svg).toContain('fill="rgb(255, 255, 255)"')
161
+ expect(svg).toContain('stroke="rgb(128, 128, 128)"')
162
+ expect(svg).toContain('stroke="currentColor"') // the unstyled span
163
+ })
164
+
165
+ test('captions are XML-escaped and short boxes get none', () => {
166
+ const svg = schematicSVG(description)
167
+ // the button box is 8px tall — below minLabelHeight — so its caption
168
+ // (which contains XML-hostile characters) must be absent entirely
169
+ expect(svg).not.toContain('a &amp; &quot;b&quot;')
170
+ const tall = schematicSVG(description, { minLabelHeight: 4 })
171
+ expect(tall).toContain('a &amp; &quot;b&quot; &lt;c&gt;')
172
+ })
173
+
174
+ test('empty map renders an empty svg rather than throwing', () => {
175
+ const svg = schematicSVG({
176
+ exposure: 'introspection',
177
+ roots: {},
178
+ actions: [],
179
+ wiring: [],
180
+ })
181
+ expect(svg).toContain('<svg')
182
+ })
183
+ })
184
+
185
+ describe('spatial scoping — within', () => {
186
+ test('within keeps intersecting records and the viewBox IS the region', () => {
187
+ const region = { x: 0, y: 0, width: 300, height: 60 }
188
+ const svg = schematicSVG(description, { within: region })
189
+ // input (10,20 200x30) and span (220,20 40x30) intersect; button at y60
190
+ // height 8 touches the edge? y:60 vs region 0..60 — 60 < 60 false → excluded
191
+ expect(svg).toContain('data-record="0"')
192
+ expect(svg).toContain('data-record="1"')
193
+ expect(svg).not.toContain('data-record="3"')
194
+ // viewBox is the region (padded by default 8)
195
+ expect(svg).toContain('viewBox="-8 -8 316 76"')
196
+ })
197
+
198
+ test('a region intersecting nothing yields the empty svg', () => {
199
+ const svg = schematicSVG(description, {
200
+ within: { x: 5000, y: 5000, width: 10, height: 10 },
201
+ })
202
+ expect(svg).toContain('viewBox="0 0 0 0"')
203
+ })
204
+ })
205
+
206
+ describe('containment-aware captions', () => {
207
+ test('a container box gets no text-derived caption — its children speak', () => {
208
+ const nested: SchematicDescription = {
209
+ exposure: 'introspection',
210
+ roots: {},
211
+ actions: [],
212
+ wiring: [
213
+ {
214
+ tag: 'ul',
215
+ text: 'alpha beta', // concatenated child text — must NOT render
216
+ bounds: { x: 0, y: 0, width: 200, height: 100 },
217
+ },
218
+ {
219
+ tag: 'span',
220
+ text: 'alpha',
221
+ bounds: { x: 10, y: 10, width: 80, height: 20 },
222
+ },
223
+ {
224
+ tag: 'span',
225
+ text: 'beta',
226
+ bounds: { x: 10, y: 40, width: 80, height: 20 },
227
+ },
228
+ ],
229
+ }
230
+ const svg = schematicSVG(nested)
231
+ expect(svg).not.toContain('alpha beta')
232
+ expect(svg).toContain('>alpha</text>')
233
+ expect(svg).toContain('>beta</text>')
234
+ })
235
+
236
+ test('a container with an explicit label keeps it', () => {
237
+ const labeled: SchematicDescription = {
238
+ exposure: 'introspection',
239
+ roots: {},
240
+ actions: [],
241
+ wiring: [
242
+ {
243
+ tag: 'ul',
244
+ label: 'todo list',
245
+ text: 'alpha',
246
+ bounds: { x: 0, y: 0, width: 200, height: 100 },
247
+ },
248
+ {
249
+ tag: 'span',
250
+ text: 'alpha',
251
+ bounds: { x: 10, y: 10, width: 80, height: 20 },
252
+ },
253
+ ],
254
+ }
255
+ const svg = schematicSVG(labeled)
256
+ expect(svg).toContain('todo list')
257
+ })
258
+ })
259
+
260
+ describe('viewport-fixed furniture', () => {
261
+ test('pinned records neither stretch the viewBox nor sit mid-document', () => {
262
+ const withChrome: SchematicDescription = {
263
+ exposure: 'introspection',
264
+ roots: {},
265
+ actions: [],
266
+ wiring: [
267
+ {
268
+ tag: 'input',
269
+ text: 'content',
270
+ bounds: { x: 100, y: 5000, width: 200, height: 30 },
271
+ },
272
+ {
273
+ tag: 'nav',
274
+ label: 'site nav',
275
+ viewportFixed: true,
276
+ bounds: { x: 0, y: 0, width: 180, height: 400 },
277
+ },
278
+ ],
279
+ }
280
+ const svg = schematicSVG(withChrome)
281
+ // viewBox fits the FLOW content only (y around 5000), not the nav
282
+ expect(svg).toContain('viewBox="92 4992 216 46"')
283
+ // the nav is pinned at the map origin (min + pad), not at page 0,0
284
+ expect(svg).toContain('<rect x="100" y="5000"')
285
+ })
286
+ })
287
+
288
+ describe('off-page hiding', () => {
289
+ test('fully negative-coordinate records are excluded — hidden is hidden', () => {
290
+ const stashed: SchematicDescription = {
291
+ exposure: 'introspection',
292
+ roots: {},
293
+ actions: [],
294
+ wiring: [
295
+ {
296
+ tag: 'input',
297
+ text: 'visible',
298
+ bounds: { x: 10, y: 10, width: 100, height: 30 },
299
+ },
300
+ {
301
+ tag: 'button',
302
+ text: 'stashed off-page',
303
+ bounds: { x: 400, y: -1358, width: 90, height: 36 },
304
+ },
305
+ ],
306
+ }
307
+ const svg = schematicSVG(stashed)
308
+ expect(svg).not.toContain('stashed off-page')
309
+ expect(svg).toContain('viewBox="2 2 116 46"') // fits the visible box only
310
+ })
311
+ })
312
+
313
+ describe('the affordance grammar — actionable is explicit', () => {
314
+ test('handler-wired elements get a bold outline; editable boxes wear the badge', () => {
315
+ const grammar: SchematicDescription = {
316
+ exposure: 'introspection',
317
+ roots: {},
318
+ actions: [],
319
+ wiring: [
320
+ {
321
+ tag: 'input',
322
+ label: 'add a todo…', // label hides the bound-value arrow…
323
+ value: ' ⟷ app.newItem',
324
+ bounds: { x: 0, y: 0, width: 200, height: 30 },
325
+ },
326
+ {
327
+ tag: 'button',
328
+ text: 'add',
329
+ on: { click: 'app.addItem' },
330
+ bounds: { x: 210, y: 0, width: 60, height: 30 },
331
+ },
332
+ {
333
+ tag: 'span',
334
+ text: '3 ⟵ app.total', // display-only: no bold, no suffix
335
+ bounds: { x: 280, y: 0, width: 40, height: 30 },
336
+ },
337
+ ],
338
+ }
339
+ const svg = schematicSVG(grammar)
340
+ // …so the ↔ badge carries it: editable is explicit, and isolated in its
341
+ // own text run so a font missing it can only tofu the badge itself
342
+ expect(svg).toContain('add a todo…</text>')
343
+ expect(svg).toContain('>↔</text>')
344
+ expect(svg).not.toContain('⟷</text>') // the rare glyph never rides a caption
345
+ // the wired button is BOLD; the display-only span is not
346
+ expect(svg).toContain('x="210" y="0" width="60" height="30" fill="transparent" stroke="currentColor" stroke-width="2"')
347
+ expect(svg).toContain('x="280" y="0" width="40" height="30" fill="transparent" stroke="currentColor"/>')
348
+ })
349
+ })
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // the kitchen-sink truths: control state must RENDER, hints must not read as
353
+ // content, and the raster can carry its own legend
354
+ describe('schematicSVG — control-state truth', () => {
355
+ const bounds = (x: number) => ({ x, y: 10, width: 120, height: 24 })
356
+ const sink: any = {
357
+ roots: {},
358
+ actions: [],
359
+ exposure: 'introspection',
360
+ wiring: [
361
+ // checkbox: checked and not — the state is the caption
362
+ { tag: 'input', type: 'checkbox', checked: true, value: 'true ⟷ a.on', bounds: { x: 10, y: 10, width: 13, height: 13 } },
363
+ { tag: 'input', type: 'checkbox', checked: false, bounds: { x: 30, y: 10, width: 13, height: 13 } },
364
+ // radios
365
+ { tag: 'input', type: 'radio', checked: true, label: 'medium', bounds: bounds(150) },
366
+ { tag: 'input', type: 'radio', checked: false, label: 'large', bounds: bounds(280) },
367
+ // empty input with placeholder: an italic HINT, not content
368
+ { tag: 'input', placeholder: 'add a todo…', value: '⟷ a.newItem', bounds: bounds(410) },
369
+ // input with BOTH: the value wins
370
+ { tag: 'input', placeholder: 'search…', value: 'milk ⟷ a.filter', bounds: bounds(540) },
371
+ // unbound input harvested live (plain string, no arrow)
372
+ { tag: 'input', value: 'typed by hand', bounds: bounds(670) },
373
+ ],
374
+ }
375
+
376
+ test('toggle state is GEOMETRY: ✕ in the checked box, dot in the checked radio', () => {
377
+ const svg = schematicSVG(sink)
378
+ // the checked checkbox draws an ✕ (two lines); the unchecked one none
379
+ expect(svg.match(/<line /g)!.length).toBe(2)
380
+ // radios are circles: two outlines, ONE filled dot (the checked one)
381
+ expect(svg.match(/<circle /g)!.length).toBe(3)
382
+ // toggle labels sit to the right of the control, not inside it
383
+ expect(svg).toContain('>medium</text>')
384
+ expect(svg).toContain('>large</text>')
385
+ // the radio rows draw no <rect> boxes — the circle IS the control
386
+ expect(svg).not.toContain('<rect x="150"')
387
+ })
388
+
389
+ test('placeholder renders as an italic hint; a held value beats it', () => {
390
+ const svg = schematicSVG(sink)
391
+ expect(svg).toContain('font-style="italic"')
392
+ expect(svg).toContain('add a todo…')
393
+ expect(svg).toContain('milk</text>')
394
+ expect(svg).not.toContain('search…') // value present — hint suppressed
395
+ expect(svg).toContain('typed by hand') // live unbound value surfaces
396
+ })
397
+
398
+ test('index: true stamps each box with its wiring index — image as legend', () => {
399
+ const svg = schematicSVG(sink, { index: true })
400
+ for (let i = 0; i < sink.wiring.length; i++) {
401
+ expect(svg).toContain(`data-record="${i}"`)
402
+ }
403
+ // one backdropped digit per record (the ↔ badge also anchors end, so
404
+ // count the backdrops — white, mostly opaque, always legible)
405
+ expect(svg.match(/data-index-backdrop/g)!.length).toBe(sink.wiring.length)
406
+ })
407
+ })
408
+
409
+ describe('focus — where the user is', () => {
410
+ test('a focused record draws a second outline just outside its box', () => {
411
+ const focusMap: any = {
412
+ roots: {},
413
+ actions: [],
414
+ exposure: 'introspection',
415
+ wiring: [
416
+ {
417
+ tag: 'input',
418
+ focused: true,
419
+ value: '⟷ a.q',
420
+ bounds: { x: 10, y: 10, width: 100, height: 24 },
421
+ },
422
+ {
423
+ tag: 'input',
424
+ value: '⟷ a.r',
425
+ bounds: { x: 10, y: 50, width: 100, height: 24 },
426
+ },
427
+ ],
428
+ }
429
+ const svg = schematicSVG(focusMap)
430
+ // the ring: offset 2.5px out, 5px larger, unfilled
431
+ expect(svg).toContain('<rect x="7.5" y="7.5" width="105" height="29"')
432
+ // exactly one ring — the unfocused input gets none
433
+ expect(svg.match(/stroke-width="1\.5"/g)!.length).toBe(1)
434
+ })
435
+ })
436
+
437
+ describe('list containers are ground, not figure', () => {
438
+ test('a list-bound container draws dotted like structure — its items act', () => {
439
+ const listMap: any = {
440
+ roots: {},
441
+ actions: [],
442
+ exposure: 'introspection',
443
+ wiring: [
444
+ {
445
+ tag: 'ul',
446
+ list: { path: 'app.items', idPath: 'id' },
447
+ bounds: { x: 0, y: 0, width: 200, height: 100 },
448
+ },
449
+ {
450
+ tag: 'input',
451
+ type: 'checkbox',
452
+ checked: true,
453
+ value: 'true ⟷ app.items[id=1].done',
454
+ bounds: { x: 10, y: 10, width: 13, height: 13 },
455
+ },
456
+ ],
457
+ }
458
+ const svg = schematicSVG(listMap)
459
+ // the ul: dotted, unfilled — wired in the JSON, ground in the drawing
460
+ expect(svg).toContain('stroke-dasharray="1 3"')
461
+ expect(svg).toContain('fill="none"')
462
+ // …but a container that also HANDLES events stays a solid affordance
463
+ listMap.wiring[0].on = { click: 'app.select' }
464
+ const svg2 = schematicSVG(listMap)
465
+ expect(svg2).not.toContain('stroke-dasharray')
466
+ })
467
+ })
468
+
469
+ describe('required and invalid — the form-truth grammar', () => {
470
+ const formMap: any = {
471
+ roots: {},
472
+ actions: [],
473
+ exposure: 'introspection',
474
+ wiring: [
475
+ {
476
+ tag: 'input',
477
+ label: 'email',
478
+ required: true,
479
+ invalid: true,
480
+ value: '⟷ a.email',
481
+ bounds: { x: 10, y: 10, width: 160, height: 24 },
482
+ },
483
+ {
484
+ tag: 'input',
485
+ label: 'nickname',
486
+ value: 'ada ⟷ a.nick',
487
+ bounds: { x: 10, y: 50, width: 160, height: 24 },
488
+ },
489
+ ],
490
+ }
491
+
492
+ test('required wears the asterisk; invalid wears the red corner flag', () => {
493
+ const svg = schematicSVG(formMap)
494
+ expect(svg).toContain('email *')
495
+ expect(svg).toContain('fill="#d32f2f"')
496
+ expect(svg).toContain(`<path d="M10 10 l7 0 l-7 7 z"`)
497
+ // the valid field wears neither
498
+ expect(svg).not.toContain('nickname: ada *')
499
+ expect(svg.match(/#d32f2f/g)!.length).toBe(1)
500
+ })
501
+ })
502
+
503
+ describe('contenteditable — drawn as an input', () => {
504
+ test('value captions, hints italicize, and the ↔ badge appears unbid', () => {
505
+ const map: any = {
506
+ roots: {},
507
+ actions: [],
508
+ exposure: 'introspection',
509
+ wiring: [
510
+ {
511
+ tag: 'div',
512
+ contentEditable: true,
513
+ value: 'draft text',
514
+ bounds: { x: 10, y: 10, width: 200, height: 60 },
515
+ },
516
+ {
517
+ tag: 'div',
518
+ contentEditable: true,
519
+ placeholder: 'jot something…',
520
+ bounds: { x: 10, y: 90, width: 200, height: 60 },
521
+ },
522
+ ],
523
+ }
524
+ const svg = schematicSVG(map)
525
+ expect(svg).toContain('draft text')
526
+ expect(svg).toContain('jot something…')
527
+ expect(svg).toContain('font-style="italic"')
528
+ // editable-by-hand is a fact of the element — no binding required
529
+ expect(svg.match(/>↔<\/text>/g)!.length).toBe(2)
530
+ })
531
+ })
532
+
533
+ describe('decorate — the plugin seam', () => {
534
+ test('runs per drawn record inside its group; skipped records never decorate', () => {
535
+ const map: SchematicDescription = {
536
+ wiring: [
537
+ { tag: 'input', value: 'x ⟷ a.x', bounds: { x: 10, y: 10, width: 100, height: 24 } },
538
+ { tag: 'div', text: 'hidden', bounds: { x: 0, y: 0, width: 0, height: 0 } },
539
+ ],
540
+ }
541
+ const seen: number[] = []
542
+ const svg = schematicSVG(map, {
543
+ decorate({ record, index, x, y, width, emit }) {
544
+ seen.push(index)
545
+ // a contrast-annotation-shaped plugin: a note at the top edge
546
+ emit(
547
+ `<text x="${x + width - 2}" y="${y - 2}" font-size="6" ` +
548
+ `text-anchor="end" data-plugin="note">${record.tag}</text>`
549
+ )
550
+ },
551
+ })
552
+ expect(seen).toEqual([0]) // the zero-size record was never drawn
553
+ expect(svg).toContain('data-plugin="note"')
554
+ // inside the record's group, before it closes
555
+ expect(svg.indexOf('data-plugin')).toBeLessThan(svg.indexOf('</g>'))
556
+ })
557
+ })
558
+
559
+ describe('producer compatibility — interface-typed maps assign without casts', () => {
560
+ test("a describe()-shaped interface flows straight in", () => {
561
+ // mirrors tosijs's AgentDescription: an INTERFACE (no implicit index
562
+ // signature) with extra fields — must satisfy SchematicDescription
563
+ interface ProducerShape {
564
+ roots: Record<string, string>
565
+ wiring: Array<{ tag: string; bounds?: { x: number; y: number; width: number; height: number }; [k: string]: unknown }>
566
+ actions: string[]
567
+ exposure: 'introspection' | 'manifest'
568
+ }
569
+ const map: ProducerShape = {
570
+ roots: { app: 'object' },
571
+ wiring: [{ tag: 'button', text: 'go', on: { click: 'app.go' }, bounds: { x: 0, y: 0, width: 40, height: 20 } }],
572
+ actions: ['app.go'],
573
+ exposure: 'introspection',
574
+ }
575
+ const svg = schematicSVG(map) // the assignment IS the assertion
576
+ expect(svg).toContain('data-record="0"')
577
+ })
578
+ })
579
+
580
+ describe('the haltija convergence — ref, flags, image, wrapping (0.2.0)', () => {
581
+ const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
582
+
583
+ test('ref: a durable handle takes the index slot and rides the group', () => {
584
+ const map: SchematicDescription = {
585
+ wiring: [
586
+ { tag: 'button', text: 'go', ref: '@42', bounds: at(10, 10) },
587
+ { tag: 'button', text: 'stop', bounds: at(10, 40) },
588
+ ],
589
+ }
590
+ const svg = schematicSVG(map, { index: true })
591
+ expect(svg).toContain('data-ref="@42"')
592
+ expect(svg).toContain('>@42</text>') // rendered in preference to 0
593
+ expect(svg).not.toContain('>0</text>')
594
+ expect(svg).toContain('>1</text>') // no ref → the index, as before
595
+ // a ref renders even without index: true — the producer asked for it
596
+ const bare = schematicSVG(map)
597
+ expect(bare).toContain('>@42</text>')
598
+ })
599
+
600
+ test('flags: severity bars on the left edge, first label shown', () => {
601
+ const map: SchematicDescription = {
602
+ wiring: [
603
+ {
604
+ tag: 'button',
605
+ text: 'low contrast',
606
+ flags: [
607
+ { kind: 'contrast', label: '2.3:1', severity: 'error' },
608
+ { kind: 'target-size', label: 'small', severity: 'warn' },
609
+ ],
610
+ bounds: at(10, 10),
611
+ },
612
+ ],
613
+ }
614
+ const svg = schematicSVG(map)
615
+ expect(svg).toContain('data-flag="contrast"')
616
+ expect(svg).toContain('data-flag="target-size"')
617
+ expect(svg).toContain('fill="#d32f2f" data-flag') // error color
618
+ expect(svg).toContain('fill="#e6a700" data-flag') // warn color
619
+ expect(svg).toContain('>2.3:1</text>') // first flag's label
620
+ expect(svg).not.toContain('>small</text>') // later labels stay quiet
621
+ })
622
+
623
+ test('image: a data-URL draws in place, behind the captions', () => {
624
+ const px =
625
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
626
+ const map: SchematicDescription = {
627
+ wiring: [
628
+ { tag: 'canvas', label: 'starfield', image: px, bounds: at(10, 10, 200, 100) },
629
+ { tag: 'img', text: 'no data', image: 'https://not-a-data-url', bounds: at(10, 120) },
630
+ ],
631
+ }
632
+ const svg = schematicSVG(map)
633
+ expect(svg).toContain(`href="${px}"`)
634
+ expect(svg).toContain('preserveAspectRatio="xMidYMid meet"')
635
+ expect(svg).not.toContain('not-a-data-url') // non-data URLs are refused
636
+ })
637
+
638
+ test('wrapping: captions use vertical room instead of truncating', () => {
639
+ const longText =
640
+ 'the quick brown fox jumps over the lazy dog and keeps going for a while'
641
+ const map: SchematicDescription = {
642
+ wiring: [
643
+ { tag: 'p', text: longText, bounds: at(10, 10, 300, 60) },
644
+ { tag: 'span', text: longText, bounds: at(10, 90, 300, 20) },
645
+ ],
646
+ }
647
+ const svg = schematicSVG(map)
648
+ expect(svg).toContain('<tspan') // the tall box wraps
649
+ expect(svg).toContain('jumps') // text past the old 36-char cut survives
650
+ // the short box still gets exactly one line, no tspans
651
+ const shortLine = svg.slice(svg.lastIndexOf('<text'))
652
+ expect(shortLine).not.toContain('<tspan')
653
+ })
654
+ })
655
+
656
+ describe('the legend convergence — href, value, the inline exception (0.3.0)', () => {
657
+ const at = (x: number, y: number, width = 160, height = 24) => ({ x, y, width, height })
658
+
659
+ test('href: a nameless link captions with its destination; a named one keeps its name', () => {
660
+ const map: SchematicDescription = {
661
+ wiring: [
662
+ { tag: 'a', href: '/docs', on: { click: 'ƒ' }, bounds: at(10, 10) },
663
+ { tag: 'a', text: 'read the docs', href: '/docs', on: { click: 'ƒ' }, bounds: at(10, 40) },
664
+ ],
665
+ }
666
+ const svg = schematicSVG(map)
667
+ expect(svg).toContain('>/docs</text>') // the sidebar case: no longer an empty box
668
+ expect(svg).toContain('read the docs') // a name, when present, wins
669
+ })
670
+
671
+ test('href always rides the legend — the destination is what an agent acts on', () => {
672
+ const { legend } = schematic({
673
+ wiring: [
674
+ { tag: 'a', text: 'home', href: 'https://example.com/a/very/long/path', on: { click: 'ƒ' }, bounds: at(10, 10) },
675
+ ],
676
+ })
677
+ expect(legend.length).toBe(1)
678
+ expect(legend[0].href).toBe('https://example.com/a/very/long/path')
679
+ })
680
+
681
+ test('value: a cramped input carries its held value into the legend', () => {
682
+ const { svg, legend } = schematic({
683
+ wiring: [
684
+ { tag: 'input', label: 'qty', value: '3 ⟷ app.qty', bounds: at(10, 10, 30, 12) },
685
+ ],
686
+ })
687
+ expect(svg).not.toContain('qty') // too cramped to caption
688
+ expect(legend.length).toBe(1)
689
+ expect(legend[0].caption).toBe('qty: 3')
690
+ expect(legend[0].value).toBe('3') // provenance stripped
691
+ })
692
+
693
+ test('inline exception: a text-sized link is not flagged undersized; an icon link is', () => {
694
+ const { legend } = schematic({
695
+ wiring: [
696
+ // a link in prose: sized by its text — WCAG 2.5.8 exempts it
697
+ { tag: 'a', text: 'terms', href: '/terms', on: { click: 'ƒ' }, bounds: at(10, 10, 34, 16) },
698
+ // an icon link (an <a> wrapping an <svg>, no text): flagged
699
+ { tag: 'a', label: 'settings', href: '/settings', on: { click: 'ƒ' }, bounds: at(60, 10, 16, 16) },
700
+ ],
701
+ })
702
+ const undersized = legend.filter((entry) => entry.undersized != null)
703
+ expect(undersized.length).toBe(1)
704
+ expect(undersized[0].href).toBe('/settings')
705
+ })
706
+
707
+ test('a producer-supplied target flag supersedes the built-in audit — no double bars', () => {
708
+ const { svg, legend } = schematic({
709
+ wiring: [
710
+ {
711
+ tag: 'button',
712
+ text: 'go',
713
+ flags: [{ kind: 'smallTarget', label: '16x16 (WCAG 2.5.8 needs 24x24)', severity: 'warn' }],
714
+ bounds: at(10, 10, 60, 16), // roomy enough to caption, short enough to flag
715
+ },
716
+ ],
717
+ })
718
+ expect(svg).toContain('data-flag="smallTarget"') // the producer's finding draws
719
+ expect(svg).not.toContain('data-flag="target-size"') // the built-in stands down
720
+ expect(legend.length).toBe(0) // nothing elided — the drawn bar says it all
721
+ })
722
+
723
+ test('a fully-wrapped caption is not falsely reported truncated', () => {
724
+ const { svg, legend } = schematic({
725
+ wiring: [
726
+ {
727
+ tag: 'p',
728
+ text: 'the quick brown fox jumps over the lazy dog',
729
+ bounds: at(10, 10, 200, 60), // wraps to two lines, all text drawn
730
+ },
731
+ ],
732
+ })
733
+ expect(svg).toContain('<tspan') // it did wrap
734
+ expect(legend.length).toBe(0) // nothing was elided — no entry, no footer
735
+ expect(svg).not.toContain('details in legend')
736
+ })
737
+ })