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

353 lines
17 KiB
TypeScript

import type { Click, Highlight } from './occurrences'
import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { DefinitionLinks } from './definitionLinks'
import { clickAction, isCurrent, KEY_SCROLL_MS, KEY_TO_COMMAND_MS, KeyScroll, occurrenceCount, OccurrenceHighlight, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, stepIndex } from './occurrences'
function name(text: string, offset: number): Segment {
return { text, role: 'variable', offset }
}
function at(start: number, end: number, kind: Occurrence['kind'] = 'read'): Occurrence {
return { start, end, kind }
}
function click(overrides: Partial<Click> = {}): Click {
return { modified: false, count: 1, selecting: false, control: false, ...overrides }
}
function setup(version = 1, translatorId = 'zh'): { occ: OccurrenceHighlight, asked: () => { id: number, version: number, offset: number }[], changes: (Highlight | undefined)[] } {
const sent: WebviewToExtension[] = []
const changes: (Highlight | undefined)[] = []
const occ = new OccurrenceHighlight(msg => sent.push(msg), highlight => changes.push(highlight))
occ.setDocument(version, translatorId)
const asked = (): { id: number, version: number, offset: number }[] => sent.flatMap(msg => msg.type === 'findOccurrences' ? [{ id: msg.id, version: msg.version, offset: msg.offset }] : [])
return { occ, asked, changes }
}
function find(occ: OccurrenceHighlight, offset: number, text: string): void {
occ.click(clickAction(click({ name: { offset, text } })))
}
describe('occurrences:哪些段画高亮', () => {
it('段在源码里的范围和一处出现重叠就画,边界相接不算,没有下标的不画', () => {
const occurrences = [at(10, 18)]
expect(segmentOccurrence(name('ALPHABET', 10), occurrences)).toBe('normal')
expect(segmentOccurrence(name('x', 2), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 8), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 18), occurrences)).toBeUndefined()
expect(segmentOccurrence(name('ab', 17), occurrences)).toBe('normal')
expect(segmentOccurrence({ text: '常量', role: 'storage' }, occurrences)).toBeUndefined()
// 整段字符串比名字长:出现落在字符串里面也算
expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, occurrences)).toBe('normal')
})
it('写入用强高亮色,读取和按文字认出的用普通高亮色;同一段碰上两种按写入画', () => {
expect(occurrenceStyle('write')).toBe('strong')
expect(occurrenceStyle('read')).toBe('normal')
expect(occurrenceStyle('text')).toBe('normal')
expect(segmentOccurrence(name('a', 0), [at(0, 1, 'text')])).toBe('normal')
expect(segmentOccurrence(name('a', 0), [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong')
})
it('真实的 Python 译文和属性表:定义处按写入、用到的地方按读取画;点分模块名每一节各按自己的范围认', async () => {
const source = [
'import luffy_agent.core.db',
'import secrets',
'',
'ALPHABET = "abcdefgh"',
'',
'def token():',
' return "".join(secrets.choice(ALPHABET) for _ in range(8))',
].join('\n')
const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => {
const style = segmentOccurrence(segment, occurrences)
return style ? [`${segment.text}:${style}`] : []
})
const range = (needle: string, kind: Occurrence['kind'], from = 0): Occurrence => {
const start = source.indexOf(needle, from)
expect(start, needle).toBeGreaterThanOrEqual(0)
return at(start, start + needle.length, kind)
}
const definition = range('ALPHABET', 'write')
expect(highlighted([definition, range('ALPHABET', 'read', definition.end)])).toEqual(['ALPHABET:strong', 'ALPHABET:normal'])
expect(highlighted([range('core', 'read')])).toEqual(['core:normal'])
expect(highlighted([range('luffy_agent.core.db', 'read')])).toEqual(['luffy_agent:normal', 'core:normal', 'db:normal'])
})
})
describe('occurrences:点击', () => {
it('单击名字问它的各处出现,名字在折叠箭头这类按钮里也算;点空白处清掉,点在按钮、链接、筛选栏上不动', () => {
expect(clickAction(click({ name: { offset: 3, text: 'x' } }))).toEqual({ kind: 'find', offset: 3, name: 'x' })
expect(clickAction(click({ name: { offset: 3, text: 'x' }, control: true }))).toEqual({ kind: 'find', offset: 3, name: 'x' })
expect(clickAction(click())).toEqual({ kind: 'clear' })
expect(clickAction(click({ control: true }))).toEqual({ kind: 'ignore' })
})
it('拖着选字、双击、带修饰键的点击都不动高亮', () => {
const onName = { name: { offset: 3, text: 'x' } }
expect(clickAction(click({ ...onName, selecting: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ selecting: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ ...onName, count: 2 }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ count: 2 }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ ...onName, modified: true }))).toEqual({ kind: 'ignore' })
expect(clickAction(click({ modified: true }))).toEqual({ kind: 'ignore' })
})
it('⌘ / Ctrl + 点击名字照旧跳到定义,不问出现处', () => {
const sent: WebviewToExtension[] = []
const links = new DefinitionLinks(msg => sent.push(msg))
links.setVersion(1)
const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {})
occ.setDocument(1, 'zh')
expect(links.click(42)).toBe(true)
occ.click(clickAction(click({ modified: true, name: { offset: 42, text: 'x' } })))
expect(sent).toEqual([{ type: 'goToDefinition', version: 1, offset: 42 }])
})
})
describe('occurrences:高亮的状态', () => {
it('问到的各处出现连同点中的名字一起显示;回答是空的不高亮', () => {
const { occ, asked, changes } = setup()
find(occ, 20, 'ALPHABET')
expect(asked()).toEqual([{ id: asked()[0].id, version: 1, offset: 20 }])
const occurrences = [at(20, 28, 'write'), at(60, 68)]
occ.answer(asked()[0].id, occurrences)
expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences, current: 0 })
expect(changes).toEqual([{ name: 'ALPHABET', occurrences, current: 0 }])
find(occ, 5, 'secrets')
occ.answer(asked()[1].id, [])
expect(occ.highlight).toBeUndefined()
})
it('点另一个名字:回答到之前旧高亮留着,到了就换成新的', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
find(occ, 5, 'secrets')
expect(occ.highlight?.name).toBe('ALPHABET')
occ.answer(asked()[1].id, [at(5, 12), at(40, 47)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12), at(40, 47)], current: 0 })
})
it('只认最近一次询问的回答,迟到的丢掉', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
find(occ, 5, 'secrets')
const [first, second] = asked()
occ.answer(second.id, [at(5, 12)])
occ.answer(first.id, [at(20, 28)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 })
occ.answer(second.id, [at(99, 100)])
expect(occ.highlight).toEqual({ name: 'secrets', occurrences: [at(5, 12)], current: 0 })
})
it('回答不留到下次用:答过「没有」后再点同一个名字照样再问', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [])
find(occ, 20, 'ALPHABET')
expect(asked()).toHaveLength(2)
occ.answer(asked()[1].id, [at(20, 28)])
expect(occ.highlight?.name).toBe('ALPHABET')
})
it('点空白处、Esc 清掉高亮,清掉后才到的回答也丢掉', () => {
const { occ, asked } = setup()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
occ.click(clickAction(click()))
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[1].id, [at(20, 28)])
find(occ, 5, 'secrets')
occ.clear()
occ.answer(asked()[2].id, [at(5, 12)])
expect(occ.highlight).toBeUndefined()
})
it('换了文档版本或翻译就清掉,同一版本同一翻译重发不清;换版本前问的回答丢掉', () => {
const { occ, asked } = setup(1, 'zh')
find(occ, 20, 'ALPHABET')
occ.answer(asked()[0].id, [at(20, 28)])
occ.setDocument(1, 'zh')
expect(occ.highlight?.name).toBe('ALPHABET')
occ.setDocument(1, 'ai')
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
occ.answer(asked()[1].id, [at(20, 28)])
find(occ, 5, 'secrets')
occ.setDocument(2, 'ai')
expect(occ.highlight).toBeUndefined()
occ.answer(asked()[2].id, [at(5, 12)])
expect(occ.highlight).toBeUndefined()
find(occ, 20, 'ALPHABET')
expect(asked()[3].version).toBe(2)
})
it('还没有文档时点名字不问', () => {
const sent: WebviewToExtension[] = []
const occ = new OccurrenceHighlight(msg => sent.push(msg), () => {})
find(occ, 20, 'ALPHABET')
expect(sent).toEqual([])
})
})
/** 框拆开,框里的词接在前后的词中间:SegmentText 同样一段段画框里的词。 */
function inlineSegments(pieces: Inline[]): Segment[] {
return pieces.flatMap(piece => 'role' in piece ? [piece] : inlineSegments(piece.segments))
}
function textSegments(text: PreviewText): Segment[] {
return 'markdown' in text ? [] : inlineSegments(text)
}
function itemSegments(items: PropertyItem[] | undefined): Segment[] {
return (items ?? []).flatMap(item => [...inlineSegments(item.cells.flat()), ...itemSegments(item.items)])
}
/** 预览里画出来的所有段,按页面上从上到下的顺序:每块的属性表或译文,再是它的子块。 */
function allSegments(views: BlockView[]): Segment[] {
return views.flatMap(view => [
...('properties' in view
? view.properties.flatMap(row => [...textSegments(row.value), ...itemSegments(row.items)])
: textSegments(view.translation)),
...allSegments(view.children),
])
}
const ANYWHERE = (): boolean => true
describe('occurrences:用键盘在各处出现之间跳', () => {
it('回答照源码先后排好,点中的那处是当前那一处;筛选栏写「第几处/共几处」,点中的不在回答里时写「N 处」', () => {
const { occ, asked } = setup()
find(occ, 40, 'ALPHABET')
occ.answer(asked()[0].id, [at(90, 98), at(40, 48), at(10, 18, 'write')])
expect(occ.highlight).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18, 'write'), at(40, 48), at(90, 98)], current: 1 })
expect(occurrenceCount(occ.highlight!)).toBe('2/3')
find(occ, 60, 'ALPHABET')
occ.answer(asked()[1].id, [at(10, 18), at(40, 48), at(90, 98)])
expect(occ.highlight?.current).toBeUndefined()
expect(occurrenceCount(occ.highlight!)).toBe('3 处')
})
it('往后、往前按源码先后跳,到头了从另一头接着跳;当前那一处跟着换', () => {
const { occ, asked, changes } = setup()
find(occ, 40, 'ALPHABET')
occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)])
expect(occ.move(1, ANYWHERE)).toEqual(at(90, 98))
expect(occ.highlight?.current).toBe(2)
expect(occ.move(1, ANYWHERE)).toEqual(at(10, 18))
expect(occ.highlight?.current).toBe(0)
expect(occ.move(-1, ANYWHERE)).toEqual(at(90, 98))
expect(occ.move(-1, ANYWHERE)).toEqual(at(40, 48))
expect(occurrenceCount(occ.highlight!)).toBe('2/3')
expect(changes.at(-1)).toEqual({ name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48), at(90, 98)], current: 1 })
})
it('页面上找不到的(在被筛掉的块里)跳过,总数照旧;一处都找不到、没有高亮时不动', () => {
const { occ, asked, changes } = setup()
expect(occ.move(1, ANYWHERE)).toBeUndefined()
find(occ, 10, 'ALPHABET')
occ.answer(asked()[0].id, [at(10, 18), at(40, 48), at(90, 98)])
const shown = (occurrence: Occurrence): boolean => occurrence.start !== 40
expect(occ.move(1, shown)).toEqual(at(90, 98))
expect(occ.move(1, shown)).toEqual(at(10, 18))
expect(occ.move(-1, shown)).toEqual(at(90, 98))
expect(occurrenceCount(occ.highlight!)).toBe('3/3')
const before = changes.length
expect(occ.move(1, () => false)).toBeUndefined()
expect(changes).toHaveLength(before)
expect(occ.highlight?.current).toBe(2)
})
it('点中的名字不在回答里:从点中的地方往后是它后面第一处,往前是它前面最后一处', () => {
const occurrences = [at(10, 18), at(40, 48), at(90, 98)]
expect(stepIndex(occurrences, undefined, 60, 1, ANYWHERE)).toBe(2)
expect(stepIndex(occurrences, undefined, 60, -1, ANYWHERE)).toBe(1)
expect(stepIndex(occurrences, undefined, 5, -1, ANYWHERE)).toBe(2)
expect(stepIndex(occurrences, undefined, 95, 1, ANYWHERE)).toBe(0)
expect(stepIndex(occurrences, 0, 0, -1, ANYWHERE)).toBe(2)
expect(stepIndex(occurrences, 1, 0, 1, occurrence => occurrence.start === 40)).toBe(1)
expect(stepIndex([], undefined, 0, 1, ANYWHERE)).toBeUndefined()
})
it('当前那一处:和它重叠的段描边,别的出现、没下标的段不描', () => {
const highlight = { name: 'ALPHABET', occurrences: [at(10, 18), at(40, 48)], current: 1 }
expect(isCurrent(name('ALPHABET', 40), highlight)).toBe(true)
expect(isCurrent(name('ALPHABET', 10), highlight)).toBe(false)
expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, highlight)).toBe(false)
expect(isCurrent(name('ALPHABET', 40), { ...highlight, current: undefined })).toBe(false)
})
it('能不能用键盘跳:有高亮、页面有焦点、焦点不在输入框里;第一次都告诉扩展端,之后变了才告诉', () => {
const sent: WebviewToExtension[] = []
const navigation = new OccurrenceNavigation(msg => sent.push(msg), { focused: true, typing: false })
const reports = (): boolean[] => sent.flatMap(msg => msg.type === 'occurrenceNavigation' ? [msg.available] : [])
navigation.update({})
expect(reports()).toEqual([false])
navigation.update({ highlighted: true })
expect(navigation.available).toBe(true)
navigation.update({ highlighted: true })
navigation.update({ typing: true })
navigation.update({ typing: false })
navigation.update({ focused: false })
navigation.update({ highlighted: false })
navigation.update({ focused: true })
expect(reports()).toEqual([false, true, false, true, false])
})
it('不拦任何键:跳转发来时回到刚按键那一刻的位置(只回一次),隔太久、点过鼠标、没按过键(命令面板)不回', () => {
const scroll = new KeyScroll()
expect(scroll.take(100)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
expect(scroll.take(1012)).toEqual({ x: 0, y: 40 })
expect(scroll.take(1013)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 2000)
expect(scroll.take(2000 + KEY_TO_COMMAND_MS + 1)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 3000)
scroll.forget()
expect(scroll.take(3010)).toBeUndefined()
})
it('跳完守住跳到的位置:浏览器对那个键自己的滚动晚于命令才开始,按键后 KEY_SCROLL_MS 内滚走了就拉回来', () => {
const scroll = new KeyScroll()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1012)
// 刚回到按键那一刻、还没跳完:守住按键那一刻的位置
expect(scroll.scrolled({ x: 0, y: 300 }, 1020)).toEqual({ x: 0, y: 40 })
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 900 }, 1030)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1200 }, 1040)).toEqual({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 1200 }, 1000 + KEY_SCROLL_MS + 1)).toBeUndefined()
expect(scroll.scrolled({ x: 0, y: 1300 }, 1000 + KEY_SCROLL_MS + 2)).toBeUndefined()
})
it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => {
const scroll = new KeyScroll()
scroll.take(10)
scroll.hold({ x: 0, y: 900 })
expect(scroll.scrolled({ x: 0, y: 0 }, 20)).toBeUndefined()
scroll.press({ x: 0, y: 40 }, 1000)
scroll.take(1010)
scroll.press({ x: 0, y: 40 }, 1100)
expect(scroll.scrolled({ x: 0, y: 300 }, 1110)).toBeUndefined()
scroll.take(1120)
scroll.forget()
expect(scroll.scrolled({ x: 0, y: 300 }, 1130)).toBeUndefined()
})
})