diff --git a/vscode/webview-ui/src/codeReader/positions.test.tsx b/vscode/webview-ui/src/codeReader/positions.test.tsx new file mode 100644 index 0000000..ed2786d --- /dev/null +++ b/vscode/webview-ui/src/codeReader/positions.test.tsx @@ -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) => 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) +registry.registerLanguage(createPythonAdapter(WASM_DIR)) +registry.registerTranslator(pythonZhTranslator) + +async function read(source: string, version: number): Promise>['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 { + return { ...await read(source, version), names: [] } as ExtensionToWebview +} + +/** 扩展端发给页面的定义卡片:source 里 needle 开头那个名字所在的块,名字显示成 name */ +async function peekOf(source: string, needle: string, name: string): Promise['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 { + 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 deliver(msg: ExtensionToWebview): Promise { + await act(async () => { + for (const listener of host.listeners) + listener(msg) + }) +} + +/** 等计时器、动画帧跑完 */ +async function settle(ms = 30): Promise { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, ms)) + }) +} + +/** 页面上(不算钉住的那几行)显示着 text 的名字,按页面先后 */ +function names(text: string): HTMLElement[] { + return [...container.querySelectorAll('[data-offset]')].filter(el => el.textContent === text && !el.closest('.cr-sticky')) +} + +function lastPosted(type: T): Extract | undefined { + return [...host.posted].reverse().find((msg): msg is Extract => msg.type === type) +} + +/** 两版文档都发过、handler 展开着(看得见它的子块)的页面;editing 是一开始就停在编辑的块 */ +async function opened(editing: string[] = []): Promise { + 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('.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(`[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('.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 }) + }) +})