1 line
773 KiB
JSON
1 line
773 KiB
JSON
{"repo": "6sea6/hotel-assistant", "files": {"tests/ai-provider.test.js": {"90cd8761bcc0bde452f791033ddd3849a9eddafe": {"t": 1785746006.2232444, "n": 43499, "h": "e6c36d327e707f0a", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst {\n assertSafeWriteResult,\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\nconst { mergeRefreshBatchResults } = require('../src/main/ai/refresh-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { inputMode: 'place' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'start',\n payload: {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n assert.equal(args['detail-start-interval-ms'], 2000);\n assert.equal(args['warmup-hotel-count'], 3);\n assert.equal(args['max-list-api-replay-concurrency'], 2);\n assert.equal(args['list-candidate-cache-ttl-ms'], 15 * 60 * 1000);\n assert.equal(args['direct-room-replay'], false);\n assert.equal(args['include-mobile-html'], false);\n assert.equal(args['risk-control-retries'], 0);\n\n const addressArgs = buildScraperArgs(\n {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(addressArgs.inputMode, 'address');\n assert.equal(addressArgs.addressQuery, '国家会展中心');\n assert.equal(addressArgs.templateId, '100');\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI address task forwards address query to hotel task runner', async () => {\n const calls = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async (input) => {\n calls.push(input);\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n await service.startTask({\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100',\n listUrlFilters: {\n priceMin: 200\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].inputMode, 'address');\n assert.equal(calls[0].addressQuery, '国家会展中心');\n assert.equal(calls[0].url, undefined);\n assert.deepEqual(calls[0].listUrlFilters, { priceMin: 200 });\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner writes cleared hotel groups even without new rooms', async () => {\n const events = [];\n const writes = [];\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=895608',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2'\n ],\n requestedConcurrency: 1,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n if (index === 1) {\n return {\n hotelName: '恒夏酒店',\n url,\n status: 'cleared',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 1,\n deleteExistingGroup: true,\n existingHotels: [\n {\n name: '恒夏酒店',\n website: url,\n room_type: '家庭房'\n }\n ],\n clearReason: '当前日期不接受预订'\n };\n }\n return {\n hotelName: '酒店二',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '采集未返回有效房型数据'\n };\n },\n writeHotels: async (hotels, context) => {\n writes.push({ hotels, context });\n return [{ operation: 'deleted-group', deletedCount: 1 }];\n }\n });\n\n assert.equal(writes.length, 1);\n assert.deepEqual(writes[0].hotels, []);\n assert.equal(writes[0].context.clearedItems.length, 1);\n assert.equal(result.updatedHotelCount, 1);\n assert.equal(result.updatedRoomTypeCount, 0);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(\n events.find((event) => event.type === 'refresh:item-done').details.status,\n 'cleared'\n );\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh prepared detail batch uses the adaptive scheduler for every hotel', async () => {\n const starts = [];\n const outcomes = [];\n const detailScheduler = {\n async beforeStart(meta) {\n starts.push(meta.index);\n },\n recordOutcome(_result, meta) {\n outcomes.push(meta.index);\n return { circuit_open: false };\n },\n recordError() {\n throw new Error('scheduler should not record an error on the success path');\n }\n };\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 2,\n detailScheduler,\n emit() {},\n runPreparedDetails: async ({ items, createDetailContext, mapPreparedResult }) => {\n for (let zeroBasedIndex = 0; zeroBasedIndex < items.length; zeroBasedIndex += 1) {\n const index = zeroBasedIndex + 1;\n const prepared = await createDetailContext({\n item: items[zeroBasedIndex],\n zeroBasedIndex,\n index,\n total: items.length,\n worker: { id: index }\n });\n await mapPreparedResult({\n preparedResult: { result: { totalPrice: 188, pageSnapshot: {} } },\n meta: prepared.meta\n });\n }\n },\n createDetailContext: async () => ({ context: {} }),\n mapPreparedResult: async ({ url, index }) => ({\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1\n }),\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.deepEqual(starts, [1, 2]);\n assert.deepEqual(outcomes, [1, 2]);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh batch merge replaces login retry skips with retry results', () => {\n const firstPass = {\n requestedConcurrency: 2,\n effectiveConcurrency: 2,\n totalHotelCount: 2,\n updatedHotelCount: 1,\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 1,\n items: [\n {\n hotelName: '已更新酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n status: 'updated',\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0\n },\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'skipped',\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n retryAfterLogin: true,\n skipReason: '已找到房型信息,但未采集到有效价格'\n }\n ],\n updatedHotels: [{ id: 1, name: '已更新酒店' }],\n rawWriteResult: [{ operation: 'first' }]\n };\n const retryPass = {\n items: [\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'updated',\n updatedRoomTypeCount: 3,\n deletedRoomTypeCount: 1\n }\n ],\n updatedHotels: [{ id: 2, name: '锁价酒店' }],\n rawWriteResult: [{ operation: 'retry' }]\n };\n\n const merged = mergeRefreshBatchResults(firstPass, retryPass);\n\n assert.equal(merged.updatedHotelCount, 2);\n assert.equal(merged.skippedHotelCount, 0);\n assert.equal(merged.updatedRoomTypeCount, 4);\n assert.equal(merged.deletedRoomTypeCount, 1);\n assert.deepEqual(\n merged.items.map((item) => item.status),\n ['updated', 'updated']\n );\n assert.deepEqual(\n merged.rawWriteResult.map((item) => item.operation),\n ['first', 'retry']\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login only when Ctrip price is locked', () => {\n const loginRequiredWithPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 166,\n roomPrices: [166],\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。',\n room_candidates_count: 3,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(loginRequiredWithPrice.needed, true);\n assert.match(loginRequiredWithPrice.reason, /登录看低价/);\n\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, false);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n\ntest('AI scraper write guard rejects login-required results even when price exists', () => {\n const safety = assertSafeWriteResult({\n success: true,\n eligibleCount: 2,\n totalPrice: 166,\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。'\n }\n });\n\n assert.equal(safety.ok, false);\n assert.match(safety.reason, /登录看低价/);\n});\n\ntest('AI scraper write guard preserves login-required reason for aborted results', () => {\n const safety = assertSafeWriteResult({\n success: false,\n error: '任务已取消',\n pageSnapshot: {\n login_required: true,\n login_reason:\n '携程房价接口返回 203。若可见浏览器已确认登录但房型仍没有价格,说明当前账号或访问环境被携程风控;将继续使用稳定浏览器资料,请暂停采集并人工确认账号恢复后再试。'\n }\n });\n\n assert.equal(safety.ok, false);\n assert.match(safety.reason, /203|风控|稳定浏览器资料/);\n});\n"}, "5aaedcb551fb0a0c7074a46ed7ab5d54bd2eeca8": {"t": 1785758714.0793955, "n": 39467, "h": "34e4576ae39fee5e", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst {\n assertSafeWriteResult,\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\nconst { mergeRefreshBatchResults } = require('../src/main/ai/refresh-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { inputMode: 'place' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'start',\n payload: {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n\n const addressArgs = buildScraperArgs(\n {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(addressArgs.inputMode, 'address');\n assert.equal(addressArgs.addressQuery, '国家会展中心');\n assert.equal(addressArgs.templateId, '100');\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI address task forwards address query to hotel task runner', async () => {\n const calls = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async (input) => {\n calls.push(input);\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n await service.startTask({\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100',\n listUrlFilters: {\n priceMin: 200\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].inputMode, 'address');\n assert.equal(calls[0].addressQuery, '国家会展中心');\n assert.equal(calls[0].url, undefined);\n assert.deepEqual(calls[0].listUrlFilters, { priceMin: 200 });\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh batch merge replaces login retry skips with retry results', () => {\n const firstPass = {\n requestedConcurrency: 2,\n effectiveConcurrency: 2,\n totalHotelCount: 2,\n updatedHotelCount: 1,\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 1,\n items: [\n {\n hotelName: '已更新酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n status: 'updated',\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0\n },\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'skipped',\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n retryAfterLogin: true,\n skipReason: '已找到房型信息,但未采集到有效价格'\n }\n ],\n updatedHotels: [{ id: 1, name: '已更新酒店' }],\n rawWriteResult: [{ operation: 'first' }]\n };\n const retryPass = {\n items: [\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'updated',\n updatedRoomTypeCount: 3,\n deletedRoomTypeCount: 1\n }\n ],\n updatedHotels: [{ id: 2, name: '锁价酒店' }],\n rawWriteResult: [{ operation: 'retry' }]\n };\n\n const merged = mergeRefreshBatchResults(firstPass, retryPass);\n\n assert.equal(merged.updatedHotelCount, 2);\n assert.equal(merged.skippedHotelCount, 0);\n assert.equal(merged.updatedRoomTypeCount, 4);\n assert.equal(merged.deletedRoomTypeCount, 1);\n assert.deepEqual(\n merged.items.map((item) => item.status),\n ['updated', 'updated']\n );\n assert.deepEqual(\n merged.rawWriteResult.map((item) => item.operation),\n ['first', 'retry']\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login only when Ctrip price is locked', () => {\n const loginRequiredWithPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 166,\n roomPrices: [166],\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。',\n room_candidates_count: 3,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(loginRequiredWithPrice.needed, true);\n assert.match(loginRequiredWithPrice.reason, /登录看低价/);\n\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, false);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n\ntest('AI scraper write guard rejects login-required results even when price exists', () => {\n const safety = assertSafeWriteResult({\n success: true,\n eligibleCount: 2,\n totalPrice: 166,\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。'\n }\n });\n\n assert.equal(safety.ok, false);\n assert.match(safety.reason, /登录看低价/);\n});\n\ntest('AI scraper write guard preserves login-required reason for aborted results', () => {\n const safety = assertSafeWriteResult({\n success: false,\n error: '任务已取消',\n pageSnapshot: {\n login_required: true,\n login_reason:\n '携程房价接口返回 203。若可见浏览器已确认登录但房型仍没有价格,说明当前账号或访问环境被携程风控;将继续使用稳定浏览器资料,请暂停采集并人工确认账号恢复后再试。'\n }\n });\n\n assert.equal(safety.ok, false);\n assert.match(safety.reason, /203|风控|稳定浏览器资料/);\n});\n"}, "8d01aa099b99a12707789603badb6190d99b3f75": {"t": 1785758715.8486998, "n": 39034, "h": "27183280cdf32698", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst {\n assertSafeWriteResult,\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\nconst { mergeRefreshBatchResults } = require('../src/main/ai/refresh-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { inputMode: 'place' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'start',\n payload: {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n\n const addressArgs = buildScraperArgs(\n {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(addressArgs.inputMode, 'address');\n assert.equal(addressArgs.addressQuery, '国家会展中心');\n assert.equal(addressArgs.templateId, '100');\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI address task forwards address query to hotel task runner', async () => {\n const calls = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async (input) => {\n calls.push(input);\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n await service.startTask({\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100',\n listUrlFilters: {\n priceMin: 200\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].inputMode, 'address');\n assert.equal(calls[0].addressQuery, '国家会展中心');\n assert.equal(calls[0].url, undefined);\n assert.deepEqual(calls[0].listUrlFilters, { priceMin: 200 });\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh batch merge replaces login retry skips with retry results', () => {\n const firstPass = {\n requestedConcurrency: 2,\n effectiveConcurrency: 2,\n totalHotelCount: 2,\n updatedHotelCount: 1,\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 1,\n items: [\n {\n hotelName: '已更新酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n status: 'updated',\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0\n },\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'skipped',\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n retryAfterLogin: true,\n skipReason: '已找到房型信息,但未采集到有效价格'\n }\n ],\n updatedHotels: [{ id: 1, name: '已更新酒店' }],\n rawWriteResult: [{ operation: 'first' }]\n };\n const retryPass = {\n items: [\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'updated',\n updatedRoomTypeCount: 3,\n deletedRoomTypeCount: 1\n }\n ],\n updatedHotels: [{ id: 2, name: '锁价酒店' }],\n rawWriteResult: [{ operation: 'retry' }]\n };\n\n const merged = mergeRefreshBatchResults(firstPass, retryPass);\n\n assert.equal(merged.updatedHotelCount, 2);\n assert.equal(merged.skippedHotelCount, 0);\n assert.equal(merged.updatedRoomTypeCount, 4);\n assert.equal(merged.deletedRoomTypeCount, 1);\n assert.deepEqual(\n merged.items.map((item) => item.status),\n ['updated', 'updated']\n );\n assert.deepEqual(\n merged.rawWriteResult.map((item) => item.operation),\n ['first', 'retry']\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login only when Ctrip price is locked', () => {\n const loginRequiredWithPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 166,\n roomPrices: [166],\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。',\n room_candidates_count: 3,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(loginRequiredWithPrice.needed, true);\n assert.match(loginRequiredWithPrice.reason, /登录看低价/);\n\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, false);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n\ntest('AI scraper write guard rejects login-required results even when price exists', () => {\n const safety = assertSafeWriteResult({\n success: true,\n eligibleCount: 2,\n totalPrice: 166,\n pageSnapshot: {\n login_required: true,\n login_reason: '检测到携程页面显示“登录看低价/解锁优惠”。'\n }\n });\n\n assert.equal(safety.ok, false);\n assert.match(safety.reason, /登录看低价/);\n});\n"}, "43cec31d1ca5d6aea0b76c0459de1cd5d430fca1": {"t": 1785758717.344675, "n": 38260, "h": "8f171679214467e4", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\nconst { mergeRefreshBatchResults } = require('../src/main/ai/refresh-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { inputMode: 'place' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'start',\n payload: {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n\n const addressArgs = buildScraperArgs(\n {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(addressArgs.inputMode, 'address');\n assert.equal(addressArgs.addressQuery, '国家会展中心');\n assert.equal(addressArgs.templateId, '100');\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI address task forwards address query to hotel task runner', async () => {\n const calls = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async (input) => {\n calls.push(input);\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n await service.startTask({\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100',\n listUrlFilters: {\n priceMin: 200\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].inputMode, 'address');\n assert.equal(calls[0].addressQuery, '国家会展中心');\n assert.equal(calls[0].url, undefined);\n assert.deepEqual(calls[0].listUrlFilters, { priceMin: 200 });\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh batch merge replaces login retry skips with retry results', () => {\n const firstPass = {\n requestedConcurrency: 2,\n effectiveConcurrency: 2,\n totalHotelCount: 2,\n updatedHotelCount: 1,\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 1,\n items: [\n {\n hotelName: '已更新酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n status: 'updated',\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0\n },\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'skipped',\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n retryAfterLogin: true,\n skipReason: '已找到房型信息,但未采集到有效价格'\n }\n ],\n updatedHotels: [{ id: 1, name: '已更新酒店' }],\n rawWriteResult: [{ operation: 'first' }]\n };\n const retryPass = {\n items: [\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'updated',\n updatedRoomTypeCount: 3,\n deletedRoomTypeCount: 1\n }\n ],\n updatedHotels: [{ id: 2, name: '锁价酒店' }],\n rawWriteResult: [{ operation: 'retry' }]\n };\n\n const merged = mergeRefreshBatchResults(firstPass, retryPass);\n\n assert.equal(merged.updatedHotelCount, 2);\n assert.equal(merged.skippedHotelCount, 0);\n assert.equal(merged.updatedRoomTypeCount, 4);\n assert.equal(merged.deletedRoomTypeCount, 1);\n assert.deepEqual(\n merged.items.map((item) => item.status),\n ['updated', 'updated']\n );\n assert.deepEqual(\n merged.rawWriteResult.map((item) => item.operation),\n ['first', 'retry']\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "3a87a87eb226df28cc9e0892d333d1b33cd7b59e": {"t": 1785758718.9692664, "n": 40205, "h": "b14b778caddce3f8", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\nconst { mergeRefreshBatchResults } = require('../src/main/ai/refresh-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { inputMode: 'place' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'start',\n payload: {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n\n const addressArgs = buildScraperArgs(\n {\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100'\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(addressArgs.inputMode, 'address');\n assert.equal(addressArgs.addressQuery, '国家会展中心');\n assert.equal(addressArgs.templateId, '100');\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI address task forwards address query to hotel task runner', async () => {\n const calls = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async (input) => {\n calls.push(input);\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n await service.startTask({\n inputMode: 'address',\n addressQuery: '国家会展中心',\n templateId: '100',\n listUrlFilters: {\n priceMin: 200\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].inputMode, 'address');\n assert.equal(calls[0].addressQuery, '国家会展中心');\n assert.equal(calls[0].url, undefined);\n assert.deepEqual(calls[0].listUrlFilters, { priceMin: 200 });\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('refresh batch merge replaces login retry skips with retry results', () => {\n const firstPass = {\n requestedConcurrency: 2,\n effectiveConcurrency: 2,\n totalHotelCount: 2,\n updatedHotelCount: 1,\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 1,\n items: [\n {\n hotelName: '已更新酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n status: 'updated',\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0\n },\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'skipped',\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n retryAfterLogin: true,\n skipReason: '已找到房型信息,但未采集到有效价格'\n }\n ],\n updatedHotels: [{ id: 1, name: '已更新酒店' }],\n rawWriteResult: [{ operation: 'first' }]\n };\n const retryPass = {\n items: [\n {\n hotelName: '锁价酒店',\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n status: 'updated',\n updatedRoomTypeCount: 3,\n deletedRoomTypeCount: 1\n }\n ],\n updatedHotels: [{ id: 2, name: '锁价酒店' }],\n rawWriteResult: [{ operation: 'retry' }]\n };\n\n const merged = mergeRefreshBatchResults(firstPass, retryPass);\n\n assert.equal(merged.updatedHotelCount, 2);\n assert.equal(merged.skippedHotelCount, 0);\n assert.equal(merged.updatedRoomTypeCount, 4);\n assert.equal(merged.deletedRoomTypeCount, 1);\n assert.deepEqual(\n merged.items.map((item) => item.status),\n ['updated', 'updated']\n );\n assert.deepEqual(\n merged.rawWriteResult.map((item) => item.operation),\n ['first', 'retry']\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "f7ac82035ca02d8beae555859b032a0c7ceb3172": {"t": 1785758721.6589966, "n": 36505, "h": "0f1c87da75469dab", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 7 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner caps concurrency at three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=5',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=6'\n ],\n requestedConcurrency: 6,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 6 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 6);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "878033951808544ea369085b0432c282393fb05b": {"t": 1785758723.1980033, "n": 36386, "h": "e14846b5dae087a7", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 4 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner allows concurrency three like normal collection', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=4'\n ],\n requestedConcurrency: 3,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 10));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 4 })\n });\n\n assert.equal(maxActiveRefreshes, 3);\n assert.equal(result.requestedConcurrency, 3);\n assert.equal(result.effectiveConcurrency, 3);\n assert.equal(result.updatedHotelCount, 4);\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "c0fdab9b7ab65498a98bb5b5598399efbb4afacd": {"t": 1785758724.8527796, "n": 35085, "h": "e6cc322a491e20ce", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 4 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n priceMax: undefined,\n sortMode: undefined,\n listUrlFilters: {\n priceMax: undefined,\n sortMode: undefined,\n accommodationTypeMode: 'include',\n accommodationTypes: ['民宿']\n }\n }\n },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n accommodationTypeMode: 'exclude',\n accommodationTypes: ['民宿', '公寓'],\n roomTypes: ['双床房'],\n roomFeatures: ['家庭房'],\n featureThemes: ['电竞酒店'],\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].input.accommodationTypeMode, 'exclude');\n assert.deepEqual(calls[0].input.accommodationTypes, ['民宿', '公寓']);\n assert.deepEqual(calls[0].input.roomTypes, ['双床房']);\n assert.deepEqual(calls[0].input.roomFeatures, ['家庭房']);\n assert.deepEqual(calls[0].input.featureThemes, ['电竞酒店']);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "1f81d2f2d990a915d71f44c739d4dfdd3ac3553b": {"t": 1785758726.2537782, "n": 34242, "h": "12114f938df952bf", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 4 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { collectBrowser: 'chrome' }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, { collectBrowser: 'chrome' }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n excludeHotelTypes: [123]\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n assert.deepEqual(\n await handlers.get('ai:task:refresh-data')(event, {\n collectBrowser: '360',\n batchConcurrency: 3\n }),\n { success: true }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} },\n {\n channel: 'start',\n payload: {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n collectBrowser: '360',\n batchConcurrency: 3\n }\n },\n {\n channel: 'refresh',\n payload: {\n collectBrowser: '360',\n batchConcurrency: 3\n }\n }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n collectBrowser: '360',\n batchConcurrency: 3\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args.browser, '360');\n assert.equal(args['batch-concurrency'], 3);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3,\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n collectBrowser: '360',\n batchConcurrency: 3\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.collectBrowser, '360');\n assert.equal(calls[0].input.batchConcurrency, 3);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "ff11b13c3b8d7bd3b295c25d0380680168e4d7ad": {"t": 1785758727.6696863, "n": 32998, "h": "0f4100c73a7dab0e", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot,\n runRefreshHotelBatch\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, { batchConcurrency: 3 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n excludeHotelTypes: [123]\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n batchConcurrency: 2\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args['batch-concurrency'], 2);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n batchConcurrency: 2,\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.batchConcurrency, 2);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI refresh passes batch concurrency to scraper runner', async (t) => {\n const aiServicePath = require.resolve('../src/main/services/ai-service');\n const lazyLoader = require('../src/main/ai/scraper-lazy-loader');\n const originalLoadScraperRunner = lazyLoader.loadScraperRunner;\n const originalAiServiceCache = require.cache[aiServicePath];\n const calls = [];\n const events = [];\n\n lazyLoader.loadScraperRunner = async () => ({\n refreshExistingCtripHotels: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'refresh:summary', message: '更新完成' });\n return {\n success: true,\n totalHotelCount: 2,\n updatedHotelCount: 2,\n updatedRoomTypeCount: 4,\n deletedRoomTypeCount: 0,\n skippedHotelCount: 0,\n items: [],\n writeResult: { batchMode: true, appliedCount: 2 }\n };\n }\n });\n delete require.cache[aiServicePath];\n\n t.after(() => {\n lazyLoader.loadScraperRunner = originalLoadScraperRunner;\n delete require.cache[aiServicePath];\n if (originalAiServiceCache) {\n require.cache[aiServicePath] = originalAiServiceCache;\n }\n });\n\n const {\n createAiService: createAiServiceWithMockRunner\n } = require('../src/main/services/ai-service');\n const service = createAiServiceWithMockRunner({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n }\n });\n\n const result = await service.refreshHotelData({\n amapKey: 'custom-amap-key',\n batchConcurrency: 2\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.batchConcurrency, 2);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.updatedHotelCount, 2);\n assert.ok(\n events.some(\n (event) => event.channel === 'ai:task:event' && event.payload.type === 'refresh:summary'\n )\n );\n});\n\ntest('refresh hotel batch runner honors bounded concurrency and writes once after collection', async () => {\n const events = [];\n const writtenBatches = [];\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: [\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=2',\n 'https://hotels.ctrip.com/hotels/detail/?hotelId=3'\n ],\n requestedConcurrency: 2,\n signal: null,\n emit(type, message, details) {\n events.push({ type, message, details });\n },\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, index === 1 ? 30 : 5));\n activeRefreshes -= 1;\n\n if (index === 3) {\n return {\n hotelName: '酒店三',\n url,\n status: 'skipped',\n updatedHotels: [],\n updatedRoomTypeCount: 0,\n deletedRoomTypeCount: 0,\n skipReason: '没有有效房型',\n error: ''\n };\n }\n\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [\n { name: `酒店${index}`, room_type: '大床房' },\n { name: `酒店${index}`, room_type: '双床房' }\n ],\n updatedRoomTypeCount: 2,\n deletedRoomTypeCount: index === 1 ? 1 : 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async (hotels) => {\n writtenBatches.push(hotels);\n return { batchMode: true, appliedCount: hotels.length };\n }\n });\n\n assert.equal(maxActiveRefreshes, 2);\n assert.equal(writtenBatches.length, 1);\n assert.deepEqual(\n writtenBatches[0].map((hotel) => `${hotel.name}:${hotel.room_type}`),\n ['酒店1:大床房', '酒店1:双床房', '酒店2:大床房', '酒店2:双床房']\n );\n assert.equal(result.requestedConcurrency, 2);\n assert.equal(result.effectiveConcurrency, 2);\n assert.equal(result.updatedHotelCount, 2);\n assert.equal(result.updatedRoomTypeCount, 4);\n assert.equal(result.deletedRoomTypeCount, 1);\n assert.equal(result.skippedHotelCount, 1);\n assert.equal(result.items.length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-start').length, 3);\n assert.equal(events.filter((event) => event.type === 'refresh:item-done').length, 2);\n assert.equal(events.filter((event) => event.type === 'refresh:item-skipped').length, 1);\n assert.equal(events.filter((event) => event.type === 'refresh:write').length, 1);\n assert.equal(events.find((event) => event.type === 'refresh:write').details.scope, 'final');\n});\n\ntest('refresh hotel batch runner stays serial when requested concurrency is one', async () => {\n let activeRefreshes = 0;\n let maxActiveRefreshes = 0;\n\n const result = await runRefreshHotelBatch({\n hotelUrls: ['url-1', 'url-2'],\n requestedConcurrency: 1,\n emit() {},\n processHotel: async ({ url, index }) => {\n activeRefreshes += 1;\n maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);\n await new Promise((resolve) => setTimeout(resolve, 5));\n activeRefreshes -= 1;\n return {\n hotelName: `酒店${index}`,\n url,\n status: 'updated',\n updatedHotels: [{ name: `酒店${index}`, room_type: '大床房' }],\n updatedRoomTypeCount: 1,\n deletedRoomTypeCount: 0,\n skipReason: '',\n error: ''\n };\n },\n writeHotels: async () => ({ batchMode: true, appliedCount: 2 })\n });\n\n assert.equal(maxActiveRefreshes, 1);\n assert.equal(result.requestedConcurrency, 1);\n assert.equal(result.effectiveConcurrency, 1);\n assert.equal(result.updatedHotelCount, 2);\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "1fd35e3270fe9a29386e3c5201a8a0cb8b180449": {"t": 1785758729.4542038, "n": 26888, "h": "6ec128dd1fc27df9", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n excludeHotelTypes: [123]\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n batchConcurrency: 2\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args['batch-concurrency'], 2);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n batchConcurrency: 2,\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.batchConcurrency, 2);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task reloads the data store after scraper writeback', async () => {\n const dataFolderPath = 'E:/实验/1/宾馆比较助手';\n const store = {\n get() {\n return {};\n },\n set() {}\n };\n const reinitializeCalls = [];\n const service = createAiService({\n dataService: {\n getStore() {\n return store;\n },\n getDataFolderPath() {\n return dataFolderPath;\n },\n reinitializeStore(folder) {\n reinitializeCalls.push(folder);\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send() {}\n }\n };\n }\n },\n hotelTaskRunner: async () => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n })\n });\n\n await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100'\n });\n\n assert.deepEqual(reinitializeCalls, [dataFolderPath]);\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "2583c153edc9cc09e6fe20f5a3a8ab8356dbe272": {"t": 1785758730.9370522, "n": 25801, "h": "c1f5141b0d7125c1", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n buildScraperArgs,\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n excludeHotelTypes: [123]\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner maps batch concurrency to scraper arguments', () => {\n const args = buildScraperArgs(\n {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n batchConcurrency: 2\n },\n path.join(os.tmpdir(), 'hotel-scraper-workdir')\n );\n\n assert.equal(args['batch-concurrency'], 2);\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n batchConcurrency: 2,\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.equal(calls[0].input.batchConcurrency, 2);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "274e5b1b51fdc4c8e681bbb208300a096bd852fa": {"t": 1785758732.3297632, "n": 25359, "h": "c5cba76fa4241dfc", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, { url: 123 }), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n targetCount: 'many'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n listFilters: 'bad'\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:task:start')(event, {\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n excludeHotelTypes: [123]\n }),\n {\n success: false,\n error: '无效的 AI 请求参数'\n }\n );\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('AI IPC validates Ctrip list URL parser and builder inputs', async () => {\n const handlers = new Map();\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n refreshHotelData() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:ctrip-list-url:parse')(event, 42), {\n success: false,\n error: '无效的携程列表页链接'\n });\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:parse')(event, 'http://hotels.ctrip.com/'),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'file:///tmp/list.html',\n settings: {}\n }),\n {\n success: false,\n error: '无效的携程列表页链接'\n }\n );\n assert.deepEqual(\n await handlers.get('ai:ctrip-list-url:build')(event, {\n baseUrl: 'https://hotels.ctrip.com/hotels/list',\n settings: 'bad'\n }),\n {\n success: false,\n error: '无效的携程列表页参数'\n }\n );\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "a874530a1260551eb03ef4d3a832bc7b5dc5afbe": {"t": 1785758733.7710617, "n": 23191, "h": "29af1e610ebf00e4", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\nfunction createTrustedIpcEvent() {\n return {\n senderFrame: { url: 'file:///trusted/index.html' },\n sender: {}\n };\n}\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')(createTrustedIpcEvent());\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n const event = createTrustedIpcEvent();\n\n assert.deepEqual(await handlers.get('ai:config:save')(event, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(event, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(event, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(event, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(event, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "5cf58810f0ad050120e397ffff69ab35f22a5a08": {"t": 1785758735.3837218, "n": 23021, "h": "8086b7e0fde3c8c0", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('AI IPC normalizes unsafe renderer payloads at the handler boundary', async () => {\n const handlers = new Map();\n const received = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n handlers.set(channel, handler);\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig(config) {\n received.push({ channel: 'save', config });\n return { success: true };\n },\n testConnection(config) {\n received.push({ channel: 'test', config });\n return { success: true };\n },\n sendChat(payload) {\n received.push({ channel: 'chat', payload });\n return { success: true };\n },\n startTask(payload) {\n received.push({ channel: 'start', payload });\n return { success: true };\n },\n refreshHotelData(payload) {\n received.push({ channel: 'refresh', payload });\n return { success: true };\n },\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.deepEqual(await handlers.get('ai:config:save')(undefined, 'bad'), { success: true });\n assert.deepEqual(await handlers.get('ai:config:test')(undefined, null), { success: true });\n assert.deepEqual(await handlers.get('ai:chat:send')(undefined, 'bad'), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:start')(undefined, null), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n assert.deepEqual(await handlers.get('ai:task:refresh-data')(undefined, []), {\n success: false,\n error: '无效的 AI 请求参数'\n });\n\n assert.deepEqual(received, [\n { channel: 'save', config: {} },\n { channel: 'test', config: {} }\n ]);\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "0c0cbb61f9058bd2c5212663694f62dd8ebc7e88": {"t": 1785758736.729909, "n": 21217, "h": "184a7725e651f4be", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "43498279f02b72157ac0eabf5c5c6b5dcc74aad6": {"t": 1785758738.357423, "n": 21219, "h": "eaf9af7a8311d790", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveRootPerfLogDir,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('scraper runner perfLogDir resolves to program root logs/perf', () => {\n const perfLogDir = resolveRootPerfLogDir();\n\n assert.equal(perfLogDir, path.resolve('logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n assert.ok(perfLogDir.endsWith(path.join('logs', 'perf')));\n});\n\ntest('scraper runner perfLogDir is independent of workDir', () => {\n const perfLogDir = resolveRootPerfLogDir();\n const fakeWorkDir = path.join(os.tmpdir(), 'some-work-dir', 'scraper-data');\n\n assert.notEqual(perfLogDir, path.join(fakeWorkDir, 'logs', 'perf'));\n assert.ok(!perfLogDir.includes('scraper-data'));\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\n\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "1bc0d11c05f528c80c72739fc5523f9a09b489da": {"t": 1785758739.8074808, "n": 20574, "h": "c3894634fbd91ec3", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\n\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "af9a70ee13de4f91f048b0b5477d6d6640eed73b": {"t": 1785758741.154412, "n": 29748, "h": "2704f24a50b088fd", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.equal(properties.maxPages, undefined);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [\n {\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [\n {\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }\n ],\n diffs: [\n {\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }\n ],\n evidence: [\n {\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }\n ],\n missingEvidence: []\n })\n }\n }\n ]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [\n {\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }\n ],\n eligibleRoomTypes: [\n {\n id: 'room-candidate-001',\n totalPrice: 300\n }\n ],\n rejectedRoomTypes: [],\n rawRoomCandidates: [\n {\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }\n ],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [\n {\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n },\n {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }\n ],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content =\n capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [\n {\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }\n ],\n diffs: [\n {\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }\n ],\n evidence: [\n {\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }\n ],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [\n {\n message: {\n role: 'assistant',\n content\n }\n }\n ]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [\n {\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }\n ],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [\n {\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }\n ],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "974f0c85780312ce6f759ccc56a25e3d86453790": {"t": 1785758742.5497234, "n": 29795, "h": "518ad2520c36b1bd", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst { buildSystemPrompt, createAiService } = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig(\n {\n provider: 'mimo'\n },\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n }\n );\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n },\n [{ role: 'user', content: 'hello' }],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]\n );\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest(\n {\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n },\n [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ],\n [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ],\n {\n maxTokens: 32\n }\n );\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find(\n (tool) => tool.function.name === 'collect_and_write_ctrip_hotel'\n );\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.ok(properties.maxPages);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({\n currentDir,\n existsSync: fs.existsSync,\n isBundledWithScraper: () => false\n }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(\n storePath,\n JSON.stringify(\n {\n hotels: [...originalStore.hotels, { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }],\n templates: [],\n settings: {}\n },\n null,\n 2\n ),\n 'utf8'\n );\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(\n isTaskCancelled(\n Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),\n null\n ),\n true\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n maxPages: 2,\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.maxPages, 2);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(\n events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done')\n );\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(\n events.some((event) => event.payload.type === 'task:error'),\n false\n );\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [\n {\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [\n {\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }\n ],\n diffs: [\n {\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }\n ],\n evidence: [\n {\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }\n ],\n missingEvidence: []\n })\n }\n }\n ]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [\n {\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }\n ],\n eligibleRoomTypes: [\n {\n id: 'room-candidate-001',\n totalPrice: 300\n }\n ],\n rejectedRoomTypes: [],\n rawRoomCandidates: [\n {\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }\n ],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [\n {\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n },\n {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }\n ],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content =\n capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [\n {\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }\n ],\n diffs: [\n {\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }\n ],\n evidence: [\n {\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }\n ],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [\n {\n message: {\n role: 'assistant',\n content\n }\n }\n ]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [\n {\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }\n ],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [\n {\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }\n ],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "10b3f3a99ae7627b80e40f63ec980bf760da2ee8": {"t": 1785758744.091516, "n": 28893, "h": "9ec5709f9f437d70", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst {\n buildSystemPrompt,\n createAiService\n} = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n }, [\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]);\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n }, [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ], {\n maxTokens: 32\n });\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n amapApiKey: 'amap-secret',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n assert.equal(sanitized.amapApiKey, '[REDACTED]');\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find((tool) => tool.function.name === 'collect_and_write_ctrip_hotel');\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.ok(properties.maxPages);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({ currentDir, existsSync: fs.existsSync, isBundledWithScraper: () => false }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({\n hotels: [\n ...originalStore.hotels,\n { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }\n ],\n templates: [],\n settings: {}\n }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(isTaskCancelled(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }), null), true);\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n maxPages: 2,\n amapKey: 'custom-amap-key',\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.maxPages, 2);\n assert.equal(calls[0].input.amapKey, 'custom-amap-key');\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done'));\n});\n\ntest('direct AI task cancellation emits cancel status instead of task error', async () => {\n const events = [];\n let capturedContext = null;\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (_input, context) => {\n capturedContext = context;\n await new Promise((resolve) => setTimeout(resolve, 5));\n if (context.signal.aborted) {\n throw new Error('任务已取消');\n }\n return {\n success: true,\n hotelName: '不应完成',\n eligibleCount: 1,\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const taskPromise = service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateName: '武汉'\n });\n await new Promise((resolve) => setTimeout(resolve, 0));\n\n const cancelResult = service.cancelTask();\n assert.equal(cancelResult.success, true);\n assert.equal(capturedContext.signal.aborted, true);\n await assert.rejects(taskPromise, /任务已取消/);\n\n const status = service.getTaskStatus();\n assert.equal(status.status, 'cancelled');\n assert.equal(status.error, '任务已取消');\n assert.ok(events.some((event) => event.payload.type === 'task:cancel'));\n assert.equal(events.some((event) => event.payload.type === 'task:error'), false);\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }],\n diffs: [{\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }],\n missingEvidence: []\n })\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }],\n eligibleRoomTypes: [{\n id: 'room-candidate-001',\n totalPrice: 300\n }],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [{\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n }, {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content = capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }],\n diffs: [{\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "823af6571bea9e029ee45efb48987570c5d9caf6": {"t": 1785758745.5993698, "n": 27130, "h": "9c5dc3b03207221d", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst {\n buildSystemPrompt,\n createAiService\n} = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n }, [\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]);\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n }, [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ], {\n maxTokens: 32\n });\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先合并携程 URL 前筛/);\n assert.doesNotMatch(prompt, /排除名称关键词/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find((tool) => tool.function.name === 'collect_and_write_ctrip_hotel');\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.listUrlFilters);\n assert.ok(properties.desiredHotelCount);\n assert.equal(properties.minScore, undefined);\n assert.equal(properties.excludeKeywords, undefined);\n assert.ok(properties.excludeHotelTypes);\n assert.ok(properties.maxPages);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.ok(handlers.has('ai:ctrip-list-url:parse'));\n assert.ok(handlers.has('ai:ctrip-list-url:build'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n assert.ok(channels.includes('ai:ctrip-list-url:parse'));\n assert.ok(channels.includes('ai:ctrip-list-url:build'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({ currentDir, existsSync: fs.existsSync, isBundledWithScraper: () => false }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({\n hotels: [\n ...originalStore.hotels,\n { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }\n ],\n templates: [],\n settings: {}\n }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(isTaskCancelled(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }), null), true);\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n excludeHotelTypes: ['民宿'],\n maxPages: 2,\n listUrlFilters: {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n }\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.maxPages, 2);\n assert.deepEqual(calls[0].input.listUrlFilters, {\n priceMin: 50,\n priceMax: 200,\n starLevels: [3, 4],\n sortMode: 'review_high',\n freeCancel: true,\n reviewCountMin: 500,\n ctripScoreMin: 4.5\n });\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done'));\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }],\n diffs: [{\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }],\n missingEvidence: []\n })\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }],\n eligibleRoomTypes: [{\n id: 'room-candidate-001',\n totalPrice: 300\n }],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [{\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n }, {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content = capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }],\n diffs: [{\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "e18ca6ac89b64651492521ad6c8f0faabea05186": {"t": 1785758747.3330462, "n": 26521, "h": "f129b67c913f292d", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst {\n buildSystemPrompt,\n createAiService\n} = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { AI_TOOL_DEFINITIONS, sanitizeSettings } = require('../src/main/ai/tools');\nconst {\n createWriteRollbackSnapshot,\n getVisibleLoginRetryNeed,\n isTaskCancelled,\n resolveScraperPath,\n restoreWriteRollbackSnapshot\n} = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n }, [\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]);\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n }, [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ], {\n maxTokens: 32\n });\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /详情页链接和酒店列表页链接/);\n assert.match(prompt, /列表页会先按最低评分/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI collect tool schema documents list and detail URL inputs', () => {\n const collectTool = AI_TOOL_DEFINITIONS.find((tool) => tool.function.name === 'collect_and_write_ctrip_hotel');\n const properties = collectTool.function.parameters.properties;\n\n assert.match(collectTool.function.description, /详情页/);\n assert.match(collectTool.function.description, /列表页/);\n assert.ok(properties.url);\n assert.ok(properties.urls);\n assert.ok(properties.listFilters);\n assert.ok(properties.desiredHotelCount);\n assert.ok(properties.minScore);\n assert.ok(properties.excludeKeywords);\n assert.ok(properties.excludeHotelTypes);\n assert.ok(properties.maxPages);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({ currentDir, existsSync: fs.existsSync, isBundledWithScraper: () => false }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('AI scraper write rollback restores compare-app store snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n const originalStore = {\n hotels: [{ id: 'existing-hotel', name: '原有酒店' }],\n templates: [{ id: 'tpl-1', name: '武汉模板' }],\n settings: { theme: 'totoro-blue' }\n };\n const events = [];\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n fs.writeFileSync(storePath, JSON.stringify(originalStore, null, 2), 'utf8');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({\n hotels: [\n ...originalStore.hotels,\n { id: 'cancelled-task-hotel', name: '本次任务写入酒店' }\n ],\n templates: [],\n settings: {}\n }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState, {\n onEvent(event) {\n events.push(event);\n }\n });\n\n assert.equal(result.restored, true);\n assert.deepEqual(JSON.parse(fs.readFileSync(storePath, 'utf8')), originalStore);\n assert.ok(events.some((event) => event.type === 'write:rollback-start'));\n assert.ok(events.some((event) => event.type === 'write:rollback-done'));\n});\n\ntest('AI scraper write rollback removes a store file created after the snapshot', async (t) => {\n const tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-write-rollback-missing-'));\n const previousDataDir = process.env.HOTEL_COMPARE_APP_DATA_DIR;\n const previousSharedDir = process.env.HOTEL_COMPARE_SHARED_DIR;\n const storePath = path.join(tempDataDir, 'hotel-data.json');\n\n process.env.HOTEL_COMPARE_APP_DATA_DIR = tempDataDir;\n process.env.HOTEL_COMPARE_SHARED_DIR = path.resolve(__dirname, '..', 'shared', 'compare-app');\n\n t.after(() => {\n if (previousDataDir === undefined) {\n delete process.env.HOTEL_COMPARE_APP_DATA_DIR;\n } else {\n process.env.HOTEL_COMPARE_APP_DATA_DIR = previousDataDir;\n }\n if (previousSharedDir === undefined) {\n delete process.env.HOTEL_COMPARE_SHARED_DIR;\n } else {\n process.env.HOTEL_COMPARE_SHARED_DIR = previousSharedDir;\n }\n fs.rmSync(tempDataDir, { recursive: true, force: true });\n });\n\n const rollbackState = {};\n await createWriteRollbackSnapshot(resolveScraperPath(), rollbackState);\n fs.writeFileSync(storePath, JSON.stringify({ hotels: [{ id: 'new' }] }, null, 2), 'utf8');\n\n const result = restoreWriteRollbackSnapshot(rollbackState);\n\n assert.equal(result.restored, true);\n assert.equal(fs.existsSync(storePath), false);\n});\n\ntest('AI scraper cancellation detector accepts abort signals and cancellation errors', () => {\n const controller = new AbortController();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), false);\n assert.equal(isTaskCancelled(new Error('connection aborted unexpectedly'), null), false);\n\n controller.abort();\n assert.equal(isTaskCancelled(new Error('普通失败'), controller.signal), true);\n assert.equal(isTaskCancelled(new Error('任务已取消'), null), true);\n assert.equal(isTaskCancelled(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }), null), true);\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉',\n desiredHotelCount: 5,\n minScore: 4.6,\n excludeKeywords: ['电竞'],\n excludeHotelTypes: ['民宿'],\n maxPages: 2\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].input.desiredHotelCount, 5);\n assert.equal(calls[0].input.minScore, 4.6);\n assert.deepEqual(calls[0].input.excludeKeywords, ['电竞']);\n assert.deepEqual(calls[0].input.excludeHotelTypes, ['民宿']);\n assert.equal(calls[0].input.maxPages, 2);\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done'));\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }],\n diffs: [{\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }],\n missingEvidence: []\n })\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }],\n eligibleRoomTypes: [{\n id: 'room-candidate-001',\n totalPrice: 300\n }],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [{\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n }, {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content = capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }],\n diffs: [{\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "f97c77046baa6843f2c3fe7bf82db1faf05774bf": {"t": 1785758748.744747, "n": 21397, "h": "adccbbcf6f3bb33e", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst {\n buildSystemPrompt,\n createAiService\n} = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { sanitizeSettings } = require('../src/main/ai/tools');\nconst { getVisibleLoginRetryNeed, resolveScraperPath } = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n }, [\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]);\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n }, [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ], {\n maxTokens: 32\n });\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const handlers = new Map();\n let aiServiceAccessCount = 0;\n const ipcMain = {\n handle(channel, handler) {\n handlers.set(channel, handler);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n getAiService() {\n aiServiceAccessCount += 1;\n return {\n getProviderConfig() {},\n getProviderPresets() {\n return [];\n },\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n };\n }\n }\n });\n\n assert.ok(handlers.has('ai:task:start'));\n assert.ok(handlers.has('ai:collect:analyze'));\n assert.ok(handlers.has('ai:collect:apply-review'));\n assert.equal(aiServiceAccessCount, 0);\n\n handlers.get('ai:config:presets')();\n assert.equal(aiServiceAccessCount, 1);\n});\n\ntest('AI IPC keeps compatibility with direct aiService object', () => {\n const channels = [];\n registerAiHandlers({\n ipcMain: {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n },\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({ currentDir, existsSync: fs.existsSync, isBundledWithScraper: () => false }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done'));\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }],\n diffs: [{\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }],\n missingEvidence: []\n })\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }],\n eligibleRoomTypes: [{\n id: 'room-candidate-001',\n totalPrice: 300\n }],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [{\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n }, {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content = capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }],\n diffs: [{\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}, "73a14628804ec88d52021cca5e23ba557f60acca": {"t": 1785758750.1154122, "n": 20342, "h": "bdd74db3c918e687", "c": "const test = require('node:test');\nconst assert = require('node:assert/strict');\nconst fs = require('node:fs');\nconst os = require('node:os');\nconst path = require('node:path');\n\nconst {\n getAiProviderPresets,\n normalizeAiProviderConfig,\n redactAiProviderConfig\n} = require('../src/main/ai/provider-presets');\nconst registerAiHandlers = require('../src/main/ipc-handlers/ai-handlers');\nconst {\n buildSystemPrompt,\n createAiService\n} = require('../src/main/services/ai-service');\nconst {\n buildAnthropicMessagesUrl,\n buildChatCompletionRequest,\n buildChatCompletionsUrl,\n buildProviderErrorMessage,\n convertMessagesToAnthropic,\n parseChatCompletionMessage\n} = require('../src/main/ai/provider-client');\nconst { sanitizeSettings } = require('../src/main/ai/tools');\nconst { getVisibleLoginRetryNeed, resolveScraperPath } = require('../src/main/ai/scraper-runner');\n\ntest('AI provider presets include MiMo and normalize config', () => {\n const presets = getAiProviderPresets();\n const mimo = presets.find((preset) => preset.id === 'mimo');\n const openai = presets.find((preset) => preset.id === 'openai');\n\n assert.equal(mimo.name, 'MiMo TokenPlan');\n assert.equal(mimo.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n assert.equal(mimo.model, 'mimo-v2.5-pro');\n assert.ok(mimo.modelOptions.includes('mimo-v2.5'));\n assert.ok(mimo.modelOptions.includes('mimo-v2.5-tts'));\n assert.equal(openai.model, 'gpt-5.4');\n\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo',\n apiKey: ' key ',\n temperature: 9\n });\n\n assert.equal(normalized.provider, 'mimo');\n assert.equal(normalized.baseUrl, mimo.baseUrl);\n assert.equal(normalized.model, 'mimo-v2.5-pro');\n assert.equal(normalized.apiKey, 'key');\n assert.equal(normalized.temperature, 0.2);\n\n const mixedCaseModel = normalizeAiProviderConfig({\n provider: 'mimo',\n model: 'MiMo-V2.5'\n });\n assert.equal(mixedCaseModel.model, 'mimo-v2.5');\n});\n\ntest('MiMo normalizer migrates old non-TokenPlan base URL to TokenPlan endpoint', () => {\n const normalized = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(normalized.baseUrl, 'https://token-plan-cn.xiaomimimo.com/anthropic');\n\n const regional = normalizeAiProviderConfig({\n provider: 'mimo'\n }, {\n provider: 'mimo',\n baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1',\n model: 'mimo-v2.5-pro'\n });\n\n assert.equal(regional.baseUrl, 'https://token-plan-sgp.xiaomimimo.com/anthropic');\n});\n\ntest('AI config redaction removes API key but keeps key presence', () => {\n const redacted = redactAiProviderConfig({\n provider: 'deepseek',\n apiKey: 'secret'\n });\n\n assert.equal(redacted.apiKey, '');\n assert.equal(redacted.hasApiKey, true);\n});\n\ntest('Chat completion request uses OpenAI-compatible tools shape', () => {\n assert.equal(\n buildChatCompletionsUrl('https://api.deepseek.com/'),\n 'https://api.deepseek.com/chat/completions'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret',\n temperature: 0.3\n }, [\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n parameters: { type: 'object', properties: {} }\n }\n }\n ]);\n\n assert.equal(request.url, 'https://api.deepseek.com/chat/completions');\n assert.equal(request.body.model, 'deepseek-chat');\n assert.equal(request.body.tool_choice, 'auto');\n assert.equal(request.init.headers.Authorization, 'Bearer secret');\n});\n\ntest('MiMo requests use Anthropic-compatible TokenPlan shape', () => {\n assert.equal(\n buildAnthropicMessagesUrl('https://token-plan-cn.xiaomimimo.com/anthropic/'),\n 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages'\n );\n\n const request = buildChatCompletionRequest({\n provider: 'mimo',\n baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',\n model: 'mimo-v2.5',\n apiKey: 'secret',\n temperature: 0.2\n }, [\n { role: 'system', content: 'system prompt' },\n { role: 'user', content: 'hello' }\n ], [\n {\n type: 'function',\n function: {\n name: 'list_templates',\n description: 'list',\n parameters: { type: 'object', properties: {} }\n }\n }\n ], {\n maxTokens: 32\n });\n\n assert.equal(request.url, 'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages');\n assert.equal(request.body.system, 'system prompt');\n assert.equal(request.body.max_tokens, 32);\n assert.equal(request.body.tools[0].input_schema.type, 'object');\n assert.equal(request.init.headers['x-api-key'], 'secret');\n});\n\ntest('Anthropic message conversion preserves tool result turns', () => {\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n },\n {\n role: 'tool',\n tool_call_id: 'tool_1',\n content: '{\"templates\":[]}'\n }\n ]);\n\n assert.equal(converted.messages[0].content[0].type, 'tool_use');\n assert.equal(converted.messages[1].content[0].type, 'tool_result');\n});\n\ntest('Anthropic message conversion preserves provider thinking blocks verbatim', () => {\n const rawContent = [\n {\n type: 'thinking',\n thinking: 'need tool',\n signature: ''\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ];\n const converted = convertMessagesToAnthropic([\n {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'tool_1',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ],\n anthropic_content: rawContent\n }\n ]);\n\n assert.deepEqual(converted.messages[0].content, rawContent);\n});\n\ntest('Chat completion parser preserves tool calls', () => {\n const message = parseChatCompletionMessage({\n choices: [\n {\n message: {\n role: 'assistant',\n content: '',\n tool_calls: [\n {\n id: 'call_1',\n type: 'function',\n function: {\n name: 'list_templates',\n arguments: '{}'\n }\n }\n ]\n }\n }\n ]\n });\n\n assert.equal(message.role, 'assistant');\n assert.equal(message.tool_calls.length, 1);\n});\n\ntest('Chat completion parser supports Anthropic tool_use content', () => {\n const message = parseChatCompletionMessage({\n content: [\n {\n type: 'text',\n text: 'ok'\n },\n {\n type: 'tool_use',\n id: 'tool_1',\n name: 'list_templates',\n input: {}\n }\n ]\n });\n\n assert.equal(message.content, 'ok');\n assert.equal(message.tool_calls[0].function.name, 'list_templates');\n assert.equal(Array.isArray(message.anthropic_content), true);\n});\n\ntest('MiMo error messages explain TokenPlan authentication and parameter failures', () => {\n assert.match(\n buildProviderErrorMessage(401, 'Invalid API Key', { provider: 'mimo' }),\n /TokenPlan 鉴权失败/\n );\n assert.match(\n buildProviderErrorMessage(400, 'Param Incorrect', { provider: 'mimo' }),\n /\\/anthropic/\n );\n});\n\ntest('AI settings sanitizer removes API key from settings-shaped objects', () => {\n const sanitized = sanitizeSettings({\n theme: 'totoro-blue',\n ai_provider_config: {\n provider: 'openai',\n apiKey: 'secret'\n }\n });\n\n assert.equal(sanitized.ai_provider_config.apiKey, '');\n assert.equal(sanitized.ai_provider_config.hasApiKey, true);\n});\n\ntest('AI fallback system prompt is compact and ignores legacy guide text', () => {\n const prompt = buildSystemPrompt('legacy guide should be ignored');\n\n assert.match(prompt, /内置 AI 兜底助手/);\n assert.match(prompt, /collect_and_write_ctrip_hotel/);\n assert.doesNotMatch(prompt, /legacy guide/);\n});\n\ntest('AI IPC registers direct task start endpoint', () => {\n const channels = [];\n const ipcMain = {\n handle(channel, handler) {\n channels.push(channel);\n assert.equal(typeof handler, 'function');\n }\n };\n\n registerAiHandlers({\n ipcMain,\n services: {\n aiService: {\n getProviderConfig() {},\n getProviderPresets() {},\n saveProviderConfig() {},\n testConnection() {},\n sendChat() {},\n startTask() {},\n analyzeCollection() {},\n applyCollectionReview() {},\n cancelTask() {},\n getTaskStatus() {}\n }\n }\n });\n\n assert.ok(channels.includes('ai:task:start'));\n assert.ok(channels.includes('ai:collect:analyze'));\n assert.ok(channels.includes('ai:collect:apply-review'));\n});\n\ntest('scraper runner resolves the embedded scraper in the app repository', (t) => {\n const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-scraper-'));\n const currentDir = path.join(tempRoot, 'project', 'src', 'main', 'ai');\n const embeddedRunner = path.join(tempRoot, 'project', 'scraper', 'src', 'task-runner.js');\n\n fs.mkdirSync(path.dirname(embeddedRunner), { recursive: true });\n fs.writeFileSync(embeddedRunner, 'module.exports = {};', 'utf-8');\n\n t.after(() => {\n fs.rmSync(tempRoot, { recursive: true, force: true });\n });\n\n assert.equal(\n resolveScraperPath({ currentDir, existsSync: fs.existsSync, isBundledWithScraper: () => false }),\n path.join(tempRoot, 'project', 'scraper')\n );\n});\n\ntest('direct AI task start runs the hotel task runner without provider config', async () => {\n const calls = [];\n const events = [];\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n }\n },\n windowService: {\n getMainWindow() {\n return {\n isDestroyed: () => false,\n webContents: {\n send(channel, payload) {\n events.push({ channel, payload });\n }\n }\n };\n }\n },\n hotelTaskRunner: async (input, context) => {\n calls.push({ input, context });\n context.onEvent({ type: 'scrape:start', message: '正在采集携程酒店页面' });\n return {\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' }\n };\n }\n });\n\n const result = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n\n assert.equal(calls.length, 1);\n assert.equal(calls[0].input.templateName, '武汉');\n assert.equal(calls[0].context.dataFolderPath, 'E:/实验/1/宾馆比较助手');\n assert.equal(result.collectResult.hotelName, '测试酒店');\n assert.equal(result.collectResult.totalPrice, 300);\n assert.equal(result.toolResults[0].name, 'collect_and_write_ctrip_hotel');\n assert.ok(events.some((event) => event.channel === 'ai:task:event' && event.payload.type === 'task:done'));\n});\n\ntest('AI collect analysis uses task review_input and disables apply on invalid evidence', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content: JSON.stringify({\n canApply: true,\n summary: '建议改价',\n issues: ['价格证据不足'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 999\n }],\n diffs: [{\n field: 'total_price',\n before: 300,\n after: 999,\n reason: '用户怀疑价格错误'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'missing-id',\n field: 'rawPriceText',\n value: '999',\n supports: '价格'\n }],\n missingEvidence: []\n })\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get(key) {\n assert.equal(key, 'settings');\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId,\n distance: '2公里',\n subway_station: '江汉路站',\n subway_distance: '600米',\n transport_time: '18分钟',\n bus_route: '乘地铁2号线'\n }],\n eligibleRoomTypes: [{\n id: 'room-candidate-001',\n totalPrice: 300\n }],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n finalHotelFieldLogs: [{\n id: 'field-distance',\n hotelId: 'final-1',\n field: 'distance',\n value: '2公里',\n source: 'finalHotels'\n }, {\n id: 'field-area',\n hotelId: 'final-1',\n field: 'room_area',\n value: '40平方米',\n source: 'rawRoomCandidates'\n }],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(analysis.canApply, false);\n assert.match(analysis.missingEvidence.join('\\n'), /不存在/);\n await assert.rejects(\n () => service.applyCollectionReview({ reviewId: analysis.reviewId }),\n /未通过证据校验/\n );\n assert.equal(capturedRequests.length, 1);\n assert.match(capturedRequests[0].body.messages[1].content, /review_input/);\n assert.doesNotMatch(capturedRequests[0].body.messages[1].content, /secret-key/);\n assert.doesNotMatch(\n capturedRequests[0].body.messages.map((message) => message.content).join('\\n'),\n /江汉路站|乘地铁2号线|field-distance|subway_station|transport_time|bus_route/\n );\n assert.match(capturedRequests[0].body.messages[1].content, /room_area/);\n});\n\ntest('AI collect analysis repairs invalid JSON response before validation', async (t) => {\n const originalFetch = globalThis.fetch;\n const capturedRequests = [];\n globalThis.fetch = async (url, init) => {\n capturedRequests.push({ url, body: JSON.parse(init.body) });\n const content = capturedRequests.length === 1\n ? '{\"canApply\": true, \"summary\": \"建议重填\", \"issues\": [\"价格可能漏选\" \"缺少逗号\"], \"revisedHotels\": []}'\n : JSON.stringify({\n canApply: true,\n summary: '建议重填',\n issues: ['价格可能漏选'],\n revisedHotels: [{\n name: '测试酒店',\n website: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n total_price: 300,\n template_id: '100'\n }],\n diffs: [{\n field: 'total_price',\n before: '',\n after: 300,\n reason: 'rawRoomCandidates 中存在价格证据'\n }],\n evidence: [{\n source: 'rawRoomCandidates',\n id: 'room-candidate-001',\n field: 'rawPriceText',\n value: '¥300',\n supports: 'total_price'\n }],\n missingEvidence: []\n });\n return {\n ok: true,\n async text() {\n return JSON.stringify({\n choices: [{\n message: {\n role: 'assistant',\n content\n }\n }]\n });\n }\n };\n };\n t.after(() => {\n globalThis.fetch = originalFetch;\n });\n\n const service = createAiService({\n dataService: {\n getDataFolderPath() {\n return 'E:/实验/1/宾馆比较助手';\n },\n getStore() {\n return {\n get() {\n return {\n ai_provider_config: {\n enabled: true,\n provider: 'deepseek',\n baseUrl: 'https://api.deepseek.com',\n model: 'deepseek-chat',\n apiKey: 'secret-key',\n temperature: 0.2\n }\n };\n }\n };\n }\n },\n windowService: {\n getMainWindow() {\n return null;\n }\n },\n hotelTaskRunner: async (input, context) => ({\n success: true,\n hotelName: '测试酒店',\n eligibleCount: 1,\n eligibleRoomTypes: [{ dailyPrice: 300, totalPrice: 300 }],\n writeResult: { operation: 'inserted' },\n reviewInput: {\n taskMeta: {\n taskId: context.taskId,\n url: input.url,\n templateId: input.templateId,\n templateName: input.templateName,\n outputFingerprint: 'fingerprint'\n },\n finalHotels: [{\n id: 'final-1',\n name: '测试酒店',\n website: input.url,\n total_price: 300,\n template_id: input.templateId\n }],\n eligibleRoomTypes: [],\n rejectedRoomTypes: [],\n rawRoomCandidates: [{\n id: 'room-candidate-001',\n rawPriceText: '¥300'\n }],\n normalizeLogs: [],\n selectionLogs: [],\n pageSnapshotSummary: {\n hasPriceArea: true\n },\n userConcern: ''\n }\n })\n });\n\n const taskResult = await service.startTask({\n url: 'https://hotels.ctrip.com/hotels/detail/?hotelId=1',\n templateId: '100',\n templateName: '武汉'\n });\n const analysis = await service.analyzeCollection({\n taskId: taskResult.taskStatus.id,\n userConcern: '价格不对'\n });\n\n assert.equal(capturedRequests.length, 2);\n assert.match(capturedRequests[1].body.messages[1].content, /JSON 格式修复/);\n assert.equal(analysis.canApply, true);\n assert.equal(analysis.revisedHotels.length, 1);\n});\n\ntest('AI scraper retry detector asks for visible login when Ctrip price is locked or missing', () => {\n const locked = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: false,\n selected_room_price_locked: true,\n sources: []\n }\n });\n\n assert.equal(locked.needed, true);\n assert.match(locked.reason, /登录看低价/);\n\n const missingPrice = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: null,\n roomPrices: [],\n pageSnapshot: {\n room_candidates_count: 3,\n room_price_visible: false,\n sources: []\n }\n });\n\n assert.equal(missingPrice.needed, true);\n assert.match(missingPrice.reason, /未采集到有效价格/);\n\n const priced = getVisibleLoginRetryNeed({\n success: true,\n totalPrice: 885,\n roomPrices: [885],\n pageSnapshot: {\n room_candidates_count: 2,\n room_price_visible: true,\n sources: []\n }\n });\n\n assert.equal(priced.needed, false);\n});\n"}}}} |