背景:第 33 轮 Code Review 的三项后续建议(EdgeScanFor 查找哈希化、 EdgePanelLayoutFor 表驱动化、wcstod 自写替身)都卡在"没有可自证的验证手段"上。 本轮先补齐基础设施,再动被建议的代码。 一、Tools/ 从忽略改为入库(只忽略各工具的 Build/ 与 Temp/ 临时产物) * `.gitignore`:删除 `Tools/` 整目录忽略,改为 `Tools/**/Build/`; 截图 / 基线 / 报告一律写 `Temp/tools/`(`Temp/` 早已忽略)。 二、新增 Tools/Makefile(统一构建入口,不碰应用本体) * 目标:`make` 构建全部、`make UnitTests` / `make OffscreenChecks` 单个、 `make clean` 清工具产物、`make help`。 * 工具链接应用本体 `../Build/Release/*.o`(排除入口 `Main.o`), 因此 ABI 开关必须与本体一致:`-fno-exceptions` / `-fno-rtti` / 链接 `-flto -flto-partition=one`。 * 工具源文件自身**不**编译成 LTO 字节码(否则 GCC 15.2.0 在 `choose_baseaddr` 处 ICE),只在链接阶段带 LTO 参数。 * `-MMD -MP` + `-include`:改头文件 / 增删源文件自动重编重链。 * 修正一个构建陷阱:`$(foreach)` 生成的工具目标会早于 `all`, make 会把第一个显式目标当默认目标 ⇒ 显式 `.DEFAULT_GOAL := all`。 三、Tools/UnitTests:纯数据层单元测试(218 项断言,17 个用例) * 极简自研框架(`Inc/UnitTestFramework.hpp`):无异常、无 RTTI、 支持布尔 / 整数 / 近似 / **逐位相等** / 宽字符串断言。 * 覆盖:`TryParseDouble` 与 libc `strtod` 逐位一致性(6 万条随机串)、 接受性语义、`FormatDecimal*` 永不科学计数法、`NiceStep/BuildTicks`、 公式语法/语义/归一化、批量求值 `FormulaProgram` 与逐点求值逐位一致、 `ApplyDrawTransform` 顺序、`TransformValueRange`、 `ComputeMovingAverageIterated` 阶数等价性、 `DataSet::FormulaTransformFor` 的值域与分组极值(逐点参考实现比对)。 四、Tools/OffscreenChecks:隐藏窗口离屏验证(5 个检查) * 窗口 `SW_HIDE`;"鼠标"用 `Application::HandleMouseMove()` 合成注入; 像素从后备缓冲区读取(不依赖窗口可见性),可保存 PNG 与计算区域 FNV 哈希。 * `smoke`:初始化 / 加载 / 渲染 / 落盘,并断言"同状态两帧逐像素相同"。 * `frames`:连续帧抓取(PNG + 哈希 + 耗时),用于问题分析。 * `panels`:7 个实验性面板全部展开后的几何不变式 + **像素基线 A/B** (`-mode record` / `-mode compare`)。 * `edgestats`:边沿统计与工具内**独立参考实现**逐位比对, 覆盖阈值变化与重复查询稳定性。 * `perf`:整帧中位数、`EdgePanelLayoutFor` 单次耗时、 198 个统计状态的整轮查询耗时(用于优化前后对比)。 五、文档 * `Tools/README.md`:目录结构、构建/运行方式、每个工具的作用、测试项与 验收标准、临时产物位置、A/B 基线用法、扩展步骤与已知限制。 实测(本机 GCC 15.2.0 UCRT / 125% DPI): * `UnitTests.exe` → `checks=218 failed=0` * `OffscreenChecks -all` → 全部 PASS;`SCATTER_TEST.csv` 整帧 ~5ms; `MF4-Record.MF4`(283 信号)198 个统计状态整轮查询 0.097 us/query。
175 lines
4.6 KiB
C++
175 lines
4.6 KiB
C++
/**
|
||
* @file Main.cpp
|
||
* @brief 单元测试执行器(控制台程序,无窗口、无 GUI 初始化)。
|
||
*
|
||
* 命令行(全部可选):
|
||
* -list 只列出用例名,不执行
|
||
* -only <substr> 只执行名字包含 <substr> 的用例
|
||
* -report <path> 报告文件路径(默认 `../Temp/tools/UnitTests-report.txt`)
|
||
* -verbose 打印每个用例的开始/结束
|
||
*
|
||
* 退出码:0 = 全部通过;1 = 有失败;2 = 参数错误 / 没有任何用例。
|
||
*
|
||
* ⚠️ 本工具**不创建窗口、不初始化 GUI、不读用户输入**,可在后台任意次运行。
|
||
* ⚠️ 报告与截图类临时产物一律写到 `Temp/`(已被 .gitignore 忽略),不要写进源码目录。
|
||
*/
|
||
|
||
#include <cstdarg>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <string>
|
||
#include <windows.h>
|
||
|
||
#include "UnitTestFramework.hpp"
|
||
|
||
namespace
|
||
{
|
||
std::FILE* g_report = nullptr;
|
||
bool g_verbose = false;
|
||
|
||
/** @brief 同时输出到标准输出与报告文件。 */
|
||
void Emit(const char* format, ...)
|
||
{
|
||
char buffer[4096];
|
||
va_list arguments;
|
||
va_start(arguments, format);
|
||
const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments);
|
||
va_end(arguments);
|
||
|
||
if (length > 0)
|
||
{
|
||
std::fputs(buffer, stdout);
|
||
std::fflush(stdout);
|
||
if (g_report != nullptr)
|
||
{
|
||
std::fputs(buffer, g_report);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @brief 递归创建目录(仅支持 ASCII 路径;失败静默忽略)。 */
|
||
void EnsureDirectory(const std::wstring& path)
|
||
{
|
||
if (path.empty())
|
||
{
|
||
return;
|
||
}
|
||
for (std::size_t index = 1; index <= path.size(); ++index)
|
||
{
|
||
if (index == path.size() || path[index] == L'/' || path[index] == L'\\')
|
||
{
|
||
const std::wstring prefix = path.substr(0, index);
|
||
CreateDirectoryW(prefix.c_str(), nullptr);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @brief 从 UTF-8 窄字符串转换为宽字符串(只用于路径)。 */
|
||
std::wstring Widen(const char* text)
|
||
{
|
||
const int length = MultiByteToWideChar(CP_ACP, 0, text, -1, nullptr, 0);
|
||
if (length <= 1)
|
||
{
|
||
return std::wstring();
|
||
}
|
||
std::wstring result(static_cast<std::size_t>(length - 1), L'\0');
|
||
MultiByteToWideChar(CP_ACP, 0, text, -1, &result[0], length);
|
||
return result;
|
||
}
|
||
|
||
/** @brief 打开报告文件(顺带创建 `Temp/tools`)。 */
|
||
std::FILE* OpenReport(const char* path)
|
||
{
|
||
std::string wide = path; // 仅 ASCII,用于切分目录
|
||
const std::size_t slash = wide.find_last_of("/\\");
|
||
if (slash != std::string::npos)
|
||
{
|
||
EnsureDirectory(Widen(wide.substr(0, slash).c_str()));
|
||
}
|
||
return std::fopen(path, "wb");
|
||
}
|
||
} // namespace
|
||
|
||
int main(int argc, char** argv)
|
||
{
|
||
const char* only = nullptr;
|
||
const char* reportPath = "../Temp/tools/UnitTests-report.txt";
|
||
|
||
for (int index = 1; index < argc; ++index)
|
||
{
|
||
const char* argument = argv[index];
|
||
if (std::strcmp(argument, "-list") == 0)
|
||
{
|
||
for (const jdi::test::TestCase& testCase : jdi::test::Registry())
|
||
{
|
||
std::printf("%s\n", testCase.name);
|
||
}
|
||
return 0;
|
||
}
|
||
if (std::strcmp(argument, "-only") == 0 && index + 1 < argc)
|
||
{
|
||
only = argv[++index];
|
||
continue;
|
||
}
|
||
if (std::strcmp(argument, "-report") == 0 && index + 1 < argc)
|
||
{
|
||
reportPath = argv[++index];
|
||
continue;
|
||
}
|
||
if (std::strcmp(argument, "-verbose") == 0)
|
||
{
|
||
g_verbose = true;
|
||
continue;
|
||
}
|
||
std::printf("unknown argument: %s\n", argument);
|
||
return 2;
|
||
}
|
||
|
||
g_report = OpenReport(reportPath);
|
||
|
||
Emit("==============================================================\n");
|
||
Emit(" Just DataInspector - unit tests\n");
|
||
Emit(" report: %s\n", reportPath);
|
||
Emit("==============================================================\n");
|
||
|
||
int executed = 0;
|
||
for (const jdi::test::TestCase& testCase : jdi::test::Registry())
|
||
{
|
||
if (only != nullptr && std::strstr(testCase.name, only) == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
++executed;
|
||
const int failedBefore = jdi::test::FailedChecks();
|
||
jdi::test::CaseFailed() = false;
|
||
Emit("[RUN ] %s\n", testCase.name);
|
||
testCase.function();
|
||
const int failed = jdi::test::FailedChecks() - failedBefore;
|
||
if (failed == 0)
|
||
{
|
||
if (g_verbose)
|
||
{
|
||
Emit("[ OK ] %s\n", testCase.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
Emit("--------------------------------------------------------------\n");
|
||
Emit("SUMMARY checks=%d failed=%d cases=%d result=%s\n", jdi::test::TotalChecks(),
|
||
jdi::test::FailedChecks(), executed,
|
||
(jdi::test::FailedChecks() == 0 && executed > 0) ? "PASS" : "FAIL");
|
||
Emit("--------------------------------------------------------------\n");
|
||
|
||
if (g_report != nullptr)
|
||
{
|
||
std::fclose(g_report);
|
||
g_report = nullptr;
|
||
}
|
||
|
||
if (executed == 0)
|
||
{
|
||
return 2;
|
||
}
|
||
return (jdi::test::FailedChecks() == 0) ? 0 : 1;
|
||
}
|