346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import type { Root } from 'react-dom/client'
|
|
import type { UnhashedBlock } from './testBlocks'
|
|
import type { CallTree, DocumentMessage, 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 { App } from './App'
|
|
import { hashed, NO_SPAN } from './testBlocks'
|
|
import { createViewStateStore } from './viewState'
|
|
|
|
/**
|
|
* 一次次提交里哪些组件真的画了(新挂上的不算):赶在 react-dom 加载之前挂上 React 开发者工具的钩子,每次提交顺着 fiber 树数。
|
|
* 跳过重画的整棵子树(fiber 和上一次的共用子节点)不往下数。
|
|
*/
|
|
const renders = vi.hoisted(() => {
|
|
interface Fiber { type: unknown, elementType: unknown, alternate: Fiber | null, flags: number, child: Fiber | null, sibling: Fiber | null }
|
|
const counts = new Map<string, number>()
|
|
let counting = false
|
|
/** 组件叫什么:memo 包着的看它的 displayName,别的看函数名 */
|
|
const nameOf = (fiber: Fiber): string | undefined => {
|
|
const memoName = (fiber.elementType as { displayName?: string } | null)?.displayName
|
|
return memoName ?? (typeof fiber.type === 'function' ? fiber.type.name : undefined)
|
|
}
|
|
const walk = (root: { current: Fiber }): void => {
|
|
const stack = [root.current]
|
|
while (stack.length > 0) {
|
|
const fiber = stack.pop()!
|
|
const name = nameOf(fiber)
|
|
// flags 的第 1 位(PerformedWork):这次真的调用了组件函数
|
|
if (name && fiber.alternate && (fiber.flags & 1))
|
|
counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
if (fiber.alternate && fiber.child === fiber.alternate.child)
|
|
continue
|
|
for (let child = fiber.child; child; child = child.sibling)
|
|
stack.push(child)
|
|
}
|
|
}
|
|
Object.assign(globalThis, {
|
|
__REACT_DEVTOOLS_GLOBAL_HOOK__: {
|
|
supportsFiber: true,
|
|
isDisabled: false,
|
|
renderers: new Map(),
|
|
inject: () => 1,
|
|
checkDCE: () => {},
|
|
onScheduleFiberRoot: () => {},
|
|
onCommitFiberRoot: (_id: number, root: { current: Fiber }) => counting && walk(root),
|
|
onCommitFiberUnmount: () => {},
|
|
onPostCommitFiberRoot: () => {},
|
|
},
|
|
})
|
|
return {
|
|
start: () => {
|
|
counts.clear()
|
|
counting = true
|
|
},
|
|
stop: (): Record<string, number> => {
|
|
counting = false
|
|
return Object.fromEntries(counts)
|
|
},
|
|
}
|
|
})
|
|
|
|
/** 扩展端那头:页面发出去的消息、页面挂着的收消息回调,以及 webview 自己存的状态(页面重新加载时还在) */
|
|
const host = vi.hoisted(() => ({
|
|
posted: [] as unknown[],
|
|
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<ViewState>) => host.store!.update(patch),
|
|
},
|
|
}))
|
|
|
|
let container: HTMLDivElement
|
|
let root: Root
|
|
let saved: ViewState | undefined
|
|
|
|
/** 页面(重新)加载:从 webview 存的状态起步 */
|
|
async function load(): Promise<void> {
|
|
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(<App />))
|
|
}
|
|
|
|
async function unload(): Promise<void> {
|
|
await act(async () => root.unmount())
|
|
container.remove()
|
|
host.listeners.clear()
|
|
}
|
|
|
|
async function deliver(msg: ExtensionToWebview): Promise<void> {
|
|
await act(async () => {
|
|
for (const listener of host.listeners)
|
|
listener(msg)
|
|
})
|
|
}
|
|
|
|
function block(id: string, anchor: string): UnhashedBlock {
|
|
return {
|
|
id,
|
|
anchor,
|
|
kind: 'function',
|
|
callTree: {},
|
|
span: NO_SPAN,
|
|
attributes: { kind: 'function' },
|
|
concepts: [{ id: 'function', label: 'function', icon: 'braces' }],
|
|
children: [],
|
|
translation: [{ text: anchor, role: 'plain' }],
|
|
}
|
|
}
|
|
|
|
function documentOf(version: number, blocks: UnhashedBlock[]): DocumentMessage {
|
|
return { type: 'document', version, languageId: 'python', blocks: hashed(blocks), source: '', colors: { roles: [], runs: [] }, attributes: [], translators: [], translatorId: '', names: [] }
|
|
}
|
|
|
|
function tree(id: string, name: string): CallTree {
|
|
return { id, name, hits: 1, root: { name, kind: 'function', where: `${name}.py:1`, jump: { uri: `file:///${name}.py`, line: 0, character: 0 }, current: false, children: [] } }
|
|
}
|
|
|
|
const TREES = [tree('read', '读取用户资料'), tree('night', '夜间对账')]
|
|
const dialog = () => document.body.querySelector('[role="dialog"]')
|
|
const selectedTab = () => document.body.querySelector('[role="tab"][aria-selected="true"]')?.textContent?.replace(/\s+/g, '')
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
saved = undefined
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await unload()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('页面重新加载后,调用树弹框照旧开着', () => {
|
|
it('弹框开着、选了第二个入口:页面重新加载拿到文档后重新打开那块、要它的树,停回第二个入口;关掉后再加载就不开了', async () => {
|
|
await load()
|
|
await deliver(documentOf(3, [block('0', 'mask_phone')]))
|
|
await act(async () => document.body.querySelector<HTMLButtonElement>('[data-call-tree="mask_phone"]')!.click())
|
|
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
|
|
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
|
|
await act(async () => document.body.querySelectorAll<HTMLButtonElement>('[role="tab"]')[1].click())
|
|
expect(saved?.callTree).toEqual({ anchor: 'mask_phone', entry: 1 })
|
|
|
|
await unload()
|
|
await load()
|
|
expect(dialog()).toBeNull()
|
|
await deliver(documentOf(3, [block('0', 'mask_phone')]))
|
|
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
|
|
expect(dialog()!.textContent).toContain('正在查调用关系')
|
|
await deliver({ type: 'callTree', version: 3, anchor: 'mask_phone', name: 'mask_phone', where: 'utils/phone.py:28', trees: TREES })
|
|
expect(selectedTab()).toBe('夜间对账1处')
|
|
|
|
await act(async () => dialog()!.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click())
|
|
expect(saved?.callTree).toBeUndefined()
|
|
await unload()
|
|
await load()
|
|
await deliver(documentOf(3, [block('0', 'mask_phone')]))
|
|
expect(dialog()).toBeNull()
|
|
expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' }))
|
|
})
|
|
|
|
it('重新加载后文档还没到,页面又重新加载了一次:记着的弹框还在', async () => {
|
|
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } }
|
|
await load()
|
|
await unload()
|
|
await load()
|
|
await deliver(documentOf(3, [block('0', 'mask_phone')]))
|
|
expect(host.posted).toContainEqual({ type: 'openCallTree', version: 3, anchor: 'mask_phone' })
|
|
expect(dialog()).not.toBeNull()
|
|
})
|
|
|
|
it('那块已经不在了:不开弹框、不要树,记录丢掉', async () => {
|
|
saved = { editing: [], folds: {}, callTree: { anchor: 'gone', entry: 0 } }
|
|
await load()
|
|
await deliver(documentOf(4, [block('0', 'mask_phone')]))
|
|
expect(dialog()).toBeNull()
|
|
expect(host.posted).not.toContainEqual(expect.objectContaining({ type: 'openCallTree' }))
|
|
expect(saved?.callTree).toBeUndefined()
|
|
})
|
|
|
|
it('弹框开着时文档换了一版:照旧关掉,记录跟着清掉', async () => {
|
|
saved = { editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 0 } }
|
|
await load()
|
|
await deliver(documentOf(5, [block('0', 'mask_phone')]))
|
|
expect(dialog()).not.toBeNull()
|
|
await deliver(documentOf(6, [block('0', 'mask_phone')]))
|
|
expect(dialog()).toBeNull()
|
|
expect(saved?.callTree).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('问 AI 和重写注释各按各的能不能用', () => {
|
|
const DOC = documentOf(1, [{ ...block('0', 'rglob'), commentTarget: 'replace' }])
|
|
const qaMessage = (rewritable: boolean): ExtensionToWebview => ({ type: 'qa', available: true, rewritable, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
|
|
const tool = (label: string) => container.querySelector(`[data-id="0"] [aria-label="${label}"]`)
|
|
|
|
it('工作区外的库文件(rewritable 为假):块上能问 AI、顶部能问,没有重写注释的按钮', async () => {
|
|
await load()
|
|
await deliver(qaMessage(false))
|
|
await deliver(DOC)
|
|
expect(tool('问 AI')).not.toBeNull()
|
|
expect(container.querySelectorAll('.cr-qa')).toHaveLength(1)
|
|
expect(tool('按注释规范重写')).toBeNull()
|
|
})
|
|
|
|
it('项目里的文件:两个按钮都有', async () => {
|
|
await load()
|
|
await deliver(qaMessage(true))
|
|
await deliver(DOC)
|
|
expect(tool('问 AI')).not.toBeNull()
|
|
expect(tool('按注释规范重写')).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('代码配色跟着当前颜色主题', () => {
|
|
it('扩展端发来的配色设成 body 上的变量:词性的 --cr-<词性>,种类的 --cr-kind-<种类>;换了主题再发来就换', async () => {
|
|
const style = document.body.style
|
|
await load()
|
|
await deliver({ type: 'codeColors', roles: { keyword: '#C586C0', function: '#DCDCAA' }, kinds: { loop: 'var(--vscode-editor-foreground)' } } as ExtensionToWebview)
|
|
expect(style.getPropertyValue('--cr-keyword')).toBe('#C586C0')
|
|
expect(style.getPropertyValue('--cr-function')).toBe('#DCDCAA')
|
|
expect(style.getPropertyValue('--cr-kind-loop')).toBe('var(--vscode-editor-foreground)')
|
|
|
|
await deliver({ type: 'codeColors', roles: { keyword: '#AF00DB', function: '#795E26' }, kinds: { loop: '#AF00DB' } } as ExtensionToWebview)
|
|
expect(style.getPropertyValue('--cr-keyword')).toBe('#AF00DB')
|
|
expect(style.getPropertyValue('--cr-kind-loop')).toBe('#AF00DB')
|
|
})
|
|
})
|
|
|
|
describe('一条消息只重画受影响的卡片', () => {
|
|
/** 三张卡片,译文里各有一个带下标的名字(下标 10、20、30) */
|
|
function named(id: string, name: string, offset: number, text = '调用'): UnhashedBlock {
|
|
return { ...block(id, name), translation: [{ text, role: 'keyword' }, { text: name, role: 'function', offset }] }
|
|
}
|
|
const DOC = documentOf(3, [named('0', 'load', 10), named('1', 'save', 20), named('2', 'drop', 30)])
|
|
/** 扩展端每次发来的都是新对象:深拷一份 */
|
|
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
|
|
async function rendered(msg: ExtensionToWebview): Promise<Record<string, number>> {
|
|
renders.start()
|
|
await deliver(msg)
|
|
return renders.stop()
|
|
}
|
|
|
|
it('中文名到了:只重画换了字的那段文字,卡片不动', async () => {
|
|
await load()
|
|
await deliver(copy(DOC))
|
|
const counts = await rendered({ type: 'displayNames', version: 3, names: [{ offset: 20, length: 4, name: '保存' }] })
|
|
expect(counts).toEqual({ SegmentText: 1 })
|
|
expect(container.querySelector('[data-offset="20"]')?.textContent).toBe('保存')
|
|
})
|
|
|
|
it('调用树棵数到了:只重画棵数变了的那几块', async () => {
|
|
await load()
|
|
await deliver(copy(DOC))
|
|
const counts = await rendered({ type: 'callTreeCounts', version: 3, counts: { save: 2 } })
|
|
expect(counts.BlockItem).toBe(1)
|
|
expect(counts.SegmentText).toBeUndefined()
|
|
expect(container.querySelector('[data-call-tree="save"]')?.textContent).toBe('2')
|
|
})
|
|
|
|
it('同一版文档重发、只改了一块的新版本:内容没变的卡片不重画', async () => {
|
|
await load()
|
|
await deliver(copy(DOC))
|
|
expect(await rendered(copy(DOC))).not.toHaveProperty('BlockItem')
|
|
const edited = documentOf(4, [named('0', 'load', 10), named('1', 'save', 20, '调用了'), named('2', 'drop', 30)])
|
|
const counts = await rendered(copy(edited))
|
|
expect(counts.BlockItem).toBe(1)
|
|
expect(container.querySelector('[data-id="1"]')?.textContent).toContain('调用了')
|
|
})
|
|
|
|
it('单击名字高亮:只重画高亮落到的那段文字', async () => {
|
|
await load()
|
|
await deliver(copy(DOC))
|
|
await act(async () => container.querySelector<HTMLElement>('[data-offset="30"]')!.click())
|
|
const asked = [...host.posted].reverse().find((msg): msg is Extract<WebviewToExtension, { type: 'findOccurrences' }> => (msg as WebviewToExtension).type === 'findOccurrences')!
|
|
const counts = await rendered({ type: 'occurrencesFound', id: asked.id, occurrences: [{ start: 30, end: 34, kind: 'read' }] })
|
|
expect(counts.SegmentText).toBe(1)
|
|
expect(counts.BlockItem).toBeUndefined()
|
|
expect(container.querySelector('[data-offset="30"]')?.getAttribute('data-occurrence')).toBe('normal')
|
|
})
|
|
|
|
it('差异到了:卡片还是原来那个元素(不拆掉重建),改了的那块标上改动色条,删掉的块放回原处', async () => {
|
|
await load()
|
|
await deliver(copy(DOC))
|
|
const cards = [...container.querySelectorAll('.cr-block')]
|
|
const removed = { ...block('9', 'gone'), translation: [{ text: 'gone', role: 'plain' as const }] }
|
|
renders.start()
|
|
await deliver({
|
|
type: 'diff',
|
|
diff: {
|
|
base: 'HEAD',
|
|
version: 3,
|
|
blocks: { save: { change: 'mod', own: true } },
|
|
removed: [{ parent: '', after: 'save', block: removed, line: 9, kindName: '函数' }],
|
|
stats: { add: 0, mod: 1, del: 1 },
|
|
lineCount: 20,
|
|
},
|
|
})
|
|
const counts = renders.stop()
|
|
expect(counts.SegmentText).toBeUndefined()
|
|
const after = [...container.querySelectorAll('.cr-block:not(.cr-removed)')]
|
|
expect(after).toHaveLength(3)
|
|
after.forEach((card, index) => expect(card).toBe(cards[index]))
|
|
expect(container.querySelector('[data-id="1"] > .cr-change-bar')?.getAttribute('data-change')).toBe('mod')
|
|
expect(container.querySelector('[data-id="0"] > .cr-change-bar')?.hasAttribute('data-change')).toBe(false)
|
|
expect(container.querySelector('.cr-level')?.hasAttribute('data-diff')).toBe(true)
|
|
const gone = container.querySelector('.cr-removed')!
|
|
expect(gone.querySelector('.cr-change-bar')?.getAttribute('data-change')).toBe('del')
|
|
expect(cards[1].compareDocumentPosition(gone) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
expect(gone.compareDocumentPosition(cards[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
})
|
|
|
|
it('没展开、没说过的块下面不画问答区;展开一块只重画那一块', async () => {
|
|
await load()
|
|
await deliver({ type: 'qa', available: true, rewritable: true, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
|
|
await deliver(copy(DOC))
|
|
// 顶部那条一直摊开
|
|
expect(container.querySelectorAll('.cr-qa')).toHaveLength(1)
|
|
renders.start()
|
|
await act(async () => container.querySelector<HTMLButtonElement>('[data-id="1"] [aria-label="问 AI"]')!.click())
|
|
const counts = renders.stop()
|
|
expect(counts.BlockItem).toBe(1)
|
|
expect(container.querySelectorAll('.cr-qa')).toHaveLength(2)
|
|
})
|
|
})
|