#include #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 #include #include 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( static_cast(x + y) / static_cast(width + height) * 65535.0); data.rgb.at(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()); EXPECT_EQ(pipeline.stage_count(), 1); pipeline.add_stage(std::make_unique()); EXPECT_EQ(pipeline.stage_count(), 2); } TEST(PipelineTest, FullPipelineRunsWithoutError) { Pipeline pipeline; pipeline.add_stage(std::make_unique()); pipeline.add_stage(std::make_unique()); pipeline.add_stage(std::make_unique()); pipeline.add_stage(std::make_unique()); pipeline.add_stage(std::make_unique()); 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()); pipeline.add_stage(std::make_unique()); 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 channels = [&] { std::vector 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); }