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 { 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 => 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 { 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 => fsp.access(file).then(() => true, () => false) function rewrite(id: string, createdAt: string, extra: Partial = {}): 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) }) })