Files
superwork/vscode/src/codeReader/translators/zhTemplate.test.ts
T

1141 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<number, Translated>> {
return withBlocks(adapter, languageId, source, (blocks) => {
const byLine = new Map<number, Translated>()
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<Map<number, PropertyRow[]>> {
return withBlocks(adapter, languageId, source, (blocks) => {
const byLine = new Map<number, PropertyRow[]>()
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<number, PropertyRow[]>): Record<number, string[]> {
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<number, Translated>): Record<number, string> {
return Object.fromEntries([...map].map(([line, { segments, markdown }]) => [line, markdown ?? text(segments)]))
}
/** 只看文字和着色:去掉各段的源码下标、属性行讲的定义(这两样另有用例)。 */
function colors<T>(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<number, Translated>): 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 extends X> = (t: T) => void',
'type R<T> = 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 限定为 X> = (t: T) => 无')
expect(map[10]).toBe('类型 R<T> = 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<EnvLockResult>',
])
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`` 时用 <b>mask</b>。',
'"""',
'# 用法:',
'#',
'# 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`` 时用 <b>mask</b>。',
})
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<T>, 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<void> {',
' 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<Result> {',
' 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<K, T extends Entity> extends Base<T> implements Loader<K>, 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<ZhTable>): 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',
})
})
})