Repository Wiki
ChanIok/SpinningMomo

单元测试与后端回归(doctest)

SpinningMomo 的 C++ 后端回归测试由 doctest 框架承载,编译为独立的 SpinningMomoTests 可执行目标,通过 xmake test 运行,用于在纯 C++ 层面(无 UI、无 JSON-RPC)验证后端纯逻辑模块(glob 匹配、录制时间、路径工具等)的行为。

Purpose and Scope

本页面覆盖以下内容:

  • tests/ 目录下的 doctest 单元测试的组织方式、构建配置与运行机制;
  • SpinningMomoTests xmake 目标的完整定义(依赖、编译选项、源码编入方式、add_tests 注册);
  • test_main.cpp 集中式测试入口的设计(DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN);
  • 测试编写范式:TEST_CASE / CHECK / CHECK_FALSE 的实际用法,以及测试文件以命名空间包裹、与生产代码命名空间对齐的惯例;
  • 各测试套件的覆盖范围(matcher_test.cpp、time_test.cpp、path_test.cpp)。

以下相邻主题有意留给兄弟页面,本页仅作指引:

  • TypeScript 端到端场景测试(tests/scenarios/,通过 JSON-RPC 驱动编译后的 SpinningMomo.exe,pnpm run test:scenarios 运行):属于场景测试页面。仓库在 AGENTS.md 中明确区分了这两类测试。
  • 交互式 RPC 调试工具(web/src/features/playground/ 与根目录 playground/):属于交互调试页面。
  • 同一构建文件 tests/xmake.lua 中的 SpinningMomoScenarioWindow 目标服务于场景测试,本页仅在构建配置一节简要提及。

需要说明的是:仓库文档将这批 doctest 测试定位为 "Legacy"(AGENTS.md),当前主要的行为级回归由 TypeScript 场景测试承担,doctest 套件负责纯函数级的小颗粒验证。

Overview

doctest 是一个单头文件的 C++ 测试框架。SpinningMomo 通过 vcpkg 引入(vcpkg::doctest,版本由 xmake-requires.lock 锁定),并在 src/vendor/doctest.hpp 中做了一层包装:

cpp
1#include "vendor/std.hpp" 2 3// 集中生成唯一的测试程序入口,让各测试文件只负责声明测试场景 4#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 5#include "vendor/doctest.hpp"

Source: test_main.cpp

这个入口文件是整个测试程序唯一的 main() 来源。DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 宏让 doctest 在此翻译单元中生成 main(),并自动注册、收集、运行所有其他翻译单元中通过 TEST_CASE 声明的测试场景。其设计意图(注释中写明)是「集中生成唯一的测试程序入口,让各测试文件只负责声明测试场景」——即入口与场景声明分离,避免每个测试文件各自实现 main 造成链接冲突。

当前测试二进制覆盖三个后端模块:

测试文件被测生产源码覆盖内容
tests/features/gallery/ignore/matcher_test.cppsrc/features/gallery/ignore/matcher.cppmatch_glob_pattern 的 glob 语义:完整路径匹配、globstar 跨段、? 通配符、[...]/[!...] 字符类、Windows 大小写不敏感
tests/features/recording/time_test.cppsrc/features/recording/time.cpp录制时间相关纯逻辑(具体断言以源码为准)
tests/utils/path_test.cppsrc/utils/path/path.cpp路径工具纯逻辑(具体断言以源码为准)
tests/test_main.cpp—(doctest runner 本身)唯一 main() 入口

架构文档 docs/developer/architecture.md 对此的表述是:"后端回归测试使用 doctest,由独立的 SpinningMomoTests 目标承载"。

Architecture

Loading diagram...

架构要点说明:

  1. 不链接产品库,而是把生产 .cpp 直接编入测试目标。SpinningMomoTests 没有依赖任何已构建的产品静态/动态库,而是把 matcher.cpp、time.cpp、path.cpp 及其依赖的 logger.cpp 直接作为源文件加入目标。这使测试目标完全自包含——只需满足这几个文件的编译需求即可构建,无需先构建整个应用。代价是:新增测试若触及新的生产源文件,必须同步把它加进 tests/xmake.lua 的 add_files 列表,否则会出现链接错误。
  2. logger.cpp + spdlog 是公共依赖。路径工具与匹配器都会产生日志输出,因此日志实现(SPDLOG_COMPILED_LIB 宏与 vcpkg::spdlog 包)也必须编入,且宏定义必须与产品构建保持一致,否则会出现符号不匹配。
  3. test_main.cpp 是唯一的 runner。所有测试文件只写 TEST_CASE,doctest 在链接期通过静态初始化自动收集它们,运行期由 test_main.cpp 生成的 main() 统一调度。
  4. add_tests("default") 把二进制注册进 xmake test,因此开发者不需要记忆测试可执行文件的具体路径,直接 xmake test 即可。

构建配置详解(tests/xmake.lua)

完整的 SpinningMomoTests 目标定义如下:

lua
1add_requires("vcpkg::doctest", "vcpkg::spdlog") 2 3target("SpinningMomoTests") 4 set_kind("binary") 5 set_default(false) 6 set_plat("windows") 7 set_arch("x64") 8 9 add_defines("NOMINMAX", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", 10 "_WIN32_WINNT=0x0A00", "SPDLOG_COMPILED_LIB") 11 add_includedirs("../src") 12 13 add_files("../src/features/recording/time.cpp") 14 add_files("../src/features/gallery/ignore/matcher.cpp") 15 add_files("../src/utils/logger/logger.cpp") 16 add_files("../src/utils/path/path.cpp") 17 add_files("test_main.cpp") 18 add_files("features/gallery/ignore/matcher_test.cpp") 19 add_files("features/recording/time_test.cpp") 20 add_files("utils/path_test.cpp") 21 22 add_packages("vcpkg::doctest", "vcpkg::spdlog") 23 add_links("shell32", "ole32") 24 add_tests("default")

Source: xmake.lua

逐项解读设计意图:

配置项值设计意图
add_requires("vcpkg::doctest", ...)vcpkg 依赖声明通过 vcpkg 包管理器解析 doctest 与 spdlog,版本锁定在 xmake-requires.lock(vcpkg::doctest#f56260b5),保证可复现构建
set_kind("binary")可执行文件测试以独立进程方式运行,进程退出码即测试结果,便于 CI 直接消费
set_default(false)不在默认构建中xmake 默认构建不会编译测试目标,避免拖慢日常增量构建;需要显式 xmake build SpinningMomoTests 或 xmake test
set_plat("windows") / set_arch("x64")Windows x64锁定测试目标的平台与架构,与产品构建对齐(add_defines 中 _WIN32_WINNT=0x0A00 对应 Windows 10 SDK 最低版本)
add_defines(...)NOMINMAX/UNICODE/_UNICODE/WIN32_LEAN_AND_MEAN/SPDLOG_COMPILED_LIB与产品代码的 Windows/字符集宏保持一致;NOMINMAX 防止 min/max 宏污染,SPDLOG_COMPILED_LIB 让 spdlog 走编译库模式以匹配 vcpkg 提供的预编译库
add_includedirs("../src")源码头文件目录测试文件用 #include "features/gallery/ignore/matcher.hpp" 这种与产品内部一致的相对路径引用头文件
add_files(../src/...)4 个生产 .cpp直接编入被测实现,不依赖产品库(自包含目标)
add_files(test_main.cpp ...)入口 + 3 个测试文件入口唯一,测试文件只声明场景
add_links("shell32", "ole32")Windows 系统库满足 path.cpp(shell32,路径/SH 系列函数)与 COM 相关(ole32)符号解析
add_tests("default")注册测试挂接到 xmake test,无需记忆可执行文件路径

注意 tests/xmake.lua 中还定义了第二个目标 SpinningMomoScenarioWindow(xmake.lua L26-L36),它服务于 TypeScript 场景测试(见兄弟页面),与 doctest 单元测试无关,仅共享同一构建文件。

测试编写范式

入口与场景分离

doctest 单头文件有两个角色:实现与接口。DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 只能出现在一个翻译单元中(否则 main 重定义),因此仓库把实现收敛到 test_main.cpp,其他测试文件只包含 vendor/doctest.hpp(即 #include <doctest/doctest.h>,见 src/vendor/doctest.hpp)以取得 TEST_CASE/CHECK 等宏。

TEST_CASE 命名与命名空间对齐

每个测试文件用与被测生产代码完全一致的命名空间包裹测试:

cpp
namespace features::gallery::ignore::matcher { TEST_CASE(...) { ... } }

这样测试内部的自由函数、辅助变量不会泄漏到全局命名空间,同时阅读时一眼可辨归属。doctest 的 TEST_CASE("名称") 字符串用于报告输出,本仓库采用「行为描述」风格的命名,例如 "glob matches the complete root-relative path"(描述被测行为而非被测函数名)。

断言风格

  • CHECK(expr):失败时记录但不中断当前 TEST_CASE,适合一组相关断言批量验证;
  • CHECK_FALSE(expr):验证否定分支;
  • 成对使用 CHECK / CHECK_FALSE 形成「正例 + 反例」的表意测试(table-like 断言群),这是本仓库最典型的风格(见下文示例)。

核心流程

Loading diagram...

流程说明:

  1. 注册发生在静态初始化期。doctest 的 TEST_CASE 宏展开后是一个全局对象的构造,它在 main() 之前向 doctest 内部注册表登记自己。这就是为什么 test_main.cpp 生成的 runner 能「自动发现」其他翻译单元中的测试——无需手工列表。
  2. CHECK 失败不终止测试。与 REQUIRE 不同,CHECK 记录失败后继续执行,因此一个 TEST_CASE 内可以放多组正反例断言,一次运行看到全部失败点。
  3. xmake test 的结果判定基于进程退出码,可直接接入 CI。

Usage Examples

示例一:glob 完整路径匹配语义(正反例成对断言)

cpp
1TEST_CASE("glob matches the complete root-relative path") { 2 CHECK(match_glob_pattern("*.jpg", "photo.jpg")); 3 CHECK_FALSE(match_glob_pattern("*.jpg", "photos/photo.jpg")); 4 5 CHECK(match_glob_pattern("photos/*", "photos/photo.jpg")); 6 CHECK_FALSE(match_glob_pattern("photos/*", "photos/2026/photo.jpg")); 7}

Source: matcher_test.cpp

这是仓库最典型的测试风格:同一 TEST_CASE 中正反例相邻摆放,*.jpg 只匹配根相对路径根层(photo.jpg),不跨段(photos/photo.jpg 应失败);photos/* 同理只匹配一层。这直接锁定了忽略规则的语义边界,防止实现退化成「任意位置匹配」。

示例二:globstar(**)跨段匹配

cpp
1TEST_CASE("globstar crosses complete path segments") { 2 CHECK(match_glob_pattern("**/*.jpg", "photo.jpg")); 3 CHECK(match_glob_pattern("**/*.jpg", "photos/2026/photo.jpg")); 4 5 CHECK(match_glob_pattern("a/**/b.jpg", "a/b.jpg")); 6 CHECK(match_glob_pattern("a/**/b.jpg", "a/x/y/b.jpg")); 7 CHECK_FALSE(match_glob_pattern("a/**/b.jpg", "a/xxb.jpg")); 8 9 CHECK(match_glob_pattern("photos/**", "photos/2026/photo.jpg")); 10 CHECK_FALSE(match_glob_pattern("photos/**", "photos")); 11}

Source: matcher_test.cpp

关键断言是 CHECK_FALSE(match_glob_pattern("a/**/b.jpg", "a/xxb.jpg"))——确保 ** 按完整路径段跨越,而不是被误实现为「任意字符序列」(那样 xxb.jpg 会被错误匹配)。photos/** 不匹配裸目录 photos 本身也锁定了 globstar 必须消耗至少一个段。

示例三:通配符与字符类

cpp
1TEST_CASE("glob supports wildcards and character classes") { 2 CHECK(match_glob_pattern("photo?.jpg", "photo1.jpg")); 3 CHECK_FALSE(match_glob_pattern("photo?.jpg", "photo10.jpg")); 4 5 CHECK(match_glob_pattern("[0-9]*.jpg", "1-photo.jpg")); 6 CHECK_FALSE(match_glob_pattern("[0-9]*.jpg", "a-photo.jpg")); 7 CHECK(match_glob_pattern("[!0-9]*.jpg", "a-photo.jpg")); 8 CHECK_FALSE(match_glob_pattern("[!0-9]*.jpg", "1-photo.jpg")); 9}

Source: matcher_test.cpp

覆盖 ? 单字符通配、[0-9] 正向字符类、[!0-9] 取反字符类。? 的反例(photo10.jpg 失败)防止单字符通配退化成多字符。

示例四:Windows 大小写不敏感

cpp
TEST_CASE("glob matching is case insensitive on Windows") { CHECK(match_glob_pattern("Photos/*.JPG", "photos/photo.jpg")); }

Source: matcher_test.cpp

平台行为(Windows 文件系统大小写不敏感)被固化为显式测试契约——这也是测试目标 set_plat("windows") 的意义所在:该断言在 Windows 上是成立的契约,而非偶然行为。

示例五:测试文件骨架(新建测试的标准模板)

cpp
1#include "features/gallery/ignore/matcher.hpp" 2 3#include "vendor/doctest.hpp" 4 5namespace features::gallery::ignore::matcher { 6 7TEST_CASE("glob matches the complete root-relative path") { 8 CHECK(match_glob_pattern("*.jpg", "photo.jpg")); 9 CHECK_FALSE(match_glob_pattern("*.jpg", "photos/photo.jpg")); 10} 11 12} // namespace features::gallery::ignore::matcher

Source: matcher_test.cpp

新建 doctest 测试的标准步骤:① #include 被测头文件(相对 ../src 的路径);② #include "vendor/doctest.hpp"(不定义 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN);③ 用与产品一致的命名空间包裹;④ 编写 TEST_CASE;⑤ 在 tests/xmake.lua 中同时登记新的测试 .cpp 与(如有)新增的生产 .cpp。

Configuration Options

选项 / 配置类型默认 / 值说明
add_requires("vcpkg::doctest")xmake 包依赖vcpkg::doctest#f56260b5(lock 锁定)doctest 测试框架本体
add_requires("vcpkg::spdlog")xmake 包依赖由 xmake-requires.lock 锁定生产代码日志库,测试目标必须同步引入
set_kind("binary")目标类型binary输出独立测试可执行文件
set_default(false)boolfalse不随默认 xmake 构建编译,需显式构建或 xmake test
set_plat("windows")平台windows仅 Windows 平台;与大小写不敏感等平台契约断言配套
set_arch("x64")架构x64与产品构建架构对齐
NOMINMAX宏定义防止 Windows 头的 min/max 宏污染
UNICODE / _UNICODE宏定义宽字符 API 选择,与产品一致
WIN32_LEAN_AND_MEAN宏定义精简 windows.h
_WIN32_WINNT=0x0A00宏Windows 10最低 SDK 版本
SPDLOG_COMPILED_LIB宏定义匹配 vcpkg 预编译 spdlog 库模式
add_includedirs("../src")包含目录../src测试以产品内部相对路径引用头文件
add_links("shell32", "ole32")系统库shell32, ole32满足 path.cpp(shell32)与 COM(ole32)符号
add_tests("default")测试注册default注册进 xmake test,按退出码判定结果
DOCTEST_CONFIG_IMPLEMENT_WITH_MAINdoctest 宏仅 test_main.cpp 定义唯一 main() 的来源,其他文件禁止定义

API Reference

本页范围内对外可用的「API」是 doctest 宏与被测函数的契约。被测函数签名取自测试中的实际调用方式。

match_glob_pattern(pattern: std::string_view, path: std::string_view): bool

(签名以测试调用形态呈现;完整实现位于 src/features/gallery/ignore/matcher.cpp。)

Parameters:

  • pattern(string):glob 模式,支持 *、?、**(globstar,按完整路径段跨越)、[...] / [!...] 字符类。
  • path(string):待匹配的根相对路径(root-relative path)。

Returns: 匹配返回 true,否则 false。匹配在 Windows 上大小写不敏感。

行为契约(由测试固化):

  • * 不跨路径段:*.jpg 匹配 photo.jpg,不匹配 photos/photo.jpg。
  • ** 按完整段跨越:a/**/b.jpg 匹配 a/b.jpg 与 a/x/y/b.jpg,不匹配 a/xxb.jpg。
  • photos/** 不匹配裸目录 photos(globstar 至少消耗一个段)。
  • ? 恰好一个字符:photo?.jpg 不匹配 photo10.jpg。
  • [0-9] 正向类、[!0-9] 取反类按单字符匹配。
  • Photos/*.JPG 可匹配 photos/photo.jpg(Windows 大小写不敏感)。

TEST_CASE(name: const char*)

doctest 宏。在静态初始化期注册一个测试场景;name 用于报告中显示。本仓库命名风格为行为描述句(如 "globstar crosses complete path segments")。

CHECK(expr)

doctest 宏。求值 expr,失败时记录文件/行号并继续执行当前测试。适合成组的正反例断言。

CHECK_FALSE(expr)

doctest 宏。求值 expr,期望其为假;失败记录并继续。

Failure Modes, Edge Cases & Concurrency

  • 链接失败(新增被测源码未登记):测试目标把生产 .cpp 直接编入,若新增测试依赖未列入 add_files 的生产源文件,会在链接期报未定义符号。这是「自包含目标」设计的直接代价,修法是把对应 .cpp 加进 tests/xmake.lua。
  • main 重定义:若在 test_main.cpp 之外的测试文件定义 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN,会产生 main 重定义链接错误。约定是:入口宏只出现在 test_main.cpp。
  • 宏不一致导致符号不匹配:SPDLOG_COMPILED_LIB、_WIN32_WINNT 等宏若与产品构建不一致,spdlog 预编译库的符号可见性/ABI 可能不匹配,产生难排查的链接错误。构建脚本通过显式 add_defines 列表固化这些宏。
  • 平台绑定断言:"glob matching is case insensitive on Windows" 这类用例依赖 Windows 文件系统语义;set_plat("windows") 保证契约的平台一致性。若未来移植到其他平台,该断言需按平台条件化。
  • 断言失败不中断:CHECK 失败后继续执行同一 TEST_CASE 内后续断言,一次运行可看到全部失败点;但也意味着断言之间存在数据依赖时要小心(本仓库测试均为独立断言,无此问题)。
  • 无并发问题:测试全部为纯函数调用(不涉及共享状态、文件系统、时钟),顺序执行、无并发竞争面。

Performance & Operational Notes

  • 运行成本极低:三个测试套件均为纯逻辑断言(glob 匹配、时间计算、路径处理),无 I/O、无进程间通信、无沙箱搭建,测试二进制整体运行在毫秒级。
  • 不进入默认构建:set_default(false) 意味着日常 xmake 增量构建不会编译测试目标,开发主循环不受拖累;提交前或 CI 中通过 xmake test 触发。
  • CI 友好:以独立进程 + 退出码作为结果判定,天然适配 CI 流水线与 xmake test 报告聚合。
  • 与场景测试的分工:doctest 覆盖纯函数颗粒度,TypeScript 场景测试(tests/scenarios/,JSON-RPC 驱动真实可执行文件)覆盖端到端行为;后者按仓库文档是当前回归主力,且默认门控 Release 构建。详见 AGENTS.md 的测试分层说明(AGENTS.md L153-L154)。
  • 测试前需注意:运行场景测试前需关闭正在运行的应用实例(AGENTS.md 提示);doctest 单元测试无此要求。

Extension Points

  • 新增单元测试:复制 matcher_test.cpp 的骨架(命名空间对齐 + TEST_CASE + 成对 CHECK/CHECK_FALSE),并在 tests/xmake.lua 中同时登记测试文件与新增生产源文件;不要在新文件中定义 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN。
  • 接入新的被测模块:把对应生产 .cpp 追加到 add_files 列表;若该模块引入新的第三方依赖,需同步 add_packages / add_links。
  • doctest 能力扩展:入口集中在 test_main.cpp,如需自定义 doctest 配置(如 DOCTEST_CONFIG_NO_SHORT_MACRO_NAMES、命令行过滤、颜色输出等),只需修改这一个文件,不影响各测试文件。doctest 二进制本身支持 --test-case=<pattern> 等命令行过滤参数,可直接对测试可执行文件调试运行。
  • 跨平台扩展:如需解除 set_plat("windows") 绑定,需先处理平台契约断言(大小写不敏感用例)与 _WIN32_WINNT 等 Windows 专属宏。

Sources

(3 files)
tests/features/gallery/ignore