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.
- package/LICENSE +202 -0
- package/README.md +202 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +298 -0
- package/package.json +46 -0
- package/src/index.ts +788 -0
- package/src/schematic.test.ts +737 -0
|
@@ -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 & "b"')
|
|
170
|
+
const tall = schematicSVG(description, { minLabelHeight: 4 })
|
|
171
|
+
expect(tall).toContain('a & "b" <c>')
|
|
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
|
+
})
|