✅ test(vscode): 代码阅读预览按源码位置干活的几处补上测试:悬停弹定义卡片、⌘ 点击跳定义、单击高亮各处出现和键盘跳下一处、跳到定义后闪的名字、编辑框发回的行号、钉住的卡片头,文档上面多了几行时报的都得是新位置
This commit is contained in:
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 })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user