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 { 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() }) })