@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,201 @@
1
+ import { pluginInfo, toolNames, initialize, destroy, isStreamEnabled, getStreamReader } from '../src/index'
2
+ import { registerProvider, unregisterProvider, BaseTTSProvider } from '../src/providers/base'
3
+ import { TTSCapabilities } from '../src/core/types'
4
+
5
+ class MockProvider extends BaseTTSProvider {
6
+ name = 'mock'
7
+ capabilities: TTSCapabilities = { speak: true, stream: true }
8
+
9
+ protected async doSpeak(text: string, voice: string | undefined, model: any) {
10
+ return {
11
+ audioData: Buffer.from([1, 2, 3]),
12
+ format: 'mp3',
13
+ isStream: model === 'stream',
14
+ duration: 1.0
15
+ }
16
+ }
17
+ }
18
+
19
+ describe('index module exports', () => {
20
+ describe('pluginInfo', () => {
21
+ it('should export correct plugin info', () => {
22
+ expect(pluginInfo.name).toBe('ocosay')
23
+ expect(pluginInfo.version).toBe('1.0.0')
24
+ expect(pluginInfo.description).toContain('TTS')
25
+ })
26
+ })
27
+
28
+ describe('toolNames', () => {
29
+ it('should export array of tool names', () => {
30
+ expect(Array.isArray(toolNames)).toBe(true)
31
+ expect(toolNames.length).toBeGreaterThan(0)
32
+ })
33
+
34
+ it('should include tts_speak', () => {
35
+ expect(toolNames).toContain('tts_speak')
36
+ })
37
+
38
+ it('should include tts_stop', () => {
39
+ expect(toolNames).toContain('tts_stop')
40
+ })
41
+
42
+ it('should include tts_stream_speak', () => {
43
+ expect(toolNames).toContain('tts_stream_speak')
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('TuiEventBus integration', () => {
49
+ const originalGlobal = global as any
50
+ let mockProvider: MockProvider
51
+
52
+ beforeEach(() => {
53
+ mockProvider = new MockProvider()
54
+ registerProvider('mock', mockProvider)
55
+ })
56
+
57
+ afterEach(async () => {
58
+ delete originalGlobal.__opencode_tuieventbus__
59
+ await destroy()
60
+ unregisterProvider('mock')
61
+ })
62
+
63
+ describe('P1-1: eventBus available', () => {
64
+ it('should subscribe to message.part.delta and message.part.end events', async () => {
65
+ const mockOn = jest.fn()
66
+ const mockOff = jest.fn()
67
+ const MockTuiEventBus = jest.fn().mockImplementation(() => ({
68
+ on: mockOn,
69
+ off: mockOff
70
+ }))
71
+ originalGlobal.__opencode_tuieventbus__ = MockTuiEventBus
72
+
73
+ await initialize({
74
+ autoRead: true,
75
+ defaultProvider: 'mock'
76
+ })
77
+
78
+ expect(mockOn).toHaveBeenCalledWith('message.part.delta', expect.any(Function))
79
+ expect(mockOn).toHaveBeenCalledWith('message.part.end', expect.any(Function))
80
+ })
81
+
82
+ it('should handle delta events via streamReader', async () => {
83
+ const mockOn = jest.fn()
84
+ const mockOff = jest.fn()
85
+ let deltaHandler: Function | undefined
86
+
87
+ mockOn.mockImplementation((event: string, handler: Function) => {
88
+ if (event === 'message.part.delta') {
89
+ deltaHandler = handler
90
+ }
91
+ })
92
+
93
+ const MockTuiEventBus = jest.fn().mockImplementation(() => ({
94
+ on: mockOn,
95
+ off: mockOff
96
+ }))
97
+ originalGlobal.__opencode_tuieventbus__ = MockTuiEventBus
98
+
99
+ await initialize({
100
+ autoRead: true,
101
+ defaultProvider: 'mock'
102
+ })
103
+
104
+ const mockEvent = {
105
+ sessionId: 'session-1',
106
+ messageId: 'msg-1',
107
+ partId: 'part-1',
108
+ properties: { delta: 'Hello' }
109
+ }
110
+
111
+ expect(deltaHandler).toBeDefined()
112
+ deltaHandler!(mockEvent)
113
+ })
114
+ })
115
+
116
+ describe('P1-1: eventBus unavailable (graceful fallback)', () => {
117
+ it('should not throw when eventBus is not available', async () => {
118
+ delete originalGlobal.__opencode_tuieventbus__
119
+
120
+ await expect(initialize({
121
+ autoRead: true,
122
+ defaultProvider: 'mock'
123
+ })).resolves.not.toThrow()
124
+ })
125
+
126
+ it('should have stream components initialized even without eventBus', async () => {
127
+ delete originalGlobal.__opencode_tuieventbus__
128
+
129
+ await initialize({
130
+ autoRead: true,
131
+ defaultProvider: 'mock'
132
+ })
133
+
134
+ expect(isStreamEnabled()).toBe(true)
135
+ expect(getStreamReader()).toBeDefined()
136
+ })
137
+ })
138
+ })
139
+
140
+ describe('initialize() idempotency', () => {
141
+ let mockProvider: MockProvider
142
+
143
+ beforeEach(() => {
144
+ mockProvider = new MockProvider()
145
+ registerProvider('mock', mockProvider)
146
+ })
147
+
148
+ afterEach(async () => {
149
+ await destroy()
150
+ unregisterProvider('mock')
151
+ })
152
+
153
+ describe('P1-2: repeated initialize() calls', () => {
154
+ it('should be safe to call initialize() multiple times', async () => {
155
+ await initialize({
156
+ defaultProvider: 'mock'
157
+ })
158
+
159
+ await expect(initialize({
160
+ defaultProvider: 'mock'
161
+ })).resolves.not.toThrow()
162
+
163
+ await expect(initialize({
164
+ defaultProvider: 'mock'
165
+ })).resolves.not.toThrow()
166
+ })
167
+
168
+ it('should not re-initialize components on repeated calls', async () => {
169
+ await initialize({
170
+ autoRead: true,
171
+ defaultProvider: 'mock'
172
+ })
173
+
174
+ const firstStreamReader = getStreamReader()
175
+ expect(firstStreamReader).toBeDefined()
176
+
177
+ await initialize({
178
+ defaultProvider: 'mock'
179
+ })
180
+
181
+ const secondStreamReader = getStreamReader()
182
+ expect(secondStreamReader).toBe(firstStreamReader)
183
+ })
184
+
185
+ it('should return early if already initialized', async () => {
186
+ const spyInitialize = jest.spyOn(mockProvider, 'initialize')
187
+
188
+ await initialize({
189
+ defaultProvider: 'mock'
190
+ })
191
+
192
+ spyInitialize.mockClear()
193
+
194
+ await initialize({
195
+ defaultProvider: 'mock'
196
+ })
197
+
198
+ expect(spyInitialize).not.toHaveBeenCalled()
199
+ })
200
+ })
201
+ })
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 真实 TTS 集成测试
3
+ * 需要设置环境变量: MINIMAX_API_KEY
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=integration-test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integration-test.d.ts","sourceRoot":"","sources":["integration-test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
@@ -0,0 +1,84 @@
1
+ const { initialize } = require('../dist/index')
2
+ const { getProvider } = require('../dist/providers/base')
3
+
4
+ const API_KEY = process.env.MINIMAX_API_KEY
5
+
6
+ if (!API_KEY) {
7
+ console.error('❌ 请设置环境变量 MINIMAX_API_KEY')
8
+ console.log('示例: MINIMAX_API_KEY=your-api-key node tests/integration-test.js')
9
+ process.exit(1)
10
+ }
11
+
12
+ async function test() {
13
+ console.log('🔧 初始化 ocosay...')
14
+
15
+ await initialize({
16
+ providers: {
17
+ minimax: {
18
+ apiKey: API_KEY,
19
+ voiceId: 'male-qn-qingse',
20
+ model: 'stream'
21
+ }
22
+ }
23
+ })
24
+
25
+ console.log('✅ 初始化成功!\n')
26
+
27
+ console.log('📝 测试 1: 同步合成 (model: sync)')
28
+ try {
29
+ const provider = getProvider('minimax')
30
+ const result = await provider.speak('你好,这是同步合成测试!', { model: 'sync' })
31
+ const data = Buffer.isBuffer(result.audioData) ? result.audioData : Buffer.from([])
32
+ console.log('✅ 同步合成成功! 音频大小: ' + data.length + ' bytes, 格式: ' + result.format)
33
+ } catch (error) {
34
+ console.error('❌ 同步合成失败:', error.message || error)
35
+ }
36
+
37
+ console.log('')
38
+
39
+ console.log('📝 测试 2: 流式合成 (model: stream)')
40
+ try {
41
+ const provider = getProvider('minimax')
42
+ const result = await provider.speak('你好,这是流式合成测试!', { model: 'stream' })
43
+ const data = Buffer.isBuffer(result.audioData) ? result.audioData : Buffer.from([])
44
+ console.log('✅ 流式合成成功! 音频大小: ' + data.length + ' bytes, 格式: ' + result.format)
45
+ } catch (error) {
46
+ console.error('❌ 流式合成失败:', error.message || error)
47
+ }
48
+
49
+ console.log('')
50
+
51
+ console.log('📝 测试 3: 异步合成 (model: async)')
52
+ try {
53
+ const provider = getProvider('minimax')
54
+ const result = await provider.speak('你好,这是异步合成测试!', { model: 'async' })
55
+ const data = Buffer.isBuffer(result.audioData) ? result.audioData : Buffer.from([])
56
+ console.log('✅ 异步合成成功! 音频大小: ' + data.length + ' bytes, 格式: ' + result.format)
57
+ } catch (error) {
58
+ console.error('❌ 异步合成失败:', error.message || error)
59
+ }
60
+
61
+ console.log('')
62
+
63
+ console.log('📝 测试 4: 列出音色')
64
+ try {
65
+ const provider = getProvider('minimax')
66
+ const voices = await provider.listVoices()
67
+ console.log('✅ 获取到 ' + voices.length + ' 个音色:')
68
+ voices.slice(0, 5).forEach(v => {
69
+ console.log(' - ' + v.id + ': ' + v.name + ' (' + (v.language || 'unknown') + ')')
70
+ })
71
+ } catch (error) {
72
+ console.error('❌ 列出音色失败:', error.message || error)
73
+ }
74
+
75
+ console.log('')
76
+ console.log('🎉 集成测试完成!')
77
+
78
+ process.exit(0)
79
+ }
80
+
81
+ test().catch(err => {
82
+ console.error('❌ 测试失败:', err)
83
+ process.exit(1)
84
+ })
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integration-test.js","sourceRoot":"","sources":["integration-test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,93 @@
1
+ import { initialize, speak, stop, listVoices, getSpeaker } from '../src/index'
2
+ import { getDefaultSpeaker } from '../src/core/speaker'
3
+ import { getProvider } from '../src/providers/base'
4
+
5
+ const API_KEY = process.env.MINIMAX_API_KEY
6
+
7
+ if (!API_KEY) {
8
+ console.error('❌ 请设置环境变量 MINIMAX_API_KEY')
9
+ console.log('示例: MINIMAX_API_KEY=your-api-key node tests/integration-test.ts')
10
+ process.exit(1)
11
+ }
12
+
13
+ async function test() {
14
+ console.log('🔧 初始化 ocosay...')
15
+
16
+ await initialize({
17
+ providers: {
18
+ minimax: {
19
+ apiKey: API_KEY,
20
+ voiceId: 'male-qn-qingse',
21
+ model: 'stream'
22
+ }
23
+ }
24
+ })
25
+
26
+ console.log('✅ 初始化成功!\n')
27
+
28
+ console.log('📝 测试 1: 同步合成 (model: sync)')
29
+ try {
30
+ const provider = getProvider('minimax')
31
+ const result = await provider.speak('你好,这是同步合成测试!', { model: 'sync' })
32
+ console.log(`✅ 同步合成成功! 音频大小: ${result.audioData.length} bytes, 格式: ${result.format}`)
33
+ } catch (error: any) {
34
+ console.error('❌ 同步合成失败:', error.message || error)
35
+ }
36
+
37
+ console.log('')
38
+
39
+ console.log('📝 测试 2: 流式合成 (model: stream)')
40
+ try {
41
+ const provider = getProvider('minimax')
42
+ const result = await provider.speak('你好,这是流式合成测试!', { model: 'stream' })
43
+ console.log(`✅ 流式合成成功! 音频大小: ${result.audioData.length} bytes, 格式: ${result.format}`)
44
+ } catch (error: any) {
45
+ console.error('❌ 流式合成失败:', error.message || error)
46
+ }
47
+
48
+ console.log('')
49
+
50
+ console.log('📝 测试 3: 异步合成 (model: async)')
51
+ try {
52
+ const provider = getProvider('minimax')
53
+ const result = await provider.speak('你好,这是异步合成测试!', { model: 'async' })
54
+ console.log(`✅ 异步合成成功! 音频大小: ${result.audioData.length} bytes, 格式: ${result.format}`)
55
+ } catch (error: any) {
56
+ console.error('❌ 异步合成失败:', error.message || error)
57
+ }
58
+
59
+ console.log('')
60
+
61
+ console.log('📝 测试 4: 列出音色')
62
+ try {
63
+ const voices = await listVoices('minimax')
64
+ console.log(`✅ 获取到 ${voices.length} 个音色:`)
65
+ voices.slice(0, 5).forEach((v: any) => {
66
+ console.log(` - ${v.id}: ${v.name} (${v.language || 'unknown'})`)
67
+ })
68
+ } catch (error: any) {
69
+ console.error('❌ 列出音色失败:', error.message || error)
70
+ }
71
+
72
+ console.log('')
73
+
74
+ console.log('📝 测试 5: 便捷函数 speak 调用链验证')
75
+ try {
76
+ const speaker = getDefaultSpeaker()
77
+ speaker.on('end', () => console.log(' 播放结束!'))
78
+ speaker.on('error', (err: any) => console.log(' 播放错误(正常,没有音频设备):', err.message))
79
+ console.log('✅ speak 便捷函数调用链验证通过')
80
+ } catch (error: any) {
81
+ console.error('❌ speak 失败:', error.message || error)
82
+ }
83
+
84
+ console.log('')
85
+ console.log('🎉 集成测试完成!')
86
+
87
+ process.exit(0)
88
+ }
89
+
90
+ test().catch((err: any) => {
91
+ console.error('❌ 测试失败:', err)
92
+ process.exit(1)
93
+ })
@@ -0,0 +1,160 @@
1
+ import { Speaker } from '../src/core/speaker'
2
+ import { TTSCapabilities } from '../src/core/types'
3
+ import { registerProvider, unregisterProvider, BaseTTSProvider } from '../src/providers/base'
4
+ import { initialize, destroy, isAutoReadEnabled, getStreamReader } from '../src/index'
5
+
6
+ class MockProvider extends BaseTTSProvider {
7
+ name = 'mock'
8
+ capabilities: TTSCapabilities = { speak: true, stream: true }
9
+ private shouldThrow = false
10
+ private destroyCalled = false
11
+
12
+ protected async doSpeak(text: string, voice: string | undefined, model: any) {
13
+ if (this.shouldThrow) {
14
+ throw new Error('Mock synthesize error')
15
+ }
16
+ return {
17
+ audioData: Buffer.from([1, 2, 3]),
18
+ format: 'mp3',
19
+ isStream: model === 'stream',
20
+ duration: 1.0
21
+ }
22
+ }
23
+
24
+ async destroy(): Promise<void> {
25
+ this.destroyCalled = true
26
+ }
27
+
28
+ setShouldThrow(throwError: boolean): void {
29
+ this.shouldThrow = throwError
30
+ }
31
+
32
+ wasDestroyCalled(): boolean {
33
+ return this.destroyCalled
34
+ }
35
+
36
+ reset(): void {
37
+ this.shouldThrow = false
38
+ this.destroyCalled = false
39
+ }
40
+ }
41
+
42
+ describe('P1 Fixes', () => {
43
+ let mockProvider: MockProvider
44
+
45
+ beforeEach(() => {
46
+ mockProvider = new MockProvider()
47
+ registerProvider('mock', mockProvider)
48
+ })
49
+
50
+ afterEach(async () => {
51
+ try {
52
+ await destroy()
53
+ } catch (e) {
54
+ // ignore destroy errors in cleanup
55
+ }
56
+ unregisterProvider('mock')
57
+ mockProvider.reset()
58
+ })
59
+
60
+ describe('P1-3: Speaker.destroy() 方法', () => {
61
+ it('should cleanup player when destroy() is called', async () => {
62
+ const speaker = new Speaker({ defaultProvider: 'mock' })
63
+
64
+ await speaker.destroy()
65
+
66
+ expect((speaker as any).player).toBeUndefined()
67
+ })
68
+
69
+ it('should reset isSpeaking and isPaused when destroy() is called', async () => {
70
+ const speaker = new Speaker({ defaultProvider: 'mock' })
71
+
72
+ expect(speaker.isPlaying()).toBe(false)
73
+ expect(speaker.isPausedState()).toBe(false)
74
+
75
+ await speaker.destroy()
76
+
77
+ expect(speaker.isPlaying()).toBe(false)
78
+ expect(speaker.isPausedState()).toBe(false)
79
+ })
80
+
81
+ it('should cleanup currentProvider and currentText', async () => {
82
+ const speaker = new Speaker({ defaultProvider: 'mock' })
83
+
84
+ await speaker.destroy()
85
+
86
+ expect((speaker as any).currentProvider).toBeUndefined()
87
+ expect((speaker as any).currentText).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('P1-4: destroy() 调用 provider.destroy()', () => {
92
+ it('should call provider.destroy() when global destroy() is called', async () => {
93
+ await initialize({
94
+ defaultProvider: 'mock',
95
+ autoRead: false
96
+ })
97
+
98
+ expect(mockProvider.wasDestroyCalled()).toBe(false)
99
+
100
+ await destroy()
101
+
102
+ expect(mockProvider.wasDestroyCalled()).toBe(true)
103
+ })
104
+ })
105
+
106
+ describe('P1-1: destroy() 重置 autoReadEnabled', () => {
107
+ it('should reset autoReadEnabled to false after destroy()', async () => {
108
+ expect(isAutoReadEnabled()).toBe(false)
109
+
110
+ await initialize({
111
+ defaultProvider: 'mock',
112
+ autoRead: true
113
+ })
114
+
115
+ expect(isAutoReadEnabled()).toBe(true)
116
+
117
+ await destroy()
118
+
119
+ expect(isAutoReadEnabled()).toBe(false)
120
+ })
121
+
122
+ it('should correctly check autoReadEnabled state', async () => {
123
+ await initialize({
124
+ defaultProvider: 'mock',
125
+ autoRead: false
126
+ })
127
+ expect(isAutoReadEnabled()).toBe(false)
128
+ await destroy()
129
+
130
+ await initialize({
131
+ defaultProvider: 'mock',
132
+ autoRead: true
133
+ })
134
+ expect(isAutoReadEnabled()).toBe(true)
135
+ await destroy()
136
+ expect(isAutoReadEnabled()).toBe(false)
137
+ })
138
+ })
139
+
140
+ describe('P1-2: processQueue() 错误处理', () => {
141
+ it('should not throw when provider synthesize throws', async () => {
142
+ await initialize({
143
+ defaultProvider: 'mock',
144
+ autoRead: true
145
+ })
146
+
147
+ const streamReader = getStreamReader()
148
+ expect(streamReader).toBeDefined()
149
+
150
+ mockProvider.setShouldThrow(true)
151
+
152
+ streamReader!.handleDelta('session1', 'msg1', 'part1', 'Error sentence')
153
+ streamReader!.handleEnd()
154
+
155
+ await new Promise(resolve => setTimeout(resolve, 50))
156
+
157
+ expect(() => streamReader!.handleDelta('session2', 'msg2', 'part2', 'Normal sentence')).not.toThrow()
158
+ })
159
+ })
160
+ })