// @vitest-environment happy-dom import type { Root } from 'react-dom/client' import type { UnhashedBlock } from './testBlocks' import type { CallTree, DocumentMessage, ExtensionToWebview, WebviewToExtension } from './types' import type { ViewState, ViewStateStore } from './viewState' import { act } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App } from './App' import { hashed, NO_SPAN } from './testBlocks' import { createViewStateStore } from './viewState' /** * 一次次提交里哪些组件真的画了(新挂上的不算):赶在 react-dom 加载之前挂上 React 开发者工具的钩子,每次提交顺着 fiber 树数。 * 跳过重画的整棵子树(fiber 和上一次的共用子节点)不往下数。 */ const renders = vi.hoisted(() => { interface Fiber { type: unknown, elementType: unknown, alternate: Fiber | null, flags: number, child: Fiber | null, sibling: Fiber | null } const counts = new Map() let counting = false /** 组件叫什么:memo 包着的看它的 displayName,别的看函数名 */ const nameOf = (fiber: Fiber): string | undefined => { const memoName = (fiber.elementType as { displayName?: string } | null)?.displayName return memoName ?? (typeof fiber.type === 'function' ? fiber.type.name : undefined) } const walk = (root: { current: Fiber }): void => { const stack = [root.current] while (stack.length > 0) { const fiber = stack.pop()! const name = nameOf(fiber) // flags 的第 1 位(PerformedWork):这次真的调用了组件函数 if (name && fiber.alternate && (fiber.flags & 1)) counts.set(name, (counts.get(name) ?? 0) + 1) if (fiber.alternate && fiber.child === fiber.alternate.child) continue for (let child = fiber.child; child; child = child.sibling) stack.push(child) } } Object.assign(globalThis, { __REACT_DEVTOOLS_GLOBAL_HOOK__: { supportsFiber: true, isDisabled: false, renderers: new Map(), inject: () => 1, checkDCE: () => {}, onScheduleFiberRoot: () => {}, onCommitFiberRoot: (_id: number, root: { current: Fiber }) => counting && walk(root), onCommitFiberUnmount: () => {}, onPostCommitFiberRoot: () => {}, }, }) return { start: () => { counts.clear() counting = true }, stop: (): Record => { counting = false return Object.fromEntries(counts) }, } }) /** 扩展端那头:页面发出去的消息、页面挂着的收消息回调,以及 webview 自己存的状态(页面重新加载时还在) */ const host = vi.hoisted(() => ({ posted: [] as unknown[], listeners: new Set<(msg: unknown) => void>(), store: undefined as ViewStateStore | undefined, })) vi.mock('./vscode', () => ({ postMessage: (msg: WebviewToExtension) => host.posted.push(msg), onMessage: (handler: (msg: unknown) => void) => { host.listeners.add(handler) return () => host.listeners.delete(handler) }, viewState: { get: () => host.store!.get(), update: (patch: Partial) => host.store!.update(patch), }, })) let container: HTMLDivElement let root: Root let saved: ViewState | undefined /** 页面(重新)加载:从 webview 存的状态起步 */ async function load(): Promise { host.store = createViewStateStore({ getState: () => saved, setState: (state) => { saved = JSON.parse(JSON.stringify(state)) as ViewState }, }) host.posted.length = 0 container = document.createElement('div') document.body.append(container) root = createRoot(container) await act(async () => root.render()) } async function unload(): Promise { await act(async () => root.unmount()) container.remove() host.listeners.clear() } async function deliver(msg: ExtensionToWebview): Promise { await act(async () => { for (const listener of host.listeners) listener(msg) }) } function block(id: string, anchor: string): UnhashedBlock { return { id, anchor, kind: 'function', callTree: {}, span: NO_SPAN, attributes: { kind: 'function' }, concepts: [{ id: 'function', label: 'function', icon: 'braces' }], children: [], translation: [{ text: anchor, role: 'plain' }], } } function documentOf(version: number, blocks: UnhashedBlock[]): DocumentMessage { return { type: 'document', version, languageId: 'python', blocks: hashed(blocks), source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] } } function tree(id: string, name: string): CallTree { return { id, name, hits: 1, root: { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [] } } } const TREES = [tree('read', '读取用户资料'), tree('night', '夜间对账')] const dialog = () => document.body.querySelector('[role="dialog"]') const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '') beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) saved = undefined }) afterEach(async () => { await unload() vi.unstubAllGlobals() }) describe('页面重新加载后,调用树弹框照旧开着', () => { it('弹框开着、选了第二个入口:页面重新加载拿到文档后重新打开那块、要它的树,停回第二个入口;关掉后再加载就不开了', async () => { await load() await deliver(documentOf(3, [block('0', 'mask_phone')])) await act(async () => document.body.querySelector('[data-call-tree="mask_phone"]')!.click()) expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES }) await act(async () => document.body.querySelectorAll('[role="tab"]')[1].click()) expect(saved?.callTree).toEqual({ anchor: 'mask_phone', entry: 1 }) await unload() await load() expect(dialog()).toBeNull() await deliver(documentOf(3, [block('0', 'mask_phone')])) expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) expect(dialog()!.textContent).toContain('正在查调用关系') await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES }) expect(selectedTab()).toBe('夜间对账1处') await act(async () => dialog()!.querySelector('[aria-label="关闭"]')!.click()) expect(saved?.callTree).toBeUndefined() await unload() await load() await deliver(documentOf(3, [block('0', 'mask_phone')])) expect(dialog()).toBeNull() expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' })) }) it('重新加载后文档还没到,页面又重新加载了一次:记着的弹框还在', async () => { saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } } await load() await unload() await load() await deliver(documentOf(3, [block('0', 'mask_phone')])) expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' }) expect(dialog()).not.toBeNull() }) it('那块已经不在了:不开弹框、不要树,记录丢掉', async () => { saved = { editing: [], folds: {}, callTree: { anchor: 'gone', entry: 0 } } await load() await deliver(documentOf(4, [block('0', 'mask_phone')])) expect(dialog()).toBeNull() expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' })) expect(saved?.callTree).toBeUndefined() }) it('弹框开着时文档换了一版:照旧关掉,记录跟着清掉', async () => { saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 0 } } await load() await deliver(documentOf(5, [block('0', 'mask_phone')])) expect(dialog()).not.toBeNull() await deliver(documentOf(6, [block('0', 'mask_phone')])) expect(dialog()).toBeNull() expect(saved?.callTree).toBeUndefined() }) }) describe('问 AI 和重写注释各按各的能不能用', () => { const DOC = documentOf(1, [{ ...block('0', 'rglob'), commentTarget: 'replace' }]) const qaMessage = (rewritable: boolean): ExtensionToWebview => ({ type: 'qa', available: true, rewritable, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] }) const tool = (label: string) => container.querySelector(`[data-id="0"] [aria-label="${label}"]`) it('工作区外的库文件(rewritable 为假):块上能问 AI、顶部能问,没有重写注释的按钮', async () => { await load() await deliver(qaMessage(false)) await deliver(DOC) expect(tool('问 AI')).not.toBeNull() expect(container.querySelectorAll('.cr-qa')).toHaveLength(1) expect(tool('按注释规范重写')).toBeNull() }) it('项目里的文件:两个按钮都有', async () => { await load() await deliver(qaMessage(true)) await deliver(DOC) expect(tool('问 AI')).not.toBeNull() expect(tool('按注释规范重写')).not.toBeNull() }) }) describe('代码配色跟着当前颜色主题', () => { it('扩展端发来的配色设成 body 上的变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>;换了主题再发来就换', async () => { const style = document.body.style await load() await deliver({ type: 'codeColors', roles: { keyword: '#C586C0', function: '#DCDCAA' }, kinds: { loop: 'var(--vscode-editor-foreground)' } } as ExtensionToWebview) expect(style.getPropertyValue('--cr-keyword')).toBe('#C586C0') expect(style.getPropertyValue('--cr-function')).toBe('#DCDCAA') expect(style.getPropertyValue('--cr-kind-loop')).toBe('var(--vscode-editor-foreground)') await deliver({ type: 'codeColors', roles: { keyword: '#AF00DB', function: '#795E26' }, kinds: { loop: '#AF00DB' } } as ExtensionToWebview) expect(style.getPropertyValue('--cr-keyword')).toBe('#AF00DB') expect(style.getPropertyValue('--cr-kind-loop')).toBe('#AF00DB') }) }) describe('一条消息只重画受影响的卡片', () => { /** 三张卡片,译文里各有一个带下标的名字(下标 10、20、30) */ function named(id: string, name: string, offset: number, text = '调用'): UnhashedBlock { return { ...block(id, name), translation: [{ text, role: 'keyword' }, { text: name, role: 'function', offset }] } } const DOC = documentOf(3, [named('0', 'load', 10), named('1', 'save', 20), named('2', 'drop', 30)]) /** 扩展端每次发来的都是新对象:深拷一份 */ const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T async function rendered(msg: ExtensionToWebview): Promise> { renders.start() await deliver(msg) return renders.stop() } it('中文名到了:只重画换了字的那段文字,卡片不动', async () => { await load() await deliver(copy(DOC)) const counts = await rendered({ type: 'displayNames', version: 3, names: [{ offset: 20, length: 4, name: '保存' }] }) expect(counts).toEqual({ SegmentText: 1 }) expect(container.querySelector('[data-offset="20"]')?.textContent).toBe('保存') }) it('调用树棵数到了:只重画棵数变了的那几块', async () => { await load() await deliver(copy(DOC)) const counts = await rendered({ type: 'callTreeCounts', version: 3, counts: { save: 2 } }) expect(counts.BlockItem).toBe(1) expect(counts.SegmentText).toBeUndefined() expect(container.querySelector('[data-call-tree="save"]')?.textContent).toBe('2') }) it('同一版文档重发、只改了一块的新版本:内容没变的卡片不重画', async () => { await load() await deliver(copy(DOC)) expect(await rendered(copy(DOC))).not.toHaveProperty('BlockItem') const edited = documentOf(4, [named('0', 'load', 10), named('1', 'save', 20, '调用了'), named('2', 'drop', 30)]) const counts = await rendered(copy(edited)) expect(counts.BlockItem).toBe(1) expect(container.querySelector('[data-id="1"]')?.textContent).toContain('调用了') }) it('单击名字高亮:只重画高亮落到的那段文字', async () => { await load() await deliver(copy(DOC)) await act(async () => container.querySelector('[data-offset="30"]')!.click()) const asked = [...host.posted].reverse().find((msg): msg is Extract => (msg as WebviewToExtension).type === 'findOccurrences')! const counts = await rendered({ type: 'occurrencesFound', id: asked.id, occurrences: [{ start: 30, end: 34, kind: 'read' }] }) expect(counts.SegmentText).toBe(1) expect(counts.BlockItem).toBeUndefined() expect(container.querySelector('[data-offset="30"]')?.getAttribute('data-occurrence')).toBe('normal') }) it('差异到了:卡片还是原来那个元素(不拆掉重建),改了的那块标上改动色条,删掉的块放回原处', async () => { await load() await deliver(copy(DOC)) const cards = [...container.querySelectorAll('.cr-block')] const removed = { ...block('9', 'gone'), translation: [{ text: 'gone', role: 'plain' as const }] } renders.start() await deliver({ type: 'diff', diff: { base: 'HEAD', version: 3, blocks: { save: { change: 'mod', own: true } }, removed: [{ parent: '', after: 'save', block: removed, line: 9, kindName: '函数' }], stats: { add: 0, mod: 1, del: 1 }, lineCount: 20, }, }) const counts = renders.stop() expect(counts.SegmentText).toBeUndefined() const after = [...container.querySelectorAll('.cr-block:not(.cr-removed)')] expect(after).toHaveLength(3) after.forEach((card, index) => expect(card).toBe(cards[index])) expect(container.querySelector('[data-id="1"] > .cr-change-bar')?.getAttribute('data-change')).toBe('mod') expect(container.querySelector('[data-id="0"] > .cr-change-bar')?.hasAttribute('data-change')).toBe(false) expect(container.querySelector('.cr-level')?.hasAttribute('data-diff')).toBe(true) const gone = container.querySelector('.cr-removed')! expect(gone.querySelector('.cr-change-bar')?.getAttribute('data-change')).toBe('del') expect(cards[1].compareDocumentPosition(gone) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(gone.compareDocumentPosition(cards[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) it('没展开、没说过的块下面不画问答区;展开一块只重画那一块', async () => { await load() await deliver({ type: 'qa', available: true, rewritable: true, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] }) await deliver(copy(DOC)) // 顶部那条一直摊开 expect(container.querySelectorAll('.cr-qa')).toHaveLength(1) renders.start() await act(async () => container.querySelector('[data-id="1"] [aria-label="问 AI"]')!.click()) const counts = renders.stop() expect(counts.BlockItem).toBe(1) expect(container.querySelectorAll('.cr-qa')).toHaveLength(2) }) })