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.
@@ -0,0 +1,1958 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { CONTROLLER_EVENTS, MIDIController } from "./MIDIController.js"
3
+
4
+ // Mock the dependencies
5
+ const mockOutputs = [
6
+ {
7
+ id: "output-1",
8
+ name: "Test Output 1",
9
+ manufacturer: "Test Manufacturer",
10
+ state: "connected",
11
+ send: vi.fn(),
12
+ },
13
+ {
14
+ id: "output-2",
15
+ name: "Test Output 2",
16
+ manufacturer: "Test Manufacturer",
17
+ state: "connected",
18
+ send: vi.fn(),
19
+ },
20
+ ]
21
+
22
+ const mockInput = {
23
+ id: "input-1",
24
+ name: "Test Input",
25
+ manufacturer: "Test Manufacturer",
26
+ state: "connected",
27
+ _onmidimessage: null,
28
+ set onmidimessage(handler) {
29
+ this._onmidimessage = handler
30
+ },
31
+ get onmidimessage() {
32
+ return this._onmidimessage
33
+ },
34
+ }
35
+
36
+ const createMockMIDIAccess = () => ({
37
+ outputs: new Map([
38
+ ["output-1", mockOutputs[0]],
39
+ ["output-2", mockOutputs[1]],
40
+ ]),
41
+ inputs: new Map([["input-1", mockInput]]),
42
+ })
43
+
44
+ describe("MIDIController", () => {
45
+ let midiController
46
+ let mockMIDIAccess
47
+ let originalNavigator
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks()
51
+ mockMIDIAccess = createMockMIDIAccess()
52
+ originalNavigator = global.navigator
53
+
54
+ global.navigator = {
55
+ requestMIDIAccess: vi.fn().mockResolvedValue(mockMIDIAccess),
56
+ }
57
+
58
+ midiController = new MIDIController({
59
+ sysex: true,
60
+ channel: 2,
61
+ })
62
+ })
63
+
64
+ afterEach(() => {
65
+ global.navigator = originalNavigator
66
+ })
67
+
68
+ describe("constructor", () => {
69
+ it("should create controller with default options", () => {
70
+ const controller = new MIDIController({ channel: 1 })
71
+ expect(controller.options.channel).toBe(1)
72
+ expect(controller.options.autoConnect).toBe(true)
73
+ expect(controller.options.sysex).toBe(false)
74
+ })
75
+
76
+ it("should track CC state", () => {
77
+ expect(midiController.state).toBeInstanceOf(Map)
78
+ expect(midiController.bindings).toBeInstanceOf(Map)
79
+ })
80
+ })
81
+
82
+ describe("initialize", () => {
83
+ it("should initialize MIDI access and connect", async () => {
84
+ await midiController.initialize()
85
+ expect(midiController.initialized).toBe(true)
86
+ expect(midiController.connection).toBeTruthy()
87
+ })
88
+
89
+ it("should warn if already initialized", async () => {
90
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
91
+ await midiController.initialize()
92
+
93
+ consoleSpy.mockClear()
94
+ await midiController.initialize()
95
+
96
+ expect(consoleSpy).toHaveBeenCalledWith("MIDI Controller already initialized")
97
+ consoleSpy.mockRestore()
98
+ })
99
+
100
+ it("should connect to input if specified", async () => {
101
+ midiController = new MIDIController({
102
+ input: 0,
103
+ autoConnect: false,
104
+ })
105
+
106
+ await midiController.initialize()
107
+ expect(midiController.getCurrentInput()).toBeTruthy()
108
+ })
109
+
110
+ it("should emit error on initialization failure", async () => {
111
+ global.navigator.requestMIDIAccess = vi.fn().mockRejectedValue(new Error("Access denied"))
112
+
113
+ const errorHandler = vi.fn()
114
+ midiController = new MIDIController({
115
+ onError: errorHandler,
116
+ })
117
+
118
+ await expect(midiController.initialize()).rejects.toThrow("Access denied")
119
+ expect(errorHandler).toHaveBeenCalledWith(expect.any(Error))
120
+ })
121
+ })
122
+
123
+ describe("connectInput", () => {
124
+ beforeEach(async () => {
125
+ await midiController.initialize()
126
+ })
127
+
128
+ it("should connect to input and emit event", async () => {
129
+ const spy = vi.fn()
130
+ midiController.on("input-connected", spy)
131
+
132
+ await midiController.connectInput("Test Input")
133
+
134
+ expect(spy).toHaveBeenCalledWith({
135
+ id: "input-1",
136
+ name: "Test Input",
137
+ manufacturer: "Test Manufacturer",
138
+ })
139
+ })
140
+
141
+ it("should handle incoming MIDI messages via callback", async () => {
142
+ await midiController.connectInput("Test Input")
143
+
144
+ // Simulate a CC message through the input's onmidimessage handler
145
+ const ccEvent = {
146
+ data: new Uint8Array([0xb0, 74, 100]), // CC 74 on channel 1, value 100
147
+ }
148
+
149
+ // Call the onmidimessage handler directly - this should call _handleMIDIMessage
150
+ mockInput.onmidimessage(ccEvent)
151
+
152
+ // Verify the CC value was stored (which happens in _handleMIDIMessage)
153
+ expect(midiController.getCC(74, 1)).toBe(100)
154
+ })
155
+ })
156
+
157
+ describe("sendCC", () => {
158
+ beforeEach(async () => {
159
+ await midiController.initialize()
160
+ await midiController.connection.connect()
161
+ })
162
+
163
+ it("should warn if not initialized", () => {
164
+ const controller = new MIDIController()
165
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
166
+
167
+ controller.sendCC(7, 100)
168
+
169
+ expect(consoleSpy).toHaveBeenCalledWith("MIDI not initialized. Call initialize() first.")
170
+ consoleSpy.mockRestore()
171
+ })
172
+
173
+ it("should send CC with correct status", async () => {
174
+ midiController.sendCC(74, 100, 5)
175
+
176
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb4, 74, 100]))
177
+ })
178
+
179
+ it("should clamp cc value to valid range", async () => {
180
+ midiController.sendCC(200, 100)
181
+ midiController.sendCC(-50, 100)
182
+
183
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 127, 100]))
184
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 0, 100]))
185
+ })
186
+
187
+ it("should clamp value to valid range", async () => {
188
+ midiController.sendCC(7, 200)
189
+ midiController.sendCC(7, -50)
190
+
191
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 7, 127]))
192
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 7, 0]))
193
+ })
194
+
195
+ it("should clamp channel to valid range", async () => {
196
+ midiController.sendCC(7, 100, 22)
197
+ midiController.sendCC(7, 100, 0)
198
+
199
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb0, 7, 100]))
200
+ })
201
+
202
+ it("should store CC state", async () => {
203
+ midiController.sendCC(74, 100, 3)
204
+
205
+ expect(midiController.getCC(74, 3)).toBe(100)
206
+ })
207
+
208
+ it("should emit cc-send event", async () => {
209
+ const spy = vi.fn()
210
+ midiController.on(CONTROLLER_EVENTS.CC_SEND, spy)
211
+
212
+ midiController.sendCC(74, 100, 2)
213
+
214
+ expect(spy).toHaveBeenCalledWith({
215
+ cc: 74,
216
+ value: 100,
217
+ channel: 2,
218
+ })
219
+ })
220
+ })
221
+
222
+ describe("sendSysEx", () => {
223
+ beforeEach(async () => {
224
+ await midiController.initialize()
225
+ await midiController.connection.connect()
226
+ })
227
+
228
+ it("should warn if not initialized", () => {
229
+ const controller = new MIDIController({ sysex: true })
230
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
231
+
232
+ controller.sendSysEx([0x42, 0x30])
233
+
234
+ expect(consoleSpy).toHaveBeenCalledWith("MIDI not initialized. Call initialize() first.")
235
+ consoleSpy.mockRestore()
236
+ })
237
+
238
+ it("should warn if sysex not enabled", async () => {
239
+ const controller = new MIDIController()
240
+ await controller.initialize()
241
+ await controller.connection.connect()
242
+
243
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
244
+
245
+ controller.sendSysEx([0x42, 0x30])
246
+
247
+ expect(consoleSpy).toHaveBeenCalledWith("SysEx not enabled. Initialize with sysex: true")
248
+ consoleSpy.mockRestore()
249
+ })
250
+
251
+ it("should send SysEx message", async () => {
252
+ midiController.sendSysEx([0xf0, 0x42, 0x30, 0x00, 0x01, 0x2f, 0x12, 0xf7])
253
+
254
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(
255
+ new Uint8Array([0xf0, 0x42, 0x30, 0x00, 0x01, 0x2f, 0x12, 0xf7]),
256
+ )
257
+ })
258
+
259
+ it("should send SysEx message with wrapper bytes", async () => {
260
+ midiController.sendSysEx([0x42, 0x30, 0x00, 0x01, 0x2f, 0x12], true)
261
+
262
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(
263
+ new Uint8Array([0xf0, 0x42, 0x30, 0x00, 0x01, 0x2f, 0x12, 0xf7]),
264
+ )
265
+ })
266
+
267
+ it("should emit sysex-send event", async () => {
268
+ const spy = vi.fn()
269
+ midiController.on(CONTROLLER_EVENTS.SYSEX_SEND, spy)
270
+
271
+ midiController.sendSysEx([0x42, 0x30])
272
+
273
+ expect(spy).toHaveBeenCalledWith({
274
+ data: [0x42, 0x30],
275
+ includeWrapper: false,
276
+ })
277
+ })
278
+ })
279
+
280
+ describe("sendNoteOn", () => {
281
+ beforeEach(async () => {
282
+ await midiController.initialize()
283
+ await midiController.connection.connect()
284
+ })
285
+
286
+ it("should not send if not initialized", async () => {
287
+ const controller = new MIDIController()
288
+ // Note: controller is NOT initialized
289
+
290
+ controller.sendNoteOn(60, 100)
291
+ // Should not throw, just return early
292
+ expect(controller.initialized).toBe(false)
293
+ })
294
+
295
+ it("should send note on with correct status", async () => {
296
+ midiController.sendNoteOn(60, 100, 5)
297
+
298
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x94, 60, 100]))
299
+ })
300
+
301
+ it("should clamp note to valid range", async () => {
302
+ midiController.sendNoteOn(200, 100)
303
+ midiController.sendNoteOn(-50, 100)
304
+
305
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x91, 127, 100]))
306
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x91, 0, 100]))
307
+ })
308
+
309
+ it("should use default velocity", async () => {
310
+ midiController.sendNoteOn(60)
311
+
312
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x91, 60, 64]))
313
+ })
314
+
315
+ it("should emit note-on-send event", async () => {
316
+ const spy = vi.fn()
317
+ midiController.on(CONTROLLER_EVENTS.NOTE_ON_SEND, spy)
318
+
319
+ midiController.sendNoteOn(60, 100, 2)
320
+
321
+ expect(spy).toHaveBeenCalledWith({
322
+ note: 60,
323
+ velocity: 100,
324
+ channel: 2,
325
+ })
326
+ })
327
+ })
328
+
329
+ describe("sendNoteOff", () => {
330
+ beforeEach(async () => {
331
+ await midiController.initialize()
332
+ await midiController.connection.connect()
333
+ })
334
+
335
+ it("should not send if not initialized", async () => {
336
+ const controller = new MIDIController()
337
+ // Note: controller is NOT initialized
338
+
339
+ controller.sendNoteOff(60)
340
+ // Should not throw, just return early
341
+ expect(controller.initialized).toBe(false)
342
+ })
343
+
344
+ it("should send note off with correct status", async () => {
345
+ midiController.sendNoteOff(60, 2, 50)
346
+
347
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x91, 60, 50]))
348
+ })
349
+
350
+ it("should use default velocity", async () => {
351
+ midiController.sendNoteOff(60, 5)
352
+
353
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x94, 60, 0]))
354
+ })
355
+
356
+ it("should emit note-off-send event", async () => {
357
+ const spy = vi.fn()
358
+ midiController.on(CONTROLLER_EVENTS.NOTE_OFF_SEND, spy)
359
+
360
+ midiController.sendNoteOff(60, 3, 40)
361
+
362
+ expect(spy).toHaveBeenCalledWith({
363
+ note: 60,
364
+ channel: 3,
365
+ velocity: 40,
366
+ })
367
+ })
368
+ })
369
+
370
+ describe("bind and unbind", () => {
371
+ let mockElement
372
+
373
+ beforeEach(async () => {
374
+ await midiController.initialize()
375
+ await midiController.connection.connect()
376
+
377
+ mockElement = {
378
+ value: "64",
379
+ addEventListener: vi.fn(),
380
+ removeEventListener: vi.fn(),
381
+ getAttribute: vi.fn((attr) => {
382
+ if (attr === "min") return "0"
383
+ if (attr === "max") return "127"
384
+ return null
385
+ }),
386
+ }
387
+ })
388
+
389
+ it("should bind element and send initial value", () => {
390
+ const unbind = midiController.bind(mockElement, {
391
+ cc: 74,
392
+ channel: 2,
393
+ })
394
+
395
+ expect(mockElement.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
396
+ expect(mockElement.addEventListener).toHaveBeenCalledWith("change", expect.any(Function))
397
+ expect(typeof unbind).toBe("function")
398
+
399
+ // Initial value should be sent
400
+ expect(mockOutputs[0].send).toHaveBeenCalled()
401
+ })
402
+
403
+ it("should warn if element is null", () => {
404
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
405
+
406
+ const unbind = midiController.bind(null, { cc: 74 })
407
+
408
+ expect(consoleSpy).toHaveBeenCalledWith("Cannot bind: element is null or undefined")
409
+ expect(typeof unbind).toBe("function")
410
+ consoleSpy.mockRestore()
411
+ })
412
+
413
+ it("should unbind element", () => {
414
+ const unbind = midiController.bind(mockElement, { cc: 74 })
415
+
416
+ unbind()
417
+
418
+ expect(mockElement.removeEventListener).toHaveBeenCalledWith("input", expect.any(Function))
419
+ expect(mockElement.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function))
420
+ })
421
+
422
+ it("should handle unbind of non-bound element", () => {
423
+ expect(() => midiController.unbind(mockElement)).not.toThrow()
424
+ })
425
+
426
+ it("should not send initial value when binding if not initialized", async () => {
427
+ // Create controller but don't initialize
428
+ const controller = new MIDIController()
429
+ expect(controller.initialized).toBe(false)
430
+
431
+ const element = {
432
+ value: "64",
433
+ addEventListener: vi.fn(),
434
+ getAttribute: vi.fn(() => "0"),
435
+ }
436
+
437
+ controller.bind(element, {
438
+ cc: 74,
439
+ channel: 2,
440
+ })
441
+
442
+ // Initial value should NOT be sent since controller is not initialized
443
+ expect(mockOutputs[0].send).not.toHaveBeenCalled()
444
+ })
445
+
446
+ describe("14-bit CC binding", () => {
447
+ it("should bind 14-bit CC with handler that normalizes and sends MSB/LSB", async () => {
448
+ await midiController.initialize()
449
+ await midiController.connection.connect()
450
+
451
+ const element = {
452
+ value: "64",
453
+ addEventListener: vi.fn(),
454
+ removeEventListener: vi.fn(),
455
+ getAttribute: vi.fn((attr) => {
456
+ if (attr === "min") return "0"
457
+ if (attr === "max") return "127"
458
+ return null
459
+ }),
460
+ }
461
+
462
+ const config = {
463
+ msb: 74,
464
+ lsb: 75,
465
+ is14Bit: true,
466
+ channel: 3,
467
+ min: 0,
468
+ max: 127,
469
+ invert: false,
470
+ }
471
+
472
+ midiController._createBinding(element, config)
473
+
474
+ expect(element.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
475
+ expect(element.addEventListener).toHaveBeenCalledWith("change", expect.any(Function))
476
+
477
+ // Trigger the handler to verify it works
478
+ element.value = "100"
479
+ const handler = element.addEventListener.mock.calls[0][1]
480
+ handler({ target: element })
481
+
482
+ // Should send two CC messages (MSB and LSB)
483
+ expect(mockOutputs[0].send).toHaveBeenCalled()
484
+ })
485
+
486
+ it("should handle NaN value in 14-bit binding", async () => {
487
+ await midiController.initialize()
488
+
489
+ const element = {
490
+ value: "invalid",
491
+ addEventListener: vi.fn(),
492
+ }
493
+
494
+ const config = {
495
+ msb: 74,
496
+ lsb: 75,
497
+ is14Bit: true,
498
+ channel: 3,
499
+ min: 0,
500
+ max: 127,
501
+ invert: false,
502
+ }
503
+
504
+ midiController._createBinding(element, config)
505
+ const handler = element.addEventListener.mock.calls[0][1]
506
+
507
+ // Should handle NaN gracefully (not throw)
508
+ expect(() => handler({ target: element })).not.toThrow()
509
+ })
510
+
511
+ it("should handle NaN value in 7-bit binding", async () => {
512
+ await midiController.initialize()
513
+
514
+ const element = {
515
+ value: "invalid",
516
+ addEventListener: vi.fn(),
517
+ getAttribute: vi.fn(() => "0"),
518
+ }
519
+
520
+ midiController._createBinding(element, {
521
+ cc: 74,
522
+ channel: 3,
523
+ })
524
+ const handler = element.addEventListener.mock.calls[0][1]
525
+
526
+ // Should handle NaN gracefully (not throw, just return early)
527
+ expect(() => handler({ target: element })).not.toThrow()
528
+ })
529
+
530
+ it("should send CC on default channel when not specified for 7-bit", async () => {
531
+ await midiController.initialize()
532
+ await midiController.connection.connect()
533
+
534
+ const element = {
535
+ value: "100",
536
+ addEventListener: vi.fn(),
537
+ getAttribute: vi.fn(() => "0"),
538
+ }
539
+
540
+ midiController._createBinding(element, {
541
+ cc: 74,
542
+ // Note: channel is NOT specified
543
+ })
544
+ const handler = element.addEventListener.mock.calls[0][1]
545
+
546
+ element.value = "100"
547
+ handler({ target: element })
548
+
549
+ // Should use default channel (2)
550
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 74, 100]))
551
+ })
552
+
553
+ it("should handle inverted values for 14-bit CC", async () => {
554
+ await midiController.initialize()
555
+ await midiController.connection.connect()
556
+
557
+ const element = {
558
+ value: "100",
559
+ addEventListener: vi.fn(),
560
+ getAttribute: vi.fn((attr) => {
561
+ if (attr === "min") return "0"
562
+ if (attr === "max") return "127"
563
+ return null
564
+ }),
565
+ }
566
+
567
+ const config = {
568
+ msb: 74,
569
+ lsb: 75,
570
+ is14Bit: true,
571
+ channel: 3,
572
+ min: 0,
573
+ max: 127,
574
+ invert: true,
575
+ }
576
+
577
+ midiController._createBinding(element, config)
578
+ const handler = element.addEventListener.mock.calls[0][1]
579
+ handler({ target: element })
580
+
581
+ // Should send inverted values
582
+ expect(mockOutputs[0].send).toHaveBeenCalled()
583
+ })
584
+
585
+ it("should destroy 14-bit binding and remove event listeners", async () => {
586
+ await midiController.initialize()
587
+
588
+ const element = {
589
+ value: "64",
590
+ addEventListener: vi.fn(),
591
+ removeEventListener: vi.fn(),
592
+ getAttribute: vi.fn(() => "0"),
593
+ }
594
+
595
+ const config = {
596
+ msb: 74,
597
+ lsb: 75,
598
+ is14Bit: true,
599
+ channel: 3,
600
+ invert: false,
601
+ }
602
+
603
+ const binding = midiController._createBinding(element, config)
604
+ binding.destroy()
605
+
606
+ expect(element.removeEventListener).toHaveBeenCalledWith("input", expect.any(Function))
607
+ expect(element.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function))
608
+ })
609
+
610
+ it("should use custom min/max values from config", async () => {
611
+ await midiController.initialize()
612
+
613
+ const element = {
614
+ value: "50",
615
+ addEventListener: vi.fn(),
616
+ getAttribute: vi.fn(() => "0"),
617
+ }
618
+
619
+ const config = {
620
+ msb: 74,
621
+ lsb: 75,
622
+ is14Bit: true,
623
+ min: 10,
624
+ max: 100,
625
+ invert: false,
626
+ }
627
+
628
+ midiController._createBinding(element, config)
629
+
630
+ expect(element.getAttribute).not.toHaveBeenCalled() // Should use config values, not element attributes
631
+ })
632
+
633
+ it("should use default channel when not specified", async () => {
634
+ await midiController.initialize()
635
+ await midiController.connection.connect()
636
+
637
+ const element = {
638
+ value: "100",
639
+ addEventListener: vi.fn(),
640
+ getAttribute: vi.fn(() => "0"),
641
+ }
642
+
643
+ const config = {
644
+ msb: 74,
645
+ lsb: 75,
646
+ is14Bit: true,
647
+ min: 0,
648
+ max: 127,
649
+ invert: false,
650
+ // Note: channel is NOT specified, should use this.options.channel (which is 2)
651
+ }
652
+
653
+ midiController._createBinding(element, config)
654
+ const handler = element.addEventListener.mock.calls[0][1]
655
+
656
+ // Trigger the handler
657
+ element.value = "100"
658
+ handler({ target: element })
659
+
660
+ // Should send CC messages on default channel (2) since no channel was specified
661
+ expect(mockOutputs[0].send).toHaveBeenCalled()
662
+ })
663
+ })
664
+
665
+ it("should call onInput callback when setPatch updates bound element", async () => {
666
+ await midiController.initialize()
667
+ await midiController.connection.connect()
668
+
669
+ const onInputCallback = vi.fn()
670
+ const element = {
671
+ value: "64",
672
+ addEventListener: vi.fn(),
673
+ removeEventListener: vi.fn(),
674
+ getAttribute: vi.fn((attr) => {
675
+ if (attr === "min") return "0"
676
+ if (attr === "max") return "127"
677
+ return null
678
+ }),
679
+ }
680
+
681
+ // Bind with onInput callback - specify channel to match patch
682
+ midiController.bind(element, {
683
+ cc: 74,
684
+ channel: 1,
685
+ min: 0,
686
+ max: 100,
687
+ onInput: onInputCallback,
688
+ })
689
+
690
+ // Apply a patch that should trigger onInput
691
+ const patch = {
692
+ name: "Test Patch",
693
+ device: "Test Output 1",
694
+ timestamp: new Date().toISOString(),
695
+ version: "1.0",
696
+ channels: {
697
+ 1: {
698
+ ccs: {
699
+ 74: 64, // This should call onInput with the converted value
700
+ },
701
+ },
702
+ },
703
+ settings: {},
704
+ }
705
+
706
+ await midiController.setPatch(patch)
707
+
708
+ // onInput should have been called with the converted value
709
+ expect(onInputCallback).toHaveBeenCalledTimes(1)
710
+ // Value should be converted from MIDI (0-127) to element range (0-100) = 50.39...
711
+ expect(onInputCallback).toHaveBeenCalledWith(expect.closeTo(50.39, 1))
712
+ })
713
+
714
+ it("should not call onInput callback when setPatch runs on element without callback", async () => {
715
+ await midiController.initialize()
716
+ await midiController.connection.connect()
717
+
718
+ const element = {
719
+ value: "64",
720
+ addEventListener: vi.fn(),
721
+ removeEventListener: vi.fn(),
722
+ getAttribute: vi.fn((attr) => {
723
+ if (attr === "min") return "0"
724
+ if (attr === "max") return "127"
725
+ return null
726
+ }),
727
+ dispatchEvent: vi.fn(),
728
+ }
729
+
730
+ // Bind WITHOUT onInput callback - specify channel to match patch
731
+ const setValueSpy = vi.spyOn(element, "value", "set")
732
+ midiController.bind(element, {
733
+ cc: 75,
734
+ channel: 1,
735
+ min: 0,
736
+ max: 100,
737
+ })
738
+
739
+ // Apply a patch
740
+ const patch = {
741
+ name: "Test Patch",
742
+ device: "Test Output 1",
743
+ timestamp: new Date().toISOString(),
744
+ version: "1.0",
745
+ channels: {
746
+ 1: {
747
+ ccs: {
748
+ 75: 32,
749
+ },
750
+ },
751
+ },
752
+ settings: {},
753
+ }
754
+
755
+ await midiController.setPatch(patch)
756
+
757
+ // element.value should be set directly (no onInput callback)
758
+ expect(setValueSpy).toHaveBeenCalled()
759
+ // Value should be in element range (0-100) = 25.19...
760
+ expect(setValueSpy).toHaveBeenCalledWith(expect.closeTo(25.19, 1))
761
+ })
762
+ })
763
+
764
+ describe("debouncing", () => {
765
+ let _debounceElement
766
+
767
+ beforeEach(async () => {
768
+ await midiController.initialize()
769
+ await midiController.connection.connect()
770
+
771
+ _debounceElement = {
772
+ value: "0",
773
+ addEventListener: vi.fn(),
774
+ removeEventListener: vi.fn(),
775
+ getAttribute: vi.fn((attr) => {
776
+ if (attr === "min") return "0"
777
+ if (attr === "max") return "127"
778
+ return null
779
+ }),
780
+ }
781
+ })
782
+
783
+ it("should debounce rapid input changes", async () => {
784
+ const element = {
785
+ value: "0",
786
+ addEventListener: vi.fn(),
787
+ removeEventListener: vi.fn(),
788
+ getAttribute: vi.fn((attr) => {
789
+ if (attr === "min") return "0"
790
+ if (attr === "max") return "127"
791
+ return null
792
+ }),
793
+ }
794
+
795
+ midiController.bind(element, { cc: 74 }, { debounce: 50 })
796
+
797
+ const handler = element.addEventListener.mock.calls[0][1]
798
+
799
+ // Clear the initial call
800
+ mockOutputs[0].send.mockClear()
801
+
802
+ // Rapid changes
803
+ element.value = "10"
804
+ handler({ target: element })
805
+ element.value = "20"
806
+ handler({ target: element })
807
+ element.value = "30"
808
+ handler({ target: element })
809
+
810
+ // Only last value should be sent after debounce period
811
+ await new Promise((resolve) => setTimeout(resolve, 60))
812
+
813
+ expect(mockOutputs[0].send).toHaveBeenCalledTimes(1)
814
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0xb1, 74, 30]))
815
+ })
816
+
817
+ it("should clean up timeout on destroy", () => {
818
+ const element = {
819
+ value: "50",
820
+ addEventListener: vi.fn(),
821
+ removeEventListener: vi.fn(),
822
+ getAttribute: vi.fn((attr) => {
823
+ if (attr === "min") return "0"
824
+ if (attr === "max") return "127"
825
+ return null
826
+ }),
827
+ }
828
+
829
+ const unbind = midiController.bind(element, { cc: 74 }, { debounce: 100 })
830
+
831
+ const handler = element.addEventListener.mock.calls[0][1]
832
+ handler({ target: element }) // Trigger debounce
833
+
834
+ unbind() // Should clear pending timeout
835
+
836
+ // No error should occur
837
+ expect(element.removeEventListener).toHaveBeenCalled()
838
+ })
839
+
840
+ it("should work without debouncing (default behavior)", () => {
841
+ const element = {
842
+ value: "0",
843
+ addEventListener: vi.fn(),
844
+ removeEventListener: vi.fn(),
845
+ getAttribute: vi.fn((attr) => {
846
+ if (attr === "min") return "0"
847
+ if (attr === "max") return "127"
848
+ return null
849
+ }),
850
+ }
851
+
852
+ midiController.bind(element, { cc: 74 })
853
+
854
+ const handler = element.addEventListener.mock.calls[0][1]
855
+
856
+ // Multiple changes should all be sent immediately
857
+ element.value = "10"
858
+ handler({ target: element })
859
+ element.value = "20"
860
+ handler({ target: element })
861
+ element.value = "30"
862
+ handler({ target: element })
863
+
864
+ // All three should be sent immediately (no debouncing), plus the initial value
865
+ expect(mockOutputs[0].send).toHaveBeenCalledTimes(4)
866
+ })
867
+
868
+ it("should debounce 14-bit CC changes", async () => {
869
+ const element = {
870
+ value: "0",
871
+ addEventListener: vi.fn(),
872
+ removeEventListener: vi.fn(),
873
+ getAttribute: vi.fn((attr) => {
874
+ if (attr === "min") return "0"
875
+ if (attr === "max") return "127"
876
+ return null
877
+ }),
878
+ }
879
+
880
+ midiController.bind(
881
+ element,
882
+ {
883
+ msb: 74,
884
+ lsb: 75,
885
+ is14Bit: true,
886
+ },
887
+ { debounce: 50 },
888
+ )
889
+
890
+ const handler = element.addEventListener.mock.calls[0][1]
891
+
892
+ // Clear initial calls
893
+ mockOutputs[0].send.mockClear()
894
+
895
+ // Rapid changes
896
+ element.value = "100"
897
+ handler({ target: element })
898
+ element.value = "200"
899
+ handler({ target: element })
900
+
901
+ // Should send MSB+LSB for debounced final value only
902
+ await new Promise((resolve) => setTimeout(resolve, 60))
903
+
904
+ expect(mockOutputs[0].send).toHaveBeenCalledTimes(2)
905
+ })
906
+
907
+ it("should not debounce when debounce is 0", () => {
908
+ const element = {
909
+ value: "0",
910
+ addEventListener: vi.fn(),
911
+ removeEventListener: vi.fn(),
912
+ getAttribute: vi.fn((attr) => {
913
+ if (attr === "min") return "0"
914
+ if (attr === "max") return "127"
915
+ return null
916
+ }),
917
+ }
918
+
919
+ midiController.bind(element, { cc: 74 }, { debounce: 0 })
920
+
921
+ const handler = element.addEventListener.mock.calls[0][1]
922
+
923
+ // Multiple changes
924
+ element.value = "10"
925
+ handler({ target: element })
926
+ element.value = "20"
927
+ handler({ target: element })
928
+
929
+ // Both should be sent immediately, plus the initial value
930
+ expect(mockOutputs[0].send).toHaveBeenCalledTimes(3)
931
+ })
932
+ })
933
+
934
+ describe("getPatch with edge cases", () => {
935
+ beforeEach(async () => {
936
+ await midiController.initialize()
937
+ await midiController.connection.connect()
938
+ })
939
+
940
+ it("should collect settings from elements without getAttribute method", () => {
941
+ const element = {
942
+ value: "50",
943
+ id: "test-slider",
944
+ addEventListener: vi.fn(),
945
+ // Note: No getAttribute method - this tests the optional chaining
946
+ }
947
+
948
+ midiController.bind(element, {
949
+ cc: 74,
950
+ min: 0,
951
+ max: 127,
952
+ channel: 2,
953
+ })
954
+
955
+ // Should not throw even though element has no getAttribute
956
+ const patch = midiController.getPatch()
957
+ expect(patch.settings.cc74).toBeDefined()
958
+ expect(patch.settings.cc74.label).toBeNull()
959
+ expect(patch.settings.cc74.elementId).toBe("test-slider")
960
+ })
961
+
962
+ it("should include all CC values in state", () => {
963
+ // Send multiple CC messages on different channels
964
+ midiController.sendCC(74, 100, 1)
965
+ midiController.sendCC(75, 64, 2)
966
+ midiController.sendCC(76, 32, 3)
967
+
968
+ const patch = midiController.getPatch()
969
+
970
+ // Verify all CC values are collected
971
+ expect(patch.channels[1].ccs[74]).toBe(100)
972
+ expect(patch.channels[2].ccs[75]).toBe(64)
973
+ expect(patch.channels[3].ccs[76]).toBe(32)
974
+ })
975
+
976
+ it("should create channel objects as needed", () => {
977
+ // Only send CC on channel 5
978
+ midiController.sendCC(74, 100, 5)
979
+
980
+ const patch = midiController.getPatch()
981
+
982
+ // Should create channel 5 object
983
+ expect(patch.channels[5]).toBeDefined()
984
+ expect(patch.channels[5].ccs[74]).toBe(100)
985
+ // Other channels should be empty or not exist
986
+ expect(patch.channels[1]).toBeUndefined()
987
+ expect(patch.channels[2]).toBeUndefined()
988
+ })
989
+ })
990
+
991
+ describe("getCC", () => {
992
+ beforeEach(async () => {
993
+ await midiController.initialize()
994
+ await midiController.connection.connect()
995
+ })
996
+
997
+ it("should return undefined for unknown CC", () => {
998
+ expect(midiController.getCC(74, 2)).toBeUndefined()
999
+ })
1000
+
1001
+ it("should return stored CC value", async () => {
1002
+ midiController.sendCC(74, 100, 3)
1003
+ expect(midiController.getCC(74, 3)).toBe(100)
1004
+ })
1005
+
1006
+ it("should use default channel", () => {
1007
+ midiController.sendCC(7, 64)
1008
+ expect(midiController.getCC(7, 2)).toBe(64) // Default channel is 2
1009
+ })
1010
+ })
1011
+
1012
+ describe("getOutputs", () => {
1013
+ it("should return outputs from connection", async () => {
1014
+ await midiController.initialize()
1015
+ const outputs = midiController.getOutputs()
1016
+
1017
+ expect(outputs).toHaveLength(2)
1018
+ expect(outputs[0].name).toBe("Test Output 1")
1019
+ })
1020
+
1021
+ it("should return empty array if no connection", () => {
1022
+ expect(midiController.getOutputs()).toEqual([])
1023
+ })
1024
+ })
1025
+
1026
+ describe("getInputs", () => {
1027
+ it("should return inputs from connection", async () => {
1028
+ await midiController.initialize()
1029
+ const inputs = midiController.getInputs()
1030
+
1031
+ expect(inputs).toHaveLength(1)
1032
+ expect(inputs[0].name).toBe("Test Input")
1033
+ })
1034
+
1035
+ it("should return empty array if no connection", () => {
1036
+ expect(midiController.getInputs()).toEqual([])
1037
+ })
1038
+ })
1039
+
1040
+ describe("setOutput", () => {
1041
+ beforeEach(async () => {
1042
+ await midiController.initialize()
1043
+ })
1044
+
1045
+ it("should switch output and emit event", async () => {
1046
+ await midiController.connection.connect(0)
1047
+ const spy = vi.fn()
1048
+ midiController.on("output-changed", spy)
1049
+
1050
+ await midiController.setOutput(1)
1051
+
1052
+ expect(spy).toHaveBeenCalled()
1053
+ expect(midiController.getCurrentOutput().id).toBe("output-2")
1054
+ })
1055
+ })
1056
+
1057
+ describe("getCurrentOutput", () => {
1058
+ it("should return current output", async () => {
1059
+ await midiController.initialize()
1060
+ await midiController.connection.connect(0)
1061
+
1062
+ const output = midiController.getCurrentOutput()
1063
+ expect(output.id).toBe("output-1")
1064
+ })
1065
+
1066
+ it("should return null if no connection", () => {
1067
+ expect(midiController.getCurrentOutput()).toBeNull()
1068
+ })
1069
+ })
1070
+
1071
+ describe("getCurrentInput", () => {
1072
+ it("should return current input", async () => {
1073
+ await midiController.initialize()
1074
+ await midiController.connectInput(0)
1075
+
1076
+ const input = midiController.getCurrentInput()
1077
+ expect(input.id).toBe("input-1")
1078
+ })
1079
+
1080
+ it("should return null if no connection", () => {
1081
+ expect(midiController.getCurrentInput()).toBeNull()
1082
+ })
1083
+ })
1084
+
1085
+ describe("destroy", () => {
1086
+ beforeEach(async () => {
1087
+ await midiController.initialize()
1088
+ await midiController.connection.connect()
1089
+ })
1090
+
1091
+ it("should clean up all bindings", () => {
1092
+ // Create mock binding
1093
+ const mockBinding = {
1094
+ destroy: vi.fn(),
1095
+ }
1096
+ midiController.bindings.set({}, mockBinding)
1097
+
1098
+ midiController.destroy()
1099
+
1100
+ expect(mockBinding.destroy).toHaveBeenCalled()
1101
+ expect(midiController.bindings.size).toBe(0)
1102
+ })
1103
+
1104
+ it("should clear state", () => {
1105
+ midiController.state.set("1:7", 100)
1106
+ expect(midiController.state.size).toBeGreaterThan(0)
1107
+
1108
+ midiController.destroy()
1109
+
1110
+ expect(midiController.state.size).toBe(0)
1111
+ })
1112
+
1113
+ it("should disconnect connection", () => {
1114
+ const spy = vi.spyOn(midiController.connection, "disconnect")
1115
+
1116
+ midiController.destroy()
1117
+
1118
+ expect(spy).toHaveBeenCalled()
1119
+ })
1120
+
1121
+ it("should remove all listeners", () => {
1122
+ const spy = vi.fn()
1123
+ midiController.on("test", spy)
1124
+
1125
+ midiController.destroy()
1126
+
1127
+ midiController.emit("test")
1128
+ expect(spy).not.toHaveBeenCalled()
1129
+ })
1130
+
1131
+ it("should emit destroyed event", () => {
1132
+ const spy = vi.fn()
1133
+ midiController.on("destroyed", spy)
1134
+
1135
+ midiController.destroy()
1136
+
1137
+ expect(spy).toHaveBeenCalled()
1138
+ expect(midiController.initialized).toBe(false)
1139
+ })
1140
+
1141
+ it("should clean up on destroy", () => {
1142
+ midiController.destroy()
1143
+ expect(midiController.initialized).toBe(false)
1144
+ })
1145
+ })
1146
+
1147
+ describe("_handleMIDIMessage", () => {
1148
+ beforeEach(async () => {
1149
+ await midiController.initialize()
1150
+ })
1151
+
1152
+ it("should handle SysEx messages", () => {
1153
+ const spy = vi.fn()
1154
+ midiController.on(CONTROLLER_EVENTS.SYSEX_RECV, spy)
1155
+
1156
+ const event = {
1157
+ data: new Uint8Array([0xf0, 0x42, 0x30, 0xf7]),
1158
+ midiwire: 1234,
1159
+ }
1160
+
1161
+ midiController._handleMIDIMessage(event)
1162
+
1163
+ expect(spy).toHaveBeenCalledWith({
1164
+ data: [0xf0, 0x42, 0x30, 0xf7],
1165
+ timestamp: 1234,
1166
+ })
1167
+ })
1168
+
1169
+ it("should handle CC messages", () => {
1170
+ const spy = vi.fn()
1171
+ midiController.on(CONTROLLER_EVENTS.CC_RECV, spy)
1172
+
1173
+ const event = {
1174
+ data: new Uint8Array([0xb1, 74, 100]),
1175
+ }
1176
+
1177
+ midiController._handleMIDIMessage(event)
1178
+
1179
+ expect(spy).toHaveBeenCalledWith({
1180
+ cc: 74,
1181
+ value: 100,
1182
+ channel: 2,
1183
+ })
1184
+ expect(midiController.getCC(74, 2)).toBe(100)
1185
+ })
1186
+
1187
+ it("should handle Note On messages", () => {
1188
+ const spy = vi.fn()
1189
+ midiController.on(CONTROLLER_EVENTS.NOTE_ON_RECV, spy)
1190
+
1191
+ const event = {
1192
+ data: new Uint8Array([0x91, 60, 100]),
1193
+ }
1194
+
1195
+ midiController._handleMIDIMessage(event)
1196
+
1197
+ expect(spy).toHaveBeenCalledWith({
1198
+ note: 60,
1199
+ velocity: 100,
1200
+ channel: 2,
1201
+ })
1202
+ })
1203
+
1204
+ it("should handle Note Off messages (0x80)", () => {
1205
+ const spy = vi.fn()
1206
+ midiController.on(CONTROLLER_EVENTS.NOTE_OFF_RECV, spy)
1207
+
1208
+ const event = {
1209
+ data: new Uint8Array([0x81, 60, 0]),
1210
+ }
1211
+
1212
+ midiController._handleMIDIMessage(event)
1213
+
1214
+ expect(spy).toHaveBeenCalledWith({
1215
+ note: 60,
1216
+ channel: 2,
1217
+ })
1218
+ })
1219
+
1220
+ it("should handle Note Off messages (0x90 with velocity 0)", () => {
1221
+ const spy = vi.fn()
1222
+ midiController.on(CONTROLLER_EVENTS.NOTE_OFF_RECV, spy)
1223
+
1224
+ const event = {
1225
+ data: new Uint8Array([0x91, 60, 0]),
1226
+ }
1227
+
1228
+ midiController._handleMIDIMessage(event)
1229
+
1230
+ expect(spy).toHaveBeenCalledWith({
1231
+ note: 60,
1232
+ channel: 2,
1233
+ })
1234
+ })
1235
+
1236
+ it("should handle other MIDI messages", () => {
1237
+ const spy = vi.fn()
1238
+ midiController.on(CONTROLLER_EVENTS.MIDI_MSG, spy)
1239
+
1240
+ const event = {
1241
+ data: new Uint8Array([0xe1, 0x00, 0x40]),
1242
+ midiwire: 5678,
1243
+ }
1244
+
1245
+ midiController._handleMIDIMessage(event)
1246
+
1247
+ expect(spy).toHaveBeenCalledWith({
1248
+ status: 0xe1,
1249
+ data: [0x00, 0x40],
1250
+ channel: 2,
1251
+ timestamp: 5678,
1252
+ })
1253
+ })
1254
+ })
1255
+
1256
+ describe("Patch Management", () => {
1257
+ beforeEach(async () => {
1258
+ await midiController.initialize()
1259
+ await midiController.connection.connect()
1260
+ })
1261
+
1262
+ describe("getPatch", () => {
1263
+ it("should create patch with default name", () => {
1264
+ const patch = midiController.getPatch()
1265
+
1266
+ expect(patch).toHaveProperty("name", "Unnamed Patch")
1267
+ expect(patch).toHaveProperty("device")
1268
+ expect(patch).toHaveProperty("timestamp")
1269
+ expect(patch).toHaveProperty("version", "1.0")
1270
+ expect(patch).toHaveProperty("channels")
1271
+ expect(patch).toHaveProperty("settings")
1272
+ })
1273
+
1274
+ it("should create patch with custom name", () => {
1275
+ const patch = midiController.getPatch("My Patch")
1276
+
1277
+ expect(patch.name).toBe("My Patch")
1278
+ })
1279
+
1280
+ it("should include current CC values", () => {
1281
+ midiController.sendCC(74, 100, 2)
1282
+ midiController.sendCC(71, 64, 3)
1283
+
1284
+ const patch = midiController.getPatch()
1285
+
1286
+ expect(patch.channels["2"].ccs["74"]).toBe(100)
1287
+ expect(patch.channels["3"].ccs["71"]).toBe(64)
1288
+ })
1289
+
1290
+ it("should include device information", () => {
1291
+ const patch = midiController.getPatch()
1292
+
1293
+ expect(patch.device).toBe("Test Output 1")
1294
+ })
1295
+
1296
+ it("should handle null device", async () => {
1297
+ await midiController.connection.disconnect()
1298
+ const patch = midiController.getPatch()
1299
+
1300
+ expect(patch.device).toBeNull()
1301
+ })
1302
+
1303
+ it("should collect control settings", () => {
1304
+ const element = {
1305
+ value: "50",
1306
+ id: "cutoff-slider",
1307
+ addEventListener: vi.fn(),
1308
+ getAttribute: vi.fn((attr) => {
1309
+ if (attr === "data-midi-label") return "Filter Cutoff"
1310
+ if (attr === "min" || attr === "max") return "0"
1311
+ return null
1312
+ }),
1313
+ }
1314
+
1315
+ midiController.bind(element, {
1316
+ cc: 74,
1317
+ min: 20,
1318
+ max: 20000,
1319
+ channel: 2,
1320
+ invert: false,
1321
+ })
1322
+
1323
+ const patch = midiController.getPatch()
1324
+
1325
+ expect(patch.settings.cc74).toEqual({
1326
+ min: 20,
1327
+ max: 20000,
1328
+ invert: false,
1329
+ is14Bit: false,
1330
+ label: "Filter Cutoff",
1331
+ elementId: "cutoff-slider",
1332
+ })
1333
+ })
1334
+ })
1335
+
1336
+ describe("setPatch", () => {
1337
+ it("should apply CC values from patch", async () => {
1338
+ const patch = {
1339
+ name: "Test Patch",
1340
+ channels: {
1341
+ 1: {
1342
+ ccs: {
1343
+ 74: 100,
1344
+ 71: 64,
1345
+ },
1346
+ },
1347
+ },
1348
+ }
1349
+
1350
+ await midiController.setPatch(patch)
1351
+
1352
+ expect(midiController.getCC(74, 1)).toBe(100)
1353
+ expect(midiController.getCC(71, 1)).toBe(64)
1354
+ expect(mockOutputs[0].send).toHaveBeenCalled()
1355
+ })
1356
+
1357
+ it("should throw error for invalid patch", async () => {
1358
+ await expect(midiController.setPatch(null)).rejects.toThrow("Invalid patch format")
1359
+ await expect(midiController.setPatch({})).rejects.toThrow("Invalid patch format")
1360
+ })
1361
+
1362
+ it("should apply notes from patch", async () => {
1363
+ const patch = {
1364
+ name: "Test Patch",
1365
+ channels: {
1366
+ 1: {
1367
+ notes: {
1368
+ 60: 100, // Note on
1369
+ 64: 0, // Note off
1370
+ },
1371
+ },
1372
+ },
1373
+ }
1374
+
1375
+ await midiController.setPatch(patch)
1376
+
1377
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x90, 60, 100]))
1378
+ expect(mockOutputs[0].send).toHaveBeenCalledWith(new Uint8Array([0x90, 64, 0]))
1379
+ })
1380
+
1381
+ it("should emit PATCH_LOADED event", async () => {
1382
+ const spy = vi.fn()
1383
+ midiController.on(CONTROLLER_EVENTS.PATCH_LOADED, spy)
1384
+
1385
+ const patch = {
1386
+ name: "Test Patch",
1387
+ channels: {
1388
+ 1: { ccs: { 74: 100 } },
1389
+ },
1390
+ }
1391
+
1392
+ await midiController.setPatch(patch)
1393
+
1394
+ expect(spy).toHaveBeenCalledWith({ patch })
1395
+ })
1396
+
1397
+ it("should apply settings to controls when possible", async () => {
1398
+ const element = {
1399
+ value: "50",
1400
+ min: "0",
1401
+ max: "127",
1402
+ addEventListener: vi.fn(),
1403
+ getAttribute: vi.fn(() => "0"),
1404
+ dispatchEvent: vi.fn(),
1405
+ }
1406
+
1407
+ midiController.bind(element, { cc: 74, min: 0, max: 127, channel: 2 })
1408
+
1409
+ const patch = {
1410
+ name: "Test Patch",
1411
+ channels: {
1412
+ 2: { ccs: { 74: 100 } },
1413
+ },
1414
+ settings: {
1415
+ cc74: { min: 10, max: 1000 },
1416
+ },
1417
+ }
1418
+
1419
+ await midiController.setPatch(patch)
1420
+
1421
+ expect(element.min).toBe("10")
1422
+ expect(element.max).toBe("1000")
1423
+ })
1424
+
1425
+ it("should convert MIDI CC value to element value with custom min/max", async () => {
1426
+ const element = {
1427
+ value: "1000",
1428
+ min: "20",
1429
+ max: "20000",
1430
+ addEventListener: vi.fn(),
1431
+ getAttribute: vi.fn(() => "0"),
1432
+ dispatchEvent: vi.fn(),
1433
+ }
1434
+
1435
+ midiController.bind(element, { cc: 74, min: 20, max: 20000, channel: 2 })
1436
+
1437
+ // Create a patch with settings that match the binding config
1438
+ const patch = {
1439
+ name: "Test Patch",
1440
+ channels: {
1441
+ 2: { ccs: { 74: 63 } }, // MIDI value ~50%
1442
+ },
1443
+ settings: {
1444
+ cc74: { min: 20, max: 20000, channel: 2 },
1445
+ },
1446
+ }
1447
+
1448
+ await midiController.setPatch(patch)
1449
+
1450
+ // MIDI value 63/127 ~ 0.496, so 20 + 0.496 * (20000-20) ≈ 9940
1451
+ const expectedValue = 20 + (63 / 127) * (20000 - 20)
1452
+ expect(Math.round(parseFloat(element.value))).toBe(Math.round(expectedValue))
1453
+ expect(element.dispatchEvent).toHaveBeenCalled()
1454
+ expect(element.dispatchEvent.mock.calls[0][0].type).toBe("input")
1455
+ })
1456
+
1457
+ it("should convert MIDI CC value with inverted mapping", async () => {
1458
+ const element = {
1459
+ value: "64",
1460
+ min: "0",
1461
+ max: "127",
1462
+ addEventListener: vi.fn(),
1463
+ getAttribute: vi.fn(() => "0"),
1464
+ dispatchEvent: vi.fn(),
1465
+ }
1466
+
1467
+ midiController.bind(element, { cc: 74, invert: true, channel: 2 })
1468
+
1469
+ const patch = {
1470
+ name: "Test Patch",
1471
+ channels: {
1472
+ 2: { ccs: { 74: 63 } }, // MIDI value ~50%
1473
+ },
1474
+ }
1475
+
1476
+ await midiController.setPatch(patch)
1477
+
1478
+ // Inverted: 127 - (63/127)*(127-0) = 127 - 62.7 = 64.3
1479
+ const expectedValue = 127 - (63 / 127) * (127 - 0)
1480
+ expect(Math.round(parseFloat(element.value))).toBe(Math.round(expectedValue))
1481
+ expect(element.dispatchEvent).toHaveBeenCalled()
1482
+ })
1483
+
1484
+ it("should handle CC values with default min/max", async () => {
1485
+ const element = {
1486
+ value: "64",
1487
+ min: "0",
1488
+ max: "127",
1489
+ addEventListener: vi.fn(),
1490
+ getAttribute: vi.fn(() => "0"),
1491
+ dispatchEvent: vi.fn(),
1492
+ }
1493
+
1494
+ midiController.bind(element, { cc: 74, channel: 2 })
1495
+
1496
+ const patch = {
1497
+ name: "Test Patch",
1498
+ channels: {
1499
+ 2: { ccs: { 74: 100 } },
1500
+ },
1501
+ }
1502
+
1503
+ await midiController.setPatch(patch)
1504
+
1505
+ // With default min/max (0/127), MIDI value 100 should map to element value 100
1506
+ expect(parseFloat(element.value)).toBe(100)
1507
+ expect(element.dispatchEvent).toHaveBeenCalled()
1508
+ })
1509
+
1510
+ it("should handle missing CC values in patch gracefully", async () => {
1511
+ const element = {
1512
+ value: "64",
1513
+ min: "0",
1514
+ max: "127",
1515
+ addEventListener: vi.fn(),
1516
+ getAttribute: vi.fn(() => "0"),
1517
+ dispatchEvent: vi.fn(),
1518
+ }
1519
+
1520
+ midiController.bind(element, { cc: 74, channel: 2 })
1521
+
1522
+ const patch = {
1523
+ name: "Test Patch",
1524
+ channels: {
1525
+ 2: { ccs: { 75: 100 } }, // Different CC
1526
+ },
1527
+ }
1528
+
1529
+ await midiController.setPatch(patch)
1530
+
1531
+ // Element value should not change if CC is not in patch
1532
+ expect(parseFloat(element.value)).toBe(64)
1533
+ // Should not dispatch event for missing CC
1534
+ expect(element.dispatchEvent).not.toHaveBeenCalled()
1535
+ })
1536
+
1537
+ it("should handle numeric string min/max values", async () => {
1538
+ const element = {
1539
+ value: "500",
1540
+ min: "100",
1541
+ max: "1000",
1542
+ addEventListener: vi.fn(),
1543
+ getAttribute: vi.fn((attr) => {
1544
+ if (attr === "min") return "100"
1545
+ if (attr === "max") return "1000"
1546
+ return null
1547
+ }),
1548
+ dispatchEvent: vi.fn(),
1549
+ }
1550
+
1551
+ midiController.bind(element, { cc: 74, channel: 2 })
1552
+
1553
+ const patch = {
1554
+ name: "Test Patch",
1555
+ channels: {
1556
+ 2: { ccs: { 74: 100 } }, // Default min=100, max=1000
1557
+ },
1558
+ }
1559
+
1560
+ await midiController.setPatch(patch)
1561
+
1562
+ // Should parse string values correctly: 100 + (100/127)*(1000-100) = 100 + 708.66 = 808.66
1563
+ expect(parseFloat(element.value)).toBeGreaterThan(500)
1564
+ expect(element.dispatchEvent).toHaveBeenCalled()
1565
+ })
1566
+
1567
+ it("should apply multiple CC values to multiple bound elements", async () => {
1568
+ const element1 = {
1569
+ value: "50",
1570
+ min: "0",
1571
+ max: "127",
1572
+ addEventListener: vi.fn(),
1573
+ getAttribute: vi.fn((attr) => {
1574
+ if (attr === "min") return "0"
1575
+ if (attr === "max") return "127"
1576
+ return null
1577
+ }),
1578
+ dispatchEvent: vi.fn(),
1579
+ }
1580
+
1581
+ const element2 = {
1582
+ value: "1000",
1583
+ min: "20",
1584
+ max: "20000",
1585
+ addEventListener: vi.fn(),
1586
+ getAttribute: vi.fn((attr) => {
1587
+ if (attr === "min") return "20"
1588
+ if (attr === "max") return "20000"
1589
+ return null
1590
+ }),
1591
+ dispatchEvent: vi.fn(),
1592
+ }
1593
+
1594
+ midiController.bind(element1, { cc: 74, channel: 2 })
1595
+ midiController.bind(element2, { cc: 75, channel: 2 })
1596
+
1597
+ const patch = {
1598
+ name: "Test Patch",
1599
+ channels: {
1600
+ 2: {
1601
+ ccs: {
1602
+ 74: 100, // Should map to exactly 100 in 0-127 range
1603
+ 75: 63, // Should map to 20 + (63/127)*(20000-20) ≈ 9940 in 20-20000 range
1604
+ },
1605
+ },
1606
+ },
1607
+ }
1608
+
1609
+ await midiController.setPatch(patch)
1610
+
1611
+ expect(parseFloat(element1.value)).toBe(100)
1612
+ const expectedValue = 20 + (63 / 127) * (20000 - 20)
1613
+ expect(Math.round(parseFloat(element2.value))).toBe(Math.round(expectedValue))
1614
+ expect(element1.dispatchEvent).toHaveBeenCalled()
1615
+ expect(element2.dispatchEvent).toHaveBeenCalled()
1616
+ })
1617
+
1618
+ it("should handle elements without min/max attributes", async () => {
1619
+ // Test case where config has no min/max, so it reads from element attributes
1620
+ const element = {
1621
+ value: "50",
1622
+ // No min/max properties directly on element
1623
+ addEventListener: vi.fn(),
1624
+ getAttribute: vi.fn((attr) => {
1625
+ if (attr === "min") return "0"
1626
+ if (attr === "max") return "127"
1627
+ return null
1628
+ }),
1629
+ dispatchEvent: vi.fn(),
1630
+ }
1631
+
1632
+ midiController.bind(element, { cc: 74, channel: 2 })
1633
+ // No min/max in config
1634
+
1635
+ const patch = {
1636
+ name: "Test Patch",
1637
+ channels: {
1638
+ 2: { ccs: { 74: 100 } },
1639
+ },
1640
+ settings: {},
1641
+ }
1642
+
1643
+ await midiController.setPatch(patch)
1644
+
1645
+ // Should have accessed element attributes
1646
+ expect(element.getAttribute).toHaveBeenCalled()
1647
+ expect(element.dispatchEvent).toHaveBeenCalled()
1648
+ })
1649
+
1650
+ it("should handle elements without config.cc in settings", async () => {
1651
+ // This tests line 535 where binding.config.cc matches bindingKey
1652
+ // We need a case where settings has a cc that doesn't match any binding
1653
+ const element = {
1654
+ value: "50",
1655
+ min: "0",
1656
+ max: "127",
1657
+ addEventListener: vi.fn(),
1658
+ getAttribute: vi.fn(() => "0"),
1659
+ dispatchEvent: vi.fn(),
1660
+ }
1661
+
1662
+ midiController.bind(element, { cc: 74, channel: 2 })
1663
+
1664
+ const patch = {
1665
+ name: "Test Patch",
1666
+ channels: {
1667
+ 2: { ccs: { 74: 100 } },
1668
+ },
1669
+ settings: {
1670
+ cc75: { min: 10, max: 1000 }, // CC 75, not 74 - should not match
1671
+ },
1672
+ }
1673
+
1674
+ await midiController.setPatch(patch)
1675
+
1676
+ // CC 74 binding should not be affected by cc75 settings
1677
+ expect(element.min).toBe("0") // Should remain unchanged
1678
+ expect(element.max).toBe("127") // Should remain unchanged
1679
+ })
1680
+ })
1681
+
1682
+ describe("patch versioning", () => {
1683
+ it("should apply v1.0 patches", async () => {
1684
+ const patch = {
1685
+ version: "1.0",
1686
+ channels: { 1: { ccs: { 74: 100 } } },
1687
+ }
1688
+ await midiController.setPatch(patch)
1689
+ expect(midiController.getCC(74, 1)).toBe(100)
1690
+ })
1691
+
1692
+ it("should warn on unknown version", async () => {
1693
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
1694
+ const patch = {
1695
+ version: "99.0",
1696
+ channels: { 1: { ccs: { 74: 100 } } },
1697
+ }
1698
+ await midiController.setPatch(patch)
1699
+ expect(consoleSpy).toHaveBeenCalledWith(
1700
+ expect.stringContaining("Unknown patch version: 99.0"),
1701
+ )
1702
+ consoleSpy.mockRestore()
1703
+ })
1704
+
1705
+ it("should default to v1.0 for patches without version", async () => {
1706
+ const patch = {
1707
+ channels: { 1: { ccs: { 74: 100 } } },
1708
+ }
1709
+ await midiController.setPatch(patch)
1710
+ expect(midiController.getCC(74, 1)).toBe(100)
1711
+ })
1712
+ })
1713
+
1714
+ describe("savePatch", () => {
1715
+ beforeEach(() => {
1716
+ vi.stubGlobal("localStorage", {
1717
+ setItem: vi.fn(),
1718
+ getItem: vi.fn(),
1719
+ removeItem: vi.fn(),
1720
+ length: 0,
1721
+ key: vi.fn(),
1722
+ clear: vi.fn(),
1723
+ })
1724
+ })
1725
+
1726
+ afterEach(() => {
1727
+ vi.unstubAllGlobals()
1728
+ })
1729
+
1730
+ it("should save patch to localStorage", () => {
1731
+ const patch = { name: "Test Patch", channels: { 1: { ccs: { 74: 100 } } } }
1732
+ const key = midiController.savePatch("Test Patch", patch)
1733
+
1734
+ expect(key).toBe("midiwire_patch_Test Patch")
1735
+ expect(localStorage.setItem).toHaveBeenCalledWith(
1736
+ "midiwire_patch_Test Patch",
1737
+ JSON.stringify(patch),
1738
+ )
1739
+ })
1740
+
1741
+ it("should use getPatch() if no patch provided", () => {
1742
+ midiController.sendCC(74, 100, 1)
1743
+
1744
+ midiController.savePatch("My Patch")
1745
+
1746
+ expect(localStorage.setItem).toHaveBeenCalled()
1747
+ const savedData = JSON.parse(localStorage.setItem.mock.calls[0][1])
1748
+ expect(savedData.name).toBe("My Patch")
1749
+ expect(savedData.channels["1"].ccs["74"]).toBe(100)
1750
+ })
1751
+
1752
+ it("should emit PATCH_SAVED event", () => {
1753
+ const spy = vi.fn()
1754
+ midiController.on(CONTROLLER_EVENTS.PATCH_SAVED, spy)
1755
+
1756
+ midiController.savePatch("Test Patch")
1757
+
1758
+ expect(spy).toHaveBeenCalled()
1759
+ expect(spy.mock.calls[0][0]).toHaveProperty("name", "Test Patch")
1760
+ })
1761
+
1762
+ it("should handle localStorage errors", () => {
1763
+ localStorage.setItem = vi.fn().mockImplementation(() => {
1764
+ throw new Error("Quota exceeded")
1765
+ })
1766
+
1767
+ expect(() => midiController.savePatch("Test Patch")).toThrow("Quota exceeded")
1768
+ })
1769
+ })
1770
+
1771
+ describe("loadPatch", () => {
1772
+ beforeEach(() => {
1773
+ vi.stubGlobal("localStorage", {
1774
+ setItem: vi.fn(),
1775
+ getItem: vi.fn(),
1776
+ removeItem: vi.fn(),
1777
+ length: 0,
1778
+ key: vi.fn(),
1779
+ clear: vi.fn(),
1780
+ })
1781
+ })
1782
+
1783
+ afterEach(() => {
1784
+ vi.unstubAllGlobals()
1785
+ })
1786
+
1787
+ it("should load patch from localStorage", () => {
1788
+ const patch = { name: "Test Patch", channels: { 1: { ccs: { 74: 100 } } } }
1789
+ localStorage.getItem = vi.fn().mockReturnValue(JSON.stringify(patch))
1790
+
1791
+ const loaded = midiController.loadPatch("Test Patch")
1792
+
1793
+ expect(loaded).toEqual(patch)
1794
+ expect(localStorage.getItem).toHaveBeenCalledWith("midiwire_patch_Test Patch")
1795
+ })
1796
+
1797
+ it("should return null if patch not found", () => {
1798
+ localStorage.getItem = vi.fn().mockReturnValue(null)
1799
+
1800
+ const loaded = midiController.loadPatch("Nonexistent")
1801
+
1802
+ expect(loaded).toBeNull()
1803
+ })
1804
+
1805
+ it("should emit PATCH_LOADED event", () => {
1806
+ const patch = { name: "Test Patch", channels: { 1: { ccs: { 74: 100 } } } }
1807
+ localStorage.getItem = vi.fn().mockReturnValue(JSON.stringify(patch))
1808
+
1809
+ const spy = vi.fn()
1810
+ midiController.on(CONTROLLER_EVENTS.PATCH_LOADED, spy)
1811
+
1812
+ midiController.loadPatch("Test Patch")
1813
+
1814
+ expect(spy).toHaveBeenCalledWith({ name: "Test Patch", patch })
1815
+ })
1816
+
1817
+ it("should handle JSON parse errors", () => {
1818
+ localStorage.getItem = vi.fn().mockReturnValue("invalid json")
1819
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1820
+
1821
+ const loaded = midiController.loadPatch("Test Patch")
1822
+
1823
+ expect(loaded).toBeNull()
1824
+ expect(consoleSpy).toHaveBeenCalled()
1825
+
1826
+ consoleSpy.mockRestore()
1827
+ })
1828
+ })
1829
+
1830
+ describe("deletePatch", () => {
1831
+ beforeEach(() => {
1832
+ vi.stubGlobal("localStorage", {
1833
+ setItem: vi.fn(),
1834
+ getItem: vi.fn(),
1835
+ removeItem: vi.fn(),
1836
+ length: 0,
1837
+ key: vi.fn(),
1838
+ clear: vi.fn(),
1839
+ })
1840
+ })
1841
+
1842
+ afterEach(() => {
1843
+ vi.unstubAllGlobals()
1844
+ })
1845
+
1846
+ it("should delete patch from localStorage", () => {
1847
+ const result = midiController.deletePatch("Test Patch")
1848
+
1849
+ expect(result).toBe(true)
1850
+ expect(localStorage.removeItem).toHaveBeenCalledWith("midiwire_patch_Test Patch")
1851
+ })
1852
+
1853
+ it("should emit PATCH_DELETED event", () => {
1854
+ const spy = vi.fn()
1855
+ midiController.on(CONTROLLER_EVENTS.PATCH_DELETED, spy)
1856
+
1857
+ midiController.deletePatch("Test Patch")
1858
+
1859
+ expect(spy).toHaveBeenCalledWith({ name: "Test Patch" })
1860
+ })
1861
+
1862
+ it("should handle errors gracefully", () => {
1863
+ localStorage.removeItem = vi.fn().mockImplementation(() => {
1864
+ throw new Error("Storage error")
1865
+ })
1866
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1867
+
1868
+ const result = midiController.deletePatch("Test Patch")
1869
+
1870
+ expect(result).toBe(false)
1871
+ expect(consoleSpy).toHaveBeenCalled()
1872
+
1873
+ consoleSpy.mockRestore()
1874
+ })
1875
+ })
1876
+
1877
+ describe("listPatches", () => {
1878
+ beforeEach(() => {
1879
+ vi.stubGlobal("localStorage", {
1880
+ setItem: vi.fn(),
1881
+ getItem: vi.fn(),
1882
+ removeItem: vi.fn(),
1883
+ length: 3,
1884
+ key: vi.fn((index) => {
1885
+ const keys = ["other_key", "midiwire_patch_A", "midiwire_patch_B"]
1886
+ return keys[index]
1887
+ }),
1888
+ clear: vi.fn(),
1889
+ })
1890
+ })
1891
+
1892
+ afterEach(() => {
1893
+ vi.unstubAllGlobals()
1894
+ })
1895
+
1896
+ it("should list all midiwire patches", () => {
1897
+ const patchA = { name: "Patch A", channels: { 1: { ccs: { 74: 100 } } } }
1898
+ const patchB = { name: "Patch B", channels: { 1: { ccs: { 71: 64 } } } }
1899
+
1900
+ localStorage.getItem = vi.fn((key) => {
1901
+ if (key === "midiwire_patch_A") return JSON.stringify(patchA)
1902
+ if (key === "midiwire_patch_B") return JSON.stringify(patchB)
1903
+ return null
1904
+ })
1905
+
1906
+ const patches = midiController.listPatches()
1907
+
1908
+ expect(patches).toHaveLength(2)
1909
+ expect(patches[0].name).toBe("A")
1910
+ expect(patches[0].patch).toEqual(patchA)
1911
+ expect(patches[1].name).toBe("B")
1912
+ expect(patches[1].patch).toEqual(patchB)
1913
+ })
1914
+
1915
+ it("should filter out invalid patches", () => {
1916
+ localStorage.getItem = vi.fn(() => null)
1917
+
1918
+ const patches = midiController.listPatches()
1919
+
1920
+ expect(patches).toHaveLength(0)
1921
+ })
1922
+
1923
+ it("should sort patches by name", () => {
1924
+ const patchB = { name: "Patch B", channels: { 1: { ccs: { 74: 100 } } } }
1925
+ const patchA = { name: "Patch A", channels: { 1: { ccs: { 71: 64 } } } }
1926
+
1927
+ localStorage.getItem = vi.fn((key) => {
1928
+ if (key === "midiwire_patch_A") return JSON.stringify(patchA)
1929
+ if (key === "midiwire_patch_B") return JSON.stringify(patchB)
1930
+ return null
1931
+ })
1932
+
1933
+ const patches = midiController.listPatches()
1934
+
1935
+ expect(patches[0].name).toBe("A")
1936
+ expect(patches[1].name).toBe("B")
1937
+ })
1938
+
1939
+ it("should handle errors in listPatches gracefully", () => {
1940
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1941
+
1942
+ // Make localStorage.length throw an error
1943
+ Object.defineProperty(localStorage, "length", {
1944
+ get: () => {
1945
+ throw new Error("Storage access denied")
1946
+ },
1947
+ })
1948
+
1949
+ const patches = midiController.listPatches()
1950
+
1951
+ expect(consoleSpy).toHaveBeenCalledWith("Failed to list patches:", expect.any(Error))
1952
+ expect(patches).toEqual([]) // Should return empty array on error
1953
+
1954
+ consoleSpy.mockRestore()
1955
+ })
1956
+ })
1957
+ })
1958
+ })