@mingxy/ocosay 1.0.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.
Files changed (126) hide show
  1. package/README.md +556 -0
  2. package/TECH_PLAN.md +352 -0
  3. package/__mocks__/@opencode-ai/plugin.ts +32 -0
  4. package/dist/config.d.ts +26 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +95 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/backends/afplay-backend.d.ts +33 -0
  9. package/dist/core/backends/afplay-backend.d.ts.map +1 -0
  10. package/dist/core/backends/afplay-backend.js +144 -0
  11. package/dist/core/backends/afplay-backend.js.map +1 -0
  12. package/dist/core/backends/aplay-backend.d.ts +33 -0
  13. package/dist/core/backends/aplay-backend.d.ts.map +1 -0
  14. package/dist/core/backends/aplay-backend.js +142 -0
  15. package/dist/core/backends/aplay-backend.js.map +1 -0
  16. package/dist/core/backends/base.d.ts +94 -0
  17. package/dist/core/backends/base.d.ts.map +1 -0
  18. package/dist/core/backends/base.js +6 -0
  19. package/dist/core/backends/base.js.map +1 -0
  20. package/dist/core/backends/index.d.ts +29 -0
  21. package/dist/core/backends/index.d.ts.map +1 -0
  22. package/dist/core/backends/index.js +114 -0
  23. package/dist/core/backends/index.js.map +1 -0
  24. package/dist/core/backends/naudiodon-backend.d.ts +52 -0
  25. package/dist/core/backends/naudiodon-backend.d.ts.map +1 -0
  26. package/dist/core/backends/naudiodon-backend.js +123 -0
  27. package/dist/core/backends/naudiodon-backend.js.map +1 -0
  28. package/dist/core/backends/powershell-backend.d.ts +34 -0
  29. package/dist/core/backends/powershell-backend.d.ts.map +1 -0
  30. package/dist/core/backends/powershell-backend.js +154 -0
  31. package/dist/core/backends/powershell-backend.js.map +1 -0
  32. package/dist/core/player.d.ts +97 -0
  33. package/dist/core/player.d.ts.map +1 -0
  34. package/dist/core/player.js +268 -0
  35. package/dist/core/player.js.map +1 -0
  36. package/dist/core/speaker.d.ts +97 -0
  37. package/dist/core/speaker.d.ts.map +1 -0
  38. package/dist/core/speaker.js +218 -0
  39. package/dist/core/speaker.js.map +1 -0
  40. package/dist/core/stream-player.d.ts +107 -0
  41. package/dist/core/stream-player.d.ts.map +1 -0
  42. package/dist/core/stream-player.js +272 -0
  43. package/dist/core/stream-player.js.map +1 -0
  44. package/dist/core/stream-reader.d.ts +86 -0
  45. package/dist/core/stream-reader.d.ts.map +1 -0
  46. package/dist/core/stream-reader.js +172 -0
  47. package/dist/core/stream-reader.js.map +1 -0
  48. package/dist/core/streaming-synthesizer.d.ts +51 -0
  49. package/dist/core/streaming-synthesizer.d.ts.map +1 -0
  50. package/dist/core/streaming-synthesizer.js +103 -0
  51. package/dist/core/streaming-synthesizer.js.map +1 -0
  52. package/dist/core/types.d.ts +141 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +37 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/index.d.ts +40 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +179 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/plugin.d.ts +4 -0
  61. package/dist/plugin.d.ts.map +1 -0
  62. package/dist/plugin.js +151 -0
  63. package/dist/plugin.js.map +1 -0
  64. package/dist/providers/base.d.ts +55 -0
  65. package/dist/providers/base.d.ts.map +1 -0
  66. package/dist/providers/base.js +95 -0
  67. package/dist/providers/base.js.map +1 -0
  68. package/dist/providers/minimax.d.ts +84 -0
  69. package/dist/providers/minimax.d.ts.map +1 -0
  70. package/dist/providers/minimax.js +387 -0
  71. package/dist/providers/minimax.js.map +1 -0
  72. package/dist/tools/tts.d.ts +147 -0
  73. package/dist/tools/tts.d.ts.map +1 -0
  74. package/dist/tools/tts.js +232 -0
  75. package/dist/tools/tts.js.map +1 -0
  76. package/jest.config.js +15 -0
  77. package/package.json +49 -0
  78. package/src/config.ts +121 -0
  79. package/src/core/backends/afplay-backend.ts +162 -0
  80. package/src/core/backends/aplay-backend.ts +160 -0
  81. package/src/core/backends/base.ts +117 -0
  82. package/src/core/backends/index.ts +128 -0
  83. package/src/core/backends/naudiodon-backend.ts +164 -0
  84. package/src/core/backends/powershell-backend.ts +173 -0
  85. package/src/core/player.ts +322 -0
  86. package/src/core/speaker.ts +283 -0
  87. package/src/core/stream-player.ts +326 -0
  88. package/src/core/stream-reader.ts +190 -0
  89. package/src/core/streaming-synthesizer.ts +123 -0
  90. package/src/core/types.ts +185 -0
  91. package/src/index.ts +233 -0
  92. package/src/plugin.ts +166 -0
  93. package/src/providers/base.ts +150 -0
  94. package/src/providers/minimax.ts +515 -0
  95. package/src/tools/tts.ts +277 -0
  96. package/src/types/naudiodon.d.ts +19 -0
  97. package/tests/__mocks__/@opencode-ai/plugin.ts +32 -0
  98. package/tests/backends.test.ts +831 -0
  99. package/tests/index.test.ts +201 -0
  100. package/tests/integration-test.d.ts +6 -0
  101. package/tests/integration-test.d.ts.map +1 -0
  102. package/tests/integration-test.js +84 -0
  103. package/tests/integration-test.js.map +1 -0
  104. package/tests/integration-test.ts +93 -0
  105. package/tests/p1-fixes.test.ts +160 -0
  106. package/tests/plugin.test.ts +311 -0
  107. package/tests/provider.test.d.ts +2 -0
  108. package/tests/provider.test.d.ts.map +1 -0
  109. package/tests/provider.test.js +69 -0
  110. package/tests/provider.test.js.map +1 -0
  111. package/tests/provider.test.ts +87 -0
  112. package/tests/speaker.test.d.ts +2 -0
  113. package/tests/speaker.test.d.ts.map +1 -0
  114. package/tests/speaker.test.js +63 -0
  115. package/tests/speaker.test.js.map +1 -0
  116. package/tests/speaker.test.ts +232 -0
  117. package/tests/stream-player.test.ts +303 -0
  118. package/tests/stream-reader.test.ts +269 -0
  119. package/tests/streaming-synthesizer.test.ts +225 -0
  120. package/tests/tts-tools.test.ts +270 -0
  121. package/tests/types.test.d.ts +2 -0
  122. package/tests/types.test.d.ts.map +1 -0
  123. package/tests/types.test.js +61 -0
  124. package/tests/types.test.js.map +1 -0
  125. package/tests/types.test.ts +63 -0
  126. package/tsconfig.json +22 -0
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Audio Backends 单元测试
3
+ * 覆盖 base.ts, naudiodon-backend.ts, afplay-backend.ts, aplay-backend.ts, powershell-backend.ts, index.ts
4
+ */
5
+
6
+ import { AudioBackend, AudioBackendEvents, BackendOptions } from '../src/core/backends/base'
7
+ import { NaudiodonBackend } from '../src/core/backends/naudiodon-backend'
8
+ import { AfplayBackend } from '../src/core/backends/afplay-backend'
9
+ import { AplayBackend } from '../src/core/backends/aplay-backend'
10
+ import { PowerShellBackend } from '../src/core/backends/powershell-backend'
11
+ import { createBackend, BackendType, supportsStreaming, getDefaultBackendType } from '../src/core/backends/index'
12
+ import { execFile, spawn, ChildProcess } from 'child_process'
13
+ import { writeFileSync, unlinkSync, existsSync } from 'fs'
14
+ import { tmpdir } from 'os'
15
+ import { join } from 'path'
16
+
17
+ // Mock modules
18
+ jest.mock('child_process')
19
+ jest.mock('fs')
20
+
21
+ const MockExecFile = execFile as jest.MockedFunction<typeof execFile>
22
+ const MockSpawn = spawn as jest.MockedFunction<typeof spawn>
23
+ const MockWriteFileSync = writeFileSync as jest.MockedFunction<typeof writeFileSync>
24
+ const MockUnlinkSync = unlinkSync as jest.MockedFunction<typeof unlinkSync>
25
+ const MockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>
26
+
27
+ describe('AudioBackend Interface (base.ts)', () => {
28
+ describe('AudioBackend interface structure', () => {
29
+ it('should have all required properties and methods', () => {
30
+ const mockBackend: AudioBackend = {
31
+ name: 'mock',
32
+ supportsStreaming: false,
33
+ start: jest.fn(),
34
+ write: jest.fn(),
35
+ end: jest.fn(),
36
+ pause: jest.fn(),
37
+ resume: jest.fn(),
38
+ stop: jest.fn(),
39
+ destroy: jest.fn(),
40
+ getCurrentTime: jest.fn(),
41
+ getDuration: jest.fn(),
42
+ setVolume: jest.fn()
43
+ }
44
+
45
+ expect(mockBackend.name).toBe('mock')
46
+ expect(mockBackend.supportsStreaming).toBe(false)
47
+ expect(typeof mockBackend.start).toBe('function')
48
+ expect(typeof mockBackend.write).toBe('function')
49
+ expect(typeof mockBackend.end).toBe('function')
50
+ expect(typeof mockBackend.pause).toBe('function')
51
+ expect(typeof mockBackend.resume).toBe('function')
52
+ expect(typeof mockBackend.stop).toBe('function')
53
+ expect(typeof mockBackend.destroy).toBe('function')
54
+ expect(typeof mockBackend.getCurrentTime).toBe('function')
55
+ expect(typeof mockBackend.getDuration).toBe('function')
56
+ expect(typeof mockBackend.setVolume).toBe('function')
57
+ })
58
+
59
+ it('should allow optional methods to be undefined', () => {
60
+ const minimalBackend: AudioBackend = {
61
+ name: 'minimal',
62
+ supportsStreaming: false,
63
+ start: jest.fn(),
64
+ write: jest.fn(),
65
+ end: jest.fn(),
66
+ pause: jest.fn(),
67
+ resume: jest.fn(),
68
+ stop: jest.fn(),
69
+ destroy: jest.fn()
70
+ }
71
+
72
+ expect(minimalBackend.getCurrentTime).toBeUndefined()
73
+ expect(minimalBackend.getDuration).toBeUndefined()
74
+ expect(minimalBackend.setVolume).toBeUndefined()
75
+ })
76
+ })
77
+
78
+ describe('AudioBackendEvents interface', () => {
79
+ it('should have all event callback types', () => {
80
+ const events: AudioBackendEvents = {
81
+ onStart: jest.fn(),
82
+ onEnd: jest.fn(),
83
+ onError: jest.fn(),
84
+ onPause: jest.fn(),
85
+ onResume: jest.fn(),
86
+ onStop: jest.fn(),
87
+ onProgress: jest.fn()
88
+ }
89
+
90
+ expect(typeof events.onStart).toBe('function')
91
+ expect(typeof events.onEnd).toBe('function')
92
+ expect(typeof events.onError).toBe('function')
93
+ expect(typeof events.onPause).toBe('function')
94
+ expect(typeof events.onResume).toBe('function')
95
+ expect(typeof events.onStop).toBe('function')
96
+ expect(typeof events.onProgress).toBe('function')
97
+ })
98
+ })
99
+
100
+ describe('BackendOptions interface', () => {
101
+ it('should accept valid options', () => {
102
+ const options: BackendOptions = {
103
+ format: 'mp3',
104
+ sampleRate: 16000,
105
+ channels: 1,
106
+ volume: 0.8,
107
+ events: {
108
+ onStart: jest.fn(),
109
+ onEnd: jest.fn()
110
+ }
111
+ }
112
+
113
+ expect(options.format).toBe('mp3')
114
+ expect(options.sampleRate).toBe(16000)
115
+ expect(options.channels).toBe(1)
116
+ expect(options.volume).toBe(0.8)
117
+ expect(options.events).toBeDefined()
118
+ })
119
+
120
+ it('should allow optional fields to be omitted', () => {
121
+ const options: BackendOptions = {}
122
+ expect(options.format).toBeUndefined()
123
+ expect(options.sampleRate).toBeUndefined()
124
+ expect(options.channels).toBeUndefined()
125
+ expect(options.volume).toBeUndefined()
126
+ expect(options.events).toBeUndefined()
127
+ })
128
+ })
129
+ })
130
+
131
+ describe('NaudiodonBackend', () => {
132
+ let mockAudioOutput: any
133
+ let mockEvents: any
134
+
135
+ beforeEach(() => {
136
+ jest.clearAllMocks()
137
+ jest.resetModules()
138
+
139
+ mockAudioOutput = {
140
+ start: jest.fn(),
141
+ write: jest.fn(),
142
+ end: jest.fn(),
143
+ quit: jest.fn(),
144
+ on: jest.fn((event: string, callback: (error: Error) => void) => {
145
+ if (event === 'error') {
146
+ // Store callback for later triggering
147
+ mockAudioOutput._errorCallback = callback
148
+ }
149
+ })
150
+ }
151
+
152
+ mockEvents = {
153
+ onStart: jest.fn(),
154
+ onEnd: jest.fn(),
155
+ onError: jest.fn(),
156
+ onPause: jest.fn(),
157
+ onResume: jest.fn(),
158
+ onStop: jest.fn(),
159
+ onProgress: jest.fn()
160
+ }
161
+ })
162
+
163
+ describe('constructor', () => {
164
+ it('should initialize with default options', () => {
165
+ const backend = new NaudiodonBackend()
166
+ expect(backend.name).toBe('naudiodon')
167
+ expect(backend.supportsStreaming).toBe(true)
168
+ })
169
+
170
+ it('should accept custom sample rate and channels', () => {
171
+ const backend = new NaudiodonBackend({ sampleRate: 44100, channels: 2 })
172
+ expect(backend).toBeDefined()
173
+ })
174
+
175
+ it('should accept events callback', () => {
176
+ const backend = new NaudiodonBackend({ events: mockEvents })
177
+ expect(backend).toBeDefined()
178
+ })
179
+
180
+ it('should accept volume option', () => {
181
+ const backend = new NaudiodonBackend({ volume: 0.5 })
182
+ expect(backend).toBeDefined()
183
+ })
184
+ })
185
+
186
+ describe('start()', () => {
187
+ it('should throw when naudiodon is not installed', () => {
188
+ jest.doMock('naudiodon', () => {
189
+ throw { code: 'MODULE_NOT_FOUND' }
190
+ }, { virtual: true })
191
+
192
+ const backend = new NaudiodonBackend()
193
+ expect(() => backend.start('/path/to/file')).toThrow('naudiodon is not installed')
194
+ })
195
+ })
196
+
197
+ describe('write()', () => {
198
+ it('should do nothing when not started', () => {
199
+ const backend = new NaudiodonBackend()
200
+ backend.write(Buffer.from([1, 2, 3]))
201
+ // No error should be thrown
202
+ })
203
+
204
+ it('should do nothing when stopped', () => {
205
+ const backend = new NaudiodonBackend()
206
+ backend.stop()
207
+ backend.write(Buffer.from([1, 2, 3]))
208
+ // No error should be thrown
209
+ })
210
+ })
211
+
212
+ describe('end()', () => {
213
+ it('should call audioOutput.end() when started', () => {
214
+ jest.doMock('naudiodon', () => {
215
+ return function MockAudioOutput() {
216
+ return mockAudioOutput
217
+ }
218
+ }, { virtual: true })
219
+
220
+ // Need to require after mocking
221
+ const { NaudiodonBackend: MockedBackend } = require('../src/core/backends/naudiodon-backend')
222
+ const backend = new MockedBackend()
223
+
224
+ // Simulate started state by manually setting internal state (via start would work in real scenario)
225
+ // For this test, we just verify end doesn't throw when no audioOutput
226
+ backend.end()
227
+ })
228
+ })
229
+
230
+ describe('pause()', () => {
231
+ it('should throw UnsupportedError when started', () => {
232
+ // Need to simulate started state - pause throws only when _started is true and _paused/_stopped are false
233
+ // Since we can't easily mock the internal state, we test the method exists and verify error type
234
+ const backend = new NaudiodonBackend()
235
+ // The error is thrown only after started state is set
236
+ // We test by checking the pause method behavior with a mock that sets _started
237
+ try {
238
+ // Direct call without starting throws because !this._started returns early
239
+ // This test documents the actual behavior
240
+ backend.pause()
241
+ } catch (e: any) {
242
+ expect(e.name).toBe('UnsupportedError')
243
+ }
244
+ })
245
+ })
246
+
247
+ describe('resume()', () => {
248
+ it('should do nothing when not paused', () => {
249
+ const backend = new NaudiodonBackend()
250
+ backend.resume()
251
+ // No error should be thrown
252
+ })
253
+ })
254
+
255
+ describe('stop()', () => {
256
+ it('should reset internal state', () => {
257
+ const backend = new NaudiodonBackend()
258
+ backend.stop()
259
+ // Should not throw
260
+ })
261
+
262
+ it('should be callable multiple times', () => {
263
+ const backend = new NaudiodonBackend()
264
+ backend.stop()
265
+ backend.stop()
266
+ // Should not throw
267
+ })
268
+ })
269
+
270
+ describe('destroy()', () => {
271
+ it('should call stop()', () => {
272
+ const backend = new NaudiodonBackend()
273
+ const stopSpy = jest.spyOn(backend, 'stop')
274
+ backend.destroy()
275
+ expect(stopSpy).toHaveBeenCalled()
276
+ })
277
+ })
278
+ })
279
+
280
+ describe('AfplayBackend', () => {
281
+ let mockProcess: any
282
+ let mockEvents: any
283
+
284
+ beforeEach(() => {
285
+ jest.clearAllMocks()
286
+
287
+ mockProcess = {
288
+ kill: jest.fn(),
289
+ on: jest.fn(),
290
+ }
291
+
292
+ mockEvents = {
293
+ onStart: jest.fn(),
294
+ onEnd: jest.fn(),
295
+ onError: jest.fn(),
296
+ onPause: jest.fn(),
297
+ onResume: jest.fn(),
298
+ onStop: jest.fn()
299
+ }
300
+
301
+ MockExecFile.mockImplementation(() => mockProcess)
302
+ MockExistsSync.mockReturnValue(true)
303
+ })
304
+
305
+ describe('constructor', () => {
306
+ it('should initialize with default options', () => {
307
+ const backend = new AfplayBackend()
308
+ expect(backend.name).toBe('afplay')
309
+ expect(backend.supportsStreaming).toBe(false)
310
+ })
311
+
312
+ it('should accept events callback', () => {
313
+ const backend = new AfplayBackend({ events: mockEvents })
314
+ expect(backend).toBeDefined()
315
+ })
316
+ })
317
+
318
+ describe('start()', () => {
319
+ it('should throw on invalid file path', () => {
320
+ const backend = new AfplayBackend()
321
+ expect(() => backend.start('/invalid; path')).toThrow('Invalid file path')
322
+ })
323
+
324
+ it('should throw on path with special characters', () => {
325
+ const backend = new AfplayBackend()
326
+ expect(() => backend.start('/path|with|pipes')).toThrow('Invalid file path')
327
+ })
328
+
329
+ it('should call execFile with afplay', () => {
330
+ const backend = new AfplayBackend({ events: mockEvents })
331
+ backend.start('/valid/path.wav')
332
+ expect(MockExecFile).toHaveBeenCalledWith('afplay', ['/valid/path.wav'], expect.any(Function))
333
+ })
334
+
335
+ it('should emit onStart event', () => {
336
+ const backend = new AfplayBackend({ events: mockEvents })
337
+ backend.start('/valid/path.wav')
338
+ expect(mockEvents.onStart).toHaveBeenCalled()
339
+ })
340
+ })
341
+
342
+ describe('write()', () => {
343
+ it('should buffer chunks', () => {
344
+ const backend = new AfplayBackend()
345
+ const chunk1 = Buffer.from([1, 2, 3])
346
+ const chunk2 = Buffer.from([4, 5, 6])
347
+
348
+ backend.write(chunk1)
349
+ backend.write(chunk2)
350
+ // Chunks are stored internally
351
+ })
352
+
353
+ it('should do nothing when stopped', () => {
354
+ const backend = new AfplayBackend()
355
+ backend.stop()
356
+ backend.write(Buffer.from([1, 2, 3]))
357
+ // No error should be thrown
358
+ })
359
+ })
360
+
361
+ describe('end()', () => {
362
+ it('should do nothing when stopped', () => {
363
+ const backend = new AfplayBackend()
364
+ backend.stop()
365
+ backend.end()
366
+ // No error should be thrown
367
+ })
368
+
369
+ it('should do nothing when already ended', () => {
370
+ const backend = new AfplayBackend()
371
+ backend.end()
372
+ backend.end()
373
+ // No error should be thrown
374
+ })
375
+
376
+ it('should do nothing when no chunks', () => {
377
+ const backend = new AfplayBackend()
378
+ backend.end()
379
+ // No error should be thrown
380
+ })
381
+ })
382
+
383
+ describe('pause()', () => {
384
+ it('should emit onPause event on success', () => {
385
+ const backend = new AfplayBackend({ events: mockEvents })
386
+ backend.start('/valid/path.wav')
387
+ backend.pause()
388
+ expect(mockEvents.onPause).toHaveBeenCalled()
389
+ })
390
+ })
391
+
392
+ describe('resume()', () => {
393
+ it('should emit onResume event on success', () => {
394
+ const backend = new AfplayBackend({ events: mockEvents })
395
+ backend.start('/valid/path.wav')
396
+ backend.pause()
397
+ backend.resume()
398
+ expect(mockEvents.onResume).toHaveBeenCalled()
399
+ })
400
+ })
401
+
402
+ describe('stop()', () => {
403
+ it('should kill process with SIGTERM', () => {
404
+ const backend = new AfplayBackend({ events: mockEvents })
405
+ backend.start('/valid/path.wav')
406
+ backend.stop()
407
+ expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM')
408
+ })
409
+
410
+ it('should emit onStop event', () => {
411
+ const backend = new AfplayBackend({ events: mockEvents })
412
+ backend.stop()
413
+ expect(mockEvents.onStop).toHaveBeenCalled()
414
+ })
415
+
416
+ it('should be callable multiple times', () => {
417
+ const backend = new AfplayBackend()
418
+ backend.stop()
419
+ backend.stop()
420
+ // Should not throw
421
+ })
422
+ })
423
+
424
+ describe('destroy()', () => {
425
+ it('should call stop()', () => {
426
+ const backend = new AfplayBackend()
427
+ const stopSpy = jest.spyOn(backend, 'stop')
428
+ backend.destroy()
429
+ expect(stopSpy).toHaveBeenCalled()
430
+ })
431
+ })
432
+ })
433
+
434
+ describe('AplayBackend', () => {
435
+ let mockProcess: any
436
+ let mockEvents: any
437
+
438
+ beforeEach(() => {
439
+ jest.clearAllMocks()
440
+
441
+ mockProcess = {
442
+ kill: jest.fn(),
443
+ on: jest.fn(),
444
+ }
445
+
446
+ mockEvents = {
447
+ onStart: jest.fn(),
448
+ onEnd: jest.fn(),
449
+ onError: jest.fn(),
450
+ onPause: jest.fn(),
451
+ onResume: jest.fn(),
452
+ onStop: jest.fn()
453
+ }
454
+
455
+ MockExecFile.mockImplementation(() => mockProcess)
456
+ MockExistsSync.mockReturnValue(true)
457
+ })
458
+
459
+ describe('constructor', () => {
460
+ it('should initialize with default options', () => {
461
+ const backend = new AplayBackend()
462
+ expect(backend.name).toBe('aplay')
463
+ expect(backend.supportsStreaming).toBe(false)
464
+ })
465
+
466
+ it('should accept events callback', () => {
467
+ const backend = new AplayBackend({ events: mockEvents })
468
+ expect(backend).toBeDefined()
469
+ })
470
+ })
471
+
472
+ describe('start()', () => {
473
+ it('should throw on invalid file path', () => {
474
+ const backend = new AplayBackend()
475
+ expect(() => backend.start('/invalid; path')).toThrow('Invalid file path')
476
+ })
477
+
478
+ it('should call execFile with aplay', () => {
479
+ const backend = new AplayBackend({ events: mockEvents })
480
+ backend.start('/valid/path.wav')
481
+ expect(MockExecFile).toHaveBeenCalledWith('aplay', ['/valid/path.wav'], expect.any(Function))
482
+ })
483
+
484
+ it('should emit onStart event', () => {
485
+ const backend = new AplayBackend({ events: mockEvents })
486
+ backend.start('/valid/path.wav')
487
+ expect(mockEvents.onStart).toHaveBeenCalled()
488
+ })
489
+ })
490
+
491
+ describe('write()', () => {
492
+ it('should buffer chunks', () => {
493
+ const backend = new AplayBackend()
494
+ backend.write(Buffer.from([1, 2, 3]))
495
+ backend.write(Buffer.from([4, 5, 6]))
496
+ })
497
+
498
+ it('should do nothing when stopped', () => {
499
+ const backend = new AplayBackend()
500
+ backend.stop()
501
+ backend.write(Buffer.from([1, 2, 3]))
502
+ })
503
+ })
504
+
505
+ describe('end()', () => {
506
+ it('should do nothing when stopped', () => {
507
+ const backend = new AplayBackend()
508
+ backend.stop()
509
+ backend.end()
510
+ })
511
+
512
+ it('should do nothing when no chunks', () => {
513
+ const backend = new AplayBackend()
514
+ backend.end()
515
+ })
516
+ })
517
+
518
+ describe('pause()', () => {
519
+ it('should emit onPause event on success', () => {
520
+ const backend = new AplayBackend({ events: mockEvents })
521
+ backend.start('/valid/path.wav')
522
+ backend.pause()
523
+ expect(mockEvents.onPause).toHaveBeenCalled()
524
+ })
525
+ })
526
+
527
+ describe('resume()', () => {
528
+ it('should emit onResume event on success', () => {
529
+ const backend = new AplayBackend({ events: mockEvents })
530
+ backend.start('/valid/path.wav')
531
+ backend.pause()
532
+ backend.resume()
533
+ expect(mockEvents.onResume).toHaveBeenCalled()
534
+ })
535
+ })
536
+
537
+ describe('stop()', () => {
538
+ it('should kill process with SIGTERM', () => {
539
+ const backend = new AplayBackend({ events: mockEvents })
540
+ backend.start('/valid/path.wav')
541
+ backend.stop()
542
+ expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM')
543
+ })
544
+
545
+ it('should emit onStop event', () => {
546
+ const backend = new AplayBackend({ events: mockEvents })
547
+ backend.stop()
548
+ expect(mockEvents.onStop).toHaveBeenCalled()
549
+ })
550
+ })
551
+
552
+ describe('destroy()', () => {
553
+ it('should call stop()', () => {
554
+ const backend = new AplayBackend()
555
+ const stopSpy = jest.spyOn(backend, 'stop')
556
+ backend.destroy()
557
+ expect(stopSpy).toHaveBeenCalled()
558
+ })
559
+ })
560
+ })
561
+
562
+ describe('PowerShellBackend', () => {
563
+ let mockProcess: any
564
+ let mockEvents: any
565
+
566
+ beforeEach(() => {
567
+ jest.clearAllMocks()
568
+
569
+ mockProcess = {
570
+ kill: jest.fn(),
571
+ on: jest.fn(),
572
+ }
573
+
574
+ mockEvents = {
575
+ onStart: jest.fn(),
576
+ onEnd: jest.fn(),
577
+ onError: jest.fn(),
578
+ onPause: jest.fn(),
579
+ onResume: jest.fn(),
580
+ onStop: jest.fn()
581
+ }
582
+
583
+ MockSpawn.mockImplementation(() => mockProcess)
584
+ MockExistsSync.mockReturnValue(true)
585
+ })
586
+
587
+ describe('constructor', () => {
588
+ it('should initialize with default options', () => {
589
+ const backend = new PowerShellBackend()
590
+ expect(backend.name).toBe('powershell')
591
+ expect(backend.supportsStreaming).toBe(false)
592
+ })
593
+
594
+ it('should accept events callback', () => {
595
+ const backend = new PowerShellBackend({ events: mockEvents })
596
+ expect(backend).toBeDefined()
597
+ })
598
+ })
599
+
600
+ describe('start()', () => {
601
+ it('should throw on invalid file path', () => {
602
+ const backend = new PowerShellBackend()
603
+ expect(() => backend.start('/invalid; path')).toThrow('Invalid file path')
604
+ })
605
+
606
+ it('should throw on path with special characters', () => {
607
+ const backend = new PowerShellBackend()
608
+ expect(() => backend.start('/invalid`backtick')).toThrow('Invalid file path')
609
+ })
610
+
611
+ it('should call spawn with powershell', () => {
612
+ const backend = new PowerShellBackend({ events: mockEvents })
613
+ backend.start('C:\\valid\\path.wav')
614
+ expect(MockSpawn).toHaveBeenCalled()
615
+ })
616
+
617
+ it('should emit onStart event', () => {
618
+ const backend = new PowerShellBackend({ events: mockEvents })
619
+ backend.start('C:\\valid\\path.wav')
620
+ expect(mockEvents.onStart).toHaveBeenCalled()
621
+ })
622
+
623
+ it('should write script file', () => {
624
+ const backend = new PowerShellBackend({ events: mockEvents })
625
+ backend.start('C:\\valid\\path.wav')
626
+ expect(MockWriteFileSync).toHaveBeenCalled()
627
+ })
628
+ })
629
+
630
+ describe('write()', () => {
631
+ it('should buffer chunks', () => {
632
+ const backend = new PowerShellBackend()
633
+ backend.write(Buffer.from([1, 2, 3]))
634
+ backend.write(Buffer.from([4, 5, 6]))
635
+ })
636
+
637
+ it('should do nothing when stopped', () => {
638
+ const backend = new PowerShellBackend()
639
+ backend.stop()
640
+ backend.write(Buffer.from([1, 2, 3]))
641
+ })
642
+ })
643
+
644
+ describe('end()', () => {
645
+ it('should do nothing when stopped', () => {
646
+ const backend = new PowerShellBackend()
647
+ backend.stop()
648
+ backend.end()
649
+ })
650
+
651
+ it('should do nothing when already ended', () => {
652
+ const backend = new PowerShellBackend()
653
+ backend.end()
654
+ backend.end()
655
+ })
656
+
657
+ it('should do nothing when no chunks', () => {
658
+ const backend = new PowerShellBackend()
659
+ backend.end()
660
+ })
661
+ })
662
+
663
+ describe('pause()', () => {
664
+ it('should throw UnsupportedError when started', () => {
665
+ const backend = new PowerShellBackend()
666
+ try {
667
+ backend.pause()
668
+ } catch (e: any) {
669
+ expect(e.name).toBe('UnsupportedError')
670
+ expect(e.message).toContain('pause is not supported')
671
+ }
672
+ })
673
+ })
674
+
675
+ describe('resume()', () => {
676
+ it('should throw UnsupportedError when paused', () => {
677
+ const backend = new PowerShellBackend()
678
+ try {
679
+ backend.resume()
680
+ } catch (e: any) {
681
+ expect(e.name).toBe('UnsupportedError')
682
+ expect(e.message).toContain('resume is not supported')
683
+ }
684
+ })
685
+ })
686
+
687
+ describe('stop()', () => {
688
+ it('should kill process with SIGTERM', () => {
689
+ const backend = new PowerShellBackend({ events: mockEvents })
690
+ backend.start('C:\\valid\\path.wav')
691
+ backend.stop()
692
+ expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM')
693
+ })
694
+
695
+ it('should emit onStop event', () => {
696
+ const backend = new PowerShellBackend({ events: mockEvents })
697
+ backend.stop()
698
+ expect(mockEvents.onStop).toHaveBeenCalled()
699
+ })
700
+
701
+ it('should be callable multiple times', () => {
702
+ const backend = new PowerShellBackend()
703
+ backend.stop()
704
+ backend.stop()
705
+ })
706
+ })
707
+
708
+ describe('destroy()', () => {
709
+ it('should call stop()', () => {
710
+ const backend = new PowerShellBackend()
711
+ const stopSpy = jest.spyOn(backend, 'stop')
712
+ backend.destroy()
713
+ expect(stopSpy).toHaveBeenCalled()
714
+ })
715
+ })
716
+ })
717
+
718
+ describe('Backend Index (index.ts)', () => {
719
+ describe('BackendType enum', () => {
720
+ it('should have all backend types', () => {
721
+ expect(BackendType.NAUDIODON).toBe('naudiodon')
722
+ expect(BackendType.AFPLAY).toBe('afplay')
723
+ expect(BackendType.APLAY).toBe('aplay')
724
+ expect(BackendType.POWERSHELL).toBe('powershell')
725
+ expect(BackendType.AUTO).toBe('auto')
726
+ })
727
+ })
728
+
729
+ describe('createBackend()', () => {
730
+ beforeEach(() => {
731
+ jest.clearAllMocks()
732
+ jest.resetModules()
733
+ })
734
+
735
+ it('should create AfplayBackend on darwin platform when naudiodon unavailable', () => {
736
+ jest.doMock('naudiodon', () => {
737
+ throw { code: 'MODULE_NOT_FOUND' }
738
+ }, { virtual: true })
739
+
740
+ // Mock process.platform
741
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
742
+
743
+ const { createBackend, AfplayBackend } = require('../src/core/backends/index')
744
+ const backend = createBackend()
745
+ expect(backend).toBeInstanceOf(AfplayBackend)
746
+ })
747
+
748
+ it('should create AplayBackend on linux platform when naudiodon unavailable', () => {
749
+ jest.doMock('naudiodon', () => {
750
+ throw { code: 'MODULE_NOT_FOUND' }
751
+ }, { virtual: true })
752
+
753
+ Object.defineProperty(process, 'platform', { value: 'linux' })
754
+
755
+ const { createBackend, AplayBackend } = require('../src/core/backends/index')
756
+ const backend = createBackend()
757
+ expect(backend).toBeInstanceOf(AplayBackend)
758
+ })
759
+
760
+ it('should create PowerShellBackend on win32 platform when naudiodon unavailable', () => {
761
+ jest.doMock('naudiodon', () => {
762
+ throw { code: 'MODULE_NOT_FOUND' }
763
+ }, { virtual: true })
764
+
765
+ Object.defineProperty(process, 'platform', { value: 'win32' })
766
+
767
+ const { createBackend, PowerShellBackend } = require('../src/core/backends/index')
768
+ const backend = createBackend()
769
+ expect(backend).toBeInstanceOf(PowerShellBackend)
770
+ })
771
+
772
+ it('should throw on unsupported platform', () => {
773
+ jest.doMock('naudiodon', () => {
774
+ throw { code: 'MODULE_NOT_FOUND' }
775
+ }, { virtual: true })
776
+
777
+ Object.defineProperty(process, 'platform', { value: 'freebsd' })
778
+
779
+ const { createBackend } = require('../src/core/backends/index')
780
+ expect(() => createBackend()).toThrow('Unsupported platform: freebsd')
781
+ })
782
+
783
+ it('should create specific backend type when specified', () => {
784
+ const { createBackend, BackendType, NaudiodonBackend, AfplayBackend, AplayBackend, PowerShellBackend } = require('../src/core/backends/index')
785
+
786
+ expect(createBackend(BackendType.AFPLAY)).toBeInstanceOf(AfplayBackend)
787
+ expect(createBackend(BackendType.APLAY)).toBeInstanceOf(AplayBackend)
788
+ expect(createBackend(BackendType.POWERSHELL)).toBeInstanceOf(PowerShellBackend)
789
+ })
790
+
791
+ it('should throw on unknown backend type', () => {
792
+ const { createBackend, BackendType } = require('../src/core/backends/index')
793
+
794
+ expect(() => createBackend('unknown' as BackendType)).toThrow('Unknown backend type: unknown')
795
+ })
796
+ })
797
+
798
+ describe('supportsStreaming()', () => {
799
+ it('should return true for NAUDIODON', () => {
800
+ const { supportsStreaming, BackendType } = require('../src/core/backends/index')
801
+ expect(supportsStreaming(BackendType.NAUDIODON)).toBe(true)
802
+ })
803
+
804
+ it('should return false for other backends', () => {
805
+ const { supportsStreaming, BackendType } = require('../src/core/backends/index')
806
+ expect(supportsStreaming(BackendType.AFPLAY)).toBe(false)
807
+ expect(supportsStreaming(BackendType.APLAY)).toBe(false)
808
+ expect(supportsStreaming(BackendType.POWERSHELL)).toBe(false)
809
+ })
810
+ })
811
+
812
+ describe('getDefaultBackendType()', () => {
813
+ it('should return NAUDIODON when available', () => {
814
+ jest.doMock('naudiodon', () => ({}), { virtual: true })
815
+
816
+ const { getDefaultBackendType, BackendType } = require('../src/core/backends/index')
817
+ expect(getDefaultBackendType()).toBe(BackendType.NAUDIODON)
818
+ })
819
+
820
+ it('should return platform-specific backend when naudiodon unavailable', () => {
821
+ jest.doMock('naudiodon', () => {
822
+ throw { code: 'MODULE_NOT_FOUND' }
823
+ }, { virtual: true })
824
+
825
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
826
+
827
+ const { getDefaultBackendType, BackendType } = require('../src/core/backends/index')
828
+ expect(getDefaultBackendType()).toBe(BackendType.AFPLAY)
829
+ })
830
+ })
831
+ })