// @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 { 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> = {}, 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() } function render(folds: Record): 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( {}} 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(/]*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( toggled.push(id) }} />)) await act(async () => container.querySelector('[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('')) }) 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(/
]*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="块操作">]*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="块操作">]*aria-label="按注释规范重写"/) expect(opened).toContain('
') 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 /]*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 { 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 = 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(`[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('[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 { 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"') expect(html).toContain('if') expect(html).toContain('"a"') }) })