import type { Click, Highlight } from './occurrences' import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types' import { describe, expect, it, vi } 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, occurrenceJumpKey, OccurrenceNavigation, occurrenceStyle, segmentOccurrence, sourceOf, 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 { 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), 0, occurrences)).toBe('normal') expect(segmentOccurrence(name('x', 2), 0, occurrences)).toBeUndefined() expect(segmentOccurrence(name('ab', 8), 0, occurrences)).toBeUndefined() expect(segmentOccurrence(name('ab', 18), 0, occurrences)).toBeUndefined() expect(segmentOccurrence(name('ab', 17), 0, occurrences)).toBe('normal') expect(segmentOccurrence({ text: '常量', role: 'storage' }, 0, occurrences)).toBeUndefined() // 整段字符串比名字长:出现落在字符串里面也算 expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, 0, occurrences)).toBe('normal') }) it('出现很多、乱序、有长有短还互相重叠时,结果和逐处比一样', () => { const brute = (segment: Segment, occurrences: readonly Occurrence[]): string | undefined => { const hits = occurrences.filter(occurrence => occurrence.start < segment.offset! + segment.text.length && segment.offset! < occurrence.end) return hits.length === 0 ? undefined : hits.some(hit => hit.kind === 'write') ? 'strong' : 'normal' } let seed = 7 const random = (n: number): number => { seed = (seed * 1103515245 + 12345) % 2147483648 return seed % n } const kinds: Occurrence['kind'][] = ['read', 'write', 'text'] const occurrences = Array.from({ length: 300 }, () => { const start = random(2000) return at(start, start + 1 + random(12), kinds[random(3)]) }) for (let offset = 0; offset < 2020; offset += 3) { const segment = name('x'.repeat(1 + (offset % 7)), offset) expect(segmentOccurrence(segment, 0, occurrences), `offset ${offset}`).toBe(brute(segment, occurrences)) } }) it('写入用强高亮色,读取和按文字认出的用普通高亮色;同一段碰上两种按写入画', () => { expect(occurrenceStyle('write')).toBe('strong') expect(occurrenceStyle('read')).toBe('normal') expect(occurrenceStyle('text')).toBe('normal') expect(segmentOccurrence(name('a', 0), 0, [at(0, 1, 'text')])).toBe('normal') expect(segmentOccurrence(name('a', 0), 0, [at(0, 1, 'read'), at(0, 1, 'write')])).toBe('strong') }) it('讲别处名字的段(refers,写在字符串里的参数名)按那个名字算:那个名字的出现盖住它就画、能是当前那一处,自己照抄的位置不算', () => { const param: Segment = { text: 'user_id', role: 'variable', offset: 40, refers: { startIndex: 100, endIndex: 107 } } expect(sourceOf(param, 0)).toEqual({ start: 100, end: 107 }) expect(segmentOccurrence(param, 0, [at(100, 107, 'write')])).toBe('strong') expect(segmentOccurrence(param, 0, [at(40, 47)])).toBeUndefined() expect(isCurrent(param, 0, { name: 'user_id', occurrences: [at(100, 107)], current: 0 })).toBe(true) expect(sourceOf(name('x', 3), 0)).toEqual({ start: 3, end: 4 }) expect(sourceOf({ text: '常量', role: 'storage' }, 0)).toBeUndefined() }) it('段里的下标从所在那块的开头算:加上那块的开头(base)才是文件里的,和各处出现比、认当前那一处都按文件里的', () => { const param: Segment = { text: 'user_id', role: 'variable', offset: 4, refers: { startIndex: 10, endIndex: 17 } } expect(sourceOf(param, 1000)).toEqual({ start: 1010, end: 1017 }) expect(sourceOf(name('x', 3), 1000)).toEqual({ start: 1003, end: 1004 }) expect(segmentOccurrence(name('x', 3), 1000, [at(1003, 1004, 'write')])).toBe('strong') expect(segmentOccurrence(name('x', 3), 1000, [at(3, 4, 'write')])).toBeUndefined() expect(isCurrent(param, 1000, { name: 'user_id', occurrences: [at(1010, 1017)], current: 0 })).toBe(true) expect(isCurrent(param, 0, { name: 'user_id', occurrences: [at(1010, 1017)], current: 0 })).toBe(false) }) 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, 0, 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), 0, highlight)).toBe(true) expect(isCurrent(name('ALPHABET', 10), 0, highlight)).toBe(false) expect(isCurrent({ text: 'ALPHABET', role: 'plain' }, 0, highlight)).toBe(false) expect(isCurrent(name('ALPHABET', 40), 0, { ...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(1020, () => ({ x: 0, y: 300 }), false)).toEqual({ x: 0, y: 40 }) scroll.hold({ x: 0, y: 900 }) expect(scroll.scrolled(1030, () => ({ x: 0, y: 900 }), false)).toBeUndefined() expect(scroll.scrolled(1040, () => ({ x: 0, y: 1200 }), false)).toEqual({ x: 0, y: 900 }) expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 1, () => ({ x: 0, y: 1200 }), false)).toBeUndefined() expect(scroll.scrolled(1000 + KEY_SCROLL_MS + 2, () => ({ x: 0, y: 1300 }), false)).toBeUndefined() }) it('不是按键触发的跳转、又按了键、点了鼠标:不守', () => { const scroll = new KeyScroll() scroll.take(10) scroll.hold({ x: 0, y: 900 }) expect(scroll.scrolled(20, () => ({ x: 0, y: 0 }), false)).toBeUndefined() scroll.press({ x: 0, y: 40 }, 1000) scroll.take(1010) scroll.press({ x: 0, y: 40 }, 1100) expect(scroll.scrolled(1110, () => ({ x: 0, y: 300 }), false)).toBeUndefined() scroll.take(1120) scroll.forget() expect(scroll.scrolled(1130, () => ({ x: 0, y: 300 }), false)).toBeUndefined() }) it('不守着时滚动不读滚动位置(读它会逼浏览器先排版)', () => { const scroll = new KeyScroll() const read = vi.fn(() => ({ x: 0, y: 300 })) expect(scroll.scrolled(10, read, false)).toBeUndefined() scroll.press({ x: 0, y: 40 }, 1000) expect(scroll.scrolled(1005, read, false)).toBeUndefined() expect(read).not.toHaveBeenCalled() scroll.take(1010) expect(scroll.scrolled(1020, read, false)).toEqual({ x: 0, y: 40 }) expect(read).toHaveBeenCalledTimes(1) }) it('我们自己滚的(滚动所有者认出来的)不算浏览器滚走,改守滚到的位置;之后真被滚走拉回到那里', () => { const scroll = new KeyScroll() scroll.press({ x: 0, y: 40 }, 1000) scroll.take(1012) // 滚进视口落到 800,跳完守它;页面长高时滚动所有者自己滚到 1320 expect(scroll.scrolled(1020, () => ({ x: 0, y: 800 }), true)).toBeUndefined() scroll.hold({ x: 0, y: 800 }) expect(scroll.scrolled(1030, () => ({ x: 0, y: 1320 }), true)).toBeUndefined() expect(scroll.scrolled(1040, () => ({ x: 0, y: 300 }), false)).toEqual({ x: 0, y: 1320 }) }) it('默认快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl;带了别的修饰键不算', () => { const key = (name: string, mod: Partial<{ metaKey: boolean, ctrlKey: boolean, altKey: boolean, shiftKey: boolean }> = {}) => ({ key: name, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mod }) expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true }), true)).toBe(true) expect(occurrenceJumpKey(key('ArrowUp', { metaKey: true }), true)).toBe(true) expect(occurrenceJumpKey(key('ArrowDown', { ctrlKey: true }), true)).toBe(false) expect(occurrenceJumpKey(key('ArrowDown', { ctrlKey: true }), false)).toBe(true) expect(occurrenceJumpKey(key('ArrowUp', { ctrlKey: true }), false)).toBe(true) expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true }), false)).toBe(false) expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true, shiftKey: true }), true)).toBe(false) expect(occurrenceJumpKey(key('ArrowDown', { metaKey: true, altKey: true }), true)).toBe(false) expect(occurrenceJumpKey(key('ArrowLeft', { metaKey: true }), true)).toBe(false) }) })