Files
apes-sidebar/tests/prefs.spec.ts
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

255 lines
12 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.
import { describe, expect, it } from 'vitest'
import { loadBootDecision, loadExternalDisable, loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** A fake settings wire face whose settingsGet resolves to one raw value. */
const wire = (value: unknown): SidebarSettingsClient => ({
settingsGet: async () => ({ value, revision: 1 }),
settingsUpdate: async () => ({ value, revision: 2 }),
})
/** A fake wire carrying an explicit externalDisable flag. */
const wireWithDisable = (externalDisable: boolean): SidebarSettingsClient => ({
settingsGet: async () => ({ value: {}, revision: 1, externalDisable }),
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const rejecting = (): SidebarSettingsClient => ({
settingsGet: async () => { throw new Error('route rejected') },
settingsUpdate: async () => { throw new Error('route rejected') },
})
describe('side card preferences', () => {
it('falls back to the defaults when the settings route rejects', async () => {
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('falls back to the defaults when the value is absent or malformed', async () => {
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('parses a valid value', async () => {
expect(await loadPrefs(wire({ autoOpenSubagent: false, agentOpenTools: true })))
.toEqual({
autoOpenSubagent: false,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: true,
editorExplorer: false,
workspaceFence: true,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ autoOpenSubagent: 'no', agentOpenTools: 'yes' })))
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
})
it('defaults autoOpenSubagent to true and the agent toggles to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({})))
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
})
expect((await loadPrefs(wire({ autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The sidebar-open tool is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({}))).agentOpenTools)
.toBe(false)
expect((await loadPrefs(wire({ agentOpenTools: 1 }))).agentOpenTools)
.toBe(false)
expect((await loadPrefs(wire({ agentOpenTools: true }))).agentOpenTools)
.toBe(true)
// The job auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
expect((await loadPrefs(wire({ autoOpenJobs: false }))).autoOpenJobs)
.toBe(false)
})
it('defaults both mobile adaptations to on; only an explicit false disarms one', async () => {
// Absent or malformed → ON: a phone is exactly the case these exist for,
// and each one only changes what happens on a NARROW viewport.
for (const key of ['mobileNoAutoOpen', 'mobileDefaultTree'] as const) {
expect((await loadPrefs(wire({})))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 'yes' })))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 1 })))[key]).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ [key]: false })))[key]).toBe(false)
expect((await loadPrefs(wire({ [key]: true })))[key]).toBe(true)
}
})
it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => {
// Absent or malformed → off (separate file windows are the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: 'yes' }))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: 1 }))).editorExplorer).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ editorExplorer: false }))).editorExplorer).toBe(false)
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
})
it('defaults workspaceFence to true; only an explicit false disarms the containment guard', async () => {
// Absent or malformed → on (the fs routes keep refusing outside paths).
expect((await loadPrefs(wire({}))).workspaceFence).toBe(true)
expect((await loadPrefs(wire({ workspaceFence: 'no' }))).workspaceFence).toBe(true)
expect((await loadPrefs(wire({ workspaceFence: 0 }))).workspaceFence).toBe(true)
// An explicit false survives (the one-click off in the fence error notice).
expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false)
})
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
// Absent or malformed → auto (plain web keeps the untouched layout).
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarScheme: 'weird' }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarScheme: 1 }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({}))).titleBarPresetId).toBe('')
expect((await loadPrefs(wire({ titleBarPresetId: 5 }))).titleBarPresetId).toBe('')
expect((await loadPrefs(wire({}))).customCss).toBe('')
expect((await loadPrefs(wire({ customCss: 7 }))).customCss).toBe('')
// Valid values survive verbatim (including the explicit web scheme).
const picked = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', customCss: 'html { }' }))
expect(picked.titleBarScheme).toBe('preset')
expect(picked.titleBarPresetId).toBe('dsh-desktop')
expect(picked.customCss).toBe('html { }')
expect((await loadPrefs(wire({ titleBarScheme: 'web' }))).titleBarScheme).toBe('web')
})
it('migrates LEGACY documents that ALREADY HAVE VALUES into the custom scheme', async () => {
// A pre-scheme document with the manual compat flag on maps to the
// custom scheme, keeping the strip px the user chose.
const migrated = await loadPrefs(wire({ titleBarCompat: true, titleBarStripPx: 56 }))
expect(migrated.titleBarScheme).toBe('custom')
expect(migrated.titleBarStripPx).toBe(56)
// A non-default strip px alone (only reachable through the old gear
// popup) also counts as "already has values" → custom.
const stripOnly = await loadPrefs(wire({ titleBarStripPx: 48 }))
expect(stripOnly.titleBarScheme).toBe('custom')
expect(stripOnly.titleBarStripPx).toBe(48)
// A stored scheme always wins over the legacy fields (round-trip of the
// mirrored write: preset stays preset even though the mirror is true).
const roundTrip = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarCompat: true }))
expect(roundTrip.titleBarScheme).toBe('preset')
// Legacy off / absent / default strip → the conservative auto scheme.
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({ titleBarStripPx: 40 }))).titleBarScheme).toBe('auto')
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
})
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
// Absent or malformed → 40 (the strip default).
expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40)
expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40)
// Out-of-range numbers clamp into 0–120.
expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120)
expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48)
// In-range values survive verbatim.
expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0)
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
// Non-boolean entries are dropped; boolean entries survive verbatim.
const parsed = await loadPrefs(wire({
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
viewersEnabled: { image: false, code: 1 },
}))
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
expect(parsed.viewersEnabled).toEqual({ image: false })
})
})
describe('external disable (aionui-panel provider choice)', () => {
it('reads true when the host reports the aionui provider active', async () => {
expect(await loadExternalDisable(wireWithDisable(true))).toBe(true)
})
it('reads false when the host reports no external disable', async () => {
expect(await loadExternalDisable(wireWithDisable(false))).toBe(false)
})
it('reads false when the flag is absent or the wire rejects', async () => {
expect(await loadExternalDisable(wire({}))).toBe(false)
expect(await loadExternalDisable(rejecting())).toBe(false)
})
})
describe('boot decision (one fetch for prefs + external disable)', () => {
it('answers both decisions from a single settingsGet call', async () => {
let calls = 0
const counting = (): SidebarSettingsClient => ({
settingsGet: async () => { calls += 1; return { value: { autoOpenSubagent: false, editorExplorer: true }, revision: 1, externalDisable: true } },
settingsUpdate: async () => ({ value: {}, revision: 2 }),
})
const decision = await loadBootDecision(counting())
expect(calls, 'the boot path must fetch the settings document exactly once').toBe(1)
expect(decision.suspended).toBe(true)
expect(decision.prefs.autoOpenSubagent).toBe(false)
})
it('falls back to the defaults + not suspended on any failure', async () => {
const decision = await loadBootDecision(rejecting())
expect(decision).toEqual({ prefs: SIDEBAR_PREFS_DEFAULTS, suspended: false })
})
it('reads suspended false when the flag is absent', async () => {
const decision = await loadBootDecision(wire({ titleBarStripPx: 60 }))
expect(decision.suspended).toBe(false)
expect(decision.prefs.titleBarStripPx).toBe(60)
})
})