import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, TokenRole, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' import { flatSegments, propertyLines, readFixture, segmentText, walk, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from './pythonZh' import { typescriptZhTranslator } from './typescriptZh' import { createZhTemplateTranslator } from './zhTemplate' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) /** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ interface Translated { kind: string, segments: Inline[], markdown?: string } /** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 if (byLine.has(line) || 'facts' in block) return const translation = translator.translate(block, { source, language: adapter }) byLine.set(line, 'markdown' in translation ? { kind: block.kind, segments: [], markdown: translation.markdown } : { kind: block.kind, segments: translation }) }) return byLine }) } /** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引。 */ function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 if (!byLine.has(line) && 'facts' in block) byLine.set(line, translator.describe(block, { source, language: adapter })) }) return byLine }) } function rowTexts(map: Map): Record { return Object.fromEntries([...map].map(([line, rows]) => [line, propertyLines(rows)])) } function valueOf(rows: PropertyRow[] | undefined, label: string): Inline[] { const value = rows?.find(row => row.label === label)?.value ?? [] if ('markdown' in value) throw new Error(`「${label}」是 Markdown`) return value } /** 译文写成文字,框写成 `⟦框头|框里的文字⟧`。 */ function text(segments: Inline[] | undefined): string { return segmentText(segments ?? []) } /** 带下标的段(框里的也算),按出现顺序写成 [文字, 下标]。 */ function linked(segments: Inline[] | undefined): [string, number][] { return flatSegments(segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]]) } function texts(map: Map): Record { return Object.fromEntries([...map].map(([line, { segments, markdown }]) => [line, markdown ?? text(segments)])) } /** 只看文字和着色:去掉各段的源码下标、属性行讲的定义(这两样另有用例)。 */ function colors(value: T): T { return JSON.parse(JSON.stringify(value, (key, field: unknown) => key === 'offset' || key === 'defines' ? undefined : field)) as T } const CJK = /\p{Script=Han}/u const CJK_ONLY = /^\p{Script=Han}+$/u const WORD_CHAR = /[\p{Script=Han}\w]/u /** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 */ function expectWordsApart(map: Map): void { for (const [line, { kind, segments }] of map) { if (kind === 'comment') continue const flat = flatSegments(segments) const full = flat.map(segment => segment.text).join('') let at = 0 for (const segment of flat) { if (segment.role !== 'string' && CJK.test(segment.text)) { expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY) for (const neighbor of [full[at - 1], full[at + segment.text.length]]) expect(neighbor ?? ' ', `L${line} "${full}" 里的「${segment.text}」`).not.toMatch(WORD_CHAR) } at += segment.text.length } } } describe('typeScript 中文直译', () => { it('spec 样例逐字一致', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(text(env.get(25)?.segments)).toBe('如果 (非 folder)') expect(text(env.get(26)?.segments)).toBe('返回 空数组') expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)') expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]') expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }') }) it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号、保留换行', async () => { const env = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) expect(env[32]).toBe('公共 接口 EnvLockResult') expect(env[42]).toBe('尝试') expect(env[46]).toBe('捕获 (err)') expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })') expect(env[1]).toBe([ '.env file lock / unlock helpers.', '', 'Recursively scans the main workspace folder for `.env*` files (skipping', '`node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or', '0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`.', '', 'No persistent watcher — re-scan happens on every toggle, so newly created', '`.env` files are only picked up the next time the user clicks the button.', ].join('\n')) expect(issue[5]).toBe('prerequisiteNumber?: 数值') expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)') }) it('每个被翻的词单独成段,着色取语法树词性', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) expect(colors(flatSegments(env.get(32)?.segments ?? []).filter(segment => segment.role !== 'plain'))).toEqual([ { text: '公共', role: 'keyword' }, { text: '接口', role: 'storage' }, { text: 'EnvLockResult', role: 'type' }, ]) expect(issue.get(15)?.segments).toContainEqual({ text: '全等', role: 'operator' }) expect(env.get(26)?.segments).toEqual([{ text: '返回', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: '空数组', role: 'literal' }]) expectWordsApart(env) expectWordsApart(issue) }) it('属性名、对象键、非空断言不被误翻', async () => { const source = [ 'o = { if: 1, return: i.number, string: x!.type, new: a.default }', 'n = a !== b && !c || d == e != f', 'type S = string | void', // eslint-disable-next-line no-template-curly-in-string -- 被翻译的源码文本里本来就有模板字符串 's = typeof v === \'string\' ? `${v}` : \'number\'', ].join('\n') const map = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source)) expect(map[1]).toBe('o = { if: 1, return: i.number, string: x!.type, new: a.default }') expect(map[2]).toBe('n = a 不全等 b 并且 非 c 或者 d 等于 e 不等于 f') expect(map[3]).toBe('类型 S = 字符串 | 无') // eslint-disable-next-line no-template-curly-in-string -- 模板字符串原样照抄进译文 expect(map[4]).toBe('s = 如果 类型为 v 全等 \'string\' 则 `${v}` 否则 \'number\'') }) it('逻辑与、或译成「并且」「或者」', async () => { expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, 'a && b || c'))).toEqual({ 1: 'a 并且 b 或者 c' }) }) it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => { const source = [ 'call(', ' first, // 第一个', ' [],', ')', ].join('\n') expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('call( first, 空数组 )') }) it('as / in / extends / default 按所在语法节点换译法', async () => { const source = [ 'export * as path from \'node:path\'', 'export { run as start } from \'./run\'', 'v = x as string', 'k = [\'a\'] as const', 'for (const key in obj) {}', 'has = \'k\' in obj', 'use(class A extends B implements C {})', 'interface I extends J, K {}', 'type F = (t: T) => void', 'type R = T extends string ? 1 : 2', 'export default g', 'switch (x) {', ' default:', ' break', '}', ].join('\n') const map = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source)) expect(map[1]).toBe('公共 * 作为 path 来自 \'node:path\'') expect(map[2]).toBe('公共 { run 作为 start } 来自 \'./run\'') expect(map[3]).toBe('v = x 断言为 字符串') expect(map[4]).toBe('k = [\'a\'] 断言为 常量') expect(map[5]).toBe('遍历 (常量 key 取自 obj)') expect(map[6]).toBe('has = \'k\' 在 obj') expect(map[7]).toBe('use(类 A 继承 B 实现 C {})') expect(map[8]).toBe('接口 I 继承 J, K') expect(map[9]).toBe('类型 F = (t: T) => 无') expect(map[10]).toBe('类型 R = T 符合 字符串 ? 1 : 2') expect(map[11]).toBe('公共 默认 g') expect(map[13]).toBe('默认情况') }) it('三元表达式按「如果…则…否则…」重排,嵌套的、槽位里的关键字和运算符照样翻', async () => { const source = [ 'err instanceof Error ? err.message : String(err)', 'z = x ? (y ? 1 : 2) : 3', 'w = a ? b : c ? d : e', 'v = a !== b && !c ? typeof d : await e', ].join('\n') const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source) expect(texts(map)).toEqual({ 1: '如果 err 属于 Error 则 err.message 否则 String(err)', 2: 'z = 如果 x 则 (如果 y 则 1 否则 2) 否则 3', 3: 'w = 如果 a 则 b 否则 如果 c 则 d 否则 e', 4: 'v = 如果 a 不全等 b 并且 非 c 则 类型为 d 否则 等待 e', }) expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword')).toEqual([ { text: '如果', role: 'keyword' }, { text: '则', role: 'keyword' }, { text: '否则', role: 'keyword' }, ]) expectWordsApart(map) }) }) describe('python 中文直译', () => { it('样例全文逐块一致', async () => { const map = await translateAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) expect(texts(map)).toEqual({ 1: 'Scan a workspace for .env files.', 12: '跳过备份文件', 14: '如果 非 found', 15: '返回 空列表', 16: '否则如果 len(found) > MAX_DEPTH', 18: '否则', 19: '跳过', 20: '遍历 name 取自 found', 21: '如果 name 在 skipped', 22: '继续', 23: '尝试', 24: '使用 open(os.path.join(root, found[0])) 作为 handle', 25: 'handle.read()', 26: '捕获 OSError 作为 err', 27: '抛出 RuntimeError(str(err)) 源自 err', 28: '最终', 29: 'skipped.clear()', 30: '返回 found', 34: 'total: 整数', 35: 'ok: 列表[字符串] = 空列表', 36: 'failed: 字典[字符串, 字符串] = 空字典', 40: '返回 如果 verbose 不是 空 则 f\'{self.total} files\' 否则 \'\'', }) expectWordsApart(map) }) it('内置类型只在注解里翻;比较里的 not in 是「不在」,推导式里也是', async () => { const source = [ 'def pick(items: dict[str, int], list: set) -> tuple:', ' return [str(k) for k in items if k not in list]', ].join('\n') expect(rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))[1]).toEqual([ '名称: pick', '访问级别: 公共', '异步: 否', '输入参数: 2 个', ' items | 字典[字符串, 整数] | ', ' list | 集合 | ', '输出参数: 元组', ]) expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 ⟦列表|遍历 items 的每个 k,仅当 k 不在 list,取 str(k)⟧') }) it('逻辑与、或译成「并且」「或者」', async () => { expect(texts(await translateAll(py, 'python', pythonZhTranslator, 'a and b or c'))).toEqual({ 1: 'a 并且 b 或者 c' }) }) it('from:raise … from 里「源自」,yield from 里「自」', async () => { const source = [ 'from typing import Annotated', 'def walk(nodes):', ' yield from nodes', ' raise RuntimeError(str(err)) from err', ].join('\n') const map = texts(await translateAll(py, 'python', pythonZhTranslator, source)) expect(map[3]).toBe('产出 自 nodes') expect(map[4]).toBe('抛出 RuntimeError(str(err)) 源自 err') }) it('else:if 后「否则」,for / while 后「未中断时」,try 后「未出错时」', async () => { const source = [ 'for k in items:', ' if k:', ' break', ' else:', ' pass', 'else:', ' pass', 'while busy:', ' pass', 'else:', ' pass', 'try:', ' pass', 'except OSError:', ' pass', 'else:', ' pass', ].join('\n') const map = texts(await translateAll(py, 'python', pythonZhTranslator, source)) expect(map[4]).toBe('否则') expect(map[6]).toBe('未中断时') expect(map[10]).toBe('未中断时') expect(map[16]).toBe('未出错时') }) it('条件表达式按「如果…则…否则…」重排,嵌套的、槽位里的关键字和运算符照样翻', async () => { const source = [ '_default_port = 5680 if _role == "beat" else 5679', 'y = a if x else b if y else c', 'z = (a if x else b) if y else c', 'v = [k for k in items if k not in seen] if not done and ok else None', ].join('\n') const map = await describeAll(py, 'python', pythonZhTranslator, source) expect(Object.fromEntries([...map].map(([line, rows]) => [line, text(valueOf(rows, '初始值'))]))).toEqual({ 1: '如果 _role 等于 "beat" 则 5680 否则 5679', 2: '如果 x 则 a 否则 如果 y 则 b 否则 c', 3: '如果 y 则 (如果 x 则 a 否则 b) 否则 c', 4: '如果 非 done 并且 ok 则 ⟦列表|遍历 items 的每个 k,仅当 k 不在 seen,取 k⟧ 否则 空', }) expect(flatSegments(valueOf(map.get(1), '初始值')).filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([ { text: '如果', role: 'keyword' }, { text: '等于', role: 'operator' }, { text: '则', role: 'keyword' }, { text: '否则', role: 'keyword' }, ]) }) }) describe('python 写在表达式里的遍历(推导式)', () => { it('spec 样例逐字一致:原地换成一个框,框里按读的顺序重排;调用和生成器共用的括号写在框外', async () => { const source = [ 'def invite_code(length):', ' return "".join(secrets.choice(ALPHABET) for _ in range(length))', 'def active_ids(users):', ' return [u.id for u in users if u.is_active]', 'def by_id(users):', ' return {u.id: u for u in users}', ].join('\n') const map = await translateAll(py, 'python', pythonZhTranslator, source) expect(texts(map)).toEqual({ 2: '返回 "".join(⟦生成器|遍历 range(length) 的每个 _,取 secrets.choice(ALPHABET)⟧)', 4: '返回 ⟦列表|遍历 users 的每个 u,仅当 u.is_active,取 u.id⟧', 6: '返回 ⟦字典|遍历 users 的每个 u,取 u.id: u⟧', }) expectWordsApart(map) }) it('框是 iteration 样式、框头是结果类型;遍历 / 的每个 / 仅当 / 取 按关键字着色,框里的名字照常着色', async () => { const map = await translateAll(py, 'python', pythonZhTranslator, 'return [u.id for u in users if u.is_active]') expect(colors(map.get(1)?.segments)).toEqual([ { text: '返回', role: 'keyword' }, { text: ' ', role: 'plain' }, { style: 'iteration', label: '列表', segments: [ { text: '遍历', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'users', role: 'variable' }, { text: ' ', role: 'plain' }, { text: '的每个', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'u', role: 'variable' }, { text: ',', role: 'plain' }, { text: '仅当', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'u', role: 'variable' }, { text: '.', role: 'plain' }, { text: 'is_active', role: 'variable' }, { text: ',', role: 'plain' }, { text: '取', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'u', role: 'variable' }, { text: '.', role: 'plain' }, { text: 'id', role: 'variable' }, ], }, ]) }) it('紧挨着的几节 for 用顿号接着写,条件在它的位置,异步的写「异步遍历」;集合、套着的框、别的参数旁边的生成器', async () => { const source = [ 'a = [c for xs in grid for c in xs]', 'b = [c for row in grid if row for c in row if c]', 'c = [v async for v in stream()]', 'd = [(x, y) for x in xs async for y in ys]', 'e = {n for n, _ in pairs}', 'f = [[y for y in row] for row in grid]', 'g = pick(a, (y for y in b))', 'h = sum(', ' n # 每一项', ' for n in counts', ')', ].join('\n') const map = await describeAll(py, 'python', pythonZhTranslator, source) expect(Object.fromEntries([...map].map(([line, rows]) => [line, text(valueOf(rows, '初始值'))]))).toEqual({ 1: '⟦列表|遍历 grid 的每个 xs、xs 的每个 c,取 c⟧', 2: '⟦列表|遍历 grid 的每个 row,仅当 row,遍历 row 的每个 c,仅当 c,取 c⟧', 3: '⟦列表|异步遍历 stream() 的每个 v,取 v⟧', 4: '⟦列表|遍历 xs 的每个 x,异步遍历 ys 的每个 y,取 (x, y)⟧', 5: '⟦集合|遍历 pairs 的每个 n, _,取 n⟧', 6: '⟦列表|遍历 grid 的每个 row,取 ⟦列表|遍历 row 的每个 y,取 y⟧⟧', 7: 'pick(a, ⟦生成器|遍历 b 的每个 y,取 y⟧)', 8: 'sum(⟦生成器|遍历 counts 的每个 n,取 n⟧)', }) const async = valueOf(map.get(3), '初始值')[0] expect('segments' in async && async.segments.filter(piece => 'role' in piece && piece.role === 'keyword')).toEqual([ { text: '异步遍历', role: 'keyword' }, { text: '的每个', role: 'keyword' }, { text: '取', role: 'keyword' }, ]) }) it('属性表的值里也换:参数默认值、字典一项的值', async () => { const source = [ 'def f(ids=[u.id for u in users]): pass', 'CONFIG = {"ids": {k: v for k, v in pairs}}', ].join('\n') const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source)) expect(rows[1]).toContain(' ids | 未标注 | ⟦列表|遍历 users 的每个 u,取 u.id⟧') expect(rows[2]).toEqual(['名称: CONFIG', '访问级别: 公共', '值: 字典 1 项', ' "ids" | ⟦字典|遍历 pairs 的每个 k, v,取 k: v⟧']) }) it('框里的名字带源码下标,和框外的一样指回源码原文', async () => { const source = 'return "".join(secrets.choice(ALPHABET) for _ in range(length))' const at = (word: string): number => source.indexOf(word) const map = await translateAll(py, 'python', pythonZhTranslator, source) expect(linked(map.get(1)?.segments)).toEqual([ ['""', at('""')], ['join', at('join')], ['range', at('range')], ['length', at('length')], ['_', at('_')], ['secrets', at('secrets')], ['choice', at('choice')], ['ALPHABET', at('ALPHABET')], ]) }) it('typeScript 没有这种写法,不认', async () => { const map = await translateAll(ts, 'typescript', typescriptZhTranslator, 'return items.map(x => x.id)') expect(text(map.get(1)?.segments)).toBe('返回 items.map(x => x.id)') }) }) describe('属性表', () => { it('spec 样例逐字一致', async () => { const env = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) const routes = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('routes.py'))) expect(env[18]).toEqual([ '名称: findEnvFiles', '说明: Scan the workspace folder rooted at `workspaceRoot` for all `.env*`\nfiles, skipping `node_modules`, `.git`, and `.claude`. Returns absolute\nfs paths. Empty array if the workspace folder isn\'t currently open.', '访问级别: 公共', '异步: 是', '输入参数: 1 个', ' workspaceRoot | 字符串 | ', '输出参数: Promise<字符串[]>', ]) expect(env[38]).toEqual([ '名称: chmodAll', '访问级别: 私有', '异步: 是', '输入参数: 2 个', ' workspaceRoot | 字符串 | ', ' mode | 数值 | ', '输出参数: Promise', ]) expect(env[12]).toEqual(['来源: \'node:fs\'', '仅类型: 否', '导入项: 1 个', ' promises 作为 fsp']) expect(env[15]).toEqual(['名称: ENV_PATTERN', '访问级别: 私有', '值: \'**/.env*\'']) expect(env[27]).toEqual(['名称: include', '初始值: 新建 RelativePattern(folder, ENV_PATTERN)']) expect(env[28]).toEqual(['名称: uris', '初始值: 等待 workspace.findFiles(include, EXCLUDE_PATTERN)']) expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: 对象 3 项', ' total | files.length', ' ok | 空数组', ' failed | 空数组']) expect(issue[1]).toEqual(['来源: \'../gitea/types\'', '仅类型: 是', '导入项: 1 个', ' Issue']) expect(issue[14]).toEqual(['名称: issue', '初始值: issues.find(i => i.number 全等 issueNumber)']) expect(routes[19]).toEqual([ '来源: .schemas', '导入项: 3 个', ' UserAvatarUploadResponse', ' UserDetailResponse', ' UserUpdateRequest', ]) expect(routes[29]).toEqual([ '名称: get_user', '说明: 获取用户详情。\n\n管理员可查看任意用户,普通用户只能查看自己。', '访问级别: 公共', '异步: 是', '装饰器: @router.get("/{user_id}", response_model=UserDetailResponse)', '输入参数: 3 个', ' user_id | Int4Path | ', ' db | AsyncSession | Depends(get_db)', ' current_user | User | Depends(get_current_user)', '输出参数: UserDetailResponse', ]) expect(routes[111]).toContain(' file | Annotated[UploadFile, File(..., description="头像文件")] | ') expect(routes[118]).toEqual(['名称: payload', '初始值: 等待 file.read()']) const sample = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py'))) expect(sample[10]).toEqual(['名称: find_env_files', '说明: Return every .env file under root.', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' root | 字符串 | ', '输出参数: 列表[字符串]']) expect(sample[33]).toEqual(['名称: EnvLockResult', '访问级别: 公共']) const port = rowTexts(await describeAll(py, 'python', pythonZhTranslator, '_default_port = 5680 if _role == "beat" else 5679')) expect(port[1]).toEqual(['名称: _default_port', '访问级别: 私有', '初始值: 如果 _role 等于 "beat" 则 5680 否则 5679']) }) it('输入参数的子行排成表格:列名是名称 / 类型 / 默认值,默认值格子只写译文、没有就留空;导入项、字典的子行没有列名', async () => { const pySource = [ 'from m import a, b', 'def f(x: int = 1, y=None, *args): pass', 'D = {"k": 1}', ].join('\n') const py1 = await describeAll(py, 'python', pythonZhTranslator, pySource) const params = py1.get(2)?.find(row => row.label === '输入参数') expect(params?.columns).toEqual(['名称', '类型', '默认值']) expect(colors(params?.items?.map(item => item.cells))).toEqual([ [[{ text: 'x', role: 'variable' }], [{ text: '整数', role: 'type' }], [{ text: '1', role: 'number' }]], [[{ text: 'y', role: 'variable' }], [{ text: '未标注', role: 'hint' }], [{ text: '空', role: 'storage' }]], [[{ text: '*', role: 'plain' }, { text: 'args', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []], ]) expect([...py1.values()].flat().filter(row => row.columns).map(row => row.label)).toEqual(['输入参数']) const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, 'function g(a: string, b = 2) {}') const tsParams = tsRows.get(1)?.find(row => row.label === '输入参数') expect(tsParams?.columns).toEqual(['名称', '类型', '默认值']) expect(tsParams?.items?.map(item => item.cells.map(cell => text(cell)))).toEqual([['a', '字符串', ''], ['b', '未标注', '2']]) }) it('属性名是纯文字;值按词性着色,说明词(未标注、N 个)用 hint', async () => { const env = await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const rows = colors(env.get(18) ?? []) expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数']) expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }]) expect(rows[1].value).toEqual({ markdown: expect.stringMatching(/^Scan the workspace folder/) }) expect(rows[2].value).toEqual([{ text: '公共', role: 'keyword' }]) expect(rows[3].value).toEqual([{ text: '是', role: 'storage' }]) expect(rows[4].value).toEqual([{ text: '1 个', role: 'hint' }]) expect(rows[4].items).toEqual([{ cells: [[{ text: 'workspaceRoot', role: 'variable' }], [{ text: '字符串', role: 'type' }], []] }]) expect(rows[5].value).toEqual([ { text: 'Promise', role: 'type' }, { text: '<', role: 'plain' }, { text: '字符串', role: 'type' }, { text: '[]>', role: 'plain' }, ]) expect(colors(valueOf(env.get(12), '来源'))).toEqual([{ text: '\'node:fs\'', role: 'string' }]) expect(colors(env.get(12)?.at(-1)?.items?.[0].cells[0])).toEqual([ { text: 'promises', role: 'variable' }, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: 'fsp', role: 'variable' }, ]) const sample = await describeAll(py, 'python', pythonZhTranslator, readFixture('sample.py')) expect(colors(valueOf(sample.get(3), '来源'))).toEqual([{ text: 'os', role: 'type' }]) expect(colors(sample.get(39)?.find(row => row.label === '输入参数')?.items)).toEqual([ { cells: [[{ text: 'self', role: 'variable' }], [{ text: '未标注', role: 'hint' }], []] }, { cells: [[{ text: 'verbose', role: 'variable' }], [{ text: '布尔', role: 'type' }], [{ text: '假', role: 'storage' }]] }, ]) }) it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行和 @ 标签行;没有文字不显示', async () => { const tsSource = [ 'class K {', ' /**', ' * 读配置。', ' *', ' * 例子:', ' * load(\'a\')', ' * @param path 文件路径', ' * @returns 配置', ' */', ' load(path: string) {}', ' /** */', ' empty() {}', '}', ].join('\n') const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource)) expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')\n@param path 文件路径\n@returns 配置', '访问级别: 公共']) expect(tsRows[11].slice(0, 2)).toEqual(['名称: empty', '访问级别: 公共']) const pySource = [ 'def f():', ' \'\'\'', ' 第一段', ' 缩进的一行', '', ' 第二段', ' \'\'\'', 'def g():', ' """"""', ' return 1', ].join('\n') const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, pySource)) expect(pyRows[1].slice(0, 3)).toEqual(['名称: f', '说明: 第一段\n 缩进的一行\n\n第二段', '访问级别: 公共']) expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共']) }) it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、JSDoc 标签、HTML 照抄', async () => { const pySource = [ '"""手机号规范化 / 脱敏工具.', '', '凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 mask。', '"""', '# 用法:', '#', '# mask_phone("13800138000")', 'def mask_phone(phone: str) -> str:', ' """把中间四位换成 ``*``.', '', ' - 不足 7 位原样返回', ' - 见 https://example.com/phone', ' """', ].join('\n') const pyText = await translateAll(py, 'python', pythonZhTranslator, pySource) expect(pyText.get(1)).toEqual({ kind: 'comment', segments: [], markdown: '手机号规范化 / 脱敏工具.\n\n凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 mask。', }) expect(pyText.get(5)?.markdown).toBe('用法:\n\n mask_phone("13800138000")') const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) expect(pyRows.get(8)?.find(row => row.label === '说明')?.value).toEqual({ markdown: '把中间四位换成 ``*``.\n\n- 不足 7 位原样返回\n- 见 https://example.com/phone', }) const tsSource = [ '// 第一行', '// - 缩进的列表', '/* 单行块注释 */', '/**', ' * 读配置,见 {@link load}。', ' *', ' * ```ts', ' * load(\'a\')', ' * ```', ' * @param path 文件路径', ' */', 'function load(path: string) {}', ].join('\n') const tsText = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, tsSource)) expect(tsText[1]).toBe('第一行\n - 缩进的列表') expect(tsText[3]).toBe('单行块注释') const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource) expect(tsRows.get(4)?.find(row => row.label === '说明')?.value).toEqual({ markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```\n@param path 文件路径', }) }) it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => { const tsSource = [ 'import d, { b as c } from \'y\'', 'import * as ns from \'z\'', 'import \'./side\'', 'import fs = require(\'fs\')', ].join('\n') expect(rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 1: ['来源: \'y\'', '仅类型: 否', '导入项: 2 个', ' 默认导出 作为 d', ' b 作为 c'], 2: ['来源: \'z\'', '仅类型: 否', '导入项: 1 个', ' 全部 作为 ns'], 3: ['来源: \'./side\'', '仅类型: 否', '导入项: 无'], 4: ['来源: \'fs\'', '仅类型: 否', '导入项: 1 个', ' 整个模块 作为 fs'], }) const pySource = [ 'import numpy.linalg as la', 'import os, sys as system', 'from m import *', ].join('\n') expect(rowTexts(await describeAll(py, 'python', pythonZhTranslator, pySource))).toEqual({ 1: ['来源: numpy.linalg', '导入项: 1 个', ' 整个模块 作为 la'], 2: ['来源: os, sys', '导入项: 2 个', ' os', ' sys 作为 system'], 3: ['来源: m', '导入项: 1 个', ' 全部'], }) }) it('函数:匿名、可省略参数、嵌套函数没有访问级别、类成员受保护、装饰器照抄源码', async () => { const source = [ 'export default async function () {}', 'function f(b?: number, c = 1): void {', ' function inner() {}', '}', 'class K {', ' @memo()', ' protected load(): x is Foo {}', '}', ].join('\n') expect(rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, source))).toEqual({ 1: ['名称: 匿名', '访问级别: 公共', '异步: 是', '输入参数: 无', '输出参数: 未标注'], 2: ['名称: f', '访问级别: 私有', '异步: 否', '输入参数: 2 个', ' b? | 数值 | ', ' c | 未标注 | 1', '输出参数: 无'], 3: ['名称: inner', '异步: 否', '输入参数: 无', '输出参数: 未标注'], 5: ['名称: K', '访问级别: 私有', '抽象: 否'], 6: ['名称: load', '访问级别: 受保护', '异步: 否', '装饰器: @memo()', '输入参数: 无', '输出参数: x is Foo'], }) }) it('类:说明、抽象(只有 TypeScript 有)、装饰器、类型参数、父类、实现的接口有才显示;父类的泛型实参、类型照常翻', async () => { const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt'))) expect(tsRows[3]).toEqual([ '名称: Repository', '说明: 仓库基类。\n\n子类实现 `load`。', '访问级别: 公共', '抽象: 是', '装饰器: @Injectable()', '类型参数: K, T 限定为 Entity = Entity', '父类: Base<字符串>', '实现的接口: Loader, Disposable', ]) expect(tsRows[15]).toEqual(['名称: Query', '访问级别: 公共', '抽象: 否', '父类: QueryBase']) expect(tsRows[20]).toEqual(['名称: 匿名', '访问级别: 公共', '抽象: 否']) expect(tsRows[24]).toEqual(['名称: Mixed', '说明: 类表达式', '访问级别: 公共', '抽象: 否', '父类: mixin(A, B)']) expect(tsRows[28]).toEqual(['名称: Local', '抽象: 否']) expect(tsRows[32]).toEqual(['名称: Plain', '访问级别: 私有', '抽象: 否', '装饰器: @sealed']) const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('classes.py'))) expect(pyRows[6]).toEqual(['名称: AppException', '说明: 应用异常的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: Exception']) expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppException']) expect(pyRows[24]).toEqual(['名称: Meta', '访问级别: 公共', '装饰器: @dataclass(frozen=True)', '父类: Base, metaclass=ABCMeta']) expect(pyRows[29]).toEqual(['名称: _Hidden', '访问级别: 私有']) expect(pyRows[38]).toEqual(['名称: Local', '说明: 局部类。']) }) it('类的着色:名称、父类名按类型,说明是 Markdown,抽象按 storage', async () => { const tsRows = colors((await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('classes.ts.txt'))).get(3) ?? []) expect(tsRows[0].value).toEqual([{ text: 'Repository', role: 'type' }]) expect(tsRows[1].value).toEqual({ markdown: '仓库基类。\n\n子类实现 `load`。' }) expect(tsRows[3].value).toEqual([{ text: '是', role: 'storage' }]) expect(valueOf(tsRows, '父类')).toEqual([ { text: 'Base', role: 'type' }, { text: '<', role: 'plain' }, { text: '字符串', role: 'type' }, { text: '>', role: 'plain' }, ]) const pyRows = colors((await describeAll(py, 'python', pythonZhTranslator, readFixture('classes.py'))).get(24) ?? []) expect(valueOf(pyRows, '父类')).toEqual([ { text: 'Base', role: 'type' }, { text: ', ', role: 'plain' }, { text: 'metaclass', role: 'variable' }, { text: '=', role: 'plain' }, { text: 'ABCMeta', role: 'variable' }, ]) }) it('变量:一条语句多个变量各占一组,没初始值写无,带语句块主体的回调只翻到主体开始之前', async () => { const source = [ 'let i = 0, n: number', 'function f() {', ' const v = useMemo(() => {', ' return 1', ' }, [])', '}', ].join('\n') const rows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, source)) expect(rows[1]).toEqual(['名称: i', '访问级别: 私有', '初始值: 0', '名称: n', '访问级别: 私有', '类型: 数值', '初始值: 无']) expect(rows[3]).toEqual(['名称: v', '初始值: useMemo(() =>']) }) it('字典:值写「字典 N 项」,每项一个子行「键 | 值」,嵌套的字典一层层往下展开;**展开的键写「展开」,空字典照旧一行', async () => { const source = [ '_CONTEST_STATUS_KEYS = {', ' "published": "contests:publish",', ' "ended": "contests:end",', ' "draft": "contests:update",', '}', 'CONFIG = {"db": {"host": host, "opts": {}}, **defaults, 1: await load()}', 'EMPTY = {}', ].join('\n') const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source)) expect(rows[1]).toEqual([ '名称: _CONTEST_STATUS_KEYS', '访问级别: 私有', '初始值: 字典 3 项', ' "published" | "contests:publish"', ' "ended" | "contests:end"', ' "draft" | "contests:update"', ]) expect(rows[6]).toEqual([ '名称: CONFIG', '访问级别: 公共', '值: 字典 3 项', ' "db" | 字典 2 项', ' "host" | host', ' "opts" | 空字典', ' 展开 | defaults', ' 1 | 等待 load()', ]) expect(rows[7]).toEqual(['名称: EMPTY', '访问级别: 公共', '值: 空字典']) }) it('对象:值写「对象 N 项」,键照源码写法(计算属性名照常翻),简写的值就是它自己,方法的值是它的头部,嵌套的一层层往下展开;空对象一行「空对象」', async () => { const source = [ 'export const handlers = {', ' total: files.length,', ' \'x-y\': await load(),', ' [key]: !ok,', ' files,', ' ...rest,', ' async load(id: string): Promise {', ' await run(id)', ' },', ' nested: { a: [], b: { c: 1 } },', '}', 'let none = {}', ].join('\n') const described = await describeAll(ts, 'typescript', typescriptZhTranslator, source) const rows = rowTexts(described) expect(rows[1]).toEqual([ '名称: handlers', '访问级别: 公共', '值: 对象 7 项', ' total | files.length', ' \'x-y\' | 等待 load()', ' [key] | 非 ok', ' files | files', ' 展开 | rest', ' load | 异步 load(id: 字符串): Promise<无>', ' nested | 对象 2 项', ' a | 空数组', ' b | 对象 1 项', ' c | 1', ]) expect(rows[12]).toEqual(['名称: none', '访问级别: 私有', '初始值: 空对象']) const value = colors(described.get(1) ?? []).find(row => row.label === '值') expect(value?.value).toEqual([{ text: '对象 7 项', role: 'hint' }]) expect(value?.items?.map(item => item.cells[0])).toEqual([ [{ text: 'total', role: 'variable' }], [{ text: '\'x-y\'', role: 'string' }], [{ text: '[', role: 'plain' }, { text: 'key', role: 'variable' }, { text: ']', role: 'plain' }], [{ text: 'files', role: 'variable' }], [{ text: '展开', role: 'hint' }], [{ text: 'load', role: 'function' }], [{ text: 'nested', role: 'variable' }], ]) expect(value?.items?.[6].cells[1]).toEqual([{ text: '对象 2 项', role: 'hint' }]) }) }) describe('源码下标(跳到定义用)', () => { function itemCells(rows: PropertyRow[] | undefined, label: string): Inline[][] { return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? [] } function propertySegments(rows: PropertyRow[]): Segment[] { const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flat()), ...itemSegments(item.items)]) return rows.flatMap(row => [...('markdown' in row.value ? [] : flatSegments(row.value)), ...itemSegments(row.items)]) } it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => { const source = [ '# 数据库连接', 'from luffy_agent.core.db import get_db as conn', 'import os.path', 'from . import x', ].join('\n') const at = (word: string): number => source.indexOf(word) const rows = await describeAll(py, 'python', pythonZhTranslator, source) expect(text(valueOf(rows.get(2), '来源'))).toBe('luffy_agent.core.db') expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['luffy_agent', at('luffy_agent')], ['core', at('core')], ['db', at('db')]]) expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['get_db', at('get_db')], ['conn', at('conn')]]]) expect(linked(valueOf(rows.get(3), '来源'))).toEqual([['os', at('os')], ['path', at('path')]]) expect(linked(valueOf(rows.get(4), '来源'))).toEqual([]) expect(itemCells(rows.get(4), '导入项').map(linked)).toEqual([[['x', at('x')]]]) }) it('python:一行译文里的名字带下标,关键字、运算符、字面量译词不带', async () => { const source = [ 'def f(user):', ' if not user.active and user is not None:', ' raise HTTPException(status_code=404)', ].join('\n') const at = (word: string, from = 0): number => source.indexOf(word, from) const map = await translateAll(py, 'python', pythonZhTranslator, source) const ifLine = at('if') expect(text(map.get(2)?.segments)).toBe('如果 非 user.active 并且 user 不是 空') expect(linked(map.get(2)?.segments)).toEqual([['user', at('user', ifLine)], ['active', at('active')], ['user', at('user is')]]) expect(linked(map.get(3)?.segments)).toEqual([['HTTPException', at('HTTPException')], ['status_code', at('status_code')]]) }) it('typeScript:模块字符串、导入项、函数名称、参数名和类型、返回类型、译文里的名字带下标', async () => { const source = [ '// 读取配置', 'import { readFile as rf } from \'./fs\'', 'export async function load(path: string, opts?: Options): Promise {', ' if (opts === undefined) return rf(path)', '}', ].join('\n') const at = (word: string): number => source.indexOf(word) const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) expect(linked(valueOf(rows.get(2), '来源'))).toEqual([['\'./fs\'', at('\'./fs\'')]]) expect(itemCells(rows.get(2), '导入项').map(linked)).toEqual([[['readFile', at('readFile')], ['rf', at('rf }')]]]) expect(linked(valueOf(rows.get(3), '名称'))).toEqual([['load', at('load')]]) expect(itemCells(rows.get(3), '输入参数').map(linked)).toEqual([ [['path', at('path:')]], [], [], [['opts', at('opts?')]], [['Options', at('Options')]], [], ]) expect(linked(valueOf(rows.get(3), '输出参数'))).toEqual([['Promise', at('Promise')], ['Result', at('Result')]]) const map = await translateAll(ts, 'typescript', typescriptZhTranslator, source) expect(text(map.get(4)?.segments)).toBe('如果 (opts 全等 未定义)') expect(linked(map.get(4)?.segments)).toEqual([['opts', at('opts ===')]]) }) it('属性行、子行标出它讲的定义:参数名(连前缀、整个解构模式)、导入项的名称到别名、每个声明的变量名,其余行不标', async () => { /** 标了 defines 的行写成「属性名: 那段源码」,子行缩进两格只写那段源码。 */ const marked = (source: string, rows: PropertyRow[] | undefined): string[] => (rows ?? []).flatMap(row => [ ...(row.defines ? [`${row.label}: ${source.slice(row.defines.startIndex, row.defines.endIndex)}`] : []), ...(row.items ?? []).flatMap(item => item.defines ? [` ${source.slice(item.defines.startIndex, item.defines.endIndex)}`] : []), ]) const pySource = [ 'from m import a as b, c', 'import os.path as p', 'def f(self, *args, key: int = 1, **kw): pass', 'x = 1', ].join('\n') const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) expect(marked(pySource, pyRows.get(1))).toEqual([' a as b', ' c']) expect(marked(pySource, pyRows.get(2))).toEqual([' os.path as p']) expect(marked(pySource, pyRows.get(3))).toEqual([' self', ' *args', ' key', ' **kw']) expect(marked(pySource, pyRows.get(4))).toEqual(['名称: x']) const tsSource = [ 'import d, * as ns from \'m\'', 'function g({ u, v }: T, w?: number) {}', 'let i = 0, j', ].join('\n') const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource) expect(marked(tsSource, tsRows.get(1))).toEqual([' d', ' ns']) expect(marked(tsSource, tsRows.get(2))).toEqual([' { u, v }', ' w']) expect(marked(tsSource, tsRows.get(3))).toEqual(['名称: i', '名称: j']) }) it('字典 / 对象字面量:键和值里的名字、字符串带下标,嵌套的也是;每项标出它讲的定义是键,展开没有', async () => { const source = [ 'const o = {', ' total: files.length,', ' "k": v,', ' inner: { deep: x },', ' ...rest,', ' load() {},', '}', ].join('\n') const at = (word: string): number => source.indexOf(word) const items = (await describeAll(ts, 'typescript', typescriptZhTranslator, source)).get(1)?.find(row => row.label === '值')?.items ?? [] expect(items.map(item => item.cells.map(linked))).toEqual([ [[['total', at('total')]], [['files', at('files')], ['length', at('length')]]], [[['"k"', at('"k"')]], [['v', at('v,')]]], [[['inner', at('inner')]], []], [[], [['rest', at('rest')]]], [[['load', at('load')]], [['load', at('load')]]], ]) expect(items[2].items?.map(item => item.cells.map(linked))).toEqual([[[['deep', at('deep')]], [['x', at('x }')]]]]) expect(items.map(item => item.defines && source.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['total', '"k"', 'inner', undefined, 'load']) expect(items[2].items?.[0].defines).toEqual({ startIndex: at('deep'), endIndex: at('deep') + 'deep'.length }) const pySource = 'X = {"a": b, **c}' const pyItems = (await describeAll(py, 'python', pythonZhTranslator, pySource)).get(1)?.find(row => row.label === '值')?.items ?? [] expect(pyItems.map(item => item.cells.map(linked))).toEqual([ [[['"a"', pySource.indexOf('"a"')]], [['b', pySource.indexOf('b')]]], [[], [['c', pySource.indexOf('c')]]], ]) }) it('类:名称、类型参数、父类、实现的接口里的名字带下标;只有「类型参数」一行标出它讲的定义(这几个泛型参数),名称是块自己的名字、不标', async () => { const source = 'export class Repo extends Base implements Loader, Disposable {}' const at = (word: string): number => source.indexOf(word) const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) const found = rows.get(1) ?? [] expect(linked(valueOf(found, '名称'))).toEqual([['Repo', at('Repo')]]) expect(linked(valueOf(found, '类型参数'))).toEqual([['K', at('K,')], ['T', at('T extends')], ['Entity', at('Entity')]]) expect(linked(valueOf(found, '父类'))).toEqual([['Base', at('Base')], ['T', at('T> implements')]]) expect(linked(valueOf(found, '实现的接口'))).toEqual([['Loader', at('Loader')], ['K', at('K>,')], ['Disposable', at('Disposable')]]) expect(found.filter(row => row.defines).map(row => [row.label, source.slice(row.defines?.startIndex, row.defines?.endIndex)])).toEqual([['类型参数', 'K, T extends Entity']]) const pySource = 'class NotFound(errors.AppException, metaclass=ABCMeta):\n pass' const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) expect(linked(valueOf(pyRows.get(1), '父类'))).toEqual([ ['errors', pySource.indexOf('errors')], ['AppException', pySource.indexOf('AppException')], ['metaclass', pySource.indexOf('metaclass')], ['ABCMeta', pySource.indexOf('ABCMeta')], ]) }) it('样例全文:带下标的只有照抄的名字和字符串,下标处的源码就是这段;变量名、函数名都带,译词、说明词都不带', async () => { 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'], ] for (const [adapter, languageId, translator, name] of samples) { const source = readFixture(name) const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => flatSegments(block.segments)) const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments) const segments = [...translated, ...described] expect(linked(segments).length, name).toBeGreaterThan(10) for (const segment of segments) { const where = `${name} 「${segment.text}」` if (segment.role === 'variable' || segment.role === 'function') expect(segment.offset, where).toBeTypeOf('number') if (segment.offset === undefined) continue expect(['variable', 'function', 'type', 'string'], where).toContain(segment.role) if (segment.role === 'string') expect(source[segment.offset], where).toBe(segment.text[0]) else expect(source.startsWith(segment.text, segment.offset), where).toBe(true) } } }) }) describe('中文模板引擎', () => { function testTranslator(table: Partial): Translator { return createZhTemplateTranslator({ id: 'test.zh-template', label: '测试', languageIds: ['python'], keywords: {}, literals: {}, builtinTypes: {}, isTypePosition: () => false, operators: {}, operatorParents: [], emptyLiterals: {}, mapLiterals: {}, stringNodes: [], commentText: node => node.text, nodeTemplates: {}, ...table, }) } /** 按 Python 分块、词性都是 role 的语言插件:引擎着色只看翻译时传进来的语言插件。 */ function pythonColored(role: TokenRole): LanguageAdapter { return { ...py, tokenRole: () => role } } it('关键字按父节点类型选译法,更长的节点类型链优先,都不匹配用默认', async () => { const translator = testTranslator({ keywords: { in: { zh: '甲', byContext: { for_statement: '乙' } }, else: { zh: '丙', byContext: { 'else_clause': '丁', 'for_statement > else_clause': '戊' } }, }, }) const source = [ 'for k in items:', ' if k in seen:', ' pass', ' else:', ' print(a if c else b)', 'else:', ' pass', ].join('\n') const map = texts(await translateAll(pythonColored('keyword'), 'python', translator, source)) expect(map[1]).toBe('for k 乙 items') expect(map[2]).toBe('if k 甲 seen') expect(map[4]).toBe('丁') expect(map[5]).toBe('print(a if c 丙 b)') expect(map[6]).toBe('戊') }) it('节点模板按节点类型重排:槽位按字段名或具名子节点序号取子节点并递归翻译,译词按关键字着色', async () => { const translator = testTranslator({ keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, operators: { '==': '等于' }, operatorParents: ['comparison_operator'], nodeTemplates: { conditional_expression: ['若', { namedChild: 1 }, '则', { namedChild: 0 }, '不然', { namedChild: 2 }], augmented_assignment: ['令', { field: 'left' }, '加上', { field: 'right' }], }, }) const source = [ 'n += a if k in seen else b', 'print(a if c else b if d else e)', 'print((a # 注释不占序号', ' if k == 1 else b))', ].join('\n') const map = await translateAll(pythonColored('variable'), 'python', translator, source) expect(texts(map)).toEqual({ 1: '令 n 加上 若 k 在 seen 则 a 不然 b', 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', 3: 'print((若 k 等于 1 则 a 不然 b))', }) expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword').map(segment => segment.text)) .toEqual(['令', '加上', '若', '则', '不然']) }) it('属性表里的类型、值用同一张表和同一套模板翻', async () => { const translator = testTranslator({ keywords: { in: '在' }, builtinTypes: { int: '整型' }, isTypePosition: leaf => leaf.parent?.type === 'type', nodeTemplates: { conditional_expression: ['若', { namedChild: 1 }, '则', { namedChild: 0 }, '不然', { namedChild: 2 }] }, }) const rows = await describeAll(pythonColored('variable'), 'python', translator, 'n: int = a if k in seen else b') expect(rowTexts(rows)[1]).toEqual(['名称: n', '访问级别: 公共', '类型: 整型', '初始值: 若 k 在 seen 则 a 不然 b']) }) it('没命中模板的节点照抄源码结构;模板引用的子节点不存在时也不重排', async () => { const translator = testTranslator({ keywords: { in: '在', else: { zh: '丙', byContext: { else_clause: '丁' } } }, nodeTemplates: { augmented_assignment: ['令', { field: 'left' }, '类型', { field: 'type' }, '为', { field: 'right' }], }, }) const source = [ 'x += a if k in seen else b', 'if k in seen:', ' pass', 'else:', ' pass', ].join('\n') expect(texts(await translateAll(pythonColored('keyword'), 'python', translator, source))).toEqual({ 1: 'x += a if k 在 seen 丙 b', 2: 'if k 在 seen', 3: 'pass', 4: '丁', 5: 'pass', }) }) })