✅ test(vscode): 代码阅读预览按源码位置干活的几处补上测试:悬停弹定义卡片、⌘ 点击跳定义、单击高亮各处出现和键盘跳下一处、跳到定义后闪的名字、编辑框发回的行号、钉住的卡片头,文档上面多了几行时报的都得是新位置

This commit is contained in:
cruldra committed 2026-10-13 16:07:14 +11:00
1 parent fce8e8cec2
commit 3a2815c19c
1 file changed
+259
@@ -0,0 +1,259 @@
// @vitest-environment happy-dom
import type { Root } from 'react-dom/client'
import type { Memento } from 'vscode'
import type { 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 { peekBlock } from '../../../src/codeReader/editor/definitionPeek'
import { documentMessage } from '../../../src/codeReader/editor/documentMessage'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { CodeReaderRegistry } from '../../../src/codeReader/registry'
import { runtime, WASM_DIR } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { App } from './App'
import { pageLayout } from './pageLayout'
import { createViewStateStore } from './viewState'
/**
* 页面上按源码位置干活的几处:悬停弹定义卡片、⌘ 点击跳定义、单击高亮各处出现(连同用键盘跳到下一处)、跳到定义后闪的名字、
* 编辑框发回的行号、钉住的卡片头。文档照扩展端的做法生成;先发一版,再发上面多了两行、前一个函数里多了一个字的一版:
* 后面那几块的内容没变、在文件里的位置变了,页面报给扩展端的、和扩展端发来的位置比的,都得是新的那一版里的位置。
*/
const host = vi.hoisted(() => ({
posted: [] as WebviewToExtension[],
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<ViewState>) => host.store!.update(patch),
},
}))
const FIRST = [
'import os',
'',
'',
'def helper(value):',
' return value + 1',
'',
'',
'def handler(request):',
' total = helper(request.size)',
' if total > 3:',
' return helper(total)',
' return total',
'',
].join('\n')
/** 上面多两个空行,helper 里多一个字:handler 那几块的内容没变,行号往后挪 2,下标往后挪 3 */
const SECOND = FIRST.replace('import os\n', 'import os\n\n\n').replace('value + 1', 'value + 10')
const OTHER = [
'def tool(path):',
' return path',
'',
].join('\n')
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
async function read(source: string, version: number): Promise<Awaited<ReturnType<typeof documentMessage>>['message']> {
const { message } = await documentMessage({ languageId: 'python', version, getText: () => source, fileName: 'x.py' }, registry, runtime, () => {})
return message
}
/** 扩展端发给页面的那份文档 */
async function documentOf(source: string, version: number): Promise<ExtensionToWebview> {
return { ...await read(source, version), names: [] } as ExtensionToWebview
}
/** 扩展端发给页面的定义卡片:source 里 needle 开头那个名字所在的块,名字显示成 name */
async function peekOf(source: string, needle: string, name: string): Promise<Extract<ExtensionToWebview, { type: 'definitionPeeked' }>['peek']> {
const offset = source.indexOf(needle)
const line = source.slice(0, offset).split('\n').length - 1
const { blocks } = await read(source, 1)
return { path: 'other.py', line, block: peekBlock(blocks, offset, line)!, names: [{ offset, length: needle.length, name }] }
}
/** source 里第 nth 个 needle 的下标、所在的行 */
function at(source: string, needle: string, nth = 0): { offset: number, line: number } {
let offset = -1
for (let i = 0; i <= nth; i++)
offset = source.indexOf(needle, offset + 1)
return { offset, line: source.slice(0, offset).split('\n').length - 1 }
}
let container: HTMLDivElement
let root: Root
let saved: ViewState | undefined
async function load(): Promise<void> {
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(<App />))
}
async function deliver(msg: ExtensionToWebview): Promise<void> {
await act(async () => {
for (const listener of host.listeners)
listener(msg)
})
}
/** 等计时器、动画帧跑完 */
async function settle(ms = 30): Promise<void> {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, ms))
})
}
/** 页面上(不算钉住的那几行)显示着 text 的名字,按页面先后 */
function names(text: string): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>('[data-offset]')].filter(el => el.textContent === text && !el.closest('.cr-sticky'))
}
function lastPosted<T extends WebviewToExtension['type']>(type: T): Extract<WebviewToExtension, { type: T }> | undefined {
return [...host.posted].reverse().find((msg): msg is Extract<WebviewToExtension, { type: T }> => msg.type === type)
}
/** 两版文档都发过、handler 展开着(看得见它的子块)的页面;editing 是一开始就停在编辑的块 */
async function opened(editing: string[] = []): Promise<void> {
const first = await read(FIRST, 1)
const handler = first.blocks.find(block => block.defines && FIRST.slice(block.defines.startIndex, block.defines.endIndex) === 'handler')!
saved = { editing, folds: { [handler.id]: false } }
await load()
await deliver({ type: 'hoverDelay', delay: 0, hidingDelay: 0 })
await deliver(await documentOf(FIRST, 1))
await deliver(await documentOf(SECOND, 2))
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
saved = undefined
// happy-dom 没有 Web Animations:闪的那一处没有动画可重播
if (!Element.prototype.getAnimations)
Element.prototype.getAnimations = () => []
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
host.listeners.clear()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('页面报给扩展端的位置是眼下这一版里的', () => {
it('悬停名字:问定义卡片的下标是这个名字在新一版里的下标;回来的卡片照那个文件的下标换中文名', async () => {
await opened()
const [request] = names('request')
await act(async () => request.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })))
await settle()
const asked = lastPosted('peekDefinition')
expect(asked).toMatchObject({ version: 2, offset: at(SECOND, 'request').offset })
await deliver({ type: 'definitionPeeked', id: asked!.id, peek: await peekOf(OTHER, 'tool', '工具') })
expect(document.body.querySelector('[data-peek] [data-name="工具"]')).not.toBeNull()
})
it('⌘ / Ctrl + 点击名字:跳定义的下标是新一版里的', async () => {
await opened()
const [request] = names('request')
const modifiers = { bubbles: true, metaKey: true, ctrlKey: true }
await act(async () => {
request.dispatchEvent(new MouseEvent('mousemove', modifiers))
request.dispatchEvent(new MouseEvent('click', { ...modifiers, detail: 1 }))
})
expect(lastPosted('goToDefinition')).toEqual({ type: 'goToDefinition', version: 2, offset: at(SECOND, 'request').offset })
})
it('单击名字:问各处出现的下标是新一版里的;回答的各处画在对应的名字上,点中的是当前那一处,键盘跳到下一处', async () => {
await opened()
const helpers = names('helper')
expect(helpers).toHaveLength(3)
await act(async () => helpers[1].dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })))
const asked = lastPosted('findOccurrences')
expect(asked).toMatchObject({ version: 2, offset: at(SECOND, 'helper', 1).offset })
const occurrences = [0, 1, 2].map(nth => ({ start: at(SECOND, 'helper', nth).offset, end: at(SECOND, 'helper', nth).offset + 6, kind: 'read' as const }))
await deliver({ type: 'occurrencesFound', id: asked!.id, occurrences })
expect([...container.querySelectorAll<HTMLElement>('.cr-block [data-occurrence]')]).toEqual(names('helper'))
expect(container.querySelector('[data-current]')).toBe(names('helper')[1])
await deliver({ type: 'moveOccurrence', step: 1 })
await settle()
expect(container.querySelector('[data-current]')).toBe(names('helper')[2])
})
it('跳到定义:闪的是定义处那个名字,不是旁边的同名名字', async () => {
await opened()
const target = at(SECOND, 'helper(total)')
// 闪完(按动画时长)就摘掉,记下闪过哪几处
const flashed: Node[] = []
const observer = new MutationObserver(records => flashed.push(...records.filter(record => (record.target as Element).hasAttribute('data-flash')).map(record => record.target)))
observer.observe(container, { subtree: true, attributes: true, attributeFilter: ['data-flash'] })
await deliver({ type: 'reveal', line: target.line, definition: target.offset })
await settle()
observer.disconnect()
expect(flashed).toEqual([names('helper')[2]])
})
it('编辑框:里面是这块在新一版里的源码,改了发回的行号是这块在新一版里的行', async () => {
const first = await read(FIRST, 1)
const handler = first.blocks.find(block => block.callTree && block.defines && FIRST.slice(block.defines.startIndex, block.defines.endIndex) === 'handler')!
const branch = handler.children.find(block => block.kind === 'if')!
await opened([branch.id])
const textarea = container.querySelector<HTMLTextAreaElement>(`[data-id="${branch.id}"] textarea`)!
expect(textarea.value).toBe('if total > 3:\n return helper(total)')
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, `${textarea.value}\n`)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
})
await settle(260)
expect(lastPosted('edit')).toEqual({
type: 'edit',
startLine: at(SECOND, 'if total').line,
endLine: at(SECOND, 'return helper(total)').line,
text: 'if total > 3:\n return helper(total)\n',
})
})
it('钉住的卡片头:标题照新一版的下标换中文名,悬停标题里的名字问的也是新一版的下标', async () => {
const first = await read(FIRST, 1)
const handler = first.blocks.find(block => block.defines && FIRST.slice(block.defines.startIndex, block.defines.endIndex) === 'handler')!
vi.spyOn(pageLayout, 'stickyScopes').mockReturnValue([{ id: handler.id, depth: 0, top: -10, bottom: 400, left: 0, right: 600 }])
await opened()
await deliver({ type: 'stickyScroll', enabled: true, maxLineCount: 5 })
await settle(50)
const name = at(SECOND, 'handler')
await deliver({ type: 'displayNames', version: 2, names: [{ offset: name.offset, length: 7, name: '处理' }] })
const pinned = container.querySelector<HTMLElement>('.cr-sticky [data-name="处理"]')
expect(pinned).not.toBeNull()
await act(async () => pinned!.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })))
await settle()
expect(lastPosted('peekDefinition')).toMatchObject({ version: 2, offset: name.offset })
})
})