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:
1 parent
c5d7a6fb91
commit
97e9163314
14 files changed
+3515
-2
No files matched your search
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file Capture.cpp
|
||||
* @brief 后备缓冲抓帧、区域哈希、PNG 落盘、目录工具。
|
||||
*
|
||||
* 全程不碰窗口 DC:读的是 `Application::BackBufferSurface()` 给的 top-down 32bpp DIB 段,
|
||||
* 因此窗口即使被隐藏(`SW_HIDE`)也能抓到与真实显示一致的像素。
|
||||
*/
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "OffscreenSupport.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/** @brief 取 GDI+ 编码器的 CLSID。 */
|
||||
bool FindEncoderClsid(const wchar_t* mimeType, CLSID& clsid)
|
||||
{
|
||||
UINT count = 0;
|
||||
UINT size = 0;
|
||||
if (Gdiplus::GetImageEncodersSize(&count, &size) != Gdiplus::Ok || size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::vector<std::uint8_t> buffer(size);
|
||||
Gdiplus::ImageCodecInfo* codecs =
|
||||
reinterpret_cast<Gdiplus::ImageCodecInfo*>(buffer.data());
|
||||
if (Gdiplus::GetImageEncoders(count, size, codecs) != Gdiplus::Ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (UINT index = 0; index < count; ++index)
|
||||
{
|
||||
if (wcscmp(codecs[index].MimeType, mimeType) == 0)
|
||||
{
|
||||
clsid = codecs[index].Clsid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @brief 把 ASCII 路径转宽字符串。 */
|
||||
std::wstring WidenPath(const std::string& path)
|
||||
{
|
||||
const int length = MultiByteToWideChar(CP_ACP, 0, path.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, path.c_str(), -1, &wide[0], length);
|
||||
return wide;
|
||||
}
|
||||
|
||||
/** @brief 夹取区域到帧内,返回 false 表示空区域。 */
|
||||
bool ClampRect(const Frame& frame, const PixelRect& rect, PixelRect& clamped)
|
||||
{
|
||||
const int left = std::max(0, rect.x);
|
||||
const int top = std::max(0, rect.y);
|
||||
const int right = std::min(frame.width, rect.x + rect.width);
|
||||
const int bottom = std::min(frame.height, rect.y + rect.height);
|
||||
if (right <= left || bottom <= top)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
clamped.x = left;
|
||||
clamped.y = top;
|
||||
clamped.width = right - left;
|
||||
clamped.height = bottom - top;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool CaptureFrame(const Application& app, Frame& frame)
|
||||
{
|
||||
const UiSurface surface = app.BackBufferSurface();
|
||||
if (!surface.Valid() || surface.bits == nullptr || surface.stride <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int width = static_cast<int>(surface.rect.Width);
|
||||
const int height = static_cast<int>(surface.rect.Height);
|
||||
const std::size_t bytes = static_cast<std::size_t>(surface.stride) *
|
||||
static_cast<std::size_t>(height);
|
||||
|
||||
frame.width = width;
|
||||
frame.height = height;
|
||||
frame.stride = surface.stride;
|
||||
frame.pixels.resize(bytes);
|
||||
std::memcpy(frame.pixels.data(), surface.bits, bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!frame.Valid() || !ClampRect(frame, rect, area))
|
||||
{
|
||||
return 0ull;
|
||||
}
|
||||
std::uint64_t hash = 1469598103934665603ull; // FNV-1a 64 位 offset basis
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
// 只混 BGR:GDI 写 DIB 时不设置 alpha 字节,混进去会让哈希随填充方式漂移。
|
||||
hash ^= line[column * 4 + 0];
|
||||
hash *= 1099511628211ull;
|
||||
hash ^= line[column * 4 + 1];
|
||||
hash *= 1099511628211ull;
|
||||
hash ^= line[column * 4 + 2];
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
int CountInk(const Frame& frame, const PixelRect& rect)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!frame.Valid() || !ClampRect(frame, rect, area))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// 先找到一个"主色"(出现次数最多的颜色 = 背景),再数与之不同的像素。
|
||||
std::unordered_map<std::uint32_t, int> histogram;
|
||||
histogram.reserve(static_cast<std::size_t>(area.width) * 2u);
|
||||
for (int row = 0; row < area.height; row += 2)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; column += 2)
|
||||
{
|
||||
const std::uint32_t color = static_cast<std::uint32_t>(line[column * 4 + 0]) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 2]) << 16);
|
||||
++histogram[color];
|
||||
}
|
||||
}
|
||||
std::uint32_t background = 0;
|
||||
int best = -1;
|
||||
for (const auto& entry : histogram)
|
||||
{
|
||||
if (entry.second > best)
|
||||
{
|
||||
best = entry.second;
|
||||
background = entry.first;
|
||||
}
|
||||
}
|
||||
|
||||
int ink = 0;
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
const std::uint32_t color = static_cast<std::uint32_t>(line[column * 4 + 0]) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 2]) << 16);
|
||||
if (color != background)
|
||||
{
|
||||
++ink;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ink;
|
||||
}
|
||||
|
||||
int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, const int tolerance)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!left.Valid() || !right.Valid() || left.width != right.width ||
|
||||
left.height != right.height || !ClampRect(left, rect, area))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
int changed = 0;
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* a = left.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
const std::uint8_t* b = right.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
const int deltaB = std::abs(static_cast<int>(a[column * 4 + 0]) - b[column * 4 + 0]);
|
||||
const int deltaG = std::abs(static_cast<int>(a[column * 4 + 1]) - b[column * 4 + 1]);
|
||||
const int deltaR = std::abs(static_cast<int>(a[column * 4 + 2]) - b[column * 4 + 2]);
|
||||
if (deltaB > tolerance || deltaG > tolerance || deltaR > tolerance)
|
||||
{
|
||||
++changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool SavePng(const Frame& frame, const std::string& path)
|
||||
{
|
||||
if (!frame.Valid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::size_t slash = path.find_last_of("/\\");
|
||||
if (slash != std::string::npos)
|
||||
{
|
||||
EnsureDirectory(path.substr(0, slash));
|
||||
}
|
||||
|
||||
Gdiplus::Bitmap bitmap(frame.width, frame.height, frame.stride,
|
||||
PixelFormat32bppRGB,
|
||||
const_cast<BYTE*>(frame.pixels.data()));
|
||||
if (bitmap.GetLastStatus() != Gdiplus::Ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
CLSID pngClsid = {};
|
||||
if (!FindEncoderClsid(L"image/png", pngClsid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::wstring widePath = WidenPath(path);
|
||||
const Gdiplus::Status status = bitmap.Save(widePath.c_str(), &pngClsid, nullptr);
|
||||
return status == Gdiplus::Ok;
|
||||
}
|
||||
|
||||
void EnsureDirectory(const std::string& asciiPath)
|
||||
{
|
||||
if (asciiPath.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const std::wstring wide = WidenPath(asciiPath);
|
||||
if (wide.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::wstring prefix;
|
||||
prefix.reserve(wide.size());
|
||||
for (std::size_t index = 0; index < wide.size(); ++index)
|
||||
{
|
||||
prefix.push_back(wide[index]);
|
||||
const bool boundary =
|
||||
(wide[index] == L'/' || wide[index] == L'\\' || index + 1 == wide.size());
|
||||
if (boundary)
|
||||
{
|
||||
if (!prefix.empty() && prefix.back() != L'/' && prefix.back() != L'\\')
|
||||
{
|
||||
CreateDirectoryW(prefix.c_str(), nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveDirectoryTree(const std::string& asciiPath)
|
||||
{
|
||||
const std::wstring wide = WidenPath(asciiPath);
|
||||
if (wide.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 用 SHFileOperation 太重;这里用"先递归删文件再删目录"的最小实现。
|
||||
WIN32_FIND_DATAW data = {};
|
||||
HANDLE handle = FindFirstFileW((wide + L"\\*").c_str(), &data);
|
||||
if (handle != INVALID_HANDLE_VALUE)
|
||||
{
|
||||
do
|
||||
{
|
||||
if (wcscmp(data.cFileName, L".") == 0 || wcscmp(data.cFileName, L"..") == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::wstring child = wide + L"\\" + data.cFileName;
|
||||
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)
|
||||
{
|
||||
const int length = WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, nullptr, 0,
|
||||
nullptr, nullptr);
|
||||
std::string narrow(static_cast<std::size_t>(std::max(0, length - 1)), '\0');
|
||||
if (length > 1)
|
||||
{
|
||||
WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, &narrow[0], length, nullptr,
|
||||
nullptr);
|
||||
}
|
||||
RemoveDirectoryTree(narrow);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteFileW(child.c_str());
|
||||
}
|
||||
} while (FindNextFileW(handle, &data) != FALSE);
|
||||
FindClose(handle);
|
||||
}
|
||||
RemoveDirectoryW(wide.c_str());
|
||||
}
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
Reference in new issue
Block a user