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。
This commit is contained in:
1 parent
c5d7a6fb91
commit
97e9163314
14 files changed
+3515
-2
No files matched your search
+7
-2
@@ -17,5 +17,10 @@ Temp/
|
||||
# 测试数据(仅本地保留,不纳入版本控制)
|
||||
TestData/
|
||||
|
||||
# 临时验证工具(仅本地保留,不入版本控制)
|
||||
Tools/
|
||||
# 验证工具(Tools/)**入库**,但只忽略各工具自己的构建产物:
|
||||
# 每个工具目录内的 Build/(与 Inc/Src 同级)不放仓库,其它协作者本地 make 即可。
|
||||
# `Build/` 已在上方整体忽略(无前导斜杠 = 任意层级),这里显式重申,避免日后误读。
|
||||
Tools/**/Build/
|
||||
|
||||
# 工具的临时运行产物(截图 / 连续帧 / 基线哈希 / 报告)统一放到 Temp/ 下
|
||||
# (Temp/ 已在上方忽略),工具内部禁止把临时文件写进 Tools/ 目录。
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
# =============================================================================
|
||||
# Tools/Makefile —— 验证工具(单元测试 / 离屏验证)的统一构建入口
|
||||
# =============================================================================
|
||||
#
|
||||
# 职责边界(很重要,别混):
|
||||
# * 根目录 `Makefile` **只负责应用本体**(`Build/Release`、`Build/Debug`)。
|
||||
# 它按目录通配收集源文件,`Tools/` 不在其 `CXX_SOURCES_PATHS` 里,
|
||||
# 因此**新增工具不需要改根 Makefile**,也不会把工具代码编进应用本体。
|
||||
# * 本文件负责 `Tools/` 下所有工具的编译 / 链接 / 清理。
|
||||
# 工具是"白盒验证":直接链接应用本体已构建好的目标文件
|
||||
# (`../Build/Release/*.o`,排除入口 `Main.o`),因此**必须先构建本体**。
|
||||
#
|
||||
# 用法(在 `Tools/` 目录下执行,或 `make -C Tools ...`):
|
||||
# make 构建全部工具
|
||||
# make UnitTests 只构建单元测试
|
||||
# make OffscreenChecks 只构建离屏验证工具
|
||||
# make clean 删除所有工具的构建目录(**不动**应用本体)
|
||||
# make help 打印帮助
|
||||
#
|
||||
# 目录约定:
|
||||
# Tools/<Tool>/Inc 仅头文件
|
||||
# Tools/<Tool>/Src 仅实现(每个 `.cpp` 都会编译)
|
||||
# Tools/<Tool>/Build 产物(与 Inc/Src 同级;已被 .gitignore 忽略)
|
||||
#
|
||||
# ⚠️ 工具目标文件必须与应用本体使用**同一套 ABI 相关开关**,否则链接会失败:
|
||||
# `-fno-exceptions`(本体 `NO_EXCEPTIONS = 1`)
|
||||
# `-fno-rtti` (本体 `NO_RTTI = 1`,本仓已无 dynamic_cast)
|
||||
# `-flto -flto-partition=one`(本体 `LTO = 1`,目标文件是 LTO 字节码)
|
||||
# =============================================================================
|
||||
|
||||
# ---- 工具清单(新增工具只需在这里追加,并保证目录名 = 目标名)--------------
|
||||
TOOLS := UnitTests OffscreenChecks
|
||||
|
||||
# ⚠️ 必须显式指定默认目标:下面用 `$(foreach)` 生成的工具目标会先于 `all` 定义,
|
||||
# 而 make 会把"第一个出现的显式目标"当作默认目标 ⇒ 直接 `make` 只会建第一个工具。
|
||||
.DEFAULT_GOAL := all
|
||||
|
||||
# 工具内构建目录名(与 Inc / Src 同级)
|
||||
BUILD_DIR_NAME := Build
|
||||
|
||||
# ---- 工具链 ----------------------------------------------------------------
|
||||
C_COMPILER := gcc
|
||||
CXX_COMPILER := g++
|
||||
|
||||
C_STD := c17
|
||||
CXX_STD := c++17
|
||||
|
||||
INPUT_CHAR_ENCODING := UTF-8
|
||||
OUTPUT_CHAR_ENCODING := GBK
|
||||
OUTPUT_WCHAR_ENCODING := UTF-16LE
|
||||
|
||||
# 工具自身优化等级:验证工具追求"跑得快",不参与体积红线
|
||||
TOOL_OPT := -O2
|
||||
TOOL_DEBUG_INFO := -g
|
||||
|
||||
# ---- 与被测应用本体保持一致的 ABI 开关-------------------------------------
|
||||
NO_EXCEPTIONS := 1
|
||||
NO_RTTI := 1
|
||||
LTO := 1
|
||||
|
||||
# ---- 应用本体 --------------------------------------------------------------
|
||||
APP_BUILD_DIR := ../Build/Release
|
||||
# 排除入口 `Main.o`(它定义 `WinMain`,与工具的 `main` 不冲突但不需要)
|
||||
APP_OBJECTS := $(filter-out $(APP_BUILD_DIR)/Main.o,$(wildcard $(APP_BUILD_DIR)/*.o))
|
||||
|
||||
# ---- 头文件目录(与应用本体一致)------------------------------------------
|
||||
PROJECT_INCLUDES := \
|
||||
../Utilities/Inc \
|
||||
../Resources/Inc \
|
||||
../Components/Main/Inc \
|
||||
../Components/Shared/Inc \
|
||||
../Components/Application/Inc \
|
||||
../Components/TabBar/Inc \
|
||||
../Components/Toolbar/Inc \
|
||||
../Components/SignalPanel/Inc \
|
||||
../Components/PlotView/Inc
|
||||
|
||||
# ---- 链接库(与应用本体一致)----------------------------------------------
|
||||
LINK_LIBS := \
|
||||
-lgdi32 \
|
||||
-lgdiplus \
|
||||
-lmsimg32 \
|
||||
-lcomdlg32 \
|
||||
-ld3d11 \
|
||||
-ldwmapi \
|
||||
-ld3dcompiler
|
||||
|
||||
# ---- 编译 / 链接选项 -------------------------------------------------------
|
||||
# ⚠️ 这里用 `=`(递归展开)而不是 `:=`:`-MF"$(@:%.o=%.d)"` 里的 `$@`
|
||||
# 必须在**展开 recipe 时**才求值(那时才确定是哪一个目标文件)。
|
||||
BASE_CXXFLAGS = \
|
||||
-D_UNICODE -DUNICODE \
|
||||
$(addprefix -I,$(PROJECT_INCLUDES)) \
|
||||
-std=$(CXX_STD) \
|
||||
-finput-charset=$(INPUT_CHAR_ENCODING) \
|
||||
-fexec-charset=$(OUTPUT_CHAR_ENCODING) \
|
||||
-fwide-exec-charset=$(OUTPUT_WCHAR_ENCODING) \
|
||||
-static \
|
||||
-Wall \
|
||||
$(TOOL_OPT) \
|
||||
$(TOOL_DEBUG_INFO) \
|
||||
-MMD -MP -MF"$(@:%.o=%.d)"
|
||||
|
||||
ifeq ($(strip $(NO_EXCEPTIONS)),1)
|
||||
BASE_CXXFLAGS += -fno-exceptions
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_RTTI)),1)
|
||||
BASE_CXXFLAGS += -fno-rtti
|
||||
endif
|
||||
|
||||
BASE_LDFLAGS =
|
||||
ifeq ($(strip $(LTO)),1)
|
||||
# 本体目标文件是 LTO 字节码 ⇒ **链接**必须带 `-flto -flto-partition=one`。
|
||||
# ⚠️ 工具自身源文件**不要**加 `-flto`:把工具代码也交给 LTRANS 会在
|
||||
# `choose_baseaddr`(i386.cc:7447)处 ICE(GCC 15.2.0 实测),
|
||||
# 而工具体量很小,根本不需要跨单元内联。
|
||||
BASE_LDFLAGS += -flto -flto-partition=one
|
||||
endif
|
||||
|
||||
# ---- 每个工具的源文件 / 目标文件 / 产物 ------------------------------------
|
||||
$(foreach tool,$(TOOLS),$(eval $(tool)_SRC := $(wildcard $(tool)/Src/*.cpp)))
|
||||
$(foreach tool,$(TOOLS),$(eval $(tool)_OBJ := $(patsubst $(tool)/Src/%.cpp,$(tool)/$(BUILD_DIR_NAME)/%.o,$($(tool)_SRC))))
|
||||
$(foreach tool,$(TOOLS),$(eval $(tool)_EXE := $(tool)/$(BUILD_DIR_NAME)/$(tool).exe))
|
||||
|
||||
# 本体的目标文件缺失时给出明确提示(而不是让链接器报一堆 undefined reference)
|
||||
ifeq ($(strip $(APP_OBJECTS)),)
|
||||
$(warning ********************************************************************)
|
||||
$(warning 未找到应用本体目标文件:$(APP_BUILD_DIR)/*.o)
|
||||
$(warning 请先在项目根目录构建本体,例如:make release -j8)
|
||||
$(warning ********************************************************************)
|
||||
endif
|
||||
|
||||
# ---- 每个工具的规则(模板)-------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
define TOOL_RULES
|
||||
.PHONY: $(1)
|
||||
$(1): $$($(1)_EXE)
|
||||
|
||||
$$($(1)_EXE): $$($(1)_OBJ) $(APP_OBJECTS) Makefile
|
||||
@echo ====== [$(1)] All File Compiled. Now Linking... ======
|
||||
$(CXX_COMPILER) $$($(1)_OBJ) $(APP_OBJECTS) -static $(TOOL_DEBUG_INFO) -o $$@ $(LINK_LIBS) $(BASE_LDFLAGS) -mconsole
|
||||
@echo ====== [$(1)] Link Finished: $$@ ======
|
||||
|
||||
$$($(1)_OBJ): $(1)/$(BUILD_DIR_NAME)/%.o: $(1)/Src/%.cpp Makefile | $(1)/$(BUILD_DIR_NAME)
|
||||
@echo ====== [$(1)] C++ Source File "$$<" Compiling... ======
|
||||
$(CXX_COMPILER) -c $$(BASE_CXXFLAGS) -I$(1)/Inc $$< -o $$@
|
||||
|
||||
$(1)/$(BUILD_DIR_NAME):
|
||||
@powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '$$@' | Out-Null"
|
||||
endef
|
||||
|
||||
$(foreach tool,$(TOOLS),$(eval $(call TOOL_RULES,$(tool))))
|
||||
|
||||
# ---- 聚合目标 --------------------------------------------------------------
|
||||
.PHONY: all clean help unit offscreen
|
||||
|
||||
all: $(TOOLS)
|
||||
@echo ====== [Tools] All Tools Built ======
|
||||
|
||||
# 便利别名
|
||||
unit: UnitTests
|
||||
offscreen: OffscreenChecks
|
||||
|
||||
# 各工具的构建目录(空格分隔 → PowerShell 里 `-split ' '` 成数组)
|
||||
TOOL_BUILD_DIRS := $(strip $(foreach tool,$(TOOLS),$(tool)/$(BUILD_DIR_NAME)))
|
||||
|
||||
clean:
|
||||
@echo ====== [Tools] Cleaning Build Directories... ======
|
||||
@powershell -NoProfile -Command "'$(TOOL_BUILD_DIRS)' -split ' ' | ForEach-Object { if (Test-Path $$_) { Remove-Item -Recurse -Force $$_ } }"
|
||||
@echo ====== [Tools] Clean Accomplished ======
|
||||
|
||||
help:
|
||||
@echo Tools/Makefile —— 验证工具构建入口
|
||||
@echo.
|
||||
@echo make 构建全部工具:$(TOOLS)
|
||||
@echo make UnitTests 只构建单元测试(Tools/UnitTests)
|
||||
@echo make OffscreenChecks 只构建离屏验证工具(Tools/OffscreenChecks)
|
||||
@echo make clean 删除各工具自己的 Build/ 目录
|
||||
@echo make help 显示本帮助
|
||||
@echo.
|
||||
@echo 依赖:应用本体已构建(../Build/Release/*.o)
|
||||
|
||||
# ---- 头文件依赖(与根 Makefile 同一套 `-MMD -MP` 机制)--------------------
|
||||
# ⚠️ 删掉/重命名头文件后若出现"莫名为 undefined reference",先 `make clean`。
|
||||
-include $(foreach tool,$(TOOLS),$($(tool)_OBJ:.o=.d))
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file CheckRunner.hpp
|
||||
* @brief 离屏检查的注册表与统一断言/输出接口。
|
||||
*
|
||||
* 每个检查都是一个签名为 `int (const CheckOptions&)` 的函数,
|
||||
* 通过 `Expect()` 记录断言;返回值只用于"该检查自己额外发现的失败数"(通常直接返回全局失败数)。
|
||||
*/
|
||||
|
||||
#ifndef __CHECKRUNNER_HPP__
|
||||
#define __CHECKRUNNER_HPP__
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "OffscreenSupport.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
/** @brief 一次检查运行的参数(来自命令行)。 */
|
||||
struct CheckOptions
|
||||
{
|
||||
std::string dataPath; /**< 数据文件路径(ASCII 或本地代码页)。 */
|
||||
std::string outDir; /**< 输出目录(默认 `../Temp/tools`)。 */
|
||||
std::string mode; /**< `verify`(默认)/ `record` / `compare`。 */
|
||||
int frames = 8; /**< 连续帧数量(仅 `frames` 检查使用)。 */
|
||||
bool savePng = true; /**< 是否把抓到的帧落盘(默认开,便于事后分析)。 */
|
||||
};
|
||||
|
||||
/** @brief 一个可运行的检查。 */
|
||||
struct CheckDescriptor
|
||||
{
|
||||
const char* name = ""; /**< 检查名(命令行 `-run <name>` 使用)。 */
|
||||
const char* summary = ""; /**< 一句话说明(ASCII,`-list` 打印)。 */
|
||||
/** 检查实现:`session` 已 `Start()` 并加载好数据文件;返回该检查的失败断言数。 */
|
||||
int (*run)(Session& session, const CheckOptions& options) = nullptr;
|
||||
};
|
||||
|
||||
/** @brief 全部已注册检查(顺序即执行顺序)。 */
|
||||
const std::vector<CheckDescriptor>& AvailableChecks();
|
||||
|
||||
/** @brief 按名字查检查;找不到返回 nullptr。 */
|
||||
const CheckDescriptor* FindCheck(const std::string& name);
|
||||
|
||||
/** @brief 统一输出(同时写标准输出与报告文件,由 `Main.cpp` 提供实现)。 */
|
||||
void EmitLine(const char* format, ...);
|
||||
|
||||
/** @brief 断言总数。 */
|
||||
int& CheckTotal();
|
||||
|
||||
/** @brief 失败断言数。 */
|
||||
int& CheckFailed();
|
||||
|
||||
/** @brief 记录一条断言(失败会打印 `[FAIL]` 并累加失败数)。 */
|
||||
void Expect(const bool ok, const std::string& what);
|
||||
|
||||
/** @brief 记录一条"整数相等"断言。 */
|
||||
void ExpectEqual(const long long actual, const long long expected, const std::string& what);
|
||||
|
||||
/** @brief 记录一条数值近似断言。 */
|
||||
void ExpectNear(const double actual, const double expected, const double tolerance,
|
||||
const std::string& what);
|
||||
|
||||
/** @brief 把 `gd::RectF` 转成整像素矩形(向外取整,保证覆盖)。 */
|
||||
inline PixelRect ToPixelRect(const gd::RectF& rect)
|
||||
{
|
||||
PixelRect result;
|
||||
result.x = static_cast<int>(std::floor(rect.X));
|
||||
result.y = static_cast<int>(std::floor(rect.Y));
|
||||
result.width = static_cast<int>(std::ceil(rect.X + rect.Width)) - result.x;
|
||||
result.height = static_cast<int>(std::ceil(rect.Y + rect.Height)) - result.y;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @brief 读取一个文本文件(失败返回空串)。 */
|
||||
std::string ReadTextFile(const std::string& path);
|
||||
|
||||
/** @brief 写一个文本文件(自动建目录)。 */
|
||||
bool WriteTextFile(const std::string& path, const std::string& content);
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __CHECKRUNNER_HPP__
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file OffscreenSupport.hpp
|
||||
* @brief 离屏验证工具的公共支撑:后备缓冲抓帧、区域哈希、隐藏窗口会话。
|
||||
*
|
||||
* ⚠️ **为什么要 `#define private public`**:本工具是"白盒验证"——
|
||||
* 它需要读取后备缓冲区(`Application::m_backBits`)、驱动鼠标消息、
|
||||
* 直接调用面板几何函数(`PlotView::EdgePanelLayoutFor`)。这些都不是给终端用户用的
|
||||
* 公开 API,因此不为了测试而污染产品接口,改成在工具侧临时放宽访问级别。
|
||||
*
|
||||
* ⚠️ **先引入全部标准库 / 系统头,再放宽访问级别**:否则随后被 include 的
|
||||
* 标准库头里的 `private:` 会被宏替换成 `public:`,造成莫名其妙的编译错误。
|
||||
*
|
||||
* ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不占用户的屏幕与鼠标键盘。
|
||||
* 抓帧读的是**后备缓冲区**(不是窗口 DC),因此完全不需要窗口可见。
|
||||
*/
|
||||
|
||||
#ifndef __OFFSCREENSUPPORT_HPP__
|
||||
#define __OFFSCREENSUPPORT_HPP__
|
||||
|
||||
// ---- 先铺满标准库与系统头 ---------------------------------------------------
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <numeric>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// ---- 临时放宽访问级别,引入被测项目头 --------------------------------------
|
||||
#define private public
|
||||
#define protected public
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "DataModel.hpp"
|
||||
#include "PlotView.hpp"
|
||||
#include "PlotViewInternal.hpp"
|
||||
#include "SignalPanel.hpp"
|
||||
#include "TabBar.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Toolbar.hpp"
|
||||
#include "UiSurface.hpp"
|
||||
|
||||
#undef protected
|
||||
#undef private
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
/** @brief 后备缓冲区里的一块像素矩形(设备像素、客户区坐标)。 */
|
||||
struct PixelRect
|
||||
{
|
||||
int x = 0; /**< 左边界。 */
|
||||
int y = 0; /**< 上边界。 */
|
||||
int width = 0; /**< 宽度。 */
|
||||
int height = 0; /**< 高度。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 一帧后备缓冲区像素(top-down 32bpp BGRA)。
|
||||
*
|
||||
* ⚠️ **不要跨帧缓存 `pixels.data()` 的内部指针**:窗口尺寸 / DPI 变化会重建后备缓冲区;
|
||||
* 每次 `RenderFrame()` 都会重新 `CaptureFrame`。
|
||||
*/
|
||||
struct Frame
|
||||
{
|
||||
int width = 0; /**< 像素宽。 */
|
||||
int height = 0; /**< 像素高。 */
|
||||
int stride = 0; /**< 行字节数。 */
|
||||
std::vector<std::uint8_t> pixels; /**< BGRA 像素(逐行自上而下)。 */
|
||||
|
||||
/** @brief 是否抓到了有效像素。 */
|
||||
bool Valid() const { return width > 0 && height > 0 && !pixels.empty(); }
|
||||
|
||||
/** @brief 第 `y` 行的起始地址。 */
|
||||
const std::uint8_t* Row(const int y) const
|
||||
{
|
||||
return pixels.data() + static_cast<std::size_t>(y) * static_cast<std::size_t>(stride);
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief 从应用后备缓冲区抓一帧(不依赖窗口可见性)。 */
|
||||
bool CaptureFrame(const Application& app, Frame& frame);
|
||||
|
||||
/** @brief 计算区域内像素的 FNV-1a 64 位哈希(只混 BGR,忽略 alpha)。 */
|
||||
std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect);
|
||||
|
||||
/** @brief 统计区域内"墨迹"像素数(与区域主色不同的像素)。 */
|
||||
int CountInk(const Frame& frame, const PixelRect& rect);
|
||||
|
||||
/** @brief 统计两帧在同一区域里差异超过容差的像素数。 */
|
||||
int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, int tolerance);
|
||||
|
||||
/** @brief 把一帧保存成 PNG(路径必须是 ASCII,目录不存在会自动创建)。 */
|
||||
bool SavePng(const Frame& frame, const std::string& path);
|
||||
|
||||
/** @brief 递归创建目录(ASCII 路径)。 */
|
||||
void EnsureDirectory(const std::string& asciiPath);
|
||||
|
||||
/** @brief 递归删除目录(ASCII 路径,用于"清空临时输出")。 */
|
||||
void RemoveDirectoryTree(const std::string& asciiPath);
|
||||
|
||||
/**
|
||||
* @brief 一次离屏会话:隐藏窗口 + 可选的数据文件 + 逐帧抓取。
|
||||
*
|
||||
* 生命周期:`Start()` → 若干 `Load()` / `RenderFrame()` → `Stop()`。
|
||||
* ⚠️ `Stop()` 之后**不要**再调用成员函数;进程退出用 `std::_Exit()` 收尾,
|
||||
* 避免单例析构晚于 `GdiplusShutdown`(历史踩过的退出访问违例)。
|
||||
*/
|
||||
class Session
|
||||
{
|
||||
public:
|
||||
/** @brief 创建隐藏窗口并初始化各组件(不显示、不抢焦点)。 */
|
||||
bool Start();
|
||||
|
||||
/** @brief 关闭窗口并释放 GDI+ 相关资源。 */
|
||||
void Stop();
|
||||
|
||||
/** @brief 加载一个数据文件(同步路径,便于断言)。 */
|
||||
bool Load(const std::string& path);
|
||||
|
||||
/** @brief 应用本体引用。 */
|
||||
Application& App() { return *m_app; }
|
||||
|
||||
/** @brief 只读应用本体引用。 */
|
||||
const Application& App() const { return *m_app; }
|
||||
|
||||
/** @brief 强制整帧重绘并抓帧;返回是否成功。 */
|
||||
bool RenderFrame();
|
||||
|
||||
/** @brief 最近一帧。 */
|
||||
const Frame& LastFrame() const { return m_lastFrame; }
|
||||
|
||||
/** @brief 最近一次 `Render()` 的耗时(毫秒)。 */
|
||||
double LastRenderMs() const { return m_lastRenderMs; }
|
||||
|
||||
/** @brief 只显示给定信号(其余全部隐藏),并重算视图。 */
|
||||
void ShowSignals(const std::vector<SignalRef>& refs);
|
||||
|
||||
/** @brief 显示前 `limit` 个信号(跨分组,`limit <= 0` 表示全部)。 */
|
||||
void ShowFirstSignals(int limit);
|
||||
|
||||
/** @brief 全部隐藏。 */
|
||||
void HideAll();
|
||||
|
||||
/** @brief 注入一次鼠标移动(客户区坐标),返回组件是否认为状态有变化。 */
|
||||
bool MouseMove(int x, int y);
|
||||
|
||||
/** @brief 绘图区矩形(客户区坐标)。 */
|
||||
gd::RectF PlotArea() const;
|
||||
|
||||
/** @brief 第 `index` 个网格的波形区域(客户区坐标)。 */
|
||||
gd::RectF LaneRect(int index) const;
|
||||
|
||||
/** @brief 当前网格数。 */
|
||||
int LaneCount() const;
|
||||
|
||||
/** @brief 客户区尺寸。 */
|
||||
void ClientSize(int& width, int& height) const;
|
||||
|
||||
/** @brief 当前加载的路径(窄字符串,仅用于打印)。 */
|
||||
const std::string& LoadedPath() const { return m_loadedPath; }
|
||||
|
||||
private:
|
||||
Application* m_app = nullptr; /**< 进程级单例。 */
|
||||
bool m_started = false; /**< 是否已 `Initialize`。 */
|
||||
Frame m_lastFrame; /**< 最近一帧像素。 */
|
||||
double m_lastRenderMs = 0.0; /**< 最近一帧渲染耗时。 */
|
||||
std::string m_loadedPath; /**< 当前数据文件路径(ASCII 打印用)。 */
|
||||
};
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __OFFSCREENSUPPORT_HPP__
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @file Capture.cpp
|
||||
* @brief 后备缓冲抓帧、区域哈希、PNG 落盘、目录工具。
|
||||
*
|
||||
* 全程不碰窗口 DC:读的是 `Application::BackBufferSurface()` 给的 top-down 32bpp DIB 段,
|
||||
* 因此窗口即使被隐藏(`SW_HIDE`)也能抓到与真实显示一致的像素。
|
||||
*/
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "OffscreenSupport.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/** @brief 取 GDI+ 编码器的 CLSID。 */
|
||||
bool FindEncoderClsid(const wchar_t* mimeType, CLSID& clsid)
|
||||
{
|
||||
UINT count = 0;
|
||||
UINT size = 0;
|
||||
if (Gdiplus::GetImageEncodersSize(&count, &size) != Gdiplus::Ok || size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::vector<std::uint8_t> buffer(size);
|
||||
Gdiplus::ImageCodecInfo* codecs =
|
||||
reinterpret_cast<Gdiplus::ImageCodecInfo*>(buffer.data());
|
||||
if (Gdiplus::GetImageEncoders(count, size, codecs) != Gdiplus::Ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (UINT index = 0; index < count; ++index)
|
||||
{
|
||||
if (wcscmp(codecs[index].MimeType, mimeType) == 0)
|
||||
{
|
||||
clsid = codecs[index].Clsid;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @brief 把 ASCII 路径转宽字符串。 */
|
||||
std::wstring WidenPath(const std::string& path)
|
||||
{
|
||||
const int length = MultiByteToWideChar(CP_ACP, 0, path.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, path.c_str(), -1, &wide[0], length);
|
||||
return wide;
|
||||
}
|
||||
|
||||
/** @brief 夹取区域到帧内,返回 false 表示空区域。 */
|
||||
bool ClampRect(const Frame& frame, const PixelRect& rect, PixelRect& clamped)
|
||||
{
|
||||
const int left = std::max(0, rect.x);
|
||||
const int top = std::max(0, rect.y);
|
||||
const int right = std::min(frame.width, rect.x + rect.width);
|
||||
const int bottom = std::min(frame.height, rect.y + rect.height);
|
||||
if (right <= left || bottom <= top)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
clamped.x = left;
|
||||
clamped.y = top;
|
||||
clamped.width = right - left;
|
||||
clamped.height = bottom - top;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool CaptureFrame(const Application& app, Frame& frame)
|
||||
{
|
||||
const UiSurface surface = app.BackBufferSurface();
|
||||
if (!surface.Valid() || surface.bits == nullptr || surface.stride <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int width = static_cast<int>(surface.rect.Width);
|
||||
const int height = static_cast<int>(surface.rect.Height);
|
||||
const std::size_t bytes = static_cast<std::size_t>(surface.stride) *
|
||||
static_cast<std::size_t>(height);
|
||||
|
||||
frame.width = width;
|
||||
frame.height = height;
|
||||
frame.stride = surface.stride;
|
||||
frame.pixels.resize(bytes);
|
||||
std::memcpy(frame.pixels.data(), surface.bits, bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t HashRegion(const Frame& frame, const PixelRect& rect)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!frame.Valid() || !ClampRect(frame, rect, area))
|
||||
{
|
||||
return 0ull;
|
||||
}
|
||||
std::uint64_t hash = 1469598103934665603ull; // FNV-1a 64 位 offset basis
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
// 只混 BGR:GDI 写 DIB 时不设置 alpha 字节,混进去会让哈希随填充方式漂移。
|
||||
hash ^= line[column * 4 + 0];
|
||||
hash *= 1099511628211ull;
|
||||
hash ^= line[column * 4 + 1];
|
||||
hash *= 1099511628211ull;
|
||||
hash ^= line[column * 4 + 2];
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
int CountInk(const Frame& frame, const PixelRect& rect)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!frame.Valid() || !ClampRect(frame, rect, area))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// 先找到一个"主色"(出现次数最多的颜色 = 背景),再数与之不同的像素。
|
||||
std::unordered_map<std::uint32_t, int> histogram;
|
||||
histogram.reserve(static_cast<std::size_t>(area.width) * 2u);
|
||||
for (int row = 0; row < area.height; row += 2)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; column += 2)
|
||||
{
|
||||
const std::uint32_t color = static_cast<std::uint32_t>(line[column * 4 + 0]) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 2]) << 16);
|
||||
++histogram[color];
|
||||
}
|
||||
}
|
||||
std::uint32_t background = 0;
|
||||
int best = -1;
|
||||
for (const auto& entry : histogram)
|
||||
{
|
||||
if (entry.second > best)
|
||||
{
|
||||
best = entry.second;
|
||||
background = entry.first;
|
||||
}
|
||||
}
|
||||
|
||||
int ink = 0;
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* line = frame.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
const std::uint32_t color = static_cast<std::uint32_t>(line[column * 4 + 0]) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(line[column * 4 + 2]) << 16);
|
||||
if (color != background)
|
||||
{
|
||||
++ink;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ink;
|
||||
}
|
||||
|
||||
int CountDiff(const Frame& left, const Frame& right, const PixelRect& rect, const int tolerance)
|
||||
{
|
||||
PixelRect area;
|
||||
if (!left.Valid() || !right.Valid() || left.width != right.width ||
|
||||
left.height != right.height || !ClampRect(left, rect, area))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
int changed = 0;
|
||||
for (int row = 0; row < area.height; ++row)
|
||||
{
|
||||
const std::uint8_t* a = left.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
const std::uint8_t* b = right.Row(area.y + row) + static_cast<std::size_t>(area.x) * 4u;
|
||||
for (int column = 0; column < area.width; ++column)
|
||||
{
|
||||
const int deltaB = std::abs(static_cast<int>(a[column * 4 + 0]) - b[column * 4 + 0]);
|
||||
const int deltaG = std::abs(static_cast<int>(a[column * 4 + 1]) - b[column * 4 + 1]);
|
||||
const int deltaR = std::abs(static_cast<int>(a[column * 4 + 2]) - b[column * 4 + 2]);
|
||||
if (deltaB > tolerance || deltaG > tolerance || deltaR > tolerance)
|
||||
{
|
||||
++changed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool SavePng(const Frame& frame, const std::string& path)
|
||||
{
|
||||
if (!frame.Valid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::size_t slash = path.find_last_of("/\\");
|
||||
if (slash != std::string::npos)
|
||||
{
|
||||
EnsureDirectory(path.substr(0, slash));
|
||||
}
|
||||
|
||||
Gdiplus::Bitmap bitmap(frame.width, frame.height, frame.stride,
|
||||
PixelFormat32bppRGB,
|
||||
const_cast<BYTE*>(frame.pixels.data()));
|
||||
if (bitmap.GetLastStatus() != Gdiplus::Ok)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
CLSID pngClsid = {};
|
||||
if (!FindEncoderClsid(L"image/png", pngClsid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::wstring widePath = WidenPath(path);
|
||||
const Gdiplus::Status status = bitmap.Save(widePath.c_str(), &pngClsid, nullptr);
|
||||
return status == Gdiplus::Ok;
|
||||
}
|
||||
|
||||
void EnsureDirectory(const std::string& asciiPath)
|
||||
{
|
||||
if (asciiPath.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const std::wstring wide = WidenPath(asciiPath);
|
||||
if (wide.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::wstring prefix;
|
||||
prefix.reserve(wide.size());
|
||||
for (std::size_t index = 0; index < wide.size(); ++index)
|
||||
{
|
||||
prefix.push_back(wide[index]);
|
||||
const bool boundary =
|
||||
(wide[index] == L'/' || wide[index] == L'\\' || index + 1 == wide.size());
|
||||
if (boundary)
|
||||
{
|
||||
if (!prefix.empty() && prefix.back() != L'/' && prefix.back() != L'\\')
|
||||
{
|
||||
CreateDirectoryW(prefix.c_str(), nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveDirectoryTree(const std::string& asciiPath)
|
||||
{
|
||||
const std::wstring wide = WidenPath(asciiPath);
|
||||
if (wide.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 用 SHFileOperation 太重;这里用"先递归删文件再删目录"的最小实现。
|
||||
WIN32_FIND_DATAW data = {};
|
||||
HANDLE handle = FindFirstFileW((wide + L"\\*").c_str(), &data);
|
||||
if (handle != INVALID_HANDLE_VALUE)
|
||||
{
|
||||
do
|
||||
{
|
||||
if (wcscmp(data.cFileName, L".") == 0 || wcscmp(data.cFileName, L"..") == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::wstring child = wide + L"\\" + data.cFileName;
|
||||
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)
|
||||
{
|
||||
const int length = WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, nullptr, 0,
|
||||
nullptr, nullptr);
|
||||
std::string narrow(static_cast<std::size_t>(std::max(0, length - 1)), '\0');
|
||||
if (length > 1)
|
||||
{
|
||||
WideCharToMultiByte(CP_ACP, 0, child.c_str(), -1, &narrow[0], length, nullptr,
|
||||
nullptr);
|
||||
}
|
||||
RemoveDirectoryTree(narrow);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteFileW(child.c_str());
|
||||
}
|
||||
} while (FindNextFileW(handle, &data) != FALSE);
|
||||
FindClose(handle);
|
||||
}
|
||||
RemoveDirectoryW(wide.c_str());
|
||||
}
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,851 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 检查实现 -------------------------------------------------------
|
||||
|
||||
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));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
const SignalData& signal = group->signals[0];
|
||||
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},
|
||||
{"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
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file Main.cpp
|
||||
* @brief 离屏验证工具入口(控制台)。
|
||||
*
|
||||
* 用法:
|
||||
* OffscreenChecks -list
|
||||
* OffscreenChecks -run smoke
|
||||
* OffscreenChecks -all
|
||||
* OffscreenChecks -run panels -mode record 记录面板像素基线到 Temp/tools
|
||||
* OffscreenChecks -run panels -mode compare 与基线比对(重构前后 A/B)
|
||||
* OffscreenChecks -run frames -frames 12 连续抓 12 帧
|
||||
* OffscreenChecks -data "../TestData/BIG_DATA.csv" -all
|
||||
*
|
||||
* 可选:`-out <dir>`(默认 `../Temp/tools`)、`-report <path>`、`-no-png`。
|
||||
*
|
||||
* 退出码:0 = 全部通过;1 = 有失败断言;2 = 参数错误 / 数据加载失败。
|
||||
*
|
||||
* ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不移动真实鼠标。
|
||||
* ⚠️ 临时产物(PNG / 基线 / 报告)统一写 `Temp/`(已被 .gitignore 忽略)。
|
||||
*/
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "CheckRunner.hpp"
|
||||
#include "OffscreenSupport.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
std::FILE* g_report = nullptr;
|
||||
|
||||
/** @brief 窄路径(本地代码页)→ 宽字符串。 */
|
||||
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 判断文件是否存在。 */
|
||||
bool FileExists(const std::string& path)
|
||||
{
|
||||
const std::wstring wide = Widen(path);
|
||||
if (wide.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
HANDLE handle = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (handle == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
CloseHandle(handle);
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
void EmitLine(const char* format, ...)
|
||||
{
|
||||
char buffer[4096];
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments);
|
||||
va_end(arguments);
|
||||
if (length <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::fputs(buffer, stdout);
|
||||
std::fflush(stdout);
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fputs(buffer, g_report);
|
||||
std::fflush(g_report);
|
||||
}
|
||||
}
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
using namespace jdi::tools;
|
||||
|
||||
CheckOptions options;
|
||||
options.outDir = "../Temp/tools";
|
||||
options.mode = "verify";
|
||||
std::string reportPath = "../Temp/tools/offscreen-report.txt";
|
||||
std::string singleCheck;
|
||||
bool runAll = false;
|
||||
bool listOnly = false;
|
||||
|
||||
for (int index = 1; index < argc; ++index)
|
||||
{
|
||||
const char* argument = argv[index];
|
||||
if (std::strcmp(argument, "-list") == 0)
|
||||
{
|
||||
listOnly = true;
|
||||
}
|
||||
else if (std::strcmp(argument, "-run") == 0 && index + 1 < argc)
|
||||
{
|
||||
singleCheck = argv[++index];
|
||||
}
|
||||
else if (std::strcmp(argument, "-all") == 0)
|
||||
{
|
||||
runAll = true;
|
||||
}
|
||||
else if (std::strcmp(argument, "-data") == 0 && index + 1 < argc)
|
||||
{
|
||||
options.dataPath = argv[++index];
|
||||
}
|
||||
else if (std::strcmp(argument, "-out") == 0 && index + 1 < argc)
|
||||
{
|
||||
options.outDir = argv[++index];
|
||||
}
|
||||
else if (std::strcmp(argument, "-report") == 0 && index + 1 < argc)
|
||||
{
|
||||
reportPath = argv[++index];
|
||||
}
|
||||
else if (std::strcmp(argument, "-mode") == 0 && index + 1 < argc)
|
||||
{
|
||||
options.mode = argv[++index];
|
||||
}
|
||||
else if (std::strcmp(argument, "-frames") == 0 && index + 1 < argc)
|
||||
{
|
||||
options.frames = std::atoi(argv[++index]);
|
||||
}
|
||||
else if (std::strcmp(argument, "-no-png") == 0)
|
||||
{
|
||||
options.savePng = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
std::printf("unknown argument: %s\n", argument);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (listOnly)
|
||||
{
|
||||
for (const CheckDescriptor& descriptor : AvailableChecks())
|
||||
{
|
||||
std::printf("%-10s %s\n", descriptor.name, descriptor.summary);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
EnsureDirectory(options.outDir);
|
||||
|
||||
g_report = std::fopen(reportPath.c_str(), "wb");
|
||||
|
||||
EmitLine("==============================================================\n");
|
||||
EmitLine(" Just DataInspector - offscreen checks\n");
|
||||
EmitLine(" mode=%s out=%s report=%s\n", options.mode.c_str(), options.outDir.c_str(), reportPath.c_str());
|
||||
EmitLine("==============================================================\n");
|
||||
|
||||
// 数据文件:命令行优先,否则在 TestData 里挑一个可用的样本
|
||||
if (options.dataPath.empty())
|
||||
{
|
||||
static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "../TestData/[1].CSV",
|
||||
"../TestData/BIG_DATA.csv", "../TestData/Problem_Data.CSV"};
|
||||
for (const char* const candidate : candidates)
|
||||
{
|
||||
if (FileExists(candidate))
|
||||
{
|
||||
options.dataPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.dataPath.empty() || !FileExists(options.dataPath))
|
||||
{
|
||||
EmitLine("[FAIL] no data file: pass -data <path>(TestData/ 不入库,需本地准备)\n");
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
EmitLine(" data=%s\n", options.dataPath.c_str());
|
||||
|
||||
Session session;
|
||||
if (!session.Start())
|
||||
{
|
||||
EmitLine("[FAIL] session start (hidden window creation)\n");
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
if (!session.Load(options.dataPath))
|
||||
{
|
||||
EmitLine("[FAIL] load data file\n");
|
||||
session.Stop();
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
EmitLine(" loaded: groups=%d lanes=%d\n", static_cast<int>(session.App().m_dataSet.GroupCount()),
|
||||
session.LaneCount());
|
||||
|
||||
std::vector<const CheckDescriptor*> selected;
|
||||
if (!singleCheck.empty())
|
||||
{
|
||||
const CheckDescriptor* descriptor = FindCheck(singleCheck);
|
||||
if (descriptor == nullptr)
|
||||
{
|
||||
EmitLine("[FAIL] unknown check: %s (use -list)\n", singleCheck.c_str());
|
||||
session.Stop();
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
selected.push_back(descriptor);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const CheckDescriptor& descriptor : AvailableChecks())
|
||||
{
|
||||
selected.push_back(&descriptor);
|
||||
}
|
||||
(void)runAll;
|
||||
}
|
||||
|
||||
int executed = 0;
|
||||
for (const CheckDescriptor* descriptor : selected)
|
||||
{
|
||||
const int failedBefore = CheckFailed();
|
||||
EmitLine("--------------------------------------------------------------\n");
|
||||
EmitLine("[CHECK] %s - %s\n", descriptor->name, descriptor->summary);
|
||||
descriptor->run(session, options);
|
||||
++executed;
|
||||
const int failed = CheckFailed() - failedBefore;
|
||||
EmitLine("[CHECK] %s => %s\n", descriptor->name, (failed == 0) ? "PASS" : "FAIL");
|
||||
}
|
||||
|
||||
EmitLine("--------------------------------------------------------------\n");
|
||||
EmitLine("SUMMARY checks=%d failed=%d groups=%d result=%s\n", CheckTotal(), CheckFailed(), executed,
|
||||
(CheckFailed() == 0) ? "PASS" : "FAIL");
|
||||
EmitLine("--------------------------------------------------------------\n");
|
||||
|
||||
session.Stop();
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
g_report = nullptr;
|
||||
}
|
||||
|
||||
// ⚠️ 用 `_Exit` 收尾:`Application` / `PlotView` 是进程级单例,析构发生在 `GdiplusShutdown`
|
||||
// 之后会访问违例(历史踩过,退出码 0xC0000005)。
|
||||
const int code = (CheckFailed() == 0) ? 0 : 1;
|
||||
std::_Exit(code);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @file Session.cpp
|
||||
* @brief 离屏会话实现:隐藏窗口、加载数据、逐帧渲染与抓帧。
|
||||
*
|
||||
* ⚠️ 全程不显示窗口、不移动真实鼠标、不抢焦点:所有"鼠标"都是直接调用
|
||||
* `Application::HandleMouseMove()` 注入的合成消息。
|
||||
*/
|
||||
|
||||
#include "OffscreenSupport.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace tools
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/** @brief 窄字符串(本地代码页)→ 宽字符串。 */
|
||||
std::wstring WidenLocal(const std::string& text)
|
||||
{
|
||||
if (text.empty())
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
// 先按 UTF-8 试一次(Agent / 终端最常见),失败再退回本地代码页。
|
||||
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.c_str(), -1, nullptr, 0);
|
||||
UINT codePage = CP_UTF8;
|
||||
if (length <= 1)
|
||||
{
|
||||
codePage = CP_ACP;
|
||||
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(codePage, 0, text.c_str(), -1, &wide[0], length);
|
||||
return wide;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool Session::Start()
|
||||
{
|
||||
if (m_started)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
m_app = &Application::Instance();
|
||||
if (!m_app->Initialize(GetModuleHandleW(nullptr), SW_HIDE))
|
||||
{
|
||||
m_app = nullptr;
|
||||
return false;
|
||||
}
|
||||
m_started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Session::Stop()
|
||||
{
|
||||
if (m_app != nullptr && m_started)
|
||||
{
|
||||
m_app->Shutdown();
|
||||
m_started = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Session::Load(const std::string& path)
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::wstring wide = WidenLocal(path);
|
||||
if (wide.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!m_app->LoadFile(wide))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_loadedPath = path;
|
||||
m_app->SynchronizeView(true);
|
||||
m_app->Relayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Session::RenderFrame()
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 强制整帧:`m_chromeDirty` 为真时标签栏/工具栏/面板/绘图区都会重绘,
|
||||
// 否则 `Render()` 可能走"只重绘绘图区"的快路径,抓到的不是完整画面。
|
||||
m_app->m_chromeDirty = true;
|
||||
|
||||
LARGE_INTEGER frequency = {};
|
||||
LARGE_INTEGER start = {};
|
||||
LARGE_INTEGER stop = {};
|
||||
QueryPerformanceFrequency(&frequency);
|
||||
QueryPerformanceCounter(&start);
|
||||
m_app->Render();
|
||||
QueryPerformanceCounter(&stop);
|
||||
m_lastRenderMs = static_cast<double>(stop.QuadPart - start.QuadPart) * 1000.0 /
|
||||
static_cast<double>(frequency.QuadPart);
|
||||
|
||||
return CaptureFrame(*m_app, m_lastFrame);
|
||||
}
|
||||
|
||||
void Session::ShowSignals(const std::vector<SignalRef>& refs)
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TabConfig& tab = m_app->ActiveTab();
|
||||
tab.SetAllVisible(false);
|
||||
for (const SignalRef& ref : refs)
|
||||
{
|
||||
tab.SetVisible(ref, true);
|
||||
}
|
||||
m_app->SynchronizeView(true);
|
||||
m_app->Relayout();
|
||||
}
|
||||
|
||||
void Session::ShowFirstSignals(const int limit)
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const DataSet& dataSet = m_app->m_dataSet;
|
||||
std::vector<SignalRef> refs;
|
||||
for (int groupIndex = 0; groupIndex < static_cast<int>(dataSet.GroupCount()); ++groupIndex)
|
||||
{
|
||||
const SignalGroup* group = dataSet.GroupAt(groupIndex);
|
||||
if (group == nullptr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (int signalIndex = 0; signalIndex < static_cast<int>(group->signals.size()); ++signalIndex)
|
||||
{
|
||||
if (limit > 0 && static_cast<int>(refs.size()) >= limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
refs.push_back(SignalRef{groupIndex, signalIndex});
|
||||
}
|
||||
if (limit > 0 && static_cast<int>(refs.size()) >= limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
ShowSignals(refs);
|
||||
}
|
||||
|
||||
void Session::HideAll()
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_app->ActiveTab().SetAllVisible(false);
|
||||
m_app->SynchronizeView(true);
|
||||
m_app->Relayout();
|
||||
}
|
||||
|
||||
bool Session::MouseMove(const int x, const int y)
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return m_app->HandleMouseMove(x, y, false);
|
||||
}
|
||||
|
||||
gd::RectF Session::PlotArea() const
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
}
|
||||
return m_app->m_plot.Area();
|
||||
}
|
||||
|
||||
gd::RectF Session::LaneRect(const int index) const
|
||||
{
|
||||
if (m_app == nullptr || index < 0)
|
||||
{
|
||||
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
}
|
||||
const std::vector<PlotView::LaneVisual>& lanes = m_app->m_plot.m_lanes;
|
||||
if (index >= static_cast<int>(lanes.size()))
|
||||
{
|
||||
return gd::RectF(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
}
|
||||
return lanes[static_cast<std::size_t>(index)].rect;
|
||||
}
|
||||
|
||||
int Session::LaneCount() const
|
||||
{
|
||||
if (m_app == nullptr)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int>(m_app->m_plot.m_lanes.size());
|
||||
}
|
||||
|
||||
void Session::ClientSize(int& width, int& height) const
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (m_app == nullptr || m_app->m_hwnd == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
RECT rect = {};
|
||||
GetClientRect(m_app->m_hwnd, &rect);
|
||||
width = rect.right - rect.left;
|
||||
height = rect.bottom - rect.top;
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
// 隐藏窗口在某些 DPI 路径下可能还没有客户区尺寸,退回后备缓冲区尺寸。
|
||||
const Frame& frame = m_lastFrame;
|
||||
width = frame.width;
|
||||
height = frame.height;
|
||||
}
|
||||
}
|
||||
} // namespace tools
|
||||
} // namespace jdi
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# `Tools/` —— 单元测试与离屏验证工具
|
||||
|
||||
> 本目录是 **验证基础设施**,不是产品代码:
|
||||
> 根目录 `Makefile` 只构建应用本体(`Build/Release`、`Build/Debug`),
|
||||
> **不会**编译这里的任何文件;这里的一切由 `Tools/Makefile` 独立构建。
|
||||
>
|
||||
> 设计目标:让"**改完能不能证明没改坏**"变成一条命令的事,
|
||||
> 而不是靠肉眼截图 + 反复手点鼠标。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构
|
||||
|
||||
```text
|
||||
Tools/
|
||||
├─ Makefile # 统一构建入口(构建 / 清理 / 帮助)
|
||||
├─ README.md # 本文件(改动工具后必须同步更新)
|
||||
├─ UnitTests/ # 工具一:纯数据层单元测试(不创建窗口)
|
||||
│ ├─ Inc/
|
||||
│ │ └─ UnitTestFramework.hpp
|
||||
│ ├─ Src/
|
||||
│ │ ├─ Main.cpp # 测试执行器:-list / -only / -report / -verbose
|
||||
│ │ ├─ Utf8UtilTests.cpp # 数值解析与格式化语义
|
||||
│ │ ├─ FormulaEvalTests.cpp# 公式语法 / 语义 / 批量求值一致性
|
||||
│ │ └─ DataModelTests.cpp # 变换顺序、滑动平均、公式值域缓存
|
||||
│ └─ Build/ # 构建产物(.gitignore,不入库)
|
||||
└─ OffscreenChecks/ # 工具二:离屏验证(隐藏窗口 + 抓帧)
|
||||
├─ Inc/
|
||||
│ ├─ OffscreenSupport.hpp# 白盒支撑:抓帧 / 哈希 / 隐藏窗口会话
|
||||
│ └─ CheckRunner.hpp # 检查注册表 + 断言 / 输出接口
|
||||
├─ Src/
|
||||
│ ├─ Main.cpp # 命令行入口
|
||||
│ ├─ Session.cpp # 隐藏窗口、加载、逐帧渲染(合成鼠标消息)
|
||||
│ ├─ Capture.cpp # 后备缓冲抓帧、FNV 哈希、PNG 落盘
|
||||
│ └─ Checks.cpp # 5 个检查的具体实现
|
||||
└─ Build/ # 构建产物(.gitignore,不入库)
|
||||
```
|
||||
|
||||
**约定**
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 头文件 | 只放 `<Tool>/Inc` |
|
||||
| 实现 | 只放 `<Tool>/Src`(该目录下**每个** `.cpp` 都会被编译) |
|
||||
| 产物 | `<Tool>/Build`,与 `Inc` / `Src` **同级** |
|
||||
| 无 Debug/Release 之分 | 工具一律 `-O2 -g`:要跑得快、要能上调试器 |
|
||||
| 临时产物 | PNG / 基线 / 报告一律写 `Temp/tools/`(已 gitignore),**不许**写进 `Tools/` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 构建
|
||||
|
||||
```powershell
|
||||
# 前置:应用本体必须先构建(工具链接的是本体的 .o,排除入口 Main.o)
|
||||
make release -j8
|
||||
|
||||
# 构建全部工具
|
||||
make -C Tools
|
||||
|
||||
# 只构建某一个
|
||||
make -C Tools UnitTests
|
||||
make -C Tools OffscreenChecks
|
||||
|
||||
# 清理(只删两个工具自己的 Build/,不动本体)
|
||||
make -C Tools clean
|
||||
|
||||
# 看帮助
|
||||
make -C Tools help
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
Tools/UnitTests/Build/UnitTests.exe
|
||||
Tools/OffscreenChecks/Build/OffscreenChecks.exe
|
||||
```
|
||||
|
||||
> ⚠️ **必须与本体保持同一套 ABI 开关**:`-fno-exceptions`、`-fno-rtti`、
|
||||
> 链接时 `-flto -flto-partition=one`。工具源码自身**不**加 `-flto`
|
||||
> (否则 GCC 15.2.0 在 `choose_baseaddr` 处 ICE)。
|
||||
>
|
||||
> ⚠️ 头文件依赖用 `-MMD -MP` 跟踪:改头文件、增删源文件都会自动重编重链。
|
||||
> 若出现"离奇的 undefined reference",先 `make -C Tools clean`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 工具一:`UnitTests`(纯逻辑,最快、最常用)
|
||||
|
||||
**不创建窗口、不初始化 GUI**,因此可以无限次在后台运行。
|
||||
|
||||
```powershell
|
||||
Tools\UnitTests\Build\UnitTests.exe # 全部
|
||||
... -only formula # 只跑名字含 formula 的用例
|
||||
... -list # 列出用例
|
||||
... -report ..\Temp\tools\ut.txt # 指定报告路径
|
||||
```
|
||||
|
||||
输出约定(便于脚本 / Agent 解析):
|
||||
|
||||
```text
|
||||
[RUN ] formula_semantics
|
||||
[FAIL] <表达式> (file:line)
|
||||
SUMMARY checks=<n> failed=<m> cases=<k> result=<PASS|FAIL>
|
||||
```
|
||||
|
||||
### 3.1 测试项
|
||||
|
||||
| 用例 | 覆盖内容 | 为什么重要 |
|
||||
| --- | --- | --- |
|
||||
| `parse_fixed_corpus_matches_strtod` | 常见字面量(含 `1e22`、`-0`、`1e-22`)与 `strtod` **逐位相同** | 数值解析的基准线 |
|
||||
| `parse_acceptance_semantics` | 空/空白/垃圾/尾随字符/十六进制浮点/`inf`/`nan` 的接受性 | 与改造前的宽松语义一致 |
|
||||
| `parse_random_corpus_bit_exact` | 6 万条随机十进制串;≤15 位有效数字要求**逐位相同**,≤19 位允许 ≤1 ULP | 「自写 strtod 替身」类优化的唯一验收标准 |
|
||||
| `format_decimal_never_scientific` | `FormatDecimal*` 永不输出科学计数法;`FormatValue` = `%.6g` | 显示层硬约束 |
|
||||
| `format_byte_size_and_frequency` | 单位后缀存在且无指数形式 | 单位格式化回归 |
|
||||
| `text_trim_and_utf8_conversion` | `Trim` / `ToLowerAscii` / `Utf8ToWide` / 大小写无关包含 | 文本基础 |
|
||||
| `ticks_nice_step_and_build_ticks` | `NiceStep` 落在 1/2/2.5/5×10ⁿ;`BuildTicks` 端点 | 标尺可读性 |
|
||||
| `formula_syntax_acceptance` | 合法 / 非法写法(单独 `f`、`2e`、`()`、`*2` …) | 公式输入框的报错边界 |
|
||||
| `formula_semantics` | `2^3^2=512`、`-2^2=-4`、`2^-1`、`e`/`pi`、恒等 | 语法语义 |
|
||||
| `formula_identity_helpers` | `f(t)` ≡ 空串;`HasFormula()`;`NormalizeFormulaText` | 第 32 轮「恒等公式卡死」的护栏 |
|
||||
| `formula_program_matches_pointwise` | 批量求值 vs 逐点求值 **逐位相同**(8 条公式 × 4000 点) | 常量折叠 / 幂次改写不许改数值 |
|
||||
| `formula_invalid_falls_back_safely` | 非法公式原样返回采样值 | 写错公式不得让波形消失 |
|
||||
| `transform_apply_order` | 公式→系数→绝对值→垂直镜像→Y 偏移;NaN 保持;水平镜像只改下标 | 变换顺序 |
|
||||
| `transform_value_range_and_order_preserving` | 区间映射(含跨 0 取绝对值)与保序判定 | Y 轴自适应 |
|
||||
| `filter_moving_average_equivalence` | 1 阶 ≡ 单次、2 阶 ≡ 链式、常数信号不变 | 均值滤波多阶 |
|
||||
| `dataset_group_access` | `AddGroup` / `GroupAt` / `Style(ref)` 读写 | 数据层入口 |
|
||||
| `dataset_formula_transform_cache` | 值域 + 逐分组极值 vs 逐点参考**逐位相同**;命中同指针;样式变化失效 | 第 32 轮核心缓存的正确性 |
|
||||
|
||||
### 3.2 验收标准
|
||||
|
||||
* **零失败**(`result=PASS`)才算通过;
|
||||
* 断言数量只增不减(当前 **218** 项):删断言必须说明理由;
|
||||
* 任何"为了让它过"而放宽的断言,必须在代码注释里写清依据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 工具二:`OffscreenChecks`(真实渲染,隐藏窗口)
|
||||
|
||||
**不显示窗口、不移动真实鼠标、不抢焦点**:窗口 `SW_HIDE` 创建,
|
||||
"鼠标"通过 `Application::HandleMouseMove()` 注入合成消息,
|
||||
像素从**后备缓冲区**读取(不是窗口 DC)。
|
||||
|
||||
```powershell
|
||||
Tools\OffscreenChecks\Build\OffscreenChecks.exe -list
|
||||
... -all # 依次跑全部检查
|
||||
... -run smoke # 单个检查
|
||||
... -run panels -mode record # 记录面板像素基线
|
||||
... -run panels -mode compare # 与基线比对(重构前后 A/B)
|
||||
... -run frames -frames 12 # 连续抓 12 帧
|
||||
... -data "..\TestData\MF4-Record.MF4" -run perf
|
||||
... -out ..\Temp\tools\run1 # 换输出目录(避免覆盖基线)
|
||||
... -no-png # 不落盘 PNG(只算哈希/耗时)
|
||||
```
|
||||
|
||||
数据文件默认按顺序探测 `TestData/` 里的
|
||||
`SCATTER_TEST.csv` → `[1].CSV` → `BIG_DATA.csv` → `Problem_Data.CSV`;
|
||||
`TestData/` 不入库,缺失时请用 `-data` 指定。
|
||||
|
||||
### 4.1 检查项
|
||||
|
||||
| 检查 | 做了什么 | 断言 / 判据 |
|
||||
| --- | --- | --- |
|
||||
| `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) |
|
||||
| `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 |
|
||||
| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** |
|
||||
| `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 |
|
||||
| `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 |
|
||||
|
||||
### 4.2 像素基线的正确用法(A/B)
|
||||
|
||||
```powershell
|
||||
# 1) 改动前:记录基线(同一台机器、同一 DPI、同一数据文件)
|
||||
OffscreenChecks.exe -run panels -mode record -out ..\Temp\tools\panel-before
|
||||
|
||||
# 2) 改代码 + 重新构建本体与工具
|
||||
|
||||
# 3) 改动后:用同一输出目录比对
|
||||
OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before
|
||||
```
|
||||
|
||||
⚠️ **像素哈希是"本机、本 DPI、本数据文件"的基线**,跨机器/跨缩放不可比。
|
||||
它适合"我改了一处,其它像素有没有动"的**本地 A/B**,不适合入库当黄金图。
|
||||
|
||||
### 4.3 输出(全部在 `Temp/tools/`,已 gitignore)
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `offscreen-report.txt` | 完整控制台输出(含 `SUMMARY` 行) |
|
||||
| `UnitTests-report.txt` | 单元测试报告 |
|
||||
| `smoke.png` / `panels.png` / `perf.png` | 关键状态截图 |
|
||||
| `frame-000.png` … | 连续帧(问题分析用) |
|
||||
| `panels-baseline.txt` | 面板像素基线(`-mode record` 生成) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 怎么用它(按改动类型选最小集)
|
||||
|
||||
| 你改了什么 | 建议跑 |
|
||||
| --- | --- |
|
||||
| 任何数据层 / 解析 / 公式 / 变换 | `UnitTests.exe` |
|
||||
| 绘图、面板、布局、缓存键 | `-run smoke -run panels`(+ 需要时 `-mode compare`) |
|
||||
| 边沿统计 / 中心轴 / 扫描预算 | `-run edgestats -run perf` |
|
||||
| 性能相关 | `-run perf`(前后各跑一次,比 `us/query` 与 `median ms`) |
|
||||
| 完全不确定影响面 | `-run -all`(约几秒到几十秒)+ `UnitTests.exe` |
|
||||
|
||||
> 用户明确要求:**除非要求"全测一遍",否则只跑针对性检查**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 新增一个检查 / 一个工具
|
||||
|
||||
### 6.1 新增离屏检查
|
||||
|
||||
1. 在 `OffscreenChecks/Src/Checks.cpp` 里写 `int CheckXxx(Session&, const CheckOptions&)`,
|
||||
用 `Expect()` / `ExpectEqual()` 记录断言,末尾 `return CheckFailed();`
|
||||
2. 在 `AvailableChecks()` 的列表里追加一行 `{ "xxx", "one-line summary", CheckXxx }`
|
||||
3. 在 README 的 §4.1 表格里补一行(**必须**)
|
||||
|
||||
### 6.2 新增工具
|
||||
|
||||
1. 建 `Tools/<NewTool>/{Inc,Src}`
|
||||
2. 在 `Tools/Makefile` 的 `TOOLS` 里追加 `<NewTool>`(目录名 = 目标名)
|
||||
3. 在本 README 的 §1 / §2 / §5 里补说明
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知限制与坑
|
||||
|
||||
* **工具是白盒**:`OffscreenSupport.hpp` 用 `#define private public` 放宽访问级别,
|
||||
为了读后备缓冲区 / 直接调面板几何。产品接口不因测试而放宽。
|
||||
* **必须先构建本体**:工具链接 `../Build/Release/*.o`,本体没构建会给出明确警告。
|
||||
* **像素基线不可跨机复现**:DPI、字体、主题不同则哈希不同。
|
||||
* **`frames` 里个别帧可能重复**(鼠标落在筛选框/空白带),因此判据是"至少一次可见变化"。
|
||||
* **窗口一律 `SW_HIDE`**:不要为了"看看效果"改成显示窗口,那会占用用户屏幕。
|
||||
需要看图请直接打开 `Temp/tools/*.png`。
|
||||
* **进程退出用 `std::_Exit()`**:`Application` / `PlotView` 是单例,
|
||||
若走正常析构会在 `GdiplusShutdown` 之后访问 GDI+ 对象(历史退出码 `0xC0000005`)。
|
||||
* **报告/截图不要提交**:全部落在 `Temp/`(已忽略)。`Tools/*/Build/` 也已忽略,
|
||||
其它协作者本地 `make -C Tools` 即可重建。
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @file UnitTestFramework.hpp
|
||||
* @brief 极简单元测试框架(无异常、无 RTTI,与项目本体 ABI 一致)。
|
||||
*
|
||||
* 设计取舍:
|
||||
* * **不引入第三方框架**(Catch2 / gtest 会带来几 MB 的静态库与异常依赖);
|
||||
* * 项目本体用 `-fno-exceptions` 构建,因此断言失败只能"记录 + 继续",
|
||||
* 不允许 `throw`;
|
||||
* * 每个 `.cpp` 用 `JDI_TEST(名字)` 注册用例,`Main.cpp` 按注册顺序执行。
|
||||
*
|
||||
* 输出约定(供 CI / Agent 读取):
|
||||
* * 逐用例一行 `[RUN ] <name>`,失败时一行 `[FAIL] <expr> (file:line)`;
|
||||
* * 结尾一行 `SUMMARY checks=<n> failed=<m> result=<PASS|FAIL>`。
|
||||
* ⚠️ 所有输出保持 **ASCII**:控制台代码页为 GBK,中文只在注释里出现。
|
||||
*/
|
||||
|
||||
#ifndef __UNITTESTFRAMEWORK_HPP__
|
||||
#define __UNITTESTFRAMEWORK_HPP__
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
namespace test
|
||||
{
|
||||
/** @brief 一个注册在案的测试用例。 */
|
||||
struct TestCase
|
||||
{
|
||||
const char* name = ""; /**< 用例名(打印用,建议 `模块.行为`)。 */
|
||||
void (*function)() = nullptr; /**< 用例体。 */
|
||||
};
|
||||
|
||||
/** @brief 全局用例表。 */
|
||||
inline std::vector<TestCase>& Registry()
|
||||
{
|
||||
static std::vector<TestCase> registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** @brief 断言总数。 */
|
||||
inline int& TotalChecks()
|
||||
{
|
||||
static int value = 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @brief 失败断言数。 */
|
||||
inline int& FailedChecks()
|
||||
{
|
||||
static int value = 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @brief 当前用例是否已失败(用于给出汇总提示)。 */
|
||||
inline bool& CaseFailed()
|
||||
{
|
||||
static bool value = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @brief 注册用例(由 `JDI_TEST` 宏在静态初始化期调用)。 */
|
||||
inline bool Register(const char* name, void (*function)())
|
||||
{
|
||||
Registry().push_back(TestCase{name, function});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @brief 记录一次布尔断言。 */
|
||||
inline void Check(bool ok, const char* expression, const char* file, int line)
|
||||
{
|
||||
++TotalChecks();
|
||||
if (!ok)
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s (%s:%d)\n", expression, file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 记录一次"带说明"的布尔断言(说明只用于失败时定位)。 */
|
||||
inline void CheckWithNote(bool ok, const char* note, const char* file, int line)
|
||||
{
|
||||
++TotalChecks();
|
||||
if (!ok)
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s (%s:%d)\n", note, file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 记录一次整数相等断言(失败时打印实际值/期望值)。 */
|
||||
inline void CheckEqual(const long long actual, const long long expected, const char* note,
|
||||
const char* file, int line)
|
||||
{
|
||||
++TotalChecks();
|
||||
if (actual != expected)
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s: actual=%lld expected=%lld (%s:%d)\n", note, actual, expected,
|
||||
file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 记录一次浮点近似断言。 */
|
||||
inline void CheckNear(const double actual, const double expected, const double tolerance,
|
||||
const char* note, const char* file, int line)
|
||||
{
|
||||
const bool bothNaN = (actual != actual) && (expected != expected);
|
||||
const double delta = (actual > expected) ? (actual - expected) : (expected - actual);
|
||||
++TotalChecks();
|
||||
if (!bothNaN && !(delta <= tolerance))
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s: actual=%.17g expected=%.17g tolerance=%.3g (%s:%d)\n", note,
|
||||
actual, expected, tolerance, file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 记录一次"逐位相同"断言(用于数值语义回归)。 */
|
||||
inline void CheckBitEqual(const double actual, const double expected, const char* note,
|
||||
const char* file, int line)
|
||||
{
|
||||
std::uint64_t a = 0;
|
||||
std::uint64_t b = 0;
|
||||
static_assert(sizeof(a) == sizeof(actual), "double must be 8 bytes");
|
||||
__builtin_memcpy(&a, &actual, sizeof(a));
|
||||
__builtin_memcpy(&b, &expected, sizeof(b));
|
||||
++TotalChecks();
|
||||
if (a != b)
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s: actual=%.17g (0x%016llX) expected=%.17g (0x%016llX) (%s:%d)\n",
|
||||
note, actual, static_cast<unsigned long long>(a), expected,
|
||||
static_cast<unsigned long long>(b), file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 记录一次宽字符串相等断言。 */
|
||||
inline void CheckWideEqual(const std::wstring& actual, const std::wstring& expected,
|
||||
const char* note, const char* file, int line)
|
||||
{
|
||||
++TotalChecks();
|
||||
if (actual != expected)
|
||||
{
|
||||
++FailedChecks();
|
||||
CaseFailed() = true;
|
||||
std::printf(" [FAIL] %s: actual=\"%ls\" expected=\"%ls\" (%s:%d)\n", note, actual.c_str(),
|
||||
expected.c_str(), file, line);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
} // namespace test
|
||||
} // namespace jdi
|
||||
|
||||
/** @brief 定义一个测试用例(自动注册)。 */
|
||||
#define JDI_TEST(case_name) \
|
||||
static void case_name(); \
|
||||
[[maybe_unused]] static const bool case_name##_registered_ = \
|
||||
::jdi::test::Register(#case_name, &case_name); \
|
||||
static void case_name()
|
||||
|
||||
/** @brief 布尔断言。 */
|
||||
#define JDI_CHECK(expression) ::jdi::test::Check((expression), #expression, __FILE__, __LINE__)
|
||||
|
||||
/** @brief 带说明的布尔断言(说明为裸字符串)。 */
|
||||
#define JDI_REQUIRE(note, expression) \
|
||||
::jdi::test::CheckWithNote((expression), (note), __FILE__, __LINE__)
|
||||
|
||||
/** @brief 整数相等断言。 */
|
||||
#define JDI_CHECK_EQ(actual, expected) \
|
||||
::jdi::test::CheckEqual(static_cast<long long>(actual), static_cast<long long>(expected), \
|
||||
#actual " == " #expected, __FILE__, __LINE__)
|
||||
|
||||
/** @brief 浮点近似断言。 */
|
||||
#define JDI_CHECK_NEAR(actual, expected, tolerance) \
|
||||
::jdi::test::CheckNear((actual), (expected), (tolerance), #actual " ~ " #expected, __FILE__, __LINE__)
|
||||
|
||||
/** @brief 浮点逐位相等断言(数值语义回归专用)。 */
|
||||
#define JDI_CHECK_BIT_EQUAL(actual, expected) \
|
||||
::jdi::test::CheckBitEqual((actual), (expected), #actual " bit== " #expected, __FILE__, __LINE__)
|
||||
|
||||
/** @brief 宽字符串相等断言。 */
|
||||
#define JDI_CHECK_WIDE_EQ(actual, expected) \
|
||||
::jdi::test::CheckWideEqual((actual), (expected), #actual " == " #expected, __FILE__, __LINE__)
|
||||
|
||||
#endif // __UNITTESTFRAMEWORK_HPP__
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file FormulaEvalTests.cpp
|
||||
* @brief `Utilities/FormulaEval` 的语法 / 语义回归。
|
||||
*
|
||||
* 重点用例:
|
||||
* * `program.matches_pointwise` —— 批量求值(`FormulaProgram`)与逐点求值
|
||||
* (`EvaluateFormula`)必须**逐位相同**。第 32 轮的性能优化(常量折叠、幂次改写、
|
||||
* `always_inline`)只允许改速度,任何一位差异都是回归。
|
||||
* * 语法边界:`^` 右结合、`-2^2 = -4`、单独的 `f` 非法、`2e` 非法。
|
||||
*/
|
||||
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "DataModel.hpp"
|
||||
#include "FormulaEval.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;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
JDI_TEST(formula_syntax_acceptance)
|
||||
{
|
||||
std::wstring error;
|
||||
|
||||
// 合法写法
|
||||
static const wchar_t* const valid[] = {
|
||||
L"f(t)", L"f(t)*2+1", L"f(t)^2", L"-f(t)", L"e*pi", L"2^-1", L"X", L"x",
|
||||
L"(f(t)-1)/(f(t)+1)", L"2*pi*f(t)",
|
||||
};
|
||||
for (const wchar_t* const text : valid)
|
||||
{
|
||||
JDI_REQUIRE("valid formula", jdi::ValidateFormula(text, &error));
|
||||
}
|
||||
|
||||
// 非法写法
|
||||
static const wchar_t* const invalid[] = {
|
||||
L"f", L"f(", L"f(t)+", L"f(t)*", L"()", L"2e", L"f(t) ** 2", L"*2",
|
||||
};
|
||||
for (const wchar_t* const text : invalid)
|
||||
{
|
||||
const bool accepted = jdi::ValidateFormula(text, &error);
|
||||
if (accepted)
|
||||
{
|
||||
std::printf(" [INFO] unexpectedly accepted: \"%ls\"\n", text);
|
||||
}
|
||||
JDI_REQUIRE("invalid formula must be rejected", !accepted);
|
||||
}
|
||||
|
||||
// 空串 = 不映射(合法)
|
||||
JDI_CHECK(jdi::ValidateFormula(L"", &error));
|
||||
}
|
||||
|
||||
JDI_TEST(formula_semantics)
|
||||
{
|
||||
// `^` 右结合:2^3^2 = 2^(3^2) = 512
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"2^3^2", 0.0), 512.0, 1e-9);
|
||||
// 一元负号优先于幂:-2^2 = -(2^2) = -4
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"-2^2", 0.0), -4.0, 1e-12);
|
||||
// 指数的负号:2^-1 = 0.5
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"2^-1", 0.0), 0.5, 1e-12);
|
||||
// 常量 e / pi(大小写均可)
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"e", 0.0), 2.718281828459045, 1e-15);
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"pi", 0.0), 3.141592653589793, 1e-15);
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"PI", 0.0), 3.141592653589793, 1e-15);
|
||||
// 恒等写法
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)", 3.25), 3.25, 0.0);
|
||||
|
||||
// 真实使用过的表达式(第 32 轮用户输入):f(t)*2/1.25+14.2,f(2) = 3.2 + 14.2
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)*2/1.25+14.2", 2.0), 17.4, 1e-12);
|
||||
|
||||
// 幂次改写的等价性(实现会把 x^2/x^3/x^0.5 换成乘法/sqrt)
|
||||
static const double points[] = {-3.5, -1.0, -0.25, 0.0, 0.5, 1.0, 2.0, 7.25, 1.0e6};
|
||||
for (const double point : points)
|
||||
{
|
||||
JDI_CHECK_BIT_EQUAL(jdi::EvaluateFormula(L"f(t)^2", point), point * point);
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)^0.5", point < 0.0 ? -point : point),
|
||||
std::sqrt(point < 0.0 ? -point : point), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
JDI_TEST(formula_identity_helpers)
|
||||
{
|
||||
// `f(t)` 与空串等价(第 32 轮硬规则:点进输入框再点出去不得写入"恒等公式")
|
||||
JDI_CHECK(jdi::FormulaIsIdentity(L""));
|
||||
JDI_CHECK(jdi::FormulaIsIdentity(L"f(t)"));
|
||||
JDI_CHECK(!jdi::FormulaIsIdentity(L"f(t)*2"));
|
||||
|
||||
jdi::SignalStyle plain;
|
||||
JDI_CHECK(!plain.HasFormula());
|
||||
plain.formula = L"f(t)";
|
||||
JDI_CHECK(!plain.HasFormula());
|
||||
plain.formula = L"f(t)*2";
|
||||
JDI_CHECK(plain.HasFormula());
|
||||
|
||||
// 归一化:`X` / `F(T)` 统一写成 `f(t)`;单独的 `f` **不**归一化(非法写法,交给校验报错)
|
||||
JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"X"), std::wstring(L"f(t)"));
|
||||
JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"F(T)"), std::wstring(L"f(t)"));
|
||||
JDI_CHECK_WIDE_EQ(jdi::NormalizeFormulaText(L"f"), std::wstring(L"f"));
|
||||
}
|
||||
|
||||
JDI_TEST(formula_program_matches_pointwise)
|
||||
{
|
||||
static const wchar_t* const formulas[] = {
|
||||
L"f(t)*2/1.25+14.2", L"f(t)^2", L"f(t)^3", L"(f(t)-1)/(f(t)+1)",
|
||||
L"2*pi*f(t)+e", L"-f(t)*0.5", L"f(t)^0.5", L"f(t)-2",
|
||||
};
|
||||
|
||||
std::mt19937 rng(20261105u);
|
||||
std::uniform_real_distribution<double> distribution(-500.0, 500.0);
|
||||
|
||||
for (const wchar_t* const formula : formulas)
|
||||
{
|
||||
jdi::FormulaProgram program(formula);
|
||||
JDI_REQUIRE("program must compile", program.Valid());
|
||||
for (int iteration = 0; iteration < 4000; ++iteration)
|
||||
{
|
||||
const double x = distribution(rng);
|
||||
const double pointwise = jdi::EvaluateFormula(formula, x);
|
||||
const double batched = program.Evaluate(x);
|
||||
if (BitsOf(pointwise) != BitsOf(batched))
|
||||
{
|
||||
std::printf(" [INFO] %ls x=%.17g pointwise=%.17g batched=%.17g\n", formula, x, pointwise,
|
||||
batched);
|
||||
JDI_CHECK_BIT_EQUAL(batched, pointwise);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JDI_TEST(formula_invalid_falls_back_safely)
|
||||
{
|
||||
// 非法表达式:求值必须**原样返回采样值**(波形照常显示,不会因为写错公式变空)
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)@@@@", 1.5), 1.5, 0.0);
|
||||
JDI_CHECK_NEAR(jdi::EvaluateFormula(L"f(t)@@@@", -2.25), -2.25, 0.0);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file Main.cpp
|
||||
* @brief 单元测试执行器(控制台程序,无窗口、无 GUI 初始化)。
|
||||
*
|
||||
* 命令行(全部可选):
|
||||
* -list 只列出用例名,不执行
|
||||
* -only <substr> 只执行名字包含 <substr> 的用例
|
||||
* -report <path> 报告文件路径(默认 `../Temp/tools/UnitTests-report.txt`)
|
||||
* -verbose 打印每个用例的开始/结束
|
||||
*
|
||||
* 退出码:0 = 全部通过;1 = 有失败;2 = 参数错误 / 没有任何用例。
|
||||
*
|
||||
* ⚠️ 本工具**不创建窗口、不初始化 GUI、不读用户输入**,可在后台任意次运行。
|
||||
* ⚠️ 报告与截图类临时产物一律写到 `Temp/`(已被 .gitignore 忽略),不要写进源码目录。
|
||||
*/
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <windows.h>
|
||||
|
||||
#include "UnitTestFramework.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
std::FILE* g_report = nullptr;
|
||||
bool g_verbose = false;
|
||||
|
||||
/** @brief 同时输出到标准输出与报告文件。 */
|
||||
void Emit(const char* format, ...)
|
||||
{
|
||||
char buffer[4096];
|
||||
va_list arguments;
|
||||
va_start(arguments, format);
|
||||
const int length = std::vsnprintf(buffer, sizeof(buffer), format, arguments);
|
||||
va_end(arguments);
|
||||
|
||||
if (length > 0)
|
||||
{
|
||||
std::fputs(buffer, stdout);
|
||||
std::fflush(stdout);
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fputs(buffer, g_report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 递归创建目录(仅支持 ASCII 路径;失败静默忽略)。 */
|
||||
void EnsureDirectory(const std::wstring& path)
|
||||
{
|
||||
if (path.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (std::size_t index = 1; index <= path.size(); ++index)
|
||||
{
|
||||
if (index == path.size() || path[index] == L'/' || path[index] == L'\\')
|
||||
{
|
||||
const std::wstring prefix = path.substr(0, index);
|
||||
CreateDirectoryW(prefix.c_str(), nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 从 UTF-8 窄字符串转换为宽字符串(只用于路径)。 */
|
||||
std::wstring Widen(const char* text)
|
||||
{
|
||||
const int length = MultiByteToWideChar(CP_ACP, 0, text, -1, nullptr, 0);
|
||||
if (length <= 1)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
std::wstring result(static_cast<std::size_t>(length - 1), L'\0');
|
||||
MultiByteToWideChar(CP_ACP, 0, text, -1, &result[0], length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @brief 打开报告文件(顺带创建 `Temp/tools`)。 */
|
||||
std::FILE* OpenReport(const char* path)
|
||||
{
|
||||
std::string wide = path; // 仅 ASCII,用于切分目录
|
||||
const std::size_t slash = wide.find_last_of("/\\");
|
||||
if (slash != std::string::npos)
|
||||
{
|
||||
EnsureDirectory(Widen(wide.substr(0, slash).c_str()));
|
||||
}
|
||||
return std::fopen(path, "wb");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const char* only = nullptr;
|
||||
const char* reportPath = "../Temp/tools/UnitTests-report.txt";
|
||||
|
||||
for (int index = 1; index < argc; ++index)
|
||||
{
|
||||
const char* argument = argv[index];
|
||||
if (std::strcmp(argument, "-list") == 0)
|
||||
{
|
||||
for (const jdi::test::TestCase& testCase : jdi::test::Registry())
|
||||
{
|
||||
std::printf("%s\n", testCase.name);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (std::strcmp(argument, "-only") == 0 && index + 1 < argc)
|
||||
{
|
||||
only = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(argument, "-report") == 0 && index + 1 < argc)
|
||||
{
|
||||
reportPath = argv[++index];
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(argument, "-verbose") == 0)
|
||||
{
|
||||
g_verbose = true;
|
||||
continue;
|
||||
}
|
||||
std::printf("unknown argument: %s\n", argument);
|
||||
return 2;
|
||||
}
|
||||
|
||||
g_report = OpenReport(reportPath);
|
||||
|
||||
Emit("==============================================================\n");
|
||||
Emit(" Just DataInspector - unit tests\n");
|
||||
Emit(" report: %s\n", reportPath);
|
||||
Emit("==============================================================\n");
|
||||
|
||||
int executed = 0;
|
||||
for (const jdi::test::TestCase& testCase : jdi::test::Registry())
|
||||
{
|
||||
if (only != nullptr && std::strstr(testCase.name, only) == nullptr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
++executed;
|
||||
const int failedBefore = jdi::test::FailedChecks();
|
||||
jdi::test::CaseFailed() = false;
|
||||
Emit("[RUN ] %s\n", testCase.name);
|
||||
testCase.function();
|
||||
const int failed = jdi::test::FailedChecks() - failedBefore;
|
||||
if (failed == 0)
|
||||
{
|
||||
if (g_verbose)
|
||||
{
|
||||
Emit("[ OK ] %s\n", testCase.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Emit("--------------------------------------------------------------\n");
|
||||
Emit("SUMMARY checks=%d failed=%d cases=%d result=%s\n", jdi::test::TotalChecks(),
|
||||
jdi::test::FailedChecks(), executed,
|
||||
(jdi::test::FailedChecks() == 0 && executed > 0) ? "PASS" : "FAIL");
|
||||
Emit("--------------------------------------------------------------\n");
|
||||
|
||||
if (g_report != nullptr)
|
||||
{
|
||||
std::fclose(g_report);
|
||||
g_report = nullptr;
|
||||
}
|
||||
|
||||
if (executed == 0)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
return (jdi::test::FailedChecks() == 0) ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user