import type { BlockView, CommentTarget } from './blockView' import type { CommentEdit, CommentJob, CommentWriteDeps, WriteResult } from './commentRewrite' import { describe, expect, it } from 'vitest' import { toBlockViews } from './blockView' import { COMMENT_SKILL, commentEdit, commentFollowUp, commentJob, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, writeCommentBody } from './commentRewrite' import { commonIndent, replaceLines, splitLines } from './editText' import { createPythonAdapter } from './languages/python' import { createRustAdapter } from './languages/rust' import { createTypeScriptAdapter } from './languages/typescript' import { WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' import { rustZhTranslator } from './translators/rustZh' import { typescriptZhTranslator } from './translators/typescriptZh' /** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */ function rewrite(file: string, start: number, end: number, body: string): string { const lines = file.split('\n').slice(start, end + 1) const filled = refillComment(commentSource(lines), body) expect(filled, '认得出定界符').toBeTruthy() return replaceLines(file, start, end, filled ?? '') } describe('重写注释后套回原来的壳', () => { it('三引号模块说明:定界符还在,正文换了,文件别的部分不动', () => { const file = [ '"""旧的模块说明。', '', '只做一件事。', '"""', '', 'def keep():', ' return 1', '', ].join('\n') expect(rewrite(file, 0, 3, '新的模块说明。\n\n换成了。')).toBe([ '"""新的模块说明。', '', '换成了。', '"""', '', 'def keep():', ' return 1', '', ].join('\n')) }) it('行注释 #:缩进和井号留着,只换这几行', () => { const file = [ 'def keep():', ' return 0', '', ' # 旧说明', ' # 第二行', ' return 1', '', ].join('\n') const next = rewrite(file, 3, 4, '新说明') expect(next).toBe([ 'def keep():', ' return 0', '', ' # 新说明', ' return 1', '', ].join('\n')) expect(commonIndent([' # 新说明'])).toBe(' ') }) it('块注释 /** */:星号和收尾留着,前后的代码不动', () => { const file = [ 'export const KEEP = 1', '', '/**', ' * 旧说明', ' * 还有一行', ' */', 'export function stay(): number {', ' return KEEP', '}', '', ].join('\n') expect(rewrite(file, 2, 5, '新说明')).toBe([ 'export const KEEP = 1', '', '/**', ' * 新说明', ' */', 'export function stay(): number {', ' return KEEP', '}', '', ].join('\n')) }) it('三斜杠和模块内文档注释:记号留着,不收成 //', () => { expect(refillComment('/// 旧说明', '新说明')).toBe('/// 新说明') expect(refillComment('//! 旧说明', '新说明')).toBe('//! 新说明') }) it('双斜杠行注释同样只换正文', () => { const file = [ 'function keep() {', ' // 旧说明', ' return 1', '}', '', ].join('\n') expect(rewrite(file, 1, 1, '新说明')).toBe([ 'function keep() {', ' // 新说明', ' return 1', '}', '', ].join('\n')) }) it('认不出定界符、正文是空的,都不给出替换', () => { expect(refillComment('const x = 1', '新')).toBeUndefined() expect(refillComment('# 旧', ' ')).toBeUndefined() }) }) const line = (value: unknown): string => JSON.stringify(value) /** 模型调 StructuredOutput 交结果:工具输入逐段来,再是整条消息 */ function structuredLines(body: string): string[] { const json = JSON.stringify({ body }) return [ line({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', name: 'StructuredOutput', input: {} } } }), line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(0, 12) } } }), line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(12) } } }), line({ type: 'stream_event', event: { type: 'content_block_stop' } }), line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'StructuredOutput', input: { body } }] } }), ] } const resultLine = (fields: Record): string => line({ type: 'result', subtype: 'success', is_error: false, ...fields }) describe('重写注释的提示', () => { it('点按钮时只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => { expect(commentRewritePrompt('src/acquisition/repository.py', 12, 20)).toBe(`${COMMENT_SKILL} src/acquisition/repository.py:13-21`) expect(commentRewritePrompt('a.ts', 0, 0)).toBe('/ecc:writing-code-comments a.ts:1-1') }) it('用户说了话:开新会话时跟在技能命令后面;接着会话说时前面是块现在在哪(写回注释后行号会变)', () => { expect(commentRewritePrompt('a.ts', 2, 9, '加上原型图')).toBe('/ecc:writing-code-comments a.ts:3-10\n\n加上原型图') expect(commentFollowUp('a.ts', 2, 9, '加上原型图')).toBe('a.ts:3-10\n\n加上原型图') }) }) describe('读模型交回的结构化结果', () => { it('正文跟着 StructuredOutput 的输入逐段长出来,读文件是正在做的事;结果以 structured_output 为准', () => { const parse = createCommentParser() const events = [ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/w/src/a.py' } }] } }), ...structuredLines('把 `a` 和 `b` 相加。'), resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' }, session_id: 's1' }), ].flatMap(parse) expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' }) expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。']) expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。', sessionId: 's1' }) }) it('会话 id 以 result 行的为准,没有时用开头 init 行的', () => { const parse = createCommentParser() expect(parse(line({ type: 'system', subtype: 'init', session_id: 's0' }))).toEqual([]) expect(parse(resultLine({ structured_output: { body: '正文' } }))).toEqual([{ kind: 'result', body: '正文', sessionId: 's0' }]) }) it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => { expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }]) expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }]) expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }]) }) }) /** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */ function memoryFile(text: string): { deps: CommentWriteDeps, text: () => string, writes: CommentEdit[] } { let current = text const writes: CommentEdit[] = [] const lines = (): string[] => splitLines(current) return { text: () => current, writes, deps: { lineAt: index => lines()[index], lineCount: () => lines().length, write: async (edit): Promise => { writes.push(edit) if (edit.kind === 'replace') { current = replaceLines(current, edit.startLine, edit.endLine, edit.text) } else { const all = lines() current = [...all.slice(0, edit.line), ...edit.text.split('\n'), ...all.slice(edit.line)].join('\n') } return 'written' }, }, } } /** 用 Python 语言插件、中文直译把源码读成视图数据,按块的第一行(1 起,含讲它的注释)找到那块。 */ async function pythonBlock(source: string, line: number): Promise { const py = createPythonAdapter(WASM_DIR) const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) const all = (list: BlockView[]): BlockView[] => list.flatMap(view => [view, ...all(view.children)]) const found = all(views).find(view => view.startLine + 1 === line) if (!found) throw new Error(`第 ${line} 行没有块`) return found } /** 开始重写时记下这块:写到哪、范围、这几行当时的原文。 */ function job(block: BlockView, source: string): CommentJob { if (!block.commentTarget) throw new Error('这块不能写注释') const lines = splitLines(source) const started = commentJob(block.commentTarget, block.startLine, block.endLine, index => lines[index], () => lines.length) if (!started) throw new Error('块不在文件里') return started } const SOURCE = [ 'import os', '', '# 旧的说明', '', '# 第二段', 'class Repo:', ' def find(self):', ' return os.sep', '', ].join('\n') describe('把交回的正文写回这块代码', () => { it('块前面的注释(几段隔着空行的也算)换成交回的正文,套回 # 和缩进', async () => { const repo = await pythonBlock(SOURCE, 3) expect(repo.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 4 }) const file = memoryFile(SOURCE) expect(await writeCommentBody(job(repo, SOURCE), '仓库:按名字找文件。', file.deps)).toBeUndefined() expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。')) }) it('函数已经有 docstring、上面没有 #:换掉 docstring 的正文,不在 def 上面再插一行', async () => { const source = 'class Repo:\n def find(self):\n """找。"""\n return 1\n' const find = await pythonBlock(source, 2) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) const file = memoryFile(source) expect(await writeCommentBody(job(find, source), '按名字找\n\n找不到时返回 1', file.deps)).toBeUndefined() expect(file.text()).toBe('class Repo:\n def find(self):\n """按名字找\n\n 找不到时返回 1\n """\n return 1\n') }) it('函数既没有 docstring 也没有前面的 #:在主体里插一段 docstring,不在 def 上面写 #', async () => { const source = 'def find(self):\n return 1\n' const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '"""_"""' }) const file = memoryFile(source) expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('def find(self):\n """按名字找。"""\n return 1\n') }) it('声明和主体写在同一行:docstring 插进主体,不写在 def 上面', async () => { const source = 'def find(): return 1\n' const find = await pythonBlock(source, 1) expect(find.commentTarget).toMatchObject({ kind: 'insert', line: 0, shell: '"""_"""' }) const file = memoryFile(source) expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('def find():\n """按名字找。"""\n return 1\n') }) it('函数只有前面的 #、没有 docstring:仍换掉那段 #', async () => { const source = '# 旧的说明\ndef find():\n return 1\n' const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 }) const file = memoryFile(source) expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined() expect(file.text()).toBe('# 按名字找。\ndef find():\n return 1\n') }) it('docstring 和前面的 # 都有:只换 docstring,# 留着', async () => { const source = '# 旧的说明\ndef find():\n """找。"""\n return 1\n' const find = await pythonBlock(source, 1) expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) const file = memoryFile(source) expect(await writeCommentBody(job(find, source), '新的说明。', file.deps)).toBeUndefined() expect(file.text()).toBe('# 旧的说明\ndef find():\n """新的说明。"""\n return 1\n') }) it('文件开头的说明卡片换的是它自己:范围就是这段注释', async () => { const source = '"""旧的模块说明。"""\n\nimport os\n' const header = await pythonBlock(source, 1) expect(header).toMatchObject({ kind: 'comment', commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } }) const file = memoryFile(source) expect(await writeCommentBody(job(header, source), '新的模块说明。', file.deps)).toBeUndefined() expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n') }) it('没交回结构化的正文:不写,说原因', async () => { const repo = await pythonBlock(SOURCE, 3) const silent = memoryFile(SOURCE) expect(await writeCommentBody(job(repo, SOURCE), undefined, silent.deps)).toBe('模型没有按要求交回注释正文,没有改文件。') expect(silent.writes).toEqual([]) }) it('重写期间这块代码变了、行号已经不在文件里:不写;编辑器没接受:说原因', async () => { const repo = await pythonBlock(SOURCE, 3) const file = memoryFile(SOURCE) const edited = splitLines(SOURCE.replace('return os.sep', 'return "/"')) expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineAt: index => edited[index] })).toBe('这块代码在重写期间变了,没有写回。') expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineCount: () => 3 })).toBe('这块代码在重写期间变了,没有写回。') expect(file.writes).toEqual([]) expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, write: async () => 'rejected' })).toBe('编辑器没接受这次修改,没有写回。') expect(commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 5, 9, () => '', () => 3)).toBeUndefined() }) it('认不出定界符:不写,说原因', async () => { const file = memoryFile('x = 1\n') const started = commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 0, 0, () => 'x = 1', () => 2) expect(started && await writeCommentBody(started, '说明', file.deps)).toBe('这段注释的定界符认不出来,没有改文件。') }) it('typescript 已经有 JSDoc:换掉这段说明,不在声明上面再写 //', async () => { const ts = createTypeScriptAdapter(WASM_DIR) const source = '/** 读配置。 */\nexport function load() {}\n' const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) expect(view.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 }) const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') expect(edit).toEqual({ kind: 'replace', startLine: 0, endLine: 0, text: '/** 启动时读一次 */' }) }) it('typescript 还没有说明:在声明上面插 /** */,不写 //', async () => { const ts = createTypeScriptAdapter(WASM_DIR) const source = 'export function load() {}\n' const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/** _ */' }) const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') expect(edit).toEqual({ kind: 'insert', line: 0, text: '/** 启动时读一次 */' }) }) it('rust 还没有说明:在项上面插 ///,不写 //', async () => { const rust = createRustAdapter(WASM_DIR) const source = 'fn load() {}\n' const [view] = await withBlocks(rust, 'rust', source, blocks => toBlockViews(blocks, { source, language: rust, markers: [] }, rustZhTranslator)) expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/// _' }) const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') expect(edit).toEqual({ kind: 'insert', line: 0, text: '/// 启动时读一次' }) }) }) describe('按记号找块', () => { it('先序找,找不到是 undefined', () => { const tree = [{ anchor: 'a', children: [{ anchor: 'b', children: [] }] }, { anchor: 'c', children: [] }] expect(findBlock(tree, 'b')?.anchor).toBe('b') expect(findBlock(tree, 'x')).toBeUndefined() }) })