diff --git a/src/cli/CliRunner.cpp b/src/cli/CliRunner.cpp index 9fd4f23..d9c1859 100644 --- a/src/cli/CliRunner.cpp +++ b/src/cli/CliRunner.cpp @@ -1,4 +1,5 @@ #include "CliRunner.h" + #include "../converter/rawloader/RawLoader.h" #include "../converter/preprocess/Preprocessor.h" #include "../converter/negative/NegativeDetector.h" @@ -7,111 +8,189 @@ #include "../converter/crop/CropProcessor.h" #include "../converter/output/OutputWriter.h" +#include #include #include namespace photoconv { +// ───────────────────────────────────────────────────────────────────────────── +// parse_args +// ───────────────────────────────────────────────────────────────────────────── + std::expected CliRunner::parse_args(int argc, char* argv[]) { Config config; bool reading_inputs = false; for (int i = 1; i < argc; ++i) { - std::string arg{argv[i]}; + const std::string arg{argv[i]}; + + if (arg == "--cli") { + reading_inputs = false; + continue; + } + + if (arg == "--batch") { + config.batch_mode = true; + reading_inputs = false; + continue; + } + + if (arg == "--config") { + reading_inputs = false; + if (i + 1 < argc) { + config.config_file = argv[++i]; + config.batch_mode = true; // --config implies batch mode + } else { + return std::unexpected(make_error( + ErrorCode::InvalidArgument, "--config requires a file path")); + } + continue; + } if (arg == "-i" || arg == "--input") { reading_inputs = true; continue; } + if (arg == "-o" || arg == "--output") { reading_inputs = false; if (i + 1 < argc) { config.output_dir = argv[++i]; } else { return std::unexpected(make_error( - ErrorCode::InvalidArgument, "Missing value for --output")); + ErrorCode::InvalidArgument, "--output requires a directory path")); } continue; } + if (arg == "--format") { reading_inputs = false; if (i + 1 < argc) { config.output_format = argv[++i]; } else { return std::unexpected(make_error( - ErrorCode::InvalidArgument, "Missing value for --format")); + ErrorCode::InvalidArgument, "--format requires a value (png16|png8|tiff16|jpeg)")); } continue; } + if (arg == "--quality") { reading_inputs = false; if (i + 1 < argc) { config.jpeg_quality = std::stoi(argv[++i]); + } else { + return std::unexpected(make_error( + ErrorCode::InvalidArgument, "--quality requires a numeric value")); } continue; } + if (arg == "-v" || arg == "--verbose") { config.verbose = true; reading_inputs = false; continue; } - if (arg == "--cli") { - reading_inputs = false; - continue; - } + if (arg == "-h" || arg == "--help") { std::cout << - "Usage: photo-converter --cli -i -o [options]\n" + "Usage: photo-converter --cli [options] -i \n" + " photo-converter --batch --config config.ini\n" "\n" "Options:\n" - " -i, --input Input image files (RAW or standard)\n" - " -o, --output Output directory (default: output/)\n" - " --format Output format: png16, png8, tiff16, jpeg\n" - " --quality <0-100> JPEG quality (default: 95)\n" - " -v, --verbose Verbose output\n" - " -h, --help Show this help\n" + " --cli CLI mode (no GUI)\n" + " --batch Batch mode (read config for input directory)\n" + " --config INI configuration file (implies --batch)\n" + " -i, --input Input image files (RAW or standard)\n" + " -o, --output Output directory (default: output/)\n" + " --format Output format: png16 png8 tiff16 jpeg\n" + " --quality <0-100> JPEG quality (default: 95)\n" + " -v, --verbose Verbose output\n" + " -h, --help Show this help\n" << std::endl; return std::unexpected(make_error(ErrorCode::InvalidArgument, "Help requested")); } - // If reading inputs or no flag active, treat as input file - if (reading_inputs || arg[0] != '-') { + // If reading_inputs is active or the argument does not start with '-', + // treat it as an input file path. + if (reading_inputs || (!arg.empty() && arg.front() != '-')) { config.input_files.emplace_back(arg); reading_inputs = true; } } - if (config.input_files.empty()) { + // Validation: we need either input files or a config file for batch mode. + if (!config.batch_mode && config.input_files.empty()) { return std::unexpected(make_error( - ErrorCode::InvalidArgument, "No input files specified. Use -i ")); + ErrorCode::InvalidArgument, + "No input files specified. Use -i or --batch --config config.ini")); } return config; } +// ───────────────────────────────────────────────────────────────────────────── +// run +// ───────────────────────────────────────────────────────────────────────────── + std::expected CliRunner::run(const Config& config) const { - // Determine output format - OutputFormat fmt = OutputFormat::PNG_16bit; - if (config.output_format == "png8") fmt = OutputFormat::PNG_8bit; - else if (config.output_format == "tiff16") fmt = OutputFormat::TIFF_16bit; - else if (config.output_format == "jpeg") fmt = OutputFormat::JPEG; + // ── Resolve AppConfig ──────────────────────────────────────────────────── + AppConfig app_cfg{}; - // Build pipeline - 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()); - pipeline.add_stage(std::make_unique( - OutputConfig{config.output_dir, fmt, config.jpeg_quality})); + if (!config.config_file.empty()) { + auto cfg_result = AppConfig::load(config.config_file); + if (!cfg_result.has_value()) { + return std::unexpected(cfg_result.error()); + } + app_cfg = std::move(cfg_result.value()); + std::cout << std::format("[CLI] Loaded config: {}", config.config_file.string()) + << std::endl; + } + // CLI flags override config file values where set explicitly. + if (!config.output_dir.empty()) { + app_cfg.batch.output_dir = config.output_dir; + } + if (!config.output_format.empty() && config.output_format != "png16") { + // "png16" is the default; only override if the user explicitly changed it. + app_cfg.conversion.output_format = config.output_format; + } + if (config.jpeg_quality != 95) { + app_cfg.quality.jpeg_quality = config.jpeg_quality; + } + + // ── Collect input files ────────────────────────────────────────────────── + std::vector files = config.input_files; + + if (files.empty() && config.batch_mode) { + const auto extensions = app_cfg.parsed_extensions(); + files = collect_files(app_cfg.batch.input_dir, extensions, + app_cfg.batch.recursive); + + if (files.empty()) { + std::cerr << std::format( + "[CLI] No matching files found in: {}", + app_cfg.batch.input_dir.string()) << std::endl; + return 0; + } + } + + // ── Build pipeline ─────────────────────────────────────────────────────── RawLoader loader; + Pipeline pipeline = build_pipeline(app_cfg); + + // ── Process files ──────────────────────────────────────────────────────── int success_count = 0; + const auto total = static_cast(files.size()); - for (const auto& file : config.input_files) { - std::cout << std::format("\n[CLI] Processing: {}", file.string()) << std::endl; + for (int idx = 0; idx < total; ++idx) { + const auto& file = files[static_cast(idx)]; + std::cerr << std::format("[{}/{}] Processing {}...", + idx + 1, total, file.filename().string()) + << std::endl; + + // Load image auto load_result = loader.load(file); if (!load_result.has_value()) { std::cerr << std::format("[CLI] Load failed: {}", @@ -119,6 +198,7 @@ std::expected CliRunner::run(const Config& config) const { continue; } + // Run pipeline auto result = pipeline.execute(std::move(load_result.value())); if (!result.has_value()) { std::cerr << std::format("[CLI] Pipeline failed: {}", @@ -129,21 +209,84 @@ std::expected CliRunner::run(const Config& config) const { ++success_count; } - std::cout << std::format("\n[CLI] Done: {}/{} files processed successfully", - success_count, config.input_files.size()) << std::endl; + std::cout << std::format("\n[CLI] Done: {}/{} file(s) converted successfully", + success_count, total) << std::endl; return success_count; } -std::expected CliRunner::parse_format(const std::string& fmt_str) { - if (fmt_str == "png16") return static_cast(OutputFormat::PNG_16bit); - if (fmt_str == "png8") return static_cast(OutputFormat::PNG_8bit); - if (fmt_str == "tiff16") return static_cast(OutputFormat::TIFF_16bit); - if (fmt_str == "jpeg") return static_cast(OutputFormat::JPEG); +// ───────────────────────────────────────────────────────────────────────────── +// collect_files +// ───────────────────────────────────────────────────────────────────────────── - return std::unexpected(make_error( - ErrorCode::InvalidArgument, - std::format("Unknown output format: '{}'. Use: png16, png8, tiff16, jpeg", fmt_str))); +std::vector CliRunner::collect_files( + const std::filesystem::path& dir, + const std::vector& extensions, + const bool recursive) +{ + std::vector result; + + if (!std::filesystem::is_directory(dir)) { + std::cerr << std::format("[CLI] Input directory not found: {}", dir.string()) + << std::endl; + return result; + } + + auto collect_entry = [&](const std::filesystem::directory_entry& entry) { + if (!entry.is_regular_file()) return; + + // Compare extension case-insensitively. + std::string ext = entry.path().extension().string(); + std::ranges::transform(ext, ext.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + const bool match = std::ranges::any_of(extensions, + [&ext](const std::string& e) { return e == ext; }); + + if (match) { + result.push_back(entry.path()); + } + }; + + if (recursive) { + for (const auto& entry : std::filesystem::recursive_directory_iterator{dir}) { + collect_entry(entry); + } + } else { + for (const auto& entry : std::filesystem::directory_iterator{dir}) { + collect_entry(entry); + } + } + + std::ranges::sort(result); + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// build_pipeline +// ───────────────────────────────────────────────────────────────────────────── + +Pipeline CliRunner::build_pipeline(const AppConfig& app_cfg) { + Pipeline pipeline; + + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique()); + + if (app_cfg.conversion.invert) { + pipeline.add_stage(std::make_unique()); + } + + pipeline.add_stage(std::make_unique()); + + pipeline.add_stage(std::make_unique()); + + pipeline.add_stage(std::make_unique(OutputConfig{ + app_cfg.batch.output_dir, + app_cfg.output_format(), + app_cfg.quality.jpeg_quality + })); + + return pipeline; } } // namespace photoconv diff --git a/src/cli/CliRunner.h b/src/cli/CliRunner.h index 4a87c6a..9afd7b2 100644 --- a/src/cli/CliRunner.h +++ b/src/cli/CliRunner.h @@ -2,6 +2,7 @@ #include "../converter/pipeline/Pipeline.h" #include "../converter/pipeline/Error.h" +#include "../config/AppConfig.h" #include #include @@ -13,23 +14,55 @@ namespace photoconv { /** * @brief CLI batch processing runner. * - * Accepts command-line arguments, loads files, runs the pipeline, - * and writes output. Designed to work without Qt/GUI dependencies. + * Accepts command-line arguments, optionally loads an AppConfig from a + * configuration file, discovers input files, runs each through the + * processing pipeline, and writes results to the output directory. * - * Usage: + * Usage (direct file list): + * @code * photo-converter --cli -i input1.arw input2.cr2 -o output/ [--format png16] + * @endcode + * + * Usage (config file / batch mode): + * @code + * photo-converter --batch --config config.ini + * photo-converter --config config.ini # same as --batch + * @endcode + * + * Progress is printed to stderr: + * @code + * [1/42] Processing DSC09246.ARW... + * @endcode */ class CliRunner { public: /** * @brief CLI configuration parsed from command-line arguments. + * + * When a config file is supplied, its values are merged in before + * the explicit CLI flags (CLI flags take precedence). */ struct Config { + /// Input files resolved from -i flags or from AppConfig::batch. std::vector input_files; + + /// Output directory (default: "output"). std::filesystem::path output_dir{"output"}; - std::string output_format{"png16"}; // png16, png8, tiff16, jpeg + + /// Output format string: "png16" | "png8" | "tiff16" | "jpeg". + std::string output_format{"png16"}; + + /// JPEG quality [0, 100]. int jpeg_quality{95}; + + /// Print extra diagnostic information. bool verbose{false}; + + /// Optional path to the INI configuration file. + std::filesystem::path config_file; + + /// Whether batch mode was explicitly requested. + bool batch_mode{false}; }; CliRunner() = default; @@ -38,25 +71,59 @@ public: /** * @brief Parse command-line arguments into Config. * + * Recognised flags: + * - `--cli` Switch to CLI mode (no-op if already in CLI mode). + * - `--batch` Activate batch mode (input from config file). + * - `--config ` Load AppConfig from the given INI file. + * - `-i / --input ` Explicit input file list. + * - `-o / --output ` Output directory. + * - `--format ` Output format (png16, png8, tiff16, jpeg). + * - `--quality <0-100>` JPEG quality. + * - `-v / --verbose` Verbose output. + * - `-h / --help` Print help (returns error with help text). + * * @param argc Argument count. * @param argv Argument values. - * @return Parsed Config or Error. + * @return Parsed Config, or Error on bad arguments. */ [[nodiscard]] static std::expected parse_args(int argc, char* argv[]); /** * @brief Execute batch processing with the given configuration. * + * If Config::batch_mode is true and Config::config_file is set, + * the function discovers files from AppConfig::batch.input_dir. + * Otherwise, it processes Config::input_files directly. + * + * Progress lines are written to stderr: "[1/42] Processing file.arw..." + * Errors are logged to stderr but do not abort the batch. + * * @param config Parsed CLI configuration. - * @return Number of successfully processed files, or Error. + * @return Number of successfully converted files, or Error on fatal failure. */ [[nodiscard]] std::expected run(const Config& config) const; private: /** - * @brief Parse the output format string to OutputFormat enum. + * @brief Collect image files from a directory. + * + * @param dir Directory to scan. + * @param extensions Lowercase dot-prefixed extensions to match. + * @param recursive Whether to recurse into subdirectories. + * @return Sorted list of matching file paths. */ - [[nodiscard]] static std::expected parse_format(const std::string& fmt_str); + [[nodiscard]] static std::vector collect_files( + const std::filesystem::path& dir, + const std::vector& extensions, + bool recursive); + + /** + * @brief Build a ready-to-execute Pipeline from an AppConfig. + * + * @param app_cfg Application configuration. + * @return Configured Pipeline instance. + */ + [[nodiscard]] static Pipeline build_pipeline(const AppConfig& app_cfg); }; } // namespace photoconv diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b220595..fc513ec 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/test_pipeline.cpp b/tests/test_pipeline.cpp index 53aa655..a40cdc0 100644 --- a/tests/test_pipeline.cpp +++ b/tests/test_pipeline.cpp @@ -8,9 +8,13 @@ #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; /** @@ -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( + static_cast(x + y) / static_cast(width + height) * 65535.0); + data.rgb.at(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 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 // ────────────────────────────────────────────── diff --git a/tests/test_rawloader.cpp b/tests/test_rawloader.cpp index 0f6c4fc..2769a58 100644 --- a/tests/test_rawloader.cpp +++ b/tests/test_rawloader.cpp @@ -3,6 +3,7 @@ #include "converter/rawloader/RawLoader.h" #include +#include 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"; }