mirror of
https://github.com/johndoe6345789/SDL3CPlusPlus.git
synced 2026-04-24 13:44:58 +00:00
ROADMAP.md
This commit is contained in:
@@ -279,6 +279,7 @@ if(BUILD_SDL3_APP)
|
||||
src/di/service_registry.cpp
|
||||
src/events/event_bus.cpp
|
||||
src/services/impl/json_config_service.cpp
|
||||
src/services/impl/json_config_document_loader.cpp
|
||||
src/services/impl/config_compiler_service.cpp
|
||||
src/services/impl/command_line_service.cpp
|
||||
src/services/impl/json_config_writer_service.cpp
|
||||
@@ -545,6 +546,7 @@ add_executable(vulkan_shader_linking_test
|
||||
src/services/impl/bgfx_gui_service.cpp
|
||||
src/services/impl/bgfx_shader_compiler.cpp
|
||||
src/services/impl/json_config_service.cpp
|
||||
src/services/impl/json_config_document_loader.cpp
|
||||
src/services/impl/materialx_shader_generator.cpp
|
||||
src/services/impl/shader_pipeline_validator.cpp
|
||||
src/services/impl/platform_service.cpp
|
||||
@@ -733,6 +735,7 @@ add_test(NAME render_graph_service_test COMMAND render_graph_service_test)
|
||||
add_executable(json_config_merge_test
|
||||
tests/json_config_merge_test.cpp
|
||||
src/services/impl/json_config_service.cpp
|
||||
src/services/impl/json_config_document_loader.cpp
|
||||
)
|
||||
target_include_directories(json_config_merge_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
target_link_libraries(json_config_merge_test PRIVATE
|
||||
@@ -746,6 +749,7 @@ add_test(NAME json_config_merge_test COMMAND json_config_merge_test)
|
||||
add_executable(json_config_schema_validation_test
|
||||
tests/json_config_schema_validation_test.cpp
|
||||
src/services/impl/json_config_service.cpp
|
||||
src/services/impl/json_config_document_loader.cpp
|
||||
)
|
||||
target_include_directories(json_config_schema_validation_test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
|
||||
target_link_libraries(json_config_schema_validation_test PRIVATE
|
||||
|
||||
51
ROADMAP.md
51
ROADMAP.md
@@ -59,6 +59,7 @@ Treat JSON config as a declarative control plane that compiles into scene, resou
|
||||
- Add runtime probe hooks and map probe severity to crash recovery policies.
|
||||
- Enforce shader uniform compatibility using reflection + material metadata.
|
||||
- Add tests for schema/merge rules, render graph validation, and budget enforcement.
|
||||
- Start service refactor program for large modules (approaching 2K LOC).
|
||||
|
||||
## Config-First Program Plan (Verbose)
|
||||
|
||||
@@ -143,6 +144,54 @@ Treat JSON config as a declarative control plane that compiles into scene, resou
|
||||
- Deliverable: regression protection for the new pipeline.
|
||||
- Acceptance: new tests pass alongside existing integration tests.
|
||||
|
||||
## Service Refactor Program (2K LOC Risk Plan)
|
||||
### Goals
|
||||
- Reduce single-file service sizes to improve readability, reviewability, and test coverage.
|
||||
- Isolate responsibilities: parsing vs validation vs runtime state vs external I/O.
|
||||
- Make failure modes explicit and easier to diagnose with trace probes.
|
||||
|
||||
### Target Services (Top Of List)
|
||||
- JsonConfigService (~1800 LOC): split into loader/merger/validator/parser modules.
|
||||
- ScriptEngineService (~1650 LOC): split Lua binding registry, library setup, and script loading.
|
||||
- BgfxGraphicsBackend (~1400 LOC): split pipeline/buffer/texture/screenshot/validation submodules.
|
||||
- BgfxGuiService (~1100 LOC): split font cache, SVG cache, command encoding, and layout.
|
||||
- MaterialXShaderGenerator (~1100 LOC): split MaterialX graph prep, shader emit, validation.
|
||||
|
||||
### Phase A: Mechanical Extraction (1-3 days)
|
||||
- [~] JsonConfigService: extracted config document load/merge helpers into `src/services/impl/json_config_document_loader.cpp` to isolate parsing + extends/@delete merging.
|
||||
- Move self-contained helpers into `*_helpers.cpp` with clear headers.
|
||||
- Extract pure data transforms into free functions with unit tests.
|
||||
- Preserve public interfaces; no behavior change in this phase.
|
||||
|
||||
### Phase B: Responsibility Split (2-5 days)
|
||||
- Create focused classes (e.g., `ConfigSchemaValidator`, `ConfigMergeService`,
|
||||
`LuaBindingRegistry`, `BgfxPipelineCache`, `TextureLoader`, `GuiFontCache`).
|
||||
- Reduce cross-module knowledge by passing simple data structs.
|
||||
- Add trace logging at handoff boundaries to retain diagnostics.
|
||||
|
||||
### Phase C: API Stabilization (2-4 days)
|
||||
- Tighten constructor injection to only needed dependencies.
|
||||
- Remove circular dependencies; make order-of-operations explicit.
|
||||
- Add targeted unit tests for each new helper/service.
|
||||
|
||||
### Acceptance Criteria
|
||||
- Each refactored service has < 800 LOC in its primary implementation file.
|
||||
- 1–3 unit tests per extracted module (minimum happy + failure path).
|
||||
- No regression in existing integration tests or runtime logs.
|
||||
|
||||
## Validation Tour (Production Self-Test)
|
||||
### Multi-Method Screen Validation
|
||||
- Image compare (baseline diff with tolerance + max diff pixels).
|
||||
- Non-black ratio checks (detect all-black or missing render output).
|
||||
- Luma range checks (detect over/under-exposed frames).
|
||||
- Mean color checks (verify dominant color scenes without exact baselines).
|
||||
- Sample point checks (pinpoint color at specific normalized coordinates).
|
||||
|
||||
### Engine Tester Config
|
||||
- `config/engine_tester_runtime.json` provides a default self-test config.
|
||||
- Designed for production binaries; no golden image required by default.
|
||||
- Produces capture artifacts in `artifacts/validation/`.
|
||||
|
||||
### Default Config Behavior (Config-First)
|
||||
- Default config resolution remains `--json-file-in` → `--set-default-json` path → stored default config → seed config.
|
||||
- Config-first is the default runtime path after the config is loaded.
|
||||
@@ -215,6 +264,7 @@ Option B: per-shader only
|
||||
- [x] Pipeline compatibility tests (shader inputs vs mesh layouts)
|
||||
- [x] Crash recovery timeout tests (`tests/crash_recovery_timeout_test.cpp`)
|
||||
- [~] Budget enforcement tests (GUI cache pruning + texture tracker covered; transient pool pending)
|
||||
- [~] Config-driven validation tour (checkpoint captures + image/ratio/luma/sample-point checks)
|
||||
- [ ] Smoke test: cube demo boots with config-first scene definition
|
||||
|
||||
## Test Strategy (Solid Coverage Plan)
|
||||
@@ -228,6 +278,7 @@ Option B: per-shader only
|
||||
- Service: render graph validation (cycles, unknown refs, duplicates), shader pipeline validation, budget enforcement.
|
||||
- Integration: shader compilation, MaterialX generation + validation, crash recovery timeouts.
|
||||
- Smoke: config-first boot of the cube demo with no Lua scene execution.
|
||||
- Runtime: validation tour checkpoints for production self-test.
|
||||
|
||||
### Coverage Matrix (What We Must Prove)
|
||||
- Config parsing + schema errors produce JSON Pointer diagnostics.
|
||||
|
||||
56
config/engine_tester_runtime.json
Normal file
56
config/engine_tester_runtime.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"configVersion": 2,
|
||||
"scripts": {
|
||||
"entry": "scripts/cube_logic.lua",
|
||||
"lua_debug": false
|
||||
},
|
||||
"runtime": {
|
||||
"scene_source": "lua"
|
||||
},
|
||||
"window": {
|
||||
"title": "SDL3 Engine Tester",
|
||||
"size": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
},
|
||||
"rendering": {
|
||||
"bgfx": {
|
||||
"renderer": "auto"
|
||||
}
|
||||
},
|
||||
"validation_tour": {
|
||||
"enabled": true,
|
||||
"fail_on_mismatch": true,
|
||||
"warmup_frames": 3,
|
||||
"capture_frames": 1,
|
||||
"output_dir": "artifacts/validation",
|
||||
"checkpoints": [
|
||||
{
|
||||
"id": "startup_spawn",
|
||||
"camera": {
|
||||
"position": [0.0, 2.0, 5.0],
|
||||
"look_at": [0.0, 1.0, 0.0],
|
||||
"fov_degrees": 60.0,
|
||||
"near": 0.1,
|
||||
"far": 100.0
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"type": "non_black_ratio",
|
||||
"threshold": 0.02,
|
||||
"min_ratio": 0.01,
|
||||
"max_ratio": 1.0
|
||||
},
|
||||
{
|
||||
"type": "luma_range",
|
||||
"min_luma": 0.01,
|
||||
"max_luma": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config_file": "config/engine_tester_runtime.json"
|
||||
}
|
||||
@@ -289,8 +289,46 @@
|
||||
"max_diff_pixels": {"type": "number", "minimum": 0}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["non_black_ratio", "luma_range", "mean_color", "sample_points"]
|
||||
},
|
||||
"threshold": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"min_ratio": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"max_ratio": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"min_luma": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"max_luma": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"color": {"$ref": "#/definitions/float3"},
|
||||
"tolerance": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"y": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"color": {"$ref": "#/definitions/float3"},
|
||||
"tolerance": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["expected"] },
|
||||
{ "required": ["checks"] }
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
|
||||
76
src/services/impl/json_config_document_loader.cpp
Normal file
76
src/services/impl/json_config_document_loader.cpp
Normal file
@@ -0,0 +1,76 @@
|
||||
#include "json_config_document_loader.hpp"
|
||||
#include "json_config_document_parser.hpp"
|
||||
#include "json_config_extend_resolver.hpp"
|
||||
#include "json_config_merge_service.hpp"
|
||||
#include "../interfaces/i_logger.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
JsonConfigDocumentLoader::JsonConfigDocumentLoader(std::shared_ptr<ILogger> logger)
|
||||
: logger_(std::move(logger)) {}
|
||||
|
||||
rapidjson::Document JsonConfigDocumentLoader::Load(const std::filesystem::path& configPath) const {
|
||||
std::unordered_set<std::string> visited;
|
||||
return LoadRecursive(configPath, visited);
|
||||
}
|
||||
|
||||
std::filesystem::path JsonConfigDocumentLoader::NormalizeConfigPath(const std::filesystem::path& path) const {
|
||||
std::error_code ec;
|
||||
auto canonicalPath = std::filesystem::weakly_canonical(path, ec);
|
||||
if (ec) {
|
||||
return std::filesystem::absolute(path);
|
||||
}
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
rapidjson::Document JsonConfigDocumentLoader::LoadRecursive(const std::filesystem::path& configPath,
|
||||
std::unordered_set<std::string>& visited) const {
|
||||
const auto normalizedPath = NormalizeConfigPath(configPath);
|
||||
const std::string pathKey = normalizedPath.string();
|
||||
if (!visited.insert(pathKey).second) {
|
||||
throw std::runtime_error("Config extends cycle detected at " + pathKey);
|
||||
}
|
||||
|
||||
if (logger_) {
|
||||
logger_->Trace("JsonConfigService", "LoadConfigDocumentRecursive",
|
||||
"configPath=" + pathKey, "Loading config document");
|
||||
}
|
||||
|
||||
JsonConfigDocumentParser parser;
|
||||
rapidjson::Document document = parser.Parse(normalizedPath, "config file");
|
||||
|
||||
JsonConfigExtendResolver extendResolver;
|
||||
auto extendPaths = extendResolver.Extract(document, normalizedPath);
|
||||
if (document.HasMember("extends")) {
|
||||
document.RemoveMember("extends");
|
||||
}
|
||||
|
||||
if (extendPaths.empty()) {
|
||||
visited.erase(pathKey);
|
||||
return document;
|
||||
}
|
||||
|
||||
if (logger_) {
|
||||
logger_->Trace("JsonConfigService", "LoadConfigDocumentRecursive",
|
||||
"configPath=" + pathKey + ", extendsCount=" + std::to_string(extendPaths.size()),
|
||||
"Merging extended configs");
|
||||
}
|
||||
|
||||
JsonConfigMergeService mergeService(logger_);
|
||||
rapidjson::Document merged;
|
||||
merged.SetObject();
|
||||
auto& allocator = merged.GetAllocator();
|
||||
for (const auto& extendPath : extendPaths) {
|
||||
auto baseDoc = LoadRecursive(extendPath, visited);
|
||||
mergeService.Merge(merged, baseDoc, allocator, extendPath.string());
|
||||
}
|
||||
mergeService.Merge(merged, document, allocator, normalizedPath.string());
|
||||
|
||||
visited.erase(pathKey);
|
||||
return merged;
|
||||
}
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
29
src/services/impl/json_config_document_loader.hpp
Normal file
29
src/services/impl/json_config_document_loader.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
|
||||
#include <rapidjson/document.h>
|
||||
|
||||
namespace sdl3cpp::services {
|
||||
class ILogger;
|
||||
}
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
class JsonConfigDocumentLoader {
|
||||
public:
|
||||
explicit JsonConfigDocumentLoader(std::shared_ptr<ILogger> logger);
|
||||
|
||||
rapidjson::Document Load(const std::filesystem::path& configPath) const;
|
||||
|
||||
private:
|
||||
rapidjson::Document LoadRecursive(const std::filesystem::path& configPath,
|
||||
std::unordered_set<std::string>& visited) const;
|
||||
std::filesystem::path NormalizeConfigPath(const std::filesystem::path& path) const;
|
||||
|
||||
std::shared_ptr<ILogger> logger_;
|
||||
};
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
31
src/services/impl/json_config_document_parser.cpp
Normal file
31
src/services/impl/json_config_document_parser.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include "json_config_document_parser.hpp"
|
||||
|
||||
#include <rapidjson/error/en.h>
|
||||
#include <rapidjson/istreamwrapper.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
rapidjson::Document JsonConfigDocumentParser::Parse(const std::filesystem::path& jsonPath,
|
||||
const std::string& description) const {
|
||||
std::ifstream configStream(jsonPath);
|
||||
if (!configStream) {
|
||||
throw std::runtime_error("Failed to open " + description + ": " + jsonPath.string());
|
||||
}
|
||||
|
||||
rapidjson::IStreamWrapper inputWrapper(configStream);
|
||||
rapidjson::Document document;
|
||||
document.ParseStream(inputWrapper);
|
||||
if (document.HasParseError()) {
|
||||
throw std::runtime_error("Failed to parse " + description + " at " + jsonPath.string() +
|
||||
": " + rapidjson::GetParseError_En(document.GetParseError()));
|
||||
}
|
||||
if (!document.IsObject()) {
|
||||
throw std::runtime_error("JSON " + description + " must contain an object at the root");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
16
src/services/impl/json_config_document_parser.hpp
Normal file
16
src/services/impl/json_config_document_parser.hpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <rapidjson/document.h>
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
class JsonConfigDocumentParser {
|
||||
public:
|
||||
rapidjson::Document Parse(const std::filesystem::path& jsonPath,
|
||||
const std::string& description) const;
|
||||
};
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
43
src/services/impl/json_config_extend_resolver.cpp
Normal file
43
src/services/impl/json_config_extend_resolver.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#include "json_config_extend_resolver.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
namespace {
|
||||
constexpr const char* kExtendsKey = "extends";
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> JsonConfigExtendResolver::Extract(
|
||||
const rapidjson::Value& document,
|
||||
const std::filesystem::path& configPath) const {
|
||||
std::vector<std::filesystem::path> paths;
|
||||
if (!document.HasMember(kExtendsKey)) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const auto& extendsValue = document[kExtendsKey];
|
||||
auto resolvePath = [&](const std::filesystem::path& candidate) {
|
||||
if (candidate.is_absolute()) {
|
||||
return candidate;
|
||||
}
|
||||
return configPath.parent_path() / candidate;
|
||||
};
|
||||
|
||||
if (extendsValue.IsString()) {
|
||||
paths.push_back(resolvePath(extendsValue.GetString()));
|
||||
} else if (extendsValue.IsArray()) {
|
||||
for (const auto& entry : extendsValue.GetArray()) {
|
||||
if (!entry.IsString()) {
|
||||
throw std::runtime_error("JSON member 'extends' must be a string or array of strings");
|
||||
}
|
||||
paths.push_back(resolvePath(entry.GetString()));
|
||||
}
|
||||
} else {
|
||||
throw std::runtime_error("JSON member 'extends' must be a string or array of strings");
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
16
src/services/impl/json_config_extend_resolver.hpp
Normal file
16
src/services/impl/json_config_extend_resolver.hpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
#include <rapidjson/document.h>
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
class JsonConfigExtendResolver {
|
||||
public:
|
||||
std::vector<std::filesystem::path> Extract(const rapidjson::Value& document,
|
||||
const std::filesystem::path& configPath) const;
|
||||
};
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
31
src/services/impl/json_config_merge_service.hpp
Normal file
31
src/services/impl/json_config_merge_service.hpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <rapidjson/document.h>
|
||||
|
||||
namespace sdl3cpp::services {
|
||||
class ILogger;
|
||||
}
|
||||
|
||||
namespace sdl3cpp::services::impl::json_config {
|
||||
|
||||
class JsonConfigMergeService {
|
||||
public:
|
||||
explicit JsonConfigMergeService(std::shared_ptr<ILogger> logger);
|
||||
|
||||
void Merge(rapidjson::Value& target,
|
||||
const rapidjson::Value& overlay,
|
||||
rapidjson::Document::AllocatorType& allocator,
|
||||
const std::string& jsonPath) const;
|
||||
|
||||
private:
|
||||
void ApplyDeleteDirectives(rapidjson::Value& target,
|
||||
const rapidjson::Value& overlay,
|
||||
const std::string& jsonPath) const;
|
||||
|
||||
std::shared_ptr<ILogger> logger_;
|
||||
};
|
||||
|
||||
} // namespace sdl3cpp::services::impl::json_config
|
||||
@@ -1,20 +1,15 @@
|
||||
#include "json_config_service.hpp"
|
||||
#include "json_config_document_loader.hpp"
|
||||
#include "../interfaces/i_logger.hpp"
|
||||
#include <rapidjson/document.h>
|
||||
#include <rapidjson/error/en.h>
|
||||
#include <rapidjson/istreamwrapper.h>
|
||||
#include <rapidjson/schema.h>
|
||||
#include <rapidjson/stringbuffer.h>
|
||||
#include <rapidjson/writer.h>
|
||||
#include <rapidjson/prettywriter.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace sdl3cpp::services::impl {
|
||||
@@ -23,8 +18,6 @@ namespace {
|
||||
constexpr int kExpectedSchemaVersion = 2;
|
||||
constexpr const char* kSchemaVersionKey = "schema_version";
|
||||
constexpr const char* kConfigVersionKey = "configVersion";
|
||||
constexpr const char* kExtendsKey = "extends";
|
||||
constexpr const char* kDeleteKey = "@delete";
|
||||
|
||||
const char* SceneSourceName(SceneSource source) {
|
||||
switch (source) {
|
||||
@@ -53,193 +46,6 @@ std::string PointerToString(const rapidjson::Pointer& pointer) {
|
||||
return buffer.GetString();
|
||||
}
|
||||
|
||||
std::filesystem::path NormalizeConfigPath(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
auto canonicalPath = std::filesystem::weakly_canonical(path, ec);
|
||||
if (ec) {
|
||||
return std::filesystem::absolute(path);
|
||||
}
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
rapidjson::Document ParseJsonDocument(const std::filesystem::path& jsonPath,
|
||||
const std::string& description) {
|
||||
std::ifstream configStream(jsonPath);
|
||||
if (!configStream) {
|
||||
throw std::runtime_error("Failed to open " + description + ": " + jsonPath.string());
|
||||
}
|
||||
|
||||
rapidjson::IStreamWrapper inputWrapper(configStream);
|
||||
rapidjson::Document document;
|
||||
document.ParseStream(inputWrapper);
|
||||
if (document.HasParseError()) {
|
||||
throw std::runtime_error("Failed to parse " + description + " at " + jsonPath.string() +
|
||||
": " + rapidjson::GetParseError_En(document.GetParseError()));
|
||||
}
|
||||
if (!document.IsObject()) {
|
||||
throw std::runtime_error("JSON " + description + " must contain an object at the root");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> ExtractExtendPaths(const rapidjson::Value& document,
|
||||
const std::filesystem::path& configPath) {
|
||||
std::vector<std::filesystem::path> paths;
|
||||
if (!document.HasMember(kExtendsKey)) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const auto& extendsValue = document[kExtendsKey];
|
||||
auto resolvePath = [&](const std::filesystem::path& candidate) {
|
||||
if (candidate.is_absolute()) {
|
||||
return candidate;
|
||||
}
|
||||
return configPath.parent_path() / candidate;
|
||||
};
|
||||
|
||||
if (extendsValue.IsString()) {
|
||||
paths.push_back(resolvePath(extendsValue.GetString()));
|
||||
} else if (extendsValue.IsArray()) {
|
||||
for (const auto& entry : extendsValue.GetArray()) {
|
||||
if (!entry.IsString()) {
|
||||
throw std::runtime_error("JSON member 'extends' must be a string or array of strings");
|
||||
}
|
||||
paths.push_back(resolvePath(entry.GetString()));
|
||||
}
|
||||
} else {
|
||||
throw std::runtime_error("JSON member 'extends' must be a string or array of strings");
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
std::filesystem::path ResolveSchemaPath(const std::filesystem::path& configPath) {
|
||||
const std::filesystem::path schemaFile = "runtime_config_v2.schema.json";
|
||||
std::vector<std::filesystem::path> candidates;
|
||||
if (!configPath.empty()) {
|
||||
candidates.push_back(configPath.parent_path() / "schema" / schemaFile);
|
||||
}
|
||||
candidates.push_back(std::filesystem::current_path() / "config" / "schema" / schemaFile);
|
||||
|
||||
std::error_code ec;
|
||||
for (const auto& candidate : candidates) {
|
||||
if (!candidate.empty() && std::filesystem::exists(candidate, ec)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ApplyDeleteDirectives(rapidjson::Value& target,
|
||||
const rapidjson::Value& overlay,
|
||||
const std::shared_ptr<ILogger>& logger,
|
||||
const std::string& jsonPath) {
|
||||
if (!overlay.HasMember(kDeleteKey)) {
|
||||
return;
|
||||
}
|
||||
const auto& deletes = overlay[kDeleteKey];
|
||||
if (!deletes.IsArray()) {
|
||||
throw std::runtime_error("JSON member '" + std::string(kDeleteKey) + "' must be an array of strings");
|
||||
}
|
||||
for (const auto& entry : deletes.GetArray()) {
|
||||
if (!entry.IsString()) {
|
||||
throw std::runtime_error("JSON member '" + std::string(kDeleteKey) + "' must contain only strings");
|
||||
}
|
||||
const char* key = entry.GetString();
|
||||
if (target.HasMember(key)) {
|
||||
target.RemoveMember(key);
|
||||
if (logger) {
|
||||
logger->Trace("JsonConfigService", "ApplyDeleteDirectives",
|
||||
"jsonPath=" + jsonPath + ", key=" + std::string(key),
|
||||
"Removed key from merged config");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MergeJsonValues(rapidjson::Value& target,
|
||||
const rapidjson::Value& overlay,
|
||||
rapidjson::Document::AllocatorType& allocator,
|
||||
const std::shared_ptr<ILogger>& logger,
|
||||
const std::string& jsonPath) {
|
||||
if (!overlay.IsObject()) {
|
||||
target.CopyFrom(overlay, allocator);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.IsObject()) {
|
||||
target.CopyFrom(overlay, allocator);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyDeleteDirectives(target, overlay, logger, jsonPath);
|
||||
|
||||
for (auto it = overlay.MemberBegin(); it != overlay.MemberEnd(); ++it) {
|
||||
const std::string memberName = it->name.GetString();
|
||||
if (memberName == kDeleteKey) {
|
||||
continue;
|
||||
}
|
||||
if (target.HasMember(memberName.c_str())) {
|
||||
auto& targetValue = target[memberName.c_str()];
|
||||
const auto& overlayValue = it->value;
|
||||
if (targetValue.IsObject() && overlayValue.IsObject()) {
|
||||
MergeJsonValues(targetValue, overlayValue, allocator, logger, jsonPath + "/" + memberName);
|
||||
} else {
|
||||
targetValue.CopyFrom(overlayValue, allocator);
|
||||
}
|
||||
} else {
|
||||
rapidjson::Value nameValue(memberName.c_str(), allocator);
|
||||
rapidjson::Value valueCopy;
|
||||
valueCopy.CopyFrom(it->value, allocator);
|
||||
target.AddMember(nameValue, valueCopy, allocator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rapidjson::Document LoadConfigDocumentRecursive(const std::filesystem::path& configPath,
|
||||
const std::shared_ptr<ILogger>& logger,
|
||||
std::unordered_set<std::string>& visited) {
|
||||
const auto normalizedPath = NormalizeConfigPath(configPath);
|
||||
const std::string pathKey = normalizedPath.string();
|
||||
if (!visited.insert(pathKey).second) {
|
||||
throw std::runtime_error("Config extends cycle detected at " + pathKey);
|
||||
}
|
||||
|
||||
if (logger) {
|
||||
logger->Trace("JsonConfigService", "LoadConfigDocumentRecursive",
|
||||
"configPath=" + pathKey, "Loading config document");
|
||||
}
|
||||
|
||||
rapidjson::Document document = ParseJsonDocument(normalizedPath, "config file");
|
||||
auto extendPaths = ExtractExtendPaths(document, normalizedPath);
|
||||
if (document.HasMember(kExtendsKey)) {
|
||||
document.RemoveMember(kExtendsKey);
|
||||
}
|
||||
|
||||
if (extendPaths.empty()) {
|
||||
visited.erase(pathKey);
|
||||
return document;
|
||||
}
|
||||
|
||||
if (logger) {
|
||||
logger->Trace("JsonConfigService", "LoadConfigDocumentRecursive",
|
||||
"configPath=" + pathKey + ", extendsCount=" + std::to_string(extendPaths.size()),
|
||||
"Merging extended configs");
|
||||
}
|
||||
|
||||
rapidjson::Document merged;
|
||||
merged.SetObject();
|
||||
auto& allocator = merged.GetAllocator();
|
||||
for (const auto& extendPath : extendPaths) {
|
||||
auto baseDoc = LoadConfigDocumentRecursive(extendPath, logger, visited);
|
||||
MergeJsonValues(merged, baseDoc, allocator, logger, extendPath.string());
|
||||
}
|
||||
MergeJsonValues(merged, document, allocator, logger, normalizedPath.string());
|
||||
|
||||
visited.erase(pathKey);
|
||||
return merged;
|
||||
}
|
||||
|
||||
std::optional<int> ReadVersionField(const rapidjson::Value& document,
|
||||
const char* fieldName,
|
||||
const std::filesystem::path& configPath) {
|
||||
@@ -314,7 +120,7 @@ void ValidateSchemaDocument(const rapidjson::Document& document,
|
||||
const std::filesystem::path& configPath,
|
||||
const std::shared_ptr<ILogger>& logger,
|
||||
const std::shared_ptr<IProbeService>& probeService) {
|
||||
const auto schemaPath = ResolveSchemaPath(configPath);
|
||||
const auto schemaPath = json_config::ResolveSchemaPath(configPath);
|
||||
if (schemaPath.empty()) {
|
||||
if (logger) {
|
||||
logger->Warn("JsonConfigService::ValidateSchemaDocument: Schema file not found for " +
|
||||
@@ -323,7 +129,7 @@ void ValidateSchemaDocument(const rapidjson::Document& document,
|
||||
return;
|
||||
}
|
||||
|
||||
rapidjson::Document schemaDocument = ParseJsonDocument(schemaPath, "schema file");
|
||||
rapidjson::Document schemaDocument = json_config::ParseJsonDocument(schemaPath, "schema file");
|
||||
rapidjson::SchemaDocument schema(schemaDocument);
|
||||
rapidjson::SchemaValidator validator(schema);
|
||||
if (!document.Accept(validator)) {
|
||||
@@ -440,7 +246,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> logger,
|
||||
logger->Trace("JsonConfigService", "LoadFromJson", args);
|
||||
|
||||
std::unordered_set<std::string> visitedPaths;
|
||||
rapidjson::Document document = LoadConfigDocumentRecursive(configPath, logger, visitedPaths);
|
||||
rapidjson::Document document = json_config::LoadConfigDocumentRecursive(configPath, logger, visitedPaths);
|
||||
const auto activeVersion = ValidateSchemaVersion(document, configPath, logger);
|
||||
if (activeVersion && *activeVersion != kExpectedSchemaVersion) {
|
||||
const bool migrated = ApplyMigrations(document,
|
||||
@@ -1294,39 +1100,192 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> logger,
|
||||
checkpoint.camera.farPlane = static_cast<float>(value.GetDouble());
|
||||
}
|
||||
|
||||
if (!entry.HasMember("expected") || !entry["expected"].IsObject()) {
|
||||
throw std::runtime_error("JSON member '" + basePath + ".expected' must be an object");
|
||||
}
|
||||
const auto& expectedValue = entry["expected"];
|
||||
if (!expectedValue.HasMember("image") || !expectedValue["image"].IsString()) {
|
||||
throw std::runtime_error("JSON member '" + basePath + ".expected.image' must be a string");
|
||||
}
|
||||
checkpoint.expected.imagePath = expectedValue["image"].GetString();
|
||||
if (expectedValue.HasMember("tolerance")) {
|
||||
const auto& value = expectedValue["tolerance"];
|
||||
auto readFloatInRange = [&](const rapidjson::Value& value,
|
||||
const std::string& path,
|
||||
float minValue,
|
||||
float maxValue) -> float {
|
||||
if (!value.IsNumber()) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.tolerance' must be a number");
|
||||
throw std::runtime_error("JSON member '" + path + "' must be a number");
|
||||
}
|
||||
const double rawValue = value.GetDouble();
|
||||
if (rawValue < 0.0 || rawValue > 1.0) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.tolerance' must be between 0 and 1");
|
||||
const double minAllowed = static_cast<double>(minValue);
|
||||
const double maxAllowed = static_cast<double>(maxValue);
|
||||
if (rawValue < minAllowed || rawValue > maxAllowed) {
|
||||
throw std::runtime_error("JSON member '" + path + "' must be between " +
|
||||
std::to_string(minValue) + " and " +
|
||||
std::to_string(maxValue));
|
||||
}
|
||||
return static_cast<float>(rawValue);
|
||||
};
|
||||
|
||||
if (entry.HasMember("expected")) {
|
||||
if (!entry["expected"].IsObject()) {
|
||||
throw std::runtime_error("JSON member '" + basePath + ".expected' must be an object");
|
||||
}
|
||||
const auto& expectedValue = entry["expected"];
|
||||
if (!expectedValue.HasMember("image") || !expectedValue["image"].IsString()) {
|
||||
throw std::runtime_error("JSON member '" + basePath + ".expected.image' must be a string");
|
||||
}
|
||||
checkpoint.expected.enabled = true;
|
||||
checkpoint.expected.imagePath = expectedValue["image"].GetString();
|
||||
if (expectedValue.HasMember("tolerance")) {
|
||||
checkpoint.expected.tolerance = readFloatInRange(
|
||||
expectedValue["tolerance"],
|
||||
basePath + ".expected.tolerance",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
if (expectedValue.HasMember("max_diff_pixels")) {
|
||||
const auto& value = expectedValue["max_diff_pixels"];
|
||||
if (!value.IsNumber()) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.max_diff_pixels' must be a number");
|
||||
}
|
||||
const double rawValue = value.GetDouble();
|
||||
if (rawValue < 0.0) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.max_diff_pixels' must be non-negative");
|
||||
}
|
||||
checkpoint.expected.maxDiffPixels = static_cast<size_t>(rawValue);
|
||||
}
|
||||
checkpoint.expected.tolerance = static_cast<float>(rawValue);
|
||||
}
|
||||
if (expectedValue.HasMember("max_diff_pixels")) {
|
||||
const auto& value = expectedValue["max_diff_pixels"];
|
||||
if (!value.IsNumber()) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.max_diff_pixels' must be a number");
|
||||
|
||||
if (entry.HasMember("checks")) {
|
||||
const auto& checksValue = entry["checks"];
|
||||
if (!checksValue.IsArray()) {
|
||||
throw std::runtime_error("JSON member '" + basePath + ".checks' must be an array");
|
||||
}
|
||||
const double rawValue = value.GetDouble();
|
||||
if (rawValue < 0.0) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
".expected.max_diff_pixels' must be non-negative");
|
||||
checkpoint.checks.clear();
|
||||
checkpoint.checks.reserve(checksValue.Size());
|
||||
|
||||
auto readFloat3Field = [&](const rapidjson::Value& value,
|
||||
const std::string& path,
|
||||
std::array<float, 3>& target) {
|
||||
if (!value.IsArray() || value.Size() != 3) {
|
||||
throw std::runtime_error("JSON member '" + path + "' must be an array of 3 numbers");
|
||||
}
|
||||
for (rapidjson::SizeType index = 0; index < 3; ++index) {
|
||||
if (!value[index].IsNumber()) {
|
||||
throw std::runtime_error("JSON member '" + path + "[" + std::to_string(index) +
|
||||
"]' must be a number");
|
||||
}
|
||||
target[index] = static_cast<float>(value[index].GetDouble());
|
||||
}
|
||||
};
|
||||
|
||||
for (rapidjson::SizeType checkIndex = 0; checkIndex < checksValue.Size(); ++checkIndex) {
|
||||
const auto& checkValue = checksValue[checkIndex];
|
||||
const std::string checkPath = basePath + ".checks[" + std::to_string(checkIndex) + "]";
|
||||
if (!checkValue.IsObject()) {
|
||||
throw std::runtime_error("JSON member '" + checkPath + "' must be an object");
|
||||
}
|
||||
if (!checkValue.HasMember("type") || !checkValue["type"].IsString()) {
|
||||
throw std::runtime_error("JSON member '" + checkPath + ".type' must be a string");
|
||||
}
|
||||
ValidationCheckConfig check{};
|
||||
check.type = checkValue["type"].GetString();
|
||||
|
||||
if (check.type == "non_black_ratio") {
|
||||
if (checkValue.HasMember("threshold")) {
|
||||
check.threshold = readFloatInRange(
|
||||
checkValue["threshold"],
|
||||
checkPath + ".threshold",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
if (checkValue.HasMember("min_ratio")) {
|
||||
check.minValue = readFloatInRange(
|
||||
checkValue["min_ratio"],
|
||||
checkPath + ".min_ratio",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
if (checkValue.HasMember("max_ratio")) {
|
||||
check.maxValue = readFloatInRange(
|
||||
checkValue["max_ratio"],
|
||||
checkPath + ".max_ratio",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
if (check.minValue > check.maxValue) {
|
||||
throw std::runtime_error("JSON member '" + checkPath +
|
||||
"' must have min_ratio <= max_ratio");
|
||||
}
|
||||
} else if (check.type == "luma_range") {
|
||||
if (!checkValue.HasMember("min_luma") || !checkValue.HasMember("max_luma")) {
|
||||
throw std::runtime_error("JSON member '" + checkPath +
|
||||
"' must include min_luma and max_luma");
|
||||
}
|
||||
check.minValue = readFloatInRange(
|
||||
checkValue["min_luma"],
|
||||
checkPath + ".min_luma",
|
||||
0.0f,
|
||||
1.0f);
|
||||
check.maxValue = readFloatInRange(
|
||||
checkValue["max_luma"],
|
||||
checkPath + ".max_luma",
|
||||
0.0f,
|
||||
1.0f);
|
||||
if (check.minValue > check.maxValue) {
|
||||
throw std::runtime_error("JSON member '" + checkPath +
|
||||
"' must have min_luma <= max_luma");
|
||||
}
|
||||
} else if (check.type == "mean_color") {
|
||||
if (!checkValue.HasMember("color")) {
|
||||
throw std::runtime_error("JSON member '" + checkPath + ".color' is required");
|
||||
}
|
||||
readFloat3Field(checkValue["color"], checkPath + ".color", check.color);
|
||||
if (checkValue.HasMember("tolerance")) {
|
||||
check.tolerance = readFloatInRange(
|
||||
checkValue["tolerance"],
|
||||
checkPath + ".tolerance",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
} else if (check.type == "sample_points") {
|
||||
if (!checkValue.HasMember("points") || !checkValue["points"].IsArray()) {
|
||||
throw std::runtime_error("JSON member '" + checkPath + ".points' must be an array");
|
||||
}
|
||||
const auto& pointsValue = checkValue["points"];
|
||||
check.points.clear();
|
||||
check.points.reserve(pointsValue.Size());
|
||||
for (rapidjson::SizeType pointIndex = 0; pointIndex < pointsValue.Size(); ++pointIndex) {
|
||||
const auto& pointValue = pointsValue[pointIndex];
|
||||
const std::string pointPath = checkPath + ".points[" + std::to_string(pointIndex) + "]";
|
||||
if (!pointValue.IsObject()) {
|
||||
throw std::runtime_error("JSON member '" + pointPath + "' must be an object");
|
||||
}
|
||||
ValidationSamplePointConfig point{};
|
||||
if (!pointValue.HasMember("x") || !pointValue.HasMember("y")) {
|
||||
throw std::runtime_error("JSON member '" + pointPath +
|
||||
"' must include x and y");
|
||||
}
|
||||
point.x = readFloatInRange(pointValue["x"], pointPath + ".x", 0.0f, 1.0f);
|
||||
point.y = readFloatInRange(pointValue["y"], pointPath + ".y", 0.0f, 1.0f);
|
||||
if (!pointValue.HasMember("color")) {
|
||||
throw std::runtime_error("JSON member '" + pointPath + ".color' is required");
|
||||
}
|
||||
readFloat3Field(pointValue["color"], pointPath + ".color", point.color);
|
||||
if (pointValue.HasMember("tolerance")) {
|
||||
point.tolerance = readFloatInRange(
|
||||
pointValue["tolerance"],
|
||||
pointPath + ".tolerance",
|
||||
0.0f,
|
||||
1.0f);
|
||||
}
|
||||
check.points.push_back(std::move(point));
|
||||
}
|
||||
} else {
|
||||
throw std::runtime_error("JSON member '" + checkPath + ".type' is unsupported");
|
||||
}
|
||||
|
||||
checkpoint.checks.push_back(std::move(check));
|
||||
}
|
||||
checkpoint.expected.maxDiffPixels = static_cast<size_t>(rawValue);
|
||||
}
|
||||
|
||||
if (!checkpoint.expected.enabled && checkpoint.checks.empty()) {
|
||||
throw std::runtime_error("JSON member '" + basePath +
|
||||
"' must define 'expected' or 'checks'");
|
||||
}
|
||||
|
||||
config.validationTour.checkpoints.push_back(std::move(checkpoint));
|
||||
@@ -1534,15 +1493,57 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config,
|
||||
cameraObject.AddMember("far", checkpoint.camera.farPlane, allocator);
|
||||
entry.AddMember("camera", cameraObject, allocator);
|
||||
|
||||
rapidjson::Value expectedObject(rapidjson::kObjectType);
|
||||
expectedObject.AddMember("image",
|
||||
rapidjson::Value(checkpoint.expected.imagePath.string().c_str(), allocator),
|
||||
allocator);
|
||||
expectedObject.AddMember("tolerance", checkpoint.expected.tolerance, allocator);
|
||||
expectedObject.AddMember("max_diff_pixels",
|
||||
static_cast<uint64_t>(checkpoint.expected.maxDiffPixels),
|
||||
allocator);
|
||||
entry.AddMember("expected", expectedObject, allocator);
|
||||
if (checkpoint.expected.enabled) {
|
||||
rapidjson::Value expectedObject(rapidjson::kObjectType);
|
||||
expectedObject.AddMember("image",
|
||||
rapidjson::Value(checkpoint.expected.imagePath.string().c_str(), allocator),
|
||||
allocator);
|
||||
expectedObject.AddMember("tolerance", checkpoint.expected.tolerance, allocator);
|
||||
expectedObject.AddMember("max_diff_pixels",
|
||||
static_cast<uint64_t>(checkpoint.expected.maxDiffPixels),
|
||||
allocator);
|
||||
entry.AddMember("expected", expectedObject, allocator);
|
||||
}
|
||||
|
||||
if (!checkpoint.checks.empty()) {
|
||||
rapidjson::Value checksArray(rapidjson::kArrayType);
|
||||
for (const auto& check : checkpoint.checks) {
|
||||
rapidjson::Value checkObject(rapidjson::kObjectType);
|
||||
checkObject.AddMember("type", rapidjson::Value(check.type.c_str(), allocator), allocator);
|
||||
if (check.type == "non_black_ratio") {
|
||||
checkObject.AddMember("threshold", check.threshold, allocator);
|
||||
checkObject.AddMember("min_ratio", check.minValue, allocator);
|
||||
checkObject.AddMember("max_ratio", check.maxValue, allocator);
|
||||
} else if (check.type == "luma_range") {
|
||||
checkObject.AddMember("min_luma", check.minValue, allocator);
|
||||
checkObject.AddMember("max_luma", check.maxValue, allocator);
|
||||
} else if (check.type == "mean_color") {
|
||||
rapidjson::Value colorValue(rapidjson::kArrayType);
|
||||
colorValue.PushBack(check.color[0], allocator);
|
||||
colorValue.PushBack(check.color[1], allocator);
|
||||
colorValue.PushBack(check.color[2], allocator);
|
||||
checkObject.AddMember("color", colorValue, allocator);
|
||||
checkObject.AddMember("tolerance", check.tolerance, allocator);
|
||||
} else if (check.type == "sample_points") {
|
||||
rapidjson::Value pointsArray(rapidjson::kArrayType);
|
||||
for (const auto& point : check.points) {
|
||||
rapidjson::Value pointValue(rapidjson::kObjectType);
|
||||
pointValue.AddMember("x", point.x, allocator);
|
||||
pointValue.AddMember("y", point.y, allocator);
|
||||
rapidjson::Value pointColor(rapidjson::kArrayType);
|
||||
pointColor.PushBack(point.color[0], allocator);
|
||||
pointColor.PushBack(point.color[1], allocator);
|
||||
pointColor.PushBack(point.color[2], allocator);
|
||||
pointValue.AddMember("color", pointColor, allocator);
|
||||
pointValue.AddMember("tolerance", point.tolerance, allocator);
|
||||
pointsArray.PushBack(pointValue, allocator);
|
||||
}
|
||||
checkObject.AddMember("points", pointsArray, allocator);
|
||||
}
|
||||
checksArray.PushBack(checkObject, allocator);
|
||||
}
|
||||
entry.AddMember("checks", checksArray, allocator);
|
||||
}
|
||||
|
||||
checkpointsArray.PushBack(entry, allocator);
|
||||
}
|
||||
|
||||
@@ -289,15 +289,57 @@ void JsonConfigWriterService::WriteConfig(const RuntimeConfig& config, const std
|
||||
cameraObject.AddMember("far", checkpoint.camera.farPlane, allocator);
|
||||
entry.AddMember("camera", cameraObject, allocator);
|
||||
|
||||
rapidjson::Value expectedObject(rapidjson::kObjectType);
|
||||
expectedObject.AddMember("image",
|
||||
rapidjson::Value(checkpoint.expected.imagePath.string().c_str(), allocator),
|
||||
allocator);
|
||||
expectedObject.AddMember("tolerance", checkpoint.expected.tolerance, allocator);
|
||||
expectedObject.AddMember("max_diff_pixels",
|
||||
static_cast<uint64_t>(checkpoint.expected.maxDiffPixels),
|
||||
allocator);
|
||||
entry.AddMember("expected", expectedObject, allocator);
|
||||
if (checkpoint.expected.enabled) {
|
||||
rapidjson::Value expectedObject(rapidjson::kObjectType);
|
||||
expectedObject.AddMember("image",
|
||||
rapidjson::Value(checkpoint.expected.imagePath.string().c_str(), allocator),
|
||||
allocator);
|
||||
expectedObject.AddMember("tolerance", checkpoint.expected.tolerance, allocator);
|
||||
expectedObject.AddMember("max_diff_pixels",
|
||||
static_cast<uint64_t>(checkpoint.expected.maxDiffPixels),
|
||||
allocator);
|
||||
entry.AddMember("expected", expectedObject, allocator);
|
||||
}
|
||||
|
||||
if (!checkpoint.checks.empty()) {
|
||||
rapidjson::Value checksArray(rapidjson::kArrayType);
|
||||
for (const auto& check : checkpoint.checks) {
|
||||
rapidjson::Value checkObject(rapidjson::kObjectType);
|
||||
checkObject.AddMember("type", rapidjson::Value(check.type.c_str(), allocator), allocator);
|
||||
if (check.type == "non_black_ratio") {
|
||||
checkObject.AddMember("threshold", check.threshold, allocator);
|
||||
checkObject.AddMember("min_ratio", check.minValue, allocator);
|
||||
checkObject.AddMember("max_ratio", check.maxValue, allocator);
|
||||
} else if (check.type == "luma_range") {
|
||||
checkObject.AddMember("min_luma", check.minValue, allocator);
|
||||
checkObject.AddMember("max_luma", check.maxValue, allocator);
|
||||
} else if (check.type == "mean_color") {
|
||||
rapidjson::Value colorValue(rapidjson::kArrayType);
|
||||
colorValue.PushBack(check.color[0], allocator);
|
||||
colorValue.PushBack(check.color[1], allocator);
|
||||
colorValue.PushBack(check.color[2], allocator);
|
||||
checkObject.AddMember("color", colorValue, allocator);
|
||||
checkObject.AddMember("tolerance", check.tolerance, allocator);
|
||||
} else if (check.type == "sample_points") {
|
||||
rapidjson::Value pointsArray(rapidjson::kArrayType);
|
||||
for (const auto& point : check.points) {
|
||||
rapidjson::Value pointValue(rapidjson::kObjectType);
|
||||
pointValue.AddMember("x", point.x, allocator);
|
||||
pointValue.AddMember("y", point.y, allocator);
|
||||
rapidjson::Value pointColor(rapidjson::kArrayType);
|
||||
pointColor.PushBack(point.color[0], allocator);
|
||||
pointColor.PushBack(point.color[1], allocator);
|
||||
pointColor.PushBack(point.color[2], allocator);
|
||||
pointValue.AddMember("color", pointColor, allocator);
|
||||
pointValue.AddMember("tolerance", point.tolerance, allocator);
|
||||
pointsArray.PushBack(pointValue, allocator);
|
||||
}
|
||||
checkObject.AddMember("points", pointsArray, allocator);
|
||||
}
|
||||
checksArray.PushBack(checkObject, allocator);
|
||||
}
|
||||
entry.AddMember("checks", checksArray, allocator);
|
||||
}
|
||||
|
||||
checkpointsArray.PushBack(entry, allocator);
|
||||
}
|
||||
|
||||
@@ -98,10 +98,7 @@ ValidationFramePlan ValidationTourService::BeginFrame(float aspect) {
|
||||
|
||||
PendingCapture pending{};
|
||||
pending.actualPath = actualPath;
|
||||
pending.expectedPath = ResolvePath(checkpoint.expected.imagePath);
|
||||
pending.checkpointId = checkpoint.id;
|
||||
pending.tolerance = checkpoint.expected.tolerance;
|
||||
pending.maxDiffPixels = checkpoint.expected.maxDiffPixels;
|
||||
pending.checkpointIndex = checkpointIndex_;
|
||||
pending.captureIndex = captureIndex_;
|
||||
pendingCapture_ = std::move(pending);
|
||||
|
||||
@@ -139,7 +136,7 @@ ValidationFrameResult ValidationTourService::EndFrame() {
|
||||
}
|
||||
|
||||
std::string errorMessage;
|
||||
bool ok = CompareImages(pending, errorMessage);
|
||||
bool ok = AnalyzeCapture(pending, errorMessage);
|
||||
if (!ok) {
|
||||
failed_ = true;
|
||||
failureMessage_ = errorMessage;
|
||||
@@ -217,7 +214,13 @@ std::filesystem::path ValidationTourService::ResolvePath(const std::filesystem::
|
||||
return std::filesystem::current_path() / path;
|
||||
}
|
||||
|
||||
bool ValidationTourService::CompareImages(const PendingCapture& pending, std::string& errorMessage) const {
|
||||
bool ValidationTourService::AnalyzeCapture(const PendingCapture& pending, std::string& errorMessage) const {
|
||||
if (pending.checkpointIndex >= config_.checkpoints.size()) {
|
||||
errorMessage = "Validation checkpoint index out of range";
|
||||
return false;
|
||||
}
|
||||
const ValidationCheckpointConfig& checkpoint = config_.checkpoints[pending.checkpointIndex];
|
||||
|
||||
int actualWidth = 0;
|
||||
int actualHeight = 0;
|
||||
int actualChannels = 0;
|
||||
@@ -228,52 +231,232 @@ bool ValidationTourService::CompareImages(const PendingCapture& pending, std::st
|
||||
STBI_rgb_alpha);
|
||||
if (!actualPixels) {
|
||||
errorMessage = "Validation capture missing or unreadable: " + pending.actualPath.string();
|
||||
ReportMismatch(pending, "Capture missing", errorMessage);
|
||||
ReportMismatch(checkpoint.id, "Capture missing", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool expectedOk = CompareExpectedImage(checkpoint, actualWidth, actualHeight, actualPixels, errorMessage);
|
||||
bool checksOk = expectedOk
|
||||
? ApplyChecks(checkpoint, actualWidth, actualHeight, actualPixels, errorMessage)
|
||||
: false;
|
||||
|
||||
stbi_image_free(actualPixels);
|
||||
if (!expectedOk || !checksOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ValidationTourService::ApplyChecks(const ValidationCheckpointConfig& checkpoint,
|
||||
int width,
|
||||
int height,
|
||||
const unsigned char* pixels,
|
||||
std::string& errorMessage) const {
|
||||
if (checkpoint.checks.empty()) {
|
||||
return true;
|
||||
}
|
||||
if (width <= 0 || height <= 0) {
|
||||
errorMessage = "Validation check failed: invalid capture dimensions";
|
||||
ReportMismatch(checkpoint.id, "Invalid capture", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
struct NonBlackState {
|
||||
float threshold = 0.05f;
|
||||
float minRatio = 0.0f;
|
||||
float maxRatio = 1.0f;
|
||||
size_t count = 0;
|
||||
size_t checkIndex = 0;
|
||||
};
|
||||
|
||||
std::vector<NonBlackState> nonBlackStates;
|
||||
nonBlackStates.reserve(checkpoint.checks.size());
|
||||
for (size_t index = 0; index < checkpoint.checks.size(); ++index) {
|
||||
const auto& check = checkpoint.checks[index];
|
||||
if (check.type == "non_black_ratio") {
|
||||
NonBlackState state{};
|
||||
state.threshold = check.threshold;
|
||||
state.minRatio = check.minValue;
|
||||
state.maxRatio = check.maxValue;
|
||||
state.checkIndex = index;
|
||||
nonBlackStates.push_back(state);
|
||||
}
|
||||
}
|
||||
|
||||
const size_t pixelCount = static_cast<size_t>(width) * static_cast<size_t>(height);
|
||||
const size_t totalChannels = pixelCount * 4;
|
||||
double sumR = 0.0;
|
||||
double sumG = 0.0;
|
||||
double sumB = 0.0;
|
||||
double sumLuma = 0.0;
|
||||
|
||||
for (size_t idx = 0; idx < totalChannels; idx += 4) {
|
||||
const double r = static_cast<double>(pixels[idx]) / 255.0;
|
||||
const double g = static_cast<double>(pixels[idx + 1]) / 255.0;
|
||||
const double b = static_cast<double>(pixels[idx + 2]) / 255.0;
|
||||
sumR += r;
|
||||
sumG += g;
|
||||
sumB += b;
|
||||
const double luma = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
sumLuma += luma;
|
||||
if (!nonBlackStates.empty()) {
|
||||
for (auto& state : nonBlackStates) {
|
||||
if (luma > static_cast<double>(state.threshold)) {
|
||||
++state.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const double denom = pixelCount == 0 ? 1.0 : static_cast<double>(pixelCount);
|
||||
const double avgLuma = sumLuma / denom;
|
||||
const double avgR = sumR / denom;
|
||||
const double avgG = sumG / denom;
|
||||
const double avgB = sumB / denom;
|
||||
|
||||
for (const auto& state : nonBlackStates) {
|
||||
const double ratio = static_cast<double>(state.count) / denom;
|
||||
if (ratio < static_cast<double>(state.minRatio) || ratio > static_cast<double>(state.maxRatio)) {
|
||||
const auto& check = checkpoint.checks[state.checkIndex];
|
||||
errorMessage = "Validation check failed (non_black_ratio) checkpoint '" + checkpoint.id +
|
||||
"' ratio=" + std::to_string(ratio) +
|
||||
" min=" + std::to_string(check.minValue) +
|
||||
" max=" + std::to_string(check.maxValue) +
|
||||
" threshold=" + std::to_string(check.threshold);
|
||||
ReportMismatch(checkpoint.id, "Non-black ratio mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& check : checkpoint.checks) {
|
||||
if (check.type == "luma_range") {
|
||||
if (avgLuma < static_cast<double>(check.minValue) ||
|
||||
avgLuma > static_cast<double>(check.maxValue)) {
|
||||
errorMessage = "Validation check failed (luma_range) checkpoint '" + checkpoint.id +
|
||||
"' avgLuma=" + std::to_string(avgLuma) +
|
||||
" min=" + std::to_string(check.minValue) +
|
||||
" max=" + std::to_string(check.maxValue);
|
||||
ReportMismatch(checkpoint.id, "Luma range mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
} else if (check.type == "mean_color") {
|
||||
const double tol = static_cast<double>(check.tolerance);
|
||||
const double dr = std::abs(avgR - static_cast<double>(check.color[0]));
|
||||
const double dg = std::abs(avgG - static_cast<double>(check.color[1]));
|
||||
const double db = std::abs(avgB - static_cast<double>(check.color[2]));
|
||||
if (dr > tol || dg > tol || db > tol) {
|
||||
errorMessage = "Validation check failed (mean_color) checkpoint '" + checkpoint.id +
|
||||
"' avgColor=[" + std::to_string(avgR) + "," +
|
||||
std::to_string(avgG) + "," + std::to_string(avgB) +
|
||||
"] target=[" + std::to_string(check.color[0]) + "," +
|
||||
std::to_string(check.color[1]) + "," +
|
||||
std::to_string(check.color[2]) + "] tolerance=" +
|
||||
std::to_string(check.tolerance);
|
||||
ReportMismatch(checkpoint.id, "Mean color mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
} else if (check.type == "sample_points") {
|
||||
for (size_t pointIndex = 0; pointIndex < check.points.size(); ++pointIndex) {
|
||||
const auto& point = check.points[pointIndex];
|
||||
const double xPos = std::round(static_cast<double>(point.x) *
|
||||
static_cast<double>(width - 1));
|
||||
const double yPos = std::round(static_cast<double>(point.y) *
|
||||
static_cast<double>(height - 1));
|
||||
const int x = std::clamp(static_cast<int>(xPos), 0, width - 1);
|
||||
const int y = std::clamp(static_cast<int>(yPos), 0, height - 1);
|
||||
const size_t pixelIndex = (static_cast<size_t>(y) * static_cast<size_t>(width) +
|
||||
static_cast<size_t>(x)) * 4;
|
||||
const double r = static_cast<double>(pixels[pixelIndex]) / 255.0;
|
||||
const double g = static_cast<double>(pixels[pixelIndex + 1]) / 255.0;
|
||||
const double b = static_cast<double>(pixels[pixelIndex + 2]) / 255.0;
|
||||
const double tol = static_cast<double>(point.tolerance);
|
||||
const double dr = std::abs(r - static_cast<double>(point.color[0]));
|
||||
const double dg = std::abs(g - static_cast<double>(point.color[1]));
|
||||
const double db = std::abs(b - static_cast<double>(point.color[2]));
|
||||
if (dr > tol || dg > tol || db > tol) {
|
||||
errorMessage = "Validation check failed (sample_points) checkpoint '" + checkpoint.id +
|
||||
"' point=" + std::to_string(pointIndex) +
|
||||
" actual=[" + std::to_string(r) + "," +
|
||||
std::to_string(g) + "," + std::to_string(b) +
|
||||
"] target=[" + std::to_string(point.color[0]) + "," +
|
||||
std::to_string(point.color[1]) + "," +
|
||||
std::to_string(point.color[2]) + "] tolerance=" +
|
||||
std::to_string(point.tolerance);
|
||||
ReportMismatch(checkpoint.id, "Sample point mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (logger_) {
|
||||
logger_->Trace("ValidationTourService", "ApplyChecks",
|
||||
"checkpoint=" + checkpoint.id +
|
||||
", avgLuma=" + std::to_string(avgLuma) +
|
||||
", avgColor=[" + std::to_string(avgR) + "," +
|
||||
std::to_string(avgG) + "," + std::to_string(avgB) + "]");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ValidationTourService::CompareExpectedImage(const ValidationCheckpointConfig& checkpoint,
|
||||
int width,
|
||||
int height,
|
||||
const unsigned char* pixels,
|
||||
std::string& errorMessage) const {
|
||||
if (!checkpoint.expected.enabled) {
|
||||
return true;
|
||||
}
|
||||
std::filesystem::path expectedPath = ResolvePath(checkpoint.expected.imagePath);
|
||||
if (expectedPath.empty()) {
|
||||
errorMessage = "Expected image path missing for checkpoint '" + checkpoint.id + "'";
|
||||
ReportMismatch(checkpoint.id, "Expected missing", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
int expectedWidth = 0;
|
||||
int expectedHeight = 0;
|
||||
int expectedChannels = 0;
|
||||
stbi_uc* expectedPixels = stbi_load(pending.expectedPath.string().c_str(),
|
||||
stbi_uc* expectedPixels = stbi_load(expectedPath.string().c_str(),
|
||||
&expectedWidth,
|
||||
&expectedHeight,
|
||||
&expectedChannels,
|
||||
STBI_rgb_alpha);
|
||||
if (!expectedPixels) {
|
||||
stbi_image_free(actualPixels);
|
||||
errorMessage = "Expected image missing or unreadable: " + pending.expectedPath.string();
|
||||
ReportMismatch(pending, "Expected missing", errorMessage);
|
||||
errorMessage = "Expected image missing or unreadable: " + expectedPath.string();
|
||||
ReportMismatch(checkpoint.id, "Expected missing", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actualWidth != expectedWidth || actualHeight != expectedHeight) {
|
||||
stbi_image_free(actualPixels);
|
||||
if (width != expectedWidth || height != expectedHeight) {
|
||||
stbi_image_free(expectedPixels);
|
||||
errorMessage = "Validation image size mismatch for checkpoint '" + pending.checkpointId +
|
||||
"' actual=" + std::to_string(actualWidth) + "x" + std::to_string(actualHeight) +
|
||||
errorMessage = "Validation image size mismatch for checkpoint '" + checkpoint.id +
|
||||
"' actual=" + std::to_string(width) + "x" + std::to_string(height) +
|
||||
" expected=" + std::to_string(expectedWidth) + "x" + std::to_string(expectedHeight);
|
||||
ReportMismatch(pending, "Image size mismatch", errorMessage);
|
||||
ReportMismatch(checkpoint.id, "Image size mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t pixelCount = static_cast<size_t>(actualWidth) * static_cast<size_t>(actualHeight);
|
||||
const size_t pixelCount = static_cast<size_t>(width) * static_cast<size_t>(height);
|
||||
const size_t totalChannels = pixelCount * 4;
|
||||
size_t mismatchCount = 0;
|
||||
float maxChannelDiff = 0.0f;
|
||||
double totalDiff = 0.0;
|
||||
double maxChannelDiff = 0.0;
|
||||
const double tolerance = static_cast<double>(checkpoint.expected.tolerance);
|
||||
|
||||
for (size_t idx = 0; idx < totalChannels; idx += 4) {
|
||||
bool pixelMismatch = false;
|
||||
for (size_t channel = 0; channel < 4; ++channel) {
|
||||
int actual = actualPixels[idx + channel];
|
||||
int actual = pixels[idx + channel];
|
||||
int expected = expectedPixels[idx + channel];
|
||||
float diff = static_cast<float>(std::abs(actual - expected)) / 255.0f;
|
||||
totalDiff += static_cast<double>(diff);
|
||||
double diff = static_cast<double>(std::abs(actual - expected)) / 255.0;
|
||||
totalDiff += diff;
|
||||
if (diff > maxChannelDiff) {
|
||||
maxChannelDiff = diff;
|
||||
}
|
||||
if (diff > pending.tolerance) {
|
||||
if (diff > tolerance) {
|
||||
pixelMismatch = true;
|
||||
}
|
||||
}
|
||||
@@ -282,37 +465,35 @@ bool ValidationTourService::CompareImages(const PendingCapture& pending, std::st
|
||||
}
|
||||
}
|
||||
|
||||
stbi_image_free(actualPixels);
|
||||
stbi_image_free(expectedPixels);
|
||||
|
||||
const double averageDiff = totalChannels == 0 ? 0.0 : totalDiff / static_cast<double>(totalChannels);
|
||||
if (mismatchCount > pending.maxDiffPixels) {
|
||||
errorMessage = "Validation mismatch for checkpoint '" + pending.checkpointId +
|
||||
if (mismatchCount > checkpoint.expected.maxDiffPixels) {
|
||||
errorMessage = "Validation mismatch for checkpoint '" + checkpoint.id +
|
||||
"' mismatchedPixels=" + std::to_string(mismatchCount) +
|
||||
" maxAllowed=" + std::to_string(pending.maxDiffPixels) +
|
||||
" tolerance=" + std::to_string(pending.tolerance) +
|
||||
" maxAllowed=" + std::to_string(checkpoint.expected.maxDiffPixels) +
|
||||
" tolerance=" + std::to_string(checkpoint.expected.tolerance) +
|
||||
" avgDiff=" + std::to_string(averageDiff) +
|
||||
" maxChannelDiff=" + std::to_string(maxChannelDiff) +
|
||||
" actual=" + pending.actualPath.string() +
|
||||
" expected=" + pending.expectedPath.string();
|
||||
ReportMismatch(pending, "Image mismatch", errorMessage);
|
||||
" expected=" + expectedPath.string();
|
||||
ReportMismatch(checkpoint.id, "Image mismatch", errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (logger_) {
|
||||
logger_->Trace("ValidationTourService", "CompareImages",
|
||||
"checkpoint=" + pending.checkpointId +
|
||||
logger_->Trace("ValidationTourService", "CompareExpectedImage",
|
||||
"checkpoint=" + checkpoint.id +
|
||||
", mismatchedPixels=" + std::to_string(mismatchCount) +
|
||||
", avgDiff=" + std::to_string(averageDiff));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ValidationTourService::ReportMismatch(const PendingCapture& pending,
|
||||
void ValidationTourService::ReportMismatch(const std::string& checkpointId,
|
||||
const std::string& summary,
|
||||
const std::string& details) const {
|
||||
if (logger_) {
|
||||
logger_->Error("ValidationTourService::CompareImages: " + details);
|
||||
logger_->Error("ValidationTourService::AnalyzeCapture: " + details);
|
||||
}
|
||||
if (!probeService_) {
|
||||
return;
|
||||
@@ -320,7 +501,7 @@ void ValidationTourService::ReportMismatch(const PendingCapture& pending,
|
||||
ProbeReport report{};
|
||||
report.severity = ProbeSeverity::Error;
|
||||
report.code = "VALIDATION_MISMATCH";
|
||||
report.resourceId = pending.checkpointId;
|
||||
report.resourceId = checkpointId;
|
||||
report.message = summary;
|
||||
report.details = details;
|
||||
probeService_->Report(report);
|
||||
|
||||
@@ -22,18 +22,25 @@ public:
|
||||
private:
|
||||
struct PendingCapture {
|
||||
std::filesystem::path actualPath;
|
||||
std::filesystem::path expectedPath;
|
||||
std::string checkpointId;
|
||||
float tolerance = 0.01f;
|
||||
size_t maxDiffPixels = 0;
|
||||
size_t checkpointIndex = 0;
|
||||
size_t captureIndex = 0;
|
||||
};
|
||||
|
||||
void AdvanceCheckpoint();
|
||||
ViewState BuildViewState(const ValidationCameraConfig& camera, float aspect) const;
|
||||
std::filesystem::path ResolvePath(const std::filesystem::path& path) const;
|
||||
bool CompareImages(const PendingCapture& pending, std::string& errorMessage) const;
|
||||
void ReportMismatch(const PendingCapture& pending,
|
||||
bool AnalyzeCapture(const PendingCapture& pending, std::string& errorMessage) const;
|
||||
bool ApplyChecks(const ValidationCheckpointConfig& checkpoint,
|
||||
int width,
|
||||
int height,
|
||||
const unsigned char* pixels,
|
||||
std::string& errorMessage) const;
|
||||
bool CompareExpectedImage(const ValidationCheckpointConfig& checkpoint,
|
||||
int width,
|
||||
int height,
|
||||
const unsigned char* pixels,
|
||||
std::string& errorMessage) const;
|
||||
void ReportMismatch(const std::string& checkpointId,
|
||||
const std::string& summary,
|
||||
const std::string& details) const;
|
||||
|
||||
|
||||
@@ -165,11 +165,35 @@ struct ValidationCameraConfig {
|
||||
* @brief Expected output for a validation checkpoint.
|
||||
*/
|
||||
struct ValidationExpectedConfig {
|
||||
bool enabled = false;
|
||||
std::filesystem::path imagePath;
|
||||
float tolerance = 0.01f;
|
||||
size_t maxDiffPixels = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Sample point expectation for validation checks.
|
||||
*/
|
||||
struct ValidationSamplePointConfig {
|
||||
float x = 0.5f;
|
||||
float y = 0.5f;
|
||||
std::array<float, 3> color = {0.0f, 0.0f, 0.0f};
|
||||
float tolerance = 0.1f;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Validation check definitions that do not require a baseline image.
|
||||
*/
|
||||
struct ValidationCheckConfig {
|
||||
std::string type;
|
||||
float minValue = 0.0f;
|
||||
float maxValue = 1.0f;
|
||||
float threshold = 0.05f;
|
||||
float tolerance = 0.1f;
|
||||
std::array<float, 3> color = {0.0f, 0.0f, 0.0f};
|
||||
std::vector<ValidationSamplePointConfig> points{};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A single validation checkpoint definition.
|
||||
*/
|
||||
@@ -177,6 +201,7 @@ struct ValidationCheckpointConfig {
|
||||
std::string id;
|
||||
ValidationCameraConfig camera{};
|
||||
ValidationExpectedConfig expected{};
|
||||
std::vector<ValidationCheckConfig> checks{};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user