test(tools): 重建单元测试与离屏验证工具(Tools/ 入库)

背景:第 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。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 11:41:08 +08:00
1 parent c5d7a6fb91
commit 97e9163314
14 files changed
+3515 -2

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
/**
* @file Main.cpp
* @brief 离屏验证工具入口(控制台)。
*
* 用法:
* OffscreenChecks -list
* OffscreenChecks -run smoke
* OffscreenChecks -all
* OffscreenChecks -run panels -mode record 记录面板像素基线到 Temp/tools
* OffscreenChecks -run panels -mode compare 与基线比对(重构前后 A/B)
* OffscreenChecks -run frames -frames 12 连续抓 12 帧
* OffscreenChecks -data "../TestData/BIG_DATA.csv" -all
*
* 可选:`-out <dir>`(默认 `../Temp/tools`)、`-report <path>`、`-no-png`。
*
* 退出码:0 = 全部通过;1 = 有失败断言;2 = 参数错误 / 数据加载失败。
*
* ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不移动真实鼠标。
* ⚠️ 临时产物(PNG / 基线 / 报告)统一写 `Temp/`(已被 .gitignore 忽略)。
*/
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "CheckRunner.hpp"
#include "OffscreenSupport.hpp"
namespace
{
std::FILE* g_report = nullptr;
/** @brief 窄路径(本地代码页)→ 宽字符串。 */
std::wstring Widen(const std::string& text)
{
const int length = MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, nullptr, 0);
if (length <= 1)
{
return std::wstring();
}
std::wstring wide(static_cast<std::size_t>(length - 1), L'\0');
MultiByteToWideChar(CP_ACP, 0, text.c_str(), -1, &wide[0], length);
return wide;
}
/** @brief 判断文件是否存在。 */
bool FileExists(const std::string& path)
{
const std::wstring wide = Widen(path);
if (wide.empty())
{
return false;
}
HANDLE handle = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE)
{
return false;
}
CloseHandle(handle);
return true;
}
} // namespace
namespace jdi
{
namespace tools
{
void EmitLine(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)
{
return;
}
std::fputs(buffer, stdout);
std::fflush(stdout);
if (g_report != nullptr)
{
std::fputs(buffer, g_report);
std::fflush(g_report);
}
}
} // namespace tools
} // namespace jdi
int main(int argc, char** argv)
{
using namespace jdi::tools;
CheckOptions options;
options.outDir = "../Temp/tools";
options.mode = "verify";
std::string reportPath = "../Temp/tools/offscreen-report.txt";
std::string singleCheck;
bool runAll = false;
bool listOnly = false;
for (int index = 1; index < argc; ++index)
{
const char* argument = argv[index];
if (std::strcmp(argument, "-list") == 0)
{
listOnly = true;
}
else if (std::strcmp(argument, "-run") == 0 && index + 1 < argc)
{
singleCheck = argv[++index];
}
else if (std::strcmp(argument, "-all") == 0)
{
runAll = true;
}
else if (std::strcmp(argument, "-data") == 0 && index + 1 < argc)
{
options.dataPath = argv[++index];
}
else if (std::strcmp(argument, "-out") == 0 && index + 1 < argc)
{
options.outDir = argv[++index];
}
else if (std::strcmp(argument, "-report") == 0 && index + 1 < argc)
{
reportPath = argv[++index];
}
else if (std::strcmp(argument, "-mode") == 0 && index + 1 < argc)
{
options.mode = argv[++index];
}
else if (std::strcmp(argument, "-frames") == 0 && index + 1 < argc)
{
options.frames = std::atoi(argv[++index]);
}
else if (std::strcmp(argument, "-no-png") == 0)
{
options.savePng = false;
}
else
{
std::printf("unknown argument: %s\n", argument);
return 2;
}
}
if (listOnly)
{
for (const CheckDescriptor& descriptor : AvailableChecks())
{
std::printf("%-10s %s\n", descriptor.name, descriptor.summary);
}
return 0;
}
EnsureDirectory(options.outDir);
g_report = std::fopen(reportPath.c_str(), "wb");
EmitLine("==============================================================\n");
EmitLine(" Just DataInspector - offscreen checks\n");
EmitLine(" mode=%s out=%s report=%s\n", options.mode.c_str(), options.outDir.c_str(), reportPath.c_str());
EmitLine("==============================================================\n");
// 数据文件:命令行优先,否则在 TestData 里挑一个可用的样本
if (options.dataPath.empty())
{
static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "../TestData/[1].CSV",
"../TestData/BIG_DATA.csv", "../TestData/Problem_Data.CSV"};
for (const char* const candidate : candidates)
{
if (FileExists(candidate))
{
options.dataPath = candidate;
break;
}
}
}
if (options.dataPath.empty() || !FileExists(options.dataPath))
{
EmitLine("[FAIL] no data file: pass -data <path>(TestData/ 不入库,需本地准备)\n");
if (g_report != nullptr)
{
std::fclose(g_report);
}
return 2;
}
EmitLine(" data=%s\n", options.dataPath.c_str());
Session session;
if (!session.Start())
{
EmitLine("[FAIL] session start (hidden window creation)\n");
if (g_report != nullptr)
{
std::fclose(g_report);
}
return 2;
}
if (!session.Load(options.dataPath))
{
EmitLine("[FAIL] load data file\n");
session.Stop();
if (g_report != nullptr)
{
std::fclose(g_report);
}
return 2;
}
EmitLine(" loaded: groups=%d lanes=%d\n", static_cast<int>(session.App().m_dataSet.GroupCount()),
session.LaneCount());
std::vector<const CheckDescriptor*> selected;
if (!singleCheck.empty())
{
const CheckDescriptor* descriptor = FindCheck(singleCheck);
if (descriptor == nullptr)
{
EmitLine("[FAIL] unknown check: %s (use -list)\n", singleCheck.c_str());
session.Stop();
if (g_report != nullptr)
{
std::fclose(g_report);
}
return 2;
}
selected.push_back(descriptor);
}
else
{
for (const CheckDescriptor& descriptor : AvailableChecks())
{
selected.push_back(&descriptor);
}
(void)runAll;
}
int executed = 0;
for (const CheckDescriptor* descriptor : selected)
{
const int failedBefore = CheckFailed();
EmitLine("--------------------------------------------------------------\n");
EmitLine("[CHECK] %s - %s\n", descriptor->name, descriptor->summary);
descriptor->run(session, options);
++executed;
const int failed = CheckFailed() - failedBefore;
EmitLine("[CHECK] %s => %s\n", descriptor->name, (failed == 0) ? "PASS" : "FAIL");
}
EmitLine("--------------------------------------------------------------\n");
EmitLine("SUMMARY checks=%d failed=%d groups=%d result=%s\n", CheckTotal(), CheckFailed(), executed,
(CheckFailed() == 0) ? "PASS" : "FAIL");
EmitLine("--------------------------------------------------------------\n");
session.Stop();
if (g_report != nullptr)
{
std::fclose(g_report);
g_report = nullptr;
}
// ⚠️ 用 `_Exit` 收尾:`Application` / `PlotView` 是进程级单例,析构发生在 `GdiplusShutdown`
// 之后会访问违例(历史踩过,退出码 0xC0000005)。
const int code = (CheckFailed() == 0) ? 0 : 1;
std::_Exit(code);
}