import type { QaAction, QaState } from './qa' import type { QaMessage, QaThreadView } from './types' import { describe, expect, it } from 'vitest' import { answerPreview, asksOf, EMPTY_QA, historyOf, 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[], 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 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' } }) state = qaReducer(state, qa([thread(IF, 'old', 'new')])) expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', text: '因为要鉴权。', status: 'done', entryId: 'new' }]) expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old']) }) 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' } }, { 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']) 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('问的时间', () => { 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()') }) })