test(tools): rowgeom 逐行几何基线 + 长度不变式按类别判定

新增 `OffscreenChecks -run rowgeom`(§13.6 第 1 项),把 7 个面板的每个矩形的
每个字段导出为文本基线并逐项比对,作为"完整结构合并 / 重写布局算式"的前置护栏。

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

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

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

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

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

文档:Code_Review_Plan §九、Progress 第 37 轮、Report §14、agent_mem 第 37 轮、Tools/README §4.1/4.3/4.4
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 15:49:25 +08:00
1 parent 221f9d2410
commit 353c553721
6 files changed
+821 -48

No files matched your search

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