182 lines
11 KiB
TypeScript
182 lines
11 KiB
TypeScript
import type { QaAction, QaState } from './qa'
|
|
import type { QaMessage, QaThreadView } from './types'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { answerPreview, asksOf, chatReducer, EMPTY_CHAT, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
|
|
|
|
const IF = 'function get_user > if @3fa1c2d0'
|
|
|
|
function thread(anchor: string, ...ids: string[]): QaThreadView {
|
|
return { anchor, entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
|
|
}
|
|
|
|
function message(threads: QaThreadView[], current: QaMessage['current'] = []): QaMessage {
|
|
return { type: 'qa', available: true, rewritable: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] }
|
|
}
|
|
|
|
function qa(threads: QaThreadView[], current: QaMessage['current'] = []): QaAction {
|
|
return { type: 'qa', message: message(threads, current) }
|
|
}
|
|
|
|
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: '为什么?', images: [] })
|
|
expect(state.open).toEqual([IF])
|
|
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], 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: { askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
|
|
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
|
|
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
|
|
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
|
|
})
|
|
|
|
it('profile 下拉:问答、重写注释各记各的,扩展端发来什么就显示什么,记录刷新不丢', () => {
|
|
const profiles = { options: [{ id: '', label: '默认' }, { id: 'sonnet', label: 'sonnet' }], selected: 'sonnet' }
|
|
expect(EMPTY_QA.profiles).toEqual({ options: [], selected: '' })
|
|
const state = run({ type: 'profiles', profiles }, qa([thread(IF, 'e1')]))
|
|
expect(state.profiles).toEqual(profiles)
|
|
expect(state.threads).toHaveLength(1)
|
|
|
|
expect(EMPTY_CHAT.profiles).toEqual({ options: [], selected: '' })
|
|
const rewrite = chatReducer(chatReducer(EMPTY_CHAT, { type: 'profiles', profiles }), { type: 'sync', available: true, threads: [thread(IF, 'r1')], current: [] })
|
|
expect(rewrite.profiles).toEqual(profiles)
|
|
expect(rewrite.threads).toHaveLength(1)
|
|
})
|
|
|
|
it('顶部的问答记在路径上,不进任何一块的历史', () => {
|
|
const state = run({
|
|
type: 'qa',
|
|
message: {
|
|
type: 'qa',
|
|
available: true,
|
|
rewritable: true,
|
|
threads: [],
|
|
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
|
|
subject: 'folder',
|
|
current: [],
|
|
rewrites: [],
|
|
rewriteCurrent: [],
|
|
},
|
|
})
|
|
expect(state.path.map(entry => entry.id)).toEqual(['p'])
|
|
expect(state.subject).toBe('folder')
|
|
expect(historyOf(state, PATH_ASK_ANCHOR).map(entry => entry.id)).toEqual(['p'])
|
|
expect(historyOf(state, IF)).toEqual([])
|
|
})
|
|
|
|
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
|
|
let state = run(
|
|
qa([thread(IF, 'e1')]),
|
|
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
|
|
{ type: 'answerEnd', end: { askId: 'a1', status: 'done', entryId: 'e1' } },
|
|
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
|
|
{ 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'])
|
|
state = qaReducer(state, { type: 'open', anchor: IF })
|
|
expect(state.open).toEqual([IF])
|
|
})
|
|
|
|
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
|
|
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半', status: 'running' as const }]
|
|
let state = run(qa([], running))
|
|
expect(state.open).toEqual([IF])
|
|
expect(asksOf(state, IF)).toEqual([running[0]])
|
|
state = qaReducer(state, qa([], running))
|
|
expect(state.asks).toHaveLength(1)
|
|
})
|
|
|
|
it('停止、失败照记;不认识的问题的消息不理', () => {
|
|
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' })
|
|
state = qaReducer(state, { type: 'answerEnd', end: { 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: { askId: 'nope', status: 'done' } })).toEqual(state)
|
|
})
|
|
|
|
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
|
|
const images = ['data:image/png;base64,AAA', 'data:image/jpeg;base64,BBB']
|
|
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
|
|
expect(asksOf(state, IF)[0].images).toEqual(images)
|
|
for (const status of ['done', 'failed', 'stopped'] as const)
|
|
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status } }), IF)[0].images).toEqual(images)
|
|
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '', status: 'running' }]))
|
|
expect(asksOf(state, IF)[0].images).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('重写注释的状态:和问 AI 同一套', () => {
|
|
const SKILL = '/ecc:writing-code-comments a.py:3-8'
|
|
|
|
it('页面重新加载后:扩展端还在写的那一轮接上、展开它那块,后面的进展接得上;出错、被叫停留着的照原样画出来', () => {
|
|
let state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [
|
|
{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' },
|
|
{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' },
|
|
] })
|
|
expect(state.open).toEqual([IF, 'class A'])
|
|
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' }])
|
|
expect(asksOf(state, 'class A')).toEqual([{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }])
|
|
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '写完了大半', activity: '运行 op export' })
|
|
expect(asksOf(state, IF)[0]).toMatchObject({ text: '写完了大半', activity: '运行 op export', status: 'running' })
|
|
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' } })
|
|
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' })
|
|
state = chatReducer(state, { type: 'sync', available: true, threads: [], current: [{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }] })
|
|
expect(state.asks).toHaveLength(2)
|
|
})
|
|
|
|
it('点按钮那一轮开始时还不知道那句技能命令,扩展端开始后报上来就换上', () => {
|
|
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
|
|
expect(state.open).toEqual([IF])
|
|
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
|
expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' })
|
|
})
|
|
|
|
it('扩展端说排着队就显示排队中,轮到了变成在写;收起这块时排着的和在写的一样留着;取消了照停下记', () => {
|
|
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
|
|
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '', queued: true })
|
|
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, images: [], text: '', status: 'queued' }])
|
|
state = chatReducer(state, { type: 'close', anchor: IF })
|
|
expect(asksOf(state, IF).map(ask => ask.status)).toEqual(['queued'])
|
|
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
|
|
expect(asksOf(state, IF)[0].status).toBe('running')
|
|
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'stopped' } })
|
|
expect(asksOf(state, IF)[0].status).toBe('stopped')
|
|
expect(chatReducer(state, { type: 'answer', askId: 'r1', text: '晚到的', queued: true })).toEqual(state)
|
|
})
|
|
|
|
it('页面重新加载后:扩展端排着队的那一轮照样显示排队中、展开它那块', () => {
|
|
const state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }] })
|
|
expect(state.open).toEqual([IF])
|
|
expect(asksOf(state, IF)).toEqual([{ askId: 'r2', anchor: IF, question: SKILL, text: '', status: 'queued' }])
|
|
})
|
|
})
|
|
|
|
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()')
|
|
})
|
|
})
|