diff --git a/README.md b/README.md index 35ac901..6d57913 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。 -![tests](https://img.shields.io/badge/tests-643%2F29%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) +![tests](https://img.shields.io/badge/tests-642%2F28%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) ## 简介 @@ -28,7 +28,7 @@ npm start ```bash npm start # 启动(UTF-8 包装,推荐;中文 Windows 别用 start:raw) -npm test # 全部单测(643 用例 / 29 文件) +npm test # 全部单测(642 用例 / 28 文件) npm run test:watch # watch 模式 npm run check # 语法 + IPC 通道配对 npm run lint # ESLint @@ -114,28 +114,16 @@ npm run build:linux # Linux → dist/(AppImage) ``` Notes/ ├── main.js # Electron 主进程 -├── main/ # 主进程模块:ai / file-ops / fs-watcher / config-store +├── main/ # 主进程模块(ai、file-ops、fs-watcher、config-store) ├── preload.js # IPC bridge ├── index.html # UI 框架 + CSP + importmap -├── src/ # 渲染端 -│ ├── ai/ # AI 面板(chat / diff / status / controller) -│ └── styles/ # CSS 分片(tokens / palettes / base / … 12 个) +├── src/ # 渲染端(ai/ 面板、styles/ CSS 分片) ├── shared/ # 主进程 / preload / 渲染端共享 -│ ├── ai-errors.js -│ ├── extension-lists.js -│ ├── friendly-fs-error.js -│ ├── markdown-diff.js -│ ├── render-sanitize.js -│ ├── settings-schema.js -│ └── slug.js ├── data/welcome.md # 首次启动种子文件 -├── patches/ # patch-package 补丁(app-builder-lib 26.15.3) -├── scripts/ -│ ├── launch.js # UTF-8 包装启动 -│ ├── check-syntax.js # 语法静态检查 -│ └── check-ipc.js # IPC 通道配对检查 +├── scripts/ # 启动包装(launch.js)+ 静态检查(check-syntax / check-ipc) └── tests/ - └── unit/ # 29 个测试文件,643 用例(vitest) + ├── unit/ # 28 个测试文件,642 用例(vitest) + └── manual/ # 手工视觉验证产物(不进版本库) ``` ## 故障排除 diff --git a/src/ai/ai-controller.js b/src/ai/ai-controller.js index c0286c2..af178a7 100644 --- a/src/ai/ai-controller.js +++ b/src/ai/ai-controller.js @@ -73,9 +73,13 @@ function linesToContextRows(content) { * @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时 * togglePanel 会拒绝打开 AI dock 并 toast(双向互斥,避免 AI dock 透过 modal * 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。 + * @param {(isOpen:boolean) => void} [options.onDiffOpenChange] - AI 修复预览面板(中间 diff 面板) + * 显隐状态变化时回调(true = 面板可见、false = 隐藏)。调用方收到 true 后可 + * 强制编辑器切到编辑模式并禁用预览 / 双栏切换;收到 false 后还原。回调仅在 + * 「与上一次状态不同」时触发,避免 setPendingDiff 内部多次刷新造成重复回调。 */ export function createAiController({ - api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen, + api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen, onDiffOpenChange, }) { if (!api || !api.aiEdit) { console.error('[ai-controller] api.aiEdit 不可用;preload 没暴露 AI 桥?'); @@ -92,6 +96,9 @@ export function createAiController({ let requestGeneration = 0; // 防抖 timer:onContentChanged 不再每个 keystroke 都跑 sync DP let recomputeTimer = null; + // AI 修复预览显隐状态:用于在 syncBodyFlag 检测到状态翻转时回调 onDiffOpenChange。 + // 初始 false —— pendingDiff 默认 null,面板隐藏。 + let diffOpenState = false; // 安全 notify 闭包:notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。 // controller 在 4 处显式调(AI_STATUS.DIFF / AI_STATUS.IDLE),其余走 chatPanel.setStatus 转发。 @@ -205,6 +212,13 @@ export function createAiController({ (pendingDiff.regions || []).length > 0 ); document.body.dataset.aiDiffOpen = has ? 'true' : 'false'; + // 通知外部「AI 修复预览」显隐状态变化 —— 外部据此强制编辑器切到编辑模式 + // 并禁用预览 / 双栏切换。仅在状态翻转时回调一次,避免 setPendingDiff 内部 + // 多次刷新造成重复通知。 + if (typeof onDiffOpenChange === 'function' && has !== diffOpenState) { + diffOpenState = has; + try { onDiffOpenChange(has); } catch { /* 不让 UI 异常阻断 AI 流程 */ } + } } // 创建面板实例(DOM 必须在 bindMountPoint 之后才能实例化) diff --git a/src/app.js b/src/app.js index 431fc1b..33684d6 100644 --- a/src/app.js +++ b/src/app.js @@ -355,6 +355,36 @@ function syncDirtyFromEditor() { return false; } +/** + * AI 修复预览显隐 → 视图模式锁定 + * + * AI 修复预览(中间 diff 面板)出现时:强制编辑器切到「编辑」模式;如果是预览 / 双栏 + * 状态则一并跳到编辑。同时锁定模式 —— 期间不让切到 preview / split。 + * + * AI 修复预览关闭时:解除模式锁,按当前 isMd 走回正常的 preview/split 禁用逻辑。 + * + * 这是 ai-controller 的 onDiffOpenChange 回调的唯一职责,不在 controller 内做 + * (controller 不依赖 DOM 视图细节,仅 fire 事件),也不在 syncBodyFlag 内做 + * (避免 controller 与 DOM 操作耦合)。 + * + * @param {boolean} isOpen + */ +function handleAiDiffOpenChange(isOpen) { + if (isOpen) { + // 锁模式到 edit —— toolbar 的 setModeLock 会让 selectMode / cycle / 外部 setMode + // 都被吸到 edit;用户点 preview / split 按钮、状态栏 chip、Ctrl+E 都无效。 + editorToolbar.setModeLock('edit'); + // 强制切到 edit(即使当前已经在 edit 也无副作用 —— applyEditorMode 内部走 _setDOM)。 + // focus: false 避免偷焦点:用户可能在 AI 输入框里编辑 prompt,不应被打断。 + applyEditorMode('edit', { focus: false }); + } else { + // 解除锁;按持久化的 state.editorMode 走正常的 md/non-md 禁用逻辑 + // (非 md 文件下 preview / split 仍会被禁用,md 文件恢复可选)。 + editorToolbar.setModeLock(null); + applyEditorMode(state.editorMode, { focus: false }); + } +} + /** * 同步侧栏底部 AI 按钮的 pressed 态 / hint —— 模块级 helper,被 mountAiPanel * 的 click handler 与 bootstrap 的 onToggleAi 快捷键 handler 共享。 @@ -1384,6 +1414,9 @@ function mountAiPanel() { // 期间手动点 AI 入口(btn-ai / 状态栏 chip / 快捷键)会让 dock 透出 modal 背景后, // 视觉重叠 + 误点。controller 用这个 getter 在 togglePanel 里把住开关。 isSettingsOpen: () => state.settingsDialogOpen, + // AI 修复预览面板(中间 diff 面板)显隐 → 视图模式锁定。打开时强制 + // 切到编辑模式 + 禁用预览 / 双栏切换;关闭时还原。具体逻辑见 handleAiDiffOpenChange。 + onDiffOpenChange: handleAiDiffOpenChange, }); // 挂载 chat dock + diff 面板到各自的 DOM 容器 if (els.aiChatDock && els.appAi) { @@ -3594,6 +3627,21 @@ function mountAiSplitter() { return Math.abs(clientX - (r.x + r.width / 2)); } + // 拖动起点的方向符号:AI 面板的列位置随模式变(split: AI 在 splitter 左 / 拖右 = AI + // 变大;preview/edit + focus: AI 在 splitter 右且贴窗口右边缘 / 拖右 AI 向左扩张、 + // splitter 视觉左移 → 与鼠标反向)。每次拖动重新比较 getBoundingClientRect: + // splitter.x >= aiPanel.right ⇒ AI 在 splitter 左侧 ⇒ +1(鼠标右移 AI 变大) + // splitter.x < aiPanel.left ⇒ AI 在 splitter 右侧 ⇒ -1(鼠标右移 AI 变小) + // 两边都不成立(splitter 跟 AI 重叠 / 不可见)兜底 +1,避免异常路径写出 0 宽度。 + function getDragSign() { + if (!els.appAi || !els.aiSplitter) return 1; + const ai = els.appAi.getBoundingClientRect(); + const sp = els.aiSplitter.getBoundingClientRect(); + if (sp.x >= ai.right - 0.5) return 1; // AI 在 splitter 左侧 + if (sp.right <= ai.left + 0.5) return -1; // AI 在 splitter 右侧 + return 1; + } + const splitter = createSplitter({ handle: els.aiSplitter, axis: 'x', @@ -3601,6 +3649,7 @@ function mountAiSplitter() { setSize: applyAiWidth, clampSize: clampAiWidth, initialSize: getInitialWidth, + getDragSign, // audit #1:持久化失败要让用户看到 —— 下次启动 AI 面板宽度丢失、用户不知情。 onDragEnd: () => { const finalW = clampAiWidth(getAiPx()); diff --git a/src/editor-toolbar.js b/src/editor-toolbar.js index a8670b8..a13e8e6 100644 --- a/src/editor-toolbar.js +++ b/src/editor-toolbar.js @@ -13,6 +13,10 @@ export class EditorToolbar { constructor({ elements, onChange }) { this.elements = elements; this.onChange = onChange; + // 模式锁:临时把视图锁在某个模式(null = 不锁)。用于「AI 修复预览 + // 出现时强制编辑」等场景 —— setMode / selectMode / cycle 都会落到锁定值, + // 用户点 toolbar / 状态栏 chip / Ctrl+E 切不到其它模式。 + this._modeLock = null; if (elements.btnPreview) { elements.btnPreview.addEventListener('click', () => this.selectMode('preview')); @@ -25,12 +29,33 @@ export class EditorToolbar { } } + /** + * 临时锁定视图模式。锁定后任何切换(按钮 / cycle / 外部 setMode)都会被吸到该模式。 + * 传 null 解除锁定。锁定值必须为合法模式,否则忽略。 + * + * 配合 applyEditorMode 使用:调用方先 setModeLock 再 applyEditorMode(锁定值), + * DOM / state 一起落到目标模式;解除时 setModeLock(null) 后 applyEditorMode(state.editorMode) + * 走正常的 md/non-md 禁用逻辑。 + * + * @param {'preview'|'edit'|'split'|null} mode + */ + setModeLock(mode) { + if (mode === null) { + this._modeLock = null; + return; + } + if (!EDITOR_MODES.includes(mode)) return; + this._modeLock = mode; + } + /** * 由用户点击按钮或外部 API 主动选择模式。触发 onChange(用于持久化)。 * @param {'preview'|'edit'|'split'} mode */ selectMode(mode) { if (!EDITOR_MODES.includes(mode)) return; + // 模式锁:拒绝一切「切到非锁定模式」的请求(按钮 / Ctrl+E / 状态栏 chip 都走这里) + if (this._modeLock !== null && mode !== this._modeLock) return; if (this.getMode() === mode) return; // 同模式 → 不重复触发 this._setDOM(mode); if (this.onChange) this.onChange(mode); @@ -39,11 +64,15 @@ export class EditorToolbar { /** * 同步 DOM 与按钮高亮,但**不**触发 onChange。 * 用于「外部(持久化设置)改变了模式」的场景,避免循环。 + * + * 模式锁生效时,setMode 会被吸到锁定值 —— 这是关键:applyEditorMode 被外部调用 + * (比如 AI 修复预览打开时其它地方误触发 applyEditorMode('preview'))也会被纠正回锁定模式。 * @param {'preview'|'edit'|'split'} mode */ setMode(mode) { if (!EDITOR_MODES.includes(mode)) return; - this._setDOM(mode); + const target = this._modeLock !== null ? this._modeLock : mode; + this._setDOM(target); } /** @@ -56,8 +85,16 @@ export class EditorToolbar { /** * 三态循环:preview → edit → split → preview * 触发 onChange 回调。 + * + * 模式锁生效时,cycle 不切模式(仅做一次 DOM 对齐,防御 lock 期间其它路径改了 dataset)。 */ cycle() { + if (this._modeLock !== null) { + if (this.getMode() !== this._modeLock) { + this._setDOM(this._modeLock); + } + return; + } const cur = this.getMode(); const idx = EDITOR_MODES.indexOf(cur); const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length]; diff --git a/src/splitter.js b/src/splitter.js index f729499..5ab7d9b 100644 --- a/src/splitter.js +++ b/src/splitter.js @@ -54,6 +54,13 @@ * @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners] * 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。 * dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。 + * @property {() => (1 | -1)} [getDragSign] - 拖动起点回调,返回 dCoord 的符号: + * +1(默认)= 鼠标右移让目标变大;-1 = 鼠标右移让目标变小。 + * 用于「目标元素可以在 splitter 两侧」的场景 —— 例如 AI 面板:split 模式下 + * AI 在 splitter 左边(鼠标右移 = AI 变大),preview/edit + focus 模式下 AI 在 + * splitter 右边且贴着窗口右边缘(鼠标右移 AI 会向左扩张,splitter 视觉上反而 + * 左移 → 与鼠标反向)。每次 startDrag 时调用一次,结果用于整次拖动。 + * 不传 = 始终 +1(语义同原 createSplitter,老调用方无需改)。 */ /** @@ -93,6 +100,7 @@ export function createSplitter(opts) { body = (typeof document !== 'undefined' ? document.body : null), hitZone = null, windowListeners = null, + getDragSign = null, } = opts; if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素'); @@ -119,6 +127,7 @@ export function createSplitter(opts) { let pointerId = null; let startCoord = 0; let startSize = 0; + let dragSign = 1; // 当前拖动的 dCoord 符号;getDragSign 在 startDrag 时赋值 function coordOf(eventLike) { return eventLike[coordKey]; @@ -130,6 +139,21 @@ export function createSplitter(opts) { handle.classList.add('is-dragging'); startCoord = coord; startSize = getSize(); + // 拖动起点的 dCoord 符号:getDragSign 让调用方按当前布局动态决定(+1 / -1)。 + // AI 面板这种「目标元素可落在 splitter 任一侧」的场景,必须每次拖动重新检测, + // 否则切模式后方向仍按旧符号计算,splitter 视觉上与鼠标反向。 + // 兜底:getDragSign 抛错或返回非 -1/1 时退化为 +1(默认方向), + // 保证一次坏回调不会让整次拖动卡死(用户能继续拖回正常状态)。 + if (getDragSign) { + try { + const sign = getDragSign(); + dragSign = sign === -1 ? -1 : 1; + } catch { + dragSign = 1; + } + } else { + dragSign = 1; + } if (body) { body.style.cursor = cursor; body.style.userSelect = 'none'; @@ -138,7 +162,7 @@ export function createSplitter(opts) { function applyDrag(coord) { const dCoord = coord - startCoord; - setSize(clampSize(startSize + dCoord)); + setSize(clampSize(startSize + dragSign * dCoord)); } function endDrag() { diff --git a/tests/unit/editor-toolbar.test.js b/tests/unit/editor-toolbar.test.js index 56b5993..82b100c 100644 --- a/tests/unit/editor-toolbar.test.js +++ b/tests/unit/editor-toolbar.test.js @@ -226,4 +226,121 @@ describe('EditorToolbar a11y', () => { it('EDIT_MODES 导出值与内部一致', () => { expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']); }); +}); + +describe('EditorToolbar.setModeLock(AI 修复预览场景)', () => { + it('锁到 edit:selectMode(\'preview\') / selectMode(\'split\') 被拒绝', () => { + // 场景:AI 修复预览打开,强制编辑器锁在编辑模式;用户点预览 / 双栏按钮无效。 + const els = setupDOM('preview'); + const onChange = vi.fn(); + const tb = new EditorToolbar({ elements: els, onChange }); + + tb.setModeLock('edit'); + // 先把 dataset 落到锁定值(外部通常会接着调 applyEditorMode('edit')) + tb.setMode('edit'); + + // 尝试切到 preview / split —— selectMode 应当 no-op(拒绝非锁定模式) + tb.selectMode('preview'); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + tb.selectMode('split'); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + // 切到锁定值本身:允许(同模式早退,不会触发 onChange) + tb.selectMode('edit'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('锁到 edit:cycle 不切模式,只做一次 DOM 对齐', () => { + // 场景:用户按 Ctrl+E 或点状态栏 chip 触发 cycle —— 锁定期间不应跳到 split。 + const els = setupDOM('split'); // 起始非锁定值,验证 cycle 会把它吸回 edit + const onChange = vi.fn(); + const tb = new EditorToolbar({ elements: els, onChange }); + + tb.setModeLock('edit'); + tb.cycle(); + + // cycle 应先把 DOM 对齐到 edit(dataset 改写),但不触发 onChange + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + // 再按一次:保持 edit,仍不触发 onChange + tb.cycle(); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('锁到 edit:setMode 强制对齐到 edit(外部误调也能纠正)', () => { + // 场景:锁定期间其它路径误调 setMode('preview') —— 仍应落到锁定值 edit。 + const els = setupDOM('split'); + const tb = new EditorToolbar({ elements: els, onChange: vi.fn() }); + + tb.setModeLock('edit'); + + tb.setMode('preview'); + expect(document.body.dataset.editorMode).toBe('edit'); + + tb.setMode('split'); + expect(document.body.dataset.editorMode).toBe('edit'); + }); + + it('按钮 click 走 selectMode:锁定期间点预览 / 双栏按钮无反应', () => { + // 端到端:按钮 click → selectMode → 被锁拒绝。覆盖顶层 use case。 + const els = setupDOM('preview'); + const onChange = vi.fn(); + const tb = new EditorToolbar({ elements: els, onChange }); + + tb.setModeLock('edit'); + tb.setMode('edit'); + + els.btnPreview.click(); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + els.btnSplit.click(); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + // 编辑按钮仍然有效(点击 = 当前模式,selectMode 同模式早退,无副作用) + els.btnEdit.click(); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('setModeLock(null):解除锁定后切模式恢复正常', () => { + const els = setupDOM('preview'); + const onChange = vi.fn(); + const tb = new EditorToolbar({ elements: els, onChange }); + + tb.setModeLock('edit'); + tb.setMode('edit'); + + // 锁定期间尝试切到 split —— 被拒 + tb.selectMode('split'); + expect(document.body.dataset.editorMode).toBe('edit'); + expect(onChange).not.toHaveBeenCalled(); + + // 解除锁定 + tb.setModeLock(null); + + // 切到 split 正常生效 + tb.selectMode('split'); + expect(document.body.dataset.editorMode).toBe('split'); + expect(onChange).toHaveBeenCalledWith('split'); + }); + + it('setModeLock 非法值:忽略(保持当前锁状态)', () => { + const els = setupDOM(); + const tb = new EditorToolbar({ elements: els, onChange: vi.fn() }); + + tb.setModeLock('edit'); + tb.setMode('edit'); + + // 非法值:保持 edit 锁定 + tb.setModeLock('wide'); + tb.selectMode('preview'); + expect(document.body.dataset.editorMode).toBe('edit'); + }); }); \ No newline at end of file diff --git a/tests/unit/splitter.test.js b/tests/unit/splitter.test.js new file mode 100644 index 0000000..36fa8ee --- /dev/null +++ b/tests/unit/splitter.test.js @@ -0,0 +1,225 @@ +// src/splitter.js —— 通用拖拽分割条工厂测试 +// +// 覆盖 getDragSign 的方向反转逻辑: +// - 不传 getDragSign(默认)→ 鼠标右移让目标变大(+1) +// - 传 getDragSign 返回 -1 → 鼠标右移让目标变小(用于 AI 面板在 splitter +// 右侧且贴窗口右边缘的场景,避免 splitter 视觉反向跟随鼠标) +// - getDragSign 抛错或返回非 -1/1 → 兜底 +1,不让一次坏拖动摧毁整个交互 +// +// 通过 jsdom 模拟 document/element,绕过 mountAiSplitter 的 DOM 依赖,直接 +// 驱动 createSplitter 的 mousedown/mousemove 路径验证 setSize 调用序列。 +// +// 不覆盖: +// - 命中区(hitZone)扩展 → 那是 mountSidebarSplitter/mountAiSplitter 的 +// 调用方职责,工厂只关心"接到 hit zone 触发后行为正确"。 +// - touch 路径 → 与 mouse 路径走同一道 startDrag/applyDrag,代码层同源。 + +// @vitest-environment jsdom + +import { describe, it, expect, vi } from 'vitest'; +import { createSplitter } from '../../src/splitter.js'; + +/** + * 在 jsdom 里构造一个最小可用的 splitter: + * - handle 是按钮元素 + * - getSize/setSize/clampSize/onDragEnd 由用例注入 + * - 不传 hitZone / initialSize / windowListeners(与 mountAiSplitter 主路径 + * 解耦,单独验证方向反转这条新分支) + * + * 返回 {handle, fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls}: + * - fireMouseDown(clientX) / fireMouseMove / fireMouseUp 模拟真实事件 + * - setSizeCalls 记录所有 setSize 调用,可断言方向与幅度 + */ +function makeSplitter({ getSize, setSize, clampSize, getDragSign }) { + const handle = document.createElement('div'); + handle.style.width = '4px'; + document.body.appendChild(handle); + + const onDragEnd = vi.fn(); + const inst = createSplitter({ + handle, + axis: 'x', + getSize, + setSize, + clampSize, + onDragEnd, + getDragSign, + }); + + const setSizeCalls = []; + const wrappedSetSize = (px) => { + setSizeCalls.push(px); + setSize(px); + }; + // 重新绑一个 setSize 用于观察 + inst.dispose(); + const inst2 = createSplitter({ + handle, + axis: 'x', + getSize, + setSize: wrappedSetSize, + clampSize, + onDragEnd, + getDragSign, + }); + + const fireMouseDown = (clientX) => { + handle.dispatchEvent(new MouseEvent('mousedown', { clientX, bubbles: true })); + }; + const fireMouseMove = (clientX) => { + document.dispatchEvent(new MouseEvent('mousemove', { clientX, bubbles: true })); + }; + const fireMouseUp = (clientX = 0) => { + document.dispatchEvent(new MouseEvent('mouseup', { clientX, bubbles: true })); + }; + + return { + handle, + inst: inst2, + onDragEnd, + fireMouseDown, + fireMouseMove, + fireMouseUp, + setSizeCalls, + }; +} + +describe('createSplitter —— 方向反转 (getDragSign)', () => { + it('不传 getDragSign:鼠标右移 → 目标变大(默认 +1)', () => { + let current = 300; + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls, onDragEnd } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(200, Math.min(800, px)), + }); + + fireMouseDown(500); + fireMouseMove(560); // +60 + fireMouseUp(); + + expect(setSizeCalls).toEqual([360]); // 300 + 60 + expect(onDragEnd).toHaveBeenCalledWith(360); + }); + + it('getDragSign 返回 -1:鼠标右移 → 目标变小(AI 面板在 splitter 右侧场景)', () => { + let current = 360; + const signProvider = vi.fn(() => -1); + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls, onDragEnd } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + getDragSign: signProvider, + }); + + fireMouseDown(500); + fireMouseMove(560); // +60 但应反向 → 减 60 + fireMouseUp(); + + expect(setSizeCalls).toEqual([300]); // 360 - 60 + expect(signProvider).toHaveBeenCalledTimes(1); // 仅 startDrag 时调用一次 + expect(onDragEnd).toHaveBeenCalledWith(300); + }); + + it('getDragSign 返回 +1:与默认行为一致(防御性测试)', () => { + let current = 360; + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + getDragSign: () => 1, + }); + + fireMouseDown(500); + fireMouseMove(560); + fireMouseUp(); + + expect(setSizeCalls).toEqual([420]); // 360 + 60 + }); + + it('getDragSign 在每次 startDrag 时重新求值(应对切模式)', () => { + let current = 360; + // 模拟「第一次拖动(split 模式)= +1,第二次拖动(preview 模式)= -1」 + const signProvider = vi.fn() + .mockReturnValueOnce(1) + .mockReturnValueOnce(-1); + + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + getDragSign: signProvider, + }); + + // 第一次拖动(+1):鼠标右移 50 → current 应 +50 + fireMouseDown(500); + fireMouseMove(550); + fireMouseUp(); + expect(current).toBe(410); + + // 第二次拖动(-1):鼠标右移 50 → current 应 -50 + fireMouseDown(500); + fireMouseMove(550); + fireMouseUp(); + expect(current).toBe(360); + + expect(signProvider).toHaveBeenCalledTimes(2); + expect(setSizeCalls).toEqual([410, 360]); + }); + + it('getDragSign 抛错 → 兜底 +1,不让坏回调炸掉拖动', () => { + let current = 360; + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + // 抛错 → 工厂内部 try/catch 兜底为 +1,拖动继续按默认方向进行 + getDragSign: () => { throw new Error('layout not ready'); }, + }); + + fireMouseDown(500); // 内部吞掉 getDragSign 抛错,dragSign = 1 + fireMouseMove(560); // startSize(360) + dragSign(1) * +60 = 420 + fireMouseUp(); + + expect(setSizeCalls).toEqual([420]); + }); + + it('getDragSign 返回非 -1/1 的奇怪值 → 归一为 +1', () => { + let current = 360; + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + getDragSign: () => 0, // 0 不是合法符号 → 应被识别为 +1 + }); + + fireMouseDown(500); + fireMouseMove(560); + fireMouseUp(); + + expect(setSizeCalls).toEqual([420]); // 360 + 60(按 +1 算) + }); + + it('reverse 方向下 clamp 仍生效(防止拖出 MIN_WIDTH)', () => { + let current = 230; + const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } = + makeSplitter({ + getSize: () => current, + setSize: (px) => { current = px; }, + clampSize: (px) => Math.max(220, Math.min(720, px)), + getDragSign: () => -1, + }); + + fireMouseDown(500); + // 鼠标右移 100 → 当前期望 current - 100 = 130,但 clamp 应钉到 MIN=220 + fireMouseMove(600); + fireMouseUp(); + + expect(setSizeCalls).toEqual([220]); + }); +}); \ No newline at end of file