Files
superwork/vscode/webview-ui/src/codeReader/BlockTree.test.tsx
T
cruldra 86beaac371 ✨ feat(vscode): React 钩子卡片按变量讲,属性表加操作列、调用树能查所有引用
- 钩子调用讲成状态、引用、记忆值、记忆函数、上下文值、过渡、副作用、钩子返回值、钩子调用几种卡片,行照签名表 reactHooks.ts 排,去掉 react.hook
- 解构接住时名称一行写出解构,展开是「第几项 | 名称 | 说明」表,每项一个调用树操作;单个名字的按钮在名称一行最右边
- 属性表的行、子行可以带操作(RowAction),表格多一列「操作」,宿主按块记号加序号起键
- 调用树服务按键数次数,新增查所有引用(references):按所在函数分组,去掉定义处和依赖数组里的
- 钩子收的函数参数都收成小框,函数体是卡片子块,直接返回的表达式由 functionBody 排成「返回 …」卡片;小框和卡片折叠箭头同一个开关
- 规则卡片可指定语言种类图标(languageKind)、种类图标悬停字和颜色(KindBadge.tooltip / iconColor)
2026-10-16 22:16:09 +11:00

478 lines
26 KiB
TypeScript

// @vitest-environment happy-dom
import type { BlockTreeHandle, BlockTreeState, RewriteTree } from './BlockTree'
import type { Ask, ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { fileAttributes } from '../../../src/codeReader/attributes'
import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView'
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { createRustAdapter } from '../../../src/codeReader/languages/rust'
import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
import { readFixture, runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { rustZhTranslator } from '../../../src/codeReader/translators/rustZh'
import { BlockTree, treeState } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
import { NO_PLACEMENT, placementOf } from './placement'
import { EMPTY_CHAT } from './qa'
import { staticStore } from './store'
import { NO_SPAN } from './testBlocks'
import { chainStepId, collapsedIds } from './viewState'
// 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
const BADGE: KindBadge = { label: '卡片', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'box' }
function view(id: string, kind: string, text: string, extra: Partial<BlockView> = {}): BlockView {
const badge = extra.badge
return {
id,
anchor: id,
kind,
span: NO_SPAN,
attributes: { kind },
concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }],
children: [],
translation: [{ text, role: 'plain' }],
...extra,
} as BlockView
}
/** 一张卡片:第 1 行的两个子行各挂一张子卡片,后面还有一块留下的语句。 */
const CARD: BlockView = {
...view('0', 'demo.card', ''),
badge: BADGE,
properties: [
{ label: '名称', value: [{ text: 'Server', role: 'type' }] },
{ label: '方法', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '参数', '返回'], items: [
{ cells: [[{ text: 'start', role: 'function' }], [], []] },
{ cells: [[{ text: 'stop', role: 'function' }], [], []] },
] },
],
children: [
view('0.0', 'demo.method', '启动服务', { slot: { row: 1, item: 0 }, badge: { ...BADGE, label: '方法', icon: 'square-function' } }),
view('0.1', 'demo.method', '停止服务', { slot: { row: 1, item: 1 }, badge: { ...BADGE, label: '方法', icon: 'square-function' } }),
view('0.2', 'statement', '留下的语句'),
],
}
/** 重写注释的状态和动作:动作什么都不做 */
function rewriteTree(state: ChatState): RewriteTree {
return { store: staticStore(state), onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} }
}
/** 能编辑、能重写注释的块树,动作什么都不做;view 盖在默认的状态上(什么都没折、没筛掉) */
function tree(view: Partial<Omit<BlockTreeState, 'folds'>> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle {
return {
state: staticStore(treeState({
edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set(), focusId: null },
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
...view,
})),
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
edit: { onToggleEdit: () => {}, onFocused: () => {} },
rewrite: rewriteTree(rewrite),
}
}
function markup(blocks: BlockView[], handle: BlockTreeHandle): string {
return renderToStaticMarkup(<BlockTree blocks={blocks} tree={handle} />)
}
function render(folds: Record<string, boolean>): string {
return markup([CARD], tree({ collapsed: collapsedIds([CARD], folds) }))
}
describe('路由筛选后展开函数体', () => {
it('真实 FastAPI 路由保留 if 和 return 子块,收起时有箭头,展开后显示完整主体及请求响应,菜单和标签统一叫路由', async () => {
const source = [
'from fastapi import APIRouter',
'router = APIRouter()',
'@router.put("/account")',
'async def put_miniprogram_account(enabled: bool):',
' if enabled:',
' return "enabled-body"',
' return "disabled-body"',
'def unrelated():',
' return "unrelated-body"',
].join('\n')
const language = createPythonAdapter(WASM_DIR)
const views = await withBlocks(language, 'python', source, parsed => toBlockViews(
parsed,
{ source, language, markers: [] },
pythonZhTranslator,
{ languageId: 'python', rules: [fastapiRule], report: (message) => { throw new Error(message) } },
))
const blocks = pageBlocks(views, source)
const route = blocks.find(block => block.kind === 'fastapi.route')!
expect(route.children.map(block => block.kind)).toEqual(['if', 'return'])
expect(route.children[0].children.map(block => block.kind)).toEqual(['return'])
const attributes = fileAttributes(views, pythonZhTranslator.attributeNames)
const excluded = { kind: attributes.find(attribute => attribute.id === 'kind')!.values.map(value => value.id).filter(kind => kind !== 'fastapi.route') }
const filtered = applyFilter(blocks, excluded, new Set())
const view = { collapsed: collapsedIds(blocks, {}), hidden: filtered.hidden, temporary: filtered.temporary }
const closed = markup(blocks, tree(view))
expect(closed).toContain('aria-label="展开子块"')
expect(closed).not.toContain('enabled-body')
const opened = markup(blocks, tree({ ...view, collapsed: new Set() }))
expect(opened).toContain('enabled-body')
expect(opened).toContain('disabled-body')
expect(opened).not.toContain('unrelated-body')
expect(opened).toContain('请求')
expect(opened).toContain('响应')
expect(route.badge?.label).toBe('路由')
expect(route.concepts.find(concept => concept.id === 'fastapi.route')?.label).toBe('路由')
const bar = renderToStaticMarkup(
<FilterBar
attributes={attributes}
excluded={excluded}
view={filtered}
highlight={undefined}
onChange={() => {}}
onMoveOccurrence={() => {}}
translators={[]}
translatorId="zh"
onSelectTranslator={() => {}}
/>,
)
expect(bar).toContain('种类:路由')
expect(bar).not.toContain('接口')
})
})
describe('库规则卡片的淡底', () => {
it('卡片给了 tint 时淡底用它、描边仍是种类色(React 钩子返回值按来源分底色):两个颜色都写在卡片上', () => {
const tinted: BlockView = { ...view('0', 'react.hookValue', 'useRouter'), badge: BADGE, tint: { dark: '#e5c07b', light: '#9a6a00' } }
expect(markup([tinted], tree())).toContain('data-kind="react.hookValue" data-badge="" data-tint="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4;--tint-dark:#e5c07b;--tint-light:#9a6a00"')
expect(markup([{ ...tinted, tint: undefined }], tree())).not.toContain('data-tint')
})
})
describe('卡片里收起来的函数小框', () => {
/** React 记忆值卡片:「计算」一行是收起来的函数,函数体(一张返回卡片)是卡片下面那一层的子块 */
const memo = (children: BlockView[]): BlockView => ({
...view('0', 'react.memo', ''),
badge: BADGE,
properties: [
{ label: '名称', value: [{ text: 'visible', role: 'variable' }] },
{ label: '计算', value: [{ style: 'fold', label: '函数', segments: [{ text: '· 1 句', role: 'hint' }] }] },
],
children,
})
const RETURN = view('0.0', 'return', '返回 results.filter(…)')
const chip = (html: string): string => html.match(/<button[^>]*data-style="fold"[^>]*>/)?.[0] ?? ''
it('卡片有下面那一层的子块时,小框能点、和卡片的折叠箭头同开同关', () => {
const closed = chip(markup([memo([RETURN])], tree({ collapsed: new Set(['0']) })))
expect(closed).toContain('aria-expanded="false"')
expect(closed).toContain('aria-label="展开函数"')
expect(closed).not.toContain('disabled')
const open = chip(markup([memo([RETURN])], tree({ collapsed: new Set() })))
expect(open).toContain('aria-expanded="true"')
expect(open).toContain('data-open=""')
})
it('点小框切换的是卡片自己的折叠', async () => {
const toggled: string[] = []
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
await act(async () => root.render(<BlockTree blocks={[memo([RETURN])]} tree={{ ...tree({ collapsed: new Set(['0']) }), onToggleFold: id => toggled.push(id) }} />))
await act(async () => container.querySelector<HTMLButtonElement>('[data-style="fold"]')!.click())
expect(toggled).toEqual(['0'])
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
it('卡片没有子块、子块都挂在子行上时,小框只是不能点的小框,也不沿用外层卡片的开关', () => {
expect(chip(markup([memo([])], tree()))).toContain('disabled')
const slotted = view('0.0', 'demo.method', '', { slot: { row: 0, item: 0 } })
expect(chip(markup([memo([slotted])], tree()))).toContain('disabled')
// 挂在外层卡片子行上的子卡片画在外层卡片的属性表里,外层卡片自己有下面那一层的子块
const inner: BlockView = { ...memo([]), id: '0.0', slot: { row: 1, item: 0 } }
const outer: BlockView = { ...CARD, children: [inner, view('0.2', 'statement', '留下的语句')] }
const html = markup([outer], tree({ collapsed: new Set() }))
expect(html).toContain('计算')
expect(chip(html)).toContain('disabled')
})
})
describe('挂在卡片子行上的子块', () => {
it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => {
const html = render({})
expect(html).not.toContain('启动服务')
expect(html).not.toContain('停止服务')
expect(html).not.toContain('留下的语句')
expect(html).toContain('<button type="button" class="cr-item-toggle" aria-expanded="false" aria-label="展开start">')
})
it('展开一张:画在表下面,那一行标成展开、垫上它的种类色,卡片左上角的箭头收起它', () => {
const html = render({ '0.0': false, '0': false })
expect(html).toContain('启动服务')
expect(html).not.toContain('停止服务')
expect(html).toContain('data-open="" data-badge="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4"')
expect(html).toContain('aria-label="收起"')
expect(html).toContain('留下的语句')
// 子卡片画在「方法」那一行后面
expect(html.indexOf('启动服务')).toBeGreaterThan(html.indexOf('方法</button>'))
})
it('折叠挂着子块的那一行:展开着的子块跟着收起', () => {
expect(render({ '0.0': false })).toContain('启动服务')
expect(render({ '0.0': false, '0/p1': true })).not.toContain('启动服务')
})
it('画出来的子层记着它是哪张卡片的(钉住卡片时按它算卡片管到哪):子行下面那层、卡片后面那层都是;收着时没有', () => {
expect(render({})).not.toContain('data-owner')
expect(render({ '0.0': false, '0': false }).match(/data-owner="0"/g)).toHaveLength(2)
})
})
describe('卡片记着块记号', () => {
it('每张卡片带着它那块的记号(data-anchor):说明里不带路径的引用靠它认自己写在哪块', () => {
const method = view('0.1', 'function', '', { anchor: 'class AgentService > function get_default_agent', translation: { markdown: '先看 [get_agent_admitted]' } })
expect(markup([method], tree())).toMatch(/<div class="cr-block [^"]*"[^>]*data-anchor="class AgentService &gt; function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/)
})
})
describe('「注释」一行和「重写注释」按钮', () => {
const comment = { label: '注释', value: { markdown: '按名字找' } }
const html = (block: BlockView): string => markup([block], tree())
/** 按钮只有一个,排在右上角那排按钮(块操作)的最前面 */
const inToolbar = (markup: string, label: string): void => {
expect(markup.match(new RegExp(`aria-label="${label}"`, 'g'))).toHaveLength(1)
expect(markup).toMatch(new RegExp(`role="group" aria-label="块操作"><button[^>]*aria-label="${label}"`))
}
it('属性表卡片:「注释」一行照属性表画,按钮在右上角那排,不在这一行上', () => {
const card = view('1', 'function', '', {
properties: [{ label: '名称', value: [{ text: 'find', role: 'function' }] }, comment, { label: '异步', value: [{ text: '否', role: 'storage' }] }],
commentTarget: 'replace',
})
const markup = html(card)
inToolbar(markup, '按注释规范重写')
expect(markup.indexOf('按名字找')).toBeLessThan(markup.indexOf('异步'))
})
it('一行译文的卡片:「注释」一行排在译文下面,按钮同样在右上角那排', () => {
const markup = html(view('2', 'statement', 'save()', { rows: [comment], commentTarget: 'replace' }))
expect(markup.indexOf('save()')).toBeLessThan(markup.indexOf('按名字找'))
inToolbar(markup, '按注释规范重写')
})
it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => {
const block = view('5', 'statement', 'save()', { commentTarget: 'replace' })
const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' }
const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] }
const collapsedMarkup = markup([block], tree({}, closed))
expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次')
inToolbar(collapsedMarkup, '展开重写记录')
const opened = markup([block], tree({}, { ...closed, open: [block.anchor] }))
expect(opened).toMatch(/role="group" aria-label="块操作"><button[^>]*aria-label="按注释规范重写"/)
expect(opened).toContain('<section class="cr-qa" aria-label="按注释规范重写">')
expect(opened).toContain('aria-label="对这段注释的修改意见"')
})
it('展开着又在写或排着队时按钮不能点;出错、停下的不挡', () => {
const block = view('6', 'statement', 'save()', { commentTarget: 'replace' })
const button = (status: Ask['status']): string => {
const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] }
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup([block], tree({}, rewrite)))?.[0] ?? ''
}
expect(button('running')).toContain('disabled')
expect(button('queued')).toContain('disabled')
expect(button('stopped')).not.toContain('disabled')
})
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => {
inToolbar(html(view('3', 'statement', 'save()', { commentTarget: 'insert' })), '按注释规范写注释')
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
})
})
describe('一行译文下面拆开的各项', () => {
/** 一段 Python 里函数体的第一块(return)经过宿主转成的视图数据;Filter 的定义是类。 */
async function returned(source: string): Promise<BlockView> {
const language = createPythonAdapter(WASM_DIR)
const views = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [], calleeKind: call => call.callee.text === 'Filter' ? 'class' : undefined }, pythonZhTranslator))
return pageBlocks(views, source)[0].children[0]
}
const SOURCE = [
'def list_filters(archived, current_user):',
' return [',
' Filter(field="user_id", value=current_user.id),',
' Filter(',
' field="archived_at",',
' value=None,',
' ),',
' {"k": v},',
' ]',
].join('\n')
/** 画成页面(只画这一块),collapsed 是折叠着的 id */
function page(block: BlockView, collapsed: ReadonlySet<string> = new Set(), diff?: BlockTreeState['diff']): HTMLElement {
const root = document.createElement('div')
root.innerHTML = markup([block], tree({ collapsed, ...(diff && { diff }) }))
return root
}
function byId(root: HTMLElement, id: string): HTMLElement {
const found = root.querySelector<HTMLElement>(`[data-id="${id}"]`)
if (!found)
throw new Error(`页面上没有 ${id}`)
return found
}
/** a 在页面上排在 b 前面 */
function before(a: Element, b: Element): boolean {
return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
}
it('各项排在译文下面、往右缩一层、左边一条竖线,和变量初始值的各项一样;项下面挂着它的实参表,排在下一项上面', async () => {
const block = await returned(SOURCE)
const root = page(block)
const line = root.querySelector('.cr-code')
expect(line?.textContent).toBe('返回 列表 3 项')
const first = byId(root, `${block.id}/i/0`)
const second = byId(root, `${block.id}/i/1`)
expect(first.textContent).toBe('第 1 项新建 Filter · 2 个参数')
expect(second.textContent).toBe('第 2 项新建 Filter · 2 个参数')
const cell = byId(root, `${block.id}/i/1/t/0`)
expect(cell.textContent).toBe('field"archived_at"')
expect(before(line!, first) && before(second, cell) && before(cell, byId(root, `${block.id}/i/2`))).toBe(true)
expect(first.closest('.cr-table')).toBeNull()
let level = first.parentElement
while (level && !level.firstElementChild?.classList.contains('cr-guide'))
level = level.parentElement
expect(level?.contains(second)).toBe(true)
expect(level?.contains(line)).toBe(false)
})
it('项下面的下一层(字典)能折叠,折着时只收起那一层;实参表总是展开的', async () => {
const block = await returned(SOURCE)
const map = `${block.id}/i/2`
const open = page(block)
expect(byId(open, map).querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(byId(open, `${map}/0`).textContent).toBe('"k"v')
const folded = page(block, new Set([map]))
expect(folded.querySelector(`[data-id="${map}/0"]`)).toBeNull()
expect(folded.querySelector(`[data-id="${block.id}/i/0/t/0"]`)).not.toBeNull()
})
it('标差异:新加的项淡绿底,删掉的项放回原来的位置、划掉', async () => {
const block = await returned(SOURCE)
const gone = { cells: [[{ text: '第 3 项', role: 'hint' as const }], [{ text: 'old', role: 'variable' as const }]] }
const changes = new Map([[block.id, { change: 'mod' as const, own: true as const, marks: { items: { changed: { 2: { added: true as const } }, removed: [{ after: 1, value: gone }] } } }]])
const root = page(block, new Set(), { changes, removed: new Map(), current: true })
expect(byId(root, `${block.id}/i/2`).dataset.diff).toBe('add')
const removed = root.querySelector<HTMLElement>('[data-diff="del"]')
expect(removed?.textContent).toBe('第 3 项old')
expect(before(byId(root, `${block.id}/i/1`), removed!) && before(removed!, byId(root, `${block.id}/i/2`))).toBe(true)
})
})
describe('链式调用卡片', () => {
/** 一段 Rust 经过宿主转成的发给页面的块:第一个函数和它函数体里的块 */
async function rustBlocks(source: string): Promise<BlockView[]> {
const language = createRustAdapter(WASM_DIR)
const views = await withBlocks(language, 'rust', source, blocks => toBlockViews(blocks, { source, language, markers: [] }, rustZhTranslator))
return pageBlocks(views, source).filter(block => block.kind === 'function')
}
/** 画成页面,函数展开着;open 是点开的那几步(折叠 id) */
async function chainPage(source: string, open: string[] = []): Promise<{ root: HTMLElement, chain: BlockView }> {
const [fn] = await rustBlocks(source)
const folds = Object.fromEntries([[fn.id, false], ...open.map(id => [id, false])])
const root = document.createElement('div')
root.innerHTML = markup([fn], tree({ collapsed: collapsedIds([fn], folds) }))
const chain = fn.children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!
return { root, chain }
}
it('起点、步骤两行,链的各步排在「步骤」那一行的值下面的竖轨道上:一步一个点、方法和值;合成的一步下面是各次实参的表;写在上面的注释挂在那一步下面', async () => {
const { root, chain } = await chainPage(readFixture('rustChain.rs'))
const card = root.querySelector(`[data-id="${chain.id}"]`)!
expect(card.querySelector('.cr-code')?.closest('.cr-prop-row')).not.toBeNull()
const steps = [...card.querySelectorAll('.cr-chain-step')]
expect(steps.map(step => step.querySelector('.cr-chain-line')?.textContent)).toEqual([
'.plugin4 次',
'.setup闭包|app| · 3 句',
'.on_window_event闭包|window, event| · 1 句',
'.manageapp_state',
'.invoke_handlertauri::generate_handler![ device_resume, settings_get, web::preview_open ]',
'.buildtauri::generate_context!()',
'.expect"error while building tauri application"',
'.run闭包|app, event| · match event · 2 支',
])
expect(steps.every(step => step.querySelector(':scope > .cr-chain-rail > .cr-chain-dot'))).toBe(true)
const plugin = steps[0]
expect(plugin.querySelector('.cr-chain-label')?.textContent).toBe('注释')
expect(plugin.querySelector('.cr-table-head')?.textContent).toBe('实参')
expect([...plugin.querySelectorAll('.cr-table-row')].map(row => row.textContent)).toHaveLength(4)
expect(card.closest('.cr-prop-row')).toBeNull()
})
it('收起来的闭包是一个按钮,默认收着、闭包体的子块不画;点开那一步(它的折叠 id 展开)后子块画在那一步下面,别的步照旧收着', async () => {
const source = readFixture('rustChain.rs')
const closed = (await chainPage(source)).root
const setupFold = closed.querySelectorAll('.cr-chain-step')[1].querySelector('button[data-style="fold"]')!
expect(setupFold.getAttribute('aria-expanded')).toBe('false')
expect(setupFold.getAttribute('aria-label')).toBe('展开闭包')
expect(closed.textContent).not.toContain('tunnels.json')
const { root, chain } = await chainPage(source, [chainStepId(chain0(await rustBlocks(source)), { row: 1, step: 1 })])
const [, setup, windowEvent] = root.querySelectorAll('.cr-chain-step')
expect(setup.querySelector('button[data-style="fold"]')?.getAttribute('aria-expanded')).toBe('true')
expect(setup.querySelector('.cr-chain-blocks')?.textContent).toContain('tunnels.json')
expect(windowEvent.querySelector('.cr-chain-blocks')).toBeNull()
expect(root.textContent).not.toContain('window.hide')
// 挂在步骤下面的子块不再排到卡片后面那一层
expect(root.querySelector(`[data-id="${chain.id}"] + .cr-level`)).toBeNull()
})
it('实参本身是链的一步在值下面缩进成一条小链(空心点),一节一行;变量的初始值是起点,链接在下面', async () => {
const source = 'fn f() {\n let names = rows.iter().filter(|r| r.active).collect();\n tauri::Builder::default()\n .plugin(\n opener::Builder::new()\n .open(false)\n .build(),\n )\n .manage(state);\n}\n'
const { root } = await chainPage(source)
const mini = root.querySelector('.cr-chain-mini')!
expect([...mini.querySelectorAll('.cr-chain-step')].map(line => line.textContent)).toEqual(['opener::Builder::new()', '.open(false)', '.build()'])
expect(mini.querySelectorAll('.cr-chain-dot[data-hollow]')).toHaveLength(3)
const value = [...root.querySelectorAll('.cr-prop-row')].find(row => row.textContent?.startsWith('初始值'))!
expect(value.querySelector('.cr-code')?.textContent).toBe('rows')
expect([...value.querySelectorAll('.cr-chain-line')].map(line => line.textContent)).toEqual(['遍历', '筛选|r| r.active', '收集'])
})
})
/** 一个函数体里第一张链式调用卡片的块 id */
function chain0(blocks: BlockView[]): string {
return blocks[0].children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!.id
}
describe('编辑框', () => {
it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => {
const source = 'def f(x):\n if x:\n return "a"\n'
const { blocks: views, colors } = await readBlockViews(runtime, createPythonAdapter(WASM_DIR), pythonZhTranslator, 'python', source)
const blocks = pageBlocks(views, source)
const html = markup(blocks, tree({
edit: { version: 1, sourceText: { source, colors }, placement: placementOf(blocks), editing: new Set(['0.0']), focusId: null },
collapsed: new Set(),
}))
expect(html).toContain('>if x:\n return &quot;a&quot;</textarea>')
expect(html).toContain('<span class="cr-role-keyword">if</span>')
expect(html).toContain('<span class="cr-role-string">&quot;a&quot;</span>')
})
})