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,638 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { createMIDIController } from "../index.js"
3
+ import { EventEmitter } from "./EventEmitter.js"
4
+ import { CONNECTION_EVENTS } from "./MIDIConnection.js"
5
+ import { MIDIDeviceManager } from "./MIDIDeviceManager.js"
6
+
7
+ describe("MIDIDeviceManager", () => {
8
+ let deviceManager
9
+ let statusUpdates = []
10
+ let connectionUpdates = []
11
+
12
+ beforeEach(() => {
13
+ statusUpdates = []
14
+ connectionUpdates = []
15
+ deviceManager = new MIDIDeviceManager({
16
+ onStatusUpdate: (message, state) => {
17
+ statusUpdates.push({ message, state })
18
+ },
19
+ onConnectionUpdate: (device, midi) => {
20
+ connectionUpdates.push({ device, midi })
21
+ },
22
+ channel: 1,
23
+ })
24
+ })
25
+
26
+ describe("constructor", () => {
27
+ it("should initialize with default options", () => {
28
+ const dm = new MIDIDeviceManager()
29
+ expect(dm.midi).toBe(null)
30
+ expect(dm.currentDevice).toBe(null)
31
+ expect(dm.isConnecting).toBe(false)
32
+ expect(dm.channel).toBe(1)
33
+ })
34
+
35
+ it("should initialize with custom options", () => {
36
+ const mockMidi = { connection: new EventEmitter() }
37
+ const dm = new MIDIDeviceManager({
38
+ midiController: mockMidi,
39
+ channel: 5,
40
+ onStatusUpdate: () => {},
41
+ onConnectionUpdate: () => {},
42
+ })
43
+ expect(dm.midi).toBe(mockMidi)
44
+ expect(dm.channel).toBe(5)
45
+ })
46
+ })
47
+
48
+ describe("setMIDI", () => {
49
+ it("should set the MIDI controller", () => {
50
+ const mockMidi = {
51
+ connection: new EventEmitter(),
52
+ }
53
+ deviceManager.setMIDI(mockMidi)
54
+ expect(deviceManager.midi).toBe(mockMidi)
55
+ })
56
+ })
57
+
58
+ describe("getOutputDevices", () => {
59
+ it("should return empty array when no MIDI connection", () => {
60
+ expect(deviceManager.getOutputDevices()).toEqual([])
61
+ })
62
+
63
+ it("should return list of devices from MIDI connection", () => {
64
+ const mockDevices = [
65
+ { id: "1", name: "Device 1", manufacturer: "Company A" },
66
+ { id: "2", name: "Device 2", manufacturer: "Company B" },
67
+ ]
68
+ const mockMidi = {
69
+ connection: {
70
+ getOutputs: () => mockDevices,
71
+ },
72
+ }
73
+ deviceManager.midi = mockMidi
74
+
75
+ expect(deviceManager.getOutputDevices()).toEqual(mockDevices)
76
+ })
77
+ })
78
+
79
+ describe("isDeviceConnected", () => {
80
+ it("should return false when no MIDI connection", () => {
81
+ expect(deviceManager.isDeviceConnected("Device 1")).toBe(false)
82
+ })
83
+
84
+ it("should return true if device is connected", () => {
85
+ const mockMidi = {
86
+ connection: {
87
+ getOutputs: () => [
88
+ { id: "1", name: "Device 1", manufacturer: "Company A" },
89
+ { id: "2", name: "Device 2", manufacturer: "Company B" },
90
+ ],
91
+ },
92
+ }
93
+ deviceManager.midi = mockMidi
94
+
95
+ expect(deviceManager.isDeviceConnected("Device 1")).toBe(true)
96
+ expect(deviceManager.isDeviceConnected("Device 3")).toBe(false)
97
+ })
98
+ })
99
+
100
+ describe("connectDeviceSelection", () => {
101
+ it("should connect to selected device", async () => {
102
+ const mockMidi = {
103
+ setOutput: vi.fn().mockResolvedValue(undefined),
104
+ getCurrentOutput: vi.fn().mockReturnValue({ name: "Device 1" }),
105
+ connection: {
106
+ on: vi.fn(),
107
+ disconnect: vi.fn(),
108
+ },
109
+ }
110
+ deviceManager.setMIDI(mockMidi)
111
+
112
+ const select = document.createElement("select")
113
+ select.innerHTML =
114
+ '<option value="">Select a device</option><option value="0">Device 1</option><option value="1">Device 2</option>'
115
+
116
+ let connectedDevice = null
117
+ deviceManager.connectDeviceSelection(select, async (_midi, device) => {
118
+ connectedDevice = device
119
+ })
120
+
121
+ // Simulate selecting device
122
+ select.value = "0"
123
+ select.dispatchEvent(new Event("change"))
124
+
125
+ // Wait for async operation
126
+ await new Promise((resolve) => setTimeout(resolve, 10))
127
+
128
+ expect(mockMidi.setOutput).toHaveBeenCalledWith(0)
129
+ expect(connectedDevice).toEqual({ name: "Device 1" })
130
+ expect(deviceManager.currentDevice).toEqual({ name: "Device 1" })
131
+ })
132
+
133
+ it("should disconnect when selecting empty option", async () => {
134
+ const mockMidi = {
135
+ setOutput: vi.fn(),
136
+ getCurrentOutput: vi.fn(),
137
+ connection: {
138
+ on: vi.fn(),
139
+ disconnect: vi.fn(),
140
+ },
141
+ }
142
+ deviceManager.setMIDI(mockMidi)
143
+ deviceManager.currentDevice = { name: "Device 1" }
144
+
145
+ const select = document.createElement("select")
146
+ deviceManager.connectDeviceSelection(select)
147
+
148
+ // Select empty option
149
+ select.value = ""
150
+ select.dispatchEvent(new Event("change"))
151
+
152
+ await new Promise((resolve) => setTimeout(resolve, 10))
153
+
154
+ expect(mockMidi.connection.disconnect).toHaveBeenCalled()
155
+ expect(deviceManager.currentDevice).toBe(null)
156
+ expect(statusUpdates).toContainEqual({
157
+ message: "Disconnected",
158
+ state: "",
159
+ })
160
+ })
161
+
162
+ it("should prevent concurrent connections", async () => {
163
+ const mockMidi = {
164
+ setOutput: vi
165
+ .fn()
166
+ .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 50))),
167
+ getCurrentOutput: vi.fn().mockReturnValue({ name: "Device 1" }),
168
+ connection: {
169
+ on: vi.fn(),
170
+ disconnect: vi.fn(),
171
+ },
172
+ }
173
+ deviceManager.setMIDI(mockMidi)
174
+
175
+ const select = document.createElement("select")
176
+ select.innerHTML =
177
+ '<option value="">Select a device</option><option value="0">Device 1</option>'
178
+
179
+ let connectCount = 0
180
+ deviceManager.connectDeviceSelection(select, async () => {
181
+ connectCount++
182
+ await new Promise((resolve) => setTimeout(resolve, 10))
183
+ })
184
+
185
+ // Trigger change twice rapidly
186
+ select.value = "0"
187
+ select.dispatchEvent(new Event("change"))
188
+ select.dispatchEvent(new Event("change"))
189
+
190
+ await new Promise((resolve) => setTimeout(resolve, 100))
191
+
192
+ expect(connectCount).toBe(1) // Should only connect once
193
+ })
194
+ })
195
+
196
+ describe("connectChannelSelection", () => {
197
+ it("should update channel when selection changes", () => {
198
+ const mockMidi = {
199
+ options: { channel: 1 },
200
+ }
201
+ deviceManager.setMIDI(mockMidi)
202
+
203
+ const select = document.createElement("select")
204
+ select.innerHTML = ""
205
+ for (let i = 1; i <= 16; i++) {
206
+ const option = document.createElement("option")
207
+ option.value = i
208
+ option.textContent = i
209
+ select.appendChild(option)
210
+ }
211
+
212
+ deviceManager.connectChannelSelection(select)
213
+
214
+ select.value = "5"
215
+ select.dispatchEvent(new Event("change"))
216
+
217
+ expect(mockMidi.options.channel).toBe(5)
218
+ })
219
+ })
220
+
221
+ describe("integration with createMIDIController", () => {
222
+ it("should work with real MIDIController", async () => {
223
+ // Mock navigator.requestMIDIAccess
224
+ global.navigator.requestMIDIAccess = vi.fn().mockResolvedValue({
225
+ outputs: new Map(),
226
+ inputs: new Map(),
227
+ onstatechange: null,
228
+ })
229
+
230
+ const midi = await createMIDIController({ autoConnect: false, channel: 3 })
231
+ deviceManager.setMIDI(midi)
232
+
233
+ expect(midi.options.channel).toBe(3)
234
+ expect(deviceManager.midi).toBe(midi)
235
+ })
236
+ })
237
+
238
+ describe("updateStatus", () => {
239
+ it("should call onStatusUpdate with message and state", () => {
240
+ deviceManager.updateStatus("Test message", "test-state")
241
+
242
+ expect(statusUpdates).toContainEqual({
243
+ message: "Test message",
244
+ state: "test-state",
245
+ })
246
+ })
247
+
248
+ it("should call onStatusUpdate with message and empty state", () => {
249
+ deviceManager.updateStatus("Test message")
250
+
251
+ expect(statusUpdates).toContainEqual({
252
+ message: "Test message",
253
+ state: "",
254
+ })
255
+ })
256
+ })
257
+
258
+ describe("updateConnectionStatus", () => {
259
+ it("should call onConnectionUpdate with current device and midi", () => {
260
+ const mockMidi = { connection: {} }
261
+ const mockDevice = { name: "Test Device" }
262
+
263
+ deviceManager.setMIDI(mockMidi)
264
+ deviceManager.currentDevice = mockDevice
265
+
266
+ deviceManager.updateConnectionStatus()
267
+
268
+ expect(connectionUpdates).toContainEqual({
269
+ device: mockDevice,
270
+ midi: mockMidi,
271
+ })
272
+ })
273
+
274
+ it("should call onConnectionUpdate with null device", () => {
275
+ const mockMidi = { connection: {} }
276
+
277
+ deviceManager.setMIDI(mockMidi)
278
+ deviceManager.currentDevice = null
279
+
280
+ deviceManager.updateConnectionStatus()
281
+
282
+ expect(connectionUpdates).toContainEqual({
283
+ device: null,
284
+ midi: mockMidi,
285
+ })
286
+ })
287
+ })
288
+
289
+ describe("setupDeviceListeners", () => {
290
+ it("should set up device connection event listeners", () => {
291
+ const mockConnection = new EventEmitter()
292
+ const mockMidi = { connection: mockConnection }
293
+
294
+ deviceManager.setMIDI(mockMidi)
295
+
296
+ const onDeviceListChange = vi.fn()
297
+ deviceManager.setupDeviceListeners(onDeviceListChange)
298
+
299
+ // Emit OUTPUT_DEVICE_CONNECTED event
300
+ const device = { name: "Device 1", id: "1" }
301
+ mockConnection.emit(CONNECTION_EVENTS.OUTPUT_DEVICE_CONNECTED, { device })
302
+
303
+ expect(statusUpdates).toContainEqual({
304
+ message: "Device connected: Device 1",
305
+ state: "connected",
306
+ })
307
+ expect(onDeviceListChange).toHaveBeenCalled()
308
+ })
309
+
310
+ it("should set up device disconnection event listeners", () => {
311
+ const mockConnection = new EventEmitter()
312
+ const mockMidi = { connection: mockConnection }
313
+
314
+ deviceManager.setMIDI(mockMidi)
315
+ deviceManager.currentDevice = { name: "Device 1" }
316
+
317
+ const onDeviceListChange = vi.fn()
318
+ deviceManager.setupDeviceListeners(onDeviceListChange)
319
+
320
+ // Emit OUTPUT_DEVICE_DISCONNECTED event
321
+ const device = { name: "Device 1", id: "1" }
322
+ mockConnection.emit(CONNECTION_EVENTS.OUTPUT_DEVICE_DISCONNECTED, { device })
323
+
324
+ expect(statusUpdates).toContainEqual({
325
+ message: "Device disconnected: Device 1",
326
+ state: "error",
327
+ })
328
+ expect(deviceManager.currentDevice).toBe(null)
329
+ expect(onDeviceListChange).toHaveBeenCalled()
330
+ })
331
+
332
+ it("should not clear currentDevice if different device disconnected", () => {
333
+ const mockConnection = new EventEmitter()
334
+ const mockMidi = { connection: mockConnection }
335
+
336
+ deviceManager.setMIDI(mockMidi)
337
+ deviceManager.currentDevice = { name: "Device 1" }
338
+
339
+ const onDeviceListChange = vi.fn()
340
+ deviceManager.setupDeviceListeners(onDeviceListChange)
341
+
342
+ // Emit OUTPUT_DEVICE_DISCONNECTED for different device
343
+ const device = { name: "Device 2", id: "2" }
344
+ mockConnection.emit(CONNECTION_EVENTS.OUTPUT_DEVICE_DISCONNECTED, { device })
345
+
346
+ expect(deviceManager.currentDevice).toEqual({ name: "Device 1" })
347
+ expect(onDeviceListChange).toHaveBeenCalled()
348
+ })
349
+
350
+ it("should return early if no MIDI connection", () => {
351
+ deviceManager.midi = null
352
+
353
+ const onDeviceListChange = vi.fn()
354
+ deviceManager.setupDeviceListeners(onDeviceListChange)
355
+
356
+ // Should not throw error and onDeviceListChange should not be called
357
+ expect(onDeviceListChange).not.toHaveBeenCalled()
358
+ })
359
+ })
360
+
361
+ describe("populateDeviceList", () => {
362
+ it("should populate select element with devices", () => {
363
+ const mockMidi = {
364
+ connection: {
365
+ getOutputs: () => [
366
+ { id: "1", name: "Device 1", manufacturer: "Company A" },
367
+ { id: "2", name: "Device 2", manufacturer: "Company B" },
368
+ ],
369
+ },
370
+ }
371
+ deviceManager.setMIDI(mockMidi)
372
+
373
+ const select = document.createElement("select")
374
+ deviceManager.populateDeviceList(select)
375
+
376
+ expect(select.innerHTML).toContain('value=""')
377
+ expect(select.innerHTML).toContain('value="0"')
378
+ expect(select.innerHTML).toContain("Device 1")
379
+ expect(select.innerHTML).toContain("Device 2")
380
+ expect(statusUpdates).toContainEqual({
381
+ message: "Select a MIDI device",
382
+ state: "",
383
+ })
384
+ })
385
+
386
+ it("should show no devices message when no outputs", () => {
387
+ const mockMidi = {
388
+ connection: {
389
+ getOutputs: () => [],
390
+ },
391
+ }
392
+ deviceManager.setMIDI(mockMidi)
393
+
394
+ const select = document.createElement("select")
395
+ deviceManager.populateDeviceList(select)
396
+
397
+ expect(select.innerHTML).toContain("No MIDI devices found")
398
+ expect(statusUpdates).toContainEqual({
399
+ message: "No MIDI devices available",
400
+ state: "error",
401
+ })
402
+ })
403
+
404
+ it("should call onChange callback if provided", () => {
405
+ const mockMidi = {
406
+ connection: {
407
+ getOutputs: () => [{ id: "1", name: "Device 1", manufacturer: "Company A" }],
408
+ },
409
+ }
410
+ deviceManager.setMIDI(mockMidi)
411
+
412
+ const onChange = vi.fn()
413
+ const select = document.createElement("select")
414
+
415
+ deviceManager.populateDeviceList(select, onChange)
416
+
417
+ expect(onChange).toHaveBeenCalled()
418
+ })
419
+
420
+ it("should return early if no select element", () => {
421
+ const mockMidi = {
422
+ connection: {
423
+ getOutputs: () => [{ id: "1", name: "Device 1", manufacturer: "Company A" }],
424
+ },
425
+ }
426
+ deviceManager.setMIDI(mockMidi)
427
+
428
+ statusUpdates = []
429
+ deviceManager.populateDeviceList(null)
430
+
431
+ expect(statusUpdates).toEqual([]) // No status updates should occur
432
+ })
433
+
434
+ it("should keep current device selected if still available", () => {
435
+ const mockMidi = {
436
+ connection: {
437
+ getOutputs: () => [
438
+ { id: "1", name: "Device 1", manufacturer: "Company A" },
439
+ { id: "2", name: "Device 2", manufacturer: "Company B" },
440
+ ],
441
+ },
442
+ }
443
+ deviceManager.setMIDI(mockMidi)
444
+ deviceManager.currentDevice = { name: "Device 1" }
445
+
446
+ const select = document.createElement("select")
447
+ deviceManager.populateDeviceList(select)
448
+
449
+ expect(select.value).toBe("0") // Device 1 is at index 0
450
+ expect(deviceManager.currentDevice).toEqual({ name: "Device 1" })
451
+ })
452
+
453
+ it("should clear selection and currentDevice if device disconnected", () => {
454
+ const mockMidi = {
455
+ connection: {
456
+ getOutputs: () => [{ id: "2", name: "Device 2", manufacturer: "Company B" }],
457
+ },
458
+ }
459
+ deviceManager.setMIDI(mockMidi)
460
+ deviceManager.currentDevice = { name: "Device 1" }
461
+
462
+ statusUpdates = []
463
+ connectionUpdates = []
464
+ const select = document.createElement("select")
465
+ deviceManager.populateDeviceList(select)
466
+
467
+ expect(select.value).toBe("") // Selection cleared
468
+ expect(deviceManager.currentDevice).toBe(null) // Current device cleared
469
+ expect(connectionUpdates).toContainEqual({
470
+ device: null,
471
+ midi: mockMidi,
472
+ })
473
+ })
474
+ })
475
+ })
476
+
477
+ describe("createMIDIDeviceManager", () => {
478
+ let originalNavigator
479
+ let _coverageCallback
480
+ let mockOutput
481
+
482
+ beforeEach(() => {
483
+ originalNavigator = global.navigator
484
+ _coverageCallback = vi.fn()
485
+
486
+ mockOutput = {
487
+ id: "test-output-1",
488
+ name: "Test Output Device",
489
+ manufacturer: "Test Manufacturer",
490
+ state: "connected",
491
+ send: vi.fn(),
492
+ }
493
+
494
+ global.navigator = {
495
+ requestMIDIAccess: vi.fn().mockResolvedValue({
496
+ outputs: new Map([
497
+ ["test-output-1", mockOutput],
498
+ [
499
+ "test-output-2",
500
+ {
501
+ id: "test-output-2",
502
+ name: "Test Output 2",
503
+ manufacturer: "Test Manufacturer",
504
+ state: "connected",
505
+ send: vi.fn(),
506
+ },
507
+ ],
508
+ ]),
509
+ inputs: new Map(),
510
+ onstatechange: null,
511
+ addEventListener: vi.fn(),
512
+ }),
513
+ }
514
+ })
515
+
516
+ afterEach(() => {
517
+ vi.clearAllMocks()
518
+ global.navigator = originalNavigator
519
+ })
520
+
521
+ it("should create a MIDIDeviceManager with integrated MIDIController", async () => {
522
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
523
+
524
+ const deviceManager = await createMIDIDeviceManager({
525
+ channel: 2,
526
+ })
527
+
528
+ expect(deviceManager).toBeInstanceOf(MIDIDeviceManager)
529
+ expect(deviceManager.midi).toBeDefined()
530
+ expect(deviceManager.midi.options.channel).toBe(2)
531
+ })
532
+
533
+ it("should auto-connect to specified device if output option provided", async () => {
534
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
535
+
536
+ const deviceManager = await createMIDIDeviceManager({
537
+ output: "Test Output Device",
538
+ channel: 1,
539
+ })
540
+
541
+ expect(deviceManager.currentDevice).toEqual({
542
+ id: "test-output-1",
543
+ name: "Test Output Device",
544
+ manufacturer: "Test Manufacturer",
545
+ })
546
+ // Device is connected if currentDevice is set
547
+ })
548
+
549
+ it("should call onReady callback when initialization completes", async () => {
550
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
551
+
552
+ const onReady = vi.fn()
553
+
554
+ const deviceManager = await createMIDIDeviceManager({
555
+ onReady,
556
+ channel: 1,
557
+ })
558
+
559
+ expect(onReady).toHaveBeenCalledWith(deviceManager.midi, deviceManager)
560
+ })
561
+
562
+ it("should handle onStatusUpdate callback", async () => {
563
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
564
+
565
+ const statusUpdates = []
566
+ const deviceManager = await createMIDIDeviceManager({
567
+ onStatusUpdate: (message, state) => {
568
+ statusUpdates.push({ message, state })
569
+ },
570
+ channel: 1,
571
+ })
572
+
573
+ deviceManager.updateStatus("Test status", "connected")
574
+
575
+ expect(statusUpdates).toContainEqual({ message: "Test status", state: "connected" })
576
+ })
577
+
578
+ it("should handle onConnectionUpdate callback", async () => {
579
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
580
+
581
+ const connectionUpdates = []
582
+ const deviceManager = await createMIDIDeviceManager({
583
+ onConnectionUpdate: (device, midi) => {
584
+ connectionUpdates.push({ device, midi })
585
+ },
586
+ channel: 1,
587
+ })
588
+
589
+ // Simulate a connection update
590
+ deviceManager.onConnectionUpdate({ name: "Test Device" }, deviceManager.midi)
591
+
592
+ expect(connectionUpdates).toContainEqual({
593
+ device: { name: "Test Device" },
594
+ midi: deviceManager.midi,
595
+ })
596
+ })
597
+
598
+ it("should pass through options to MIDIController", async () => {
599
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
600
+
601
+ const deviceManager = await createMIDIDeviceManager({
602
+ channel: 5,
603
+ sysex: true,
604
+ selector: "[data-midi-cc]",
605
+ watchDOM: true,
606
+ })
607
+
608
+ expect(deviceManager.midi.options.channel).toBe(5)
609
+ // sysex is passed to MIDIConnection constructor
610
+ expect(deviceManager.midi.connection.options.sysex).toBe(true)
611
+ expect(deviceManager.midi._binder).toBeDefined() // Auto-binding was enabled
612
+ })
613
+
614
+ it("should handle auto-connect errors gracefully", async () => {
615
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
616
+
617
+ const onError = vi.fn()
618
+
619
+ const deviceManager = await createMIDIDeviceManager({
620
+ output: "Non-existent Device",
621
+ onError,
622
+ channel: 1,
623
+ })
624
+
625
+ expect(onError).toHaveBeenCalled()
626
+ expect(deviceManager.currentDevice).toBe(null)
627
+ })
628
+
629
+ it("should use default values when options not provided", async () => {
630
+ const { createMIDIDeviceManager } = await import("../../src/index.js")
631
+
632
+ const deviceManager = await createMIDIDeviceManager()
633
+
634
+ expect(deviceManager.midi.options.channel).toBe(1)
635
+ // sysex option is passed to MIDIConnection, not stored in options
636
+ expect(deviceManager.channel).toBe(1)
637
+ })
638
+ })