Files
Just-DataInspector/Tools/OffscreenChecks/Src/Checks.cpp
T
DeepSeek V4.1 Flash 221f9d2410 test(tools): 行数组不变式收紧为"18 个全部等于行数"
背景:`AppendRow()`(提交 85aaed5)已经成为新增面板行的唯一入口,
18 个平行数组由它一次补齐。因此旧的宽松判据("13 个必须等长 +
4 个重采样专属列允许为空")不再需要 —— 任何一列短于行数,
就是"有人绕过 AppendRow 手写 push"的直接证据。

改动:
* `panels` 检查:改为断言 18 个数组长度**全部**等于 `rowRefs.size()`,
  失败时打印全部 18 列的长度便于定位。
* `panelscroll` 检查:同样收紧(大文件 566 行时才会暴露的问题)。
* `Tools/README.md`:同步不变式描述。

文档:
* `Documents/Code_Review_Plan.md` 新增 §八(第 36 轮节点与硬约束)。
* `Documents/Code_Review_Progress.md` 新增第 36 轮(两次重构 + 三个搬移陷阱)。
* `Documents/Code_Review_Report.md` 新增 §13(拆分职责表、为何不做完整合并、
  失败可见性对比、后续建议)。
* `Documents/agent_mem.md` 新增第 36 轮规则。

验证:
* `make release` 全量零 error / 零 warning,产物 **785,408 B**。
* `UnitTests` → checks=260 failed=0;`OffscreenChecks -all` → 6/6 PASS,checks=101 failed=0。
* `panels -mode compare` → 7 个面板像素哈希与几何逐项不变。
* `panelscroll`(MF4 566 行)→ 18 个行数组全部 = 566。
2026-11-04 15:20:07 +08:00

1071 lines
39 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));
// 不变式:`EdgePanelLayout` 的**平行数组长度必须全部等于行数**。
// 这 18 个按行平行的 vector 现在由 `AppendRow()` 一次补齐
//(新增行的唯一入口),因此**不允许**任何一列短于行数 ——
// 短了就说明有分支绕过 `AppendRow` 手写 push("某列读到别人矩形"的根因)。
// 成本极低(O(1) 比长度),但能挡住整类错位缺陷。
const std::size_t rowCount = layout.rowRefs.size();
const std::size_t parallelSizes[] = {
layout.rowRefs.size(), 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()};
bool parallel = true;
for (const std::size_t size : parallelSizes)
{
if (size != rowCount)
{
parallel = false;
break;
}
}
if (!parallel)
{
EmitLine("[panels] %s row arrays: refs=%d hdr=%d name=%d tri=%d chk=%d pts=%d it=%d "
"stat=%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.rowHeaders.size()),
static_cast<int>(layout.rowNameRects.size()),
static_cast<int>(layout.rowTriangles.size()),
static_cast<int>(layout.rowChecks.size()),
static_cast<int>(layout.rowPoints.size()),
static_cast<int>(layout.rowIterations.size()),
static_cast<int>(layout.rowStats.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("all 18 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()));
// 多信号场景下额外守住"18 个平行数组长度全部等于行数"
//(`AppendRow()` 是新增行的唯一入口 ⇒ 任何一列短了就是绕过它的证据)。
{
const std::size_t rows = initial.rowRefs.size();
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()};
bool aligned = true;
for (const std::size_t size : sizes)
{
if (size != rows)
{
aligned = false;
break;
}
}
Expect(aligned, std::string("all 18 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();
}
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},
{"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