返回 oh-my-ppt
settings-handlers.test.ts
根目录 / tests / unit / ipc / settings-handlers.test.ts
1 import { beforeEach, describe, expect, it, vi } from 'vitest'
2
3 const settingsHandlersState = vi.hoisted(() => {
4 const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
5
6 return {
7 appMock: {
8 getVersion: vi.fn(() => '1.0.0')
9 },
10 applyProxyMock: vi.fn(),
11 dialogMock: {
12 showOpenDialog: vi.fn()
13 },
14 handlers,
15 ipcMainMock: {
16 handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
17 handlers.set(channel, handler)
18 })
19 },
20 localeMock: {
21 readAppLocale: vi.fn(async () => 'zh'),
22 uiText: vi.fn((locale: string, zh: string, en: string) => (locale === 'en' ? en : zh))
23 },
24 logMock: {
25 error: vi.fn(),
26 info: vi.fn()
27 },
28 resolveModelMock: vi.fn()
29 }
30 })
31
32 vi.mock('electron', () => ({
33 BrowserWindow: {
34 fromWebContents: vi.fn(),
35 getFocusedWindow: vi.fn()
36 },
37 app: settingsHandlersState.appMock,
38 dialog: settingsHandlersState.dialogMock,
39 ipcMain: settingsHandlersState.ipcMainMock
40 }))
41
42 vi.mock('electron-log/main.js', () => ({
43 default: settingsHandlersState.logMock
44 }))
45
46 vi.mock('../../../src/main/agent-runtime/model', () => ({
47 resolveModel: settingsHandlersState.resolveModelMock,
48 runWithModelTemperatureControl: <T>(_config: unknown, task: () => T): T => task(),
49 OPENAI_RESPONSES_FORMAT_ERROR_EN: 'Invalid OpenAI Responses API payload.',
50 OPENAI_RESPONSES_FORMAT_ERROR_ZH: '当前 provider 返回的不是 OpenAI Responses API 格式。',
51 isOpenAIResponsesFormatError: (error: unknown) =>
52 /Cannot read propert(?:y|ies).*undefined.*map|Cannot read propert(?:y|ies).*map.*undefined/i.test(
53 error instanceof Error ? error.message : ''
54 )
55 }))
56
57 vi.mock('../../../src/main/utils/proxy', () => ({
58 applyProxy: settingsHandlersState.applyProxyMock
59 }))
60
61 vi.mock('../../../src/main/config/locale-utils', () => ({
62 readAppLocale: settingsHandlersState.localeMock.readAppLocale,
63 uiText: settingsHandlersState.localeMock.uiText
64 }))
65
66 vi.mock('@shared/model-timeout', () => ({
67 CONFIGURABLE_MODEL_TIMEOUT_PROFILES: ['planning', 'design', 'agent', 'document'],
68 resolveModelTimeoutMs: vi.fn((value: unknown, profile: string) => {
69 if (typeof value === 'number' && Number.isFinite(value)) return value
70 const defaults: Record<string, number> = {
71 planning: 300000,
72 design: 300000,
73 agent: 600000,
74 document: 600000
75 }
76 return defaults[profile] ?? 300000
77 })
78 }))
79
80 async function registerWithDb(overrides: Partial<Record<string, unknown>> = {}) {
81 vi.resetModules()
82 settingsHandlersState.handlers.clear()
83
84 const { registerSettingsHandlers } = await import('../../../src/main/config/settings-handlers')
85
86 const db = {
87 getAllSettings: vi.fn(async () => ({})),
88 listModelConfigs: vi.fn(async () => []),
89 setSetting: vi.fn(async () => undefined),
90 setStoragePath: vi.fn(async () => undefined),
91 ...overrides
92 }
93
94 const ctx = {
95 mainWindow: {} as never,
96 db,
97 encryptApiKey: vi.fn((value: string) => value),
98 decryptApiKey: vi.fn((value: unknown) => String(value ?? ''))
99 }
100
101 registerSettingsHandlers(ctx as never)
102
103 return {
104 db,
105 getHandler: (channel: string) => settingsHandlersState.handlers.get(channel)
106 }
107 }
108
109 describe('registerSettingsHandlers proxy settings', () => {
110 beforeEach(() => {
111 settingsHandlersState.applyProxyMock.mockReset()
112 settingsHandlersState.appMock.getVersion.mockClear()
113 settingsHandlersState.dialogMock.showOpenDialog.mockReset()
114 settingsHandlersState.handlers.clear()
115 settingsHandlersState.ipcMainMock.handle.mockClear()
116 settingsHandlersState.localeMock.readAppLocale.mockReset()
117 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
118 settingsHandlersState.localeMock.uiText.mockClear()
119 settingsHandlersState.logMock.error.mockClear()
120 settingsHandlersState.logMock.info.mockClear()
121 settingsHandlersState.resolveModelMock.mockReset()
122 })
123
124 it('returns trimmed proxyUrl from settings:get', async () => {
125 const { getHandler } = await registerWithDb({
126 getAllSettings: vi.fn(async () => ({
127 locale: 'en',
128 proxy_url: ' http://127.0.0.1:7890 ',
129 storage_path: ' /tmp/workspace '
130 }))
131 })
132
133 const getSettings = getHandler('settings:get')
134 const result = await getSettings?.()
135
136 expect(result).toMatchObject({
137 locale: 'en',
138 proxyUrl: 'http://127.0.0.1:7890',
139 storagePath: '/tmp/workspace'
140 })
141 })
142
143 it('applies proxy before persisting proxy_url in settings:save', async () => {
144 const callOrder: string[] = []
145 settingsHandlersState.applyProxyMock.mockImplementation(() => {
146 callOrder.push('apply')
147 })
148 const db = {
149 setSetting: vi.fn(async (key: string) => {
150 if (key === 'proxy_url') {
151 callOrder.push('persist')
152 }
153 })
154 }
155 const { getHandler } = await registerWithDb(db)
156
157 const saveSettings = getHandler('settings:save')
158 const result = await saveSettings?.(undefined, {
159 proxyUrl: ' http://127.0.0.1:7890 '
160 })
161
162 expect(result).toEqual({ success: true })
163 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith('http://127.0.0.1:7890')
164 expect(db.setSetting).toHaveBeenCalledWith('proxy_url', 'http://127.0.0.1:7890')
165 expect(callOrder).toEqual(['apply', 'persist'])
166 })
167
168 it('does not persist proxy_url when applyProxy fails', async () => {
169 settingsHandlersState.applyProxyMock.mockImplementation(() => {
170 throw new Error('bad proxy')
171 })
172 const db = {
173 setSetting: vi.fn(async () => undefined)
174 }
175 const { getHandler } = await registerWithDb(db)
176
177 const saveSettings = getHandler('settings:save')
178
179 await expect(
180 saveSettings?.(undefined, {
181 proxyUrl: 'http://broken-proxy'
182 })
183 ).rejects.toThrow('代理设置无效:bad proxy')
184
185 expect(settingsHandlersState.localeMock.readAppLocale).toHaveBeenCalled()
186 expect(db.setSetting).not.toHaveBeenCalledWith('proxy_url', expect.anything())
187 })
188
189 it('clears persisted proxy when saving an empty proxyUrl', async () => {
190 const db = {
191 setSetting: vi.fn(async () => undefined)
192 }
193 const { getHandler } = await registerWithDb(db)
194
195 const saveSettings = getHandler('settings:save')
196 await saveSettings?.(undefined, { proxyUrl: ' ' })
197
198 expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith(undefined)
199 expect(db.setSetting).toHaveBeenCalledWith('proxy_url', '')
200 })
201 })
202
203 describe('registerSettingsHandlers model temperature settings', () => {
204 beforeEach(() => {
205 settingsHandlersState.handlers.clear()
206 settingsHandlersState.ipcMainMock.handle.mockClear()
207 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
208 settingsHandlersState.resolveModelMock.mockReset()
209 })
210
211 it('returns disableTemperature in the model config list', async () => {
212 const { getHandler } = await registerWithDb({
213 listModelConfigs: vi.fn(async () => [
214 {
215 id: 'model-1',
216 name: 'Reasoning model',
217 provider: 'openai',
218 model: 'reasoner',
219 apiKey: 'secret',
220 baseUrl: '',
221 maxTokens: 4096,
222 disableTemperature: 1,
223 thinkingParameterMode: 'omit',
224 active: 1,
225 createdAt: 1,
226 updatedAt: 2
227 }
228 ])
229 })
230
231 const listModelConfigs = getHandler('settings:listModelConfigs')
232 await expect(listModelConfigs?.()).resolves.toEqual([
233 expect.objectContaining({
234 id: 'model-1',
235 disableTemperature: true,
236 thinkingParameterMode: 'omit'
237 })
238 ])
239 })
240
241 it('persists parameter controls when saving a model config', async () => {
242 const upsertModelConfig = vi.fn(async () => 'model-1')
243 const { getHandler } = await registerWithDb({ upsertModelConfig })
244
245 const saveModelConfig = getHandler('settings:upsertModelConfig')
246 await saveModelConfig?.(undefined, {
247 name: 'Reasoning model',
248 provider: 'openai',
249 model: 'reasoner',
250 apiKey: 'secret',
251 baseUrl: '',
252 maxTokens: 4096,
253 disableTemperature: true,
254 thinkingParameterMode: 'omit'
255 })
256
257 expect(upsertModelConfig).toHaveBeenCalledWith(
258 expect.objectContaining({
259 disableTemperature: true,
260 thinkingParameterMode: 'omit'
261 })
262 )
263 })
264
265 it('accepts the OpenAI Responses provider when saving a model config', async () => {
266 const upsertModelConfig = vi.fn(async () => 'model-1')
267 const { getHandler } = await registerWithDb({ upsertModelConfig })
268
269 const saveModelConfig = getHandler('settings:upsertModelConfig')
270 await saveModelConfig?.(undefined, {
271 name: 'Responses model',
272 provider: 'openai-responses',
273 model: 'gpt-5.1',
274 apiKey: 'secret',
275 baseUrl: 'https://api.openai.com/v1',
276 maxTokens: 4096,
277 disableTemperature: false,
278 thinkingParameterMode: 'not-valid'
279 })
280
281 expect(upsertModelConfig).toHaveBeenCalledWith(
282 expect.objectContaining({
283 provider: 'openai-responses',
284 thinkingParameterMode: 'auto'
285 })
286 )
287 })
288
289 it('explains invalid Responses API payloads during model verification', async () => {
290 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
291 settingsHandlersState.resolveModelMock.mockReturnValue({
292 invoke: vi.fn(async () => {
293 throw new TypeError("Cannot read properties of undefined (reading 'map')")
294 })
295 })
296 const { getHandler } = await registerWithDb()
297
298 const verifyApiKey = getHandler('settings:verifyApiKey')
299 const result = await verifyApiKey?.(undefined, {
300 provider: 'openai-responses',
301 model: 'gpt-5.5',
302 apiKey: 'secret',
303 baseUrl: 'https://www.toumingren.xyz/v1',
304 maxTokens: 4096,
305 timeoutMs: 60000
306 })
307
308 expect(result).toEqual({
309 valid: false,
310 message: expect.stringContaining('不是 OpenAI Responses API 格式')
311 })
312 })
313
314 it('explains unsupported thinking parameter errors during model verification', async () => {
315 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
316 settingsHandlersState.resolveModelMock.mockReturnValue({
317 invoke: vi.fn(async () => {
318 throw new Error('Unsupported parameter: thinking')
319 })
320 })
321 const { getHandler } = await registerWithDb()
322
323 const verifyApiKey = getHandler('settings:verifyApiKey')
324 const result = await verifyApiKey?.(undefined, {
325 provider: 'openai',
326 model: 'compatible-model',
327 apiKey: 'secret',
328 baseUrl: 'https://api.example-compatible.com/v1',
329 maxTokens: 4096,
330 thinkingParameterMode: 'auto',
331 timeoutMs: 60000
332 })
333
334 expect(result).toEqual({
335 valid: false,
336 message: expect.stringContaining('不发送 thinking 参数')
337 })
338 })
339
340 it('explains unrecognized thinking argument errors during model verification', async () => {
341 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
342 settingsHandlersState.resolveModelMock.mockReturnValue({
343 invoke: vi.fn(async () => {
344 throw new Error('Unrecognized request argument supplied: thinking')
345 })
346 })
347 const { getHandler } = await registerWithDb()
348
349 const verifyApiKey = getHandler('settings:verifyApiKey')
350 const result = await verifyApiKey?.(undefined, {
351 provider: 'openai',
352 model: 'compatible-model',
353 apiKey: 'secret',
354 baseUrl: 'https://api.example-compatible.com/v1',
355 maxTokens: 4096,
356 thinkingParameterMode: 'auto',
357 timeoutMs: 60000
358 })
359
360 expect(result).toEqual({
361 valid: false,
362 message: expect.stringContaining('不发送 thinking 参数')
363 })
364 })
365
366 it('explains older undefined map errors during Responses API verification', async () => {
367 settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh')
368 settingsHandlersState.resolveModelMock.mockReturnValue({
369 invoke: vi.fn(async () => {
370 throw new TypeError("Cannot read property 'map' of undefined")
371 })
372 })
373 const { getHandler } = await registerWithDb()
374
375 const verifyApiKey = getHandler('settings:verifyApiKey')
376 const result = await verifyApiKey?.(undefined, {
377 provider: 'openai-responses',
378 model: 'gpt-5.5',
379 apiKey: 'secret',
380 baseUrl: 'https://www.toumingren.xyz/v1',
381 maxTokens: 4096,
382 timeoutMs: 60000
383 })
384
385 expect(result).toEqual({
386 valid: false,
387 message: expect.stringContaining('OpenAI Responses API')
388 })
389 })
390 })
391
392 describe('registerSettingsHandlers model usage', () => {
393 beforeEach(() => {
394 settingsHandlersState.handlers.clear()
395 settingsHandlersState.ipcMainMock.handle.mockClear()
396 })
397
398 it('delegates the selected usage period to the database', async () => {
399 const stats = {
400 period: '7d',
401 startedAt: 1,
402 totals: {
403 callCount: 2,
404 exactCallCount: 1,
405 estimatedCallCount: 1,
406 inputTokens: 100,
407 outputTokens: 20,
408 totalTokens: 120
409 },
410 byModel: [],
411 byDay: []
412 }
413 const getModelUsageStats = vi.fn(async () => stats)
414 const { getHandler } = await registerWithDb({ getModelUsageStats })
415
416 await expect(getHandler('settings:getModelUsage')?.(undefined, '7d')).resolves.toBe(stats)
417 expect(getModelUsageStats).toHaveBeenCalledWith('7d')
418 })
419
420 it('falls back to 30 days for an invalid usage period', async () => {
421 const getModelUsageStats = vi.fn(async () => ({ period: '30d' }))
422 const { getHandler } = await registerWithDb({ getModelUsageStats })
423
424 await getHandler('settings:getModelUsage')?.(undefined, 'invalid')
425 expect(getModelUsageStats).toHaveBeenCalledWith('30d')
426 })
427 })
428
428 lines TYPESCRIPT