Files
superwork/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx
T

313 lines
15 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.
// @vitest-environment happy-dom
import type { ReactElement } from 'react'
import type { Root } from 'react-dom/client'
import type { BlockTreeState } from './BlockTree'
import type { BlockView, CallTree } from './types'
import { act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockLevel } from './BlockTree'
import { CallTreeDialog, splitWhere } from './CallTreeDialog'
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
let container: HTMLDivElement
let root: Root
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
async function render(element: ReactElement): Promise<void> {
await act(async () => root.render(element))
}
function block(kind: string, id = kind, callTree?: BlockView['callTree']): BlockView {
return {
id,
anchor: id,
kind,
...(callTree && { callTree }),
startLine: 0,
endLine: 1,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: kind, icon: kind === 'class' ? 'box' : 'braces' }],
children: [],
translation: [{ text: id, role: 'plain' }],
}
}
function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeState {
return {
...(edit && {
edit: {
version: 1,
editing: new Set<string>(),
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
},
}),
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
...(callTree && { callTree }),
}
}
function node(name: string, extra: Partial<CallTree['root']> = {}): CallTree['root'] {
return { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [], ...extra }
}
const ONE: CallTree = {
id: 'one',
name: '夜间对账',
hits: 1,
root: node('夜间对账', { detail: '入口', children: [node('mask_phone', { current: true, where: 'utils/phone.py:28' })] }),
}
const MANY: CallTree[] = [
{
id: 'read',
name: '读取用户资料',
hits: 2,
root: node('读取用户资料', {
detail: '入口',
otherCalls: 2,
children: [node('mask_phone', { current: true }), node('mask_phone', { current: true })],
}),
},
ONE,
]
describe('调用树弹框', () => {
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', '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', {})
function Page(): ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<BlockLevel
blocks={[card]}
tree={tree({ counts: new Map([['mask', 3]]), openAnchor: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
/>
{open && (
<CallTreeDialog
kind="function"
name="mask_phone"
where="utils/phone.py:28"
status="ready"
trees={[ONE]}
onClose={() => setOpen(false)}
onJump={() => {}}
/>
)}
</>
)
}
await render(<Page />)
const button = container.querySelector<HTMLButtonElement>('[data-call-tree="mask"]')!
await act(async () => button.click())
expect(container.querySelector('[data-id="mask"]')).not.toBeNull()
const dialog = document.body.querySelector('[role="dialog"]')
expect(dialog).not.toBeNull()
expect(dialog!.textContent).toContain('调用树')
expect(dialog!.textContent).toContain('mask_phone')
expect(dialog!.querySelector('.cr-call-title [data-tooltip="utils/phone.py:28"]')).not.toBeNull()
expect(dialog!.textContent).not.toContain('读取用户资料')
expect(dialog!.querySelector('[role="tablist"]')).toBeNull()
await act(async () => dialog!.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click())
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(container.querySelector('[data-id="mask"]')).not.toBeNull()
expect(document.activeElement).toBe(container.querySelector('[data-call-tree="mask"]'))
})
it('点遮罩或按 Esc 关掉弹框', async () => {
function Page(): ReactElement {
const [open, setOpen] = useState(true)
return open
? (
<>
<div data-id="mask">卡片</div>
<CallTreeDialog kind="function" name="mask_phone" where="utils/phone.py:28" status="ready" trees={[ONE]} onClose={() => setOpen(false)} onJump={() => {}} />
</>
)
: <div data-id="mask">卡片</div>
}
await render(<Page key="overlay" />)
const overlay = document.body.querySelector<HTMLElement>('.cr-call-overlay')!
await act(async () => overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await render(<Page key="esc" />)
await act(async () => document.body.querySelector('.cr-call-dialog')!.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })))
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(document.body.textContent).toContain('卡片')
})
it('多于一棵树才有入口切换,点一项换成那棵;只有一棵没有这排', async () => {
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 ?? ''
expect(body()).toContain('另外 2 处调用')
expect(body()).not.toContain('夜间对账')
await act(async () => (tabs[1] as HTMLButtonElement).click())
expect(body()).toContain('夜间对账')
expect(body()).not.toContain('另外 2 处调用')
})
it('正在查、没有调用者,以及类的「新建」节点', async () => {
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={() => {}} onJump={() => {}} />)
expect(document.body.textContent).toContain('没有调用它的地方')
const made = node('新建 OrderService', {
kind: 'class',
detail: '进入这个类',
where: 'billing/order.py:40',
children: [node('OrderService', { kind: 'class', current: true, where: 'billing/order.py:14' })],
})
await render(
<CallTreeDialog
kind="class"
name="OrderService"
where="billing/order.py:14"
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')
expect(document.body.textContent).toContain('进入这个类')
expect(document.body.textContent).toContain('当前')
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 }])
})
it('标题和卡片的位置拆成「文件名:行号」和淡色的所在目录,悬停看完整路径;卡片第一行放名字和 detail,第二行只放位置;每层缩进 16px', async () => {
const where = 'backend/src/luffy_agent/agent/runtime/runner.py:1325'
const run = node('run', {
detail: 'class SessionRunner (runner.py)',
where,
children: [node('mask_phone', { current: true, where: 'phone.py:28' })],
})
await render(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} onClose={() => {}} onJump={() => {}} />)
const title = document.body.querySelector('.cr-call-title')!
const place = title.querySelector<HTMLElement>('[data-tooltip]')!
expect(place.dataset.tooltip).toBe(where)
expect(place.querySelector('.cr-call-file')!.textContent).toBe('runner.py:1325')
expect(place.querySelector('.cr-call-dir')!.textContent).toBe('backend/src/luffy_agent/agent/runtime')
const [outer, inner] = [...document.body.querySelectorAll('button.cr-call-card')]
const lines = (card: Element) => [...card.children].map(line => [...line.querySelectorAll('.cr-call-name, .cr-call-detail, .cr-call-pill, .cr-call-file, .cr-call-dir')].map(part => part.textContent))
expect(lines(outer)).toEqual([['run', 'class SessionRunner (runner.py)'], ['runner.py:1325', 'backend/src/luffy_agent/agent/runtime']])
expect(outer.children[1].getAttribute('data-tooltip')).toBe(where)
expect(lines(inner)).toEqual([['mask_phone', '当前'], ['phone.py:28']])
const rows = [...document.body.querySelectorAll<HTMLElement>('.cr-call-row')]
expect(rows.map(row => row.style.paddingLeft)).toEqual(['0px', '16px'])
})
})
describe('位置拆成文件名和所在目录', () => {
it('按最后一个 / 拆:「文件名:行号」和它所在的目录', () => {
expect(splitWhere('backend/src/luffy_agent/agent/runtime/runner.py:1325')).toEqual({ file: 'runner.py:1325', dir: 'backend/src/luffy_agent/agent/runtime' })
expect(splitWhere('web/src/lib/agents.ts:11')).toEqual({ file: 'agents.ts:11', dir: 'web/src/lib' })
})
it('没有目录时只有文件名', () => {
expect(splitWhere('runner.py:12')).toEqual({ file: 'runner.py:12', dir: '' })
})
})