Files
superwork/vscode/src/codeReader/qa/qaMap.test.ts
T
cruldra c9f60f5b30 ✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
2026-10-12 13:45:37 +11:00

244 lines
14 KiB
TypeScript

import type { QaEntry, QaMap, RewriteEntry, RewriteMap } from './qaMap'
import { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap'
import { writeQaImages } from './qaImages'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` }
}
const DISPLAY = 'class ReferralRelation > function display'
const IF = `${DISPLAY} > if @3fa1c2d0`
let root: string
const qaFile = (): string => path.join(root, '.spx', 'code-qa.json')
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), 'code-qa-'))
})
afterEach(async () => {
await fsp.rm(root, { recursive: true, force: true })
})
async function writeRaw(content: unknown): Promise<void> {
await fsp.mkdir(path.dirname(qaFile()), { recursive: true })
await fsp.writeFile(qaFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf8')
}
describe('记一次、删一次', () => {
it('记在文件的那块下面:块按记号排,问答按时间排', () => {
let map: QaMap = {}
map = withEntry(map, 'app/models.py', IF, entry('b', '2026-09-26T10:00:00.000Z'))
map = withEntry(map, 'app/models.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z'))
map = withEntry(map, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))
expect(map).toEqual({
'app/models.py': [
{ anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] },
{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z'), entry('b', '2026-09-26T10:00:00.000Z')] },
],
})
})
it('删一次只删它;块下面删光了这块不挂了,文件里删光了这份文件不记了;不改传进来的那份', () => {
const map = withEntry(withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z'))
const once = withoutEntry(map, 'a.py', IF, 'a')
expect(once['a.py'].map(thread => [thread.anchor, thread.entries.map(item => item.id)])).toEqual([[DISPLAY, ['c']], [IF, ['b']]])
const twice = withoutEntry(once, 'a.py', IF, 'b')
expect(twice['a.py'].map(thread => thread.anchor)).toEqual([DISPLAY])
expect(withoutEntry(twice, 'a.py', DISPLAY, 'c')).toEqual({})
expect(map['a.py'].flatMap(thread => thread.entries)).toHaveLength(3)
})
it('删不存在的那条不动', () => {
const map = withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))
expect(withoutEntry(map, 'a.py', IF, 'nope')).toEqual(map)
expect(withoutEntry(map, 'b.py', IF, 'a')).toEqual(map)
})
it('文件改名、挪目录、删除时问答跟着走(同文件注释的规矩)', () => {
const map = withEntry(withEntry({}, 'app/models.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'app/views.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z'))
expect(Object.keys(moveEntries(map, 'app/models.py', 'app/orm.py'))).toEqual(['app/views.py', 'app/orm.py'])
expect(Object.keys(moveEntries(map, 'app', 'backend/app')).sort()).toEqual(['backend/app/models.py', 'backend/app/views.py'])
expect(Object.keys(dropEntries(map, 'app/models.py'))).toEqual(['app/views.py'])
})
})
describe('再问时接着哪个会话', () => {
it('接着这块最近一次问答的会话;别的块、没问过的块、没记会话 id 的都重新开', () => {
let map: QaMap = {}
map = withEntry(map, 'a.py', IF, entry('new', '2026-09-26T10:00:00.000Z'))
map = withEntry(map, 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z'))
map = withEntry(map, 'a.py', DISPLAY, { ...entry('bare', '2026-09-26T11:00:00.000Z'), sessionId: '' })
expect(latestSession(map['a.py'], IF)).toBe('s-new')
expect(latestSession(map['a.py'], DISPLAY)).toBeUndefined()
expect(latestSession(map['a.py'], 'function gone')).toBeUndefined()
expect(latestSession(undefined, IF)).toBeUndefined()
})
it('删掉最近那次,就接着剩下里最近的', () => {
const map = withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z'))
expect(latestSession(withoutEntry(map, 'a.py', IF, 'b')['a.py'], IF)).toBe('s-a')
})
})
describe('读写 .spx/code-qa.json', () => {
it('文件不存在、解析失败、不是对象都当没有', async () => {
expect(await readPathMap(root, QA_FILE)).toEqual({})
await writeRaw('{ "a.py": [')
expect(await readPathMap(root, QA_FILE)).toEqual({})
await writeRaw('[]')
expect(await readPathMap(root, QA_FILE)).toEqual({})
})
it('跳过认不出的块和问答:没有问题 / 回答、时间不对、没有记号、没有问答的块、不是数组的文件', async () => {
await writeRaw({
'a.py': [
{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z'), { id: 'x', question: '', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }, { ...entry('bad', 'not a date') }] },
{ anchor: '', entries: [entry('no-anchor', '2026-09-25T10:00:00.000Z')] },
{ anchor: DISPLAY, entries: [] },
'junk',
],
'b.py': { anchor: IF },
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }] }],
'../outside.py': [{ anchor: IF, entries: [entry('z', '2026-09-25T10:00:00.000Z')] }],
})
expect(await readPathMap(root, QA_FILE)).toEqual({
'a.py': [{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z')] }],
// 没有会话 id 的照样认,追问时重新开会话
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z', sessionId: '' }] }],
})
})
it('用哪个 profile 答的跟着这条存、读得回来;以前存的、选「默认」答的没有这一项,空的、不是字符串的当没有', async () => {
const sonnet: QaEntry = { ...entry('p', '2026-09-26T10:00:00.000Z'), profile: 'sonnet' }
const map = withEntry(withEntry({}, 'a.py', IF, sonnet), 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z'))
await writePathMap(root, QA_FILE, map)
expect(await fsp.readFile(qaFile(), 'utf8')).toContain('"profile": "sonnet"')
expect(await readPathMap(root, QA_FILE)).toEqual(map)
await writeRaw({ 'a.py': [{ anchor: IF, entries: [{ ...entry('x', '2026-09-25T10:00:00.000Z'), profile: '' }, { ...entry('y', '2026-09-26T10:00:00.000Z'), profile: 3 }] }] })
expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [entry('x', '2026-09-25T10:00:00.000Z'), entry('y', '2026-09-26T10:00:00.000Z')] }] })
})
it('手改过的顺序、重复的块读进来整理好', async () => {
await writeRaw({ 'a.py': [
{ anchor: IF, entries: [entry('b', '2026-09-26T10:00:00.000Z')] },
{ anchor: DISPLAY, entries: [entry('c', '2026-09-26T11:00:00.000Z')] },
{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z')] },
] })
expect(await readPathMap(root, QA_FILE)).toEqual(withEntry(withEntry(withEntry({}, 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', DISPLAY, entry('c', '2026-09-26T11:00:00.000Z')), 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')))
})
it('写:建 .spx、文件按路径排、两格缩进、末尾换行,读得回来;删光留 {}', async () => {
const map = withEntry(withEntry({}, 'z.py', IF, entry('a', '2026-09-25T10:00:00.000Z', '含 "引号" 和 `代码`')), 'a.py', DISPLAY, entry('b', '2026-09-25T10:00:00.000Z'))
await writePathMap(root, QA_FILE, map)
const raw = await fsp.readFile(qaFile(), 'utf8')
expect(raw).toBe(`${JSON.stringify({ 'a.py': map['a.py'], 'z.py': map['z.py'] }, null, 2)}\n`)
expect(raw.indexOf('"id"')).toBeLessThan(raw.indexOf('"question"'))
expect(await readPathMap(root, QA_FILE)).toEqual(map)
await writePathMap(root, QA_FILE, {})
expect(await fsp.readFile(qaFile(), 'utf8')).toBe('{}\n')
})
})
describe('问题带的图片', () => {
const imageFile = (name: string): string => path.join(root, '.spx', 'code-qa', name)
const exists = (file: string): Promise<boolean> => fsp.access(file).then(() => true, () => false)
const png = (id: string): { mediaType: string, base64: string } => ({ mediaType: 'image/png', base64: Buffer.from(id).toString('base64') })
/** 同 FolderStore.update:读盘上的一份改好写回,再删掉不再有条目带着的文件 */
async function update(change: (map: QaMap) => QaMap): Promise<void> {
const before = await readPathMap(root, QA_FILE)
const after = change(before)
await writePathMap(root, QA_FILE, after)
await removeFiles(root, detachedFiles(QA_FILE, before, after))
}
it('记在这条上(.spx/code-qa/ 里的文件名)、读得回来;认不出的名字(跑到别处的路径、不是字符串)跳过,一张都不剩就没有这一项', async () => {
const withImages: QaEntry = { ...entry('p', '2026-09-26T10:00:00.000Z'), images: ['p-1.png', 'p-2.webp'] }
await writePathMap(root, QA_FILE, withEntry({}, 'a.py', IF, withImages))
expect(await fsp.readFile(qaFile(), 'utf8')).toContain('"images": [')
expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [withImages] }] })
await writeRaw({ 'a.py': [{ anchor: IF, entries: [
{ ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['../../secret-1.png', 'x-1.png', 7, 'x-2.exe'] },
{ ...entry('y', '2026-09-26T10:00:00.000Z'), images: ['../y-1.png'] },
{ ...entry('z', '2026-09-26T11:00:00.000Z'), images: 'z-1.png' },
] }] })
expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [
{ ...entry('x', '2026-09-25T10:00:00.000Z'), images: ['x-1.png'] },
entry('y', '2026-09-26T10:00:00.000Z'),
entry('z', '2026-09-26T11:00:00.000Z'),
] }] })
})
it('删一次问答,它的图片跟着删;别的问答的图片留着', async () => {
const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) }
const b: QaEntry = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) }
await update(map => withEntry(withEntry(map, 'a.py', IF, a), 'a.py', DISPLAY, b))
expect(await exists(imageFile('a-1.png'))).toBe(true)
await update(map => withoutEntry(map, 'a.py', IF, 'a'))
expect(await exists(imageFile('a-1.png'))).toBe(false)
expect(await exists(imageFile('a-2.png'))).toBe(false)
expect(await exists(imageFile('b-1.png'))).toBe(true)
})
it('源文件删了、问答跟着删时图片也删;改名、挪位置按问答 id 起的名字不用动', async () => {
const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) }
await update(map => withEntry(map, 'src/a.py', IF, a))
await update(map => moveEntries(map, 'src', 'lib'))
expect(await readPathMap(root, QA_FILE)).toEqual({ 'lib/a.py': [{ anchor: IF, entries: [a] }] })
expect(await exists(imageFile('a-1.png'))).toBe(true)
await update(map => dropEntries(map, 'lib/a.py'))
expect(await exists(imageFile('a-1.png'))).toBe(false)
})
})
describe('重写注释的记录:另存一份,和问答分开', () => {
const rewriteFile = (): string => path.join(root, '.spx', 'comment-rewrites.json')
const exists = (file: string): Promise<boolean> => fsp.access(file).then(() => true, () => false)
function rewrite(id: string, createdAt: string, extra: Partial<RewriteEntry> = {}): RewriteEntry {
return { id, question: '/ecc:writing-code-comments a.py:3-8', body: `正文 ${id}`, createdAt, sessionId: `r-${id}`, ...extra }
}
it('存在 .spx/comment-rewrites.json,不进问答那份;正文、没写进文件的原因跟着这条读得回来,接着最近一次重写的会话', async () => {
const map: RewriteMap = withEntry(withEntry({}, 'a.py', IF, rewrite('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, rewrite('b', '2026-09-26T10:00:00.000Z', { error: '这段注释的定界符认不出来,没有改文件。' }))
await writePathMap(root, REWRITE_FILE, map)
expect(await exists(rewriteFile())).toBe(true)
expect(await exists(qaFile())).toBe(false)
expect(await readPathMap(root, REWRITE_FILE)).toEqual(map)
expect(await readPathMap(root, QA_FILE)).toEqual({})
expect(latestSession(map['a.py'], IF)).toBe('r-b')
})
it('模型没交回正文的那次只记原因;正文和原因都没有、正文是空的不认', async () => {
await fsp.mkdir(path.dirname(rewriteFile()), { recursive: true })
const silent = rewrite('s', '2026-09-25T10:00:00.000Z', { error: '模型没有按要求交回注释正文,没有改文件。' })
delete silent.body
await fsp.writeFile(rewriteFile(), JSON.stringify({ 'a.py': [{ anchor: IF, entries: [
silent,
{ ...rewrite('bare', '2026-09-25T11:00:00.000Z'), body: undefined },
rewrite('blank', '2026-09-25T12:00:00.000Z', { body: ' ' }),
] }] }), 'utf8')
expect(await readPathMap(root, REWRITE_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [silent] }] })
})
it('带的图片和问答的放在同一个目录,删一条重写记录只删它自己的', async () => {
const png = { mediaType: 'image/png', base64: Buffer.from('r').toString('base64') }
const image = path.join(root, '.spx', 'code-qa', 'r-1.png')
const withImage = rewrite('r', '2026-09-25T10:00:00.000Z', { images: await writeQaImages(root, 'r', [png]) })
const before = withEntry({}, 'a.py', IF, withImage)
await writePathMap(root, REWRITE_FILE, before)
expect(await readPathMap(root, REWRITE_FILE)).toEqual(before)
const after = withoutEntry(before, 'a.py', IF, 'r')
await writePathMap(root, REWRITE_FILE, after)
await removeFiles(root, detachedFiles(REWRITE_FILE, before, after))
expect(await exists(image)).toBe(false)
})
})