🐛 fix(vscode): 按种类筛选后仍能展开匹配块的完整主体,接口改叫路由

This commit is contained in:
cruldra committed 2026-10-08 17:35:07 +08:00
1 parent 3abc87dbef
commit 6723c1dcc3
5 files changed
+152 -16

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@ const HEADER = [
describe('fastapi 库规则:接口卡片', () => { describe('fastapi 库规则:接口卡片', () => {
it('luffy 的 update_user:名称 / 说明 / 标签 / 异步 / 依赖,请求表、响应表;Forbidden 的状态码查到是 403,四条消息写在一起', async () => { it('luffy 的 update_user:名称 / 说明 / 标签 / 异步 / 依赖,请求表、响应表;Forbidden 的状态码查到是 403,四条消息写在一起', async () => {
const card = await route(ROUTES, 'update_user', known({ Forbidden: '403' })) const card = await route(ROUTES, 'update_user', known({ Forbidden: '403' }))
expect(card.badge).toEqual({ label: '接口', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' }) expect(card.badge).toEqual({ label: '路由', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' })
expect(propertyLines(properties(card))).toEqual([ expect(propertyLines(properties(card))).toEqual([
'名称: update_user', '名称: update_user',
'说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。', '说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。',
+1 -1
View File
@@ -97,7 +97,7 @@ interface Words {
} }
const ZH: Words = { const ZH: Words = {
kind: '接口', kind: '路由',
routerKind: '路由器', routerKind: '路由器',
labels: { labels: {
name: '名称', name: '名称',
@@ -2,7 +2,15 @@ import type { BlockTreeState } from './BlockTree'
import type { BlockView, KindBadge } from './types' import type { BlockView, KindBadge } from './types'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { fileAttributes } from '../../../src/codeReader/attributes'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { BlockLevel } from './BlockTree' import { BlockLevel } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
import { collapsedIds } from './viewState' import { collapsedIds } from './viewState'
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 // 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
@@ -77,6 +85,62 @@ function render(folds: Record<string, boolean>): string {
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={state(collapsedIds([CARD], folds))} />) return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={state(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 blocks = await withBlocks(language, 'python', source, parsed => toBlockViews(
parsed,
{ source, language, markers: [] },
pythonZhTranslator,
{ rules: [fastapiRule], report: (message) => { throw new Error(message) } },
))
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(blocks, 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 tree = { ...state(collapsedIds(blocks, {})), ...filtered }
const closed = renderToStaticMarkup(<BlockLevel blocks={blocks} tree={tree} />)
expect(closed).toContain('aria-label="展开子块"')
expect(closed).not.toContain('enabled-body')
const opened = renderToStaticMarkup(<BlockLevel blocks={blocks} tree={{ ...tree, 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('挂在卡片子行上的子块', () => { describe('挂在卡片子行上的子块', () => {
it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => { it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => {
const html = render({}) const html = render({})
+65 -10
View File
@@ -1,8 +1,8 @@
import type { BlockKind, BlockView, FileAttribute } from './types' import type { BlockView, FileAttribute } from './types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter' import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter'
function block(id: string, kind: BlockKind, extra: Record<string, string> = {}, children: BlockView[] = []): BlockView { function block(id: string, kind: BlockView['kind'], extra: Record<string, string> = {}, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] }
} }
@@ -41,15 +41,69 @@ describe('按筛选藏块', () => {
it('同一属性里勾着的值满足一个就显示,不同属性要同时满足', () => { it('同一属性里勾着的值满足一个就显示,不同属性要同时满足', () => {
const kinds = { kind: ['comment', 'if', 'return'] } const kinds = { kind: ['comment', 'if', 'return'] }
expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['1.0', '1.0.0', '2.0', '3']) expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['3'])
const both = applyFilter(BLOCKS, { access: ['private'], async: ['false'] }, NONE) const both = applyFilter(BLOCKS, { access: ['private'], async: ['false'] }, NONE)
expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0'])
expect(excludedBy(BLOCKS[1], { access: ['private'], async: ['false'] })).toBe(false) expect(excludedBy(BLOCKS[1], { access: ['private'], async: ['false'] })).toBe(false)
expect(excludedBy(BLOCKS[1], { async: ['true'] })).toBe(true) expect(excludedBy(BLOCKS[1], { async: ['true'] })).toBe(true)
}) })
it('选中的函数保留完整主体,种类筛选不再截断其子孙;其他属性仍然逐块生效', () => {
const kinds = { kind: ['const', 'if', 'return', 'comment'] }
const view = applyFilter(BLOCKS, kinds, NONE)
expect([...view.hidden].sort()).toEqual(['0', '3'])
expect(view.hiddenCount).toBe(2)
expect(view.temporary).toEqual(NONE)
const both = applyFilter(BLOCKS, { ...kinds, access: ['private'] }, NONE)
expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3'])
expect(both.hiddenCount).toBe(4)
})
it('嵌套路由保留通向它的祖先与完整主体,不带出祖先的其他子块或兄弟路由', () => {
const blocks = [
block('0', 'function', {}, [
block('0.0', 'const'),
block('0.1', 'function', {}, [
block('0.1.0', 'fastapi.route', { httpMethod: 'PUT' }, [
block('0.1.0.0', 'if', {}, [block('0.1.0.0.0', 'return')]),
block('0.1.0.1', 'return'),
]),
block('0.1.1', 'return'),
]),
block('0.2', 'fastapi.route', { httpMethod: 'GET' }, [block('0.2.0', 'return')]),
]),
block('1', 'function', {}, [block('1.0', 'return')]),
]
const excluded = { kind: ['function', 'const', 'if', 'return'], httpMethod: ['GET'] }
const view = applyFilter(blocks, excluded, NONE)
expect([...view.hidden].sort()).toEqual(['0.0', '0.1.1', '0.2', '0.2.0', '1', '1.0'])
expect(view.hiddenCount).toBe(4)
expect(view.total).toBe(12)
expect(view.temporary).toEqual(NONE)
})
it('只看 return 时保留祖先路径,不显示路径旁的 const 和没有 return 的函数', () => {
const view = applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'comment'] }, NONE)
expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3'])
expect(view.hiddenCount).toBe(4)
})
it('种类全部排除后仍可临时显示目标路径,不带出被筛掉的兄弟', () => {
const view = applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'return', 'comment'] }, new Set(blockPath('1.0.0')))
expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3'])
expect([...view.temporary]).toEqual(['1', '1.0', '1.0.0'])
expect(view.hiddenCount).toBe(4)
})
it('没有选中种类时全部隐藏,全选后全部恢复', () => {
const all = ['const', 'function', 'if', 'return', 'comment']
const excluded = setChecked({}, 'kind', all, false)
expect(applyFilter(BLOCKS, excluded, NONE).hidden.size).toBe(8)
expect(applyFilter(BLOCKS, setChecked(excluded, 'kind', all, true), NONE).hidden).toEqual(NONE)
})
it('藏着的块里又被筛掉的子块不重复计数', () => { it('藏着的块里又被筛掉的子块不重复计数', () => {
expect(applyFilter(BLOCKS, { kind: ['function', 'if'] }, NONE).hiddenCount).toBe(2) expect(applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'return'] }, NONE).hiddenCount).toBe(3)
}) })
it('跳到定义临时露出来的:藏着的祖先下面只露出通向目标的路径,本来藏着的记进 temporary;已隐藏的块数照旧按筛选算', () => { it('跳到定义临时露出来的:藏着的祖先下面只露出通向目标的路径,本来藏着的记进 temporary;已隐藏的块数照旧按筛选算', () => {
@@ -57,10 +111,10 @@ describe('按筛选藏块', () => {
expect([...view.hidden].sort()).toEqual(['0', '2', '2.0']) expect([...view.hidden].sort()).toEqual(['0', '2', '2.0'])
expect([...view.temporary]).toEqual(['1.1']) expect([...view.temporary]).toEqual(['1.1'])
expect(view.hiddenCount).toBe(3) expect(view.hiddenCount).toBe(3)
const path = applyFilter(BLOCKS, { kind: ['function'] }, new Set(blockPath('1.0'))) const path = applyFilter(BLOCKS, { access: ['public', 'private'] }, new Set(blockPath('1.0')))
expect([...path.hidden].sort()).toEqual(['1.1', '2', '2.0']) expect([...path.hidden].sort()).toEqual(['0', '1.1', '2', '2.0'])
expect([...path.temporary].sort()).toEqual(['1', '1.0']) expect([...path.temporary].sort()).toEqual(['1', '1.0'])
expect(path.hiddenCount).toBe(2) expect(path.hiddenCount).toBe(3)
const visible = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.0'))) const visible = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.0')))
expect(visible.temporary).toEqual(new Set()) expect(visible.temporary).toEqual(new Set())
}) })
@@ -69,9 +123,10 @@ describe('按筛选藏块', () => {
const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('2'))) const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('2')))
expect([...view.temporary]).toEqual(['2']) expect([...view.temporary]).toEqual(['2'])
expect([...view.hidden].sort()).toEqual(['0', '1.1']) expect([...view.hidden].sort()).toEqual(['0', '1.1'])
const inner = applyFilter(BLOCKS, { kind: ['function', 'return'] }, new Set(blockPath('1'))) const inner = applyFilter(BLOCKS, { access: ['public', 'private'] }, new Set(blockPath('1')))
expect([...inner.hidden].sort()).toEqual(['1.0.0', '2', '2.0']) expect([...inner.hidden].sort()).toEqual(['0', '1.1', '2', '2.0'])
expect(inner.hiddenCount).toBe(2) expect([...inner.temporary]).toEqual(['1'])
expect(inner.hiddenCount).toBe(3)
}) })
}) })
+21 -4
View File
@@ -28,11 +28,28 @@ export function excludedBy(block: BlockView, excluded: Excluded): boolean {
} }
/** /**
* 按筛选算出哪些块不画:块被筛掉,连同它的子孙一起藏。keep 是跳到定义时要临时露出来的块(目标和它的各层祖先), * 种类筛选保留匹配块的完整主体及通向它的祖先,其余属性仍能隐藏整棵子树,让选中的卡片可以展开阅读。
* 其中本来藏着的照画、记进 temporary:藏着的祖先下面只露出通向目标的那条路径,目标自己和没临时显示的块一样, *
* 它的子块照常按筛选显示(函数照样能展开看主体)。 * @param blocks 文档的顶层代码块。
* @param excluded 每个筛选属性不显示的值。
* @param keep 跳到定义时临时显示的目标及祖先,原本隐藏的块会记入 `temporary`,目标的子块仍按筛选显示。
*/ */
export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, keep: ReadonlySet<string>): FilterView { export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, keep: ReadonlySet<string>): FilterView {
const { kind: excludedKinds = [], ...otherAttributes } = excluded
const kindVisible = new Set<string>()
const selectKinds = (list: readonly BlockView[], matchedAbove: boolean): boolean => {
let anyVisible = false
for (const block of list) {
const matched = matchedAbove || !excludedKinds.includes(block.attributes.kind)
const childVisible = selectKinds(block.children, matched)
if (matched || childVisible) {
kindVisible.add(block.id)
anyVisible = true
}
}
return anyVisible
}
selectKinds(blocks, false)
const hidden = new Set<string>() const hidden = new Set<string>()
const temporary = new Set<string>() const temporary = new Set<string>()
let hiddenCount = 0 let hiddenCount = 0
@@ -41,7 +58,7 @@ export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, ke
const visit = (list: readonly BlockView[], filteredAbove: boolean, hiddenAbove: boolean): void => { const visit = (list: readonly BlockView[], filteredAbove: boolean, hiddenAbove: boolean): void => {
for (const block of list) { for (const block of list) {
total += 1 total += 1
const out = excludedBy(block, excluded) const out = !kindVisible.has(block.id) || excludedBy(block, otherAttributes)
if (out && !filteredAbove) if (out && !filteredAbove)
hiddenCount += 1 hiddenCount += 1
const hiddenHere = hiddenAbove || out const hiddenHere = hiddenAbove || out