Files
apes-sidebar/tests/side-card-section-rows.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

260 lines
10 KiB
TypeScript

/**
* Interactive tests for the settings popup's text/number/select rows: the
* draft is local state committed on blur through the parent's onCommit; the
* parent's canonical return is adopted (clamped numbers, stored value for
* invalid input), and the row remounts when the committed pref value changes
* (a failed commit reverts prefs → the stored value reappears). Select rows
* commit the picked option's value (single) or the picked values in options
* order (multi) through onSelectValue.
*
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
* side-card-section.spec.tsx; this file exercises the event paths).
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
setupReactAct()
import type { SidebarSettingToggle } from '../src/client/service.ts'
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** Type into an input and commit it via blur (React 18: input event +
* focusout). The native setter bypasses React's value tracker so the
* change is actually seen. */
function typeAndBlur(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})
}
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
describe('FeatureSettingsRows typed rows (interactive)', () => {
it('commits the raw text on blur and adopts the canonical return', () => {
const commits: Array<[string, string]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onCommit: (t, raw) => {
commits.push([t.key, raw])
return raw
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'Monaco')
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
// The canonical return is adopted into the draft.
expect(input.value).toBe('Monaco')
unmount()
})
it('clamps numbers into the declared bounds on commit', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
onCommit: (t, raw) => {
const parsed = Number(raw)
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '40')
expect(commits).toEqual([['terminalFontSize', 32]])
expect(input.value).toBe('32')
unmount()
})
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
// The parent mirrors the real handler: an emptied number parses to 0
// and clamps into the bounds (a browser number input never holds a
// non-numeric string — the draft can only be empty or numeric).
onCommit: (t, raw) => {
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '')
expect(commits).toEqual([['terminalFontSize', 9]])
expect(input.value).toBe('9')
unmount()
})
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
// The parent mirrors the real handler: the optimistic commit adopts the
// typed value, and a FAILED write reverts prefs to the stored one.
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
const { container, rerender, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'New')
// Optimistic commit: the draft adopts the typed value.
expect(input.value).toBe('New')
// The optimistic pref lands (parent state): the key changes, the row
// remounts with the same value — no visible reset while editing.
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('New')
// The write fails: prefs revert to the stored value and the row
// remounts with it (the stale draft is gone).
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('Old')
unmount()
})
})
/** Open one select row's dropdown: click the anchor, the Menu portals its
* list into document.body (role="menuitem" rows). */
function openSelect(container: HTMLDivElement): HTMLElement[] {
const anchor = container.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!
act(() => { anchor.click() })
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
describe('FeatureSettingsRows select rows (interactive)', () => {
const options: NonNullable<SidebarSettingToggle['options']> = [
{ value: true, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Merged', desc: () => 'Docked tree' },
{ value: false, icon: (size: number) => createElement('i', { 'data-size': size }), title: () => 'Split', desc: () => 'Two tabs' },
]
it('commits the picked option value and closes (iconed single select)', () => {
const commits: Array<[string, unknown]> = []
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
// Merged is the selected option for this scenario (the default is now
// separate — the anchor must render whatever value the prefs carry).
prefs: { ...prefs, editorExplorer: true },
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
}))
// The closed anchor shows the selected option's title (icon variant).
expect(container.textContent).toContain('Merged')
const items = openSelect(container)
// Big-icon cards: title + desc per option.
expect(items.map(item => item.textContent)).toEqual(['MergedDocked tree', 'SplitTwo tabs'])
act(() => { items[1]!.click() })
expect(commits).toEqual([['editorExplorer', false]])
// Single-pick closes the dropdown.
expect(document.querySelectorAll('[role="menuitem"]')).toHaveLength(0)
unmount()
})
it('toggles membership and commits the picked values in options order (multi)', () => {
const commits: Array<[string, unknown]> = []
const plain: NonNullable<SidebarSettingToggle['options']> = [
{ value: 'a', title: () => 'Alpha' },
{ value: 'b', title: () => 'Beta' },
{ value: 'c', title: () => 'Gamma' },
]
const toggle: SidebarSettingToggle = {
key: 'pluginKey',
type: 'select',
multi: true,
title: () => 'Pick several',
options: plain,
}
const rows = (value: unknown) => createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onSelectValue: (t, next) => { commits.push([t.key, next]) },
valueSource: () => value,
})
const { container, rerender, unmount } = renderRoot(rows(['c', 'a']))
// The anchor shows the picked titles; options follow the declared order.
const items = openSelect(container)
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
// Deselect 'a' → ['c'] is normalized to options order… (['c'] here).
act(() => { items[0]!.click() })
expect(commits).toEqual([['pluginKey', ['c']]])
// The menu stays open under multi; re-render with the committed value.
rerender(rows(['c']))
const again = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(again).toHaveLength(3)
// Select 'b' → committed in OPTIONS order, not pick order.
act(() => { again[1]!.click() })
expect(commits[1]).toEqual(['pluginKey', ['b', 'c']])
unmount()
})
it('shows an em dash when nothing is selected', () => {
const toggle: SidebarSettingToggle = {
key: 'editorExplorer',
type: 'select',
title: () => 'Editor explorer',
options,
}
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
valueSource: () => undefined,
}))
expect(container.querySelector('button[aria-haspopup="listbox"]')!.textContent).toContain('—')
unmount()
})
})