/** * @file Checks.cpp * @brief 离屏检查的具体实现(隐藏窗口 + 合成消息,绝不占用真实鼠标/键盘/屏幕)。 * * 检查清单: * * `smoke` —— 能建隐藏窗口、能加载数据、能画出波形(像素级)。 * * `frames` —— **连续帧抓取**:沿绘图区移动鼠标,逐帧抓 PNG + 哈希 + 耗时; * 并验证"同一状态连续两帧逐像素相同"(渲染确定性)。 * * `panels` —— 7 种实验性功能面板全部展开后的**几何 + 像素基线**, * 支持 `-mode record` / `-mode compare` 做重构前后 A/B。 * * `edgestats` —— 边沿统计与**独立参考实现**逐位比对(覆盖阈值变化、缓存稳定性)。 * * `perf` —— 关键路径耗时:整帧、面板几何、边沿统计查询。 * * ⚠️ 所有 `Expect()` 都是"记录并继续",单个检查跑完仍然给出完整报告。 */ #include #include #include #include #include #include #include #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(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& values, double& low, double& high) { low = std::numeric_limits::infinity(); high = -std::numeric_limits::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& values, const SignalStyle& style, const double threshold, std::size_t& rising, std::size_t& falling) { rising = 0; falling = 0; double previous = std::numeric_limits::quiet_NaN(); for (std::size_t index = 0; index < values.size(); ++index) { const double raw = values[index]; double current = std::numeric_limits::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> ParseBaseline(const std::string& text) { std::vector> 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(static_cast(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`,其余 12 列**全为空** // (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。 // 按 `rowRefs.size()` 统一索引会越界崩溃。 const auto dumpRects = [&text, &key](const char* name, const std::vector& 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(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(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("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(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(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> expectedEntries = ParseBaseline(baseline); const std::vector> currentEntries = ParseBaseline(current); ExpectEqual(static_cast(currentEntries.size()), static_cast(expectedEntries.size()), std::string(label) + " entry count"); std::map 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 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(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(panelArea.X + panelArea.Width * 0.5f); const int panelTop = static_cast(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(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(frames), worstMs, identicalPairs); Expect(totalMs / static_cast(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(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)); // 不变式:行的几何数组必须与行数**形状一致**。 // // ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"所有数组全部全等"是**错的**: // 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 // `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空** // (它真正的行在 `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.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(layout.rowRefs.size()), static_cast(layout.rowNameRects.size()), static_cast(layout.rowNameCloses.size()), static_cast(layout.rowHeaders.size()), static_cast(layout.rowChecks.size()), static_cast(layout.rowLevelTracks.size()), static_cast(layout.rowLevelValues.size()), static_cast(layout.rowLevelResets.size()), static_cast(layout.rowIterTracks.size()), static_cast(layout.rowIterValues.size()), static_cast(layout.rowIterResets.size()), static_cast(layout.rowInfoMaxInterval.size()), static_cast(layout.rowInfoMinInterval.size()), static_cast(layout.rowIntervalField.size()), static_cast(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(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> entries = ParseBaseline(baseline); const std::vector> current = ParseBaseline(content); ExpectEqual(static_cast(current.size()), static_cast(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(app.m_dataSet.GroupCount()); ++groupIndex) { const SignalGroup* group = app.m_dataSet.GroupAt(groupIndex); if (group != nullptr) { totalSignals += static_cast(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(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.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(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` 仍是 16 个平行数组,任何"合并成单一 `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 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(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 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(group->signals.size())) { continue; } const SignalData& signal = group->signals[static_cast(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(hash), static_cast(layout.rowRefs.size()), static_cast(layout.markerRows.size())); scale += line; } EmitLine("[rowgeom] detail lines=%d scale=%s", static_cast(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(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(rising), static_cast(falling), static_cast(expectedRising), static_cast(expectedFalling)); ExpectEqual(static_cast(rising), static_cast(expectedRising), "rising edge count matches reference"); ExpectEqual(static_cast(falling), static_cast(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(againRising), static_cast(rising), "repeat query rising stable"); ExpectEqual(static_cast(againFalling), static_cast(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(rising), static_cast(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 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(stop.QuadPart - start.QuadPart) * 1000000.0 / static_cast(frequency.QuadPart); const double perCall = totalMicroseconds / static_cast(iterations * static_cast(sizeof(kinds) / sizeof(kinds[0]))); EmitLine("[perf] EdgePanelLayoutFor: %.3f us/call (x%d calls) sink=%.1f\n", perCall, iterations * static_cast(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 targets; for (int groupIndex = 0; groupIndex < static_cast(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(plot.m_edgeScans.size()), static_cast(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(r + f); } } QueryPerformanceCounter(&stop); const double total = static_cast(stop.QuadPart - start.QuadPart) * 1000000.0 / static_cast(frequency.QuadPart); const double perQuery = total / (static_cast(rounds) * static_cast(targets.size())); EmitLine("[perf] EdgeCountsFor sweep: %.3f us/query (%d targets x %d rounds) sink=%.1f\n", perQuery, static_cast(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& AvailableChecks() { static const std::vector 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(size.QuadPart), '\0'); DWORD read = 0; const BOOL ok = ReadFile(handle, &content[0], static_cast(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(content.size()), &written, nullptr); CloseHandle(handle); return ok != FALSE && written == content.size(); } } // namespace tools } // namespace jdi