Files
superwork/vscode/src/codeReader/qa/codeQa.test.ts
T

137 lines
6.3 KiB
TypeScript

import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { AskRunner } from './claudeAsk'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listClaudeProfiles } from '../../cc/profiles'
import { getSettings, saveSettings } from '../../settings/store'
import { CodeQa } from './codeQa'
vi.mock('vscode', async () => {
const { EventEmitter } = await import('node:events')
return {
EventEmitter: class {
private readonly emitter = new EventEmitter()
event = (listener: (value: unknown) => void) => {
this.emitter.on('event', listener)
return { dispose: () => this.emitter.off('event', listener) }
}
fire(value: unknown) { this.emitter.emit('event', value) }
dispose() { this.emitter.removeAllListeners() }
},
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: {},
window: { showErrorMessage: vi.fn() },
}
})
vi.mock('../folderStore', () => ({
FolderStore: class {
get() { return {} }
dispose() {}
},
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
}))
vi.mock('../editor/pathPreview', () => ({
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
}))
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
vi.mock('../../cc/profiles', () => ({
getDefaultProfilePath: () => '/p/offical.json',
getProfilesDir: () => '/p',
listClaudeProfiles: vi.fn(),
}))
const AVAILABLE = ['offical', 'sonnet', 'dsflash'].map(name => ({ name, path: `/p/${name}.json` }))
const uri = { fsPath: '/workspace/a.ts', toString: () => 'file:///workspace/a.ts' } as Uri
const dirs: string[] = []
const services: CodeQa[] = []
function context(): ExtensionContext {
const dir = mkdtempSync(path.join(tmpdir(), 'file-qa-settings-'))
dirs.push(dir)
return {
extensionPath: path.resolve(__dirname, '../../..'),
globalStorageUri: { fsPath: dir },
globalState: { get: (key: string) => key === 'superpowers.codeReader.askProfile' ? 'sonnet' : undefined },
workspaceState: { get: () => undefined, update: vi.fn() },
} as unknown as ExtensionContext
}
function service(ctx: ExtensionContext) {
const runner = vi.fn<AskRunner>(run => new Promise((_resolve, reject) => {
run.signal.addEventListener('abort', () => reject(new Error('停止')))
}))
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner)
services.push(qa)
return { qa, runner }
}
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
afterEach(() => {
services.splice(0).forEach(qa => qa.dispose())
dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true }))
})
describe('问这个文件实际使用设置的默认模型', () => {
it('新增官方文件和保存无关设置不改变显式空选择,真实默认变更才重置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' })
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE.filter(item => item.name !== 'offical'))
const { qa, runner } = service(ctx)
await qa.profiles.select('')
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)
const shown = vi.fn()
qa.profiles.subscribe(shown)
await saveSettings(ctx, { webhookPort: 9000 })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: '' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0]).not.toHaveProperty('profilePath')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/sonnet.json')
qa.stop('1')
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('忽略旧选择,临时切换不写默认;重新创建后恢复配置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
const { qa, runner } = service(ctx)
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
qa.stop('1')
await qa.profiles.select('sonnet')
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/sonnet.json')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json')
const restarted = service(ctx)
await restarted.qa.askPath(uri, 'file', { askId: '3', question: '这是什么', images: [] })
expect(restarted.runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('保存新默认后所有现有预览收到新选择,下一个问题和界面使用同一个模型', async () => {
const ctx = context()
const { qa, runner } = service(ctx)
await qa.profiles.select('sonnet')
const shown = vi.fn()
qa.profiles.subscribe(shown)
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('已删除配置以现有问答错误返回,不能启动模型;代码块问答同样阻止', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/deleted.json' })
const { qa, runner } = service(ctx)
expect(await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })).toContain('文件问答模型不可用')
expect(await qa.ask(uri, { askId: '2', anchor: 'a', question: '问题', prompt: '问题', followUp: '继续', images: [] })).toContain('文件问答模型不可用')
expect(runner).not.toHaveBeenCalled()
})
})