Files
Just-DataInspector/Tools/OffscreenChecks/Src/Checks.cpp
T
DeepSeek V4.1 Flash 97e9163314 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。
2026-11-04 11:41:08 +08:00

852 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Checks.cpp
* @brief 离屏检查的具体实现(隐藏窗口 + 合成消息,绝不占用真实鼠标/键盘/屏幕)。
*
* 检查清单:
* * `smoke` —— 能建隐藏窗口、能加载数据、能画出波形(像素级)。
* * `frames` —— **连续帧抓取**:沿绘图区移动鼠标,逐帧抓 PNG + 哈希 + 耗时;
* 并验证"同一状态连续两帧逐像素相同"(渲染确定性)。
* * `panels` —— 7 种实验性功能面板全部展开后的**几何 + 像素基线**,
* 支持 `-mode record` / `-mode compare` 做重构前后 A/B。
* * `edgestats` —— 边沿统计与**独立参考实现**逐位比对(覆盖阈值变化、缓存稳定性)。
* * `perf` —— 关键路径耗时:整帧、面板几何、边沿统计查询。
*
* ⚠️ 所有 `Expect()` 都是"记录并继续",单个检查跑完仍然给出完整报告。
*/
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
#include "CheckRunner.hpp"
namespace jdi
{
namespace tools
{
namespace
{
/** @brief 各检查共用的临时输出目录(相对项目根,已被 .gitignore 忽略)。 */
const char* const kDefaultOutDir = "../Temp/tools";
/** @brief 拼路径。 */
std::string Join(const std::string& directory, const std::string& name)
{
if (directory.empty())
{
return name;
}
const char last = directory.back();
if (last == '/' || last == '\\')
{
return directory + name;
}
return directory + "/" + name;
}
/** @brief 把 ASCII/本地代码页路径转宽字符串。 */
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 在路径中取目录部分。 */
std::string DirectoryOf(const std::string& path)
{
const std::size_t slash = path.find_last_of("/\\");
return (slash == std::string::npos) ? std::string() : path.substr(0, slash);
}
/** @brief 扫描信号的有限采样值范围(不依赖统计字段,便于构造探针阈值)。 */
void FiniteRange(const std::vector<double>& values, double& low, double& high)
{
low = std::numeric_limits<double>::infinity();
high = -std::numeric_limits<double>::infinity();
for (const double value : values)
{
if (!std::isfinite(value))
{
continue;
}
low = std::min(low, value);
high = std::max(high, value);
}
if (!std::isfinite(low) || !std::isfinite(high))
{
low = 0.0;
high = 1.0;
}
}
/**
* @brief 边沿统计的**独立参考实现**(与 `PlotView::EdgeScanFor` 的契约一致)。
*
* 契约(源码注释与实测共同确认):
* * 判定量 `delta = 绘制值 - 阈值`;
* * 采样对 `k-1 → k`:`previous < 0 && current >= 0` 记上升沿,
* `previous > 0 && current <= 0` 记下降沿;
* * 任一端为 NaN(缺失值 / 区间外)则该采样对不计入,并**打断连续性**。
*
* ⚠️ 这里刻意用最朴素的逐点写法:参考实现宁慢勿巧,才有资格当"金标准"。
*/
void ReferenceEdgeCounts(const std::vector<double>& values, const SignalStyle& style,
const double threshold, std::size_t& rising, std::size_t& falling)
{
rising = 0;
falling = 0;
double previous = std::numeric_limits<double>::quiet_NaN();
for (std::size_t index = 0; index < values.size(); ++index)
{
const double raw = values[index];
double current = std::numeric_limits<double>::quiet_NaN();
if (std::isfinite(raw))
{
const double drawn = ApplyDrawTransform(style, raw);
if (std::isfinite(drawn))
{
current = drawn - threshold;
}
}
if (std::isfinite(previous) && std::isfinite(current))
{
if (previous < 0.0 && current >= 0.0)
{
++rising;
}
else if (previous > 0.0 && current <= 0.0)
{
++falling;
}
}
previous = current;
}
}
/** @brief 查询边沿统计直到完成(每次未完成就再出一帧以重置每帧预算)。 */
bool CollectEdgeCounts(Session& session, const TimeAxis& axis, const SignalData& signal,
const SignalStyle& style, const double threshold, std::size_t& rising,
std::size_t& falling, int& framesUsed)
{
rising = 0;
falling = 0;
framesUsed = 0;
bool complete = false;
const int maxFrames = 4000;
while (!complete && framesUsed < maxFrames)
{
session.App().m_plot.EdgeCountsFor(axis, signal, style, threshold, false, 0.0, 0.0, rising,
falling, complete);
if (complete)
{
break;
}
session.RenderFrame();
++framesUsed;
}
return complete;
}
/** @brief 简单 key=value 基线文件的读取。 */
std::vector<std::pair<std::string, std::string>> ParseBaseline(const std::string& text)
{
std::vector<std::pair<std::string, std::string>> entries;
std::size_t cursor = 0;
while (cursor < text.size())
{
std::size_t end = text.find('\n', cursor);
if (end == std::string::npos)
{
end = text.size();
}
std::string line = text.substr(cursor, end - cursor);
cursor = end + 1;
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
{
line.pop_back();
}
if (line.empty() || line[0] == '#')
{
continue;
}
const std::size_t equals = line.find('=');
if (equals == std::string::npos)
{
continue;
}
entries.emplace_back(line.substr(0, equals), line.substr(equals + 1));
}
return entries;
}
/** @brief 面板种类名(打印 / 基线文件键名)。 */
const char* PanelKindName(const EdgePanelKind kind)
{
switch (kind)
{
case EdgePanelKind::COUNT:
return "count";
case EdgePanelKind::CENTER:
return "center";
case EdgePanelKind::MEAN:
return "mean";
case EdgePanelKind::PEAK:
return "peak";
case EdgePanelKind::MARK:
return "mark";
case EdgePanelKind::RESAMPLE:
return "resample";
case EdgePanelKind::CLIP:
return "clip";
}
return "unknown";
}
/** @brief 确保某个实验性功能已开启(`Toggle*` 是开关语义)。 */
void EnsureLaneFeature(PlotView& plot, const int laneIndex, const EdgePanelKind kind)
{
switch (kind)
{
case EdgePanelKind::COUNT:
if (!plot.LaneEdgeCount(laneIndex))
{
plot.ToggleLaneEdgeCount(laneIndex);
}
break;
case EdgePanelKind::CENTER:
if (!plot.LaneEdgeCenterCount(laneIndex))
{
plot.ToggleLaneEdgeCenterCount(laneIndex);
}
break;
case EdgePanelKind::MEAN:
if (!plot.LaneMeanFilter(laneIndex))
{
plot.ToggleLaneMeanFilter(laneIndex);
}
break;
case EdgePanelKind::PEAK:
if (!plot.LaneWeakPeak(laneIndex))
{
plot.ToggleLaneWeakPeak(laneIndex);
}
break;
case EdgePanelKind::MARK:
if (!plot.LaneFreeMarker(laneIndex))
{
plot.ToggleLaneFreeMarker(laneIndex);
}
break;
case EdgePanelKind::RESAMPLE:
if (!plot.LaneResample(laneIndex))
{
plot.ToggleLaneResample(laneIndex);
}
break;
case EdgePanelKind::CLIP:
if (!plot.LaneClip(laneIndex))
{
plot.ToggleLaneClip(laneIndex);
}
break;
}
}
// ---- 检查实现 -------------------------------------------------------
int CheckSmoke(Session& session, const CheckOptions& options)
{
int clientWidth = 0;
int clientHeight = 0;
session.ClientSize(clientWidth, clientHeight);
EmitLine("[smoke] client=%dx%d lanes=%d\n", clientWidth, clientHeight, session.LaneCount());
session.ShowFirstSignals(3);
Expect(session.RenderFrame(), "render frame");
Expect(session.LaneCount() > 0, "at least one lane after showing signals");
const Frame& frame = session.LastFrame();
Expect(frame.Valid(), "captured frame is valid");
const PixelRect plot = ToPixelRect(session.PlotArea());
const int ink = CountInk(frame, plot);
EmitLine("[smoke] plot rect=(%d,%d,%dx%d) ink=%d render=%.2fms\n", plot.x, plot.y, plot.width,
plot.height, ink, session.LastRenderMs());
Expect(plot.width > 100 && plot.height > 100, "plot area is reasonable");
Expect(ink > 1000, "plot area has drawn content");
if (options.savePng)
{
const std::string path = Join(options.outDir, "smoke.png");
Expect(SavePng(frame, path), "save smoke.png");
EmitLine("[smoke] saved %s\n", path.c_str());
}
// 同一状态再渲染一帧:必须逐像素相同(渲染确定性;也验证缓存没有"半帧"状态)
const Frame first = session.LastFrame();
Expect(session.RenderFrame(), "render frame again");
const PixelRect whole{0, 0, session.LastFrame().width, session.LastFrame().height};
const int drift = CountDiff(first, session.LastFrame(), whole, 0);
EmitLine("[smoke] determinism: differing pixels between two identical frames = %d\n", drift);
Expect(drift == 0, "two identical frames are pixel-identical");
return CheckFailed();
}
int CheckFrames(Session& session, const CheckOptions& options)
{
session.ShowFirstSignals(2);
Expect(session.RenderFrame(), "initial render");
const PixelRect plot = ToPixelRect(session.PlotArea());
Expect(plot.width > 100, "plot area is wide enough");
// 抓帧同时扫过**信号列表**:行悬停会改变行底色,因此每帧像素必然不同
//(比“在空白绘图区里移动鼠标”更能证明“帧序列真的拍到了状态变化”)。
const gd::RectF panelArea = session.App().m_panel.Area();
const int panelX = static_cast<int>(panelArea.X + panelArea.Width * 0.5f);
const int panelTop = static_cast<int>(panelArea.Y) + 60;
const int frames = std::max(1, options.frames);
int identicalPairs = 0;
std::uint64_t previousHash = 0;
double totalMs = 0.0;
double worstMs = 0.0;
for (int index = 0; index < frames; ++index)
{
// 沿信号列表纵向移动“鼠标”(合成消息,不动真实光标)
const int y = panelTop + index * 34;
session.MouseMove(panelX, y);
Expect(session.RenderFrame(), "render frame while moving");
const Frame& frame = session.LastFrame();
const std::uint64_t hash = HashRegion(frame, plot);
char name[64] = {};
std::snprintf(name, sizeof(name), "frame-%03d.png", index);
if (options.savePng)
{
if (!SavePng(frame, Join(options.outDir, name)))
{
Expect(false, "save frame png");
}
}
EmitLine("[frames] #%02d mouse=(%d,%d) plot-hash=%016llX render=%.2fms file=%s\n", index, panelX,
y, static_cast<unsigned long long>(hash), session.LastRenderMs(), name);
totalMs += session.LastRenderMs();
worstMs = std::max(worstMs, session.LastRenderMs());
if (index > 0 && hash == previousHash)
{
++identicalPairs;
EmitLine("[frames] WARNING: frame %d pixel-identical to previous\n", index);
}
previousHash = hash;
}
EmitLine("[frames] average=%.2fms worst=%.2fms identical_pairs=%d\n",
totalMs / static_cast<double>(frames), worstMs, identicalPairs);
Expect(totalMs / static_cast<double>(frames) < 500.0, "average frame time below 500ms");
// 至少要有一次可见变化(某些 y 落在筛选框/空白带,重复是正常的)
Expect(identicalPairs < frames - 1, "frame sequence captures at least one visual change");
return CheckFailed();
}
int CheckPanels(Session& session, const CheckOptions& options)
{
// 单信号单网格:7 个面板都落在同一个网格上,便于逐面板取几何
session.ShowFirstSignals(1);
Expect(session.LaneCount() == 1, "exactly one lane");
Application& app = session.App();
PlotView& plot = app.m_plot;
// 「选区截取」要求双光标,先切到双光标模式
if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE)
{
const int guard = 4;
for (int step = 0; step < guard && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step)
{
plot.CycleCursorMode();
}
}
static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
for (const EdgePanelKind kind : kinds)
{
EnsureLaneFeature(plot, 0, kind);
}
Expect(session.RenderFrame(), "render with all panels open");
const PixelRect plotRect = ToPixelRect(session.PlotArea());
std::string content;
for (const EdgePanelKind kind : kinds)
{
const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind);
const PixelRect rect = ToPixelRect(layout.panel);
const Lane& lane = app.ActiveTab().Lanes()[0];
const bool enabled = LanePanelEnabled(lane, kind);
const bool open = LanePanelStateOf(lane, kind).open;
EmitLine("[panels] %-8s enabled=%d open=%d rect=(%d,%d,%dx%d) hash=%016llX\n",
PanelKindName(kind), enabled ? 1 : 0, open ? 1 : 0, rect.x, rect.y, rect.width,
rect.height, static_cast<unsigned long long>(HashRegion(session.LastFrame(), rect)));
Expect(enabled, std::string("feature enabled: ") + PanelKindName(kind));
Expect(rect.width > 0 && rect.height > 0,
std::string("panel has size: ") + PanelKindName(kind));
// 不变式:面板必须被绘图区夹住(这是历史上"面板超出绘图区"缺陷的护栏)
const bool inside = rect.x >= plotRect.x - 1 && rect.y >= plotRect.y - 1 &&
rect.x + rect.width <= plotRect.x + plotRect.width + 1 &&
rect.y + rect.height <= plotRect.y + plotRect.height + 1;
Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind));
char buffer[160] = {};
std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind),
static_cast<unsigned long long>(HashRegion(session.LastFrame(), rect)), rect.x,
rect.y, rect.width, rect.height);
content += buffer;
}
const std::string baselinePath = Join(options.outDir, "panels-baseline.txt");
if (options.mode == "record")
{
Expect(WriteTextFile(baselinePath, content), "write panels baseline");
EmitLine("[panels] baseline recorded: %s\n", baselinePath.c_str());
}
else if (options.mode == "compare")
{
const std::string baseline = ReadTextFile(baselinePath);
Expect(!baseline.empty(), "panels baseline exists (run -mode record first)");
const std::vector<std::pair<std::string, std::string>> entries = ParseBaseline(baseline);
const std::vector<std::pair<std::string, std::string>> current = ParseBaseline(content);
ExpectEqual(static_cast<long long>(current.size()), static_cast<long long>(entries.size()),
"panel count matches baseline");
for (const auto& expected : entries)
{
bool found = false;
for (const auto& actual : current)
{
if (actual.first != expected.first)
{
continue;
}
found = true;
if (actual.second != expected.second)
{
EmitLine("[panels] MISMATCH %s: baseline=%s current=%s\n", expected.first.c_str(),
expected.second.c_str(), actual.second.c_str());
Expect(false, std::string("panel pixel hash unchanged: ") + expected.first);
}
break;
}
if (!found)
{
Expect(false, std::string("panel present in baseline: ") + expected.first);
}
}
EmitLine("[panels] compare done against %s\n", baselinePath.c_str());
}
else
{
// verify:没有基线也要守住"几何不变式",并把当前哈希打出来便于人工比对
EmitLine("[panels] (hint) use -mode record / -mode compare for pixel A/B\n");
}
if (options.savePng)
{
Expect(SavePng(session.LastFrame(), Join(options.outDir, "panels.png")),
"save panels.png");
}
return CheckFailed();
}
int CheckEdgeStats(Session& session, const CheckOptions& options)
{
session.ShowFirstSignals(1);
Expect(session.LaneCount() >= 1, "one lane");
const Application& app = session.App();
SignalRef ref{0, 0};
const DataSet& dataSet = app.m_dataSet;
const SignalGroup* group = dataSet.GroupAt(0);
Expect(group != nullptr && !group->signals.empty(), "group has signals");
if (group == nullptr || group->signals.empty())
{
return CheckFailed();
}
const SignalData& signal = group->signals[0];
const TimeAxis& axis = group->AxisOf(signal);
const SignalStyle& style = signal.style;
double low = 0.0;
double high = 1.0;
FiniteRange(signal.values, low, high);
EmitLine("[edgestats] signal=%d samples min=%.6g max=%.6g\n",
static_cast<int>(signal.values.size()), low, high);
const double thresholds[] = {low + (high - low) * 0.25, (low + high) * 0.5,
low + (high - low) * 0.75};
for (const double threshold : thresholds)
{
std::size_t rising = 0;
std::size_t falling = 0;
int framesUsed = 0;
const bool complete = CollectEdgeCounts(session, axis, signal, style, threshold, rising,
falling, framesUsed);
Expect(complete, "edge scan completes");
std::size_t expectedRising = 0;
std::size_t expectedFalling = 0;
ReferenceEdgeCounts(signal.values, style, threshold, expectedRising, expectedFalling);
EmitLine("[edgestats] threshold=%.6g frames=%d got=(%llu,%llu) expected=(%llu,%llu)\n",
threshold, framesUsed, static_cast<unsigned long long>(rising),
static_cast<unsigned long long>(falling),
static_cast<unsigned long long>(expectedRising),
static_cast<unsigned long long>(expectedFalling));
ExpectEqual(static_cast<long long>(rising), static_cast<long long>(expectedRising),
"rising edge count matches reference");
ExpectEqual(static_cast<long long>(falling), static_cast<long long>(expectedFalling),
"falling edge count matches reference");
// 缓存稳定性:同参数再查一次必须给出同一份完整结果
std::size_t againRising = 0;
std::size_t againFalling = 0;
bool againComplete = false;
app.m_plot.EdgeCountsFor(axis, signal, style, threshold, false, 0.0, 0.0, againRising,
againFalling, againComplete);
Expect(againComplete, "repeat query completes immediately");
ExpectEqual(static_cast<long long>(againRising), static_cast<long long>(rising),
"repeat query rising stable");
ExpectEqual(static_cast<long long>(againFalling), static_cast<long long>(falling),
"repeat query falling stable");
}
// 中心轴统计:只验证"能完成且结果稳定"(独立参考实现需要复刻窗口语义,
// 收益不大;普通计数已覆盖 `EdgeScanFor` 的共享状态机)
{
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = false;
int guard = 0;
while (!complete && guard < 4000)
{
app.m_plot.EdgeCenterCountsFor(axis, signal, style, 5, 1, false, 0.0, 0.0, rising,
falling, complete);
if (complete)
{
break;
}
session.RenderFrame();
++guard;
}
Expect(complete, "centre edge scan completes");
EmitLine("[edgestats] centre (points=5,iterations=1) rising=%llu falling=%llu frames=%d\n",
static_cast<unsigned long long>(rising),
static_cast<unsigned long long>(falling), guard);
(void)ref;
}
return CheckFailed();
}
int CheckPerf(Session& session, const CheckOptions& options)
{
session.ShowFirstSignals(8);
Expect(session.RenderFrame(), "warm-up render");
// 1) 整帧渲染耗时(取 7 帧中位数,第一帧可能是预热)
std::vector<double> renderTimes;
for (int iteration = 0; iteration < 7; ++iteration)
{
if (!session.RenderFrame())
{
Expect(false, "render frame");
break;
}
renderTimes.push_back(session.LastRenderMs());
}
std::sort(renderTimes.begin(), renderTimes.end());
const double medianRender =
renderTimes.empty() ? 0.0 : renderTimes[renderTimes.size() / 2];
EmitLine("[perf] render median=%.2fms lanes=%d\n", medianRender, session.LaneCount());
Expect(medianRender < 500.0, "full frame below 500ms");
// 2) 面板几何:`EdgePanelLayoutFor` 在鼠标移动的高频路径上,必须是"微秒级"
Application& app = session.App();
PlotView& plot = app.m_plot;
static const EdgePanelKind kinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
for (const EdgePanelKind kind : kinds)
{
EnsureLaneFeature(plot, 0, kind);
}
session.RenderFrame();
const int iterations = 2000;
double totalMicroseconds = 0.0;
LARGE_INTEGER frequency = {};
LARGE_INTEGER start = {};
LARGE_INTEGER stop = {};
QueryPerformanceFrequency(&frequency);
QueryPerformanceCounter(&start);
float sink = 0.0f;
for (int iteration = 0; iteration < iterations; ++iteration)
{
for (const EdgePanelKind kind : kinds)
{
const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind);
sink += layout.panel.Width + layout.title.Height;
}
}
QueryPerformanceCounter(&stop);
totalMicroseconds = static_cast<double>(stop.QuadPart - start.QuadPart) * 1000000.0 /
static_cast<double>(frequency.QuadPart);
const double perCall =
totalMicroseconds / static_cast<double>(iterations * static_cast<int>(sizeof(kinds) / sizeof(kinds[0])));
EmitLine("[perf] EdgePanelLayoutFor: %.3f us/call (x%d calls) sink=%.1f\n", perCall,
iterations * static_cast<int>(sizeof(kinds) / sizeof(kinds[0])), sink);
Expect(perCall < 200.0, "panel layout below 200us per call");
// 3) 边沿统计查询:**多信号**时的"状态查找"成本
// (第 33 轮报告建议把 `m_edgeScans` 的线性扫描改哈希索引,改的就是这条路径)
const DataSet& dataSet = app.m_dataSet;
struct QueryTarget
{
const SignalData* signal = nullptr;
const TimeAxis* axis = nullptr;
double threshold = 0.0;
};
std::vector<QueryTarget> targets;
for (int groupIndex = 0; groupIndex < static_cast<int>(dataSet.GroupCount()); ++groupIndex)
{
const SignalGroup* group = dataSet.GroupAt(groupIndex);
if (group == nullptr)
{
continue;
}
for (const SignalData& signal : group->signals)
{
if (signal.values.size() < 2)
{
continue;
}
double low = 0.0;
double high = 1.0;
FiniteRange(signal.values, low, high);
QueryTarget target;
target.signal = &signal;
target.axis = &group->AxisOf(signal);
target.threshold = (low + high) * 0.5;
targets.push_back(target);
if (targets.size() >= 200)
{
break;
}
}
if (targets.size() >= 200)
{
break;
}
}
if (!targets.empty())
{
// 预热:把所有累积状态都建起来并扫完(否则测到的是"扫描成本"而不是"查找成本")
for (int guard = 0; guard < 400; ++guard)
{
bool allComplete = true;
for (const QueryTarget& target : targets)
{
std::size_t r = 0;
std::size_t f = 0;
bool complete = false;
plot.EdgeCountsFor(*target.axis, *target.signal, target.signal->style,
target.threshold, false, 0.0, 0.0, r, f, complete);
if (!complete)
{
allComplete = false;
}
}
if (allComplete)
{
break;
}
session.RenderFrame();
}
EmitLine("[perf] edge states=%d targets=%d\n", static_cast<int>(plot.m_edgeScans.size()),
static_cast<int>(targets.size()));
const int rounds = 20;
QueryPerformanceCounter(&start);
for (int round = 0; round < rounds; ++round)
{
for (const QueryTarget& target : targets)
{
std::size_t r = 0;
std::size_t f = 0;
bool complete = false;
plot.EdgeCountsFor(*target.axis, *target.signal, target.signal->style,
target.threshold, false, 0.0, 0.0, r, f, complete);
sink += static_cast<float>(r + f);
}
}
QueryPerformanceCounter(&stop);
const double total = static_cast<double>(stop.QuadPart - start.QuadPart) * 1000000.0 /
static_cast<double>(frequency.QuadPart);
const double perQuery = total / (static_cast<double>(rounds) *
static_cast<double>(targets.size()));
EmitLine("[perf] EdgeCountsFor sweep: %.3f us/query (%d targets x %d rounds) sink=%.1f\n",
perQuery, static_cast<int>(targets.size()), rounds, sink);
Expect(perQuery < 20.0, "edge count query below 20us with 200 states");
}
if (options.savePng)
{
Expect(SavePng(session.LastFrame(), Join(options.outDir, "perf.png")), "save perf.png");
}
return CheckFailed();
}
} // namespace
const std::vector<CheckDescriptor>& AvailableChecks()
{
static const std::vector<CheckDescriptor> checks = {
{"smoke", "offscreen init + load + render + determinism + save png", CheckSmoke},
{"frames", "capture consecutive frames while moving mouse (png + hash + timing)", CheckFrames},
{"panels", "all 7 experiment panels: geometry invariants + pixel baseline A/B", CheckPanels},
{"edgestats", "edge counts vs independent reference implementation", CheckEdgeStats},
{"perf", "render / panel layout / edge query timings", CheckPerf},
};
return checks;
}
const CheckDescriptor* FindCheck(const std::string& name)
{
for (const CheckDescriptor& descriptor : AvailableChecks())
{
if (name == descriptor.name)
{
return &descriptor;
}
}
return nullptr;
}
int& CheckTotal()
{
static int total = 0;
return total;
}
int& CheckFailed()
{
static int failed = 0;
return failed;
}
void Expect(const bool ok, const std::string& what)
{
++CheckTotal();
if (!ok)
{
++CheckFailed();
EmitLine(" [FAIL] %s\n", what.c_str());
}
}
void ExpectEqual(const long long actual, const long long expected, const std::string& what)
{
++CheckTotal();
if (actual != expected)
{
++CheckFailed();
EmitLine(" [FAIL] %s: actual=%lld expected=%lld\n", what.c_str(), actual, expected);
}
}
void ExpectNear(const double actual, const double expected, const double tolerance,
const std::string& what)
{
const double delta = (actual > expected) ? (actual - expected) : (expected - actual);
++CheckTotal();
if (!(delta <= tolerance))
{
++CheckFailed();
EmitLine(" [FAIL] %s: actual=%.17g expected=%.17g\n", what.c_str(), actual, expected);
}
}
std::string ReadTextFile(const std::string& path)
{
const std::wstring wide = Widen(path);
if (wide.empty())
{
return std::string();
}
HANDLE handle = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE)
{
return std::string();
}
LARGE_INTEGER size = {};
if (!GetFileSizeEx(handle, &size) || size.QuadPart <= 0 || size.QuadPart > (1ll << 26))
{
CloseHandle(handle);
return std::string();
}
std::string content(static_cast<std::size_t>(size.QuadPart), '\0');
DWORD read = 0;
const BOOL ok = ReadFile(handle, &content[0], static_cast<DWORD>(content.size()), &read, nullptr);
CloseHandle(handle);
if (!ok)
{
return std::string();
}
content.resize(read);
return content;
}
bool WriteTextFile(const std::string& path, const std::string& content)
{
EnsureDirectory(DirectoryOf(path));
const std::wstring wide = Widen(path);
if (wide.empty())
{
return false;
}
HANDLE handle = CreateFileW(wide.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE)
{
return false;
}
DWORD written = 0;
const BOOL ok = WriteFile(handle, content.data(), static_cast<DWORD>(content.size()), &written,
nullptr);
CloseHandle(handle);
return ok != FALSE && written == content.size();
}
} // namespace tools
} // namespace jdi