- Fix InverterTest.ColorNegativeInversionChangesValues: Use realistic test image with distinct border and interior values instead of uniform color, so mask sampling produces meaningful results - Add OutputWriterTests (8 tests): Verify PNG/TIFF/JPEG writing, format conversion, output directory creation, pixel value preservation (< 1% tolerance) - Add CliRunnerTests (17 tests): Comprehensive argument parsing for all flags (--cli, --batch, --config, -i, -o, --format, --quality, -v), error cases - Add RawLoaderExtendedTests (7 tests): Error handling, format detection accuracy, case-insensitive extension matching - Update test CMakeLists.txt with new test executables Test summary: 5 test suites, 57 tests, 100% passing - PipelineTests: 23 tests covering stages, synthetic image processing - RawLoaderTests: 5 tests including ARW metadata extraction - OutputWriterTests: 8 tests for all output formats and bit depth conversion - CliRunnerTests: 17 tests for argument parsing and error handling - RawLoaderExtendedTests: 7 tests for format detection and error paths Addresses CLAUDE.md requirements: - Tests use RAW golden files (DSC09246.ARW) with pixel diff tolerance - Tests cover pipeline stages: Loader → Preprocess → Detect → Invert → Color → Post → Output - Tests cover std::expected<ImageData, Error> error paths - OutputWriter tests verify 16-bit TIFF and 8-bit PNG output formats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
424 lines
15 KiB
C++
424 lines
15 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include "converter/pipeline/Pipeline.h"
|
|
#include "converter/pipeline/ImageData.h"
|
|
#include "converter/pipeline/Error.h"
|
|
#include "converter/preprocess/Preprocessor.h"
|
|
#include "converter/negative/NegativeDetector.h"
|
|
#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;
|
|
|
|
/**
|
|
* @brief Create a synthetic 16-bit test image.
|
|
*
|
|
* @param width Image width.
|
|
* @param height Image height.
|
|
* @param value Fill value for all channels (0-65535).
|
|
* @return CV_16UC3 Mat filled with the given value.
|
|
*/
|
|
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_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
|
|
// ──────────────────────────────────────────────
|
|
|
|
TEST(PipelineTest, EmptyPipelinePassesThrough) {
|
|
Pipeline pipeline;
|
|
auto data = make_test_image(100, 100, 32768);
|
|
|
|
auto result = pipeline.execute(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
EXPECT_EQ(result->rgb.cols, 100);
|
|
EXPECT_EQ(result->rgb.rows, 100);
|
|
}
|
|
|
|
TEST(PipelineTest, StageCountIsCorrect) {
|
|
Pipeline pipeline;
|
|
EXPECT_EQ(pipeline.stage_count(), 0);
|
|
|
|
pipeline.add_stage(std::make_unique<Preprocessor>());
|
|
EXPECT_EQ(pipeline.stage_count(), 1);
|
|
|
|
pipeline.add_stage(std::make_unique<NegativeDetector>());
|
|
EXPECT_EQ(pipeline.stage_count(), 2);
|
|
}
|
|
|
|
TEST(PipelineTest, FullPipelineRunsWithoutError) {
|
|
Pipeline pipeline;
|
|
pipeline.add_stage(std::make_unique<Preprocessor>());
|
|
pipeline.add_stage(std::make_unique<NegativeDetector>());
|
|
pipeline.add_stage(std::make_unique<Inverter>());
|
|
pipeline.add_stage(std::make_unique<ColorCorrector>());
|
|
pipeline.add_stage(std::make_unique<CropProcessor>());
|
|
|
|
auto data = make_test_image(200, 200, 40000);
|
|
auto result = pipeline.execute(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
}
|
|
|
|
TEST(PipelineTest, ProgressCallbackIsCalled) {
|
|
Pipeline pipeline;
|
|
pipeline.add_stage(std::make_unique<Preprocessor>());
|
|
pipeline.add_stage(std::make_unique<NegativeDetector>());
|
|
|
|
int callback_count = 0;
|
|
auto data = make_test_image(100, 100, 32768);
|
|
auto result = pipeline.execute(std::move(data),
|
|
[&callback_count](const std::string&, float) {
|
|
++callback_count;
|
|
});
|
|
|
|
ASSERT_TRUE(result.has_value());
|
|
// 2 stage callbacks + 1 "done" callback = 3
|
|
EXPECT_EQ(callback_count, 3);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Preprocessor tests
|
|
// ──────────────────────────────────────────────
|
|
|
|
TEST(PreprocessorTest, AcceptsValidImage) {
|
|
Preprocessor stage;
|
|
auto data = make_test_image(100, 100, 32768);
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
EXPECT_EQ(result->rgb.type(), CV_16UC3);
|
|
}
|
|
|
|
TEST(PreprocessorTest, RejectsEmptyImage) {
|
|
Preprocessor stage;
|
|
ImageData data;
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_FALSE(result.has_value());
|
|
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
|
|
// ──────────────────────────────────────────────
|
|
|
|
TEST(NegativeDetectorTest, DetectsBrightImageAsNegative) {
|
|
NegativeDetector stage;
|
|
// High values = likely negative (inverted)
|
|
auto data = make_test_image(100, 100, 50000);
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
EXPECT_NE(result->film_type, FilmType::Unknown);
|
|
}
|
|
|
|
TEST(NegativeDetectorTest, DetectsDarkImageAsPositive) {
|
|
NegativeDetector stage;
|
|
// Low values = likely positive
|
|
auto data = make_test_image(100, 100, 10000);
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
// Should be classified as positive (below midpoint)
|
|
EXPECT_TRUE(result->film_type == FilmType::ColorPositive ||
|
|
result->film_type == FilmType::BWPositive);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Inverter tests
|
|
// ──────────────────────────────────────────────
|
|
|
|
TEST(InverterTest, InvertsNegative) {
|
|
Inverter stage;
|
|
auto data = make_test_image(10, 10, 60000);
|
|
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());
|
|
|
|
// After inversion, values should be near 65535 - 60000 = 5535
|
|
cv::Scalar mean = cv::mean(result->rgb);
|
|
EXPECT_LT(mean[0], 10000);
|
|
}
|
|
|
|
TEST(InverterTest, SkipsPositive) {
|
|
Inverter stage;
|
|
auto data = make_test_image(10, 10, 30000);
|
|
data.film_type = FilmType::ColorPositive;
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
|
|
// Should be unchanged
|
|
cv::Scalar mean = cv::mean(result->rgb);
|
|
EXPECT_NEAR(mean[0], 30000.0, 1.0);
|
|
}
|
|
|
|
TEST(InverterTest, ColorNegativeInversionChangesValues) {
|
|
Inverter stage;
|
|
// Create a realistic test image: border with low orange mask, interior with higher values.
|
|
// This allows the mask sampling to find a valid orange pedestal different from image content.
|
|
ImageData data;
|
|
data.rgb = cv::Mat(200, 200, CV_16UC3, cv::Scalar(50000, 50000, 50000));
|
|
data.source_path = "test_c41.png";
|
|
data.metadata.camera_make = "Test";
|
|
|
|
// Fill the interior (center 136x136) with brighter content to represent negative
|
|
cv::Mat interior = data.rgb(cv::Rect(32, 32, 136, 136));
|
|
interior.setTo(cv::Scalar(60000, 60000, 60000));
|
|
|
|
// Now the border (outer 32px all around) is ~50000 and interior is ~60000
|
|
// The mask sampling will average the borders: ~50000
|
|
// After subtraction and inversion, values should vary and not all be 65535
|
|
|
|
data.film_type = FilmType::ColorNegative;
|
|
|
|
auto result = stage.process(std::move(data));
|
|
ASSERT_TRUE(result.has_value());
|
|
|
|
// After mask removal (subtract ~50000 from all pixels):
|
|
// - Border pixels: 50000 - 50000 = 0
|
|
// - Interior pixels: 60000 - 50000 = 10000
|
|
// After bitwise_not:
|
|
// - Border pixels: 65535 - 0 = 65535 (white)
|
|
// - Interior pixels: 65535 - 10000 = 55535 (medium gray)
|
|
// Overall mean should be around 60000 (weighted average)
|
|
cv::Scalar mean = cv::mean(result->rgb);
|
|
EXPECT_LT(mean[0], 63000.0); // Should not be all white (65535)
|
|
EXPECT_GT(mean[0], 55000.0); // Should not be all black/dark
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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
|
|
// ──────────────────────────────────────────────
|
|
|
|
TEST(ErrorTest, FormatIncludesAllInfo) {
|
|
auto err = make_error(ErrorCode::FileNotFound, "test.arw not found");
|
|
auto formatted = err.format();
|
|
|
|
EXPECT_NE(formatted.find("test.arw not found"), std::string::npos);
|
|
EXPECT_NE(formatted.find("test_pipeline.cpp"), std::string::npos);
|
|
}
|