- 钩子调用讲成状态、引用、记忆值、记忆函数、上下文值、过渡、副作用、钩子返回值、钩子调用几种卡片,行照签名表 reactHooks.ts 排,去掉 react.hook - 解构接住时名称一行写出解构,展开是「第几项 | 名称 | 说明」表,每项一个调用树操作;单个名字的按钮在名称一行最右边 - 属性表的行、子行可以带操作(RowAction),表格多一列「操作」,宿主按块记号加序号起键 - 调用树服务按键数次数,新增查所有引用(references):按所在函数分组,去掉定义处和依赖数组里的 - 钩子收的函数参数都收成小框,函数体是卡片子块,直接返回的表达式由 functionBody 排成「返回 …」卡片;小框和卡片折叠箭头同一个开关 - 规则卡片可指定语言种类图标(languageKind)、种类图标悬停字和颜色(KindBadge.tooltip / iconColor)
478 lines
26 KiB
TypeScript
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 > 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 "a"</textarea>')
|
|
expect(html).toContain('<span class="cr-role-keyword">if</span>')
|
|
expect(html).toContain('<span class="cr-role-string">"a"</span>')
|
|
})
|
|
})
|