Files
superwork/vscode/webview-ui/src/codeReader/reveal.test.ts
T

402 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
import type { RevealPlan } from './reveal'
import type { BlockKind, BlockView, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript'
import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
}
function word(text: string): Segment {
return { text, role: 'keyword' }
}
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
}
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties }
}
/**
* 0 from luffy_agent.core.db import get_db
* 2 def load(path, user):
* 3 if not user:
* 4 return get_db(path)
* 8 LIMIT = 3
*/
const BLOCKS: BlockView[] = [
factBlock('0', 'import', 0, [
{ label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] },
{ label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] },
]),
factBlock('1', 'function', 2, [
{ label: '名称', value: [{ text: 'load', role: 'function', offset: 46 }] },
{ label: '异步', value: [{ text: '否', role: 'storage' }] },
{ label: '输入参数', value: [{ text: '2 个', role: 'hint' }], items: [{ cells: [[name('path', 51)], []] }, { cells: [[name('user', 57)], []] }] },
], [
textBlock('1.0', 'if', 3, [word('如果'), word('非'), name('user', 78)], [
textBlock('1.0.0', 'return', 4, [word('返回'), name('get_db', 102), name('path', 109)]),
]),
]),
factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]),
]
describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', () => {
const nowhere = (): boolean => false
const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' })
it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
expect(occurrencePlan(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => {
expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] })
expect(occurrencePlan(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] })
expect(occurrencePlan(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] })
})
it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => {
expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] })
expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined()
})
it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => {
const hidden = new Set(['1.0', '1.0.0'])
expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined()
expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined()
expect(occurrencePlan(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => {
const blocks: BlockView[] = [
factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [
textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]),
]),
factBlock('1', 'class', 2, [{ label: '字段', value: [{ text: '1 个', role: 'hint' }], columns: ['字段'], items: [{ cells: [[name('get_db', 30)]] }] }]),
]
expect(occurrencePlan(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] })
expect(occurrencePlan(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] })
})
})
describe('跳到定义:滚到哪块、展开哪些', () => {
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
const blocks: BlockView[] = [
textBlock('0', 'return', 0, [word('返回'), { style: 'iteration', label: '列表', segments: [word('遍历'), name('grid', 20), word('取'), inner] }]),
]
expect(revealPlan(blocks, 5, 40)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] })
})
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
factBlock('1', 'function', 1, [
{ label: '说明', value: { markdown: '同 `get_db`' } },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] },
]),
]
expect(revealPlan(blocks, 0, 30)).toEqual({ id: '1', expand: ['1/p1'] })
})
it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => {
expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] })
expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] })
})
it('名字在属性行的值里:不用展开属性行', () => {
expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] })
expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] })
})
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] })
expect(revealPlan(BLOCKS, 6, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
})
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
const plan = revealPlan(BLOCKS, 4, 102)
const hidden = (folds: Record<string, boolean>): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? []
expect(hidden({})).toEqual(['1'])
expect(hidden({ 1: false })).toEqual([])
expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0'])
})
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
expect(revealPlan([], 0, 0)).toBeUndefined()
})
})
describe('跳到定义:闪哪一处(defines)', () => {
const range = (startIndex: number, endIndex: number): { startIndex: number, endIndex: number } => ({ startIndex, endIndex })
/**
* 0 def f(*args): f 在 4,*args 在 6..11
* 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27
*/
const blocks: BlockView[] = [
{
...factBlock('0', 'function', 0, [
{ label: '名称', value: [name('f', 4)], defines: range(4, 5) },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('*args', 6)]], defines: range(6, 11) }] },
]),
defines: range(4, 5),
},
factBlock('1', 'variable', 1, [
{ label: '名称', value: [name('a', 20)], defines: range(20, 21) },
{ label: '名称', value: [name('b', 27)], defines: range(27, 28) },
]),
]
it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => {
expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] })
})
it('defines 是一段范围:定义落在中间也算(*args 的定义在 args 上)', () => {
expect(revealPlan(blocks, 0, 7)).toEqual({ id: '0', row: '0/p1/0', expand: ['0/p1'] })
})
it('一条语句声明几个变量:只闪那个变量的名称行', () => {
expect(revealPlan(blocks, 1, 27)).toEqual({ id: '1', row: '1/p1', expand: [] })
})
})
describe('跳到定义:TS / Python 源码里闪哪一处', () => {
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
/** 不填 defines 的第三方翻译插件:属性表照旧,行和子行都不标讲的是哪个定义。 */
function withoutDefines(translator: Translator): Translator {
const strip = (items: PropertyItem[]): PropertyItem[] => items.map(({ cells, items: nested }) => nested ? { cells, items: strip(nested) } : { cells })
return {
...translator,
describe: (block, source) => translator.describe(block, source).map(({ label, value, items }) =>
items ? { label, value, items: strip(items) } : { label, value }),
}
}
/** parentId 下面一层层的子行里 id 是 target 的那个。 */
function findItem(items: PropertyItem[] | undefined, parentId: string, target: string): PropertyItem | undefined {
for (const [index, item] of (items ?? []).entries()) {
const id = propertyItemId(parentId, index)
const found = id === target ? item : findItem(item.items, id, target)
if (found)
return found
}
return undefined
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
function text(segments: Segment[]): string {
return segments.map(segment => segment.text).join('')
}
/** 闪的那一处:整块写成它的第一行源码,属性行写成「属性名: 值」,子行各列用 | 隔开。 */
function flashed(views: BlockView[], plan: RevealPlan | undefined): string {
const block = flatten(views).find(view => view.id === plan?.id)
if (!block || !plan)
return ''
if (plan.row !== undefined && 'properties' in block) {
for (const [index, row] of block.properties.entries()) {
const rowId = propertyRowId(block.id, index)
if (plan.row === rowId)
return `${row.label}: ${text(row.value)}`
const item = findItem(row.items, rowId, plan.row)
if (item)
return item.cells.map(text).join(' | ')
}
}
return block.code.split('\n')[0]
}
/** 定义提供者说定义在 source 里 needle 开头那个名字上:预览滚到哪、展开哪些、闪哪一处。 */
async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> {
const offset = source.indexOf(needle)
expect(offset, needle).toBeGreaterThanOrEqual(0)
const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator))
const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset)
return { plan, flashed: flashed(views, plan) }
}
const PY = [
'from app.util import fmt, clean as tidy',
'',
'def mask_phone(phone: str) -> str:',
' if len(phone) < 9:',
' return phone',
' for i in range(3):',
' print(i)',
' return fmt(phone)',
'',
'LIMIT = 3',
'a, b = 1, 2',
].join('\n')
const TS = [
'import { readFile as rf, stat } from \'node:fs\'',
'',
'export function load(path: string, { encoding }: Options): string {',
' for (const line of lines) {',
' use(line, stat)',
' }',
' return rf(path, encoding)',
'}',
'',
'let a = 1, b = 2',
'const LIMIT = 3',
].join('\n')
it('python:参数只闪「输入参数」下它那个子行,导入项只闪它那一项', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, PY, needle)
expect(await at('phone: str')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'phone | 字符串 | ' })
expect(await at('tidy')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' })
expect(await at('clean')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' })
})
it('python:函数名、变量名(一个解构模式也算一个)是块自己的名字,循环变量在一行译文里:都闪整块', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, PY, needle)
expect(await at('mask_phone')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'def mask_phone(phone: str) -> str:' })
expect(await at('LIMIT')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'LIMIT = 3' })
expect(await at('b = 1')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'a, b = 1, 2' })
expect(await at('i in range')).toEqual({ plan: { id: '1.1', expand: ['1'] }, flashed: 'for i in range(3):' })
})
it('python:推导式的变量只在框里显示:闪框所在的那块', async () => {
const source = ['def ids(users):', ' return [u.id for u in users]'].join('\n')
expect(await jump(py, 'python', pythonZhTranslator, source, 'u in users')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'return [u.id for u in users]' })
})
it('python:翻译插件没标 defines 时闪整块,名字在子行里照样展开那一行', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(py, 'python', withoutDefines(pythonZhTranslator), PY, needle)
expect(await at('phone: str')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'def mask_phone(phone: str) -> str:' })
expect(await at('tidy')).toEqual({ plan: { id: '0', expand: ['0/p1'] }, flashed: 'from app.util import fmt, clean as tidy' })
})
it('typeScript:参数(解构参数里的名字也算)、导入项、多变量声明里的一个,只闪它那一行', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', typescriptZhTranslator, TS, needle)
expect(await at('path: string')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'path | 字符串 | ' })
expect(await at('encoding }')).toEqual({ plan: { id: '1', row: '1/p3/1', expand: ['1/p3'] }, flashed: '{ encoding } | Options | ' })
expect(await at('rf,')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'readFile 作为 rf' })
expect(await at('stat }')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'stat' })
expect(await at('b = 2')).toEqual({ plan: { id: '2', row: '2/p3', expand: [] }, flashed: '名称: b' })
})
it('typeScript:函数名、只声明一个的常量是块自己的名字,循环变量在一行译文里:都闪整块', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', typescriptZhTranslator, TS, needle)
expect(await at('load')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'export function load(path: string, { encoding }: Options): string {' })
expect(await at('LIMIT')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'const LIMIT = 3' })
expect(await at('line of')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'for (const line of lines) {' })
})
it('类:类名是块自己的名字闪整块,泛型参数只闪「类型参数」一行;类默认折叠,跳到类里的方法、参数要先展开它', async () => {
const source = [
'export class Repo<T> extends Base {',
' load(id: T) {',
' return id',
' }',
'}',
].join('\n')
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', typescriptZhTranslator, source, needle)
expect(await at('Repo')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'export class Repo<T> extends Base {' })
expect(await at('T>')).toEqual({ plan: { id: '0', row: '0/p3', expand: [] }, flashed: '类型参数: T' })
expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' })
expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' })
const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator))
expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0'])
const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n')
const atPy = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, pySource, needle)
expect(await atPy('NotFound')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'class NotFound(AppException):' })
expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' })
})
it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => {
const source = [
'from sqlalchemy.orm import Mapped, mapped_column',
'',
'class User(Base):',
' __tablename__ = "users"',
' name: Mapped[str] = mapped_column(comment="名字")',
'',
' def greet(self):',
' return self.name',
].join('\n')
const views = await withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} }))
const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => {
const offset = source.indexOf(needle)
const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset)
return { plan, flashed: flashed(views, plan) }
}
expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' })
expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' })
expect(at('greet')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'def greet(self):' })
expect([...collapsedIds(views, {})].sort()).toEqual(['1', '1.0'])
})
it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => {
const source = [
'export const routes = {',
' home: { path: \'/\', page: Home },',
' about,',
'}',
'use(routes.home.path)',
].join('\n')
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', typescriptZhTranslator, source, needle)
expect(await at('home:')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'home | 对象 2 项' })
expect(await at('path:')).toEqual({ plan: { id: '0', row: '0/p2/0/0', expand: ['0/p2', '0/p2/0'] }, flashed: 'path | \'/\'' })
expect(await at('about,')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'about | about' })
expect(await at('Home')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' })
const plain = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), source, needle)
expect(await plain('path:')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' })
const pySource = 'CONFIG = {"db": {"host": host}}'
const atPy = (needle: string): ReturnType<typeof jump> => jump(py, 'python', pythonZhTranslator, pySource, needle)
expect(await atPy('host}')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'CONFIG = {"db": {"host": host}}' })
})
it('typeScript:翻译插件没标 defines 时闪整块', async () => {
const at = (needle: string): ReturnType<typeof jump> => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle)
expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' })
expect(await at('b = 2')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'let a = 1, b = 2' })
})
})
describe('把目标块滚进视口', () => {
it('整块已经在视口里:不滚', () => {
expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined()
})
it('在视口外:放到视口正中', () => {
expect(scrollTopToShow({ top: 1000, bottom: 1040 }, 0, 500)).toBe(770)
expect(scrollTopToShow({ top: 80, bottom: 120 }, 400, 500)).toBe(0)
})
it('露了一半也算在视口外;比视口还高就顶部对齐', () => {
expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350)
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900)
})
})