✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下
This commit is contained in:
1 parent
badc722640
commit
fb53bb727c
34 files changed
+2726
-43
No files matched your search
@@ -0,0 +1,120 @@
|
||||
import type { QaAction, QaState } from './qa'
|
||||
import type { BlockView, QaMessage, QaThreadView } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { answerPreview, asksOf, EMPTY_QA, historyOf, orphanThreads, qaReducer, relativeTime, resumeFor, whereLabel } from './qa'
|
||||
|
||||
const IF = 'function get_user > if @3fa1c2d0'
|
||||
const RETURN = 'function get_user > return @9b1e2c3d'
|
||||
|
||||
function thread(anchor: string, ...ids: string[]): QaThreadView {
|
||||
return { anchor, where: [], entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
|
||||
}
|
||||
|
||||
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
|
||||
return { type: 'qa', available: true, threads, running }
|
||||
}
|
||||
|
||||
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
|
||||
return { type: 'qa', message: message(threads, running) }
|
||||
}
|
||||
|
||||
function block(anchor: string, children: BlockView[] = []): BlockView {
|
||||
return { id: anchor, anchor, kind: 'statement', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: {}, children, translation: [] }
|
||||
}
|
||||
|
||||
function run(...actions: QaAction[]): QaState {
|
||||
return actions.reduce(qaReducer, EMPTY_QA)
|
||||
}
|
||||
|
||||
describe('问 AI 的状态', () => {
|
||||
it('问一次:展开这块,当前这一轮多一条在答的;回答长了跟着变;答完记下存成的那条,历史里不再重复', () => {
|
||||
let state = run(qa([thread(IF, 'old')]), { type: 'ask', askId: 'a1', anchor: IF, question: '为什么?' })
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '', status: 'running' }])
|
||||
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new', sessionId: 's1' } })
|
||||
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
|
||||
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new', sessionId: 's1' }])
|
||||
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
|
||||
})
|
||||
|
||||
it('追问接着这一轮最近答成的会话;这一轮还没答成过就重新开', () => {
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' })
|
||||
expect(resumeFor(state, IF)).toBeUndefined()
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } })
|
||||
state = qaReducer(state, { type: 'ask', askId: 'a2', anchor: IF, question: '2' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a2', status: 'failed', error: '网络断了' } })
|
||||
expect(resumeFor(state, IF)).toBe('s1')
|
||||
expect(resumeFor(state, RETURN)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
|
||||
let state = run(
|
||||
qa([thread(IF, 'e1')]),
|
||||
{ type: 'ask', askId: 'a1', anchor: IF, question: '1' },
|
||||
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1', sessionId: 's1' } },
|
||||
{ type: 'ask', askId: 'a2', anchor: IF, question: '2' },
|
||||
{ type: 'close', anchor: IF },
|
||||
)
|
||||
expect(state.open).toEqual([])
|
||||
expect(asksOf(state, IF).map(ask => ask.askId)).toEqual(['a2'])
|
||||
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['e1'])
|
||||
expect(resumeFor(state, IF)).toBeUndefined()
|
||||
state = qaReducer(state, { type: 'open', anchor: IF })
|
||||
expect(state.open).toEqual([IF])
|
||||
})
|
||||
|
||||
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
|
||||
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
|
||||
let state = run(qa([], running))
|
||||
expect(state.open).toEqual([IF])
|
||||
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
|
||||
state = qaReducer(state, qa([], running))
|
||||
expect(state.asks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('停止、失败照记;不认识的问题的消息不理', () => {
|
||||
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1' }, { type: 'answer', askId: 'a1', text: '一半' })
|
||||
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
|
||||
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
|
||||
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
|
||||
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
|
||||
})
|
||||
})
|
||||
|
||||
describe('找不到原来位置的问答', () => {
|
||||
it('记号对不上任何一层的块才算', () => {
|
||||
const threads = [thread(IF, 'a'), thread(RETURN, 'b'), thread('function gone', 'c')]
|
||||
expect(orphanThreads(threads, [block('function get_user', [block(IF)])]).map(item => item.anchor)).toEqual([RETURN, 'function gone'])
|
||||
})
|
||||
|
||||
it('原来在哪:种类取翻译插件起的名字,没名字的块只写种类', () => {
|
||||
const names: Record<string, string> = { class: '类', function: '函数', if: '条件' }
|
||||
expect(whereLabel([{ kind: 'class', name: 'ReferralRelation' }, { kind: 'function', name: 'display' }, { kind: 'if' }], kind => names[kind] ?? kind))
|
||||
.toBe('类 ReferralRelation › 函数 display › 条件')
|
||||
})
|
||||
})
|
||||
|
||||
describe('问的时间', () => {
|
||||
const now = new Date(2026, 8, 26, 15, 0).getTime()
|
||||
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
|
||||
new Date(year, month, day, hour, minute, second).toISOString()
|
||||
|
||||
it('刚刚、几分钟前、几小时前(同一天)、昨天、几天前、一个月以前写日期', () => {
|
||||
expect(relativeTime(at(2026, 8, 26, 14, 59, 30), now)).toBe('刚刚')
|
||||
expect(relativeTime(at(2026, 8, 26, 14, 55), now)).toBe('5 分钟前')
|
||||
expect(relativeTime(at(2026, 8, 26, 9, 30), now)).toBe('5 小时前')
|
||||
expect(relativeTime(at(2026, 8, 25, 23, 0), now)).toBe('昨天')
|
||||
expect(relativeTime(at(2026, 8, 23, 10, 0), now)).toBe('3 天前')
|
||||
expect(relativeTime(at(2026, 7, 1, 10, 0), now)).toBe('2026-08-01')
|
||||
expect(relativeTime('坏的', now)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('回答的前两行预览', () => {
|
||||
it('去掉 Markdown 记号连成一段', () => {
|
||||
expect(answerPreview('## 结论\n\n`Forbidden` 定义在 **core/exceptions.py**,见 [文档](https://x.y)。\n\n- 第一条\n1. 第二条\n\n```py\nraise Forbidden()\n```'))
|
||||
.toBe('结论 Forbidden 定义在 core/exceptions.py,见 文档。 第一条 第二条 raise Forbidden()')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user