Files
superwork/vscode/webview-ui/src/codeReader/App.test.tsx
T

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