/** * @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 `(默认 `../Temp/tools`)、`-report `、`-no-png`。 * * 退出码:0 = 全部通过;1 = 有失败断言;2 = 参数错误 / 数据加载失败。 * * ⚠️ 窗口一律 `SW_HIDE`:不显示、不抢焦点、不移动真实鼠标。 * ⚠️ 临时产物(PNG / 基线 / 报告)统一写 `Temp/`(已被 .gitignore 忽略)。 */ #include #include #include #include #include #include #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(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); // 报告路径同样要能在两种工作目录下工作(见上面 `-data` 的说明)。 g_report = std::fopen(reportPath.c_str(), "wb"); if (g_report == nullptr && reportPath.rfind("../Temp/", 0) == 0) { reportPath = reportPath.substr(3); // 去掉 "../" 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 里挑一个可用的样本。 // ⚠️ 默认路径同时支持「在 Tools/ 下运行」与「在仓库根目录运行」两种习惯: // 前者要 `../TestData/...`,后者要 `TestData/...`。只认一种会让另一种 // 静默报 "no data file"(本轮就踩过,容易被误认为工具坏了)。 if (options.dataPath.empty()) { static const char* const candidates[] = {"../TestData/SCATTER_TEST.csv", "TestData/SCATTER_TEST.csv", "../TestData/[1].CSV", "TestData/[1].CSV", "../TestData/BIG_DATA.csv", "TestData/BIG_DATA.csv", "../TestData/Problem_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 (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(session.App().m_dataSet.GroupCount()), session.LaneCount()); std::vector 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); }