Files
apes-sidebar/tests/side-card-section.spec.tsx
T
Menghuan1918 d38fefe55d feat(tasks): 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置
三件事一起落地(用户 brief),各自独立可验证:

1) 后台任务输出改为常驻浮动窗
   - 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
     复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
     (右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
     只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
     拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
   - 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
     setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
   - JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
     (原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
     .popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。

2) 节点卡统一
   - 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
     层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
     当前会话改为 2px accent 描边(「边框加粗高亮」)。
   - 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
   - 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
     1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
     (border-box + min-height,卡片外框 247×134 与预算逐像素一致)。

3) 手机设置
   - 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
     PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
   - 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
     任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
   - 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。

验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。

(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
2026-09-27 22:18:57 +08:00

435 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { beforeAll, describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
// The copy assertions below are English; pin the browser language so the
// module-level t()/isZh() resolve en regardless of the host system locale
// (vitest 4.1.11+/jsdom follow the OS locale — zh-CN on this machine).
beforeAll(() => {
Object.defineProperty(navigator, 'language', {
value: 'en-US',
configurable: true,
})
})
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import type { TabDescriptor } from '../src/client/service.ts'
import { FeatureSettingsRows, mergePluginSetting, SettingsBody, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** 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: the general switch is off (agentOpenTools defaults off),
// and both tabs + the image viewer cards are pressed (3 aria-pressed
// cards). The mobile switches are NOT cards — they are rows in the 手机
// group, and their own defaults (both on) show up as checked checkboxes.
// 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 custom switches are real checkboxes: the two mobile adaptations are
// on by default, agentOpenTools is not.
// The two mobile adaptations are on by default.
expect(html.match(/checked=""/g)?.length ?? 0).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 the mobile group: heading + both narrow-viewport switches, on by default', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('>Mobile</div>')
expect(html).toContain('auto-open on narrow screens')
expect(html).toContain('Default the Tasks page to the tree')
// Both are ON by default; disarming one shows up as an unchecked box.
expect(html).toContain('checked=""')
store.setPrefs({ ...store.getPrefs(), mobileNoAutoOpen: false, mobileDefaultTree: false })
const off = renderSection(store, service)
expect(off).toContain('>Mobile</div>')
expect(off).not.toContain('checked=""')
})
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. The position-compat row is a DROPDOWN (no gear unless
// the custom scheme is active), so the total is 1.
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
expect(html).toContain('Subagents Feature settings')
})
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
const { store, service } = mount()
const html = renderSection(store, service)
// The tab grid's dashed card (tab registration).
expect(html).toContain('Add tab plugins')
expect(html).toContain('Register a new sidebar page')
// The viewer grid's dashed card (file-previewer registration).
expect(html).toContain('Add preview plugins')
expect(html).toContain('Register a file-type preview')
// The add cards are plain buttons (open the modals), never switches:
// they carry no aria-pressed, so the pressed-card counts stay untouched.
expect(pressedCount(html, 'true')).toBe(3)
})
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.
expect(pressedCount(html, 'true')).toBe(1)
// The two mobile adaptations are on by default.
expect(html.match(/checked=""/g)?.length ?? 0).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)
// The disabled Subagents card loses its gear; the position-compat row
// is a dropdown (default auto → no gear either).
expect(html).not.toContain('Feature settings')
})
it('renders the position-compat mode row as a scheme dropdown: auto default, custom keeps the gear', () => {
const { store, service } = mount()
let html = renderSection(store, service)
// The general row renders its title and description; the scheme is the
// conservative auto by default and the row is the shared SelectMenu
// dropdown (the closed anchor shows the picked option — NOT a native
// <select>).
expect(html).toContain('Position compatibility mode')
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
expect(html).not.toContain('<select')
expect(html).toContain('>Auto-detect<')
// Three general-row switches remain (agentOpenTools off by default, plus
// the two mobile adaptations on) — the scheme row is a dropdown, not a
// switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
// The two mobile adaptations are on by default (agentOpenTools is not).
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
// Auto (default) needs no further settings → no gear.
expect(html).not.toContain('Position compatibility mode Feature settings')
// The custom scheme keeps its settings button (the px + CSS popup);
// the anchor now shows the picked custom option.
store.setPrefs({ ...store.getPrefs(), titleBarScheme: 'custom', titleBarCompat: true })
html = renderSection(store, service)
expect(html).toContain('>Custom<')
expect(html).toContain('aria-label="Position compatibility mode 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', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'titleBarPresetId',
type: 'text',
title: () => 'Preset id',
desc: () => 'Named shell preset',
placeholder: '"JetBrains Mono", monospace',
}],
prefs: { ...prefs, titleBarPresetId: '"JetBrains Mono", monospace' },
onToggle: () => {},
onCommit: () => '',
}))
expect(html).toContain('Preset id')
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 (non-default bounds)', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'titleBarStripPx',
type: 'number',
title: () => 'Shift distance',
min: 9,
max: 32,
unit: 'px',
}],
prefs: { ...prefs, titleBarStripPx: 18 },
onToggle: () => {},
onCommit: () => '18',
}))
expect(html).toContain('Shift distance')
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"')
})
it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'titleBarStripPx',
type: 'number',
title: () => 'Shift distance',
desc: () => 'Title-bar strip height in px',
min: 0,
max: 120,
unit: 'px',
}],
prefs: { ...prefs, titleBarStripPx: 64 },
onToggle: () => {},
onCommit: () => '64',
}))
expect(html).toContain('Shift distance')
expect(html).toContain('Title-bar strip height in px')
expect(html).toContain('type="number"')
expect(html).toContain('value="64"')
expect(html).toContain('min="0"')
expect(html).toContain('max="120"')
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, agentOpenTools: true }
const toggle = { key: 'agentOpenTools', title: 'My flag' }
// valueOf returns undefined (the plugin never wrote this key): the row
// must render UNCHECKED even though the host pref agentOpenTools 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=""')
})
})
describe('SettingsBody rows + custom render panel (open-with seam)', () => {
it('renders the declarative rows AND the custom panel when both are declared', () => {
const { store, service } = mount()
const feature = {
id: 'demo',
title: () => 'Demo',
component: () => null,
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => 'Auto row',
desc: () => 'row description',
}],
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
},
} as unknown as TabDescriptor
const html = renderToString(createElement(SettingsBody, {
feature,
prefs: SIDEBAR_PREFS_DEFAULTS,
store,
service,
onToggle: () => {},
onCommit: () => '',
onSelectValue: () => {},
onPluginToggle: () => {},
onPluginCommit: () => '',
onPluginSelectValue: () => {},
onPluginWrite: () => {},
onClose: () => {},
}))
expect(html).toContain('Auto row')
expect(html).toContain('row description')
expect(html).toContain('data-custom-panel')
expect(html).toContain('custom panel')
})
it('renders ONLY the custom panel when no rows are declared (unchanged behavior)', () => {
const { store, service } = mount()
const feature = {
id: 'demo',
title: () => 'Demo',
component: () => null,
settings: {
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
},
} as unknown as TabDescriptor
const html = renderToString(createElement(SettingsBody, {
feature,
prefs: SIDEBAR_PREFS_DEFAULTS,
store,
service,
onToggle: () => {},
onCommit: () => '',
onSelectValue: () => {},
onPluginToggle: () => {},
onPluginCommit: () => '',
onPluginSelectValue: () => {},
onPluginWrite: () => {},
onClose: () => {},
}))
expect(html).toContain('custom panel')
expect(html).not.toContain('Auto row')
})
})