import type { BlockView } from './blockView' import type { LanguageAdapter, LibraryRule, Translator } from './contract' import { describe, expect, it } from 'vitest' import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView' import { replaceLines } from './editText' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { commentText, DOCKERFILE_WASM_DIR, previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) const bash = createBashAdapter(WASM_DIR) function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise { return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) } function flatten(list: BlockView[]): BlockView[] { return list.flatMap(view => [view, ...flatten(view.children)]) } function byId(list: BlockView[], id: string): BlockView { const found = flatten(list).find(view => view.id === id) if (!found) throw new Error(`没有块 ${id}`) return found } const SAMPLES: [LanguageAdapter, string, Translator, string][] = [ [ts, 'typescript', typescriptZhTranslator, 'envLock.ts.txt'], [ts, 'typescript', typescriptZhTranslator, 'issueLock.ts.txt'], [ts, 'typescript', typescriptZhTranslator, 'classes.ts.txt'], [py, 'python', pythonZhTranslator, 'sample.py'], [py, 'python', pythonZhTranslator, 'routes.py'], [py, 'python', pythonZhTranslator, 'classes.py'], [py, 'python', pythonZhTranslator, 'attributeDocs.py'], ] const PROPERTY_KINDS = new Set(['function', 'class', 'import', 'const', 'variable']) function translationText(view: BlockView): string { if (!('translation' in view)) throw new Error(`块 ${view.id} 是属性表`) return previewText(view.translation) } describe('blockView', () => { it('有名字的函数、类(含嵌在里面的方法)标上能看调用树,别的块不标', async () => { const source = 'import os\n\nLIMIT = 1\n\n\nclass Order:\n def pay(self):\n print(os.name)\n\n\ndef main():\n Order().pay()\n' const found = flatten(await views(py, 'python', pythonZhTranslator, source)).map(view => [view.kind, view.callTree]) expect(found).toEqual([ ['import', undefined], ['const', undefined], ['class', {}], ['function', {}], ['statement', undefined], ['function', {}], ['statement', undefined], ]) }) it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是编辑范围整行去共同缩进', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) const lines = source.split('\n') for (const view of flatten(await views(adapter, languageId, translator, source))) { expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code) const rows = lines.slice(view.startLine, view.endLine + 1) const indent = Math.min(...rows.filter(row => row.trim()).map(row => row.length - row.trimStart().length)) expect(view.code, view.id).toBe(rows.map(row => row.slice(indent)).join('\n')) if ('properties' in view) expect(view.properties.length, view.id).toBeGreaterThan(0) else expect(previewText(view.translation), view.id).not.toBe('') } }) it.each(SAMPLES)('%#: 函数、类、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { expect('properties' in view, view.id).toBe(PROPERTY_KINDS.has(view.kind)) expect('translation' in view, view.id).toBe(!PROPERTY_KINDS.has(view.kind)) } }) it.each(SAMPLES)('%#: 编辑范围是块的完整范围:盖住各子孙块,兄弟块之间最多共用一行,首尾不是空行,原样写回不变;头部是开头那几行', async (adapter, languageId, translator, fixture) => { const source = readFixture(fixture) const lines = source.split('\n') const visit = (list: BlockView[], parent: BlockView | undefined): void => { list.forEach((view, i) => { expect(lines[view.startLine].trim(), view.id).not.toBe('') expect(lines[view.endLine].trim(), view.id).not.toBe('') expect(view.headerEndLine, view.id).toBeGreaterThanOrEqual(view.startLine) expect(view.headerEndLine, view.id).toBeLessThanOrEqual(view.endLine) if (parent) { expect(view.startLine, `${parent.id} 盖住 ${view.id}`).toBeGreaterThanOrEqual(parent.startLine) expect(view.endLine, `${parent.id} 盖住 ${view.id}`).toBeLessThanOrEqual(parent.endLine) } const next = list[i + 1] if (next) expect(view.endLine, `${view.id} / ${next.id}`).toBeLessThanOrEqual(next.startLine) expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) visit(view.children, view) }) } visit(await views(adapter, languageId, translator, source), undefined) }) it('编辑父块时编辑框里是整块代码:头部、各子块、结尾都在,改完整块写回;子块的编辑框只有它自己连同它的子块', async () => { const source = [ '/** 窗口顶部 40px.', '侧栏开关只在侧栏顶部. */', 'export const DesktopTitlebar = ({ platform }: { platform: DesktopShell["platform"] }) => {', ' if (platform === "macos") {', ' return null', ' }', ' return
', '}', '', ].join('\n') const list = await views(ts, 'typescriptreact', typescriptZhTranslator, source) const bar = byId(list, '0') expect(bar).toMatchObject({ kind: 'function', startLine: 0, headerEndLine: 2, endLine: 7, code: source.trimEnd() }) expect(byId(list, '0.0')).toMatchObject({ kind: 'if', startLine: 3, headerEndLine: 3, endLine: 5, code: 'if (platform === "macos") {\n return null\n}' }) expect(byId(list, '0.0.0')).toMatchObject({ kind: 'return', startLine: 4, endLine: 4, code: 'return null' }) expect(replaceLines(source, bar.startLine, bar.endLine, bar.code.replace('return null', 'return '))).toBe(source.replace('return null', 'return ')) }) it('展平成兄弟块的 else、elif、catch:各到下一块开头之前,最后一块到整条语句结尾(Shell 的 fi 归它);同一行写的 `} else {` 两块都有', async () => { const code = async (adapter: LanguageAdapter, languageId: string, source: string): Promise<[string, number, number, string][]> => (await views(adapter, languageId, undefined, source)).map(view => [view.kind, view.startLine, view.endLine, view.code]) expect(await code(ts, 'typescript', 'if (a) {\n x()\n} else if (b) {\n y()\n}\nelse {\n z()\n}\ntry {\n x()\n}\ncatch {\n y()\n}\n')).toEqual([ ['if', 0, 2, 'if (a) {\n x()\n} else if (b) {'], ['else', 2, 4, '} else if (b) {\n y()\n}'], ['else', 5, 7, 'else {\n z()\n}'], ['try', 8, 10, 'try {\n x()\n}'], ['catch', 11, 13, 'catch {\n y()\n}'], ]) expect(await code(py, 'python', 'if a:\n x()\nelif b:\n y()\nelse:\n z()\n')).toEqual([ ['if', 0, 1, 'if a:\n x()'], ['else', 2, 3, 'elif b:\n y()'], ['else', 4, 5, 'else:\n z()'], ]) expect(await code(bash, 'shellscript', 'if [ -n "$a" ]; then\n x\nelif [ -n "$b" ]; then\n y\nelse\n z\nfi\n')).toEqual([ ['if', 0, 1, 'if [ -n "$a" ]; then\n x'], ['else', 2, 3, 'elif [ -n "$b" ]; then\n y'], ['else', 4, 6, 'else\n z\nfi'], ]) }) it('函数的说明算在函数块的头部里:编辑时和签名一起显示、一起改', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '5')).toMatchObject({ kind: 'function', startLine: 17, headerEndLine: 22, endLine: 29 }) expect(byId(env, '5').code.split('\n').slice(0, 6)).toEqual([ '/**', ' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`', ' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute', ' * fs paths. Empty array if the workspace folder isn\'t currently open.', ' */', 'export async function findEnvFiles(workspaceRoot: string): Promise {', ]) expect(byId(env, '4')).toMatchObject({ kind: 'const', startLine: 15, endLine: 15 }) const routes = await views(py, 'python', pythonZhTranslator, readFixture('routes.py')) const getUser = flatten(routes).find(view => view.kind === 'function' && view.startLine === 28) expect(getUser).toMatchObject({ startLine: 28, headerEndLine: 37 }) expect(getUser?.code.split('\n').slice(5, 10)).toEqual([ ') -> UserDetailResponse:', ' """获取用户详情。', '', ' 管理员可查看任意用户,普通用户只能查看自己。', ' """', ]) expect(getUser?.children[0]).toMatchObject({ kind: 'if', startLine: 38, endLine: 40, headerEndLine: 39 }) expect(commentText(getUser?.children[0])).toBe('非管理员只能查看自己') }) it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => { const repo = byId(await views(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt')), '1') expect(repo).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 8 }) expect(repo.code.split('\n').slice(4, 7)).toEqual([ ' */', '@Injectable()', 'export abstract class Repository extends Base implements Loader, Disposable {', ]) expect('properties' in repo && repo.properties[1]).toEqual({ label: '说明', value: { markdown: '仓库基类。\n\n子类实现 `load`。' } }) const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3') expect(notFound).toMatchObject({ kind: 'class', startLine: 17, headerEndLine: 19 }) expect(notFound.code.split('\n').slice(0, 3)).toEqual([ 'class NotFound(AppResponse): # 行尾', ' # 说明前面的注释', ' """找不到资源。"""', ]) expect(notFound.children[0]).toMatchObject({ kind: 'variable', startLine: 20, endLine: 20 }) }) it('属性 docstring 算在常量、变量、字段块里:常量、变量的「说明」紧跟名称,字段的说明排在译文下面;编辑范围连着它', async () => { const source = [ 'CASE_DEPLOYMENT_UNIQUE_CONSTRAINT = "uq_case_library_deployment"', '"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""', '', '', 'class CaseStatus:', ' ANALYZING = "analyzing"', '', ' """正在分析源 deployment."""', ' title: str = ""', ' """案例标题,', ' 用户可编辑."""', ].join('\n') const list = await views(py, 'python', pythonZhTranslator, source) const snapshot = flatten(list).map(view => ({ id: view.id, kind: view.kind, lines: [view.startLine, view.endLine], ...('properties' in view ? { rows: propertyLines(view.properties) } : { translation: previewText(view.translation), doc: view.doc }), })) expect(snapshot).toEqual([ { id: '0', kind: 'const', lines: [0, 1], rows: [ '名称: CASE_DEPLOYMENT_UNIQUE_CONSTRAINT', '说明: 一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。', '访问级别: 公共', '值: "uq_case_library_deployment"', ], }, { id: '1', kind: 'class', lines: [4, 10], rows: ['名称: CaseStatus', '访问级别: 公共'] }, { id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] }, { id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } }, ]) expect(byId(list, '0').code).toBe(source.split('\n').slice(0, 2).join('\n')) expect(byId(list, '1.0').code).toBe('ANALYZING = "analyzing"\n\n"""正在分析源 deployment."""') expect(byId(list, '1.1').code).toBe('title: str = ""\n"""案例标题,\n用户可编辑."""') for (const view of flatten(list)) expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source) expect(replaceLines(source, 8, 10, 'title: str = "x"\n"""新说明"""').split('\n').slice(8)).toEqual([' title: str = "x"', ' """新说明"""']) }) it('注释块的译文、函数的「说明」是 Markdown 原文,webview 按它渲染', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '0')).toMatchObject({ kind: 'comment', translation: { markdown: expect.stringMatching(/^\.env file lock \/ unlock helpers\.\n\nRecursively/) } }) const findEnvFiles = byId(env, '5') expect('properties' in findEnvFiles && findEnvFiles.properties[1]).toEqual({ label: '说明', value: { markdown: expect.stringMatching(/^Scan the workspace folder/) } }) }) it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) expect(byId(env, '5.1')).toMatchObject({ kind: 'if', startLine: 24, endLine: 25, code: 'if (!folder)\n return []' }) expect(byId(env, '7.2.1')).toMatchObject({ kind: 'catch', startLine: 45, endLine: 47, code: 'catch (err) {\n result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })\n}', }) const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(byId(issue, '2.4')).toMatchObject({ kind: 'return', startLine: 19, endLine: 23, code: 'return {\n locked: true,\n prerequisiteNumber: prereq.number,\n prerequisiteColumn: prereq.column,\n}', }) }) it('源码着色来自语法树:每个叶子一段,空白和标点归 plain', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const tokens = byId(env, '5.3').codeTokens expect(byId(env, '5.3').code).toBe('const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)') expect(tokens.slice(0, 7)).toEqual([ { text: 'const', role: 'storage' }, { text: ' ', role: 'plain' }, { text: 'uris', role: 'variable' }, { text: ' = ', role: 'plain' }, { text: 'await', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'workspace', role: 'variable' }, ]) expect(tokens).toContainEqual({ text: 'findFiles', role: 'function' }) expect(byId(env, '0').codeTokens).toEqual([{ text: byId(env, '0').code, role: 'comment' }]) // 中文注释之后的行:语法树下标按字符串下标算,不按 UTF-8 字节 const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(byId(issue, '2').codeTokens.slice(0, 7)).toEqual([ { text: '/**\n * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,\n * 与 webview 的 `isIssueLocked` 保持同一语义。\n */', role: 'comment' }, { text: '\n', role: 'plain' }, { text: 'export', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'function', role: 'storage' }, { text: ' ', role: 'plain' }, { text: 'evaluateLockedReason', role: 'function' }, ]) const sample = await views(py, 'python', pythonZhTranslator, readFixture('sample.py')) const signature = byId(sample, '5').codeTokens expect(signature).toContainEqual({ text: 'def', role: 'storage' }) expect(signature).toContainEqual({ text: 'find_env_files', role: 'function' }) // 签名里注解的 str 是类型,函数体里调用的 str(err) 是函数 expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }, { text: 'str', role: 'function' }]) }) it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => { const own = (source: string, list: BlockView[]): Record => Object.fromEntries(list.map(view => [view.id, view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)])) const pySource = ['import os', 'def f(x): pass', 'a, b = 1, 2', 'for i in x: pass', 'class K: pass'].join('\n') expect(own(pySource, await views(py, 'python', pythonZhTranslator, pySource))).toEqual({ 0: undefined, 1: 'f', 2: 'a, b', 3: undefined, 4: 'K' }) const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1', 'class A {}', 'const B = class C {}', 'export default class {}'].join('\n') expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h', 3: 'A', 4: 'B', 5: undefined }) }) it('\\r\\n 和制表符缩进:code 用 \\n 连接,去掉共同的制表符', async () => { const source = 'function f() {\r\n\tif (a &&\r\n\t\tb) {\r\n\t\treturn 1\r\n\t}\r\n}\r\n' const list = await views(ts, 'typescript', undefined, source) const cond = byId(list, '0.0') expect(cond).toMatchObject({ kind: 'if', startLine: 1, headerEndLine: 2, endLine: 4, code: 'if (a &&\n\tb) {\n\treturn 1\n}', translation: [] }) expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code) expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] }) }) it('readBlockViews:解析、转视图、释放语法树', async () => { const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', 'def f(x: int) -> bool:\n return not x\n') expect(list).toHaveLength(1) expect('properties' in list[0] && propertyLines(list[0].properties)).toEqual([ '名称: f', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' x | 整数 | ', '输出参数: 布尔', ]) expect(translationText(list[0].children[0])).toBe('返回 非 x') }) it('跨几行写的调用:一行译文下面带摊开实参的表;calleeKind 交给翻译插件分新建还是调用', async () => { const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n' const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { calleeKind: call => call.callee.text === 'Detail' ? 'class' : undefined }) const returned = list[0].children[0] expect(translationText(returned)).toBe('返回 新建 Detail · 1 个参数') expect('table' in returned && returned.table).toMatchObject({ columns: ['参数', '值'], items: [{ cells: [[{ text: 'id', offset: source.indexOf('id=') }], [{ text: 'user' }, { text: '.' }, { text: 'id' }]] }], }) }) it('跨几行写的列表:一行译文下面带拆开的各项,项里的调用带着自己的实参表', async () => { const source = 'def f(user):\n return [\n Detail(\n id=user.id,\n ),\n user,\n ]\n' const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { calleeKind: call => call.callee.text === 'Detail' ? 'class' : undefined }) const returned = list[0].children[0] expect(translationText(returned)).toBe('返回 列表 2 项') expect(returned).not.toHaveProperty('table') expect('items' in returned && returned.items).toMatchObject([ { cells: [[{ text: '第 1 项' }], [{ text: '新建' }, { text: ' ' }, { text: 'Detail', offset: source.indexOf('Detail') }, { text: ' ' }, { text: '· 1 个参数' }]], table: { columns: ['参数', '值'] } }, { cells: [[{ text: '第 2 项' }], [{ text: 'user', offset: source.indexOf('user,') }]] }, ]) }) it('概念列表:语言的种类在前,语言另认的接着,规则的种类和另认的在后;同一个 id 只留第一个', async () => { const language: LanguageAdapter = { ...py, concepts: block => block.kind === 'class' ? [ { id: 'class', label: '重复的类', icon: 'zap' }, { id: 'python.abstract', label: '抽象', icon: 'braces' }, ] : [], } const rule: LibraryRule = { id: 'demo', label: '演示', languageIds: ['python'], libraries: [], claim: block => block.kind === 'class' ? { kind: 'demo.thing', badge: { label: '东西', color: { dark: '#111111', light: '#222222' }, icon: 'package' }, rows: [], absorbs: [], concepts: [ { id: 'class', label: '又一个类', icon: 'lock' }, { id: 'demo.thing', label: '重复的东西', icon: 'tag', color: { dark: '#333333', light: '#444444' } }, { id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } }, ], } : undefined, } const source = 'class Box:\n pass\n' const [view] = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [] }, pythonZhTranslator, { rules: [rule], report: () => {} })) expect(view?.concepts).toEqual([ { id: 'class', label: '类', icon: 'box' }, { id: 'python.abstract', label: '抽象', icon: 'braces' }, { id: 'demo.thing', label: '东西', icon: 'package', color: { dark: '#111111', light: '#222222' } }, { id: 'demo.extra', label: '额外', icon: 'lock', color: { dark: '#555555', light: '#666666' } }, ]) expect(view?.kind).toBe('demo.thing') expect(view?.attributes.kind).toBe('demo.thing') }) it('readDefinitionKind:offset 落在哪块自己的名字上就是那块的种类,里层的块优先;不在名字上没有', async () => { const source = 'class UserService:\n def update_user(self):\n pass\n\nclass NotFound(Exception):\n pass\n\nlimit = 3\n' const kind = (text: string): Promise => readDefinitionKind(runtime, py, 'python', source, source.indexOf(text)) expect(await kind('UserService')).toBe('class') expect(await kind('update_user')).toBe('function') expect(await kind('NotFound')).toBe('exception') expect(await kind('limit')).toBe('variable') expect(await kind('pass')).toBeUndefined() }) }) describe('卡片上的「注释」一行', () => { it('luffy 的 acquisition/repository.py:讲类的注释是类卡片的「注释」一行(排在名称后面),类的编辑范围、源码连着这几行', async () => { const source = readFixture('acquisitionRepository.py') const list = await views(py, 'python', pythonZhTranslator, source) expect(list.some(view => view.kind === 'comment')).toBe(false) const repo = list.find(view => view.kind === 'class') expect(repo).toMatchObject({ startLine: 12, headerEndLine: 20, commentTarget: { kind: 'replace', startLine: 12, endLine: 15 } }) expect('properties' in repo! && repo.properties.map(row => row.label).slice(0, 3)).toEqual(['名称', '注释', '访问级别']) expect(commentText(repo)).toBe([ 'luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):', 'user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个', '应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入', '会与该级联互相等待, 构成循环导入。', ].join('\n')) expect(repo?.code.split('\n').slice(0, 7)).toEqual([ '# luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):', '# user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个', '# 应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入', '# 会与该级联互相等待, 构成循环导入。', '', '', 'class AcquisitionChannelRepository(', ]) expect(replaceLines(source, repo!.startLine, repo!.endLine, repo!.code)).toBe(source) }) it('说明和注释都有时排成 名称、说明、注释;几段注释是几段 Markdown;一行译文的卡片「注释」一行排在译文下面', async () => { const source = [ 'import { readFile } from \'node:fs/promises\'', '', '// 讲 load', '', '// 第二段', '/** 读出配置。 */', 'export async function load(path: string) {', ' // 先检查', ' if (!path) {', ' return null', ' }', ' return readFile(path)', '}', ].join('\n') const [, load] = await views(ts, 'typescript', typescriptZhTranslator, source) expect('properties' in load && load.properties.map(row => row.label).slice(0, 4)).toEqual(['名称', '说明', '注释', '访问级别']) expect(commentText(load)).toBe('讲 load\n\n第二段') expect(load).toMatchObject({ startLine: 2, commentTarget: { kind: 'replace', startLine: 5, endLine: 5 } }) const [check, returned] = load.children expect(check).toMatchObject({ kind: 'if', startLine: 7, rows: [{ label: '注释', value: { markdown: '先检查' } }] }) expect(returned).toMatchObject({ kind: 'return', commentTarget: { kind: 'insert', line: 11 } }) expect(returned).not.toHaveProperty('rows') }) it('嵌着的代码里的块不能写注释:没有 commentTarget', async () => { const shell = createBashAdapter(WASM_DIR) const source = 'FROM node:22\nRUN npm ci && npm run build\n' const { blocks } = await readBlockViews(runtime, createDockerfileAdapter(DOCKERFILE_WASM_DIR), dockerfileZhTranslator, 'dockerfile', source, { embedded: languageId => languageId === 'shellscript' ? { adapter: shell, translator: bashZhTranslator } : undefined, }) const run = flatten(blocks).find(view => view.kind === 'run') expect(run?.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '# _' }) expect(run?.children.length).toBeGreaterThan(0) expect(run?.children.every(step => step.commentTarget === undefined)).toBe(true) }) })