// @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 { 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(), 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'] { 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( {} })} />, ) 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( {} }, 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 ( <> setOpen(true) })} /> {open && ( setOpen(false)} onJump={() => {}} /> )} ) } await render() const button = container.querySelector('[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('[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 ? ( <>
卡片
setOpen(false)} onJump={() => {}} /> ) :
卡片
} await render() const overlay = document.body.querySelector('.cr-call-overlay')! await act(async () => overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))) expect(document.body.querySelector('[role="dialog"]')).toBeNull() await render() 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( {}} 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( {}} onJump={() => {}} />) expect(document.body.textContent).toContain('正在查调用关系') await render( {}} 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( {}} 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( {}} 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( {}} onJump={() => {}} />) expect(body()).toContain('工作区里没找到接口契约文件(openapi-typescript 生成的 .d.ts 或 openapi.json)') expect(body()).not.toContain('没有调用它的地方') await render( {}} 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( {}} 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( {}} onJump={jump => jumps.push(jump)} />) expect(document.body.textContent).toContain('页面 /my-agents') const rows = [...document.body.querySelectorAll('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( {}} onJump={() => {}} />) const title = document.body.querySelector('.cr-call-title')! const place = title.querySelector('[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('.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: '' }) }) })