Files
Just-DataInspector/Tools/OffscreenChecks/Src/Checks.cpp
T
DeepSeek V4.1 Flash 353c553721 test(tools): rowgeom 逐行几何基线 + 长度不变式按类别判定
新增 `OffscreenChecks -run rowgeom`(§13.6 第 1 项),把 7 个面板的每个矩形的
每个字段导出为文本基线并逐项比对,作为"完整结构合并 / 重写布局算式"的前置护栏。

两层覆盖
- 细节层:6 信号 1 网格、7 面板全开、参数行展开、3 个真实固定点、折起+展开各一份,
  983 条键(-data MF4-Record.MF4 时 1304 条),失败时直接给出字段名+行号+新旧值;
- 规模层:全部信号合并成 1 个网格(MF4 = 566 行),每面板一个 FNV-1a 摘要 + 行数,
  任何一行变动都会改哈希,同时不产生几十万行基线。

负向自检(证明护栏不是空转)
- 篡改基线副本 `mark.markerRowCount=3 → 2` ⇒ `CHANGED … mismatches=1` + FAIL。

顺带修正第 36 轮定得过严的不变式
- `rowRefs` 是重载字段:MARK 面板用它表示"信号分组",真正的条目行在 markerRows,
  且只填 rowNameRects / rowNameCloses,其余 14 列必须为空;
  ⇒ `panels` / `panelscroll` 的"18 列全等"改为按 `PanelStyle::markerGroups` 分流判定,
  失败时打印 15 个计数字段便于定位。
- 导出器/不变式一律**逐列按自身长度**遍历(按 rowRefs.size() 索引其它列会越界,
  这是本轮第一次 0xC0000005 的根因)。

测试用例里的两处修正
- `MERGE_LANES` 之后补 `SynchronizeView(true)`:`SetMode` 只重建 TabConfig 的网格配置,
  `PlotView::m_lanes` 要等下一次布局才刷新;
- 场景 B 重新播固定点:`ShowSignals()` 先 `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃网格"
  是 RebuildLanes 的既有设计,网格级状态随之清空(不是缺陷),因此不能跨场景依赖。

验证
- make release:零 warning,785,408 B 不变(本轮只动工具/检查与文档)
- make -C Tools clean && make -C Tools:零 warning
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 127 断言全 PASS(原 6 / 101)
- rowgeom -mode compare:默认数据 983+7 键、MF4 1304+7 键,0 差异
- panels -mode compare:7 个面板像素哈希 + 几何逐项不变

文档:Code_Review_Plan §九、Progress 第 37 轮、Report §14、agent_mem 第 37 轮、Tools/README §4.1/4.3/4.4
2026-11-04 15:49:25 +08:00

1567 lines
60 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;
}
}
/** @brief 把矩形格式化成 `l,t,w,h`(2 位小数;坐标是设备像素,2 位足够)。 */
std::string FormatRect(const gd::RectF& rect)
{
char buffer[128] = {};
std::snprintf(buffer, sizeof(buffer), "%.2f,%.2f,%.2f,%.2f", rect.X, rect.Y, rect.Width,
rect.Height);
return std::string(buffer);
}
/** @brief 把一段文本混进 FNV-1a 哈希(用于"多行规模"场景只存一个摘要)。 */
void MixText(std::uint64_t& hash, const std::string& text)
{
for (const char character : text)
{
hash ^= static_cast<std::uint64_t>(static_cast<unsigned char>(character));
hash *= 1099511628211ull;
}
}
/** @brief 追加 `key=rect` 一行。 */
void AppendRectLine(std::string& text, const std::string& key, const gd::RectF& rect)
{
text += key;
text += '=';
text += FormatRect(rect);
text += '\n';
}
/**
* @brief 把一个面板布局的**全部几何**追加成文本(逐字段、逐行、逐列)。
*
* 这是「逐行几何基线」的核心:面板改布局时,像素哈希只能告诉你"有变化",
* 而这份文本能告诉你**哪一个字段、哪一行、哪一列**变了。
* 有了它,`EdgePanelLayout` 的完整结构合并(平行数组 → `PanelRow` 单数组)
* 与布局算式重写才具备"逐项证明等价"的能力。
*
* ⚠️ 键名必须稳定:一旦改名,历史基线全部失效(`-mode record` 重新记录即可)。
*
* @param [IN,OUT] text 目标文本(追加)。
* @param [IN] prefix 该面板的键前缀(= 面板种类名)。
* @param [IN] layout 待导出的布局。
*/
void AppendLayoutDump(std::string& text, const std::string& prefix,
const PlotView::EdgePanelLayout& layout)
{
const auto key = [&prefix](const char* name) { return prefix + "." + name; };
// ---- 面板骨架 ----
AppendRectLine(text, key("panel"), layout.panel);
AppendRectLine(text, key("title"), layout.title);
AppendRectLine(text, key("close"), layout.close);
AppendRectLine(text, key("disclosure"), layout.disclosure);
AppendRectLine(text, key("badge"), layout.badge);
AppendRectLine(text, key("edit"), layout.edit);
AppendRectLine(text, key("button"), layout.button);
AppendRectLine(text, key("content"), layout.content);
AppendRectLine(text, key("tableHeader"), layout.tableHeader);
AppendRectLine(text, key("scrollTrack"), layout.scrollTrack);
AppendRectLine(text, key("scrollThumb"), layout.scrollThumb);
AppendRectLine(text, key("opacityTrack"), layout.opacityTrack);
AppendRectLine(text, key("opacityValue"), layout.opacityValue);
AppendRectLine(text, key("showDelta"), layout.showDelta);
AppendRectLine(text, key("showEnum"), layout.showEnum);
AppendRectLine(text, key("clipBuildButton"), layout.clipBuildButton);
AppendRectLine(text, key("clipDestructiveButton"), layout.clipDestructiveButton);
text += key("collapsed") + "=" + (layout.collapsed ? "1" : "0") + "\n";
text += key("rows") + "=" + std::to_string(layout.rows) + "\n";
text += key("markerContentHeight") + "=" + std::to_string(layout.markerContentHeight) + "\n";
text += key("scrollOffset") + "=" + std::to_string(layout.scrollOffset) + "\n";
// ---- 表格三列 ----
for (int column = 0; column < 3; ++column)
{
const PlotView::EdgePanelLayout::TableColumn& cell = layout.tableColumns[column];
char name[32] = {};
std::snprintf(name, sizeof(name), "tableColumn[%d]", column);
text += key(name) + "=" + std::to_string(cell.left) + "/" +
std::to_string(cell.width) + "\n";
}
// ---- 每一行 / 每一列 ----
// ⚠️ **每一列只按自己的长度导出**,不要假设所有列等长:
// 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填
// `rowNameRects` / `rowNameCloses`,其余 14 列**全为空**
// (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。
// 按 `rowRefs.size()` 统一索引会越界崩溃。
const auto dumpRects = [&text, &key](const char* name, const std::vector<gd::RectF>& rects) {
text += key(name) + ".count=" + std::to_string(rects.size()) + "\n";
for (std::size_t index = 0; index < rects.size(); ++index)
{
char cell[64] = {};
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
AppendRectLine(text, key(name) + cell, rects[index]);
}
};
text += key("rowRefs") + ".count=" + std::to_string(layout.rowRefs.size()) + "\n";
for (std::size_t index = 0; index < layout.rowRefs.size(); ++index)
{
char cell[64] = {};
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
text += key("rowRefs") + cell + "=" +
std::to_string(layout.rowRefs[index].groupIndex) + "/" +
std::to_string(layout.rowRefs[index].signalIndex) + "\n";
}
dumpRects("rowHeaders", layout.rowHeaders);
dumpRects("rowNameRects", layout.rowNameRects);
dumpRects("rowNameCloses", layout.rowNameCloses);
dumpRects("rowTriangles", layout.rowTriangles);
dumpRects("rowChecks", layout.rowChecks);
dumpRects("rowPoints", layout.rowPoints);
dumpRects("rowIterations", layout.rowIterations);
dumpRects("rowStats", layout.rowStats);
dumpRects("rowLevelTracks", layout.rowLevelTracks);
dumpRects("rowLevelValues", layout.rowLevelValues);
dumpRects("rowLevelResets", layout.rowLevelResets);
dumpRects("rowIterTracks", layout.rowIterTracks);
dumpRects("rowIterValues", layout.rowIterValues);
dumpRects("rowIterResets", layout.rowIterResets);
dumpRects("rowInfoMaxInterval", layout.rowInfoMaxInterval);
dumpRects("rowInfoMinInterval", layout.rowInfoMinInterval);
dumpRects("rowIntervalField", layout.rowIntervalField);
dumpRects("rowFrequencyField", layout.rowFrequencyField);
// ---- 自由标记:分组栏 / 表头 / 条目 ----
text += key("markerGroupCount") + "=" + std::to_string(layout.markerGroups.size()) + "\n";
for (std::size_t group = 0; group < layout.markerGroups.size(); ++group)
{
const PlotView::EdgePanelLayout::MarkerGroup& entry = layout.markerGroups[group];
char name[48] = {};
std::snprintf(name, sizeof(name), "markerGroup[%03d]", static_cast<int>(group));
const std::string groupKey = key(name);
AppendRectLine(text, groupKey + ".header", entry.header);
AppendRectLine(text, groupKey + ".tableHeader", entry.tableHeader);
AppendRectLine(text, groupKey + ".triangle", entry.triangle);
AppendRectLine(text, groupKey + ".check", entry.check);
AppendRectLine(text, groupKey + ".close", entry.close);
text += groupKey + ".collapsed=" + (entry.collapsed ? "1" : "0") + "\n";
text += groupKey + ".pinned=" + (entry.pinned ? "1" : "0") + "\n";
}
text += key("markerRowCount") + "=" + std::to_string(layout.markerRows.size()) + "\n";
for (std::size_t row = 0; row < layout.markerRows.size(); ++row)
{
const PlotView::EdgePanelLayout::MarkerRow& entry = layout.markerRows[row];
char name[48] = {};
std::snprintf(name, sizeof(name), "markerRow[%03d]", static_cast<int>(row));
const std::string rowKey = key(name);
text += rowKey + ".item=" + std::to_string(entry.itemIndex) + "/" +
std::to_string(entry.serial) + "\n";
AppendRectLine(text, rowKey + ".rect", entry.rect);
AppendRectLine(text, rowKey + ".close", entry.close);
}
}
/** @brief 统计文本的行数(用于打印导出规模)。 */
std::size_t CountLines(const std::string& text)
{
std::size_t lines = 0;
for (const char character : text)
{
if (character == '\n')
{
++lines;
}
}
return lines;
}
/**
* @brief 逐行比对一份"键值基线"(`key=value`),报告缺失 / 多出 / 值变化。
*
* 与 `panels` 里的比对相比,这里刻意把**每一处差异都打印出来**(上限 20 条):
* 几何基线的价值就在于"哪一行的哪一列变了",只报一个总数等于没用。
*
* @param [IN] path 基线文件路径。
* @param [IN] current 当前导出文本。
* @param [IN] label 报告用的名字。
*/
void CompareBaselineText(const std::string& path, const std::string& current, const char* label)
{
const std::string baseline = ReadTextFile(path);
Expect(!baseline.empty(), std::string(label) + " baseline exists (run -mode record first)");
if (baseline.empty())
{
return;
}
const std::vector<std::pair<std::string, std::string>> expectedEntries = ParseBaseline(baseline);
const std::vector<std::pair<std::string, std::string>> currentEntries = ParseBaseline(current);
ExpectEqual(static_cast<long long>(currentEntries.size()),
static_cast<long long>(expectedEntries.size()),
std::string(label) + " entry count");
std::map<std::string, std::string> currentMap;
for (const auto& entry : currentEntries)
{
currentMap[entry.first] = entry.second;
}
int reported = 0;
int mismatches = 0;
for (const auto& expected : expectedEntries)
{
const auto found = currentMap.find(expected.first);
if (found == currentMap.end())
{
++mismatches;
if (reported < 20)
{
EmitLine("[%s] MISSING %s (baseline=%s)\n", label, expected.first.c_str(),
expected.second.c_str());
++reported;
}
continue;
}
if (found->second != expected.second)
{
++mismatches;
if (reported < 20)
{
EmitLine("[%s] CHANGED %s: baseline=%s current=%s\n", label, expected.first.c_str(),
expected.second.c_str(), found->second.c_str());
++reported;
}
}
}
// 反过来查"多出来的键"(新增几何字段时会出现,属于预期变化)。
std::map<std::string, std::string> expectedMap;
for (const auto& entry : expectedEntries)
{
expectedMap[entry.first] = entry.second;
}
for (const auto& entry : currentEntries)
{
if (expectedMap.find(entry.first) == expectedMap.end())
{
++mismatches;
if (reported < 20)
{
EmitLine("[%s] ADDED %s=%s\n", label, entry.first.c_str(), entry.second.c_str());
++reported;
}
}
}
EmitLine("[%s] compared %d entries, %d mismatch(es)\n", label,
static_cast<int>(expectedEntries.size()), mismatches);
Expect(mismatches == 0, std::string(label) + " geometry is unchanged");
}
// ---- 检查实现 -------------------------------------------------------
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));
// 不变式:行的几何数组必须与行数**形状一致**。
//
// ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"18 个数组全部全等"是**错的**:
// 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填
// `rowNameRects` / `rowNameCloses`,其余 14 列**必须为空**
// (它真正的行在 `markerRows` / `markerGroups` 里)。
// 这是 `rowgeom` 第一次带真实固定点运行时越界崩溃所暴露的结构事实。
const PanelStyle rowStyle = PanelStyleOf(kind);
const std::size_t rowCount = layout.rowRefs.size();
bool parallel = true;
if (rowStyle.markerGroups)
{
parallel = layout.rowNameRects.size() == rowCount &&
layout.rowNameCloses.size() == rowCount && layout.rowHeaders.empty() &&
layout.rowChecks.empty() && layout.rowLevelTracks.empty() &&
layout.rowIterTracks.empty();
}
else
{
const std::size_t parallelSizes[] = {
layout.rowHeaders.size(), layout.rowNameRects.size(),
layout.rowTriangles.size(), layout.rowChecks.size(),
layout.rowPoints.size(), layout.rowIterations.size(),
layout.rowStats.size(), layout.rowLevelTracks.size(),
layout.rowLevelValues.size(), layout.rowLevelResets.size(),
layout.rowIterTracks.size(), layout.rowIterValues.size(),
layout.rowIterResets.size(), layout.rowInfoMaxInterval.size(),
layout.rowInfoMinInterval.size(), layout.rowIntervalField.size(),
layout.rowFrequencyField.size()};
for (const std::size_t size : parallelSizes)
{
if (size != rowCount)
{
parallel = false;
break;
}
}
}
if (!parallel)
{
EmitLine("[panels] %s row arrays: refs=%d name=%d nameClose=%d hdr=%d chk=%d lvl=%d/%d/%d "
"iter=%d/%d/%d info=%d/%d fld=%d/%d\n",
PanelKindName(kind), static_cast<int>(layout.rowRefs.size()),
static_cast<int>(layout.rowNameRects.size()),
static_cast<int>(layout.rowNameCloses.size()),
static_cast<int>(layout.rowHeaders.size()),
static_cast<int>(layout.rowChecks.size()),
static_cast<int>(layout.rowLevelTracks.size()),
static_cast<int>(layout.rowLevelValues.size()),
static_cast<int>(layout.rowLevelResets.size()),
static_cast<int>(layout.rowIterTracks.size()),
static_cast<int>(layout.rowIterValues.size()),
static_cast<int>(layout.rowIterResets.size()),
static_cast<int>(layout.rowInfoMaxInterval.size()),
static_cast<int>(layout.rowInfoMinInterval.size()),
static_cast<int>(layout.rowIntervalField.size()),
static_cast<int>(layout.rowFrequencyField.size()));
}
Expect(parallel, std::string("row arrays match row count: ") + 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();
}
/**
* @brief 面板**内容滚动**的行为检查(每一类面板都要能被滚动,且几何随之一致移动)。
*
* 为什么单独一项:面板滚动量过去由 7 个近乎重复的设置函数 + 2 条按种类分派的
* `switch`/`if` 链共同维护,历史上出现过"滚轮能滚、拖滚动条滚不动"与
* "读的是 A 面板的偏移、写进了 B 面板"。本检查对 7 种面板逐一:
* 1. 取布局 → 若可滚动,记录初始 `scrollOffset`;
* 2. 通过**统一入口** `SetLanePanelScroll` 滚到底;
* 3. 断言 `scrollOffset` 等于 `maxScroll`、滑块位置真的移到了轨道底部;
* 4. 再滚回顶部,断言滑块回到轨道顶部。
*/
int CheckPanelScroll(Session& session, const CheckOptions& options)
{
Application& app = session.App();
PlotView& plot = app.m_plot;
// 让面板内容**必定超高**:显示全部信号并合并成一个网格
//(真实场景见历史缺陷:283 条信号合并到同一网格、开实验性功能)。
int totalSignals = 0;
for (int groupIndex = 0; groupIndex < static_cast<int>(app.m_dataSet.GroupCount()); ++groupIndex)
{
const SignalGroup* group = app.m_dataSet.GroupAt(groupIndex);
if (group != nullptr)
{
totalSignals += static_cast<int>(group->signals.size());
}
}
session.ShowFirstSignals(0);
if (session.LaneCount() > 1)
{
app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES);
}
EmitLine("[panelscroll] signals=%d lanes=%d\n", totalSignals, session.LaneCount());
Expect(totalSignals > 0, "loaded at least one signal");
if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE)
{
for (int step = 0; step < 4 && 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);
}
session.RenderFrame();
int scrollableKinds = 0;
for (const EdgePanelKind kind : kinds)
{
const char* name = PanelKindName(kind);
const PlotView::EdgePanelLayout initial = plot.EdgePanelLayoutFor(0, kind);
const float maxScroll =
std::max(0.0f, initial.markerContentHeight - initial.content.Height);
EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d rows=%d\n",
name, initial.markerContentHeight, initial.content.Height, maxScroll,
initial.scrollTrack.Width > 0.0f ? 1 : 0,
static_cast<int>(initial.rowRefs.size()));
// 多信号场景下同样守住"行数组形状一致"(判据按排版类别区分:
// MARK 只用 rowRefs / rowNameRects / rowNameCloses,详见 `panels` 检查里的说明)。
{
const PanelStyle rowStyle = PanelStyleOf(kind);
const std::size_t rows = initial.rowRefs.size();
bool aligned = true;
if (rowStyle.markerGroups)
{
aligned = initial.rowNameRects.size() == rows &&
initial.rowNameCloses.size() == rows && initial.rowHeaders.empty() &&
initial.rowChecks.empty();
}
else
{
const std::size_t sizes[] = {initial.rowHeaders.size(),
initial.rowNameRects.size(),
initial.rowTriangles.size(),
initial.rowChecks.size(),
initial.rowPoints.size(),
initial.rowIterations.size(),
initial.rowStats.size(),
initial.rowLevelTracks.size(),
initial.rowLevelValues.size(),
initial.rowLevelResets.size(),
initial.rowIterTracks.size(),
initial.rowIterValues.size(),
initial.rowIterResets.size(),
initial.rowInfoMaxInterval.size(),
initial.rowInfoMinInterval.size(),
initial.rowIntervalField.size(),
initial.rowFrequencyField.size()};
for (const std::size_t size : sizes)
{
if (size != rows)
{
aligned = false;
break;
}
}
}
Expect(aligned, std::string("row arrays match row count: ") + name);
}
if (maxScroll <= 1.0f)
{
// 内容没超出可视高度(例如单信号文件):不可滚动是正确行为。
Expect(initial.scrollTrack.Width <= 0.0f,
std::string("no scrollbar when content fits: ") + name);
ExpectNear(initial.scrollOffset, 0.0, 0.5,
std::string("scroll offset stays 0: ") + name);
continue;
}
++scrollableKinds;
Expect(initial.scrollTrack.Width > 0.0f,
std::string("scrollbar appears when content overflows: ") + name);
// 滚到底
Expect(plot.SetLanePanelScroll(0, kind, maxScroll + 500.0f),
std::string("set scroll to bottom: ") + name);
const PlotView::EdgePanelLayout bottom = plot.EdgePanelLayoutFor(0, kind);
ExpectNear(bottom.scrollOffset, maxScroll, 0.5,
std::string("scroll offset clamps to maxScroll: ") + name);
const float travel = initial.scrollTrack.Height - initial.scrollThumb.Height;
ExpectNear(bottom.scrollThumb.Y, initial.scrollTrack.Y + travel, 1.0,
std::string("thumb reaches track bottom: ") + name);
// 内容区几何不能随滚动改变
ExpectNear(bottom.content.Y, initial.content.Y, 0.01,
std::string("content area stays put when scrolling: ") + name);
// 滚回顶部
Expect(plot.SetLanePanelScroll(0, kind, -100.0f),
std::string("set scroll back to top: ") + name);
const PlotView::EdgePanelLayout top = plot.EdgePanelLayoutFor(0, kind);
ExpectNear(top.scrollOffset, 0.0, 0.5, std::string("scroll offset back to 0: ") + name);
ExpectNear(top.scrollThumb.Y, initial.scrollTrack.Y, 1.0,
std::string("thumb back to track top: ") + name);
}
EmitLine("[panelscroll] scrollable kinds: %d / %d\n", scrollableKinds,
static_cast<int>(sizeof(kinds) / sizeof(kinds[0])));
// 用大文件(如 MF4-Record.MF4,283 信号)时必须至少有一类面板真的能滚:
// 否则这条检查会退化成"全是 0 == 0"的空转。
if (totalSignals >= 50)
{
Expect(scrollableKinds > 0, "with many signals at least one panel is scrollable");
}
session.RenderFrame();
if (options.savePng)
{
Expect(SavePng(session.LastFrame(), Join(options.outDir, "panelscroll.png")),
"save panelscroll.png");
}
return CheckFailed();
}
/**
* @brief 逐行几何基线:把每个面板的**每一个矩形**导出成文本并逐项比对。
*
* 为什么需要它(Code Review 报告 §13.6 建议 1):
* * 像素哈希只能回答"有没有变化",回答不了"**哪一行哪一列**变了";
* * `EdgePanelLayout` 仍是 18 个平行数组,任何"合并成单一 `PanelRow`"或
* "重写布局算式"的重构都必须先能**逐项证明等价**;
* * 面板改布局时的真实缺陷形态(某列读到别人的矩形)会让**成片的**行错位,
* 而像素基线在面板互相重叠时还会被"下层露出"掩盖。
*
* 两个场景:
* A. **明细**:6 条信号合并到 1 个网格,7 种面板全部展开,
* 并把第 1/2 条信号勾选上(让参数行同时覆盖"展开"与"未勾选"两种形态);
* 另有一条 `*.collapsed` 用例覆盖"折起只剩标题栏"。
* B. **规模**:全部信号合并到 1 个网格,只记录每个面板的几何摘要哈希 ——
* 566 行的场景下逐行文本会有上万行(不适合入库当基线),
* 但"行数 + 全部矩形的滚动哈希"足以发现任何错位。
*/
int CheckRowGeometry(Session& session, const CheckOptions& options)
{
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};
// 双光标(「选区截取」需要它才有内容)。
if (app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE)
{
for (int step = 0; step < 4 && app.ActiveTab().CursorMode() != PlotCursorMode::DOUBLE; ++step)
{
plot.CycleCursorMode();
}
}
// ---- 场景 A:6 条信号合并成 1 个网格 ----
session.ShowFirstSignals(6);
if (session.LaneCount() > 1)
{
app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES);
// `SetMode` 只重建 TabConfig 的网格配置;`PlotView::m_lanes` 要等下一次
// 布局/绘制才刷新 ⇒ 必须**先同步一次**再断言,否则读到的是合并前的数量。
app.SynchronizeView(true);
session.RenderFrame();
}
Expect(session.LaneCount() == 1, "signals merged into one lane");
// 收集可见信号(在开启功能**之前**:功能开关不改变可见性,
// 但 `Toggle*` 内部可能重建网格,持有 `Lane&` 跨调用是危险的)。
std::vector<SignalRef> visibleRefs;
{
const Lane& lane = app.ActiveTab().Lanes()[0];
for (const SignalRef& ref : lane.signals)
{
if (app.ActiveTab().IsVisible(ref))
{
visibleRefs.push_back(ref);
}
}
}
Expect(!visibleRefs.empty(), "merged lane has visible signals");
if (visibleRefs.size() < 3)
{
// 小样本文件(如 SCATTER_TEST.csv 只有 1 条信号)覆盖度较低,但检查仍然有效;
// 想要完整覆盖请用 `-data ..\TestData\MF4-Record.MF4`(566 条信号)。
EmitLine("[rowgeom] note: only %d visible signal(s); use a bigger -data for full coverage\n",
static_cast<int>(visibleRefs.size()));
}
for (const EdgePanelKind kind : kinds)
{
EnsureLaneFeature(plot, 0, kind);
}
// 把第 2 条信号也勾选上:同一面板里同时存在"展开的参数行"与"未勾选的行"。
if (visibleRefs.size() >= 2)
{
plot.ToggleLaneMeanFilterSignal(0, visibleRefs[1]);
plot.ToggleLaneEdgeCenterSignal(0, visibleRefs[0]);
plot.ToggleLaneWeakPeakSignal(0, visibleRefs[1]);
plot.ToggleLaneResampleSignal(0, visibleRefs[1]);
plot.ToggleLaneClipSignal(0, visibleRefs[1]);
}
// 「自由标记」需要真实固定点才会出现分组栏与条目行。
//
// ⚠️ 每次写入后都要**重新取** `Lanes()[0]`:`AddFreeMarker` 内部会整理/插入条目,
// 跨调用持有 `Lane&` 会在条目扩容时失效。
// ⚠️ 不能依赖"上一个场景加过的固定点还在":`ShowSignals()` 会先
// `SetAllVisible(false)`,而"全部不可见 ⇒ 丢弃空网格"是既有设计
// (`RebuildLanes` 的 MERGED 分支),网格级状态(含固定点)随之清空。
const auto addFreeMarkers = [&app](std::size_t wanted)
{
std::vector<SignalRef> refs;
{
const Lane& lane = app.ActiveTab().Lanes()[0];
for (const SignalRef& ref : lane.signals)
{
if (refs.size() >= wanted)
{
break;
}
if (app.ActiveTab().IsVisible(ref))
{
refs.push_back(ref);
}
}
}
for (std::size_t index = 0; index < refs.size(); ++index)
{
const SignalGroup* group = app.m_dataSet.GroupAt(refs[index].groupIndex);
if (group == nullptr ||
refs[index].signalIndex >= static_cast<int>(group->signals.size()))
{
continue;
}
const SignalData& signal =
group->signals[static_cast<std::size_t>(refs[index].signalIndex)];
const TimeAxis& axis = group->AxisOf(signal);
if (axis.time.empty())
{
continue;
}
app.ActiveTab().Lanes()[0].AddFreeMarker(refs[index], axis.minTime);
if (index == 0)
{
const double t1 = axis.minTime + (axis.maxTime - axis.minTime) * 0.35;
app.ActiveTab().Lanes()[0].AddFreeMarker(refs[index], t1);
}
}
};
addFreeMarkers(2);
Expect(session.RenderFrame(), "render with dense row geometry");
std::string detail;
for (const EdgePanelKind kind : kinds)
{
const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind);
AppendLayoutDump(detail, PanelKindName(kind), layout);
}
// 折起态也纳入基线(折起时内容区/按钮/滚动条必须全部为空)。
for (const EdgePanelKind kind : kinds)
{
plot.ToggleLanePanelCollapsed(0, kind);
}
Expect(session.RenderFrame(), "render with all panels collapsed");
for (const EdgePanelKind kind : kinds)
{
const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind);
Expect(layout.collapsed, std::string("panel reports collapsed: ") + PanelKindName(kind));
ExpectNear(layout.panel.Height, layout.title.Height, 0.01,
std::string("collapsed panel height equals title height: ") + PanelKindName(kind));
ExpectNear(layout.content.Width, 0.0, 0.01,
std::string("collapsed panel has no content area: ") + PanelKindName(kind));
AppendLayoutDump(detail, std::string(PanelKindName(kind)) + ".collapsed", layout);
}
// 复原(后续检查依赖面板展开)。
for (const EdgePanelKind kind : kinds)
{
plot.ToggleLanePanelCollapsed(0, kind);
}
// ---- 场景 B:全部信号合并成 1 个网格(只存摘要哈希)----
session.ShowFirstSignals(0);
if (session.LaneCount() > 1)
{
app.ExecuteToolbarAction(ToolbarAction::MERGE_LANES);
app.SynchronizeView(true);
}
for (const EdgePanelKind kind : kinds)
{
EnsureLaneFeature(plot, 0, kind);
}
// 固定点必须在本场景**重新加**(见 `addFreeMarkers` 上方的说明)。
addFreeMarkers(2);
Expect(session.RenderFrame(), "render with every signal in one lane");
std::string scale;
for (const EdgePanelKind kind : kinds)
{
const PlotView::EdgePanelLayout layout = plot.EdgePanelLayoutFor(0, kind);
std::string dump;
AppendLayoutDump(dump, PanelKindName(kind), layout);
std::uint64_t hash = 1469598103934665603ull;
MixText(hash, dump);
char line[160] = {};
std::snprintf(line, sizeof(line), "%s.hash=%016llX rows=%d markerRows=%d\n",
PanelKindName(kind), static_cast<unsigned long long>(hash),
static_cast<int>(layout.rowRefs.size()),
static_cast<int>(layout.markerRows.size()));
scale += line;
}
EmitLine("[rowgeom] detail lines=%d scale=%s", static_cast<int>(CountLines(detail)),
scale.c_str());
// ---- 记录 / 比对 ----
const std::string detailPath = Join(options.outDir, "rowgeom-baseline.txt");
const std::string scalePath = Join(options.outDir, "rowgeom-scale-baseline.txt");
if (options.mode == "record")
{
Expect(WriteTextFile(detailPath, detail), "write rowgeom detail baseline");
Expect(WriteTextFile(scalePath, scale), "write rowgeom scale baseline");
EmitLine("[rowgeom] baselines recorded: %s / %s\n", detailPath.c_str(),
scalePath.c_str());
}
else if (options.mode == "compare")
{
CompareBaselineText(detailPath, detail, "rowgeom detail");
CompareBaselineText(scalePath, scale, "rowgeom scale");
}
else
{
EmitLine("[rowgeom] (hint) use -mode record / -mode compare for geometry A/B\n");
}
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();
}
// 挑一条"最有起伏"的信号:常量信号(只有 1 个不同取值)上任何阈值都得不到边沿,
// 断言会退化成"0 == 0"的空转(MF4 的第一条信号就是常量)。
const SignalData* chosen = nullptr;
double chosenSpan = -1.0;
for (const SignalData& candidate : group->signals)
{
double low = 0.0;
double high = 1.0;
FiniteRange(candidate.values, low, high);
const double span = high - low;
if (candidate.values.size() >= 4 && span > chosenSpan)
{
chosen = &candidate;
chosenSpan = span;
}
}
Expect(chosen != nullptr && chosenSpan > 0.0, "found a non-constant signal");
if (chosen == nullptr || !(chosenSpan > 0.0))
{
return CheckFailed();
}
const SignalData& signal = *chosen;
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},
{"panelscroll", "panel content scrolling: every kind scrolls, clamps, and keeps content fixed",
CheckPanelScroll},
{"rowgeom", "per-field/per-row geometry baseline for all 7 panels (record/compare)",
CheckRowGeometry},
{"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