313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
// @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: '' })
|
||
})
|
||
})
|