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

+84
View File
@@ -0,0 +1,84 @@
/**
* @file CheckRunner.hpp
* @brief 离屏检查的注册表与统一断言/输出接口。
*
* 每个检查都是一个签名为 `int (const CheckOptions&)` 的函数,
* 通过 `Expect()` 记录断言;返回值只用于"该检查自己额外发现的失败数"(通常直接返回全局失败数)。
*/
#ifndef __CHECKRUNNER_HPP__
#define __CHECKRUNNER_HPP__
#include <string>
#include <vector>
#include "OffscreenSupport.hpp"
namespace jdi
{
namespace tools
{
/** @brief 一次检查运行的参数(来自命令行)。 */
struct CheckOptions
{
std::string dataPath; /**< 数据文件路径(ASCII 或本地代码页)。 */
std::string outDir; /**< 输出目录(默认 `../Temp/tools`)。 */
std::string mode; /**< `verify`(默认)/ `record` / `compare`。 */
int frames = 8; /**< 连续帧数量(仅 `frames` 检查使用)。 */
bool savePng = true; /**< 是否把抓到的帧落盘(默认开,便于事后分析)。 */
};
/** @brief 一个可运行的检查。 */
struct CheckDescriptor
{
const char* name = ""; /**< 检查名(命令行 `-run <name>` 使用)。 */
const char* summary = ""; /**< 一句话说明(ASCII,`-list` 打印)。 */
/** 检查实现:`session` 已 `Start()` 并加载好数据文件;返回该检查的失败断言数。 */
int (*run)(Session& session, const CheckOptions& options) = nullptr;
};
/** @brief 全部已注册检查(顺序即执行顺序)。 */
const std::vector<CheckDescriptor>& AvailableChecks();
/** @brief 按名字查检查;找不到返回 nullptr。 */
const CheckDescriptor* FindCheck(const std::string& name);
/** @brief 统一输出(同时写标准输出与报告文件,由 `Main.cpp` 提供实现)。 */
void EmitLine(const char* format, ...);
/** @brief 断言总数。 */
int& CheckTotal();
/** @brief 失败断言数。 */
int& CheckFailed();
/** @brief 记录一条断言(失败会打印 `[FAIL]` 并累加失败数)。 */
void Expect(const bool ok, const std::string& what);
/** @brief 记录一条"整数相等"断言。 */
void ExpectEqual(const long long actual, const long long expected, const std::string& what);
/** @brief 记录一条数值近似断言。 */
void ExpectNear(const double actual, const double expected, const double tolerance,
const std::string& what);
/** @brief 把 `gd::RectF` 转成整像素矩形(向外取整,保证覆盖)。 */
inline PixelRect ToPixelRect(const gd::RectF& rect)
{
PixelRect result;
result.x = static_cast<int>(std::floor(rect.X));
result.y = static_cast<int>(std::floor(rect.Y));
result.width = static_cast<int>(std::ceil(rect.X + rect.Width)) - result.x;
result.height = static_cast<int>(std::ceil(rect.Y + rect.Height)) - result.y;
return result;
}
/** @brief 读取一个文本文件(失败返回空串)。 */
std::string ReadTextFile(const std::string& path);
/** @brief 写一个文本文件(自动建目录)。 */
bool WriteTextFile(const std::string& path, const std::string& content);
} // namespace tools
} // namespace jdi
#endif // __CHECKRUNNER_HPP__
@@ -0,0 +1,191 @@
/**
* @file OffscreenSupport.hpp
* @brief 离屏验证工具的公共支撑:后备缓冲抓帧、区域哈希、隐藏窗口会话。
*
* ⚠️ **为什么要 `#define private public`**:本工具是"白盒验证"——
* 它需要读取后备缓冲区(`Application::m_backBits`)、驱动鼠标消息、
* 直接调用面板几何函数(`PlotView::EdgePanelLayoutFor`)。这些都不是给终端用户用的
* 公开 API,因此不为了测试而污染产品接口,改成在工具侧临时放宽访问级别。
*
* ⚠️ **先引入全部标准库 / 系统头,再放宽访问级别**:否则随后被 include 的
* 标准库头里的 `private:` 会被宏替换成 `public:`,造成莫名其妙的编译错误。
*
* ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不占用户的屏幕与鼠标键盘。
* 抓帧读的是**后备缓冲区**(不是窗口 DC),因此完全不需要窗口可见。
*/
#ifndef __OFFSCREENSUPPORT_HPP__
#define __OFFSCREENSUPPORT_HPP__
// ---- 先铺满标准库与系统头 ---------------------------------------------------
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <functional>
#include <limits>
#include <map>
#include <memory>
#include <mutex>
#include <numeric>
#include <random>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
// ---- 临时放宽访问级别,引入被测项目头 --------------------------------------
#define private public
#define protected public
#include "Application.hpp"
#include "DataModel.hpp"
#include "PlotView.hpp"
#include "PlotViewInternal.hpp"
#include "SignalPanel.hpp"
#include "TabBar.hpp"
#include "Theme.hpp"
#include "Toolbar.hpp"
#include "UiSurface.hpp"
#undef protected
#undef private
namespace jdi
{
namespace tools
{
/** @brief 后备缓冲区里的一块像素矩形(设备像素、客户区坐标)。 */
struct PixelRect
{
int x = 0; /**< 左边界。 */
int y = 0; /**< 上边界。 */
int width = 0; /**< 宽度。 */
int height = 0; /**< 高度。 */
};
/**
* @brief 一帧后备缓冲区像素(top-down 32bpp BGRA)。
*
* ⚠️ **不要跨帧缓存 `pixels.data()` 的内部指针**:窗口尺寸 / DPI 变化会重建后备缓冲区;
* 每次 `RenderFrame()` 都会重新 `CaptureFrame`。
*/
struct Frame
{
int width = 0; /**< 像素宽。 */
int height = 0; /**< 像素高。 */
int stride = 0; /**< 行字节数。 */
std::vector<std::uint8_t> pixels; /**< BGRA 像素(逐行自上而下)。 */
/** @brief 是否抓到了有效像素。 */
bool Valid() const { return width > 0 && height > 0 && !pixels.empty(); }
/** @brief 第 `y` 行的起始地址。 */
const std::uint8_t* Row(const int y) const
{
return pixels.data() + static_cast<std::size_t>(y) * static_cast<std::size_t>(stride);
}
};
/** @brief 从应用后备缓冲区抓一帧(不依赖窗口可见性)。 */
bool CaptureFrame(const Application& app, Frame& frame);
/** @brief 计算区域内像素的 FNV-1a 64 位哈希(只混 BGR,忽略 alpha)。 */
std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect);
/** @brief 统计区域内"墨迹"像素数(与区域主色不同的像素)。 */
int CountInk(const Frame& frame, const PixelRect& rect);
/** @brief 统计两帧在同一区域里差异超过容差的像素数。 */
int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, int tolerance);
/** @brief 把一帧保存成 PNG(路径必须是 ASCII,目录不存在会自动创建)。 */
bool SavePng(const Frame& frame, const std::string& path);
/** @brief 递归创建目录(ASCII 路径)。 */
void EnsureDirectory(const std::string& asciiPath);
/** @brief 递归删除目录(ASCII 路径,用于"清空临时输出")。 */
void RemoveDirectoryTree(const std::string& asciiPath);
/**
* @brief 一次离屏会话:隐藏窗口 + 可选的数据文件 + 逐帧抓取。
*
* 生命周期:`Start()` → 若干 `Load()` / `RenderFrame()` → `Stop()`。
* ⚠️ `Stop()` 之后**不要**再调用成员函数;进程退出用 `std::_Exit()` 收尾,
* 避免单例析构晚于 `GdiplusShutdown`(历史踩过的退出访问违例)。
*/
class Session
{
public:
/** @brief 创建隐藏窗口并初始化各组件(不显示、不抢焦点)。 */
bool Start();
/** @brief 关闭窗口并释放 GDI+ 相关资源。 */
void Stop();
/** @brief 加载一个数据文件(同步路径,便于断言)。 */
bool Load(const std::string& path);
/** @brief 应用本体引用。 */
Application& App() { return *m_app; }
/** @brief 只读应用本体引用。 */
const Application& App() const { return *m_app; }
/** @brief 强制整帧重绘并抓帧;返回是否成功。 */
bool RenderFrame();
/** @brief 最近一帧。 */
const Frame& LastFrame() const { return m_lastFrame; }
/** @brief 最近一次 `Render()` 的耗时(毫秒)。 */
double LastRenderMs() const { return m_lastRenderMs; }
/** @brief 只显示给定信号(其余全部隐藏),并重算视图。 */
void ShowSignals(const std::vector<SignalRef>& refs);
/** @brief 显示前 `limit` 个信号(跨分组,`limit <= 0` 表示全部)。 */
void ShowFirstSignals(int limit);
/** @brief 全部隐藏。 */
void HideAll();
/** @brief 注入一次鼠标移动(客户区坐标),返回组件是否认为状态有变化。 */
bool MouseMove(int x, int y);
/** @brief 绘图区矩形(客户区坐标)。 */
gd::RectF PlotArea() const;
/** @brief 第 `index` 个网格的波形区域(客户区坐标)。 */
gd::RectF LaneRect(int index) const;
/** @brief 当前网格数。 */
int LaneCount() const;
/** @brief 客户区尺寸。 */
void ClientSize(int& width, int& height) const;
/** @brief 当前加载的路径(窄字符串,仅用于打印)。 */
const std::string& LoadedPath() const { return m_loadedPath; }
private:
Application* m_app = nullptr; /**< 进程级单例。 */
bool m_started = false; /**< 是否已 `Initialize`。 */
Frame m_lastFrame; /**< 最近一帧像素。 */
double m_lastRenderMs = 0.0; /**< 最近一帧渲染耗时。 */
std::string m_loadedPath; /**< 当前数据文件路径(ASCII 打印用)。 */
};
} // namespace tools
} // namespace jdi
#endif // __OFFSCREENSUPPORT_HPP__