@bespokeagentics/microdots-host 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/evals/topology-regressions.p5.v1.json +105 -0
- package/package.json +6 -4
- package/src/composeFromSelection.test.ts +277 -0
- package/src/composeFromSelection.ts +376 -0
- package/src/compositionSpec.test.ts +311 -0
- package/src/compositionSpec.ts +802 -0
- package/src/compositionSpec.v2.test.ts +216 -0
- package/src/fillComposition.test.ts +240 -0
- package/src/fillComposition.ts +159 -0
- package/src/index.ts +140 -2
- package/src/mountedRegistry.test.ts +196 -0
- package/src/mounting.test.ts +8 -8
- package/src/mounting.ts +116 -0
- package/src/placementChecks.test.ts +12 -12
- package/src/rules.test.ts +4 -4
- package/src/rules.ts +1 -1
- package/src/slotDom.test.ts +151 -0
- package/src/slotDom.ts +111 -0
- package/src/slotResize.test.ts +392 -0
- package/src/slotResize.ts +367 -0
- package/src/slots.test.ts +53 -0
- package/src/slots.ts +30 -3
- package/src/topologyRegression.test.ts +37 -0
- package/src/topologyRegression.ts +143 -0
- package/src/topologySource.test.ts +137 -0
- package/src/topologySource.ts +97 -0
- package/src/tracePublisher.test.ts +95 -0
- package/src/tracePublisher.ts +117 -0
- package/src/wire.test.ts +13 -11
- package/src/wireEngine.test.ts +60 -36
- package/src/wireEngine.ts +13 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ensureSection,
|
|
5
|
+
ensureSlot,
|
|
6
|
+
pruneGeneratedContainers,
|
|
7
|
+
} from './slotDom.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Containers for placements the build never knew about.
|
|
11
|
+
*
|
|
12
|
+
* Two properties carry this module, and both fail silently when broken:
|
|
13
|
+
*
|
|
14
|
+
* 1. **A generated slot carries its id.** Foldkit dies without one and
|
|
15
|
+
* `Runtime.embed` swallows the defect, so the dot renders nothing on a clean
|
|
16
|
+
* console with a green suite.
|
|
17
|
+
* 2. **Existing markup is never regenerated over.** The demo host's sections
|
|
18
|
+
* carry a heading and a description that exist nowhere in the topology;
|
|
19
|
+
* replacing them would quietly turn a showcase into bare boxes, and no
|
|
20
|
+
* assertion about mounting would notice.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SECTION_CLASS = 'generated-section'
|
|
24
|
+
const SLOT_CLASS = 'generated-slot'
|
|
25
|
+
|
|
26
|
+
const generatedRoot = (): HTMLElement => {
|
|
27
|
+
const root = document.createElement('div')
|
|
28
|
+
root.id = 'generated-sections'
|
|
29
|
+
document.body.appendChild(root)
|
|
30
|
+
return root
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A hand-written section, copy and all — what must never be overwritten. */
|
|
34
|
+
const writtenSection = (sectionId: string, slotId: string): HTMLElement => {
|
|
35
|
+
const section = document.createElement('section')
|
|
36
|
+
section.id = sectionId
|
|
37
|
+
section.innerHTML = `<div><h2>Readout view</h2><p>Public readout surface.</p></div><div id="${slotId}"></div>`
|
|
38
|
+
document.body.appendChild(section)
|
|
39
|
+
return section
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
document.body.replaceChildren()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('ensureSection', () => {
|
|
47
|
+
test('creates a section carrying its declared id', () => {
|
|
48
|
+
const root = generatedRoot()
|
|
49
|
+
|
|
50
|
+
const section = ensureSection('section-new', root, SECTION_CLASS)
|
|
51
|
+
|
|
52
|
+
expect(section.id).toBe('section-new')
|
|
53
|
+
expect(section.tagName).toBe('SECTION')
|
|
54
|
+
expect(section.parentElement).toBe(root)
|
|
55
|
+
expect(section.dataset['generated']).toBe('true')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('the id is what lets `activate` hide it — without it the dot renders on every route', () => {
|
|
59
|
+
const root = generatedRoot()
|
|
60
|
+
ensureSection('section-new', root, SECTION_CLASS)
|
|
61
|
+
|
|
62
|
+
expect(document.getElementById('section-new')).not.toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('returns hand-written markup UNTOUCHED — the copy survives', () => {
|
|
66
|
+
const root = generatedRoot()
|
|
67
|
+
const written = writtenSection('section-readout-view', 'readout-view-slot')
|
|
68
|
+
|
|
69
|
+
const found = ensureSection('section-readout-view', root, SECTION_CLASS)
|
|
70
|
+
|
|
71
|
+
expect(found).toBe(written)
|
|
72
|
+
expect(found.querySelector('h2')?.textContent).toBe('Readout view')
|
|
73
|
+
expect(found.dataset['generated']).toBeUndefined()
|
|
74
|
+
expect(root.childElementCount).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('is idempotent — a second call adds nothing', () => {
|
|
78
|
+
const root = generatedRoot()
|
|
79
|
+
|
|
80
|
+
const first = ensureSection('section-new', root, SECTION_CLASS)
|
|
81
|
+
const second = ensureSection('section-new', root, SECTION_CLASS)
|
|
82
|
+
|
|
83
|
+
expect(second).toBe(first)
|
|
84
|
+
expect(root.childElementCount).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('ensureSlot', () => {
|
|
89
|
+
test('THE INVARIANT: a created slot carries its id', () => {
|
|
90
|
+
const root = generatedRoot()
|
|
91
|
+
const section = ensureSection('section-new', root, SECTION_CLASS)
|
|
92
|
+
|
|
93
|
+
const slot = ensureSlot('new-slot', section, SLOT_CLASS)
|
|
94
|
+
|
|
95
|
+
// Foldkit dies without this, and `Runtime.embed` forks the runtime so the
|
|
96
|
+
// defect never reaches the console.
|
|
97
|
+
expect(slot.id).toBe('new-slot')
|
|
98
|
+
expect(document.getElementById('new-slot')).toBe(slot)
|
|
99
|
+
expect(slot.parentElement).toBe(section)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('returns a hand-written slot untouched, and does not move it', () => {
|
|
103
|
+
const root = generatedRoot()
|
|
104
|
+
const written = writtenSection('section-readout-view', 'readout-view-slot')
|
|
105
|
+
const section = ensureSection('section-readout-view', root, SECTION_CLASS)
|
|
106
|
+
|
|
107
|
+
const slot = ensureSlot('readout-view-slot', section, SLOT_CLASS)
|
|
108
|
+
|
|
109
|
+
expect(slot).toBe(written.querySelector('#readout-view-slot'))
|
|
110
|
+
expect(slot.dataset['generated']).toBeUndefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('is idempotent — a second call does not add a second slot', () => {
|
|
114
|
+
const root = generatedRoot()
|
|
115
|
+
const section = ensureSection('section-new', root, SECTION_CLASS)
|
|
116
|
+
|
|
117
|
+
const first = ensureSlot('new-slot', section, SLOT_CLASS)
|
|
118
|
+
const second = ensureSlot('new-slot', section, SLOT_CLASS)
|
|
119
|
+
|
|
120
|
+
expect(second).toBe(first)
|
|
121
|
+
expect(section.childElementCount).toBe(1)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('pruneGeneratedContainers', () => {
|
|
126
|
+
test('removes a generated slot and its section once the dot is unmounted', () => {
|
|
127
|
+
const root = generatedRoot()
|
|
128
|
+
const section = ensureSection('section-new', root, SECTION_CLASS)
|
|
129
|
+
const slot = ensureSlot('new-slot', section, SLOT_CLASS)
|
|
130
|
+
slot.appendChild(document.createElement('new-dot'))
|
|
131
|
+
|
|
132
|
+
// Still mounted: nothing is pruned.
|
|
133
|
+
expect(pruneGeneratedContainers(root)).toBe(0)
|
|
134
|
+
|
|
135
|
+
// The unmount removed the MicroDot's element, leaving the scaffolding.
|
|
136
|
+
slot.replaceChildren()
|
|
137
|
+
|
|
138
|
+
expect(pruneGeneratedContainers(root)).toBe(2)
|
|
139
|
+
expect(document.getElementById('new-slot')).toBeNull()
|
|
140
|
+
expect(document.getElementById('section-new')).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('never touches hand-written markup, however empty', () => {
|
|
144
|
+
const root = generatedRoot()
|
|
145
|
+
const written = writtenSection('section-readout-view', 'readout-view-slot')
|
|
146
|
+
|
|
147
|
+
expect(pruneGeneratedContainers(root)).toBe(0)
|
|
148
|
+
expect(written.isConnected).toBe(true)
|
|
149
|
+
expect(document.getElementById('readout-view-slot')).not.toBeNull()
|
|
150
|
+
})
|
|
151
|
+
})
|
package/src/slotDom.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containers for placements the BUILD never knew about.
|
|
3
|
+
*
|
|
4
|
+
* Until Phase B of `wiki/plans/active/wiring-live-placement-on-a-remote-host.md`
|
|
5
|
+
* a shell could only mount a MicroDot into a `<div id="…-slot">` somebody had
|
|
6
|
+
* hand-written into `index.html`. That is fine while the topology is compiled
|
|
7
|
+
* in and cannot change; it is fatal the moment the topology is fetched, because
|
|
8
|
+
* a placement added at runtime has nowhere to go and `getElementById` returns
|
|
9
|
+
* `null`.
|
|
10
|
+
*
|
|
11
|
+
* **Markup wins; this is the fallback.** The demo host's twenty-five sections
|
|
12
|
+
* carry a heading and a description per dot that exist nowhere in the topology
|
|
13
|
+
* — the slot manifest declares `id`, `kind`, `row`, `width` and `capacity`, and
|
|
14
|
+
* no copy. Generating over the top of them would silently delete that copy and
|
|
15
|
+
* turn a showcase into a stack of bare boxes, so an element that already exists
|
|
16
|
+
* is always returned as-is. See
|
|
17
|
+
* `wiki/framework/composition/gap-a-generated-section-has-no-copy-to-render.md`.
|
|
18
|
+
*
|
|
19
|
+
* Neither function reads a topology or a route: they take ids and a container,
|
|
20
|
+
* so they are testable without a shell, which is the same reason
|
|
21
|
+
* `mounting.ts` exists.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `<section>` a route reveals, created in `generatedRoot` if the page has
|
|
26
|
+
* none.
|
|
27
|
+
*
|
|
28
|
+
* The id matters as much as it does for a slot, though for a different reason:
|
|
29
|
+
* `activate` hides and shows sections by the ids in `route.sectionIds`, so a
|
|
30
|
+
* generated section that does not carry its declared id is a section that never
|
|
31
|
+
* hides — its MicroDot would then render on every route.
|
|
32
|
+
*/
|
|
33
|
+
export const ensureSection = (
|
|
34
|
+
sectionId: string,
|
|
35
|
+
generatedRoot: HTMLElement,
|
|
36
|
+
className: string,
|
|
37
|
+
): HTMLElement => {
|
|
38
|
+
const existing = document.getElementById(sectionId)
|
|
39
|
+
if (existing !== null) return existing
|
|
40
|
+
|
|
41
|
+
const section = document.createElement('section')
|
|
42
|
+
section.id = sectionId
|
|
43
|
+
section.className = className
|
|
44
|
+
section.dataset['generated'] = 'true'
|
|
45
|
+
generatedRoot.appendChild(section)
|
|
46
|
+
return section
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `<div>` a MicroDot mounts into, created inside `section` if the page has
|
|
51
|
+
* none.
|
|
52
|
+
*
|
|
53
|
+
* **The `id` assignment on the created element is load-bearing and is the
|
|
54
|
+
* single most expensive failure in this repo.** Foldkit dies if its mount
|
|
55
|
+
* container has no `id`, and `Runtime.embed` forks the runtime, so the startup
|
|
56
|
+
* defect never reaches the console: the MicroDot renders nothing, the console
|
|
57
|
+
* is clean, and typecheck, lint, tests and build are all green. Hand-written
|
|
58
|
+
* slots got their ids from a human reading `host-topology.json`; a generated
|
|
59
|
+
* one gets it from here or from nowhere. See
|
|
60
|
+
* `wiki/patterns-and-traps/mount-container-needs-an-id.md`.
|
|
61
|
+
*/
|
|
62
|
+
export const ensureSlot = (
|
|
63
|
+
slotId: string,
|
|
64
|
+
section: HTMLElement,
|
|
65
|
+
className: string,
|
|
66
|
+
): HTMLElement => {
|
|
67
|
+
const existing = document.getElementById(slotId)
|
|
68
|
+
if (existing !== null) return existing
|
|
69
|
+
|
|
70
|
+
const slot = document.createElement('div')
|
|
71
|
+
// Never omit, never rename, never defer to a caller: see above.
|
|
72
|
+
slot.id = slotId
|
|
73
|
+
slot.className = className
|
|
74
|
+
slot.dataset['generated'] = 'true'
|
|
75
|
+
section.appendChild(slot)
|
|
76
|
+
return slot
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remove generated sections that are now empty.
|
|
81
|
+
*
|
|
82
|
+
* An unmount removes the MicroDot's element but leaves the container that was
|
|
83
|
+
* generated for it, and a page that accumulates empty boxes every time a dot is
|
|
84
|
+
* removed looks broken in a way no assertion catches. Hand-written sections are
|
|
85
|
+
* never touched — they are the page, not scaffolding — which is what
|
|
86
|
+
* `data-generated` distinguishes.
|
|
87
|
+
*/
|
|
88
|
+
export const pruneGeneratedContainers = (
|
|
89
|
+
generatedRoot: HTMLElement,
|
|
90
|
+
): number => {
|
|
91
|
+
const sections = Array.from(
|
|
92
|
+
generatedRoot.querySelectorAll('section[data-generated="true"]'),
|
|
93
|
+
)
|
|
94
|
+
let removed = 0
|
|
95
|
+
sections.forEach(section => {
|
|
96
|
+
const slots = Array.from(
|
|
97
|
+
section.querySelectorAll('div[data-generated="true"]'),
|
|
98
|
+
)
|
|
99
|
+
slots.forEach(slot => {
|
|
100
|
+
if (slot.childElementCount === 0) {
|
|
101
|
+
slot.remove()
|
|
102
|
+
removed += 1
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
if (section.childElementCount === 0) {
|
|
106
|
+
section.remove()
|
|
107
|
+
removed += 1
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return removed
|
|
111
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SLOT_RESIZE_HANDLE_CLASS,
|
|
5
|
+
type SlotResizeStorage,
|
|
6
|
+
applySlotSize,
|
|
7
|
+
attachSlotResize,
|
|
8
|
+
chromeOfSlot,
|
|
9
|
+
clearSlotResize,
|
|
10
|
+
restoreSlotResize,
|
|
11
|
+
slotResizeStorageKey,
|
|
12
|
+
slotSizeVar,
|
|
13
|
+
} from './slotResize.ts'
|
|
14
|
+
import type { SlotSpec } from './slots.ts'
|
|
15
|
+
|
|
16
|
+
const slot = (id: string, resize: SlotSpec['resize']): SlotSpec =>
|
|
17
|
+
resize === undefined
|
|
18
|
+
? { id, kind: 'rail', row: 1 }
|
|
19
|
+
: { id, kind: 'rail', row: 1, resize }
|
|
20
|
+
|
|
21
|
+
const memoryStorage = (): SlotResizeStorage => {
|
|
22
|
+
const values = new Map<string, string>()
|
|
23
|
+
return {
|
|
24
|
+
getItem: key => values.get(key) ?? null,
|
|
25
|
+
removeItem: key => {
|
|
26
|
+
values.delete(key)
|
|
27
|
+
},
|
|
28
|
+
setItem: (key, value) => {
|
|
29
|
+
values.set(key, value)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const wiringShell = (): {
|
|
35
|
+
readonly section: HTMLElement
|
|
36
|
+
readonly drawer: HTMLElement
|
|
37
|
+
readonly bottom: HTMLElement
|
|
38
|
+
} => {
|
|
39
|
+
const section = document.createElement('section')
|
|
40
|
+
section.id = 'section-wiring'
|
|
41
|
+
const canvas = document.createElement('div')
|
|
42
|
+
canvas.id = 'wiring-canvas-slot'
|
|
43
|
+
const drawer = document.createElement('div')
|
|
44
|
+
drawer.className = 'wiring-drawer'
|
|
45
|
+
Object.defineProperty(drawer, 'offsetWidth', {
|
|
46
|
+
value: 380,
|
|
47
|
+
configurable: true,
|
|
48
|
+
})
|
|
49
|
+
Object.defineProperty(drawer, 'offsetHeight', {
|
|
50
|
+
value: 600,
|
|
51
|
+
configurable: true,
|
|
52
|
+
})
|
|
53
|
+
const inspector = document.createElement('div')
|
|
54
|
+
inspector.id = 'wiring-inspector-slot'
|
|
55
|
+
const trace = document.createElement('div')
|
|
56
|
+
trace.id = 'wiring-trace-slot'
|
|
57
|
+
drawer.append(inspector, trace)
|
|
58
|
+
const bottom = document.createElement('div')
|
|
59
|
+
bottom.className = 'wiring-bottom'
|
|
60
|
+
Object.defineProperty(bottom, 'offsetWidth', {
|
|
61
|
+
value: 800,
|
|
62
|
+
configurable: true,
|
|
63
|
+
})
|
|
64
|
+
Object.defineProperty(bottom, 'offsetHeight', {
|
|
65
|
+
value: 200,
|
|
66
|
+
configurable: true,
|
|
67
|
+
})
|
|
68
|
+
const table = document.createElement('div')
|
|
69
|
+
table.id = 'wiring-table-slot'
|
|
70
|
+
bottom.append(table)
|
|
71
|
+
section.append(canvas, drawer, bottom)
|
|
72
|
+
Object.defineProperty(section, 'clientHeight', {
|
|
73
|
+
value: 800,
|
|
74
|
+
configurable: true,
|
|
75
|
+
})
|
|
76
|
+
document.body.append(section)
|
|
77
|
+
return { section, drawer, bottom }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
document.body.replaceChildren()
|
|
82
|
+
window.sessionStorage.clear()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('chromeOfSlot', () => {
|
|
86
|
+
test('a nested tab pane resizes its grid-area ancestor, not the hidden tab', () => {
|
|
87
|
+
const { section, drawer } = wiringShell()
|
|
88
|
+
const inspector = document.getElementById('wiring-inspector-slot')
|
|
89
|
+
expect(inspector).not.toBeNull()
|
|
90
|
+
if (inspector === null) return
|
|
91
|
+
expect(chromeOfSlot(inspector, section)).toBe(drawer)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('attachSlotResize', () => {
|
|
96
|
+
test('slots without resize get no handle', () => {
|
|
97
|
+
const { section } = wiringShell()
|
|
98
|
+
attachSlotResize({
|
|
99
|
+
section,
|
|
100
|
+
slots: [slot('wiring-canvas-slot', undefined)],
|
|
101
|
+
})
|
|
102
|
+
expect(section.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a missing slot id is skipped — generated later, not a throw', () => {
|
|
106
|
+
const { section } = wiringShell()
|
|
107
|
+
expect(() =>
|
|
108
|
+
attachSlotResize({
|
|
109
|
+
section,
|
|
110
|
+
slots: [slot('wiring-missing-slot', 'x')],
|
|
111
|
+
}),
|
|
112
|
+
).not.toThrow()
|
|
113
|
+
expect(section.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('inspector + trace share the drawer — one x handle, not two', () => {
|
|
117
|
+
const { section, drawer } = wiringShell()
|
|
118
|
+
attachSlotResize({
|
|
119
|
+
section,
|
|
120
|
+
slots: [
|
|
121
|
+
slot('wiring-inspector-slot', 'x'),
|
|
122
|
+
slot('wiring-trace-slot', 'x'),
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
expect(drawer.querySelectorAll(`.${SLOT_RESIZE_HANDLE_CLASS}`).length).toBe(
|
|
126
|
+
1,
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('x and y handles land on their chrome, titled from the prototype', () => {
|
|
131
|
+
const { section, drawer, bottom } = wiringShell()
|
|
132
|
+
attachSlotResize({
|
|
133
|
+
section,
|
|
134
|
+
slots: [
|
|
135
|
+
slot('wiring-inspector-slot', 'x'),
|
|
136
|
+
slot('wiring-table-slot', 'y'),
|
|
137
|
+
],
|
|
138
|
+
})
|
|
139
|
+
const x = drawer.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)
|
|
140
|
+
const y = bottom.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)
|
|
141
|
+
expect(x).toBeInstanceOf(HTMLButtonElement)
|
|
142
|
+
expect(y).toBeInstanceOf(HTMLButtonElement)
|
|
143
|
+
if (
|
|
144
|
+
!(x instanceof HTMLButtonElement) ||
|
|
145
|
+
!(y instanceof HTMLButtonElement)
|
|
146
|
+
) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
expect(x.dataset['axis']).toBe('x')
|
|
150
|
+
expect(y.dataset['axis']).toBe('y')
|
|
151
|
+
expect(x.title).toBe('Drag to resize')
|
|
152
|
+
expect(y.title).toBe('Drag to resize')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('dragging the drawer left grows it and marks data-resized', () => {
|
|
156
|
+
const { section, drawer } = wiringShell()
|
|
157
|
+
attachSlotResize({
|
|
158
|
+
section,
|
|
159
|
+
slots: [slot('wiring-inspector-slot', 'x')],
|
|
160
|
+
})
|
|
161
|
+
const handle = drawer.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)
|
|
162
|
+
expect(handle).toBeInstanceOf(HTMLButtonElement)
|
|
163
|
+
if (!(handle instanceof HTMLButtonElement)) return
|
|
164
|
+
|
|
165
|
+
handle.dispatchEvent(
|
|
166
|
+
new PointerEvent('pointerdown', {
|
|
167
|
+
bubbles: true,
|
|
168
|
+
clientX: 900,
|
|
169
|
+
clientY: 100,
|
|
170
|
+
button: 0,
|
|
171
|
+
pointerId: 1,
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
document.dispatchEvent(
|
|
175
|
+
new PointerEvent('pointermove', {
|
|
176
|
+
bubbles: true,
|
|
177
|
+
clientX: 820,
|
|
178
|
+
clientY: 100,
|
|
179
|
+
pointerId: 1,
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
document.dispatchEvent(
|
|
183
|
+
new PointerEvent('pointerup', {
|
|
184
|
+
bubbles: true,
|
|
185
|
+
clientX: 820,
|
|
186
|
+
clientY: 100,
|
|
187
|
+
pointerId: 1,
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
section.style.getPropertyValue(slotSizeVar('wiring-inspector-slot')),
|
|
193
|
+
).toBe('460px')
|
|
194
|
+
expect(section.dataset['resized']).toBe('x')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('ArrowLeft on the drawer handle grows it', () => {
|
|
198
|
+
const { section, drawer } = wiringShell()
|
|
199
|
+
attachSlotResize({
|
|
200
|
+
section,
|
|
201
|
+
slots: [slot('wiring-inspector-slot', 'x')],
|
|
202
|
+
})
|
|
203
|
+
const handle = drawer.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)
|
|
204
|
+
expect(handle).toBeInstanceOf(HTMLButtonElement)
|
|
205
|
+
if (!(handle instanceof HTMLButtonElement)) return
|
|
206
|
+
handle.dispatchEvent(
|
|
207
|
+
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }),
|
|
208
|
+
)
|
|
209
|
+
expect(
|
|
210
|
+
section.style.getPropertyValue(slotSizeVar('wiring-inspector-slot')),
|
|
211
|
+
).toBe('388px')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('detach removes the handles', () => {
|
|
215
|
+
const { section, drawer } = wiringShell()
|
|
216
|
+
const attached = attachSlotResize({
|
|
217
|
+
section,
|
|
218
|
+
slots: [slot('wiring-inspector-slot', 'x')],
|
|
219
|
+
})
|
|
220
|
+
attached.detach()
|
|
221
|
+
expect(drawer.querySelector(`.${SLOT_RESIZE_HANDLE_CLASS}`)).toBeNull()
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('clearSlotResize', () => {
|
|
226
|
+
test('drops the CSS vars and data-resized so a named preset can apply', () => {
|
|
227
|
+
const { section } = wiringShell()
|
|
228
|
+
applySlotSize(section, 'wiring-inspector-slot', 'x', 500)
|
|
229
|
+
applySlotSize(section, 'wiring-table-slot', 'y', 240)
|
|
230
|
+
expect(section.dataset['resized']).toBe('x y')
|
|
231
|
+
clearSlotResize(section, [
|
|
232
|
+
slot('wiring-inspector-slot', 'x'),
|
|
233
|
+
slot('wiring-table-slot', 'y'),
|
|
234
|
+
])
|
|
235
|
+
expect(
|
|
236
|
+
section.style.getPropertyValue(slotSizeVar('wiring-inspector-slot')),
|
|
237
|
+
).toBe('')
|
|
238
|
+
expect(
|
|
239
|
+
section.style.getPropertyValue(slotSizeVar('wiring-table-slot')),
|
|
240
|
+
).toBe('')
|
|
241
|
+
expect(section.dataset['resized']).toBeUndefined()
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('applySlotSize', () => {
|
|
246
|
+
test('clamps the drawer to the prototype 300–980 band', () => {
|
|
247
|
+
const { section } = wiringShell()
|
|
248
|
+
expect(applySlotSize(section, 'wiring-inspector-slot', 'x', 12)).toBe(300)
|
|
249
|
+
expect(applySlotSize(section, 'wiring-inspector-slot', 'x', 2000)).toBe(980)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('tab-session persistence', () => {
|
|
254
|
+
test('stores slot id, axis, and the clamped pixel size', () => {
|
|
255
|
+
const storage = memoryStorage()
|
|
256
|
+
const { section } = wiringShell()
|
|
257
|
+
applySlotSize(section, 'wiring-inspector-slot', 'x', 2_000, storage)
|
|
258
|
+
expect(storage.getItem(slotResizeStorageKey(section.id))).toBe(
|
|
259
|
+
'{"version":1,"sizes":[{"slotId":"wiring-inspector-slot","axis":"x","px":980}]}',
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a fresh section restores both axes before handles attach', () => {
|
|
264
|
+
const storage = memoryStorage()
|
|
265
|
+
const first = wiringShell()
|
|
266
|
+
applySlotSize(first.section, 'wiring-inspector-slot', 'x', 480, storage)
|
|
267
|
+
applySlotSize(first.section, 'wiring-table-slot', 'y', 252, storage)
|
|
268
|
+
|
|
269
|
+
document.body.replaceChildren()
|
|
270
|
+
const fresh = wiringShell()
|
|
271
|
+
attachSlotResize({
|
|
272
|
+
section: fresh.section,
|
|
273
|
+
slots: [
|
|
274
|
+
slot('wiring-inspector-slot', 'x'),
|
|
275
|
+
slot('wiring-table-slot', 'y'),
|
|
276
|
+
],
|
|
277
|
+
storage,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect(
|
|
281
|
+
fresh.section.style.getPropertyValue(
|
|
282
|
+
slotSizeVar('wiring-inspector-slot'),
|
|
283
|
+
),
|
|
284
|
+
).toBe('480px')
|
|
285
|
+
expect(
|
|
286
|
+
fresh.section.style.getPropertyValue(slotSizeVar('wiring-table-slot')),
|
|
287
|
+
).toBe('252px')
|
|
288
|
+
expect(fresh.section.dataset['resized']).toBe('x y')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('a hidden route restores its vertical size without clamping it to 46px', () => {
|
|
292
|
+
const storage = memoryStorage()
|
|
293
|
+
const first = wiringShell()
|
|
294
|
+
applySlotSize(first.section, 'wiring-table-slot', 'y', 252, storage)
|
|
295
|
+
|
|
296
|
+
document.body.replaceChildren()
|
|
297
|
+
const fresh = wiringShell()
|
|
298
|
+
fresh.section.hidden = true
|
|
299
|
+
Object.defineProperty(fresh.section, 'clientHeight', {
|
|
300
|
+
value: 0,
|
|
301
|
+
configurable: true,
|
|
302
|
+
})
|
|
303
|
+
attachSlotResize({
|
|
304
|
+
section: fresh.section,
|
|
305
|
+
slots: [slot('wiring-table-slot', 'y')],
|
|
306
|
+
storage,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
expect(
|
|
310
|
+
fresh.section.style.getPropertyValue(slotSizeVar('wiring-table-slot')),
|
|
311
|
+
).toBe('252px')
|
|
312
|
+
expect(storage.getItem(slotResizeStorageKey(fresh.section.id))).toBe(
|
|
313
|
+
'{"version":1,"sizes":[{"slotId":"wiring-table-slot","axis":"y","px":252}]}',
|
|
314
|
+
)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('a named preset clears storage as well as the live DOM override', () => {
|
|
318
|
+
const storage = memoryStorage()
|
|
319
|
+
const first = wiringShell()
|
|
320
|
+
const slots = [
|
|
321
|
+
slot('wiring-inspector-slot', 'x'),
|
|
322
|
+
slot('wiring-table-slot', 'y'),
|
|
323
|
+
]
|
|
324
|
+
applySlotSize(first.section, 'wiring-inspector-slot', 'x', 480, storage)
|
|
325
|
+
applySlotSize(first.section, 'wiring-table-slot', 'y', 252, storage)
|
|
326
|
+
clearSlotResize(first.section, slots, storage)
|
|
327
|
+
expect(storage.getItem(slotResizeStorageKey(first.section.id))).toBeNull()
|
|
328
|
+
|
|
329
|
+
document.body.replaceChildren()
|
|
330
|
+
const fresh = wiringShell()
|
|
331
|
+
attachSlotResize({ section: fresh.section, slots, storage })
|
|
332
|
+
expect(
|
|
333
|
+
fresh.section.style.getPropertyValue(
|
|
334
|
+
slotSizeVar('wiring-inspector-slot'),
|
|
335
|
+
),
|
|
336
|
+
).toBe('')
|
|
337
|
+
expect(fresh.section.dataset['resized']).toBeUndefined()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('missing and malformed storage restore no override', () => {
|
|
341
|
+
const malformed = memoryStorage()
|
|
342
|
+
const first = wiringShell()
|
|
343
|
+
malformed.setItem(slotResizeStorageKey(first.section.id), '{not json')
|
|
344
|
+
expect(() =>
|
|
345
|
+
restoreSlotResize(
|
|
346
|
+
first.section,
|
|
347
|
+
[slot('wiring-inspector-slot', 'x')],
|
|
348
|
+
malformed,
|
|
349
|
+
),
|
|
350
|
+
).not.toThrow()
|
|
351
|
+
expect(
|
|
352
|
+
first.section.style.getPropertyValue(
|
|
353
|
+
slotSizeVar('wiring-inspector-slot'),
|
|
354
|
+
),
|
|
355
|
+
).toBe('')
|
|
356
|
+
|
|
357
|
+
expect(() =>
|
|
358
|
+
restoreSlotResize(
|
|
359
|
+
first.section,
|
|
360
|
+
[slot('wiring-inspector-slot', 'x')],
|
|
361
|
+
null,
|
|
362
|
+
),
|
|
363
|
+
).not.toThrow()
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('throwing storage never blocks live resize or preset clear', () => {
|
|
367
|
+
const throwing: SlotResizeStorage = {
|
|
368
|
+
getItem: () => {
|
|
369
|
+
throw new Error('denied')
|
|
370
|
+
},
|
|
371
|
+
removeItem: () => {
|
|
372
|
+
throw new Error('denied')
|
|
373
|
+
},
|
|
374
|
+
setItem: () => {
|
|
375
|
+
throw new Error('denied')
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
const { section } = wiringShell()
|
|
379
|
+
const slots = [slot('wiring-inspector-slot', 'x')]
|
|
380
|
+
|
|
381
|
+
expect(() =>
|
|
382
|
+
applySlotSize(section, 'wiring-inspector-slot', 'x', 500, throwing),
|
|
383
|
+
).not.toThrow()
|
|
384
|
+
expect(
|
|
385
|
+
section.style.getPropertyValue(slotSizeVar('wiring-inspector-slot')),
|
|
386
|
+
).toBe('500px')
|
|
387
|
+
expect(() => clearSlotResize(section, slots, throwing)).not.toThrow()
|
|
388
|
+
expect(
|
|
389
|
+
section.style.getPropertyValue(slotSizeVar('wiring-inspector-slot')),
|
|
390
|
+
).toBe('')
|
|
391
|
+
})
|
|
392
|
+
})
|