Slate v2 Backspace/Caret 测试计划:用 TDD 关闭「删除后光标消失」浏览器编辑覆盖缺口
2026/9/16 18:05:31 网站建设 项目流程

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 用户路径行,原计划要求同时断言四个层面

  1. 模型文本(Model text);
  2. Slate selection;
  3. 可见 DOM 文本(Visible DOM text);
  4. 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=chromium

Phase 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"

覆盖度矩阵:按通道评估信心与所需证明

原计划用一张矩阵固化各删除通道的现状与目标,规划时可直接对照使用:

LaneCurrent confidenceNeeded proof
Insert after browser selectionGoodKeep existing caret rows
Backspace after browser selectionBadNative Backspace + model/DOM/caret + follow-up typing
Delete after browser selectionBadNative Delete + model/DOM/caret + follow-up typing
Expanded range deleteMediumNative Backspace/Delete rows, not only semantic handles
Decorated text deleteWeakHighlighted-text delete/backspace rows
Inline/void deleteWeakVoid/inline deletion + follow-up typing
Large-doc deleteMediumAdd native user-path rows beside semantic-handle rows
Shadow DOM deleteWeakBackspace/Delete inside Shadow DOM
IME deletionNot active ownerAdd only after basic deletion rows are stable
Mobile deletionUnknownExpand 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询