对应 Code_Review_Report §15.6 第 1 项(方案 B:有结构,无列视图)。
结构
- EdgePanelLayout 的 16 个平行 std::vector 合并为 std::vector<PanelRow> rows,
PanelRow 带 17 个具名成员:ref / header / nameRect / nameClose / triangle / check /
stats / levelTrack / levelValue / levelReset / iterTrack / iterValue / iterReset /
infoMaxInterval / infoMinInterval / intervalField / frequencyField。
- 唯一新增入口:AppendRow(ref, header, nameRect, triangle, check)(返回新行引用)与
AppendMarkerGroupRow(ref, nameRect, nameClose)(「自由标记」分组行)。
参数行/重采样行一律 layout.rows.back().xxx = … 赋值。
- 原 int rows(可见信号数)与新容器同名 ⇒ 改名 signalCount,并注明它与 rows.size()
在 MARK 面板里不相等(只有有固定点的信号才有行)。
消掉的两类问题
1. 某分支漏 push 一列 ⇒ 该列之后所有行读到别人的矩形(静默错位)。现在一行一次成型。
2. rowRefs 字段重载(列表式 = 行信号 / MARK = 分组信号,其余 12 列必须为空)。
现在每行自带 ref,语义由“填哪些字段”表达;不变式简化为语义断言
(每行必须有名称区;MARK 行不得带 header/check/stats/滑块/重采样字段)。
顺带清理
- 合并后 rowIndex < layout.rowXxx.size() 全部变成恒真,批量删除 50+ 处
(含 15 元组长度数组、? x : gd::RectF() 三元判空、if(...){extendRow(...)} 包裹)。
- 调试框与缓存键改为逐行遍历(新增字段只需在 PanelRow 与这两处循环各加一行)。
等价性证明(rowgeom 键名刻意保持不变,故可逐键比对)
old keys = 919, new keys = 965
仅存在于旧 = 0;已有键值变化 = 20(全部是 ".count":0 -> N)
仅存在于新 = 46(全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00)
⇒ 逐行逐字段的真实几何零位移;7 个面板像素哈希(含 resample=2BFC74B3D9B3239C)逐项不变。
验证
- make release:零 warning;784,896 → 782,336 B(−2,560);make debug / make -C Tools 同样零 warning
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言(上轮 127)全 PASS
- panels -mode compare:7 面板像素哈希逐项不变
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异
- 读点:旧 243 处 layout.rowXxx[…] → 现 95 处 layout.rows[…](84 命中判定 / 11 绘制)
过程记录:清理恒真判空时曾用整段替换把「均值滤波」双滑块段误换成「弱峰」双滑块,
编译器报 brace 不平衡,用备份回滚后重做 —— 整段替换必须先精确读出上下文。
文档:Plan §十一、Progress 第 39 轮、Report §16、agent_mem 第 39 轮、Tools/README §4.1/§4.4
1526 lines
58 KiB
C++
1526 lines
58 KiB
C++
/**
|
||
* @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.signalCount) + "\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";
|
||
}
|
||
|
||
// ---- 每一行 / 每一个字段 ----
|
||
// 2026-11-04(第 39 轮)`EdgePanelLayout` 的 16 个平行数组已合并为
|
||
// `std::vector<PanelRow> rows`,所以这里**逐行**导出全部字段即可,
|
||
// 不再需要"每一列可能长度不同"的分列遍历(那种结构问题已从根上消失)。
|
||
//
|
||
// ⚠️ 键名保持与合并前**完全一致**(`<panel>.rowLevelTracks[001]=…`):
|
||
// 这样重构前后的基线可以直接逐字节比对,等价性不靠人工核对。
|
||
text += key("rowRefs") + ".count=" + std::to_string(layout.rows.size()) + "\n";
|
||
for (std::size_t index = 0; index < layout.rows.size(); ++index)
|
||
{
|
||
char cell[64] = {};
|
||
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
|
||
text += key("rowRefs") + cell + "=" +
|
||
std::to_string(layout.rows[index].ref.groupIndex) + "/" +
|
||
std::to_string(layout.rows[index].ref.signalIndex) + "\n";
|
||
}
|
||
const auto dumpField = [&text, &key](const char* name,
|
||
const std::vector<PlotView::EdgePanelLayout::PanelRow>& rows,
|
||
gd::RectF PlotView::EdgePanelLayout::PanelRow::*field) {
|
||
text += key(name) + ".count=" + std::to_string(rows.size()) + "\n";
|
||
for (std::size_t index = 0; index < rows.size(); ++index)
|
||
{
|
||
char cell[64] = {};
|
||
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
|
||
AppendRectLine(text, key(name) + cell, rows[index].*field);
|
||
}
|
||
};
|
||
dumpField("rowHeaders", layout.rows, &PlotView::EdgePanelLayout::PanelRow::header);
|
||
dumpField("rowNameRects", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameRect);
|
||
dumpField("rowNameCloses", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameClose);
|
||
dumpField("rowTriangles", layout.rows, &PlotView::EdgePanelLayout::PanelRow::triangle);
|
||
dumpField("rowChecks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::check);
|
||
dumpField("rowStats", layout.rows, &PlotView::EdgePanelLayout::PanelRow::stats);
|
||
dumpField("rowLevelTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelTrack);
|
||
dumpField("rowLevelValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelValue);
|
||
dumpField("rowLevelResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelReset);
|
||
dumpField("rowIterTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterTrack);
|
||
dumpField("rowIterValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterValue);
|
||
dumpField("rowIterResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterReset);
|
||
dumpField("rowInfoMaxInterval", layout.rows,
|
||
&PlotView::EdgePanelLayout::PanelRow::infoMaxInterval);
|
||
dumpField("rowInfoMinInterval", layout.rows,
|
||
&PlotView::EdgePanelLayout::PanelRow::infoMinInterval);
|
||
dumpField("rowIntervalField", layout.rows,
|
||
&PlotView::EdgePanelLayout::PanelRow::intervalField);
|
||
dumpField("rowFrequencyField", layout.rows,
|
||
&PlotView::EdgePanelLayout::PanelRow::frequencyField);
|
||
|
||
// ---- 自由标记:分组栏 / 表头 / 条目 ----
|
||
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));
|
||
|
||
// 不变式:**行结构自检**。
|
||
//
|
||
// 2026-11-04(第 39 轮)起 `EdgePanelLayout` 的 16 个平行数组已合并成
|
||
// `std::vector<PanelRow> rows` ⇒ "某列比别的列短"这类错位**在结构上不可能**
|
||
// (一行的字段是一个整体,由 `AppendRow` / `AppendMarkerGroupRow` 一次成型)。
|
||
// 旧的"15 列长度全部等于行数"因此退化为恒真,这里改为守住**语义**:
|
||
// * 每一行都必须有名称区(布局漏算就会变成空矩形,绘制时该行不可见/不可点);
|
||
// * 「自由标记」的行是**分组行**:参数滑块 / 重采样 / 统计字段必须保持为空
|
||
// (真正的条目行在 `markerRows`),防止有人把列表式布局误用到它上面。
|
||
const PanelStyle rowStyle = PanelStyleOf(kind);
|
||
bool rowsOk = true;
|
||
for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows)
|
||
{
|
||
if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f)
|
||
{
|
||
rowsOk = false;
|
||
break;
|
||
}
|
||
if (rowStyle.markerGroups &&
|
||
(row.header.Width > 0.0f || row.check.Width > 0.0f ||
|
||
row.stats.Width > 0.0f || row.levelTrack.Width > 0.0f ||
|
||
row.iterTrack.Width > 0.0f || row.intervalField.Width > 0.0f ||
|
||
row.frequencyField.Width > 0.0f))
|
||
{
|
||
rowsOk = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!rowsOk)
|
||
{
|
||
EmitLine("[panels] %s row structure broken (rows=%d markerGroups=%d)\n",
|
||
PanelKindName(kind), static_cast<int>(layout.rows.size()),
|
||
layout.markerGroups.size());
|
||
}
|
||
Expect(rowsOk, std::string("row structure is sound: ") + PanelKindName(kind));
|
||
// 每一行的信号引用必须有效(布局用无效引用填行 ⇒ 绘制时会跳过该行)。
|
||
for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows)
|
||
{
|
||
Expect(row.ref.IsValid() || rowStyle.markerGroups,
|
||
std::string("row has a valid signal ref: ") + 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.rows.size()));
|
||
|
||
// 行结构自检(合并成 `std::vector<PanelRow>` 后长度错位已不可能,
|
||
// 这里守住"每行都有名称区",详见 `panels` 检查里的说明)。
|
||
{
|
||
bool rowsOk = true;
|
||
for (const PlotView::EdgePanelLayout::PanelRow& row : initial.rows)
|
||
{
|
||
if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f)
|
||
{
|
||
rowsOk = false;
|
||
break;
|
||
}
|
||
}
|
||
Expect(rowsOk, std::string("every row has a name rect: ") + 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` 仍是 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<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.rows.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
|