three.js Editor 撤销/重做命令单元测试编写指南

原创2026-09-08 21:41:571,995 阅读
文章标签:3D渲染图形学前端

three.js Editor 撤销/重做命令单元测试编写指南

导读

本文面向 three.js 内置编辑器(editor/ 目录)的二次开发者,系统讲解如何为编辑器自定义的撤销/重做(Undo/Redo)命令编写单元测试。你将掌握从创建测试文件、注册命令与测试到编写断言与执行测试的完整流程,同时通过源码理解 editor.execute()、editor.undo()、editor.redo() 背后的历史栈(History)机制,确保新命令的撤销/重做行为可被自动化验证。

概述:为 Undo/Redo 命令写测试的核心思路

three.js 编辑器中的所有可撤销操作都以“命令(Command)”的形式实现。编写单元测试的思路非常简单:

模拟一个场景,执行动作,然后执行撤销与重做,并逐阶段校验结果。

整个过程分为四个步骤:

  1. 创建一个新的单元测试文件;
  2. 将新命令与对应的测试文件注册进编辑器的测试套件;
  3. 编写测试用例(执行 → 撤销 → 重做 → 断言);
  4. 在浏览器中执行测试并查看结果。

下文将对每一步进行详细展开,并结合仓库源码说明其背后的实现原理。

1. 创建新的单元测试文件

在路径 test/unit/editor/TestDoSomethingCommand.js 下创建新的测试文件。

说明:test/unit/editor/ 目录集中存放编辑器相关的测试文件;DoSomethingCommand 是占位名,实际开发中请替换为你要测试的命令名,例如 TestMoveObjectCommand.js。

从当前仓库的结构看,editor/js/commands/ 目录(见 editor/js/commands)内置了 25 个可撤销命令,如 AddObjectCommand、MoveObjectCommand、SetPositionCommand、SetMaterialValueCommand 等,它们统一继承自 Command.js 基类,并为后续编写测试提供了丰富的参考对象。

2. 将新命令纳入编辑器测试套件

打开编辑器测试套件入口 test/unit/unittests_editor.html,需要完成两处注册。

2.1 注册命令对象类

在文件内的 <!-- command object classes --> 注释块中,加入新命令的脚本引用,并保持字母序:

// <!-- command object classes -->
//...
<script src="../../editor/js/commands/AddScriptCommand.js"></script>
<script src="../../editor/js/commands/DoSomethingCommand.js"></script>         // add this line
<script src="../../editor/js/commands/MoveObjectCommand.js"></script>
//...

说明:../../editor/js/commands/ 是从 test/unit/ 出发指向仓库根目录下 editor/js/commands/ 的相对路径。DoSomethingCommand.js 是你自己的命令实现文件,可参考 editor/js/commands 中现有命令的写法。

2.2 注册测试文件

在同一文件内的 <!-- Undo-Redo tests --> 注释块中,加入刚才创建的测试文件引用:

// <!-- Undo-Redo tests -->
//...
<script src="editor/TestAddScriptCommand.js"></script>
<script src="editor/TestDoSomethingCommand.js"></script>              // add this line
<script src="editor/TestMoveObjectCommand.js"></script>
//...

这里 editor/ 是相对于测试套件所在目录的路径,对应 test/unit/editor/ 目录。同样建议尽量保持字母序排列。

3. 编写测试用例

3.1 推荐模板

打开新创建的 test/unit/editor/TestDoSomethingCommand.js,粘贴以下骨架代码:

module( "DoSomethingCommand" );

test("Test DoSomethingCommand (Undo and Redo)", function() {

    var editor = new Editor();

    var box = aBox( 'Name your box' );

    // other available objects from "CommonUtilities.js"
    // var sphere = aSphere( 'Name your sphere' );
    // var pointLight = aPointLight( 'Name your pointLight' );
    // var perspectiveCamera = aPerspectiveCamera( 'Name your perspectiveCamera' );

    // in most cases you'll need to add the object to work with
    editor.execute( new AddObjectCommand( editor, box ) );

    // your test begins here...

} );

模板要点解读:

  • module( "DoSomethingCommand" ) 与 test(...) 是 QUnit 测试框架的 API,分别用于分组和定义单个用例;
  • var editor = new Editor() 在内存中创建编辑器实例,测试完全在内存中运行,不依赖浏览器 UI;
  • aBox()、aSphere()、aPointLight()、aPerspectiveCamera() 等工厂函数来自 CommonUtilities.js(测试公共工具),用于快速创建测试对象;
  • 大多数命令需要先有一个可操作的对象,因此先通过 editor.execute( new AddObjectCommand( editor, box ) ) 把盒子加入场景。

3.2 测试编写规范

模板代码只是降低开发成本的起点,你不必完全照搬。但测试至少应覆盖以下三个调用各一次:

  • editor.execute(...) —— 执行命令;
  • editor.undo() —— 撤销;
  • editor.redo() —— 重做。

最佳实践:对被测命令 editor.execute( new DoSomethingCommand( {custom parameters} ) ) 连续调用两次。因为之后必然要执行一次撤销(回退一步),连续两次执行能为“撤销后”与“默认状态”之间提供可比较的自定义状态。尽量避免用 ok() 去断言默认值,否则无法区分“撤销生效”与“从未执行”。

3.3 断言节奏

完整的断言分三个阶段进行:

  1. 执行后断言:完成两次 editor.execute() 后,做第一次断言,确认两次执行都正确生效;
  2. 撤销后断言:执行 editor.undo(),校验最后一步动作已被撤销;
  3. 重做后断言:执行 editor.redo(),校验值恢复为执行后的期望状态。

这一“执行 → 撤销 → 重做 → 各阶段断言”的模式,正是对编辑器历史栈行为的直接验证。

4. 执行测试

在浏览器中打开编辑器的单元测试套件 test/unit/unittests_editor.html,测试框架会自动运行全部用例,你可以在页面中查看每个模块(module)与用例(test)的通过/失败结果。

源码深挖:execute / undo / redo 的底层机制

为了写出更精准的断言,理解三个 API 在编辑器内部的实现很有帮助。Editor 在 Editor.js 中把操作委托给历史管理器:

execute: function ( cmd, optionalName ) {
    this.history.execute( cmd, optionalName );
},
undo: function () {
    this.history.undo();
},
redo: function () {
    this.history.redo();
}

真正的逻辑在 History.js 中,其核心是两条栈:this.undos 与 this.redos。

execute:入栈并执行

execute( cmd, optionalName ) 的逻辑要点(见 History.js):

  • 若新命令与栈顶命令满足“可更新(updatable)”条件——即同为 updatable、操作同一对象、同类型、同脚本、同属性——且时间差小于 500ms,则调用 lastCmd.update( cmd ) 合并更新,而不是新压栈(SetScriptValueCommand 忽略时间限制);
  • 否则将命令压入 undos 栈并分配自增的 id;
  • 随后调用 cmd.execute() 真正改变场景状态,并清空 redos 栈(新动作使既有重做记录失效)。

undo / redo:弹栈并反转

  • undo()(见 History.js):从 undos 弹出栈顶命令,若该命令已序列化(inMemory === false)则先 fromJSON() 恢复状态,再调用 cmd.undo(),最后压入 redos;
  • redo()(见 History.js):从 redos 弹出命令,调用 cmd.execute() 重放,并压回 undos。

这意味着命令本身必须实现成对的 execute() 与 undo() 方法才能保证撤销/重做对称。以内置的 MoveObjectCommand.js 为例:execute() 把对象从 oldParent 移动到 newParent 的指定索引,undo() 则反向把对象从 newParent 移回 oldParent 的原索引;而 AddObjectCommand.js 中 execute() 调用 editor.addObject(),undo() 调用 editor.removeObject(),互为逆操作。

如果你的自定义命令需要序列化(例如测试涉及 toJSON/fromJSON),注意 Command 基类(见 Command.js)已定义 toJSON() 输出 type、id、name,子类需在 super.toJSON( this ) 基础上追加自身状态字段,并在 fromJSON() 中恢复。

总结

为 three.js 编辑器的撤销/重做命令编写单元测试,本质上就是围绕“执行 → 撤销 → 重做”三条调用构建断言循环:

  1. 在 test/unit/editor/ 创建测试文件;
  2. 在 test/unit/unittests_editor.html 的两处注释块中注册命令与测试;
  3. 使用 QUnit 的 module/test 编写用例,连续执行两次命令以获得可比较的自定义状态;
  4. 在浏览器中打开测试套件页面查看结果。

理解 History.js 中 undos/redos 双栈的运作方式,以及命令成对实现 execute()/undo() 的约定,能帮助你写出覆盖边界场景、真正验证命令对称性的高质量测试。

登录后查看全文
three.js