背景:第 33 轮 Code Review 的三项后续建议(EdgeScanFor 查找哈希化、 EdgePanelLayoutFor 表驱动化、wcstod 自写替身)都卡在"没有可自证的验证手段"上。 本轮先补齐基础设施,再动被建议的代码。 一、Tools/ 从忽略改为入库(只忽略各工具的 Build/ 与 Temp/ 临时产物) * `.gitignore`:删除 `Tools/` 整目录忽略,改为 `Tools/**/Build/`; 截图 / 基线 / 报告一律写 `Temp/tools/`(`Temp/` 早已忽略)。 二、新增 Tools/Makefile(统一构建入口,不碰应用本体) * 目标:`make` 构建全部、`make UnitTests` / `make OffscreenChecks` 单个、 `make clean` 清工具产物、`make help`。 * 工具链接应用本体 `../Build/Release/*.o`(排除入口 `Main.o`), 因此 ABI 开关必须与本体一致:`-fno-exceptions` / `-fno-rtti` / 链接 `-flto -flto-partition=one`。 * 工具源文件自身**不**编译成 LTO 字节码(否则 GCC 15.2.0 在 `choose_baseaddr` 处 ICE),只在链接阶段带 LTO 参数。 * `-MMD -MP` + `-include`:改头文件 / 增删源文件自动重编重链。 * 修正一个构建陷阱:`$(foreach)` 生成的工具目标会早于 `all`, make 会把第一个显式目标当默认目标 ⇒ 显式 `.DEFAULT_GOAL := all`。 三、Tools/UnitTests:纯数据层单元测试(218 项断言,17 个用例) * 极简自研框架(`Inc/UnitTestFramework.hpp`):无异常、无 RTTI、 支持布尔 / 整数 / 近似 / **逐位相等** / 宽字符串断言。 * 覆盖:`TryParseDouble` 与 libc `strtod` 逐位一致性(6 万条随机串)、 接受性语义、`FormatDecimal*` 永不科学计数法、`NiceStep/BuildTicks`、 公式语法/语义/归一化、批量求值 `FormulaProgram` 与逐点求值逐位一致、 `ApplyDrawTransform` 顺序、`TransformValueRange`、 `ComputeMovingAverageIterated` 阶数等价性、 `DataSet::FormulaTransformFor` 的值域与分组极值(逐点参考实现比对)。 四、Tools/OffscreenChecks:隐藏窗口离屏验证(5 个检查) * 窗口 `SW_HIDE`;"鼠标"用 `Application::HandleMouseMove()` 合成注入; 像素从后备缓冲区读取(不依赖窗口可见性),可保存 PNG 与计算区域 FNV 哈希。 * `smoke`:初始化 / 加载 / 渲染 / 落盘,并断言"同状态两帧逐像素相同"。 * `frames`:连续帧抓取(PNG + 哈希 + 耗时),用于问题分析。 * `panels`:7 个实验性面板全部展开后的几何不变式 + **像素基线 A/B** (`-mode record` / `-mode compare`)。 * `edgestats`:边沿统计与工具内**独立参考实现**逐位比对, 覆盖阈值变化与重复查询稳定性。 * `perf`:整帧中位数、`EdgePanelLayoutFor` 单次耗时、 198 个统计状态的整轮查询耗时(用于优化前后对比)。 五、文档 * `Tools/README.md`:目录结构、构建/运行方式、每个工具的作用、测试项与 验收标准、临时产物位置、A/B 基线用法、扩展步骤与已知限制。 实测(本机 GCC 15.2.0 UCRT / 125% DPI): * `UnitTests.exe` → `checks=218 failed=0` * `OffscreenChecks -all` → 全部 PASS;`SCATTER_TEST.csv` 整帧 ~5ms; `MF4-Record.MF4`(283 信号)198 个统计状态整轮查询 0.097 us/query。
325 lines
11 KiB
C++
325 lines
11 KiB
C++
/**
|
||
* @file DataModelTests.cpp
|
||
* @brief `Utilities/DataModel` 的纯数据层语义回归(不创建窗口、不碰 GUI)。
|
||
*
|
||
* 重点用例:
|
||
* * `dataset.formula_transform_cache` —— 第 32 轮引入的「公式绘制值缓存」
|
||
* (`DataSet::FormulaTransformFor`)必须与"逐点 `ApplyDrawTransform` 后统计"**逐位一致**,
|
||
* 并且命中缓存时返回同一指针、样式变化后失效重建。
|
||
* 这条是"公式信号不再逐帧全扫"的正确性底线。
|
||
* * `filter.moving_average` —— 多阶等价性(1 阶 == 单次、2 阶 == 链式)。
|
||
* * `transform.*` —— 变换顺序(公式 → 系数 → 绝对值 → 垂直镜像 → Y 偏移)与区间映射。
|
||
*/
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
#include <limits>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#include "DataModel.hpp"
|
||
#include "UnitTestFramework.hpp"
|
||
|
||
namespace
|
||
{
|
||
/** @brief 取 double 的位模式。 */
|
||
std::uint64_t BitsOf(const double value)
|
||
{
|
||
std::uint64_t bits = 0;
|
||
__builtin_memcpy(&bits, &value, sizeof(bits));
|
||
return bits;
|
||
}
|
||
|
||
/** @brief 构造一条合成信号分组(一条单调时间轴 + 一条信号)。 */
|
||
jdi::SignalGroup MakeGroup(const std::vector<double>& values, const wchar_t* name)
|
||
{
|
||
jdi::SignalGroup group;
|
||
group.name = L"synthetic";
|
||
|
||
jdi::TimeAxis axis;
|
||
axis.name = L"time";
|
||
axis.time.resize(values.size());
|
||
for (std::size_t index = 0; index < values.size(); ++index)
|
||
{
|
||
axis.time[index] = static_cast<double>(index) * 0.001;
|
||
}
|
||
axis.monotonic = true;
|
||
axis.minTime = 0.0;
|
||
axis.maxTime = values.empty() ? 0.0 : axis.time.back();
|
||
axis.finiteCount = axis.time.size();
|
||
group.axes.push_back(std::move(axis));
|
||
|
||
jdi::SignalData signal;
|
||
signal.name = name;
|
||
signal.axisIndex = 0;
|
||
signal.values = values;
|
||
group.signals.push_back(std::move(signal));
|
||
return group;
|
||
}
|
||
} // namespace
|
||
|
||
JDI_TEST(transform_apply_order)
|
||
{
|
||
jdi::SignalStyle style;
|
||
style.gain = 2.0f;
|
||
style.absolute = true;
|
||
style.mirrorVertical = true;
|
||
style.offsetY = 1.0f;
|
||
|
||
// -1.5 → ×2 = -3 → |·| = 3 → 垂直镜像 = -3 → +1 = -2
|
||
JDI_CHECK_NEAR(jdi::ApplyDrawTransform(style, -1.5), -2.0, 0.0);
|
||
|
||
// 公式最先结算:f(t)=x*10,再 ×2、|·|、镜像、+1 ⇒ 2.5 → 25 → 50 → 50 → -50 → -49
|
||
style.formula = L"f(t)*10";
|
||
JDI_CHECK_NEAR(jdi::ApplyDrawTransform(style, 2.5), -49.0, 1e-9);
|
||
|
||
// NaN 必须保持 NaN(断线语义)
|
||
JDI_CHECK(std::isnan(jdi::ApplyDrawTransform(style, std::numeric_limits<double>::quiet_NaN())));
|
||
|
||
// 水平镜像不改变取值(只改下标),`SourceIndexFor` 负责
|
||
jdi::SignalStyle mirrored;
|
||
mirrored.mirrorHorizontal = true;
|
||
JDI_CHECK_NEAR(jdi::ApplyDrawTransform(mirrored, 7.0), 7.0, 0.0);
|
||
JDI_CHECK_EQ(jdi::SourceIndexFor(mirrored, 10, 0), 9);
|
||
JDI_CHECK_EQ(jdi::SourceIndexFor(mirrored, 10, 9), 0);
|
||
}
|
||
|
||
JDI_TEST(transform_value_range_and_order_preserving)
|
||
{
|
||
jdi::SignalStyle style;
|
||
style.gain = 2.0f;
|
||
style.absolute = true;
|
||
style.mirrorVertical = true;
|
||
style.offsetY = 1.0f;
|
||
|
||
double low = -2.0;
|
||
double high = 3.0;
|
||
jdi::TransformValueRange(style, low, high);
|
||
// [-2,3] → ×2 = [-4,6] → |·| = [0,6] → 镜像 = [-6,0] → +1 = [-5,1]
|
||
JDI_CHECK_NEAR(low, -5.0, 0.0);
|
||
JDI_CHECK_NEAR(high, 1.0, 0.0);
|
||
|
||
jdi::SignalStyle plain;
|
||
JDI_CHECK(jdi::DrawTransformOrderPreserving(plain));
|
||
plain.gain = -1.0f;
|
||
JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain));
|
||
plain.gain = 1.0f;
|
||
plain.absolute = true;
|
||
JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain));
|
||
plain.absolute = false;
|
||
plain.mirrorVertical = true;
|
||
JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain));
|
||
plain.mirrorVertical = false;
|
||
plain.formula = L"f(t)*f(t)";
|
||
JDI_CHECK(!jdi::DrawTransformOrderPreserving(plain));
|
||
plain.formula = L"f(t)";
|
||
JDI_CHECK(jdi::DrawTransformOrderPreserving(plain));
|
||
}
|
||
|
||
JDI_TEST(filter_moving_average_equivalence)
|
||
{
|
||
std::vector<double> values;
|
||
values.reserve(400);
|
||
for (int index = 0; index < 400; ++index)
|
||
{
|
||
values.push_back(std::sin(static_cast<double>(index) * 0.07) * 100.0 + (index % 7));
|
||
}
|
||
values[10] = std::numeric_limits<double>::quiet_NaN();
|
||
values[200] = std::numeric_limits<double>::quiet_NaN();
|
||
|
||
std::vector<float> single;
|
||
std::vector<float> singleCopy;
|
||
jdi::ComputeMovingAverage(values, 3, single);
|
||
|
||
// 1 阶 == 单次平均(逐位)
|
||
jdi::ComputeMovingAverageIterated(values, 3, 1, singleCopy);
|
||
JDI_CHECK_EQ(single.size(), singleCopy.size());
|
||
bool identical = (single.size() == singleCopy.size());
|
||
for (std::size_t index = 0; identical && index < single.size(); ++index)
|
||
{
|
||
identical = (BitsOf(single[index]) == BitsOf(singleCopy[index]));
|
||
}
|
||
JDI_CHECK(identical);
|
||
|
||
// 2 阶 == 对 1 阶结果再做一次(逐位)
|
||
std::vector<double> firstPass(single.begin(), single.end());
|
||
std::vector<float> chained;
|
||
jdi::ComputeMovingAverage(firstPass, 3, chained);
|
||
std::vector<float> iterated;
|
||
jdi::ComputeMovingAverageIterated(values, 3, 2, iterated);
|
||
JDI_CHECK_EQ(chained.size(), iterated.size());
|
||
bool chainIdentical = (chained.size() == iterated.size());
|
||
for (std::size_t index = 0; chainIdentical && index < chained.size(); ++index)
|
||
{
|
||
chainIdentical = (BitsOf(chained[index]) == BitsOf(iterated[index]));
|
||
}
|
||
JDI_CHECK(chainIdentical);
|
||
|
||
// 常数信号经任意阶数保持不变
|
||
std::vector<double> constant(500, 7.5);
|
||
std::vector<float> filtered;
|
||
jdi::ComputeMovingAverageIterated(constant, 5, 3, filtered);
|
||
bool constantHeld = (filtered.size() == constant.size());
|
||
for (std::size_t index = 0; constantHeld && index < filtered.size(); ++index)
|
||
{
|
||
constantHeld = (std::fabs(static_cast<double>(filtered[index]) - 7.5) < 1e-5);
|
||
}
|
||
JDI_CHECK(constantHeld);
|
||
}
|
||
|
||
JDI_TEST(dataset_group_access)
|
||
{
|
||
jdi::DataSet dataSet;
|
||
int firstIndex = -1;
|
||
JDI_CHECK(dataSet.AddGroup(MakeGroup({1.0, 2.0, 3.0}, L"a"), firstIndex));
|
||
JDI_CHECK_EQ(firstIndex, 0);
|
||
JDI_CHECK_EQ(dataSet.GroupCount(), 1);
|
||
|
||
const jdi::SignalGroup* group = dataSet.GroupAt(0);
|
||
JDI_CHECK(group != nullptr);
|
||
if (group != nullptr)
|
||
{
|
||
JDI_CHECK_EQ(group->signals.size(), 1);
|
||
JDI_CHECK_WIDE_EQ(group->signals[0].name, std::wstring(L"a"));
|
||
JDI_CHECK_EQ(group->axes.size(), 1);
|
||
}
|
||
|
||
// 外观属于信号本身:通过 `Style(ref)` 读写
|
||
const jdi::SignalRef ref{0, 0};
|
||
jdi::SignalStyle* style = dataSet.Style(ref);
|
||
JDI_CHECK(style != nullptr);
|
||
if (style != nullptr)
|
||
{
|
||
style->lineWidth = 3.5f;
|
||
const jdi::SignalStyle* readBack = dataSet.Style(ref);
|
||
JDI_CHECK(readBack != nullptr && readBack->lineWidth == 3.5f);
|
||
}
|
||
JDI_CHECK(dataSet.GroupAt(5) == nullptr);
|
||
}
|
||
|
||
JDI_TEST(dataset_formula_transform_cache)
|
||
{
|
||
std::vector<double> values;
|
||
values.reserve(5000);
|
||
for (int index = 0; index < 5000; ++index)
|
||
{
|
||
values.push_back(std::sin(static_cast<double>(index) * 0.013) * 50.0 + (index % 11) * 0.5);
|
||
}
|
||
values[17] = std::numeric_limits<double>::quiet_NaN();
|
||
values[4999] = std::numeric_limits<double>::quiet_NaN();
|
||
|
||
jdi::DataSet dataSet;
|
||
int groupIndex = -1;
|
||
JDI_CHECK(dataSet.AddGroup(MakeGroup(values, L"formula"), groupIndex));
|
||
const jdi::SignalGroup* group = dataSet.GroupAt(0);
|
||
JDI_CHECK(group != nullptr);
|
||
if (group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const jdi::SignalData& signal = group->signals[0];
|
||
jdi::SignalStyle& style = *dataSet.Style(jdi::SignalRef{0, 0});
|
||
|
||
// 恒等 / 空公式 ⇒ 不建立缓存(调用方走 O(1) 保序快路径)
|
||
JDI_CHECK(dataSet.FormulaTransformFor(signal, style) == nullptr);
|
||
style.formula = L"f(t)";
|
||
JDI_CHECK(dataSet.FormulaTransformFor(signal, style) == nullptr);
|
||
|
||
// 真公式 ⇒ 建立一次缓存
|
||
style.formula = L"f(t)*2";
|
||
style.gain = 1.5f;
|
||
style.absolute = true;
|
||
style.mirrorVertical = false;
|
||
style.offsetY = -3.0f;
|
||
|
||
const jdi::DataSet::FormulaTransformCache* entry = dataSet.FormulaTransformFor(signal, style);
|
||
JDI_CHECK(entry != nullptr);
|
||
if (entry == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
JDI_CHECK(entry->ready);
|
||
|
||
// 参考实现:逐点走 `ApplyDrawTransform`(同一套语义),按缓存给出的 stride 分组
|
||
double referenceLow = std::numeric_limits<double>::infinity();
|
||
double referenceHigh = -std::numeric_limits<double>::infinity();
|
||
std::vector<double> referenceMin(entry->minimums.size(), std::numeric_limits<double>::quiet_NaN());
|
||
std::vector<double> referenceMax(entry->maximums.size(), std::numeric_limits<double>::quiet_NaN());
|
||
const std::size_t stride = entry->stride;
|
||
JDI_CHECK(stride > 0);
|
||
for (std::size_t index = 0; index < signal.values.size(); ++index)
|
||
{
|
||
const double mapped = jdi::ApplyDrawTransform(style, signal.values[index]);
|
||
if (!std::isfinite(mapped))
|
||
{
|
||
continue;
|
||
}
|
||
const std::size_t bucket = index / stride;
|
||
if (bucket >= referenceMin.size())
|
||
{
|
||
continue;
|
||
}
|
||
if (mapped < referenceMin[bucket] || referenceMin[bucket] != referenceMin[bucket])
|
||
{
|
||
referenceMin[bucket] = mapped;
|
||
}
|
||
if (mapped > referenceMax[bucket] || referenceMax[bucket] != referenceMax[bucket])
|
||
{
|
||
referenceMax[bucket] = mapped;
|
||
}
|
||
referenceLow = std::min(referenceLow, mapped);
|
||
referenceHigh = std::max(referenceHigh, mapped);
|
||
}
|
||
|
||
JDI_CHECK_BIT_EQUAL(entry->low, referenceLow);
|
||
JDI_CHECK_BIT_EQUAL(entry->high, referenceHigh);
|
||
JDI_CHECK_EQ(entry->minimums.size(), referenceMin.size());
|
||
|
||
bool bucketsMatch = (entry->minimums.size() == referenceMin.size());
|
||
for (std::size_t bucket = 0; bucketsMatch && bucket < referenceMin.size(); ++bucket)
|
||
{
|
||
const bool emptyReference = (referenceMin[bucket] != referenceMin[bucket]);
|
||
const bool emptyEntry = (entry->minimums[bucket] != entry->minimums[bucket]);
|
||
if (emptyReference != emptyEntry)
|
||
{
|
||
bucketsMatch = false;
|
||
break;
|
||
}
|
||
if (emptyReference)
|
||
{
|
||
continue;
|
||
}
|
||
bucketsMatch = BitsOf(entry->minimums[bucket]) == BitsOf(referenceMin[bucket]) &&
|
||
BitsOf(entry->maximums[bucket]) == BitsOf(referenceMax[bucket]);
|
||
}
|
||
JDI_CHECK(bucketsMatch);
|
||
|
||
// 命中缓存:同一信号 + 同一样式 ⇒ 返回同一指针(不再重扫)
|
||
const jdi::DataSet::FormulaTransformCache* again = dataSet.FormulaTransformFor(signal, style);
|
||
JDI_CHECK(again == entry);
|
||
|
||
// 样式变化 ⇒ 失效重建,且新值域按新公式计算
|
||
style.formula = L"f(t)*3";
|
||
const jdi::DataSet::FormulaTransformCache* rebuilt = dataSet.FormulaTransformFor(signal, style);
|
||
JDI_CHECK(rebuilt != nullptr);
|
||
if (rebuilt != nullptr)
|
||
{
|
||
JDI_CHECK(rebuilt != entry);
|
||
double expectedLow = std::numeric_limits<double>::infinity();
|
||
double expectedHigh = -std::numeric_limits<double>::infinity();
|
||
for (const double value : signal.values)
|
||
{
|
||
const double mapped = jdi::ApplyDrawTransform(style, value);
|
||
if (!std::isfinite(mapped))
|
||
{
|
||
continue;
|
||
}
|
||
expectedLow = std::min(expectedLow, mapped);
|
||
expectedHigh = std::max(expectedHigh, mapped);
|
||
}
|
||
JDI_CHECK_BIT_EQUAL(rebuilt->low, expectedLow);
|
||
JDI_CHECK_BIT_EQUAL(rebuilt->high, expectedHigh);
|
||
}
|
||
}
|