Files
superwork/vscode/src/codeReader/languages/exceptions.test.ts
T

340 lines
16 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 { BlockView } from '../blockView'
import type { Inline, LanguageAdapter, PropertyRow, Segment, Translator } from '../contract'
import { describe, expect, it } from 'vitest'
import { fileAttributes } from '../attributes'
import { readBlockViews, toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { celeryRule } from '../rules/celery'
import { dataclassRule } from '../rules/dataclass'
import { enumRule } from '../rules/enum'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { flatSegments, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { createPythonAdapter } from './python'
import { createTypeScriptAdapter } from './typescript'
const py = createPythonAdapter(WASM_DIR)
const ts = createTypeScriptAdapter(WASM_DIR)
const APP = readFixture('appExceptions.py')
const COMMERCE = readFixture('commerceExceptions.py')
const TS = readFixture('exceptions.ts.txt')
/** 和扩展里注册的顺序一样。 */
const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule]
function views(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<BlockView[]> {
return withBlocks(adapter, languageId, source, blocks =>
toBlockViews(blocks, { source, language: adapter, markers: [] }, translator, { rules: RULES, report: () => {} }))
}
const pyViews = (source: string): Promise<BlockView[]> => views(py, 'python', pythonZhTranslator, source)
const tsViews = (source: string): Promise<BlockView[]> => views(ts, 'typescript', typescriptZhTranslator, source)
function flatten(list: readonly BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
function properties(view: BlockView | undefined): PropertyRow[] {
if (!view || !('properties' in view))
throw new Error(`块 ${view?.id} 不是属性表`)
return view.properties
}
function named(list: readonly BlockView[], name: string): BlockView {
const found = flatten(list).find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`)
if (!found)
throw new Error(`没有 ${name}`)
return found
}
function lines(list: readonly BlockView[], name: string): string[] {
return propertyLines(properties(named(list, name)))
}
/** 属性表里所有带下标的词(连表格里的格子)。 */
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
const PY_CASES = [
'from dataclasses import dataclass',
'import typing',
'',
'',
'class Late(Base):',
' """写在父类前面,照样认得出。"""',
'',
'',
'class Base(Exception):',
' # 错误码',
' # 机器可读',
' code = "E"',
' retry: bool = False # 能不能重试',
' detail: str',
' """额外的说明。"""',
'',
' # 讲方法的注释',
' def describe(self) -> str:',
' return self.code',
'',
' level = 1',
' a = b = 2',
' x, y = 1, 2',
'',
' def __init__(self, message: str, *, level: int = 1, **extra) -> None:',
' super().__init__(message)',
'',
' # 收尾',
'',
'',
'class Deeper(Late):',
' pass',
'',
'',
'class Stop(StopIteration): ...',
'class Warned(UserWarning): ...',
'class Remote(pkg.errors.AppException): ...',
'class Typed(typing.Generic[T], KeyboardInterrupt): ...',
'class Plain(Model, metaclass=ErrorMeta): ...',
'class Other(Mapping[str, int]): ...',
'class Starred(*bases): ...',
'',
'',
'class Loose(Exception):',
' def __init__(*args):',
' pass',
'',
'',
'@dataclass',
'class Payload(ValueError):',
' field: int = 0',
'',
'',
'def factory():',
' class Local(RuntimeError):',
' pass',
' return Local',
'',
].join('\n')
describe('异常卡片(Python)', () => {
it('luffy 的 core/exceptions:基类的类属性、构造参数,子类只改类属性;方法留作子块', async () => {
const list = await pyViews(APP)
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', ...Array.from({ length: 12 }, () => 'exception')])
expect(lines(list, 'AppException')).toEqual([
'名称: AppException',
'说明: 应用异常基类.\n\nAttributes:\n status_code: HTTP 状态码。\n error_code: 业务错误码(机器可读)。\n message: 错误描述(人类可读)。\n detail: 额外错误详情。',
'访问级别: 公共',
'继承: Exception',
'属性: 3 个',
' status_code | 整数 | 500',
' error_code | 字符串 | "INTERNAL_ERROR"',
' message | 字符串 | "服务器内部错误"',
'构造参数: 3 个',
' message | 字符串 | 空 | 空',
' detail | Any | 空',
' error_code | 字符串 | 空 | 空',
])
const app = properties(named(list, 'AppException'))
expect(app.map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, ['属性', '类型', '值'], ['名称', '类型', '默认值']])
expect(named(list, 'AppException').children.map(child => [child.kind, child.startLine])).toEqual([['function', 25], ['function', 39]])
expect(lines(list, 'NotFound')).toEqual([
'名称: NotFound',
'说明: 资源不存在 (404).',
'访问级别: 公共',
'继承: AppException',
'属性: 3 个',
' status_code | 404',
' error_code | "NOT_FOUND"',
' message | "资源不存在"',
])
expect(properties(named(list, 'NotFound')).at(-1)?.columns).toEqual(['属性', '值'])
expect(named(list, 'NotFound').children).toEqual([])
expect(lines(list, 'GatewayTimeout').slice(0, 2)).toEqual([
'名称: GatewayTimeout',
'说明: 上游服务响应超时 (504).\n\n用于表达 "请求已发往上游但在时限内未拿到响应":区别于 502\n(上游明确报错) 和 503 (上游不可达/未就绪)。',
])
})
it('luffy 的 commerce/exceptions:一串子类都是异常;只有说明的一张卡片四行,写了 __init__ 的多一行构造参数', async () => {
const list = await pyViews(COMMERCE)
expect(list.map(view => view.kind)).toEqual(['comment', 'import', ...Array.from({ length: 16 }, () => 'exception')])
expect(lines(list, 'CommerceError')).toEqual(['名称: CommerceError', '说明: commerce 域基础异常.', '访问级别: 公共', '继承: Exception'])
expect(lines(list, 'ProductNotFoundError')).toEqual(['名称: ProductNotFoundError', '说明: 商品不存在或已下架.', '访问级别: 公共', '继承: CommerceError'])
expect(lines(list, 'UnsupportedPaymentMethodError')).toEqual([
'名称: UnsupportedPaymentMethodError',
'说明: 未配置当前 payment_method 对应的 driver. 通常意味着环境变量缺失.',
'访问级别: 公共',
'继承: CommerceError',
'构造参数: 1 个',
' method | 字符串 | ',
])
expect(named(list, 'UnsupportedPaymentMethodError').children.map(child => child.kind)).toEqual(['function'])
})
it('认哪些类:父类是自带的异常类(含 Warning)、同一文件里的异常类(写在前面后面都行,一层层往下传)、名字以 Error / Exception / Warning 结尾;关键字参数、泛型、展开不算', async () => {
const list = await pyViews(PY_CASES)
const kinds = Object.fromEntries(flatten(list).filter(view => view.kind === 'exception' || view.kind === 'class')
.map(view => [propertyLines(properties(view))[0].replace('名称: ', ''), view.kind]))
expect(kinds).toEqual({
Late: 'exception',
Base: 'exception',
Deeper: 'exception',
Stop: 'exception',
Warned: 'exception',
Remote: 'exception',
Typed: 'exception',
Plain: 'class',
Other: 'class',
Starred: 'class',
Loose: 'exception',
Payload: 'exception',
Local: 'exception',
})
})
it('类属性:给一个名字赋值(带不带注解、有没有值),说明取上面的注释块、行尾注释、属性 docstring;连着赋值、解构赋值、方法照常是子块;构造参数去掉 self', async () => {
const list = await pyViews(PY_CASES)
expect(lines(list, 'Base')).toEqual([
'名称: Base',
'访问级别: 公共',
'继承: Exception',
'属性: 4 个',
' code | | "E" | 错误码 机器可读',
' retry | 布尔 | 假 | 能不能重试',
' detail | 字符串 | | 额外的说明。',
' level | | 1 | ',
'构造参数: 3 个',
' message | 字符串 | ',
' level | 整数 | 1',
' **extra | 未标注 | ',
])
const source = PY_CASES.split('\n')
const base = named(list, 'Base')
expect(base.children.map(child => source[child.startLine].trim())).toEqual([
'# 讲方法的注释',
'def describe(self) -> str:',
'a = b = 2',
'x, y = 1, 2',
'def __init__(self, message: str, *, level: int = 1, **extra) -> None:',
'# 收尾',
])
expect(lines(list, 'Loose').slice(-2)).toEqual(['构造参数: 1 个', ' *args | 未标注 | '])
expect(lines(list, 'Payload')).toEqual(['名称: Payload', '访问级别: 公共', '装饰器: @dataclass', '继承: ValueError', '属性: 1 个', ' field | 整数 | 0'])
expect(named(list, 'Local').attributes).toEqual({ kind: 'exception' })
})
it('编辑范围是整个类(并进来的属性、留下的方法都在里面),改卡片写回整个类;块自己是类名的定义,属性行、参数行各是它那个名字的定义', async () => {
const source = PY_CASES.split('\n')
const base = named(await pyViews(PY_CASES), 'Base')
expect([source[base.startLine], source[base.endLine]]).toEqual(['class Base(Exception):', ' # 收尾'])
expect(replaceLines(PY_CASES, base.startLine, base.endLine, base.code.replace('code = "E"', 'code = "F"')))
.toBe(PY_CASES.replace('code = "E"', 'code = "F"'))
expect(PY_CASES.slice(base.defines?.startIndex, base.defines?.endIndex)).toBe('Base')
const rows = properties(base)
const defined = (label: string): string[] => (rows.find(row => row.label === label)?.items ?? []).map(item => PY_CASES.slice(item.defines?.startIndex, item.defines?.endIndex))
expect(defined('属性')).toEqual(['code', 'retry', 'detail', 'level'])
expect(defined('构造参数')).toEqual(['message', 'level', '**extra'])
const [fieldName] = rows.find(row => row.label === '属性')?.items?.[0].cells[0] as Segment[]
expect(fieldName).toEqual({ text: 'code', role: 'variable', offset: PY_CASES.indexOf('code = "E"') })
for (const segment of linkedSegments(rows))
expect(PY_CASES.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
})
})
describe('异常卡片(TypeScript)', () => {
it('extends Error 的类:JSDoc 当说明,类字段(含 static)进属性表,constructor 的参数(含参数属性)进构造参数,方法留作子块', async () => {
const list = await tsViews(TS)
expect(lines(list, 'ApiError')).toEqual([
'名称: ApiError',
'说明: 调接口出错。',
'访问级别: 公共',
'继承: Error',
'属性: 2 个',
' status | | 500 | ',
' kind | 字符串 | \'API_ERROR\' | 机器可读的错误码',
'构造参数: 2 个',
' message | 字符串 | ',
' code? | 字符串 | ',
])
const api = named(list, 'ApiError')
expect(api.children.map(child => [child.kind, child.startLine])).toEqual([['function', 6], ['function', 10]])
expect([api.startLine, api.endLine]).toEqual([0, 13])
expect(lines(list, 'NotFoundError')).toEqual(['名称: NotFoundError', '访问级别: 公共', '继承: ApiError', '属性: 1 个', ' status | 404 | 找不到'])
})
it('认哪些类:同一文件里的异常类(写在后面也算)、赋给变量的类表达式、`ns.X<T>`;父类名不合习惯、不在这份文件里认出来的照常是类', async () => {
const list = await tsViews(TS)
expect(lines(list, 'Slow')).toEqual(['名称: Slow', '访问级别: 私有', '继承: Timeout'])
expect(named(list, 'Timeout').kind).toBe('exception')
expect(lines(list, 'Cancelled')).toEqual(['名称: Cancelled', '访问级别: 私有', '继承: RangeError'])
expect(lines(list, 'Forbidden')).toEqual(['名称: Forbidden', '访问级别: 公共', '继承: errors.HttpException<字符串>'])
const repository = named(list, 'Repository')
expect(repository.kind).toBe('class')
expect(repository.children.map(child => child.kind)).toEqual(['field'])
})
it('javaScript 的类字段同样认', async () => {
const source = 'class HttpError extends Error {\n status = 500\n static code\n}\n'
const list = await views(ts, 'javascript', typescriptZhTranslator, source)
expect(lines(list, 'HttpError')).toEqual(['名称: HttpError', '访问级别: 私有', '继承: Error', '属性: 2 个', ' status | 500', ' code | '])
})
it('typeScript 和 Python 同样的异常得到一样的种类、属性表行、列名', async () => {
const pyList = await pyViews('class Failed(Exception):\n status = 500 # 状态码\n\n def __init__(self, message: str) -> None:\n pass\n')
const tsList = await tsViews('export class Failed extends Error {\n status = 500 // 状态码\n\n constructor(message: string) {\n super(message)\n }\n}\n')
const shape = (list: BlockView[]): unknown => properties(named(list, 'Failed')).map(row => [row.label, row.columns, row.items?.map(item => item.cells.length)])
expect(named(pyList, 'Failed').kind).toBe('exception')
expect(named(tsList, 'Failed').kind).toBe('exception')
expect(shape(pyList)).toEqual(shape(tsList))
expect(lines(pyList, 'Failed').slice(-4)).toEqual(lines(tsList, 'Failed').slice(-4))
})
})
describe('异常卡片(宿主、筛选、中文名)', () => {
it('筛选:种类是 exception、叫「异常」,访问级别同原来的类;没有规则给的种类(badge),库规则不认', async () => {
const list = await pyViews(PY_CASES)
expect(named(list, 'Base').attributes).toEqual({ kind: 'exception', access: 'public' })
expect(named(list, 'Base').badge).toBeUndefined()
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
.toContainEqual({ id: 'exception', name: '异常', count: 10 })
})
it('各块的源码着色拼起来等于编辑范围的整行源码', async () => {
for (const [list, source] of [[await pyViews(APP), APP], [await pyViews(COMMERCE), COMMERCE], [await tsViews(TS), TS]] as const) {
const rows = source.split('\n')
for (const view of flatten(list)) {
expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code)
const indent = Math.min(...rows.slice(view.startLine, view.endLine + 1).filter(row => row.trim()).map(row => row.length - row.trimStart().length))
expect(view.code, view.id).toBe(rows.slice(view.startLine, view.endLine + 1).map(row => row.slice(indent)).join('\n'))
}
}
})
it('代码块树:异常块的头部是整个类,留下的方法是子块', async () => {
expect(await withBlocks(py, 'python', 'class E(Exception):\n code = 1\n\n def run(self):\n pass\n', outline)).toEqual([
{ at: [1, 5], kind: 'exception', kids: [{ at: 4, kind: 'function', kids: [{ at: 5, kind: 'statement' }] }] },
])
})
it('属性的中文名:属性格子按下标换字,行尾记号不进说明', async () => {
const source = [
'from typing import Annotated',
'class Failed(Exception):',
' code: Annotated[str, Comment("错误码")] = "E"',
' status = 500 # 状态 @Comment 状态码',
].join('\n')
const { blocks, displayNames } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
expect(propertyLines(properties(named(blocks, 'Failed'))).slice(-3)).toEqual([
'属性: 2 个',
' code | Annotated[字符串, Comment("错误码")] | "E" | ',
' status | | 500 | 状态',
])
expect(displayNames.map(({ name, text }) => `${source.slice(name.startIndex, name.endIndex)} → ${text}`)).toEqual(['code → 错误码', 'status → 状态码'])
})
})