import type { BlockView } from './blockView' import type { LanguageAdapter, Translator } from './contract' import { describe, expect, it } from 'vitest' import { readBlockViews, toBlockViews } from './blockView' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { propertyLines, readFixture, runtime, segmentText, WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise { return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, 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'], [py, 'python', pythonZhTranslator, 'sample.py'], [py, 'python', pythonZhTranslator, 'routes.py'], ] const PROPERTY_KINDS = new Set(['function', 'import', 'const', 'variable']) function translationText(view: BlockView): string { if (!('translation' in view)) throw new Error(`块 ${view.id} 是属性表`) return segmentText(view.translation) } describe('blockView', () => { it.each(SAMPLES)('%#: codeTokens 拼起来等于 code,code 是 header 整行去共同缩进', 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(view.translation.length, view.id).toBeGreaterThan(0) } }) 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('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '6.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) expect(byId(env, '6.1')).toMatchObject({ kind: 'if', startLine: 24, code: 'if (!folder)' }) expect(byId(env, '8.2.1')).toMatchObject({ kind: 'catch', startLine: 45, code: 'catch (err) {' }) const issue = await views(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(byId(issue, '3.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, '6.3').codeTokens expect(byId(env, '6.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, '3').codeTokens.slice(0, 5)).toEqual([ { text: 'export', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'function', role: 'storage' }, { text: ' ', role: 'plain' }, { text: 'evaluateLockedReason', role: 'function' }, ]) expect(byId(issue, '2').codeTokens).toEqual([{ text: byId(issue, '2').code, role: 'comment' }]) 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' }) expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }]) }) 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, endLine: 2, code: 'if (a &&\n\tb) {', translation: [] }) expect(cond.codeTokens.map(token => token.text).join('')).toBe(cond.code) expect(byId(list, '0')).toMatchObject({ kind: 'function', properties: [] }) }) it('readBlockViews:解析、转视图、释放语法树', async () => { const 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') }) })