feat: extend CliRunner with config file/batch mode and add comprehensive tests

CliRunner:
- --batch / --config <file> flags trigger batch mode with directory scanning
- collect_files() with recursive support and case-insensitive extension matching
- build_pipeline() respects AppConfig conversion flags (invert toggle)
- Progress output to stderr: "[1/42] Processing DSC09246.ARW..."

Tests (test_pipeline.cpp):
- AppConfig: load/save roundtrip, missing file error, extension parsing,
  format mapping, write_default
- CropProcessor: levels adjustment, sharpening no-clip, empty image error
- ColorCorrector: AWB preserves neutral grey, skips B&W film
- Inverter: color negative changes values, B&W inversion, positive passthrough
- Preprocessor: 8-bit→16-bit conversion

test_rawloader.cpp: added missing <fstream> include

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-14 09:42:01 +01:00
parent db39ef8c58
commit 73ccaa3e95
5 changed files with 499 additions and 61 deletions

View File

@@ -17,6 +17,10 @@ target_include_directories(test_pipeline PRIVATE
${CMAKE_SOURCE_DIR}/src
)
target_compile_definitions(test_pipeline PRIVATE
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
)
add_test(NAME PipelineTests COMMAND test_pipeline)
# ──────────────────────────────────────────────
@@ -36,12 +40,8 @@ target_include_directories(test_rawloader PRIVATE
${CMAKE_SOURCE_DIR}/src
)
add_test(NAME RawLoaderTests COMMAND test_rawloader)
# Make test data path available
target_compile_definitions(test_pipeline PRIVATE
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
)
target_compile_definitions(test_rawloader PRIVATE
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
)
add_test(NAME RawLoaderTests COMMAND test_rawloader)

View File

@@ -8,9 +8,13 @@
#include "converter/invert/Inverter.h"
#include "converter/color/ColorCorrector.h"
#include "converter/crop/CropProcessor.h"
#include "config/AppConfig.h"
#include <opencv2/core.hpp>
#include <filesystem>
#include <fstream>
using namespace photoconv;
/**
@@ -25,11 +29,32 @@ static ImageData make_test_image(int width, int height, uint16_t value) {
ImageData data;
data.rgb = cv::Mat(height, width, CV_16UC3, cv::Scalar(value, value, value));
data.source_path = "test_synthetic.png";
data.metadata.camera_make = "Test";
data.metadata.camera_make = "Test";
data.metadata.camera_model = "Synthetic";
return data;
}
/**
* @brief Create a synthetic gradient test image.
*
* Useful for levels-adjustment and crop tests that need non-uniform content.
*/
static ImageData make_gradient_image(int width, int height) {
ImageData data;
data.rgb = cv::Mat(height, width, CV_16UC3);
data.source_path = "test_gradient.png";
data.metadata.camera_make = "Test";
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const uint16_t v = static_cast<uint16_t>(
static_cast<double>(x + y) / static_cast<double>(width + height) * 65535.0);
data.rgb.at<cv::Vec3w>(y, x) = {v, v, v};
}
}
return data;
}
// ──────────────────────────────────────────────
// Pipeline orchestration tests
// ──────────────────────────────────────────────
@@ -107,6 +132,21 @@ TEST(PreprocessorTest, RejectsEmptyImage) {
EXPECT_EQ(result.error().code, ErrorCode::InvalidBitDepth);
}
TEST(PreprocessorTest, Converts8BitTo16Bit) {
Preprocessor stage;
ImageData data;
data.rgb = cv::Mat(100, 100, CV_8UC3, cv::Scalar(128, 128, 128));
data.source_path = "test.png";
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->rgb.type(), CV_16UC3);
// 128 * 257 = 32896 ≈ 32768 midpoint
cv::Scalar mean = cv::mean(result->rgb);
EXPECT_GT(mean[0], 30000.0);
EXPECT_LT(mean[0], 36000.0);
}
// ──────────────────────────────────────────────
// NegativeDetector tests
// ──────────────────────────────────────────────
@@ -140,7 +180,7 @@ TEST(NegativeDetectorTest, DetectsDarkImageAsPositive) {
TEST(InverterTest, InvertsNegative) {
Inverter stage;
auto data = make_test_image(10, 10, 60000);
data.film_type = FilmType::ColorNegative;
data.film_type = FilmType::BWNegative; // Use B&W to avoid orange mask sampling
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
@@ -163,6 +203,193 @@ TEST(InverterTest, SkipsPositive) {
EXPECT_NEAR(mean[0], 30000.0, 1.0);
}
TEST(InverterTest, ColorNegativeInversionChangesValues) {
Inverter stage;
// Create an image large enough for border sampling
auto data = make_test_image(200, 200, 55000);
data.film_type = FilmType::ColorNegative;
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
// After orange mask removal and inversion, values should have changed
cv::Scalar mean = cv::mean(result->rgb);
EXPECT_LT(mean[0], 65000.0); // Not all white
}
// ──────────────────────────────────────────────
// ColorCorrector tests
// ──────────────────────────────────────────────
TEST(ColorCorrectorTest, AWBPreservesNeutralGrey) {
ColorCorrector stage;
// A perfectly neutral grey should be unchanged by AWB
auto data = make_test_image(100, 100, 32768);
data.film_type = FilmType::ColorPositive;
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
// All channels should remain equal (neutral)
const std::vector<cv::Mat> channels = [&] {
std::vector<cv::Mat> ch(3);
cv::split(result->rgb, ch);
return ch;
}();
const double b_mean = cv::mean(channels[0])[0];
const double g_mean = cv::mean(channels[1])[0];
const double r_mean = cv::mean(channels[2])[0];
EXPECT_NEAR(b_mean, g_mean, 500.0);
EXPECT_NEAR(g_mean, r_mean, 500.0);
}
TEST(ColorCorrectorTest, SkipsGreyscaleFilm) {
ColorCorrector stage;
auto data = make_test_image(100, 100, 32768);
data.film_type = FilmType::BWNegative;
// Must succeed without error
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
}
// ──────────────────────────────────────────────
// CropProcessor tests
// ──────────────────────────────────────────────
TEST(CropProcessorTest, LevelsAdjustmentRunsWithoutError) {
CropProcessor stage;
auto data = make_gradient_image(256, 256);
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
EXPECT_FALSE(result->rgb.empty());
}
TEST(CropProcessorTest, SharpeningDoesNotClip) {
CropProcessor stage;
// Mid-grey: sharpening should not saturate to 0 or 65535
auto data = make_test_image(100, 100, 32768);
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
cv::Scalar mean = cv::mean(result->rgb);
EXPECT_GT(mean[0], 100.0);
EXPECT_LT(mean[0], 65000.0);
}
TEST(CropProcessorTest, RejectsEmptyImage) {
CropProcessor stage;
ImageData data; // empty rgb
auto result = stage.process(std::move(data));
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error().code, ErrorCode::CropFailed);
}
// ──────────────────────────────────────────────
// AppConfig tests
// ──────────────────────────────────────────────
TEST(AppConfigTest, LoadsValidIniFile) {
// Write a minimal config to a temp file
const auto temp = std::filesystem::temp_directory_path() / "test_config.ini";
{
std::ofstream f{temp};
f << "[batch]\n"
<< "input_dir = /tmp/in\n"
<< "output_dir = /tmp/out\n"
<< "recursive = true\n"
<< "file_extensions = arw,cr2\n"
<< "\n"
<< "[conversion]\n"
<< "film_type = c41\n"
<< "output_format = png8\n"
<< "output_bit_depth = 8\n"
<< "auto_crop = false\n"
<< "sharpen = false\n"
<< "invert = true\n"
<< "\n"
<< "[quality]\n"
<< "jpeg_quality = 80\n"
<< "sharpen_strength = 0.3\n";
}
auto result = AppConfig::load(temp);
ASSERT_TRUE(result.has_value()) << result.error().message;
const AppConfig& cfg = result.value();
EXPECT_EQ(cfg.batch.input_dir, "/tmp/in");
EXPECT_EQ(cfg.batch.output_dir, "/tmp/out");
EXPECT_TRUE(cfg.batch.recursive);
EXPECT_EQ(cfg.batch.file_extensions, "arw,cr2");
EXPECT_EQ(cfg.conversion.film_type, "c41");
EXPECT_EQ(cfg.conversion.output_format, "png8");
EXPECT_EQ(cfg.conversion.output_bit_depth, 8);
EXPECT_FALSE(cfg.conversion.auto_crop);
EXPECT_FALSE(cfg.conversion.sharpen);
EXPECT_TRUE(cfg.conversion.invert);
EXPECT_EQ(cfg.quality.jpeg_quality, 80);
EXPECT_NEAR(cfg.quality.sharpen_strength, 0.3, 0.001);
std::filesystem::remove(temp);
}
TEST(AppConfigTest, RejectsMissingFile) {
auto result = AppConfig::load("/nonexistent/config.ini");
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error().code, ErrorCode::FileNotFound);
}
TEST(AppConfigTest, ParsedExtensionsHaveDots) {
AppConfig cfg;
cfg.batch.file_extensions = "arw, CR2, NEF";
const auto exts = cfg.parsed_extensions();
ASSERT_EQ(exts.size(), 3u);
EXPECT_EQ(exts[0], ".arw");
EXPECT_EQ(exts[1], ".cr2");
EXPECT_EQ(exts[2], ".nef");
}
TEST(AppConfigTest, OutputFormatMapping) {
AppConfig cfg;
cfg.conversion.output_format = "png8";
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_8bit);
cfg.conversion.output_format = "tiff16";
EXPECT_EQ(cfg.output_format(), OutputFormat::TIFF_16bit);
cfg.conversion.output_format = "jpg";
EXPECT_EQ(cfg.output_format(), OutputFormat::JPEG);
cfg.conversion.output_format = "png16";
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_16bit);
cfg.conversion.output_format = "unknown";
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_16bit); // fallback
}
TEST(AppConfigTest, WriteDefaultCreatesFile) {
const auto temp = std::filesystem::temp_directory_path() / "default_config.ini";
std::filesystem::remove(temp); // Ensure it does not exist
auto result = AppConfig::write_default(temp);
ASSERT_TRUE(result.has_value()) << result.error().message;
EXPECT_TRUE(std::filesystem::exists(temp));
EXPECT_GT(std::filesystem::file_size(temp), 0u);
// Verify the written file can be loaded back.
auto reload = AppConfig::load(temp);
ASSERT_TRUE(reload.has_value()) << reload.error().message;
std::filesystem::remove(temp);
}
// ──────────────────────────────────────────────
// Error type tests
// ──────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@
#include "converter/rawloader/RawLoader.h"
#include <filesystem>
#include <fstream>
using namespace photoconv;
@@ -28,7 +29,7 @@ TEST(RawLoaderTest, RejectsUnsupportedFormat) {
// Create a temporary file with unsupported extension
auto temp = std::filesystem::temp_directory_path() / "test.xyz";
{
std::ofstream f(temp);
std::ofstream f{temp};
f << "dummy";
}