✨ feat(vscode): FastAPI 接口卡片的调用树带上前端调用它的地方

This commit is contained in:
cruldra committed 2026-10-12 12:22:51 +11:00
1 parent d4459e93b3
commit 29cedcb751
30 files changed
+1316 -130

No files matched your search

@@ -36,11 +36,12 @@ async function render(element: ReactElement): Promise<void> {
await act(async () => root.render(element))
}
function block(kind: string, id = kind): BlockView {
function block(kind: string, id = kind, callTree?: BlockView['callTree']): BlockView {
return {
id,
anchor: id,
kind,
...(callTree && { callTree }),
startLine: 0,
endLine: 1,
code: '',
@@ -79,7 +80,7 @@ function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeStat
}
function node(name: string, extra: Partial<CallTree['root']> = {}): CallTree['root'] {
return { name, kind: 'function', where: `${name}.py:1`, current: false, children: [], ...extra }
return { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [], ...extra }
}
const ONE: CallTree = {
@@ -104,21 +105,29 @@ const MANY: CallTree[] = [
]
describe('调用树弹框', () => {
it('函数和类都有按钮,棵数为 0 只有图标、大于 0 带数字,语句没有;只读的树一排按钮都没有', async () => {
const counts = new Map([['function', 0], ['class', 2], ['statement', 4]])
await render(<BlockLevel blocks={[block('function'), block('class'), block('statement')]} tree={tree({ counts, onOpen: () => {} })} />)
it('宿主标了能看调用树的块才有按钮(接口卡片也是),棵数为 0 只有图标、大于 0 带数字;只读的树一排按钮都没有', async () => {
const counts = new Map([['function', 0], ['class', 2], ['statement', 4], ['route', 1], ['bare', 5]])
const endpoint = { method: 'GET', path: '/agents' }
await render(
<BlockLevel
blocks={[block('function', 'function', {}), block('class', 'class', {}), block('statement'), block('fastapi.route', 'route', { endpoint }), block('function', 'bare')]}
tree={tree({ counts, onOpen: () => {} })}
/>,
)
expect(container.querySelector('[data-call-tree="function"]')!.textContent).toBe('')
expect(container.querySelector('[data-call-tree="statement"]')).toBeNull()
expect(container.querySelector('[data-call-tree="class"]')).not.toBeNull()
expect(container.querySelector('[data-call-tree="class"]')!.textContent).toBe('2')
expect(container.querySelector('[data-call-tree="route"]')!.textContent).toBe('1')
expect(container.querySelector('[data-call-tree="bare"]')).toBeNull()
await render(<BlockLevel blocks={[block('function')]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
await render(<BlockLevel blocks={[block('function', 'function', {})]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
expect(container.querySelector('[aria-label="块操作"]')).toBeNull()
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
})
it('弹框打开时原来的卡片还在,关掉之后弹框没了,焦点回到那个按钮', async () => {
const card = block('function', 'mask')
const card = block('function', 'mask', {})
function Page(): ReactElement {
const [open, setOpen] = useState(false)
return (
@@ -135,6 +144,7 @@ describe('调用树弹框', () => {
status="ready"
trees={[ONE]}
onClose={() => setOpen(false)}
onJump={() => {}}
/>
)}
</>
@@ -165,7 +175,7 @@ describe('调用树弹框', () => {
? (
<>
<div data-id="mask">卡片</div>
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} />
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} onJump={() => {}} />
</>
)
: <div data-id="mask">卡片</div>
@@ -184,7 +194,7 @@ describe('调用树弹框', () => {
})
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} />)
await render(<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={MANY} onClose={() => {}} onJump={() => {}} />)
const tabs = [...document.body.querySelectorAll('[role="tab"]')]
expect(tabs.map(tab => tab.textContent?.replace(/\s+/g, ''))).toEqual(['读取用户资料2处', '夜间对账1处'])
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
@@ -196,9 +206,9 @@ describe('调用树弹框', () => {
})
it('正在查、没有调用者,以及类的「新建」节点', async () => {
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} />)
await render(<CallTreeDialog kind="class" name="" where="" status="loading" trees={[]} onClose={() => {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('正在查调用关系')
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} />)
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('没有调用它的地方')
const made = node('新建 OrderService', {
kind: 'class',
@@ -214,6 +224,7 @@ describe('调用树弹框', () => {
status="ready"
trees={[{ id: 'post', name: 'POST /orders', hits: 1, root: node('POST /orders', { detail: '入口', kind: 'function', children: [made] }) }]}
onClose={() => {}}
onJump={() => {}}
/>,
)
expect(document.body.textContent).toContain('新建 OrderService')
@@ -222,4 +233,48 @@ describe('调用树弹框', () => {
expect(document.body.textContent).not.toContain('方法')
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
})
it('接口在契约文件里没对上:写明重新生成、列出找到的契约文件;一份都没有时写没找到;都不说「没有调用它的地方」', async () => {
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: ['web/src/lib/api/schema.d.ts', 'admin/src/lib/api/schema.d.ts'] }} onClose={() => {}} onJump={() => {}} />)
const body = () => document.body.querySelector('.cr-call-body')!.textContent ?? ''
expect(body()).toContain('接口契约文件里没有这个接口,重新生成后再看')
expect(body()).toContain('web/src/lib/api/schema.d.ts')
expect(body()).toContain('admin/src/lib/api/schema.d.ts')
expect(body()).not.toContain('没有调用它的地方')
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)')
expect(body()).not.toContain('没有调用它的地方')
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="ready" trees={[ONE]} contractMiss={{ files: [] }} onClose={() => {}} onJump={() => {}} />)
expect(body()).toContain('工作区里没找到接口契约文件')
expect(body()).toContain('夜间对账')
})
it('契约里几条都对得上、分不出是哪一条时列出那几条,不叫人重新生成', async () => {
const miss = { files: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] }
await render(<CallTreeDialog kind="fastapi.route" name="list_agents" where="api/routes.py:40" status="empty" trees={[]} contractMiss={miss} onClose={() => {}} onJump={() => {}} />)
const body = document.body.querySelector('.cr-call-body')!.textContent ?? ''
expect(body).toContain('接口契约文件里有几条都对得上这个接口,按 operationId 也分不出是哪一条')
expect(body).toContain('/api/v1/admin/agents')
expect(body).toContain('/api/v1/agents')
expect(body).not.toContain('重新生成')
expect(body).not.toContain('没有调用它的地方')
})
it('点一行跳到它在的文件和行,「请求」节点跳到发请求的那一行', async () => {
const jumps: unknown[] = []
const request = node('请求 GET /api/v1/agents/default', {
kind: 'request',
where: 'web/src/lib/agents.ts:11',
jump: { uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 },
children: [node('get_default_agent', { current: true })],
})
const page: CallTree = { id: 'page', name: 'MyAgentsPage', hits: 1, root: node('MyAgentsPage', { detail: '页面 /my-agents', children: [request] }) }
await render(<CallTreeDialog kind="fastapi.route" name="get_default_agent" where="api/routes.py:79" status="ready" trees={[page]} onClose={() => {}} onJump={jump => jumps.push(jump)} />)
expect(document.body.textContent).toContain('页面 /my-agents')
const rows = [...document.body.querySelectorAll<HTMLButtonElement>('button.cr-call-card')]
expect(rows.map(row => row.querySelector('.cr-call-name')!.textContent)).toEqual(['MyAgentsPage', '请求 GET /api/v1/agents/default', 'get_default_agent'])
await act(async () => rows[1].click())
expect(jumps).toEqual([{ uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 }])
})
})