@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,232 @@
1
+ import { Speaker, getDefaultSpeaker, speak, stop, pause, resume, listVoices } from '../src/core/speaker'
2
+ import { TTSError, TTSErrorCode, TTSCapabilities, Voice } from '../src/core/types'
3
+ import { registerProvider, unregisterProvider, BaseTTSProvider } from '../src/providers/base'
4
+ import { AudioPlayer } from '../src/core/player'
5
+
6
+ jest.mock('../src/core/player')
7
+
8
+ const MockAudioPlayer = AudioPlayer as jest.MockedClass<typeof AudioPlayer>
9
+
10
+ class MockProvider extends BaseTTSProvider {
11
+ name = 'mock'
12
+ capabilities: TTSCapabilities = { speak: true, stream: true }
13
+
14
+ protected async doSpeak(text: string, voice: string | undefined, model: any) {
15
+ if (text === 'error') {
16
+ throw new TTSError('Provider error', TTSErrorCode.UNKNOWN, this.name)
17
+ }
18
+ return {
19
+ audioData: Buffer.from([1, 2, 3]),
20
+ format: 'mp3',
21
+ isStream: model === 'stream',
22
+ duration: 1.0
23
+ }
24
+ }
25
+
26
+ async listVoices(): Promise<Voice[]> {
27
+ return [
28
+ { id: 'voice1', name: 'Voice 1', language: 'zh-CN' },
29
+ { id: 'voice2', name: 'Voice 2', language: 'en-US', gender: 'male' as const }
30
+ ]
31
+ }
32
+ }
33
+
34
+ describe('Speaker', () => {
35
+ let speaker: Speaker
36
+ let mockPlayer: any
37
+ let capturedEvents: any
38
+
39
+ beforeEach(() => {
40
+ jest.clearAllMocks()
41
+ registerProvider('mock', new MockProvider())
42
+
43
+ capturedEvents = {}
44
+ mockPlayer = {
45
+ play: jest.fn().mockImplementation(() => {
46
+ capturedEvents.onStart?.()
47
+ return Promise.resolve()
48
+ }),
49
+ pause: jest.fn().mockImplementation(() => { capturedEvents.onPause?.() }),
50
+ resume: jest.fn().mockImplementation(() => { capturedEvents.onResume?.() }),
51
+ stop: jest.fn().mockImplementation(() => {
52
+ capturedEvents.onStop?.()
53
+ return Promise.resolve()
54
+ })
55
+ }
56
+ MockAudioPlayer.mockImplementation((events?: any) => {
57
+ Object.assign(capturedEvents, events)
58
+ return mockPlayer
59
+ })
60
+
61
+ speaker = new Speaker({ defaultProvider: 'mock', defaultModel: 'stream' })
62
+ })
63
+
64
+ afterEach(() => {
65
+ unregisterProvider('mock')
66
+ })
67
+
68
+ describe('initialization', () => {
69
+ it('should create speaker with options', () => {
70
+ const s = new Speaker({ defaultProvider: 'mock', defaultModel: 'sync' })
71
+ expect(s.getProviders()).toContain('mock')
72
+ })
73
+
74
+ it('should create speaker with all options', () => {
75
+ const s = new Speaker({
76
+ defaultProvider: 'mock',
77
+ defaultModel: 'stream',
78
+ defaultVoice: 'voice1',
79
+ onEvent: jest.fn()
80
+ })
81
+ expect(s.getProviders()).toContain('mock')
82
+ })
83
+ })
84
+
85
+ describe('speak', () => {
86
+ it('should throw on empty text', async () => {
87
+ await expect(speaker.speak('')).rejects.toThrow(TTSError)
88
+ })
89
+
90
+ it('should throw on whitespace-only text', async () => {
91
+ await expect(speaker.speak(' ')).rejects.toThrow(TTSError)
92
+ })
93
+
94
+ it('should stop current playback before new speak', async () => {
95
+ await speaker.speak('First')
96
+ await speaker.speak('Second')
97
+ expect(mockPlayer.stop).toHaveBeenCalled()
98
+ })
99
+
100
+ it('should play audio data', async () => {
101
+ await speaker.speak('Hello')
102
+ expect(mockPlayer.play).toHaveBeenCalledWith(Buffer.from([1, 2, 3]), 'mp3')
103
+ })
104
+
105
+ it('should throw on provider error', async () => {
106
+ await expect(speaker.speak('error')).rejects.toThrow()
107
+ })
108
+
109
+ it('should throw when provider not found', async () => {
110
+ await expect(speaker.speak('Hello', { provider: 'non-existent' })).rejects.toThrow()
111
+ })
112
+ })
113
+
114
+ describe('control methods', () => {
115
+ it('should pause without error', () => {
116
+ expect(() => speaker.pause()).not.toThrow()
117
+ })
118
+
119
+ it('should resume without error', () => {
120
+ expect(() => speaker.resume()).not.toThrow()
121
+ })
122
+
123
+ it('should stop without error', async () => {
124
+ await expect(speaker.stop()).resolves.toBeUndefined()
125
+ })
126
+ })
127
+
128
+ describe('listVoices', () => {
129
+ it('should return voices from provider', async () => {
130
+ const voices = await speaker.listVoices('mock')
131
+ expect(Array.isArray(voices)).toBe(true)
132
+ expect(voices.length).toBe(2)
133
+ })
134
+
135
+ it('should use default provider when none specified', async () => {
136
+ const voices = await speaker.listVoices()
137
+ expect(Array.isArray(voices)).toBe(true)
138
+ })
139
+ })
140
+
141
+ describe('getCapabilities', () => {
142
+ it('should return provider capabilities', () => {
143
+ const caps = speaker.getCapabilities('mock')
144
+ expect(caps.speak).toBe(true)
145
+ })
146
+
147
+ it('should use default provider when none specified', () => {
148
+ const caps = speaker.getCapabilities()
149
+ expect(caps.speak).toBe(true)
150
+ })
151
+ })
152
+
153
+ describe('getProviders', () => {
154
+ it('should return list of registered providers', () => {
155
+ const providers = speaker.getProviders()
156
+ expect(providers).toContain('mock')
157
+ })
158
+ })
159
+
160
+ describe('isPlaying', () => {
161
+ it('should return false initially', () => {
162
+ expect(speaker.isPlaying()).toBe(false)
163
+ })
164
+
165
+ it('should return true while speaking', async () => {
166
+ const speakPromise = speaker.speak('Hello')
167
+ expect(speaker.isPlaying()).toBe(true)
168
+ await speakPromise
169
+ })
170
+
171
+ it('should return false after stop', async () => {
172
+ await speaker.speak('Hello')
173
+ await speaker.stop()
174
+ expect(speaker.isPlaying()).toBe(false)
175
+ })
176
+ })
177
+
178
+ describe('isPausedState', () => {
179
+ it('should return false initially', () => {
180
+ expect(speaker.isPausedState()).toBe(false)
181
+ })
182
+ })
183
+
184
+ describe('destroy', () => {
185
+ it('should stop player on destroy', async () => {
186
+ await speaker.destroy()
187
+ expect(mockPlayer.stop).toHaveBeenCalled()
188
+ })
189
+
190
+ it('should set isSpeaking to false', async () => {
191
+ await speaker.speak('Hello')
192
+ await speaker.destroy()
193
+ expect(speaker.isPlaying()).toBe(false)
194
+ })
195
+
196
+ it('should be callable multiple times', async () => {
197
+ await speaker.destroy()
198
+ await expect(speaker.destroy()).resolves.not.toThrow()
199
+ })
200
+ })
201
+
202
+ describe('events', () => {
203
+ it('should emit start event', async () => {
204
+ const startCallback = jest.fn()
205
+ speaker.on('start', startCallback)
206
+ await speaker.speak('Hello')
207
+ expect(startCallback).toHaveBeenCalled()
208
+ })
209
+
210
+ it('should emit stop event on stop', async () => {
211
+ const stopCallback = jest.fn()
212
+ speaker.on('stop', stopCallback)
213
+ await speaker.stop()
214
+ expect(stopCallback).toHaveBeenCalled()
215
+ })
216
+ })
217
+ })
218
+
219
+ describe('getDefaultSpeaker', () => {
220
+ beforeEach(() => {
221
+ registerProvider('mock', new MockProvider())
222
+ })
223
+
224
+ afterEach(() => {
225
+ unregisterProvider('mock')
226
+ })
227
+
228
+ it('should return a Speaker instance', () => {
229
+ const speaker = getDefaultSpeaker()
230
+ expect(speaker).toBeInstanceOf(Speaker)
231
+ })
232
+ })
@@ -0,0 +1,303 @@
1
+ import { StreamPlayer } from '../src/core/stream-player'
2
+ import { spawn } from 'child_process'
3
+ import { createWriteStream } from 'fs'
4
+
5
+ jest.mock('child_process')
6
+ jest.mock('fs')
7
+
8
+ const mockSpawn = spawn as jest.MockedFunction<typeof spawn>
9
+ const mockCreateWriteStream = createWriteStream as jest.MockedFunction<typeof createWriteStream>
10
+
11
+ describe('StreamPlayer', () => {
12
+ let mockWriteStream: any
13
+ let mockPlayerProcess: any
14
+
15
+ beforeEach(() => {
16
+ jest.clearAllMocks()
17
+
18
+ mockWriteStream = {
19
+ write: jest.fn().mockReturnValue(true),
20
+ end: jest.fn(),
21
+ destroy: jest.fn(),
22
+ on: jest.fn(),
23
+ once: jest.fn()
24
+ }
25
+
26
+ mockPlayerProcess = {
27
+ kill: jest.fn(),
28
+ on: jest.fn(),
29
+ stdin: { end: jest.fn() }
30
+ }
31
+
32
+ mockCreateWriteStream.mockReturnValue(mockWriteStream as any)
33
+ mockSpawn.mockImplementation(() => mockPlayerProcess as any)
34
+ })
35
+
36
+ afterEach(() => {
37
+ jest.restoreAllMocks()
38
+ })
39
+
40
+ function createPlayer(options?: any): StreamPlayer {
41
+ return new StreamPlayer(options)
42
+ }
43
+
44
+ describe('initialization', () => {
45
+ it('should create StreamPlayer with default options', () => {
46
+ const player = createPlayer()
47
+ expect(player).toBeInstanceOf(StreamPlayer)
48
+ })
49
+
50
+ it('should create StreamPlayer with custom format', () => {
51
+ const player = createPlayer({ format: 'wav' })
52
+ expect(player).toBeInstanceOf(StreamPlayer)
53
+ })
54
+
55
+ it('should create StreamPlayer with events callback', () => {
56
+ const events = {
57
+ onStart: jest.fn(),
58
+ onEnd: jest.fn(),
59
+ onProgress: jest.fn(),
60
+ onError: jest.fn(),
61
+ onStop: jest.fn()
62
+ }
63
+ const player = createPlayer({ events })
64
+ expect(player).toBeInstanceOf(StreamPlayer)
65
+ })
66
+ })
67
+
68
+ describe('write', () => {
69
+ it('should auto-start if not started', () => {
70
+ const player = createPlayer()
71
+ player.write(Buffer.from([1, 2, 3]))
72
+ expect(player.isStarted()).toBe(true)
73
+ })
74
+
75
+ it('should ignore write if stopped', () => {
76
+ const player = createPlayer()
77
+ player.start()
78
+ player.stop()
79
+ player.write(Buffer.from([1, 2, 3]))
80
+ expect(player.getBytesWritten()).toBe(0)
81
+ })
82
+
83
+ it('should write chunk to stream', () => {
84
+ const player = createPlayer()
85
+ player.write(Buffer.from([1, 2, 3]))
86
+ expect(mockWriteStream.write).toHaveBeenCalledWith(Buffer.from([1, 2, 3]))
87
+ })
88
+
89
+ it('should increment bytesWritten counter', () => {
90
+ const player = createPlayer()
91
+ player.write(Buffer.from([1, 2, 3]))
92
+ expect(player.getBytesWritten()).toBe(3)
93
+ })
94
+
95
+ it('should call onProgress callback', () => {
96
+ const onProgress = jest.fn()
97
+ const player = createPlayer({ events: { onProgress } })
98
+ player.write(Buffer.from([1, 2, 3]))
99
+ expect(onProgress).toHaveBeenCalledWith(3)
100
+ })
101
+
102
+ it('should emit progress event', () => {
103
+ const player = createPlayer()
104
+ const progressCallback = jest.fn()
105
+ player.on('progress', progressCallback)
106
+ player.write(Buffer.from([1, 2, 3]))
107
+ expect(progressCallback).toHaveBeenCalledWith(3)
108
+ })
109
+ })
110
+
111
+ describe('start', () => {
112
+ it('should set started flag to true', () => {
113
+ const player = createPlayer()
114
+ player.start()
115
+ expect(player.isStarted()).toBe(true)
116
+ })
117
+
118
+ it('should not start twice', () => {
119
+ const player = createPlayer()
120
+ player.start()
121
+ player.start()
122
+ expect(mockCreateWriteStream).toHaveBeenCalledTimes(1)
123
+ })
124
+
125
+ it('should call onStart callback', () => {
126
+ const onStart = jest.fn()
127
+ const player = createPlayer({ events: { onStart } })
128
+ player.start()
129
+ expect(onStart).toHaveBeenCalled()
130
+ })
131
+
132
+ it('should emit start event', () => {
133
+ const player = createPlayer()
134
+ const startCallback = jest.fn()
135
+ player.on('start', startCallback)
136
+ player.start()
137
+ expect(startCallback).toHaveBeenCalled()
138
+ })
139
+ })
140
+
141
+ describe('stop', () => {
142
+ it('should set stopped flag to true', () => {
143
+ const player = createPlayer()
144
+ player.start()
145
+ player.stop()
146
+ expect(player.isStopped()).toBe(true)
147
+ })
148
+
149
+ it('should kill player process', () => {
150
+ const player = createPlayer()
151
+ player.start()
152
+ player.stop()
153
+ expect(mockPlayerProcess.kill).toHaveBeenCalledWith('SIGTERM')
154
+ })
155
+
156
+ it('should call onStop callback', () => {
157
+ const onStop = jest.fn()
158
+ const player = createPlayer({ events: { onStop } })
159
+ player.start()
160
+ player.stop()
161
+ expect(onStop).toHaveBeenCalled()
162
+ })
163
+
164
+ it('should emit stop event', () => {
165
+ const player = createPlayer()
166
+ player.start()
167
+ const stopCallback = jest.fn()
168
+ player.on('stop', stopCallback)
169
+ player.stop()
170
+ expect(stopCallback).toHaveBeenCalled()
171
+ })
172
+ })
173
+
174
+ describe('pause', () => {
175
+ it('should set paused flag to true', () => {
176
+ const player = createPlayer()
177
+ player.start()
178
+ player.pause()
179
+ expect(player.isPaused()).toBe(true)
180
+ })
181
+
182
+ it('should send SIGSTOP to player process', () => {
183
+ const player = createPlayer()
184
+ player.start()
185
+ player.pause()
186
+ expect(mockPlayerProcess.kill).toHaveBeenCalledWith('SIGSTOP')
187
+ })
188
+
189
+ it('should call onPause callback', () => {
190
+ const onPause = jest.fn()
191
+ const player = createPlayer({ events: { onPause } })
192
+ player.start()
193
+ player.pause()
194
+ expect(onPause).toHaveBeenCalled()
195
+ })
196
+
197
+ it('should emit pause event', () => {
198
+ const player = createPlayer()
199
+ player.start()
200
+ const pauseCallback = jest.fn()
201
+ player.on('pause', pauseCallback)
202
+ player.pause()
203
+ expect(pauseCallback).toHaveBeenCalled()
204
+ })
205
+
206
+ it('should not pause if not started', () => {
207
+ const player = createPlayer()
208
+ player.pause()
209
+ expect(mockPlayerProcess.kill).not.toHaveBeenCalled()
210
+ })
211
+
212
+ it('should not pause if already paused', () => {
213
+ const player = createPlayer()
214
+ player.start()
215
+ player.pause()
216
+ player.pause()
217
+ expect(mockPlayerProcess.kill).toHaveBeenCalledTimes(1)
218
+ })
219
+ })
220
+
221
+ describe('resume', () => {
222
+ it('should set paused flag to false', () => {
223
+ const player = createPlayer()
224
+ player.start()
225
+ player.pause()
226
+ player.resume()
227
+ expect(player.isPaused()).toBe(false)
228
+ })
229
+
230
+ it('should send SIGCONT to player process', () => {
231
+ const player = createPlayer()
232
+ player.start()
233
+ player.pause()
234
+ player.resume()
235
+ expect(mockPlayerProcess.kill).toHaveBeenCalledWith('SIGCONT')
236
+ })
237
+
238
+ it('should call onResume callback', () => {
239
+ const onResume = jest.fn()
240
+ const player = createPlayer({ events: { onResume } })
241
+ player.start()
242
+ player.pause()
243
+ player.resume()
244
+ expect(onResume).toHaveBeenCalled()
245
+ })
246
+
247
+ it('should emit resume event', () => {
248
+ const player = createPlayer()
249
+ player.start()
250
+ player.pause()
251
+ const resumeCallback = jest.fn()
252
+ player.on('resume', resumeCallback)
253
+ player.resume()
254
+ expect(resumeCallback).toHaveBeenCalled()
255
+ })
256
+
257
+ it('should not resume if not paused', () => {
258
+ const player = createPlayer()
259
+ player.start()
260
+ player.resume()
261
+ expect(mockPlayerProcess.kill).not.toHaveBeenCalledWith('SIGCONT')
262
+ })
263
+ })
264
+
265
+ describe('end', () => {
266
+ it('should end write stream without stopping player', () => {
267
+ const player = createPlayer()
268
+ player.start()
269
+ player.end()
270
+ expect(mockWriteStream.end).toHaveBeenCalled()
271
+ })
272
+ })
273
+
274
+ describe('state queries', () => {
275
+ it('isStarted should return false initially', () => {
276
+ const player = createPlayer()
277
+ expect(player.isStarted()).toBe(false)
278
+ })
279
+
280
+ it('isPaused should return false initially', () => {
281
+ const player = createPlayer()
282
+ expect(player.isPaused()).toBe(false)
283
+ })
284
+
285
+ it('isStopped should return false initially', () => {
286
+ const player = createPlayer()
287
+ expect(player.isStopped()).toBe(false)
288
+ })
289
+
290
+ it('getBytesWritten should return 0 initially', () => {
291
+ const player = createPlayer()
292
+ expect(player.getBytesWritten()).toBe(0)
293
+ })
294
+ })
295
+
296
+ describe('error handling', () => {
297
+ it('should have error handler registered', () => {
298
+ const player = createPlayer()
299
+ player.start()
300
+ expect(mockWriteStream.on).toHaveBeenCalledWith('error', expect.any(Function))
301
+ })
302
+ })
303
+ })