Files
apes-sidebar/tests/side-card-section.spec.tsx
T
Menghuan1918 0995a58454 fix: 回应独立 CR 的 P2/P3 意见(0×P1)
- P2-1 onOpen 收到真正落地的 tab:url 新建场景从 landed 态按 id 反查
  (补丁后对象),回调不再拿到未打 url 的 tab
- P2-2 插件设置行显式取值源:FeatureSettingsRows 新增 valueSource prop,
  插件行读自己的 pluginSettings blob——插件键与宿主 pref 同名时不再
  静默读宿主值(原投影合并有读宿主写插件的读写不对称);prop 命名避开
  Object.prototype.valueOf 冲突
- P2-3 consumer 类型检查接入工程:package.json 新增 check:consumer-types
  脚本 + CI 在 build 后执行;脚本补 lib/types 存在性前置检查(防假绿)
- P3-1 url seed 只在新建时应用:dedupe 聚焦的 tab 不再被覆写 path
- P3-2 closeTab/activateTab 未知 id 严格 no-op:store.reduce 对同引用
  结果短路(不 notify、不重写 localStorage),根因修复 + 回归测试
- P3-3 closeTab/activateTab 增加可选 scope(cwd 随回调传递),Sidebar
  双树激活/关闭路径传入 { sessionId, cwd }
- P3-4 定向打开不再自动展开不可见 session 的面板
- P3-5 Sidebar 终端 pty 释放查找覆盖双树(底部面板终端不再漏掉 HTTP
  兜底)
- P3-6/P3-7 与 Nits:AGENTS.md/指南文档化(dedupeKey 纯函数、meta 用
  null 清除、回调 scope 的 cwd 语义);consumer fixture 补常量导出覆盖

测试 +6(url 落地保真 / url 不覆写聚焦 / 未知 id 无 notify / 定向打开
不展开 / scope 随回调 / valueSource 键隔离),464 全绿;typecheck、
build、consumer 校验全部通过。
2026-08-14 21:28:41 +08:00

270 lines
11 KiB
TypeScript

/**
* Side card settings section render tests: the section is DECLARATIVE —
* every small card (icon, title, type id, extensions, on/off state) derives
* from the sidebar service's tab/viewer registries instead of hardcoded
* copy. The toggles are CARDS in a responsive grid: the card's main area is
* the switch, the visual state IS the state (highlighted = enabled),
* announced via `aria-pressed`, and the check badge sits at the far right.
* The general rows follow the DSH settings-row recipe with custom SWITCHES
* (real checkboxes driving a styled track). Features that declare related
* settings carry a gear corner button whose popup rows (switch controls)
* are tested through the extracted FeatureSettingsRows component (the Modal
* portal renders only while open).
*
* Rendered with renderToString (mount effects — the settings RPC sync — do
* not run in SSR; the initial store prefs are the render input).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
function mount(): { store: SidebarStore; service: BetterSidebarService } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'explorer',
title: () => 'Explorer',
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
order: 10,
component: () => null,
})
service.registerTab({
id: 'subagent',
title: () => 'Subagents',
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
order: 30,
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}],
},
component: () => null,
})
service.registerFileViewer({
id: 'image',
title: () => 'Image',
icon: () => createElement('svg', { 'data-icon': 'image' }),
exts: ['png', 'jpg'],
fetchStrategy: 'mediaUrl',
component: () => null,
})
return { store, service }
}
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
return renderToString(createElement(
SideCardSection,
{ store, service } as unknown as SideCardSectionProps,
))
}
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
function pressedCount(html: string, value: string): number {
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
}
describe('SideCardSection declarative inventory', () => {
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="explorer"')
expect(html).toContain('>Explorer<')
// The type id is the card's desc (the declarative "type" surface).
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + interceptOpenPath switches checked, and
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, checked).
expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html).not.toContain('Auto-open Subagents')
})
it('renders the section intro and group headings with inventory counts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('Manage what the side card shows and how it behaves')
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
expect(html).toContain('>Sidebar content</span><span')
expect(html).toContain('>File viewers</span><span')
expect(html).toContain('>2</span>')
expect(html).toContain('>1</span>')
})
it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="image"')
expect(html).toContain('>Image<')
// The covered extensions are the card's desc.
expect(html).toContain('png · jpg')
})
it('renders the gear corner button on features that declare related settings', () => {
const { store, service } = mount()
const html = renderSection(store, service)
// Subagents declares a toggle → its card carries the settings gear
// (aria-label = "<title> Feature settings"); Explorer and Image declare
// none → no gear.
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
})
it('a disabled feature renders pressed=false', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
const html = renderSection(store, service)
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
// The explorer card stays pressed; the general switches stay checked.
expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(2)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
const html = renderSection(store, service)
expect(html).not.toContain('Feature settings')
})
})
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
...SIDEBAR_PREFS_DEFAULTS,
autoOpenSubagent: false,
}
const toggles = [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}]
it('renders one switch row per declared toggle with its current value', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs,
onToggle: () => {},
}))
expect(html).toContain('Auto-open Subagents')
expect(html).toContain('Expand on new subagent')
// The row's switch is a real checkbox (aria-label = the toggle title)
// reflecting the prefs value (false here → unchecked).
expect(html).toContain('aria-label="Auto-open Subagents"')
expect(html).not.toContain('checked=""')
})
it('checks the row when the pref is on', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs: { ...prefs, autoOpenSubagent: true },
onToggle: () => {},
}))
expect(html).toContain('checked=""')
})
it('renders a text row as an input seeded with the pref value (empty = theme default)', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
desc: () => 'CSS stack',
placeholder: '"JetBrains Mono", monospace',
}],
prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' },
onToggle: () => {},
onCommit: () => '',
}))
expect(html).toContain('Font family')
expect(html).toContain('placeholder="&quot;JetBrains Mono&quot;, monospace"')
// The input carries the pref value (no switch for text rows).
expect(html).toContain('value="&quot;JetBrains Mono&quot;, monospace"')
expect(html).not.toContain('type="checkbox"')
})
it('renders a number row with the pref value, the declared bounds and a unit suffix', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
unit: 'px',
}],
prefs: { ...prefs, terminalFontSize: 18 },
onToggle: () => {},
onCommit: () => '18',
}))
expect(html).toContain('Font size')
expect(html).toContain('type="number"')
expect(html).toContain('value="18"')
expect(html).toContain('min="9"')
expect(html).toContain('max="32"')
expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"')
})
})
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
it('sequential merges are additive — a later write never drops an earlier key', () => {
// Simulates two same-tick updatePluginSetting calls: each merge spreads
// the map it was GIVEN, so building from the latest optimistic map
// preserves both keys (the pre-fix code spread the stale render-time
// prefs twice and the second write dropped the first key).
let map: Record<string, Record<string, unknown>> = {}
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 25)
map = mergePluginSetting(map, 'my-plugin:db', 'theme', 'dark')
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
// A second descriptor's blob stays independent.
map = mergePluginSetting(map, 'other:view', 'refresh', true)
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
expect(map['other:view']).toEqual({ refresh: true })
// Overwriting one key keeps the sibling keys.
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 50)
expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' })
})
})
describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => {
it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => {
const prefs = { ...SIDEBAR_PREFS_DEFAULTS, openByDefault: true }
const toggle = { key: 'openByDefault', title: 'My flag' }
// valueOf returns undefined (the plugin never wrote this key): the row
// must render UNCHECKED even though the host pref openByDefault is true.
let html = renderToString(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
valueSource: () => undefined,
}))
expect(html).not.toContain('checked=""')
// The plugin wrote `true` into its own blob: the row is checked.
html = renderToString(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
valueSource: () => true,
}))
expect(html).toContain('checked=""')
// Without valueOf the row falls back to the prefs face (host semantics).
html = renderToString(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
}))
expect(html).toContain('checked=""')
})
})