midiwire 0.1.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 +21 -0
- package/README.md +845 -0
- package/dist/midiwire.es.js +1987 -0
- package/dist/midiwire.umd.js +1 -0
- package/package.json +58 -0
- package/src/bindings/DataAttributeBinder.js +198 -0
- package/src/bindings/DataAttributeBinder.test.js +825 -0
- package/src/core/EventEmitter.js +93 -0
- package/src/core/EventEmitter.test.js +357 -0
- package/src/core/MIDIConnection.js +364 -0
- package/src/core/MIDIConnection.test.js +783 -0
- package/src/core/MIDIController.js +756 -0
- package/src/core/MIDIController.test.js +1958 -0
- package/src/core/MIDIDeviceManager.js +204 -0
- package/src/core/MIDIDeviceManager.test.js +638 -0
- package/src/core/errors.js +99 -0
- package/src/index.js +181 -0
- package/src/utils/dx7.js +1294 -0
- package/src/utils/dx7.test.js +1208 -0
- package/src/utils/midi.js +244 -0
- package/src/utils/midi.test.js +260 -0
- package/src/utils/sysex.js +98 -0
- package/src/utils/sysex.test.js +222 -0
- package/src/utils/validators.js +88 -0
- package/src/utils/validators.test.js +300 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { DataAttributeBinder } from "./DataAttributeBinder.js"
|
|
3
|
+
|
|
4
|
+
// Mock MIDIController for testing
|
|
5
|
+
class MockMIDIController {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.boundElements = new Map()
|
|
8
|
+
this.bindings = new Map()
|
|
9
|
+
this.options = { channel: 1 }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
bind(element, config) {
|
|
13
|
+
this.boundElements.set(element, config)
|
|
14
|
+
const unbind = () => this._unbind(element)
|
|
15
|
+
this.bindings.set(element, unbind)
|
|
16
|
+
// Try to set initial value if element has value
|
|
17
|
+
if (element.value !== undefined && element.value !== "") {
|
|
18
|
+
// This would normally trigger the handler
|
|
19
|
+
}
|
|
20
|
+
return unbind
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
unbind(element) {
|
|
24
|
+
const binding = this.bindings.get(element)
|
|
25
|
+
if (binding) {
|
|
26
|
+
binding()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_unbind(element) {
|
|
31
|
+
this.boundElements.delete(element)
|
|
32
|
+
this.bindings.delete(element)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("DataAttributeBinder", () => {
|
|
37
|
+
let mockController
|
|
38
|
+
let binder
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockController = new MockMIDIController()
|
|
42
|
+
document.body.innerHTML = ""
|
|
43
|
+
vi.clearAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
if (binder) {
|
|
48
|
+
binder.destroy()
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("constructor", () => {
|
|
53
|
+
it("should create with default selector", () => {
|
|
54
|
+
binder = new DataAttributeBinder(mockController)
|
|
55
|
+
expect(binder.controller).toBe(mockController)
|
|
56
|
+
expect(binder.selector).toBe("[data-midi-cc]")
|
|
57
|
+
expect(binder.observer).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("should create with custom selector", () => {
|
|
61
|
+
binder = new DataAttributeBinder(mockController, ".my-controls")
|
|
62
|
+
expect(binder.selector).toBe(".my-controls")
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("_parseAttributes", () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
binder = new DataAttributeBinder(mockController)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("should parse valid midi-cc attribute", () => {
|
|
72
|
+
const element = document.createElement("input")
|
|
73
|
+
element.setAttribute("data-midi-cc", "74")
|
|
74
|
+
|
|
75
|
+
const config = binder._parseAttributes(element)
|
|
76
|
+
expect(config).toEqual({
|
|
77
|
+
cc: 74,
|
|
78
|
+
channel: undefined,
|
|
79
|
+
min: 0,
|
|
80
|
+
max: 127,
|
|
81
|
+
invert: false,
|
|
82
|
+
label: undefined,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("should parse all attributes", () => {
|
|
87
|
+
const element = document.createElement("input")
|
|
88
|
+
element.setAttribute("data-midi-cc", "74")
|
|
89
|
+
element.setAttribute("data-midi-channel", "2")
|
|
90
|
+
element.setAttribute("min", "0")
|
|
91
|
+
element.setAttribute("max", "100")
|
|
92
|
+
element.setAttribute("data-midi-invert", "true")
|
|
93
|
+
element.setAttribute("data-midi-label", "Cutoff")
|
|
94
|
+
|
|
95
|
+
const config = binder._parseAttributes(element)
|
|
96
|
+
expect(config).toEqual({
|
|
97
|
+
cc: 74,
|
|
98
|
+
channel: 2,
|
|
99
|
+
min: 0,
|
|
100
|
+
max: 100,
|
|
101
|
+
invert: true,
|
|
102
|
+
label: "Cutoff",
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("should return null for invalid cc", () => {
|
|
107
|
+
const element = document.createElement("input")
|
|
108
|
+
element.setAttribute("data-midi-cc", "invalid")
|
|
109
|
+
|
|
110
|
+
const config = binder._parseAttributes(element)
|
|
111
|
+
expect(config).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("should return null for cc out of range", () => {
|
|
115
|
+
const element = document.createElement("input")
|
|
116
|
+
element.setAttribute("data-midi-cc", "-1")
|
|
117
|
+
|
|
118
|
+
const config = binder._parseAttributes(element)
|
|
119
|
+
expect(config).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should return null for cc > 127", () => {
|
|
123
|
+
const element = document.createElement("input")
|
|
124
|
+
element.setAttribute("data-midi-cc", "200")
|
|
125
|
+
|
|
126
|
+
const config = binder._parseAttributes(element)
|
|
127
|
+
expect(config).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("should parse min/max from element attributes", () => {
|
|
131
|
+
const element = document.createElement("input")
|
|
132
|
+
element.setAttribute("data-midi-cc", "74")
|
|
133
|
+
element.setAttribute("min", "10")
|
|
134
|
+
element.setAttribute("max", "1000")
|
|
135
|
+
|
|
136
|
+
const config = binder._parseAttributes(element)
|
|
137
|
+
expect(config.min).toBe(10)
|
|
138
|
+
expect(config.max).toBe(1000)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("should use defaults when min/max not specified", () => {
|
|
142
|
+
const element = document.createElement("input")
|
|
143
|
+
element.setAttribute("data-midi-cc", "74")
|
|
144
|
+
|
|
145
|
+
const config = binder._parseAttributes(element)
|
|
146
|
+
expect(config.min).toBe(0)
|
|
147
|
+
expect(config.max).toBe(127)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("should parse channel correctly", () => {
|
|
151
|
+
const element = document.createElement("input")
|
|
152
|
+
element.setAttribute("data-midi-cc", "74")
|
|
153
|
+
element.setAttribute("data-midi-channel", "5")
|
|
154
|
+
|
|
155
|
+
const config = binder._parseAttributes(element)
|
|
156
|
+
expect(config.channel).toBe(5)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("should parse invert correctly", () => {
|
|
160
|
+
const element = document.createElement("input")
|
|
161
|
+
element.setAttribute("data-midi-cc", "74")
|
|
162
|
+
|
|
163
|
+
element.setAttribute("data-midi-invert", "true")
|
|
164
|
+
expect(binder._parseAttributes(element).invert).toBe(true)
|
|
165
|
+
|
|
166
|
+
element.setAttribute("data-midi-invert", "false")
|
|
167
|
+
expect(binder._parseAttributes(element).invert).toBe(false)
|
|
168
|
+
|
|
169
|
+
// Should be false when not set
|
|
170
|
+
element.removeAttribute("data-midi-invert")
|
|
171
|
+
expect(binder._parseAttributes(element).invert).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("should parse label", () => {
|
|
175
|
+
const element = document.createElement("input")
|
|
176
|
+
element.setAttribute("data-midi-cc", "74")
|
|
177
|
+
element.setAttribute("data-midi-label", "Filter Cutoff")
|
|
178
|
+
|
|
179
|
+
const config = binder._parseAttributes(element)
|
|
180
|
+
expect(config.label).toBe("Filter Cutoff")
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("bindAll", () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
binder = new DataAttributeBinder(mockController)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("should bind all matching elements", () => {
|
|
190
|
+
document.body.innerHTML = `
|
|
191
|
+
<input type="range" data-midi-cc="74">
|
|
192
|
+
<input type="range" data-midi-cc="71">
|
|
193
|
+
<input type="range" data-midi-cc="7">
|
|
194
|
+
`
|
|
195
|
+
|
|
196
|
+
binder.bindAll()
|
|
197
|
+
|
|
198
|
+
expect(mockController.boundElements.size).toBe(3)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("should not bind elements without data-midi-cc", () => {
|
|
202
|
+
document.body.innerHTML = `
|
|
203
|
+
<input type="range">
|
|
204
|
+
<input type="range" data-midi-cc="74">
|
|
205
|
+
`
|
|
206
|
+
|
|
207
|
+
binder.bindAll()
|
|
208
|
+
|
|
209
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("should not bind elements that are already bound", () => {
|
|
213
|
+
document.body.innerHTML = `
|
|
214
|
+
<input type="range" data-midi-cc="74" data-midi-bound="true">
|
|
215
|
+
<input type="range" data-midi-cc="71">
|
|
216
|
+
`
|
|
217
|
+
|
|
218
|
+
binder.bindAll()
|
|
219
|
+
|
|
220
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
221
|
+
const unboundElements = document.querySelectorAll('input:not([data-midi-bound="true"])')
|
|
222
|
+
if (unboundElements.length > 0) {
|
|
223
|
+
const config = mockController.boundElements.get(unboundElements[0])
|
|
224
|
+
expect(config).toBeDefined()
|
|
225
|
+
expect(config.cc).toBe(71)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("should set data-midi-bound attribute", () => {
|
|
230
|
+
document.body.innerHTML = `<input type="range" data-midi-cc="74">`
|
|
231
|
+
const element = document.querySelector('[data-midi-cc="74"]')
|
|
232
|
+
|
|
233
|
+
expect(element.hasAttribute("data-midi-bound")).toBe(false)
|
|
234
|
+
|
|
235
|
+
binder.bindAll()
|
|
236
|
+
|
|
237
|
+
expect(element.hasAttribute("data-midi-bound")).toBe(true)
|
|
238
|
+
expect(element.getAttribute("data-midi-bound")).toBe("true")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("should not bind elements with invalid cc", () => {
|
|
242
|
+
document.body.innerHTML = `
|
|
243
|
+
<input type="range" data-midi-cc="-1">
|
|
244
|
+
<input type="range" data-midi-cc="74">
|
|
245
|
+
<input type="range" data-midi-cc="200">
|
|
246
|
+
`
|
|
247
|
+
|
|
248
|
+
binder.bindAll()
|
|
249
|
+
|
|
250
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
251
|
+
expect(
|
|
252
|
+
mockController.boundElements.get(document.querySelector('[data-midi-cc="74"]')).cc,
|
|
253
|
+
).toBe(74)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("should handle empty document", () => {
|
|
257
|
+
document.body.innerHTML = ""
|
|
258
|
+
expect(() => binder.bindAll()).not.toThrow()
|
|
259
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("should work with custom selector", () => {
|
|
263
|
+
binder = new DataAttributeBinder(mockController, ".midi-control")
|
|
264
|
+
|
|
265
|
+
document.body.innerHTML = `
|
|
266
|
+
<input type="range" class="midi-control" data-midi-cc="74">
|
|
267
|
+
<input type="range" data-midi-cc="71">
|
|
268
|
+
<input type="range" class="midi-control" data-midi-cc="7">
|
|
269
|
+
`
|
|
270
|
+
|
|
271
|
+
binder.bindAll()
|
|
272
|
+
|
|
273
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe("enableAutoBinding", () => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
binder = new DataAttributeBinder(mockController)
|
|
280
|
+
binder.bindAll = vi.fn() // Mock for testing
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("should create a MutationObserver", () => {
|
|
284
|
+
binder.enableAutoBinding()
|
|
285
|
+
expect(binder.observer).toBeInstanceOf(MutationObserver)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("should observe document body", () => {
|
|
289
|
+
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe")
|
|
290
|
+
binder.enableAutoBinding()
|
|
291
|
+
|
|
292
|
+
expect(observeSpy).toHaveBeenCalledWith(document.body, {
|
|
293
|
+
childList: true,
|
|
294
|
+
subtree: true,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
observeSpy.mockRestore()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("should use custom selector in auto-binding", async () => {
|
|
301
|
+
binder = new DataAttributeBinder(mockController, ".custom-midi")
|
|
302
|
+
binder.enableAutoBinding()
|
|
303
|
+
|
|
304
|
+
// Add element with default selector (should not bind)
|
|
305
|
+
const defaultElement = document.createElement("input")
|
|
306
|
+
defaultElement.setAttribute("data-midi-cc", "74")
|
|
307
|
+
document.body.appendChild(defaultElement)
|
|
308
|
+
|
|
309
|
+
// Add element with custom selector (should bind)
|
|
310
|
+
const customElement = document.createElement("input")
|
|
311
|
+
customElement.setAttribute("data-midi-cc", "75")
|
|
312
|
+
customElement.className = "custom-midi"
|
|
313
|
+
document.body.appendChild(customElement)
|
|
314
|
+
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
316
|
+
|
|
317
|
+
// Only the custom element should be bound
|
|
318
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
319
|
+
expect(mockController.boundElements.has(customElement)).toBe(true)
|
|
320
|
+
expect(mockController.boundElements.has(defaultElement)).toBe(false)
|
|
321
|
+
|
|
322
|
+
// Cleanup
|
|
323
|
+
document.body.removeChild(defaultElement)
|
|
324
|
+
document.body.removeChild(customElement)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("should not create multiple observers", () => {
|
|
328
|
+
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe")
|
|
329
|
+
binder.enableAutoBinding()
|
|
330
|
+
binder.enableAutoBinding()
|
|
331
|
+
|
|
332
|
+
expect(observeSpy).toHaveBeenCalledTimes(1)
|
|
333
|
+
|
|
334
|
+
observeSpy.mockRestore()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it("should handle already bound elements gracefully", () => {
|
|
338
|
+
binder = new DataAttributeBinder(mockController)
|
|
339
|
+
binder.enableAutoBinding()
|
|
340
|
+
|
|
341
|
+
const element = document.createElement("input")
|
|
342
|
+
element.setAttribute("data-midi-cc", "74")
|
|
343
|
+
|
|
344
|
+
// Should not throw
|
|
345
|
+
document.body.appendChild(element)
|
|
346
|
+
return new Promise((resolve) => setTimeout(resolve, 100))
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("should bind child elements when parent is added", async () => {
|
|
350
|
+
binder = new DataAttributeBinder(mockController)
|
|
351
|
+
binder.enableAutoBinding()
|
|
352
|
+
|
|
353
|
+
// Create a container with child elements
|
|
354
|
+
const container = document.createElement("div")
|
|
355
|
+
container.innerHTML = `
|
|
356
|
+
<input type="range" data-midi-cc="74">
|
|
357
|
+
<input type="range" data-midi-cc="75">
|
|
358
|
+
<input type="range" data-midi-cc="76">
|
|
359
|
+
`
|
|
360
|
+
|
|
361
|
+
// Initial state - no bindings
|
|
362
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
363
|
+
|
|
364
|
+
// Add the container to DOM (this triggers MutationObserver)
|
|
365
|
+
document.body.appendChild(container)
|
|
366
|
+
|
|
367
|
+
// Wait for MutationObserver to process
|
|
368
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
369
|
+
|
|
370
|
+
// Verify all child elements were bound
|
|
371
|
+
expect(mockController.boundElements.size).toBe(3)
|
|
372
|
+
|
|
373
|
+
// Verify each child has data-midi-bound attribute
|
|
374
|
+
const inputs = container.querySelectorAll("input")
|
|
375
|
+
inputs.forEach((input) => {
|
|
376
|
+
expect(input.hasAttribute("data-midi-bound")).toBe(true)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Cleanup
|
|
380
|
+
document.body.removeChild(container)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it("should not re-bind child elements that are already bound", async () => {
|
|
384
|
+
binder = new DataAttributeBinder(mockController)
|
|
385
|
+
binder.enableAutoBinding()
|
|
386
|
+
|
|
387
|
+
// Create a container with one pre-bound child
|
|
388
|
+
const container = document.createElement("div")
|
|
389
|
+
container.innerHTML = `
|
|
390
|
+
<input type="range" data-midi-cc="74">
|
|
391
|
+
<input type="range" data-midi-cc="75" data-midi-bound="true">
|
|
392
|
+
`
|
|
393
|
+
|
|
394
|
+
document.body.appendChild(container)
|
|
395
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
396
|
+
|
|
397
|
+
// Only the unbound child should be bound
|
|
398
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
399
|
+
|
|
400
|
+
const boundElement = document.querySelector('[data-midi-cc="74"]')
|
|
401
|
+
expect(boundElement.hasAttribute("data-midi-bound")).toBe(true)
|
|
402
|
+
|
|
403
|
+
// The pre-bound element should still have the attribute
|
|
404
|
+
const preBoundElement = document.querySelector('[data-midi-cc="75"]')
|
|
405
|
+
expect(preBoundElement.getAttribute("data-midi-bound")).toBe("true")
|
|
406
|
+
|
|
407
|
+
// Cleanup
|
|
408
|
+
document.body.removeChild(container)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("should handle invalid child elements gracefully", async () => {
|
|
412
|
+
binder = new DataAttributeBinder(mockController)
|
|
413
|
+
binder.enableAutoBinding()
|
|
414
|
+
|
|
415
|
+
// Create a container with mixed valid/invalid children
|
|
416
|
+
const container = document.createElement("div")
|
|
417
|
+
container.innerHTML = `
|
|
418
|
+
<input type="range" data-midi-cc="74">
|
|
419
|
+
<input type="range" data-midi-cc="invalid">
|
|
420
|
+
<input type="range" data-midi-cc="200">
|
|
421
|
+
<input type="range" data-midi-cc="75">
|
|
422
|
+
`
|
|
423
|
+
|
|
424
|
+
document.body.appendChild(container)
|
|
425
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
426
|
+
|
|
427
|
+
// Only valid children should be bound
|
|
428
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
429
|
+
|
|
430
|
+
// Cleanup
|
|
431
|
+
document.body.removeChild(container)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it("should handle element that matches but has invalid config", async () => {
|
|
435
|
+
binder = new DataAttributeBinder(mockController)
|
|
436
|
+
binder.enableAutoBinding()
|
|
437
|
+
|
|
438
|
+
// Add element with invalid CC (matches selector but config is null)
|
|
439
|
+
const element = document.createElement("input")
|
|
440
|
+
element.setAttribute("data-midi-cc", "invalid")
|
|
441
|
+
document.body.appendChild(element)
|
|
442
|
+
|
|
443
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
444
|
+
|
|
445
|
+
// Should not bind due to invalid config
|
|
446
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
447
|
+
expect(element.hasAttribute("data-midi-bound")).toBe(false)
|
|
448
|
+
|
|
449
|
+
// Cleanup
|
|
450
|
+
document.body.removeChild(element)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
describe("disableAutoBinding", () => {
|
|
455
|
+
beforeEach(() => {
|
|
456
|
+
binder = new DataAttributeBinder(mockController)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it("should disconnect the observer", () => {
|
|
460
|
+
binder.enableAutoBinding()
|
|
461
|
+
const disconnectSpy = vi.spyOn(binder.observer, "disconnect")
|
|
462
|
+
|
|
463
|
+
binder.disableAutoBinding()
|
|
464
|
+
|
|
465
|
+
expect(disconnectSpy).toHaveBeenCalled()
|
|
466
|
+
expect(binder.observer).toBeNull()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it("should not throw when no observer exists", () => {
|
|
470
|
+
expect(() => binder.disableAutoBinding()).not.toThrow()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it("should prevent further bindings", () => {
|
|
474
|
+
binder.enableAutoBinding()
|
|
475
|
+
binder.disableAutoBinding()
|
|
476
|
+
|
|
477
|
+
const element = document.createElement("input")
|
|
478
|
+
element.setAttribute("data-midi-cc", "74")
|
|
479
|
+
document.body.appendChild(element)
|
|
480
|
+
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
setTimeout(() => {
|
|
483
|
+
// Should not have bound since observer was disabled
|
|
484
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
485
|
+
document.body.removeChild(element)
|
|
486
|
+
resolve()
|
|
487
|
+
}, 100)
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
describe("destroy", () => {
|
|
493
|
+
beforeEach(() => {
|
|
494
|
+
binder = new DataAttributeBinder(mockController)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it("should disable auto binding", () => {
|
|
498
|
+
binder.enableAutoBinding()
|
|
499
|
+
const disconnectSpy = vi.spyOn(binder.observer, "disconnect")
|
|
500
|
+
|
|
501
|
+
binder.destroy()
|
|
502
|
+
|
|
503
|
+
expect(disconnectSpy).toHaveBeenCalled()
|
|
504
|
+
expect(binder.observer).toBeNull()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it("should not throw when destroyed without auto binding", () => {
|
|
508
|
+
expect(() => binder.destroy()).not.toThrow()
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe("edge cases", () => {
|
|
513
|
+
beforeEach(() => {
|
|
514
|
+
binder = new DataAttributeBinder(mockController)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it("should handle elements without a form or default values", () => {
|
|
518
|
+
document.body.innerHTML = `
|
|
519
|
+
<div data-midi-cc="74"></div>
|
|
520
|
+
`
|
|
521
|
+
|
|
522
|
+
binder.bindAll()
|
|
523
|
+
|
|
524
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it("should handle mixed valid and invalid elements", () => {
|
|
528
|
+
document.body.innerHTML = `
|
|
529
|
+
<input type="range" data-midi-cc="74">
|
|
530
|
+
<input type="range" data-midi-cc="invalid">
|
|
531
|
+
<input type="range" data-midi-cc="200">
|
|
532
|
+
<input type="range" data-midi-cc="71">
|
|
533
|
+
`
|
|
534
|
+
|
|
535
|
+
binder.bindAll()
|
|
536
|
+
|
|
537
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it("should handle non-input elements", () => {
|
|
541
|
+
document.body.innerHTML = `
|
|
542
|
+
<div data-midi-cc="74"></div>
|
|
543
|
+
<span data-midi-cc="71"></span>
|
|
544
|
+
`
|
|
545
|
+
|
|
546
|
+
binder.bindAll()
|
|
547
|
+
|
|
548
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it("should preserve existing attributes", () => {
|
|
552
|
+
document.body.innerHTML = `
|
|
553
|
+
<input type="range" data-midi-cc="74" class="slider" id="cutoff">
|
|
554
|
+
`
|
|
555
|
+
|
|
556
|
+
const element = document.querySelector('[data-midi-cc="74"]')
|
|
557
|
+
const originalClass = element.className
|
|
558
|
+
const originalId = element.id
|
|
559
|
+
|
|
560
|
+
binder.bindAll()
|
|
561
|
+
|
|
562
|
+
expect(element.className).toBe(originalClass)
|
|
563
|
+
expect(element.id).toBe(originalId)
|
|
564
|
+
expect(element.getAttribute("data-midi-bound")).toBe("true")
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
describe("14-bit CC binding", () => {
|
|
568
|
+
it("should bind 14-bit CC with MSB and LSB", () => {
|
|
569
|
+
document.body.innerHTML = `
|
|
570
|
+
<input type="range" data-midi-msb="74" data-midi-lsb="75" min="0" max="127" value="64">
|
|
571
|
+
`
|
|
572
|
+
|
|
573
|
+
binder.bindAll()
|
|
574
|
+
|
|
575
|
+
const element = document.querySelector("[data-midi-msb][data-midi-lsb]")
|
|
576
|
+
const config = mockController.boundElements.get(element)
|
|
577
|
+
|
|
578
|
+
expect(config).toBeDefined()
|
|
579
|
+
expect(config.is14Bit).toBe(true)
|
|
580
|
+
expect(config.msb).toBe(74)
|
|
581
|
+
expect(config.lsb).toBe(75)
|
|
582
|
+
expect(config.min).toBe(0)
|
|
583
|
+
expect(config.max).toBe(127)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it("should bind 14-bit CC with channel, invert and label", () => {
|
|
587
|
+
document.body.innerHTML = `
|
|
588
|
+
<input type="range" data-midi-msb="74" data-midi-lsb="75" data-midi-channel="5"
|
|
589
|
+
data-midi-invert="true" data-midi-label="Filter">
|
|
590
|
+
`
|
|
591
|
+
|
|
592
|
+
binder.bindAll()
|
|
593
|
+
|
|
594
|
+
const element = document.querySelector("[data-midi-msb][data-midi-lsb]")
|
|
595
|
+
const config = mockController.boundElements.get(element)
|
|
596
|
+
|
|
597
|
+
expect(config.channel).toBe(5)
|
|
598
|
+
expect(config.invert).toBe(true)
|
|
599
|
+
expect(config.label).toBe("Filter")
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it("should not bind invalid 14-bit CC (out of range)", () => {
|
|
603
|
+
document.body.innerHTML = `
|
|
604
|
+
<input type="range" data-midi-msb="200" data-midi-lsb="75">
|
|
605
|
+
<input type="range" data-midi-msb="74" data-midi-lsb="200">
|
|
606
|
+
`
|
|
607
|
+
|
|
608
|
+
binder.bindAll()
|
|
609
|
+
|
|
610
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it("should not bind when only MSB is provided", () => {
|
|
614
|
+
document.body.innerHTML = `
|
|
615
|
+
<input type="range" data-midi-msb="74" min="0" max="127" value="64">
|
|
616
|
+
`
|
|
617
|
+
|
|
618
|
+
binder.bindAll()
|
|
619
|
+
|
|
620
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("should not bind when only LSB is provided", () => {
|
|
624
|
+
document.body.innerHTML = `
|
|
625
|
+
<input type="range" data-midi-lsb="75" min="0" max="127" value="64">
|
|
626
|
+
`
|
|
627
|
+
|
|
628
|
+
binder.bindAll()
|
|
629
|
+
|
|
630
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it("should handle missing min/max for 14-bit CC", () => {
|
|
634
|
+
document.body.innerHTML = `
|
|
635
|
+
<input type="range" data-midi-msb="74" data-midi-lsb="75">
|
|
636
|
+
`
|
|
637
|
+
|
|
638
|
+
binder.bindAll()
|
|
639
|
+
|
|
640
|
+
const element = document.querySelector("[data-midi-msb][data-midi-lsb]")
|
|
641
|
+
const config = mockController.boundElements.get(element)
|
|
642
|
+
|
|
643
|
+
expect(config.min).toBe(0)
|
|
644
|
+
expect(config.max).toBe(127)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it("should warn when both 7-bit and 14-bit CC attributes are present", () => {
|
|
648
|
+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
649
|
+
|
|
650
|
+
document.body.innerHTML = `
|
|
651
|
+
<input type="range" data-midi-cc="74" data-midi-msb="74" data-midi-lsb="75" min="0" max="127" value="64">
|
|
652
|
+
`
|
|
653
|
+
|
|
654
|
+
binder.bindAll()
|
|
655
|
+
|
|
656
|
+
const element = document.querySelector("input")
|
|
657
|
+
const config = mockController.boundElements.get(element)
|
|
658
|
+
|
|
659
|
+
// Should use 14-bit configuration (msb/lsb takes precedence)
|
|
660
|
+
expect(config).toBeDefined()
|
|
661
|
+
expect(config.is14Bit).toBe(true)
|
|
662
|
+
expect(config.msb).toBe(74)
|
|
663
|
+
expect(config.lsb).toBe(75)
|
|
664
|
+
|
|
665
|
+
// Should warn about the conflict
|
|
666
|
+
expect(consoleWarnSpy).toHaveBeenCalledOnce()
|
|
667
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
668
|
+
expect.stringMatching(/both 7-bit.*14-bit/),
|
|
669
|
+
element,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
consoleWarnSpy.mockRestore()
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
describe("memory leak prevention", () => {
|
|
677
|
+
it("should unbind elements when they are removed from DOM", async () => {
|
|
678
|
+
binder = new DataAttributeBinder(mockController)
|
|
679
|
+
binder.enableAutoBinding()
|
|
680
|
+
|
|
681
|
+
const container = document.createElement("div")
|
|
682
|
+
container.innerHTML = `
|
|
683
|
+
<input type="range" data-midi-cc="74" min="0" max="127" value="64">
|
|
684
|
+
`
|
|
685
|
+
document.body.appendChild(container)
|
|
686
|
+
|
|
687
|
+
// Wait for mutation observer to process
|
|
688
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
689
|
+
|
|
690
|
+
const element = container.querySelector("input")
|
|
691
|
+
expect(mockController.boundElements.has(element)).toBe(true)
|
|
692
|
+
|
|
693
|
+
// Remove the container
|
|
694
|
+
container.remove()
|
|
695
|
+
|
|
696
|
+
// Wait for mutation observer to process removal
|
|
697
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
698
|
+
|
|
699
|
+
// Element should be unbound
|
|
700
|
+
expect(mockController.boundElements.has(element)).toBe(false)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it("should recursively unbind child elements when parent is removed", async () => {
|
|
704
|
+
binder = new DataAttributeBinder(mockController)
|
|
705
|
+
binder.enableAutoBinding()
|
|
706
|
+
|
|
707
|
+
const container = document.createElement("div")
|
|
708
|
+
container.innerHTML = `
|
|
709
|
+
<div>
|
|
710
|
+
<input type="range" data-midi-cc="74" min="0" max="127" value="0">
|
|
711
|
+
<input type="range" data-midi-msb="75" data-midi-lsb="76" min="0" max="16383" value="0">
|
|
712
|
+
</div>
|
|
713
|
+
`
|
|
714
|
+
document.body.appendChild(container)
|
|
715
|
+
|
|
716
|
+
// Wait for mutation observer to process
|
|
717
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
718
|
+
|
|
719
|
+
const elements = container.querySelectorAll("input")
|
|
720
|
+
elements.forEach((el) => {
|
|
721
|
+
expect(mockController.boundElements.has(el)).toBe(true)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// Remove the container
|
|
725
|
+
container.remove()
|
|
726
|
+
|
|
727
|
+
// Wait for mutation observer to process removal
|
|
728
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
729
|
+
|
|
730
|
+
// All elements should be unbound
|
|
731
|
+
elements.forEach((el) => {
|
|
732
|
+
expect(mockController.boundElements.has(el)).toBe(false)
|
|
733
|
+
})
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
describe("child element binding", () => {
|
|
738
|
+
it("should not bind child elements that are already bound", () => {
|
|
739
|
+
document.body.innerHTML = `
|
|
740
|
+
<div class="container">
|
|
741
|
+
<input type="range" data-midi-cc="74" value="64">
|
|
742
|
+
<input type="range" data-midi-cc="75" value="32">
|
|
743
|
+
</div>
|
|
744
|
+
`
|
|
745
|
+
|
|
746
|
+
binder.bindAll()
|
|
747
|
+
|
|
748
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
749
|
+
|
|
750
|
+
// Try to bind again
|
|
751
|
+
binder.bindAll()
|
|
752
|
+
|
|
753
|
+
// Should still be only 2, not 4
|
|
754
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it("should bind new child elements added after first bindAll", () => {
|
|
758
|
+
document.body.innerHTML = `
|
|
759
|
+
<div class="container">
|
|
760
|
+
<input type="range" data-midi-cc="74" value="64">
|
|
761
|
+
</div>
|
|
762
|
+
`
|
|
763
|
+
|
|
764
|
+
binder.bindAll()
|
|
765
|
+
expect(mockController.boundElements.size).toBe(1)
|
|
766
|
+
|
|
767
|
+
// Add a new element
|
|
768
|
+
const container = document.querySelector(".container")
|
|
769
|
+
const newInput = document.createElement("input")
|
|
770
|
+
newInput.setAttribute("type", "range")
|
|
771
|
+
newInput.setAttribute("data-midi-cc", "75")
|
|
772
|
+
newInput.value = "32"
|
|
773
|
+
container.appendChild(newInput)
|
|
774
|
+
|
|
775
|
+
// Bind again
|
|
776
|
+
binder.bindAll()
|
|
777
|
+
|
|
778
|
+
// Should bind the new element but not re-bind the old one
|
|
779
|
+
expect(mockController.boundElements.size).toBe(2)
|
|
780
|
+
})
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
describe("edge cases", () => {
|
|
784
|
+
it("should handle element removal and re-addition", () => {
|
|
785
|
+
document.body.innerHTML = `
|
|
786
|
+
<input type="range" data-midi-cc="74" value="64">
|
|
787
|
+
`
|
|
788
|
+
|
|
789
|
+
binder.bindAll()
|
|
790
|
+
const element = document.querySelector('[data-midi-cc="74"]')
|
|
791
|
+
expect(mockController.boundElements.has(element)).toBe(true)
|
|
792
|
+
|
|
793
|
+
// Remove element
|
|
794
|
+
element.remove()
|
|
795
|
+
expect(mockController.boundElements.has(element)).toBe(true) // Still bound
|
|
796
|
+
|
|
797
|
+
// Re-add element
|
|
798
|
+
document.body.appendChild(element)
|
|
799
|
+
binder.bindAll()
|
|
800
|
+
|
|
801
|
+
// Should not re-bind due to data-midi-bound attribute
|
|
802
|
+
expect(mockController.boundElements.has(element)).toBe(true)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it("should handle binding when document has no elements", () => {
|
|
806
|
+
document.body.innerHTML = ""
|
|
807
|
+
|
|
808
|
+
expect(() => binder.bindAll()).not.toThrow()
|
|
809
|
+
expect(mockController.boundElements.size).toBe(0)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it("should handle null selector gracefully", () => {
|
|
813
|
+
binder = new DataAttributeBinder(mockController)
|
|
814
|
+
binder.selector = null
|
|
815
|
+
|
|
816
|
+
document.body.innerHTML = `
|
|
817
|
+
<input type="range" data-midi-cc="74" value="64">
|
|
818
|
+
`
|
|
819
|
+
|
|
820
|
+
// This will likely throw or do nothing
|
|
821
|
+
expect(() => binder.bindAll()).not.toThrow()
|
|
822
|
+
})
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
})
|