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 { return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator, { rules: RULES, report: () => {} })) } const pyViews = (source: string): Promise => views(py, 'python', pythonZhTranslator, source) const tsViews = (source: string): Promise => 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`;父类名不合习惯、不在这份文件里认出来的照常是类', 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 → 状态码']) }) })