Slate v2 Backspace/Caret 测试计划:用 TDD 关闭「删除后光标消失」浏览器编辑覆盖缺口
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
本文档基于 plate 仓库中的《Slate v2 Backspace/Caret Testing Plan》,系统讲解如何在 Slate v2 中围绕「用户按 Backspace/Delete 删除后,可见光标仍停留在编辑器内、后续输入仍能落在原逻辑光标处」这一核心用户契约,构建 TDD 优先的浏览器编辑测试。文章完整继承了原计划的九阶段执行序列、覆盖度矩阵、聚焦命令与最终门槛命令,并结合仓库中的模型层删除实现 deleteText 与 slate-browser 测试框架文档 做了源码级深化。读完本文,你将掌握:如何为删除类 bug 写出「四层断言 + 跟随输入」的浏览器行、如何按职责分类修复最小持有者、以及如何把单个红色用例扩展为跨装饰文本、内联 void、大文档、Shadow DOM 与多浏览器矩阵的完整覆盖通道。
背景:一个让用户「删着删着就删不动了」的 Bug 类
计划的起点是一个具体的用户可见缺陷:按 Backspace 删除后,可见光标从编辑器中消失,用户无法继续输入。此类缺陷之所以难以被既有测试捕获,是因为现有套件对删除过于乐观——它证明了大量「模型路径」和「直接同步路径」,但并未证明用户真正关心的完整链路:
- 用户按下 Backspace;
- 内容被正确修改;
- Slate selection 保持非
null且正确; - DOM selection 仍位于编辑器内部;
- 可见光标仍停留在后续输入将继续的位置;
- 下一个键入的字符恰好落在该光标处。
原计划的核心判断是:如果某行测试没有证明「删除后的跟随输入」,它就没有关闭这一 Bug 类。这是全文最重要的验收哲学,后续所有阶段都是围绕它展开的。
当前覆盖度真相:哪些已强、哪些缺失
原计划首先对既有浏览器编辑测试做了诚实的盘点,这决定了后续补覆盖的优先级。
已经较强的部分:
richtext.test.ts中的浏览器插入/光标行;- 浏览器插入后的可见光标行;
- 浏览器/模型编辑后的 undo;
- 装饰文本的 copy/cut/paste 证明;
- 通过语义句柄(semantic handles)完成的大文档直接同步删除(向前/向后);
- Shadow DOM 换行输入;
- IME 组合输入行。
缺失或薄弱的部分:
- 正常
richtext中浏览器选区后的原生 Backspace; - 块尾浏览器插入后的原生 Backspace;
- 标点前浏览器插入后的原生 Backspace;
- 自定义渲染/装饰 leaf 内的原生 Backspace;
- Backspace 后的跟随输入;
- Backspace 后的 DOM selection/caret 断言;
- Backspace 后的 selection-null 回归断言;
- 正常 richtext 中原生 Delete 与 Backspace 的对等性;
- 原生 Backspace/Delete 的选区范围删除 + 跟随输入;
- Backspace 行的跨浏览器分类。
可以看到,缺失项高度集中在「原生键盘传输(native keyboard transport)+ 可见光标」这条用户路径上,而非纯模型语义路径。
核心 Bug 契约:四层断言 + 跟随输入
对于每一条 Backspace/Delete 用户路径行,原计划要求同时断言四个层面:
- 模型文本(Model text);
- Slate selection;
- 可见 DOM 文本(Visible DOM text);
- DOM selection/caret。
然后在删除之后键入一个跟随字符,并断言它落在同一逻辑光标处。原计划给出的模板断言如下:
await editor.assert.text(expectedText); await editor.assert.selection(expectedSelection); await editor.assert.domSelection(expectedDOMSelection); await editor.type("Z"); await editor.assert.text(expectedTextAfterFollowUpTyping); await editor.assert.selection(expectedSelectionAfterTyping);对于尚未迁移到slate-browser的行,richtext.test.ts中的等价本地助手也必须断言模型文本、DOM 文本、模型 selection、DOM selection 以及跟随输入。
这条契约的合理性可以从仓库中一份真实逻辑错误记录得到印证:Slate React model-owned insert must repair the DOM caret 描述了同族问题:在自定义渲染 leaf 中标点前插入时,模型文本与模型 selection 都正确,但浏览器选区锚点落在包裹 span 的 offset0上,导致 Chrome 把光标画在插入字符之前。该记录明确指出:「模型 selection 断言与文本断言无法捕获这类问题,浏览器测试必须同时断言 DOM caret 的节点与 offset,或比较 caret 矩形」。这正是「四层断言」存在的理由。
测试所有者文件与职责划分
原计划把这一覆盖通道的代码落点做了明确分工(注:下述.tmp/slate-v2/...路径来自计划 frontmatter 中声明的source_repos工作树,即 slate-v2 仓库布局;本 plate 仓库中对应的模型层实现见 packages/slate/src/internal/transforms/deleteText.ts):
浏览器主测试行:
.tmp/slate-v2/playwright/integration/examples/richtext.test.ts.tmp/slate-v2/playwright/integration/examples/highlighted-text.test.ts.tmp/slate-v2/playwright/integration/examples/large-document-runtime.test.ts.tmp/slate-v2/playwright/integration/examples/shadow-dom.test.ts.tmp/slate-v2/playwright/integration/examples/editable-voids.test.ts
测试助手持有者:
.tmp/slate-v2/packages/slate-browser/src/playwright/index.ts
行失败时的产品持有者(按职责归类):
keyboard-input-strategy.ts:原生 Backspace 处理阻碍了浏览器选区修复或路由了错误删除意图;model-input-strategy.ts:删除更新了模型但丢失了折叠 selection;selection-reconciler.ts:模型 selection 正确但 DOM/caret 错误;dom-repair-queue.ts:模型持有的删除在提交后需要 DOM 修复;editable.tsx:仅当协调器或包装器接线本身是实测所有者时才归它。
值得说明的是,plate 仓库中 slate-browser 测试框架的总体设计记录在 docs/slate-browser/overview.md,其中强调「最好的编辑器测试框架是分层的」,反对「一个 runner 统治一切」,并给出了 Lexical 的 IME 真实感、Slate 的示例驱动 Playwright harness、edix/rich-textarea 的浏览器契约速度等借鉴方向——本计划中「每个示例文件负责一类行」的组织方式正是该分层思想的落地。
TDD 序列:九个阶段的渐进展开
原计划明确要求TDD 优先、不做横向大套件、一次只处理一个用户行为:写一行失败的浏览器测试 → 确认失败即用户可见 Bug → 修复最小持有者 → 重跑该行 → 绿色后再扩展覆盖。
Phase 1:红色曳光弹(Red Tracer Bullet)
在richtext.test.ts中新增一行 Chromium 用例:
test('keeps caret editable after browser Backspace at selected text end', ...)Setup:
- 打开
/examples/richtext; - 支持时用真实 DOM selection 在第一块末尾选中/折叠光标;
- 按下原生
Backspace。
断言:第一块被删除一个字符;Slate selection 非null;Slate selection 折叠在新的逻辑末尾;DOM selection 折叠在编辑器文本节点内部;可见光标停留在第一块新末尾;键入Z落在此光标处。
预期的首次失败形态(三者之一):
- Slate selection 变为
null; - 或 DOM selection 离开编辑器;
- 或跟随输入没有落位。
如果该行立即通过,则收紧该行以匹配上报 Bug:使用手工复现中的精确 richtext 块/位置、断言 DOM caret 节点与 offset(而非仅模型 selection)、断言跟随输入。
最早聚焦命令:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Backspace at selected text end"Phase 2:修复被实测的所有者
「不要猜」。先对失败进行分类,再决定归属哪个持有者:
- 原生 Backspace 处理阻止了浏览器选区修复或路由了错误删除意图 →
keyboard-input-strategy; - 删除更新了模型但丢失折叠 selection →
model-input-strategy; - 模型 selection 正确但 DOM/caret 错误 →
selection-reconciler; - 模型持有的删除提交后需要修复 →
dom-repair-queue; - 协调器接线阻止了正确持有者运行 →
EditableDOMRoot。
最小修复规则:
- 保留浏览器编辑语义;
- 不添加仅供测试的钩子(test-only hooks);
- 不用纯模型修复去解决可见光标 Bug;
- 若删除的是选中/范围内容,用 ref 保留删除起点,并在变更后同时恢复模型与 DOM selection。
这条「用 ref 保留起点、变更后恢复 selection」的规则,与仓库中模型层deleteText的实现高度一致:见 packages/slate/src/internal/transforms/deleteText.ts,其中使用editor.api.pathRef(...)与editor.api.pointRef(...)记录起点/终点引用,在删除与合并节点之后通过editor.tf.select(point)恢复 selection——这正是「模型层删除如何不丢光标」的底层答案。
绿色命令:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Backspace at selected text end"Phase 3:扩展原生 Backspace 行
一次一行,逐步加入:
- 浏览器插入到块尾后的 Backspace;
- 尾部标点前的 Backspace;
- 普通文本 leaf 内的 Backspace;
- 自定义渲染 leaf 内的 Backspace;
- 选中范围删除后的 Backspace。
每一行都必须包含跟随输入断言。聚焦命令:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Backspace"Phase 4:原生 Delete(前向删除)行
镜像 Backspace 套件,覆盖:
- 浏览器选中的中间点 Delete;
- 标点前 Delete;
- 跨选中范围 Delete;
- Delete 后跟随输入。
除非特定行为不是原生浏览器传输,否则这些行不得依赖语义句柄证明。聚焦命令:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Delete"Phase 5:装饰文本删除覆盖
使用highlighted-text.test.ts,覆盖:
- 装饰边界 Backspace;
- 装饰边界 Delete;
- 跨装饰多 leaf 文本的范围删除;
- 每次删除后的跟随输入。
断言项除语义文本、Slate selection、DOM selection 外,还包括:期望存在时高亮包裹层仍存在;无仅渲染用的包裹层泄漏进选中文本/剪贴板行为。聚焦命令:
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "delete|Backspace"Phase 6:内联/Void 删除覆盖
在最终 API 面支持的前提下使用现有 inline/void 示例:
- 内联 void mention/card 旁的 Backspace;
- 内联 void mention/card 旁的 Delete;
- 选中内联 void 的 Backspace;
- 删除后的跟随输入。
若某示例/测试只服务于已死的遗留行为,应硬切(hard-cut)或重写,而不是保留过时期望。聚焦命令:
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromiumPhase 7:大文档运行时删除覆盖
现有大文档行通过语义句柄做直接同步删除;本阶段在诚实可行处补充原生或壳路径行:
- 激活/挂载 shell 后的原生 Backspace;
- 激活/挂载 shell 后的原生 Delete;
- 直接 DOM 文本同步后的 Backspace;
- 直接 DOM 文本同步后的 Delete;
- 每次删除后的跟随输入。
语义句柄行仅保留给模型路径证明,用户路径行必须走原生键盘传输。聚焦命令:
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "delete|Backspace"Phase 8:Shadow DOM 删除覆盖
- Shadow DOM 编辑器内的 Backspace;
- Shadow DOM 编辑器内的 Delete;
- Shadow DOM 中换行后的 Backspace;
- 删除后的跟随输入。
聚焦命令:
bunx playwright test ./playwright/integration/examples/shadow-dom.test.ts --project=chromium --grep "Backspace|Delete|line"Phase 9:浏览器矩阵扩展
Chromium 行全绿之后依次扩展:Firefox → WebKit → Mobile。不做整批跳过(Do not blanket skip)。
对每个失败项目,必须分类为:产品持有、浏览器持有、测试 harness 持有、或可接受的平台限制。然后:
- 若该行描述的是受支持行为,保留该行;
- 若传输方式不可能但行为受支持,通过另一条诚实路径重写该行;
- 对接受/推迟的行,记录精确理由。
矩阵命令:
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=firefox --grep "Backspace|Delete" bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=webkit --grep "Backspace|Delete" bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=mobile --grep "Backspace|Delete"覆盖度矩阵:按通道评估信心与所需证明
原计划用一张矩阵固化各删除通道的现状与目标,规划时可直接对照使用:
| Lane | Current confidence | Needed proof |
|---|---|---|
| Insert after browser selection | Good | Keep existing caret rows |
| Backspace after browser selection | Bad | Native Backspace + model/DOM/caret + follow-up typing |
| Delete after browser selection | Bad | Native Delete + model/DOM/caret + follow-up typing |
| Expanded range delete | Medium | Native Backspace/Delete rows, not only semantic handles |
| Decorated text delete | Weak | Highlighted-text delete/backspace rows |
| Inline/void delete | Weak | Void/inline deletion + follow-up typing |
| Large-doc delete | Medium | Add native user-path rows beside semantic-handle rows |
| Shadow DOM delete | Weak | Backspace/Delete inside Shadow DOM |
| IME deletion | Not active owner | Add only after basic deletion rows are stable |
| Mobile deletion | Unknown | Expand after Chromium owner is closed |
要点:插入通道信心已「Good」,而 Backspace/Delete 通道是「Bad」——这正是本计划要关闭的缺口;IME 删除与移动端删除被明确标注为暂不活跃/未知,只有在基础删除行稳定后才进入。
最终门槛:本覆盖通道的放行命令集
原计划定义了一组从聚焦到发布级的分层门槛:
聚焦门槛(Chromium):
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Backspace|Delete|visual caret|browser-selected end"扩展 Chromium 门槛(覆盖五个示例文件):
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/large-document-runtime.test.ts ./playwright/integration/examples/shadow-dom.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium产品变更后的包级门槛:
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1 bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1 bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1 bun run lint:fix bun run lint bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force最终发布质量门槛:
bun test:integration-local原计划特别强调:bun test:integration-local只有在失败/跳过项被显式分类时才能关闭本通道;一个全绿的聚焦 Chromium 套件并不等于完整的浏览器编辑关闭。
停止规则:什么时候才算真正完成
原计划要求只有同时满足以下条件才可停止:
- 所有 Backspace/Delete Chromium 行全绿;
- 每一行都断言了模型文本、模型 selection、可见 DOM 文本、DOM selection/caret,并在相关处断言跟随输入;
- 非 Chromium/移动端行全绿或被显式分类;
- 包级门槛通过;
- 没有已知的 Backspace/Delete 光标丢失路径仍未被测试。
最后一句是全文的收束:「不要在修复一行 Backspace 后就停下来——那会重复让这个 Bug 漏过去的同一个错误。」这也正是本文反复强调四层断言与跟随输入的原因:删除类缺陷的真正验收标准,永远是删除之后用户还能不能继续打字。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考