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(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() }) })