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
@@ -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
|
||||
Reference in new issue
Block a user