1433 lines
70 KiB
TypeScript
1433 lines
70 KiB
TypeScript
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PropertyItem, PropertyRow, Segment, SourceDocument, 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, tableLines, 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 里,下面摊开实参的表在 table 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
|
||
interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable }
|
||
|
||
/**
|
||
* 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。
|
||
* calleeKind 同 SourceDocument.calleeKind(调用的名字定义成哪种块),不给就是宿主不查。
|
||
*/
|
||
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): 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 { text: translation, table } = translator.translate(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
|
||
byLine.set(line, 'markdown' in translation
|
||
? { kind: block.kind, segments: [], markdown: translation.markdown }
|
||
: { kind: block.kind, segments: translation, ...(table && { table }) })
|
||
})
|
||
return byLine
|
||
})
|
||
}
|
||
|
||
/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引;calleeKind 同 translateAll。 */
|
||
function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): 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, markers: [], ...(calleeKind && { calleeKind }) }))
|
||
})
|
||
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 = [
|
||
'list = [',
|
||
' first, // 第一个',
|
||
' [],',
|
||
']',
|
||
].join('\n')
|
||
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = [ 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 · 1 个参数',
|
||
})
|
||
// 跨几行写的调用唯一的实参是生成器:共用的那对括号属于调用,实参表里只有框
|
||
expect(propertyLines(map.get(8) ?? []).at(-1)).toBe(' 第 1 个 | ⟦生成器|遍历 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(['名称: AppResponse', '说明: 应用响应的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: BaseModel'])
|
||
expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppResponse'])
|
||
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(models.AppResponse, metaclass=ABCMeta):\n pass'
|
||
const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource)
|
||
expect(linked(valueOf(pyRows.get(1), '父类'))).toEqual([
|
||
['models', pySource.indexOf('models')],
|
||
['AppResponse', pySource.indexOf('AppResponse')],
|
||
['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('跨几行写的调用', () => {
|
||
const routes = readFixture('routes.py')
|
||
const calls = readFixture('calls.ts.txt')
|
||
|
||
/** 调用的名字定义成哪种块(按被调用的表达式原文查,没有的是还没查到),并记下问了哪些。 */
|
||
function kinds(known: Record<string, BlockKind>): { asked: string[], calleeKind: (call: CallFacts) => BlockKind | undefined } {
|
||
const asked: string[] = []
|
||
return {
|
||
asked,
|
||
calleeKind: (call) => {
|
||
asked.push(call.callee.text)
|
||
return known[call.callee.text]
|
||
},
|
||
}
|
||
}
|
||
|
||
const USER_DETAIL = [
|
||
'id | user.id',
|
||
'username | user.username',
|
||
'display_name | user.display_name',
|
||
'role | user.role.value',
|
||
'email | user.email',
|
||
'phone | user.phone',
|
||
'bio | user.bio',
|
||
'avatar_url | user.avatar_url',
|
||
'is_active | user.is_active',
|
||
'created_at | user.created_at',
|
||
'updated_at | user.updated_at',
|
||
]
|
||
|
||
it('返回里新建对象(名字定义成类):一行「返回 新建 UserDetailResponse · 11 个参数」,实参摊成表(参数 | 值),按名字传的写名字', async () => {
|
||
const { asked, calleeKind } = kinds({ UserDetailResponse: 'class' })
|
||
const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45)
|
||
expect(colors(line?.segments)).toEqual([
|
||
{ text: '返回', role: 'keyword' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: '新建', role: 'storage' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: 'UserDetailResponse', role: 'type' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: '· 11 个参数', role: 'hint' },
|
||
])
|
||
expect(line?.table?.columns).toEqual(['参数', '值'])
|
||
expect(tableLines(line?.table)).toEqual(USER_DETAIL)
|
||
expect(asked).toContain('UserDetailResponse')
|
||
// 被调用的名字、按名字传的名字、值里的名字都带源码下标:能跳到定义、显示中文名
|
||
const call = routes.indexOf('UserDetailResponse(')
|
||
expect(line?.segments.find(piece => 'role' in piece && piece.role === 'type')).toMatchObject({ offset: call })
|
||
expect(line?.table?.items[0].cells).toEqual([
|
||
[{ text: 'id', role: 'variable', offset: routes.indexOf('id=user.id', call) }],
|
||
[
|
||
{ text: 'user', role: 'variable', offset: routes.indexOf('user.id', call) },
|
||
{ text: '.', role: 'plain' },
|
||
{ text: 'id', role: 'variable', offset: routes.indexOf('user.id', call) + 5 },
|
||
],
|
||
])
|
||
})
|
||
|
||
it('名字的定义还没查到、查不到、是函数都按调用算:被调用的名字按函数着色', async () => {
|
||
for (const calleeKind of [undefined, kinds({}).calleeKind, kinds({ UserDetailResponse: 'function' }).calleeKind]) {
|
||
const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45)
|
||
expect(text(line?.segments)).toBe('返回 调用 UserDetailResponse · 11 个参数')
|
||
expect(line?.segments.find(piece => 'role' in piece && piece.text === 'UserDetailResponse')).toMatchObject({ role: 'function' })
|
||
}
|
||
const exception = (await translateAll(py, 'python', pythonZhTranslator, routes, kinds({ UserDetailResponse: 'exception' }).calleeKind)).get(96)
|
||
expect(text(exception?.segments)).toBe('返回 新建 UserDetailResponse · 11 个参数')
|
||
})
|
||
|
||
it('变量的初始值里跨几行调用:初始值一行「等待 调用 user_service.update_user · 10 个参数」,实参表展开在这一行下面,按位置传的写「第 N 个」', async () => {
|
||
const rows = (await describeAll(py, 'python', pythonZhTranslator, routes, kinds({}).calleeKind)).get(83) ?? []
|
||
expect(propertyLines(rows)).toEqual([
|
||
'名称: user',
|
||
'初始值: 等待 调用 user_service.update_user · 10 个参数',
|
||
' 第 1 个 | db',
|
||
' user_id | user_id',
|
||
' display_name | body.display_name',
|
||
' email | body.email',
|
||
' phone | body.phone',
|
||
' bio | body.bio',
|
||
' avatar_url | body.avatar_url',
|
||
' role | body.role',
|
||
' is_active | body.is_active',
|
||
' password | body.password',
|
||
])
|
||
const value = rows.find(row => row.label === '初始值')
|
||
expect(value?.columns).toEqual(['参数', '值'])
|
||
expect(colors(value?.value)).toEqual([
|
||
{ text: '等待', role: 'keyword' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: '调用', role: 'storage' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: 'user_service', role: 'variable' },
|
||
{ text: '.', role: 'plain' },
|
||
{ text: 'update_user', role: 'function' },
|
||
{ text: ' ', role: 'plain' },
|
||
{ text: '· 10 个参数', role: 'hint' },
|
||
])
|
||
expect(value?.items?.[0].cells[0]).toEqual([{ text: '第 1 个', role: 'hint' }])
|
||
})
|
||
|
||
it('一行写完的调用照旧一行译文,不问定义', async () => {
|
||
const { asked, calleeKind } = kinds({})
|
||
const map = await describeAll(py, 'python', pythonZhTranslator, routes, calleeKind)
|
||
expect(propertyLines(map.get(43) ?? [])).toEqual(['名称: user', '初始值: 等待 user_service.get_user(db, user_id=user_id)'])
|
||
expect(propertyLines(map.get(118) ?? [])).toEqual(['名称: payload', '初始值: 等待 file.read()'])
|
||
expect(asked).not.toContain('user_service.get_user')
|
||
expect(asked).not.toContain('file.read')
|
||
})
|
||
|
||
it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的', async () => {
|
||
const source = [
|
||
'run(',
|
||
' first,',
|
||
' *rest,',
|
||
' second,',
|
||
' key=value,',
|
||
' **options,',
|
||
')',
|
||
].join('\n')
|
||
const line = (await translateAll(py, 'python', pythonZhTranslator, source)).get(1)
|
||
expect(text(line?.segments)).toBe('调用 run · 5 个参数')
|
||
expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | value', '展开关键字 | options'])
|
||
})
|
||
|
||
it('嵌套:实参的值是跨几行写的调用(可以带 await)时,值那一格写它的一行,它的实参表排在格子里;一行写完的调用、只是式子里有调用的照旧一行', async () => {
|
||
const payment = readFixture('paymentConsoleRoutes.py')
|
||
const { asked, calleeKind } = kinds({ PaymentCapabilityDiagnostic: 'class', CredentialPresence: 'class' })
|
||
const line = (await translateAll(py, 'python', pythonZhTranslator, payment, calleeKind)).get(64)
|
||
expect(text(line?.segments)).toBe('返回 新建 PaymentCapabilityDiagnostic · 15 个参数')
|
||
expect(tableLines(line?.table)).toEqual([
|
||
'native_enabled | bool(settings.WECHAT_PAY_MCH_ID)',
|
||
'h5_enabled | settings.WECHAT_H5_ENABLED',
|
||
'jsapi_enabled | settings.WECHAT_JSAPI_ENABLED',
|
||
'jsapi_ready | jsapi_ready(settings)',
|
||
'pay_app_id_masked | _mask_app_id(settings.WECHAT_PAY_APP_ID)',
|
||
'mp_app_id_masked | _mask_app_id(settings.WECHAT_MP_APP_ID)',
|
||
'credentials_present | 新建 CredentialPresence · 4 个参数',
|
||
' mp_app_secret | bool(_secret_text(settings.WECHAT_MP_APP_SECRET))',
|
||
' wechat_pay_api_v3_key | bool(settings.WECHAT_PAY_API_V3_KEY)',
|
||
// 跨了两行、却是式子不是一次调用:照旧一行
|
||
' wechat_pay_private_key | bool(private_key_path) 并且 Path(private_key_path).is_file()',
|
||
' jsapi_intent_master_key | _intent_key_present(settings)',
|
||
'oauth_callback_host | oauth_host',
|
||
'pay_origin | pay_origin',
|
||
'allowed_pay_origins | allowed',
|
||
'jsapi_auth_dir_hint | ⟦列表|遍历 hint_origins 的每个 origin,仅当 origin,取 f"{origin}/"⟧',
|
||
'domain_whitelist | "unknown_manual_check"',
|
||
'merchant_binding | "unknown_manual_check"',
|
||
'recovery_required_count | count',
|
||
'recovery_required_earliest | earliest',
|
||
])
|
||
expect(line?.table?.columns).toEqual(['参数', '值'])
|
||
// 里层和外层一样:问定义分新建和调用,名字带源码下标
|
||
expect(asked).toEqual(expect.arrayContaining(['PaymentCapabilityDiagnostic', 'CredentialPresence']))
|
||
const nested = line?.table?.items[6]
|
||
expect(nested?.table?.columns).toEqual(['参数', '值'])
|
||
expect(nested?.cells[1].find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) })
|
||
expect(nested?.table?.items[0].cells[0]).toEqual([{ text: 'mp_app_secret', role: 'variable', offset: payment.indexOf('mp_app_secret=') }])
|
||
const unresolved = (await translateAll(py, 'python', pythonZhTranslator, payment)).get(64)
|
||
expect(segmentText(unresolved?.table?.items[6].cells[1] ?? [])).toBe('调用 CredentialPresence · 4 个参数')
|
||
})
|
||
|
||
it('嵌套一层层往下套;await 的调用写「等待」', async () => {
|
||
const source = [
|
||
'return Outer(',
|
||
' inner=Middle(',
|
||
' leaf=Leaf(',
|
||
' value=1,',
|
||
' ),',
|
||
' flag=await fetch(',
|
||
' key,',
|
||
' ),',
|
||
' ),',
|
||
')',
|
||
].join('\n')
|
||
const { asked, calleeKind } = kinds({ Outer: 'class', Middle: 'class', Leaf: 'class' })
|
||
const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(1)
|
||
expect(text(line?.segments)).toBe('返回 新建 Outer · 1 个参数')
|
||
expect(tableLines(line?.table)).toEqual([
|
||
'inner | 新建 Middle · 2 个参数',
|
||
' leaf | 新建 Leaf · 1 个参数',
|
||
' value | 1',
|
||
' flag | 等待 调用 fetch · 1 个参数',
|
||
' 第 1 个 | key',
|
||
])
|
||
expect([...asked].sort()).toEqual(['Leaf', 'Middle', 'Outer', 'fetch'])
|
||
})
|
||
|
||
it('埋在式子里的跨几行调用也排:后面紧跟着同一个式子的别的部分(.x()、[0]、or)时整行括起来,后面是分隔符或什么都没有时不括', async () => {
|
||
const chained = readFixture('chainedCalls.py')
|
||
const rows = await describeAll(py, 'python', pythonZhTranslator, chained)
|
||
expect(propertyLines(rows.get(18) ?? []).slice(1)).toEqual([
|
||
'初始值: (调用 base64.b64encode · 1 个参数).decode()',
|
||
' 第 1 个 | f"{admin_user}:{admin_password}".encode()',
|
||
])
|
||
// 调用跨几行的是外面那次:被调用的里面那次调用照旧一行,挤成一行时圆括号贴着里面的内容
|
||
expect(propertyLines(rows.get(55) ?? []).slice(1, 2)).toEqual(['初始值: 等待 调用 config.get_payment_driver(order.payment_method).query_refund_status · 2 个参数'])
|
||
|
||
const source = [
|
||
'first = make(',
|
||
' a,',
|
||
')[0]',
|
||
'either = make(',
|
||
' a,',
|
||
') or fallback',
|
||
'other = fallback or make(',
|
||
' a,',
|
||
')',
|
||
'items = [',
|
||
' make(',
|
||
' a,',
|
||
' ),',
|
||
' b,',
|
||
']',
|
||
'run(',
|
||
' key=Model(',
|
||
' a=1,',
|
||
' ).model_dump(),',
|
||
')',
|
||
].join('\n')
|
||
const values = await describeAll(py, 'python', pythonZhTranslator, source)
|
||
const value = (line: number): string | undefined => propertyLines(values.get(line) ?? []).find(row => row.startsWith('初始值'))
|
||
expect(value(1)).toBe('初始值: (调用 make · 1 个参数)[0]')
|
||
expect(value(4)).toBe('初始值: (调用 make · 1 个参数) 或者 fallback')
|
||
expect(value(7)).toBe('初始值: fallback 或者 调用 make · 1 个参数')
|
||
expect(value(10)).toBe('初始值: [ 调用 make · 1 个参数, b ]')
|
||
const cell = (await translateAll(py, 'python', pythonZhTranslator, source)).get(16)
|
||
expect(tableLines(cell?.table)).toEqual(['key | (调用 Model · 1 个参数).model_dump()', ' a | 1'])
|
||
})
|
||
|
||
it('typeScript:new 写法就是新建,不带 new 的是调用,都不问定义;...x 写「展开」', async () => {
|
||
const { asked, calleeKind } = kinds({})
|
||
const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind)
|
||
expect(text(texts.get(18)?.segments)).toBe('this.definitions = 新建 DefinitionSources · 1 个参数')
|
||
expect(tableLines(texts.get(18)?.table)).toEqual([
|
||
'第 1 个 | { rule: 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)) }',
|
||
])
|
||
expect(text(texts.get(33)?.segments)).toBe('返回 调用 concatAll · 2 个参数')
|
||
expect(tableLines(texts.get(33)?.table)).toEqual(['第 1 个 | base', '展开 | extra'])
|
||
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind)
|
||
// 实参是模板字符串:照抄
|
||
expect(propertyLines(rows.get(7) ?? []).slice(1)).toEqual([
|
||
'初始值: 等待 调用 window.showWarningMessage · 3 个参数',
|
||
expect.stringMatching(/^ {2}第 1 个 \| 如果 branch 则 `确认删除 worktree [^`]+` 否则 `确认删除 worktree [^`]+`$/),
|
||
' 第 2 个 | { modal: 真 }',
|
||
' 第 3 个 | \'删除\'',
|
||
])
|
||
expect(propertyLines(rows.get(26) ?? []).slice(1)).toEqual(['初始值: 新建 URL · 1 个参数', expect.stringMatching(/^ {2}第 1 个 \| `[^`]+\/comments`$/)])
|
||
expect(asked).toEqual([])
|
||
})
|
||
|
||
it('typeScript 嵌套:实参是跨几行写的调用、new 时同样排在值那一格里', async () => {
|
||
const line = (await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('nestedCalls.ts.txt'))).get(3)
|
||
expect(text(line?.segments)).toBe('调用 context.subscriptions.push · 4 个参数')
|
||
expect(tableLines(line?.table)).toEqual([
|
||
'第 1 个 | treeView',
|
||
'第 2 个 | 调用 commands.registerCommand · 2 个参数',
|
||
' 第 1 个 | \'superpowers.openKanban\'',
|
||
' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) }',
|
||
'第 3 个 | 调用 commands.registerCommand · 2 个参数',
|
||
' 第 1 个 | \'superpowers.refresh\'',
|
||
' 第 2 个 | () => { KanbanWebviewPanel.refresh() }',
|
||
'第 4 个 | 调用 commands.registerCommand · 2 个参数',
|
||
' 第 1 个 | \'superpowers.setGiteaToken\'',
|
||
' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) KanbanWebviewPanel.requestEditAuth() }',
|
||
])
|
||
const source = 'const server = new Server(\n new Options(\n port,\n ),\n handler,\n)'
|
||
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
|
||
expect(propertyLines(rows.get(1) ?? []).slice(-4)).toEqual(['值: 新建 Server · 2 个参数', ' 第 1 个 | 新建 Options · 1 个参数', ' 第 1 个 | port', ' 第 2 个 | handler'])
|
||
})
|
||
|
||
it('链式调用跨了几行、实参列表各自写在一行里的不排成表;回调主体不在块头部里的调用也不排', async () => {
|
||
const source = [
|
||
'const ids = items',
|
||
' .filter(a => a.ok)',
|
||
' .map(a => a.id)',
|
||
'describe(\'x\', () => {',
|
||
' run()',
|
||
'})',
|
||
].join('\n')
|
||
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
|
||
expect(propertyLines(rows.get(1) ?? []).at(-1)).toBe('值: items .filter(a => a.ok) .map(a => a.id)')
|
||
const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
|
||
expect(texts.get(4)).toEqual(expect.not.objectContaining({ table: expect.anything() }))
|
||
})
|
||
})
|
||
|
||
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)',
|
||
'return (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: 'return (若 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',
|
||
})
|
||
})
|
||
})
|