test-builder:测试文档怎么写得像代码

3 分钟阅读
·

上一篇看了 example-setup 怎么把一批插件装配成一个能用的编辑器。这篇不看编辑器本身,看支撑各包测试的基础设施。翻 prosemirror-transform 的测试目录,满眼是这种写法:

add(doc(p("hello <a>there<b>!")),
    schema.mark("strong"),
    doc(p("hello ", strong("there"), "!")))

文档用函数调用拼出来,位置用尖括号标在字符串里,第三个参数给出期望文档。负责这套写法的是 prosemirror-test-builder,全部代码只有 src/build.ts 和 src/index.ts 两个文件,合计一百六十来行。这篇把这一百六十来行读完:builder 函数怎么从 schema 生成、尖括号怎么变成位置、mark builder 为什么返回一堆节点,以及 transform 的测试怎么把同一组标签用两次。参考代码是 prosemirror-test-builder 的 629d824;对照用法看的 prosemirror-transform 是 662b7a9;顺带引用的 prosemirror-model、prosemirror-schema-basic、prosemirror-schema-list、prosemirror-state、prosemirror-tables 分别是 6264de0、756726f、1501619、ffad5d9、eb522f2。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理
08-08 collab(下):receiveTransaction 与整个收发循环
08-15 changeset:变更集的计算与展示
09-05 markdown:文档与 Markdown 的双向转换
09-19 search:查找替换插件
10-03 表格专题(上):表格 schema 与 TableMap
10-10 表格专题(中):CellSelection,矩形的选区
10-17 表格专题(下):addColumn/mergeCells 等编辑命令
10-24 columnresizing:列宽拖拽的实现
11-07 example-setup:官方起手式是怎么装配的
11-14 test-builder:测试文档怎么写得像代码(本篇)

这个包解决的两件事

写 transform、state 层的测试,每个用例需要三样东西:操作前的文档、操作的位置参数、操作后的期望文档。拿 schema.node(“doc”, …) 手写节点树,嵌套一深就没法读;位置参数更麻烦,要按位置约定自己数:块的开标签占一个位置,每个字符占一个位置。doc(p("foo")) 里段落末尾是 4,doc(blockquote(p("foo"))) 里同样的字符后面是 5,多套一层块全部数字加一,数错一位整个用例白跑。test-builder 把这两件事分别交给 builder 函数和字符串里的 <name> 标签,位置从内容里自动算出来,测试作者只需要关心文档长什么样。

builders:从 schema 生成一套函数

build.ts 的入口是 builders(schema, names)。它遍历 schema.nodes 和 schema.marks,给每个名字生成对应 builder,挂在返回对象上,对象上还带一个 schema 字段。第二个参数 names 可以加别名:值是一个 attrs 对象,里面用 nodeType 或 markType 指定底层类型,其余字段作为这个别名的默认 attrs。

节点 builder 由 block(type, attrs) 生成,签名允许第一个参数是 attrs、后面跟任意个子节点。区分靠 takeAttrs(src/build.ts):首参是字符串、Node 实例或带 flat 属性的对象时,认为调用方没传 attrs,直接用默认值;否则把首参从参数列表里 shift 出来,和默认 attrs 合并,调用方的键覆盖默认键。所以 p("x")h1({level: 4}, "title") 都合法,后者临时盖住别名里预置的 level 1。首参传 null 或 undefined 同样拿到默认值:takeAttrs 只在首参为真值时才检查它的类型,空值虽然会走到 shift 那一步,但随即被 if (!a0) return attrs 挡回默认 attrs。

开头的 addMark 例子按这套规则拆开看:doc(p("hello <a>there<b>!")) 造出的文档 tag 是 {a: 7, b: 12},a 在 “hello ” 之后,b 在 “there” 之后,各自再加 p 开标签占的一位。addMark(tag(doc, “a”), tag(doc, “b”), mark) 实际执行的就是 addMark(7, 12, strong),只是这两个数字从头到尾没有出现在测试代码里。

别名解析有一个回退:类型名取 value.nodeType || value.markType || name。带 nodeType 或 markType 时是换类型加默认 attrs(h1 指定 heading 加 level 1);都不带时按别名自己的名字找类型,attrs 纯当默认值用。注意 value 对象是整体当默认 attrs 传下去的,nodeType 这个键也在里面;它不会混进造出来的节点,因为 model 的 computeAttrs 只按 schema 声明过的属性逐个取值,未声明的键直接忽略。build.ts 顶部还定义了 NodeBuilder、MarkBuilder 两个函数类型和 Builders 映射类型,后者按 schema 的 nodes、marks 键逐个生成签名,另加一个字符串索引签名兜底,builders(mySchema) 的返回值因此有类型信息可查。

子节点收齐后交给 flatten(下一节细说),最后用 type.create(myAttrs, nodes) 造节点。create 不做内容校验,校验在 createChecked 里(两者的分工见 prosemirror-model 的 src/schema.ts)。这意味着测试可以造出 schema 不允许的文档。对 transform 的用例这反而是必要的:很多输入本来就是合法操作序列中间才会出现的形态,校验卡住就写不了了。

flatten:尖括号怎么变成位置

flatten 是 build.ts 的核心,签名是 (schema, children, f),返回 {nodes, tag}。它做两件事:把 ChildSpec 列表摊平成节点数组,同时维护一本 tag 账,记录每个标签在已产出内容里的累计位置。第三个参数 f 是节点出站前的处理钩子:block 传的是恒等函数 id,节点原样透传;mark builder 传的是刷 mark 的闭包,下一节会看到。同一个 flatten 靠这个钩子同时服务两种 builder。

先说账放在哪。build.ts 顶部有一行不太起眼的代码:

const noTag = (Node.prototype as any).tag = Object.create(null)

它往 Node.prototype 上挂了一个共享的空对象当默认 tag。带来两个效果:任何节点都有 .tag 属性,测试里 node.tag.a 取不到时是 undefined 而不会抛错;flatten 判断「这个子节点自己带没带标签」只要比较 child.tag != Node.prototype.tag,不需要额外标志位。共享的前提是没人往这个对象上写:flatten 里每次要记账前先判断 tag == noTag,是就先换一个新的空对象再写,共享对象从头到尾保持空。

字符串参数用 /<(\w+)>/g 扫一遍。pos 变量只累计可见字符数,每命中一对尖括号就把标签名和当前 pos 记进账里,尖括号本身不进文本、不占位置。扫完如果还有剩余文本,schema.text(out) 造成文本节点推入结果。这里有两个细节值得记住:

  • 多个标签可以挤在同一位置,<a><b> 两个名字记同一个数。
  • 字符串里只有标签没有文字时,out 是空串,什么都不推入。所以 doc(blockquote(p("x")), "<cursor>", p("y")) 合法,标签落在两个块之间的位置,而这个位置本来不允许存在文本节点。

p("one <a>two ") 走一遍扫描过程:正则命中 <a> 时 m.index 是 4,pos 从 0 加上这 4 个字符,账上记 a: 4,at 跳到尖括号之后继续扫,“two ” 四个字符进 out 也进 pos。扫完 out 是 “one two “,造成一个 8 字符的文本节点,标签本身在文本里不留痕迹。正则只认 \w+,标签名里不能带横杠或点,想标 range-start 这类名字只能写成 rangeStart。

子节点是 builder 产物时,账要平移。子节点自己的 tag 是在它内部坐标系里算的,从它的内容起点计 0;并入父节点时要加上它前面兄弟已经占掉的 pos。普通节点还要再加 1,跳过它自己的开标签 token;mark 的产物(带 flat)和文本节点没有包裹 token,加 0。build.ts 里就是一行:

tag[id] = child.tag[id] + (child.flat || child.isText ? 0 : 1) + pos

平移完的 tag 由 block 挂到造好的节点上(tag 有内容时才覆盖原型上的共享空对象),跟着节点一路传到最外层的 doc。这套位置编号和文档绝对位置的约定一致,就是第 7 篇 ResolvedPos 讲过的那套:开标签占一位,字符各占一位。

flatten 的位置记账

图里是一个完整例子:doc(p("one <a>two ", em("three<b> four")))。p 内部坐标里 a 是 4、b 是 13;并入 doc 时整体加 1,doc.tag 是 {a: 5, b: 14}。em 是 mark,它的产物经 flat 展开,不引入包裹 token,所以 b 的位置和纯文本情形连续。

mark builder 返回一堆节点

mark(type, attrs) 生成的函数,返回值是 {flat: nodes, tag},属于 ChildSpec 的一种。原因在 model 层的设计里:mark 不进树,挂在 inline 节点的 marks 数组上(第 5 篇),单独一个 mark 没有对应的独立节点可造。所以 mark builder 递归 flatten 自己的子参数,在回调 f 里对每个节点做 mark.addToSet(n.marks),把标记刷到所有文本上。

addToSet 的返回值被顺手用来做去重:新集合长度没变,说明同类型同 attrs 的 mark 已经在,节点原样保留;真的加了新 mark 才 n.mark(newMarks) 换节点。效果在这个包自己的测试(test/test-marks.ts)里能看到:a({href: "/foo"}, a({href: "/foo"}, "click here")) 只剩一个 mark,href 不同则两个都留,测试用的 schema 里这个 mark 的 excludes 为空,允许同类型并存。

tag 账在 mark 的产物里同样有效。test-marks.ts 的去重用例实际写的是 a({href: "/foo"}, a({href: "/foo"}, "click <p>here")),内层 flatten 记下 p: 6(“click ” 六个字符),外层 mark 的 flatten 平移时 flat 产物加 0,标签一路传到 doc 上。用例末尾直接拿它做断言:ist(actual.nodeAt(actual.tag.p).marks.length, 1),nodeAt 解析到的正是 “click here” 这个文本节点。

mark builder 的首参也走 takeAttrs,所以 index.ts 预置了 href: “foo” 的 a 可以被临时覆盖:transform 测试里「用不同 attrs 覆盖 mark」的用例就是 doc(p("this is a ", a({href: "bar"}, "link")))

flat 属性还带来嵌套能力。strong(em("x")) 里 em 的产物是个 flat 数组,strong 的 flatten 把它当普通子节点展开,逐个刷上 strong,marks 数组自然累积,标签账也照常平移。

叶节点 builder 有另一个技巧。block 生成函数时会试一次 result.flat = [type.create(attrs)],成功的话这个 builder 不调用也能直接当子节点用:p("foo", br, "bar") 里的 br 是函数对象本身,flatten 检测到 flat 属性就展开。try/catch 兜住的是带必填 attrs 的叶节点:比如 image 的 src 没有默认值时 create 会抛,这种 builder 没有 flat,必须显式调用并传参。index.ts 给 img 别名预置了 src: “img.png”,所以测试里 img 可以裸用。

index.ts:一套开箱即用的测试 schema

src/index.ts 不到五十行。它用 schema-basic 的节点加上 schema-list 的 addListNodes("paragraph block*", "block") 拼出测试 schema,然后预置一批别名:p 是 paragraph,pre 是 code_block,h1/h2/h3 是 heading 加 level,li/ul/ol 是列表三件套,br 是 hard_break,img 带默认 src,hr 是 horizontal_rule,a 是 href 为 “foo” 的 link mark。doc、em、strong 这些名字来自 schema 本身,别名来自 builders 的第二个参数。

一个容易看混的点:a 这个别名是 link mark 的 builder,而 <a> 在字符串里是位置标签,两者完全无关,只是恰好共用了字母。测试里 a("<a>link<b>") 这种写法两个都在用:外层 a(…) 加链接,尖括号记位置。index.ts 还导出一个 eq 辅助函数,本体是 a.eq(b),给断言库当深比较器用。各包测试 import 的就是这份导出清单:doc、p、pre、h1 到 h3、li、ul、ol、img、hr、br、blockquote 是 NodeBuilder,a、em、strong、code 是 MarkBuilder,类型上分开,调用方式一致。

transform 的测试把标签用两次

prosemirror-transform/test/test-trans.ts 开头定义了小函数 tag(node, name):读 node.tag[name],取不到就抛错,避免 undefined 位置让用例假通过。每个用例形如开头的 addMark 例子,add 的实现是 new Transform(doc).addMark(tag(doc, "a"), tag(doc, "b"), mark),然后交给 test/trans.ts 的 testTransform。标签在这里第一次被消费:作为操作的位置参数。

testTransform 做四件事。第一,ist(tr.doc, expect, eq),变换结果和期望文档逐节点相等。第二,invert:把所有 step 逆序取反施一遍,要求回到 tr.before,验证 step 的可逆性。第三,step 的 JSON round trip:每个 step toJSON 再 fromJSON 重放,结果仍要等于 expect。第四件事又用到标签:遍历 expect.tag 里的每个名字,要求 tr.mapping 把操作前文档里同名标签的位置,正好映射到期望文档里这个标签的位置;内部还会把 mapping 里的每个 StepMap 逐个 invert、逆序拼成一个新 Mapping,要求同一个位置映回原值,两个方向都校验。映射时 assoc 参数固定传 1,标签位置按与右侧内容关联处理。这是标签的第二次消费:作为位置映射的断言锚点。

第二次消费让位置断言也自动化了。操作前和操作后的文档是两次独立的 builder 调用,标签位置各自从各自的内容里算。变换实现错了,要么 eq 挂,要么映射断言挂,测试作者从头到尾没有数过一个位置。用例后来要改,比如在文档里加一段引言,所有标签位置跟着内容自动重算,不用回头修数字。

trans.ts 里还有一段工程化的细节。设置 EMIT_JSON 环境变量时,outputTransform 会把每个跑过的用例导出成 JSON:schema、起始文档、steps、期望结果,以及每个标签从操作前到操作后的位置对。这批数据可以脱离 JS 运行时重放,换语言实现同一套变换算法时,直接拿这份用例集做一致性校验。builder 表达式因此除了给人读,还充当可序列化测试资产的源头。

标签名本身没有任何约定,正则认 \w+ 就行,但各包测试里形成了一套习惯:a 和 b 标一个区间的两端,cursor、anchor、head 标选区相关位置。prosemirror-state 的选区测试就是靠 a、b 两个标签构造 TextSelection:TextSelection.between(d.resolve(d.tag.b), d.resolve(d.tag.a)),选区方向和内容直接写在 builder 表达式里。

自己项目里的用法

预置的那套 schema 只覆盖 schema-basic 加列表,自定义了 schema 的项目要走另一条路:调 builders(mySchema, {别名表}) 生成自己的 builder 集。prosemirror-tables 的测试就是这么做的,它的 test/build.ts 用 tableNodes 拼出自己的 schema,然后生成 p、tr、td、th 四个别名,还在此基础上封了一层带标签的快捷件:cCursor = td(p("x<cursor>")) 造一个光标在末尾的单元格,selectionFor 读 doc.tag.cursor 直接构造 TextSelection。

cCursor 只是快捷件里最常用的一个,同一文件还有 cAnchor、cHead,分别把 anchor 和 head 标签预置在单元格里;selectionFor 读不到 cursor 时会退回这两个标签,组合出一个 CellSelection。commands.test.ts 里的典型用例长这样:table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)),三行三列的表格、光标在正中间那一格,文档结构和选区位置一眼读完,c11 是 colspan、rowspan 都为 1 的普通单元格。表格那种位置数起来极容易错的结构,收益更明显。

回头看这个包的全部机制:builder 函数拼节点、正则扫标签、tag 账随嵌套平移,再加一个挂在原型上的空对象。一百多行代码换来整个测试套件里没有一个手写位置数字,这个交换很划算。下一篇看这些包本身是怎么构建和发布的。


1236 字 · 44 段落
xi ming

Written by xi mingFollow onGitHub