import type { BlockView } from '../blockView' import type { IndexEntry, PropertyRow, RuleContext, Segment } from '../contract' import { describe, expect, it } from 'vitest' import { fileAttributes } from '../attributes' import { toBlockViews } from '../blockView' import { diffBlockTrees } from '../diff/blockDiff' import { createPythonAdapter } from '../languages/python' import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from '../translators/pythonZh' import { celeryRule } from './celery' import { dataclassRule } from './dataclass' import { enumRule } from './enum' import { fastapiRule } from './fastapi' import { MODEL_INDEX, pydanticRule } from './pydantic' import { sqlalchemyRule } from './sqlalchemy' const py = createPythonAdapter(WASM_DIR) const AIGC = readFixture('aigcMetadata.py') const SHARE = readFixture('createShareRequest.py') /** 和扩展里注册的顺序一样。 */ const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule, pydanticRule, fastapiRule] async function views(source: string, reports: string[] = [], lookup?: RuleContext['lookup']): Promise { return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: RULES, report: message => reports.push(message), lookup })) } async function indexEntries(source: string): Promise { return withBlocks(py, 'python', source, blocks => MODEL_INDEX.entries(blocks[0].node.tree.rootNode, { path: 'sample.py', source, exists: () => false })) } function properties(view: BlockView | undefined): PropertyRow[] { if (!view || !('properties' in view)) throw new Error(`块 ${view?.id} 不是属性表`) return view.properties } function named(list: BlockView[], name: string): BlockView { const found = list.find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`) if (!found) throw new Error(`没有 ${name}`) return found } /** 属性表里所有带下标的词(连表格里的格子)。 */ function linkedSegments(rows: PropertyRow[]): Segment[] { const pieces = rows.flatMap(row => [ ...('markdown' in row.value ? [] : row.value), ...(row.items ?? []).flatMap(item => item.cells.flat()), ]) return flatSegments(pieces).filter(segment => segment.offset !== undefined) } describe('pydantic 库规则(Python)', () => { it('luffy 的 AIGCMetadata:数据模型卡片,7 个无默认值字段,选项是严格、禁止多余字段;model_config 不单成一块', async () => { const reports: string[] = [] const list = await views(AIGC, reports) expect(reports).toEqual([]) const metadata = named(list, 'AIGCMetadata') expect(metadata.kind).toBe('pydantic.model') expect(metadata.badge).toEqual({ label: '数据模型', color: { dark: '#c678dd', light: '#8b3db5' }, icon: 'braces' }) expect(propertyLines(properties(metadata))).toEqual([ '名称: AIGCMetadata', '说明: 生成内容来源标识。', '访问级别: 公共', '继承: BaseModel', '选项: 严格 · 禁止多余字段', '字段: 7 个', ' Label | 字符串 | | ', ' ContentProducer | 字符串 | | ', ' ProduceID | 字符串 | | ', ' ReservedCode1 | 字符串 | | ', ' ContentPropagator | 字符串 | | ', ' PropagateID | 字符串 | | ', ' ReservedCode2 | 字符串 | | ', ]) expect(properties(metadata).at(-1)?.columns).toEqual(['字段', '类型', '默认值', '说明']) expect(metadata.children).toEqual([]) }) it('luffy 的 TextTrace:可空字段的默认值是空', async () => { const trace = named(await views(AIGC), 'TextTrace') expect(propertyLines(properties(trace))).toEqual([ '名称: TextTrace', '说明: 生成文本及其来源标识。', '访问级别: 公共', '继承: BaseModel', '字段: 7 个', ' content_key | 字符串 | | ', ' text | 字符串 | | ', ' reasoning_content | 字符串 | 空 | 空 | ', ' metadata | AIGCMetadataEnvelope | | ', ' model_id | 字符串 | 空 | 空 | ', ' served_model | 字符串 | 空 | 空 | ', ' generated_at | datetime | 空 | 空 | ', ]) }) it('luffy 的 CreateShareRequest:Field 的说明和长度约束写进行,校验方法仍是子块', async () => { const request = named(await views(SHARE), 'CreateShareRequest') expect(propertyLines(properties(request))).toEqual([ '名称: CreateShareRequest', '访问级别: 公共', '继承: BaseModel', '选项: 可从对象读取', '字段: 6 个', ' target_type | ShareTargetType | ShareTargetType.SESSION_ARTIFACT | ', ' session_id | 字符串 | 至少 1 个字符 | 作品所属 session id', ' preview_url | 字符串 | 至少 1 个字符 · 最长 2048 | 作品当前 preview URL(前端从预览态取)', ' title | 字符串 | 至少 1 个字符 · 最长 255 | 分享卡片标题', ' description | 字符串 | 空 | 空 · 最长 500 | ', ' thumbnail_url | 字符串 | 空 | 空 · 最长 2048 | ', ]) const lines = SHARE.split('\n') expect(request.children.map(child => [child.kind, lines[child.startLine].trim()])).toEqual([ ['function', '@field_validator("preview_url", "thumbnail_url")'], ]) expect(request.code).toContain('def validate_http_scheme') }) it('同一文件里继承模型的也是模型;TypedDict、NamedTuple、普通类、没导入 pydantic 的不是', async () => { const withChild = `${AIGC}\n\nclass Child(AIGCMetadata):\n extra: int = 1\n` const child = named(await views(withChild), 'Child') expect(child.kind).toBe('pydantic.model') expect(propertyLines(properties(child)).slice(0, 4)).toEqual([ '名称: Child', '访问级别: 公共', '继承: AIGCMetadata', '字段: 1 个', ]) expect(propertyLines(properties(child)).at(-1)).toBe(' extra | 整数 | 1 | ') const others = [ 'from typing import NamedTuple, TypedDict', 'from pydantic import BaseModel', '', 'class ViaDict(TypedDict):', ' x: str', '', 'class ViaTuple(NamedTuple):', ' x: str', '', 'class Plain:', ' x: str', '', 'class Model(BaseModel):', ' x: str', '', ].join('\n') expect((await views(others)).map(view => view.kind)).toEqual(['import', 'import', 'class', 'class', 'class', 'pydantic.model']) expect((await views('class Model(BaseModel):\n x: str\n')).map(view => view.kind)).toEqual(['class']) }) it('别名、Field 的默认值和约束、说明优先于注释;ClassVar 和没注解的类属性留下', async () => { const source = [ 'from pydantic import BaseModel as Model, Field', 'from typing import ClassVar', '', 'class Limits(Model):', ' """界限。"""', ' registry: ClassVar[dict] = {}', ' # 个数', ' n: int = Field(default=0, ge=0, le=10)', ' # 忽略这行', ' name: str = Field(min_length=1, max_length=255, description="名字")', ' tags: list[str] = Field(default_factory=list)', ' step: int = Field(default=3, multiple_of=3, pattern="[0-9]+", unknown=1)', ' label = "a"', '', ' def ping(self) -> None:', ' return None', '', ].join('\n') const limits = named(await views(source), 'Limits') expect(propertyLines(properties(limits))).toEqual([ '名称: Limits', '说明: 界限。', '访问级别: 公共', '继承: Model', '字段: 4 个', ' n | 整数 | 0 · ≥ 0 · ≤ 10 | 个数', ' name | 字符串 | 至少 1 个字符 · 最长 255 | 名字', ' tags | 列表[字符串] | 每次新建 list() | ', ' step | 整数 | 3 · 3 的倍数 · 匹配 "[0-9]+" · unknown=1 | ', ]) const lines = source.split('\n') expect(limits.children.map(child => lines[child.startLine].trim())).toEqual([ 'registry: ClassVar[dict] = {}', 'label = "a"', 'def ping(self) -> None:', ]) }) it('筛选:种类是 pydantic.model、叫「数据模型」;类型里的名字带着源码下标', async () => { const list = await views(AIGC) const metadata = named(list, 'AIGCMetadata') expect(metadata.attributes).toEqual({ kind: 'pydantic.model', access: 'public' }) expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values) .toContainEqual({ id: 'pydantic.model', name: '数据模型', count: 2 }) const linked = linkedSegments(properties(metadata)) expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['AIGCMetadata', 'BaseModel', 'Label'])) for (const segment of linked) expect(AIGC.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text) const trace = named(list, 'TextTrace') const typeNames = linkedSegments(properties(trace)).filter(segment => segment.text === 'AIGCMetadataEnvelope') expect(typeNames.map(segment => AIGC.slice(segment.offset, (segment.offset ?? 0) + segment.text.length))).toEqual(['AIGCMetadataEnvelope']) const share = named(await views(SHARE), 'CreateShareRequest') expect(linkedSegments(properties(share)).map(segment => segment.text)).toEqual(expect.arrayContaining(['ShareTargetType'])) }) it('别的文件里的父类:索引认本地能确定的模型;查不到先当普通类,查到再换成数据模型卡片', async () => { expect(MODEL_INDEX.mentions(AIGC)).toBe(true) expect(MODEL_INDEX.mentions('from luffy_agent.model.aigc import AIGCMetadata\n\nclass Child(AIGCMetadata):\n x: int\n')).toBe(false) const entries = await indexEntries(`${AIGC}\n\nclass Plain:\n x: str\n`) expect(entries.map(entry => [entry.key, entry.value])).toEqual([['AIGCMetadata', 'model'], ['TextTrace', 'model']]) const alias = await indexEntries('from pydantic import BaseModel as Model\n\nclass Alias(Model):\n x: int\n') expect(alias.map(entry => entry.key)).toEqual(['Alias']) const child = [ 'from luffy_agent.model.aigc import AIGCMetadata', '', 'class Child(AIGCMetadata):', ' extra: int = 1', '', 'class Other(NotAModel):', ' x: int', '', ].join('\n') const pending = await views(child) expect(pending.map(view => view.kind)).toEqual(['import', 'class', 'class']) const model = (index: { id: string }, node: { text: string }): boolean => index.id === MODEL_INDEX.id && node.text === 'AIGCMetadata' const found = await views(child, [], (index, node) => model(index, node) ? { key: node.text, name: { startIndex: node.startIndex, endIndex: node.endIndex }, value: 'model' } : undefined) const card = named(found, 'Child') expect(card.kind).toBe('pydantic.model') expect(propertyLines(properties(card)).slice(-2)).toEqual(['字段: 1 个', ' extra | 整数 | 1 | ']) expect(named(found, 'Other').kind).toBe('class') }) it('给模型加一个字段、改一个字段的默认值:字段行标新增,默认值那一格标改过', async () => { const after = AIGC .replace(' ReservedCode2: str\n', ' ReservedCode2: str\n Extra: str\n') .replace(' reasoning_content: str | None = None\n', ' reasoning_content: str | None = ""\n') expect(after).not.toBe(AIGC) const before = await views(AIGC) const now = await views(after) const diff = diffBlockTrees({ blocks: before, source: AIGC }, { blocks: now, source: after }) const metadata = named(now, 'AIGCMetadata') const trace = named(now, 'TextTrace') const metadataChange = diff.blocks[metadata.anchor] const traceChange = diff.blocks[trace.anchor] expect(metadataChange).toMatchObject({ change: 'mod', own: true }) expect(traceChange).toMatchObject({ change: 'mod', own: true }) const fieldsIndex = properties(metadata).findIndex(row => row.label === '字段') const added = metadataChange.marks?.rows?.changed[fieldsIndex].items?.changed expect(added?.[7]).toEqual({ added: true }) expect(Object.keys(added ?? {}).filter(index => added?.[Number(index)].added)).toEqual(['7']) const traceFields = properties(trace).findIndex(row => row.label === '字段') const cell = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2] expect(cell?.map(part => part.change)).toEqual(['del', 'add']) expect(cell?.[0].segments.map(segment => segment.text).join('')).toBe('空') expect(cell?.[1].segments.map(segment => segment.text).join('')).toBe('""') expect(diff.stats).toEqual({ add: 0, mod: 2, del: 0 }) }) })