483 lines
27 KiB
TypeScript
483 lines
27 KiB
TypeScript
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<BlockView[]> {
|
||
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 <div data-tauri-drag-region="deep" />',
|
||
'}',
|
||
'',
|
||
].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 <span />'))).toBe(source.replace('return null', 'return <span />'))
|
||
})
|
||
|
||
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<string[]> {',
|
||
])
|
||
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<K, T extends Entity = Entity> extends Base<string> implements Loader<T>, 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<string, string | undefined> =>
|
||
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<string | undefined> => 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)
|
||
})
|
||
})
|