从覆盖度队列到执行纪律:Plate 仓库非 React 测试覆盖路线图 Phase 2 复盘
2026/9/15 15:55:34 网站建设 项目流程

从覆盖度队列到执行纪律:Plate 仓库非 React 测试覆盖路线图 Phase 2 复盘

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

导读

本文以 docs/plans/2026-03-24-non-react-coverage-roadmap-phase-2.md 为骨架,复盘 Plate 编辑器仓库在测试覆盖率治理中如何对"非 React 侧"代码执行最后一轮收敛:先冻结队列、再按 Tier 分层执行、最后用明确规则拒绝无谓重排。你将看到一套可复用的"覆盖度队列治理方法论",以及htmlDeserializerCodeBlockParserPluginpipeDecoratewithScrollingupsertLinkconvertNodesSerialize等 17 个核心文件的真实实现与测试切入点,并顺带发现该批次暴露出的一个真实运行时缺陷。

背景:为什么需要冻结非 React 覆盖度队列

在大型 monorepo 中,覆盖率治理最常见的问题是"反复给同一批遗留文件重新打分、重新排序",却迟迟不真正动手补测试。Phase 2 文档开宗明义地给出了它的目标:

Freeze the last worthwhile non-React cleanup batch so future passes stop re-ranking the same leftovers and just burn down the queue.

即:冻结最后一轮值得做的非 React 清理批次,让后续的 pass 不再对同一批"残羹剩饭"反复重排,而是直接消耗队列。这个目标拆解为三条 Lock Rules(锁定规则):

  1. Phase 为临时性非 React 削减(temporary non-React cut only),只针对非 React 侧代码,不扩散到 React 组件测试;
  2. 冻结阈值:以 2026-03-24-coverage-priority-files-testing-review-non-react-post-roadmap.tsv 这份新鲜生成的 TSV 覆盖度优先级清单为起点,但不盲目执行所有score >= 5的文件——分数只是起点,最终取舍仍要人工判断;
  3. 队列以文件为单位、文件优先(file-first):不轻易重排,除非文件被删除、文件已被直接测试完全覆盖、或文件被证明是"虚假 ROI"而有意延期。

配套执行文档 2026-03-24-non-react-coverage-roadmap-phase-2-execution.md 进一步明确了工作流:先检查现有实现与邻近 spec,再补 Tier 1 覆盖、补 Tier 2 覆盖、更新路线图状态,最后对受影响的包运行定向测试与 build/typecheck/lint。它特别提醒:ViewPluginonDropNodeupsertLinkconvertNodesSerialize在 Tier 2 中已有不错的测试脚手架,应当扩展现有套件而非新建重复套件。

Tier 1:立即执行的 8 个文件

Tier 1 是本次路线的"必做项",8 个文件全部标记为[done],覆盖分数为 6~7 分。它们集中在 HTML 反序列化、静态渲染管道与核心插件体系上。

1. 代码块 HTML 反序列化:htmlDeserializerCodeBlock.ts(7 分)

htmlDeserializerCodeBlock.ts 负责把粘贴/导入的 HTML 中的<pre>font-family: Consolas段落转换为 Slate 的代码块结构。其HtmlDeserializer结构包含两条规则:

  • validNodeName: 'PRE':直接匹配<pre>元素;
  • validNodeName: 'P'validStyle.fontFamily: 'Consolas':兼容某些富文本编辑器导出为普通段落但字体设为等宽字体的代码片段。

parse回调的细节值得注意:它先扫描子节点中是否存在<select>语言选择器(一些在线代码编辑器会附带该元素),将其textContent从整体文本中剔除,再按换行符切分,最终产出一组KEYS.codeLine子节点包裹在KEYS.codeBlock中。这个"剥离语言选择器文本"的逻辑是测试中必须覆盖的关键路径,因为它直接决定粘贴后的文本是否干净。

2. HTML 插件:HtmlPlugin.ts(7 分)

HtmlPlugin.ts 是整个 HTML 粘贴/导出能力的入口,通过createSlatePlugin({ key: 'html' })注册:

  • extendApideserializeHtmlbindFirst绑定到 editor 上,暴露为editor.api.html.deserialize
  • extend.parser声明format: 'text/html'deserialize中先用parseHtmlDocument(data)解析 DOM,再以document.body为根调用api.html.deserialize

这里的 parser 声明格式被下游 ParserPlugin.ts 消费,是粘贴链路的第一环,测试时既可以直接构造 HTML 字符串验证反序列化产物,也可以验证 parser 的format/mimeTypes契约。

3. HTML 字符串到编辑器 DOM:htmlStringToEditorDOM.ts(7 分)

htmlStringToEditorDOM.ts 提供getEditorDOMFromHtmlString(html):用DOMParser解析 Plate 导出的 HTML,再querySelector('[data-slate-editor="true"]')取出编辑器根元素。它是"编辑器导出 HTML → 重新取回 DOM"这条闭环中的纯函数工具,非常适合无 DOM 依赖的单元测试(jsdom 环境下即可验证选择器与返回值)。

4. 静态文本渲染:pluginRenderTextStatic.tsx(6 分)

pluginRenderTextStatic.tsx 是静态(服务端/无交互)渲染体系的一部分,导出两个函数:

  • pluginRenderTextStatic(editor, plugin):为单个文本类型插件生成渲染函数,命中text[plugin.node.type ?? plugin.key]时选择组件(缺省回退SlateText),通过getRenderNodeStaticProps组装上下文属性;
  • pipeRenderTextStatic(editor, { renderText }):遍历editor.meta.pluginCache.node.isTextnode.textProps两组插件缓存,逐个叠加渲染结果与文本属性,最后统一用getNodeDataAttributes输出data-*属性。

pluginCache的分组缓存是本文件性能设计的核心:它把"文本插件"与"文本属性插件"分开遍历,避免每次渲染都全量扫描插件列表。测试重点可放在多插件叠加时的 children 传递顺序与className合并(内部用clsx)。

5. 粘贴解析内核:ParserPlugin.ts(6 分)

ParserPlugin.ts 通过overrideEditor重写了insertData变换,是整个粘贴链路的中枢。其执行顺序为:

  1. 逆序遍历插件列表,寻找第一个声明了parser的插件;
  2. parser.format规范化为text/${format}形式的 MIME 列表(若直接声明mimeTypes则优先使用);
  3. dataTransfer取数据,经pipeInsertDataQuery查询放行、pipeTransformData转换数据;
  4. 调用插件deserialize得到 fragment,经pipeTransformFragment转换;
  5. pipeInsertFragment插入,命中即返回true,避免后续插件重复处理。

从源码结构看,这一设计保证"第一个能解析的插件赢得插入权",且每个环节都留了插件可注入的钩子(pipe*系列),是典型的管道-过滤器架构。测试应覆盖:无 parser 插件时的回退、formatmimeTypes两种声明方式、以及insertData失败时回落到原始insertData(dataTransfer)

6. 装饰管道:pipeDecorate.ts(6 分)

pipeDecorate.ts 实现pipeDecorate(editor, decorateProp?),把editor.meta.pluginCache.decorate中所有插件的decorate输出与外部decorateProp合并成TRange[]。文件头注释点明了一个刻意优化:

Optimization: return undefined if empty list so Editable uses a memo.

即当没有任何装饰插件且未传入decorateProp时直接返回undefined,让Editable走 memo 路径,避免空函数造成的多余渲染。测试时应当断言:空列表返回undefined、多插件范围拼接、decorateProp追加这三个分支。

7. Excalidraw 基础插件:BaseExcalidrawPlugin.ts(6 分)

BaseExcalidrawPlugin.ts 只声明了两件事:key: KEYS.excalidraw,以及node: { isElement: true, isVoid: true },并导出了TExcalidrawElement类型(携带可空的data.elements/data.appState)。作为一个极简插件,其测试价值主要在于验证createSlatePlugin配置的元数据正确性——isElement/isVoid决定它在编辑、序列化、光标导航中的行为,是典型的"小而关键"覆盖对象。

8. 视图插件:ViewPlugin.ts(6 分)

ViewPlugin.ts 继承DOMPlugin,干两件事:

  • extendEditorApi暴露getFragment()(内部委托getSelectedDomFragment);
  • overrideEditor重写setFragmentData:当originEvent === 'copy'且选区跨多个块(fragment.length > 0)时,向剪贴板写入三份数据:application/x-slate-fragmentbtoa(encodeURIComponent(JSON.stringify(fragment)))编码)、text/html(DOM 片段 innerHTML)、text/plain(纯文本)。

它还通过isSelectOutside拦截"选区在编辑器外"的复制,避免污染剪贴板。这块逻辑与 getSelectedDomFragment.tsx(Tier 2 第 1 项)是同一批测试的天然组合:后者负责"从 DOM 选区重建 Slate fragment"。

Tier 2:仍然值得做的 9 个文件

Tier 2 是"有余力再做"的批次,9 个文件全部[done],覆盖分数 5 分。执行文档特别强调其中有 4 个文件已有测试脚手架,应以扩展为主。

1. 选区 DOM 片段重建:getSelectedDomFragment.tsx(5 分)

getSelectedDomFragment.tsx 从window.getSelection()取范围、cloneContents()克隆 DOM,再querySelectorAll('[data-slate-node="element"][data-slate-id]')找出块级元素。对每个块:

  • 通过dataset.slateIdeditor.api.node({ id })找回 Slate 节点,并用block[1].length !== 1排除内联元素(如链接、表格单元格);
  • 若首尾块未被选区完整覆盖(文本内容与NodeApi.string(block[0])不一致且非 void 元素),则回退到editor.api.html.deserialize做局部反序列化,保证拿到的是"选区真实覆盖"的片段而非整块。

这个"局部选中时回退反序列化"的分支是测试设计的难点与重点。

2. 静态叶子渲染:pluginRenderLeafStatic.tsx(5 分)

pluginRenderLeafStatic.tsx 与 Tier 1 的pluginRenderTextStatic对称,处理叶子(leaf/mark)渲染:pluginRenderLeafStatic命中leaf[plugin.node.type]时选择组件(优先plugin.render.leaf,其次editor.meta.components,最后SlateLeaf);pipeRenderLeafStatic遍历pluginCache.node.isLeafnode.leafProps,叠加渲染与属性,className同样用clsx合并。测试应覆盖 mark 命中/未命中、多 mark 叠加顺序、leafProps函数式与对象式两种形态。

3. 滚动控制:withScrolling.ts(5 分)

withScrolling.ts 导出一个包装函数:调用前把临时scrollMode/scrollOperations/scrollOptions写入DOMPlugin的 store,置位AUTO_SCROLL标记,try/finally中执行回调并恢复原配置。执行记录(2026-03-24-non-react-coverage-roadmap-phase-2-execution.md)披露了一个由该批次测试暴露的真实 bug:

withScrolling 之前把mode/operations写进了 DOMPlugin store,而不是scrollMode/scrollOperations,并且在回调抛出异常时没有恢复状态。

也就是说,这次覆盖工作不只是"补测试",还真实修复了运行时缺陷——这正是"覆盖度队列"驱动出实际价值的典型案例。测试应断言:正确键名写入、异常路径下 finally 恢复、嵌套调用时配置还原。

4. 静态元素渲染:pluginRenderElementStatic.tsx(5 分)

pluginRenderElementStatic.tsx 是元素级静态渲染:先取editor.meta.components?.[plugin.key](缺省SlateElement),依次执行pluginCache.render.belowNodes的 HOC 包裹 children、渲染belowRootNodes、最后用aboveNodes的 HOC 包裹整个组件树。这个"below → root → above"三层 HOC 顺序是插件扩展渲染能力的标准接缝,测试应锁定该顺序与 props 传递的一致性。

5. 拖放落点计算:onDropNode.ts(5 分)

onDropNode.ts 提供getDropPath:基于getHoverDirection判断方向,把right视作bottom(插入到悬停节点之后)、left视作top(插入到之前);若拖动节点已在目标位置则直接返回(空操作);还支持从文件系统拖入(dragItem不含element)时以[]作为默认路径。canDropNodemonitor.canDrop()双重校验、editorId匹配检查都是测试要点。该文件在 Tier 2 中被点名已有测试脚手架,应在其上扩展边界场景。

6. HTML 元素到叶子:htmlElementToLeaf.ts(5 分)

htmlElementToLeaf.ts 实现 HTML 反序列化的叶子构建:先pipeDeserializeHtmlLeaf聚合各插件的叶子反序列化结果,再递归deserializeHtmlNodeChildren处理子节点,对元素子节点用mergeDeepToNodes把 mark 深合并进文本节点(query.filter限定只合并TextApi.isText),对文本子节点用slate-hyperscriptjsx('text', ...)重建,并保证"子节点已有属性优先、不覆盖"。测试应覆盖 mark 深合并与属性覆盖优先级这两个分支。

7. Slate AST 反序列化:AstPlugin.ts(5 分)

AstPlugin.ts 注册application/x-slate-fragmentMIME 的 parser:window.atob(data)解码(先decodeURIComponent)再JSON.parse。它对应ViewPlugin写入剪贴板的x-slate-fragment格式,构成"复制 → 粘贴"闭环的另一半。测试要覆盖合法 base64 JSON、非法数据(JSON.parse抛错时静默吞掉返回undefined)两个分支。

8. 链接增改:upsertLink.ts(5 分)

upsertLink.ts 是链接编辑的核心变换,行为分四路:

  • 光标在链接内且insertTextInLink为真:直接insertText(url)插入文本;
  • skipValidation为假时先validateUrl校验;
  • 光标在链接上(linkAbove命中):URL 或 target 变化则setNodes更新,并upsertLinkText同步文本;
  • 选区展开:先unwrapLinksplit: true)再wrapLink重包,最后更新文本。

从源码结构看,RangeApi.isExpanded分支对"拖选文字转链接"这一最常见交互至关重要,测试覆盖应包含:光标在链接内、编辑已有链接、展开选区转链接、空文本回退为 URL 等场景。

9. 节点序列化为 Markdown AST:convertNodesSerialize.ts(5 分)

convertNodesSerialize.ts 是 Slate 节点 → mdast 的转换器:按顺序消费节点数组,连续文本节点进入textQueueconvertTextsSerialize处理;遇到带listStyleType的段落节点时累积进listBlock,在"下一项不是同级缩进"或"列表样式切换"时用listToMdastTree生成列表节点(支持fragment类型展开);普通节点走buildMda*分支并按getSerializerByKey分发。过滤函数shouldIncludeNode/shouldIncludeText保证序列化可受SerializeMdOptions控制。测试重点:相邻文本合并、同级/嵌套列表、列表样式切换、被过滤节点的跳过。

Deferred By Design:有意延期的 8 个文件

被标记[deferred]的文件不是"不测了",而是按 ROI 判断暂不投入。文档为每个文件都给了明确理由,这是"队列治理"纪律性的体现:

文件分数延期理由
isEditOnlyDisabled.ts5仅一行局部缺口,不值得单独跑一趟,除非邻近 Tier 1 spec 顺带覆盖
pipeInjectNodeProps.tsx5同样是微小残留,不是真正的阶段驱动项
html-to-docx.ts4巨大的序列化"烂泥",对最后一轮非 React pass 而言 ROI 很低
font-table.ts4纯 schema 样板
content-types.ts4纯 schema 样板
focus.ts4DOM 风格的工具碎屑,若将来值得再做,留给 DOM 专项阶段
useRecordHotkeys.ts4虽不在/react目录,但行为接近 React 侧,可等 React 阶段
AutoformatPlugin.ts4仅小部分缺口,不值得提前于更严格的队列处理

这 8 条理由本身就是一个很好的"延期决策清单":微小缺口、schema 样板、低 ROI 巨型文件、跨阶段归属、临近 React 行为——每一条都是可复用的延期判据。

状态更新规则与纪律执行

路线图用[done]/[deferred]/[removed]三个状态标记维护队列,更新规则被刻意收紧:

  • 文件获得直接测试 → 翻为[done]
  • 文件被证明是虚假 ROI → 翻为[deferred]并附理由;
  • 文件消失 → 翻为[removed]
  • 禁止因为"新一轮 pass 有了新感觉"而重排队列。

配合 2026-03-24-coverage-priority-map-testing-review-non-react.md 等系列文档可以看到,这套方法论在同批次的多个阶段(phase-2、phase-3、phase-4)中被持续复用,最终沉淀为非 React 覆盖率治理的稳定工作流:TSV 分数表定优先级 → 分 Tier 执行 → 状态翻转更新 → 禁止无理由重排

给团队的实践启示

  1. 冻结阈值,但不冻结判断score >= 5只是候选集,html-to-docx.ts的 4 分与isEditOnlyDisabled.ts的 5 分都说明"分数高不一定要做,分数低也可能值得做";
  2. 文件优先,测试就近扩展:已有脚手架的文件(ViewPluginonDropNodeupsertLinkconvertNodesSerialize)优先扩展,避免套件重复;
  3. 延期必须带理由:8 个 deferred 文件每一条都有可审计的原因,这让"不做什么"和"做什么"一样透明;
  4. 覆盖工作能暴露真实 bugwithScrolling的键名错误与状态恢复缺失正是被测试逼出来的,这是"覆盖度队列驱动质量"最有说服力的回报;
  5. 状态机要简单且强制[done]/[deferred]/[removed]三态 + "禁止无理由重排"一条铁律,足以让队列长期保持可消耗状态。

结语

Phase 2 的价值不在"又补了 17 个文件的测试",而在于它示范了一种可持续的覆盖率治理姿势:用冻结的优先级清单代替反复重排,用 Tier 分层控制投入节奏,用带理由的延期保持队列诚实,用严格的状态翻转规则防止队列腐烂。配合 Phase 2 Execution 文档中withScrolling的缺陷修复,这轮非 React 覆盖工作同时交付了测试资产、缺陷修复与一套可复制到其他模块的治理模板——对于任何面临"覆盖率数字好看但队列永远清不完"的团队,这都是一份可直接借鉴的实操范本。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询