✨ feat(vscode): 代码阅读的函数和类可以看调用树

This commit is contained in:
cruldra committed 2026-10-11 00:37:24 -04:00
1 parent 8dc572bf94
commit 645542eb5c
17 files changed
+1679 -5

No files matched your search

@@ -0,0 +1,225 @@
// @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 } 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): BlockView {
return {
id,
anchor: id,
kind,
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: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
}),
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`, 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 的函数没有按钮,类有,语句没有;只读的树一排按钮都没有', 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: () => {} })} />)
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
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')
await render(<BlockLevel blocks={[block('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)}
/>
)}
</>
)
}
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!.textContent).toContain('utils/phone.py:28')
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)} />
</>
)
: <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={() => {}} />)
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={() => {}} />)
expect(document.body.textContent).toContain('正在查调用关系')
await render(<CallTreeDialog kind="class" name="OrderService" where="billing/order.py:14" status="empty" trees={[]} onClose={() => {}} />)
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={() => {}}
/>,
)
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()
})
})