Files
apes-sidebar/tests/md-toc.spec.tsx
T
Menghuan1918 936df662b0 test(specs): 收敛 28 个组件 spec 的渲染样板到共享 test-utils
- 7 个 spec 的本地 mount()(createRoot + act 容器循环)替换为
  renderRoot():sidechat-view、subagent-jobs-view、subagent-live-polling、
  lazy-chunk、side-card-section-rows(本地 mount 删除,调用点改名);
  editor-refresh、markdown-manual-refresh(EditorHost 包装 mount 改为
  委托 renderRoot,调用点不动)
- 24 个 spec 的内联 IS_REACT_ACT_ENVIRONMENT 赋值替换为 setupReactAct()
  (subagent-jobs-view / subagent-live-polling / lazy-chunk 原本未设
  flag,保持原样不引入)

全部为语义相同的样板替换:迁移后全量 118 files / 1241 passed +
9 skipped,与迁移前一致。
2026-09-03 12:04:35 +08:00

194 lines
7.9 KiB
TypeScript

/**
* MdToc spec: the markdown preview's outline button + popover. Covers the
* minimum-headings gate, collection from the rendered DOM (including headings
* inside HTML `<details>` runs, which appear late through the observer),
* jump behavior (smooth scroll + flash + auto-expanding collapsed ancestors)
* and Esc-to-close.
*/
// @vitest-environment jsdom
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { MdToc, TOC_MIN_HEADINGS } from '../src/client/md-toc.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
/** The preview-harness shape MdToc mounts in: the bar is a direct child of
* the scroll container, headings are siblings (inside or outside details). */
interface HeadingSpec {
tag: string
text: string
insideClosedDetails?: boolean
}
function Harness({ specs }: { specs: readonly HeadingSpec[] }): React.ReactElement {
return createElement(
'div',
null,
createElement(MdToc),
...specs.map((spec) => spec.insideClosedDetails === true
? createElement('details', null, createElement(spec.tag, null, spec.text))
: createElement(spec.tag, null, spec.text)),
)
}
/** Render the harness; returns the scroll container (the bar's parent). */
async function mountToc(specs: readonly HeadingSpec[]): Promise<{ container: HTMLDivElement; root: Root }> {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
await act(async () => {
root.render(createElement(Harness, { specs }))
await new Promise((resolve) => { setTimeout(resolve, 0) })
})
const container = host.firstElementChild as HTMLDivElement
return { container, root }
}
async function unmount(root: Root): Promise<void> {
await act(async () => { root.unmount() })
}
beforeEach(() => {
document.body.innerHTML = ''
scrollIntoView.mockClear()
})
describe('MdToc', () => {
it('exposes the minimum-headings threshold', () => {
expect(TOC_MIN_HEADINGS).toBe(3)
})
it('stays hidden below the heading threshold', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'One' },
{ tag: 'h2', text: 'Two' },
])
expect(container.querySelector('[data-dsh-md-toc]')).toBeNull()
await unmount(root)
})
it('shows the button and lists collected headings', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Install' },
{ tag: 'h2', text: 'Usage' },
{ tag: 'h3', text: 'Deep' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')
expect(button).not.toBeNull()
await act(async () => { button!.click() })
const panel = container.querySelector('[data-dsh-md-toc-panel]')
expect(panel).not.toBeNull()
const items = [...panel!.querySelectorAll('button')]
expect(items.map((item) => item.textContent)).toEqual(['1Title', '2Install', '2Usage', '3Deep'])
await unmount(root)
})
it('jumps: expands collapsed details ancestors, smooth-scrolls, flashes, closes', async () => {
// Mount with real timers (the settle await needs them), then fake only the
// flash-removal timeout window.
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Outside' },
{ tag: 'h3', text: 'Hidden', insideClosedDetails: true },
])
vi.useFakeTimers()
try {
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
const target = [...container.querySelectorAll('h3')].find((h) => h.textContent === 'Hidden')!
const item = [...container.querySelectorAll<HTMLButtonElement>('[data-dsh-md-toc-panel] button')]
.find((candidate) => candidate.textContent?.includes('Hidden'))!
await act(async () => { item.click() })
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
expect(target.closest('details')?.hasAttribute('open'), 'the collapsed ancestor must open').toBe(true)
expect(target.className, 'the jumped-to heading must flash').not.toBe('')
expect(container.querySelector('[data-dsh-md-toc-panel]'), 'the panel must close on jump').toBeNull()
vi.advanceTimersByTime(1300)
expect(target.className, 'the flash class must clear after the timeout').toBe('')
await unmount(root)
} finally {
vi.useRealTimers()
}
})
it('collects headings that appear after mount (observer path)', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Only two so far' },
])
expect(container.querySelector('[data-dsh-md-toc]')).toBeNull()
const late = document.createElement('h2')
late.textContent = 'Late'
await act(async () => {
container.appendChild(late)
await new Promise((resolve) => { setTimeout(resolve, 0) })
})
expect(container.querySelector('[data-dsh-md-toc]'), 'late headings must surface the button').not.toBeNull()
await unmount(root)
})
it('closes the popover on Escape', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'A' },
{ tag: 'h2', text: 'B' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
})
expect(container.querySelector('[data-dsh-md-toc-panel]')).toBeNull()
await unmount(root)
})
it('closes the popover when a pointerdown lands outside the button and panel', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'A' },
{ tag: 'h2', text: 'B' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
// A press on a heading that sits in the scroll container (a sibling of the
// TOC bar) is "blank space" to the outline: it must dismiss the popover.
const heading = container.querySelector('h2')!
await act(async () => {
heading.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
})
expect(container.querySelector('[data-dsh-md-toc-panel]'), 'outside press must close the panel').toBeNull()
await unmount(root)
})
it('keeps the toggle working: a press inside the bar does not dismiss, a click re-closes', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'A' },
{ tag: 'h2', text: 'B' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
// A pointerdown on the toggle itself must not be swallowed by the dismiss
// handler (the button re-enables pointer events inside the pointer-events:
// none bar), so the subsequent click still toggles it closed.
await act(async () => {
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
})
expect(container.querySelector('[data-dsh-md-toc-panel]'), 'press on the toggle must not dismiss').not.toBeNull()
await act(async () => { button.click() })
expect(container.querySelector('[data-dsh-md-toc-panel]'), 're-clicking the toggle must close').toBeNull()
await unmount(root)
})
})