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

+231
View File
@@ -0,0 +1,231 @@
/**
* @file Session.cpp
* @brief 离屏会话实现:隐藏窗口、加载数据、逐帧渲染与抓帧。
*
* ⚠️ 全程不显示窗口、不移动真实鼠标、不抢焦点:所有"鼠标"都是直接调用
* `Application::HandleMouseMove()` 注入的合成消息。
*/
#include "OffscreenSupport.hpp"
namespace jdi
{
namespace tools
{
namespace
{
/** @brief 窄字符串(本地代码页)→ 宽字符串。 */
std::wstring WidenLocal(const std::string& text)
{
if (text.empty())
{
return std::wstring();
}
// 先按 UTF-8 试一次(Agent / 终端最常见),失败再退回本地代码页。
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.c_str(), -1, nullptr, 0);
UINT codePage = CP_UTF8;
if (length <= 1)
{
codePage = CP_ACP;
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(codePage, 0, text.c_str(), -1, &wide[0], length);
return wide;
}
} // namespace
bool Session::Start()
{
if (m_started)
{
return true;
}
m_app = &Application::Instance();
if (!m_app->Initialize(GetModuleHandleW(nullptr), SW_HIDE))
{
m_app = nullptr;
return false;
}
m_started = true;
return true;
}
void Session::Stop()
{
if (m_app != nullptr && m_started)
{
m_app->Shutdown();
m_started = false;
}
}
bool Session::Load(const std::string& path)
{
if (m_app == nullptr)
{
return false;
}
const std::wstring wide = WidenLocal(path);
if (wide.empty())
{
return false;
}
if (!m_app->LoadFile(wide))
{
return false;
}
m_loadedPath = path;
m_app->SynchronizeView(true);
m_app->Relayout();
return true;
}
bool Session::RenderFrame()
{
if (m_app == nullptr)
{
return false;
}
// 强制整帧:`m_chromeDirty` 为真时标签栏/工具栏/面板/绘图区都会重绘,
// 否则 `Render()` 可能走"只重绘绘图区"的快路径,抓到的不是完整画面。
m_app->m_chromeDirty = true;
LARGE_INTEGER frequency = {};
LARGE_INTEGER start = {};
LARGE_INTEGER stop = {};
QueryPerformanceFrequency(&frequency);
QueryPerformanceCounter(&start);
m_app->Render();
QueryPerformanceCounter(&stop);
m_lastRenderMs = static_cast<double>(stop.QuadPart - start.QuadPart) * 1000.0 /
static_cast<double>(frequency.QuadPart);
return CaptureFrame(*m_app, m_lastFrame);
}
void Session::ShowSignals(const std::vector<SignalRef>& refs)
{
if (m_app == nullptr)
{
return;
}
TabConfig& tab = m_app->ActiveTab();
tab.SetAllVisible(false);
for (const SignalRef& ref : refs)
{
tab.SetVisible(ref, true);
}
m_app->SynchronizeView(true);
m_app->Relayout();
}
void Session::ShowFirstSignals(const int limit)
{
if (m_app == nullptr)
{
return;
}
const DataSet& dataSet = m_app->m_dataSet;
std::vector<SignalRef> refs;
for (int groupIndex = 0; groupIndex < static_cast<int>(dataSet.GroupCount()); ++groupIndex)
{
const SignalGroup* group = dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
continue;
}
for (int signalIndex = 0; signalIndex < static_cast<int>(group->signals.size()); ++signalIndex)
{
if (limit > 0 && static_cast<int>(refs.size()) >= limit)
{
break;
}
refs.push_back(SignalRef{groupIndex, signalIndex});
}
if (limit > 0 && static_cast<int>(refs.size()) >= limit)
{
break;
}
}
ShowSignals(refs);
}
void Session::HideAll()
{
if (m_app == nullptr)
{
return;
}
m_app->ActiveTab().SetAllVisible(false);
m_app->SynchronizeView(true);
m_app->Relayout();
}
bool Session::MouseMove(const int x, const int y)
{
if (m_app == nullptr)
{
return false;
}
return m_app->HandleMouseMove(x, y, false);
}
gd::RectF Session::PlotArea() const
{
if (m_app == nullptr)
{
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
}
return m_app->m_plot.Area();
}
gd::RectF Session::LaneRect(const int index) const
{
if (m_app == nullptr || index < 0)
{
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
}
const std::vector<PlotView::LaneVisual>& lanes = m_app->m_plot.m_lanes;
if (index >= static_cast<int>(lanes.size()))
{
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
}
return lanes[static_cast<std::size_t>(index)].rect;
}
int Session::LaneCount() const
{
if (m_app == nullptr)
{
return 0;
}
return static_cast<int>(m_app->m_plot.m_lanes.size());
}
void Session::ClientSize(int& width, int& height) const
{
width = 0;
height = 0;
if (m_app == nullptr || m_app->m_hwnd == nullptr)
{
return;
}
RECT rect = {};
GetClientRect(m_app->m_hwnd, &rect);
width = rect.right - rect.left;
height = rect.bottom - rect.top;
if (width <= 0 || height <= 0)
{
// 隐藏窗口在某些 DPI 路径下可能还没有客户区尺寸,退回后备缓冲区尺寸。
const Frame& frame = m_lastFrame;
width = frame.width;
height = frame.height;
}
}
} // namespace tools
} // namespace jdi