oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/dom-stub.mjs — the smallest DOM the tests need, and only
2// the tests: nothing in the bundle imports it (build-bundles.mjs walks
3// from editor-entry.js, which does not).
4//
5// It exists because two of the rules this project cares about are DOM
6// rules and neither can be checked without one: that the Blocks list
7// keeps the SAME button elements across a fill (so focus survives a
8// colleague's keystroke), and that a mount's listeners are actually
9// gone after destroy() (so a resync does not leave a second copy of
10// every handler on the page's own elements). Both are about node
11// identity and listener count, which is exactly what a stub can answer
12// and what a headless browser answers slowly.
13//
14// It is not a DOM. Selectors are matched by attribute presence only —
15// `[data-select-block]`, `[slides-blocks-buttons]` — because that is
16// all the code under test asks for.
17
18class StubElement {
19 constructor (tag = 'div') {
20 this.tagName = tag.toUpperCase()
21 this.attrs = new Map()
22 this.children = []
23 this.parentNode = null
24 this.dataset = {}
25 this.listeners = []
26 this.text = ''
27 this.hidden = false
28 this.type = ''
29 }
30
31 get textContent () {
32 if (this.children.length) return this.children.map(c => c.textContent).join('')
33 return this.text
34 }
35
36 set textContent (v) {
37 // The real thing replaces every child with one text node — which
38 // is why the code under test avoids writing it when it need not.
39 this.children = []
40 this.text = String(v)
41 }
42
43 setAttribute (name, value) { this.attrs.set(name, String(value)) }
44 getAttribute (name) { return this.attrs.has(name) ? this.attrs.get(name) : null }
45 hasAttribute (name) { return this.attrs.has(name) }
46 removeAttribute (name) { this.attrs.delete(name) }
47
48 get firstElementChild () { return this.children[0] || null }
49 get nextElementSibling () {
50 if (!this.parentNode) return null
51 const at = this.parentNode.children.indexOf(this)
52 return this.parentNode.children[at + 1] || null
53 }
54
55 append (...nodes) {
56 for (const n of nodes) {
57 n.remove()
58 n.parentNode = this
59 this.children.push(n)
60 }
61 }
62
63 insertBefore (node, ref) {
64 node.remove()
65 node.parentNode = this
66 const at = ref ? this.children.indexOf(ref) : -1
67 if (at < 0) this.children.push(node)
68 else this.children.splice(at, 0, node)
69 return node
70 }
71
72 remove () {
73 if (!this.parentNode) return
74 // A browser blurs the focused element when it (or an ancestor)
75 // leaves the tree — which is what moving a node does, and what
76 // strip.mjs's sync has to put right afterwards.
77 const doc = globalThis.document
78 if (doc && doc.activeElement && this.contains(doc.activeElement)) doc.activeElement = null
79 const at = this.parentNode.children.indexOf(this)
80 if (at >= 0) this.parentNode.children.splice(at, 1)
81 this.parentNode = null
82 }
83
84 focus () {
85 if (globalThis.document) globalThis.document.activeElement = this
86 }
87
88 matches (selector) {
89 const name = selector.replace(/^\[|\]$/g, '')
90 if (name.startsWith('data-')) {
91 const key = name.slice(5).replace(/-([a-z])/g, (m, c) => c.toUpperCase())
92 return this.dataset[key] !== undefined
93 }
94 return this.attrs.has(name)
95 }
96
97 descendants () {
98 return this.children.flatMap(c => [c, ...c.descendants()])
99 }
100
101 querySelectorAll (selector) {
102 return this.descendants().filter(el => el.matches(selector))
103 }
104
105 querySelector (selector) {
106 return this.querySelectorAll(selector)[0] || null
107 }
108
109 contains (el) {
110 return el === this || this.descendants().includes(el)
111 }
112
113 closest (selector) {
114 for (let el = this; el; el = el.parentNode) if (el.matches(selector)) return el
115 return null
116 }
117
118 // The whole point of the listener half: {signal} has to work, or a
119 // destroy() built on AbortController would test as passing while
120 // leaving every handler in place.
121 addEventListener (type, fn, opts = {}) {
122 const entry = { type, fn }
123 this.listeners.push(entry)
124 if (opts && opts.signal) {
125 opts.signal.addEventListener('abort', () => {
126 const at = this.listeners.indexOf(entry)
127 if (at >= 0) this.listeners.splice(at, 1)
128 })
129 }
130 }
131
132 removeEventListener (type, fn) {
133 const at = this.listeners.findIndex(l => l.type === type && l.fn === fn)
134 if (at >= 0) this.listeners.splice(at, 1)
135 }
136
137 countListeners (type) {
138 return this.listeners.filter(l => l.type === type).length
139 }
140}
141
142/** A stub element, optionally with attributes and children. */
143export function el (tag = 'div', attrs = {}, children = []) {
144 const node = new StubElement(tag)
145 for (const [k, v] of Object.entries(attrs)) {
146 if (k.startsWith('data-')) node.dataset[k.slice(5).replace(/-([a-z])/g, (m, c) => c.toUpperCase())] = String(v)
147 node.setAttribute(k, v)
148 }
149 node.append(...children)
150 return node
151}
152
153/**
154 * Installs a stub `document` on globalThis for the duration of a test
155 * and returns it plus the undo. Only what the modules under test reach
156 * for: createElement, and activeElement (nothing focused).
157 */
158export function withDocument () {
159 const before = globalThis.document
160 const doc = el('html')
161 doc.createElement = tag => new StubElement(tag)
162 doc.activeElement = null
163 globalThis.document = doc
164 return { document: doc, restore () { globalThis.document = before } }
165}
166