Files
Just-DataInspector/Tools/UnitTests/Src/Utf8UtilTests.cpp
T
DeepSeek V4.1 Flash 97e9163314 test(tools): 重建单元测试与离屏验证工具(Tools/ 入库)
背景:第 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。
2026-11-04 11:41:08 +08:00

307 lines
9.4 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Utf8UtilTests.cpp
* @brief `Utilities/Utf8Util` 的数值解析 / 文本格式化语义回归。
*
* 重点用例:
* * `parse.bit_exact_vs_strtod` —— **逐位**对比 `TryParseDouble` 与 libc `strtod`。
* 这是"自写 strtod 替身"类体积优化唯一可信的验收标准(见 Code Review 报告 §10.4):
* 常见写法(≤15 位有效数字、|十进制指数| ≤ 22)必须逐位相同;
* 17 位以上的长数字允许 ≤1 ULP 偏差(实现里已注释该取舍)。
* * `format.*` —— 只断言文档里给出的实例与"绝不出现科学计数法"这类硬约束,
* 避免把测试绑死在内部格式细节上。
*/
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <random>
#include <string>
#include <vector>
#include "UnitTestFramework.hpp"
#include "Utf8Util.hpp"
namespace
{
/** @brief 取 double 的位模式。 */
std::uint64_t BitsOf(const double value)
{
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
return bits;
}
/** @brief 两个 double 之间相差多少个 ULP(异号时返回一个很大的数)。 */
long long UlpDifference(const double left, const double right)
{
if (left == right)
{
return 0;
}
const std::uint64_t a = BitsOf(left);
const std::uint64_t b = BitsOf(right);
if ((a >> 63) != (b >> 63))
{
return 1000000;
}
return (a > b) ? static_cast<long long>(a - b) : -static_cast<long long>(b - a);
}
/** @brief 用 `TryParseDouble` 解析整段文本。 */
bool Parse(const std::string& text, double& value)
{
return jdi::TryParseDouble(text.c_str(), text.c_str() + text.size(), value);
}
/** @brief 统计十进制有效数字位数(忽略符号/小数点/前导零/指数部分)。 */
int SignificantDigits(const std::string& text)
{
int digits = 0;
bool sawNonZero = false;
for (const char character : text)
{
if (character == 'e' || character == 'E')
{
break;
}
if (character < '0' || character > '9')
{
continue;
}
if (character != '0')
{
sawNonZero = true;
}
if (sawNonZero)
{
++digits;
}
}
return digits;
}
/** @brief 生成 `数字.数字e指数` 形式的随机十进制文本。 */
std::string MakeRandomNumber(std::mt19937& rng, const int maxDigits, const bool allowFraction,
const bool allowExponent, const int maxExponent)
{
std::string text;
if ((rng() & 1u) != 0u)
{
text.push_back('-');
}
const int integerDigits = 1 + static_cast<int>(rng() % static_cast<unsigned>(maxDigits));
for (int index = 0; index < integerDigits; ++index)
{
text.push_back(static_cast<char>('0' + (rng() % 10u)));
}
if (allowFraction && (rng() % 3u) != 0u)
{
text.push_back('.');
const int fractionDigits = 1 + static_cast<int>(rng() % static_cast<unsigned>(maxDigits));
for (int index = 0; index < fractionDigits; ++index)
{
text.push_back(static_cast<char>('0' + (rng() % 10u)));
}
}
if (allowExponent && (rng() % 2u) == 0u)
{
text.push_back((rng() & 1u) != 0u ? 'e' : 'E');
const bool negativeExponent = (rng() & 1u) != 0u;
text.push_back(negativeExponent ? '-' : '+');
const int exponent = static_cast<int>(rng() % static_cast<unsigned>(maxExponent + 1));
const std::string digits = std::to_string(exponent);
text += digits;
}
return text;
}
} // namespace
JDI_TEST(parse_fixed_corpus_matches_strtod)
{
static const char* const corpus[] = {
"0", "1", "-1", "+1", "0.1", "-0.1", "1.5",
"123.456", "-123.456", "1e10", "1e-10", "1E+5", "1e22", "1e-22",
"3.141592653589793", "2.718281828459045", "6.02214076e23", "1.602176634e-19", "0.000001",
"1000000", "-0", "0.0", "123456789012345",
};
for (const char* const text : corpus)
{
double ours = 0.0;
const bool accepted = Parse(text, ours);
char* stop = nullptr;
const double reference = std::strtod(text, &stop);
const bool referenceAccepted = (stop != text);
JDI_REQUIRE(text, accepted == referenceAccepted);
if (accepted && referenceAccepted)
{
JDI_CHECK_BIT_EQUAL(ours, reference);
}
}
}
JDI_TEST(parse_acceptance_semantics)
{
double value = 0.0;
JDI_CHECK(!Parse("", value));
JDI_CHECK(!Parse(" ", value));
JDI_CHECK(!Parse("abc", value));
JDI_CHECK(!Parse("e5", value));
// 尾部杂字符:与 `strtod` 一致,接受前缀数值(历史语义,不要改)
JDI_CHECK(Parse("12abc", value));
JDI_CHECK_NEAR(value, 12.0, 0.0);
JDI_CHECK(Parse("1,5", value));
JDI_CHECK_NEAR(value, 1.0, 0.0);
// 首尾空白应被忽略
JDI_CHECK(Parse(" -2.5\t\r\n", value));
JDI_CHECK_NEAR(value, -2.5, 0.0);
// 十六进制浮点 / inf / nan 走标准库回退路径,语义必须与 strtod 相同
static const char* const exotic[] = {"0x1p3", "inf", "-inf", "nan", "infinity", "1e9999", "1e-9999"};
for (const char* const text : exotic)
{
double ours = 0.0;
char* stop = nullptr;
const double reference = std::strtod(text, &stop);
const bool referenceAccepted = (stop != text);
JDI_REQUIRE(text, Parse(text, ours) == referenceAccepted);
if (referenceAccepted)
{
JDI_CHECK_BIT_EQUAL(ours, reference);
}
}
}
JDI_TEST(parse_random_corpus_bit_exact)
{
std::mt19937 rng(20261104u);
int mismatches = 0;
int beyondOneUlp = 0;
int cases = 0;
// 常见写法:≤15 位有效数字、|指数| ≤ 22 ⇒ 必须逐位相同。
for (int iteration = 0; iteration < 40000; ++iteration)
{
const std::string text = MakeRandomNumber(rng, 6, true, true, 22);
double ours = 0.0;
char* stop = nullptr;
const double reference = std::strtod(text.c_str(), &stop);
if (stop == text.c_str())
{
continue;
}
++cases;
if (!Parse(text, ours))
{
++mismatches;
continue;
}
if (BitsOf(ours) != BitsOf(reference))
{
++mismatches;
if (SignificantDigits(text) <= 15 && std::fabs(reference) > 1e-300 &&
std::fabs(reference) < 1e300)
{
std::printf(" [INFO] bit mismatch on short literal: %s ours=%.17g ref=%.17g\n", text.c_str(),
ours, reference);
}
}
}
JDI_CHECK_EQ(cases, 40000);
JDI_CHECK_EQ(mismatches, 0);
// 长数字(最多 19 位):允许 ≤1 ULP 偏差(实现里有明确注释与容差约定)。
for (int iteration = 0; iteration < 20000; ++iteration)
{
const std::string text = MakeRandomNumber(rng, 19, true, true, 22);
double ours = 0.0;
char* stop = nullptr;
const double reference = std::strtod(text.c_str(), &stop);
if (stop == text.c_str() || !Parse(text, ours))
{
continue;
}
if (UlpDifference(ours, reference) > 1)
{
++beyondOneUlp;
}
}
JDI_CHECK_EQ(beyondOneUlp, 0);
}
JDI_TEST(format_decimal_never_scientific)
{
JDI_CHECK_WIDE_EQ(jdi::FormatDecimal(1.0e-5), std::wstring(L"0.00001"));
JDI_CHECK_WIDE_EQ(jdi::FormatDecimal(0.0), std::wstring(L"0"));
JDI_CHECK_WIDE_EQ(jdi::FormatDecimalFixed(700.0, 3), std::wstring(L"700.000"));
static const double values[] = {1.0e-9, 1.0e-6, 1.0e-5, 0.000123456, 1.0, 1234.5678, 1.0e9};
for (const double value : values)
{
const std::wstring text = jdi::FormatDecimal(value);
JDI_REQUIRE("FormatDecimal must not use exponent", text.find(L'e') == std::wstring::npos &&
text.find(L'E') == std::wstring::npos);
}
// 文档实例:`FormatValue` = `%.6g`
JDI_CHECK_WIDE_EQ(jdi::FormatValue(123.456789), std::wstring(L"123.457"));
JDI_CHECK_WIDE_EQ(jdi::FormatValue(1.0), std::wstring(L"1"));
}
JDI_TEST(format_byte_size_and_frequency)
{
const std::wstring bytes = jdi::FormatByteSize(1024ull * 1024ull);
JDI_REQUIRE("FormatByteSize(1MB) contains MB", bytes.find(L"MB") != std::wstring::npos);
const std::wstring hertz = jdi::FormatFrequency(270.5166);
JDI_REQUIRE("FormatFrequency contains unit", hertz.find(L"Hz") != std::wstring::npos);
JDI_REQUIRE("FormatFrequency has no exponent", hertz.find(L'e') == std::wstring::npos);
const std::wstring duration = jdi::FormatDurationCompound(5.06);
JDI_REQUIRE("FormatDurationCompound has no exponent", duration.find(L'e') == std::wstring::npos);
JDI_REQUIRE("FormatDurationCompound ends with unit", duration.find(L's') != std::wstring::npos);
}
JDI_TEST(text_trim_and_utf8_conversion)
{
JDI_CHECK_WIDE_EQ(jdi::Trim(L" abc \t"), std::wstring(L"abc"));
JDI_CHECK_WIDE_EQ(jdi::Trim(L" \t\r\n "), std::wstring());
JDI_CHECK_WIDE_EQ(jdi::ToLowerAscii(L"AbC-129"), std::wstring(L"abc-129"));
const std::wstring wide = jdi::Utf8ToWide("abc");
JDI_CHECK_WIDE_EQ(wide, std::wstring(L"abc"));
JDI_CHECK(jdi::ContainsInsensitive(L"HelloWorld", L"world"));
JDI_CHECK(!jdi::ContainsInsensitive(L"HelloWorld", L"xyz"));
}
JDI_TEST(ticks_nice_step_and_build_ticks)
{
// `NiceStep` 必须落在 1/2/2.5/5 × 10^n 这类"好读"的格点上
static const double ranges[] = {1.0, 10.0, 123.0, 0.0003, 98765.0};
for (const double range : ranges)
{
const double step = jdi::NiceStep(range, 10);
JDI_REQUIRE("NiceStep > 0", step > 0.0);
const double ratio = step / std::pow(10.0, std::floor(std::log10(step)));
const bool nice = std::fabs(ratio - 1.0) < 1e-9 || std::fabs(ratio - 2.0) < 1e-9 ||
std::fabs(ratio - 2.5) < 1e-9 || std::fabs(ratio - 5.0) < 1e-9;
JDI_REQUIRE("NiceStep is a round number", nice);
}
std::vector<double> ticks;
jdi::BuildTicks(0.0, 10.0, 1.0, ticks);
JDI_CHECK_EQ(ticks.size(), 11);
if (ticks.size() == 11)
{
JDI_CHECK_NEAR(ticks.front(), 0.0, 0.0);
JDI_CHECK_NEAR(ticks.back(), 10.0, 0.0);
}
}