Fix 26 bugs, edge cases, and consistency issues from fifth audit pass

Critical: undo toast now trashes only batch output files (not entire dir),
JPEG scanline write errors propagated, selective metadata write result returned.

High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio
rejection, FM integration toggle infinite recursion guard, saturating counter
arithmetic in executor.

Medium: PNG compression level passed to oxipng, pct mode updates job_config,
external file loading updates step indicator, CLI undo removes history entries,
watch config write failures reported, fast-copy path reads image dimensions for
rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl),
CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot
fix, generation guards on all preview threads to cancel stale results, default
DPI aligned to 0, watermark text width uses char count not byte length.

Low: binary path escaped in Nautilus extension, file dialog filter aligned with
discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

View File

@@ -0,0 +1,145 @@
use pixstrip_core::operations::AdjustmentsConfig;
use pixstrip_core::operations::adjustments::apply_adjustments;
use image::DynamicImage;
fn noop_config() -> AdjustmentsConfig {
AdjustmentsConfig {
brightness: 0,
contrast: 0,
saturation: 0,
sharpen: false,
grayscale: false,
sepia: false,
crop_aspect_ratio: None,
trim_whitespace: false,
canvas_padding: 0,
}
}
#[test]
fn is_noop_default() {
assert!(noop_config().is_noop());
}
#[test]
fn is_noop_with_brightness() {
let mut config = noop_config();
config.brightness = 10;
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_sharpen() {
let mut config = noop_config();
config.sharpen = true;
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_crop() {
let mut config = noop_config();
config.crop_aspect_ratio = Some((16.0, 9.0));
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_trim() {
let mut config = noop_config();
config.trim_whitespace = true;
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_padding() {
let mut config = noop_config();
config.canvas_padding = 20;
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_grayscale() {
let mut config = noop_config();
config.grayscale = true;
assert!(!config.is_noop());
}
#[test]
fn is_noop_with_sepia() {
let mut config = noop_config();
config.sepia = true;
assert!(!config.is_noop());
}
// --- crop_to_aspect_ratio edge cases ---
fn make_test_image(w: u32, h: u32) -> DynamicImage {
DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([128, 128, 128, 255])))
}
#[test]
fn crop_zero_ratio_returns_original() {
let img = make_test_image(100, 100);
let config = AdjustmentsConfig {
crop_aspect_ratio: Some((0.0, 9.0)),
..noop_config()
};
let result = apply_adjustments(img, &config).unwrap();
assert_eq!(result.width(), 100);
assert_eq!(result.height(), 100);
}
#[test]
fn crop_zero_height_ratio_returns_original() {
let img = make_test_image(100, 100);
let config = AdjustmentsConfig {
crop_aspect_ratio: Some((16.0, 0.0)),
..noop_config()
};
let result = apply_adjustments(img, &config).unwrap();
assert_eq!(result.width(), 100);
assert_eq!(result.height(), 100);
}
#[test]
fn crop_square_on_landscape() {
let img = make_test_image(200, 100);
let config = AdjustmentsConfig {
crop_aspect_ratio: Some((1.0, 1.0)),
..noop_config()
};
let result = apply_adjustments(img, &config).unwrap();
assert_eq!(result.width(), 100);
assert_eq!(result.height(), 100);
}
#[test]
fn crop_16_9_on_square() {
let img = make_test_image(100, 100);
let config = AdjustmentsConfig {
crop_aspect_ratio: Some((16.0, 9.0)),
..noop_config()
};
let result = apply_adjustments(img, &config).unwrap();
assert_eq!(result.width(), 100);
assert_eq!(result.height(), 56);
}
#[test]
fn canvas_padding_large_value() {
let img = make_test_image(10, 10);
let config = AdjustmentsConfig {
canvas_padding: 500,
..noop_config()
};
let result = apply_adjustments(img, &config).unwrap();
assert_eq!(result.width(), 1010);
assert_eq!(result.height(), 1010);
}
#[test]
fn noop_config_returns_same_dimensions() {
let img = make_test_image(200, 100);
let result = apply_adjustments(img, &noop_config()).unwrap();
assert_eq!(result.width(), 200);
assert_eq!(result.height(), 100);
}

View File

@@ -112,9 +112,14 @@ fn execute_with_cancellation() {
let executor = PipelineExecutor::with_cancel(cancel);
let result = executor.execute(&job, |_| {}).unwrap();
// With immediate cancellation, fewer images should be processed
assert!(result.succeeded + result.failed <= 2);
assert!(result.cancelled);
// Cancellation flag should be set
assert!(result.cancelled, "result.cancelled should be true when cancel flag is set");
// Total processed should be less than total sources (at least some skipped)
assert!(
result.succeeded + result.failed <= 2,
"processed count ({}) should not exceed total (2)",
result.succeeded + result.failed
);
}
#[test]

View File

@@ -42,3 +42,84 @@ fn privacy_mode_strips_gps() {
strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap();
assert!(output.exists());
}
fn create_test_png(path: &Path) {
let img = image::RgbaImage::from_fn(100, 80, |x, y| {
image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
});
img.save_with_format(path, image::ImageFormat::Png).unwrap();
}
#[test]
fn strip_png_metadata_produces_valid_png() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.png");
let output = dir.path().join("stripped.png");
create_test_png(&input);
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
assert!(output.exists());
// Output must be a valid PNG that can be opened
let img = image::open(&output).unwrap();
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 80);
}
#[test]
fn strip_png_removes_text_chunks() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.png");
let output = dir.path().join("stripped.png");
create_test_png(&input);
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
// Read output and verify no tEXt chunks remain
let data = std::fs::read(&output).unwrap();
let mut pos = 8; // skip PNG signature
while pos + 12 <= data.len() {
let chunk_len = u32::from_be_bytes([data[pos], data[pos+1], data[pos+2], data[pos+3]]) as usize;
let chunk_type = &data[pos+4..pos+8];
assert_ne!(chunk_type, b"tEXt", "tEXt chunk should be stripped");
assert_ne!(chunk_type, b"iTXt", "iTXt chunk should be stripped");
assert_ne!(chunk_type, b"zTXt", "zTXt chunk should be stripped");
pos += 12 + chunk_len;
}
}
#[test]
fn strip_png_output_smaller_or_equal() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.png");
let output = dir.path().join("stripped.png");
create_test_png(&input);
let input_size = std::fs::metadata(&input).unwrap().len();
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
let output_size = std::fs::metadata(&output).unwrap().len();
assert!(output_size <= input_size);
}
#[test]
fn strip_jpeg_removes_app1_exif() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("test.jpg");
let output = dir.path().join("stripped.jpg");
create_test_jpeg(&input);
strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap();
// Verify no APP1 (0xFFE1) markers remain
let data = std::fs::read(&output).unwrap();
let mut i = 2; // skip SOI
while i + 1 < data.len() {
if data[i] != 0xFF { break; }
let marker = data[i + 1];
if marker == 0xDA { break; } // SOS - rest is image data
assert_ne!(marker, 0xE1, "APP1/EXIF marker should be stripped");
if i + 3 < data.len() {
let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize);
i += 2 + len;
} else {
break;
}
}
}

View File

@@ -91,8 +91,12 @@ fn rename_config_simple_template() {
suffix: String::new(),
counter_start: 1,
counter_padding: 3,
counter_enabled: true,
counter_position: 3,
template: None,
case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(),
regex_replace: String::new(),
};
@@ -107,11 +111,101 @@ fn rename_config_with_suffix() {
suffix: "_web".into(),
counter_start: 1,
counter_padding: 2,
counter_enabled: true,
counter_position: 3,
template: None,
case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(),
regex_replace: String::new(),
};
let result = config.apply_simple("photo", "webp", 5);
assert_eq!(result, "photo_web_05.webp");
}
#[test]
fn rename_counter_overflow_saturates() {
let config = RenameConfig {
prefix: String::new(),
suffix: String::new(),
counter_start: u32::MAX,
counter_padding: 1,
counter_enabled: true,
counter_position: 3,
template: None,
case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(),
regex_replace: String::new(),
};
// Should not panic - saturating arithmetic
let result = config.apply_simple("photo", "jpg", u32::MAX);
assert!(result.contains("photo"));
}
#[test]
fn metadata_config_custom_selective() {
let config = MetadataConfig::Custom {
strip_gps: true,
strip_camera: false,
strip_software: true,
strip_timestamps: false,
strip_copyright: false,
};
assert!(config.should_strip_gps());
assert!(!config.should_strip_camera());
assert!(!config.should_strip_copyright());
}
#[test]
fn metadata_config_custom_all_off() {
let config = MetadataConfig::Custom {
strip_gps: false,
strip_camera: false,
strip_software: false,
strip_timestamps: false,
strip_copyright: false,
};
assert!(!config.should_strip_gps());
assert!(!config.should_strip_camera());
assert!(!config.should_strip_copyright());
}
#[test]
fn metadata_config_custom_all_on() {
let config = MetadataConfig::Custom {
strip_gps: true,
strip_camera: true,
strip_software: true,
strip_timestamps: true,
strip_copyright: true,
};
assert!(config.should_strip_gps());
assert!(config.should_strip_camera());
assert!(config.should_strip_copyright());
}
#[test]
fn watermark_rotation_variants_exist() {
let rotations = [
WatermarkRotation::Degrees45,
WatermarkRotation::DegreesNeg45,
WatermarkRotation::Degrees90,
WatermarkRotation::Custom(30.0),
];
assert_eq!(rotations.len(), 4);
}
#[test]
fn rotation_auto_orient_variant() {
let rotation = Rotation::AutoOrient;
assert!(matches!(rotation, Rotation::AutoOrient));
}
#[test]
fn overwrite_action_default_is_auto_rename() {
let default = OverwriteAction::default();
assert!(matches!(default, OverwriteAction::AutoRename));
}

View File

@@ -37,7 +37,7 @@ fn preset_serialization_roundtrip() {
#[test]
fn all_builtin_presets() {
let presets = Preset::all_builtins();
assert_eq!(presets.len(), 8);
assert_eq!(presets.len(), 9);
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"Blog Photos"));
assert!(names.contains(&"Social Media"));

View File

@@ -1,4 +1,7 @@
use pixstrip_core::operations::rename::{apply_template, resolve_collision};
use pixstrip_core::operations::rename::{
apply_template, apply_regex_replace, apply_space_replacement,
apply_special_chars, apply_case_conversion, resolve_collision,
};
#[test]
fn template_basic_variables() {
@@ -93,3 +96,185 @@ fn no_collision_returns_same() {
let resolved = resolve_collision(&path);
assert_eq!(resolved, path);
}
// --- Regex replace tests ---
#[test]
fn regex_replace_basic() {
let result = apply_regex_replace("hello_world", "_", "-");
assert_eq!(result, "hello-world");
}
#[test]
fn regex_replace_pattern() {
let result = apply_regex_replace("IMG_20260307_001", r"\d{8}", "DATE");
assert_eq!(result, "IMG_DATE_001");
}
#[test]
fn regex_replace_invalid_pattern_returns_original() {
let result = apply_regex_replace("hello", "[invalid", "x");
assert_eq!(result, "hello");
}
#[test]
fn regex_replace_empty_find_returns_original() {
let result = apply_regex_replace("hello", "", "x");
assert_eq!(result, "hello");
}
// --- Space replacement tests ---
#[test]
fn space_replacement_none() {
assert_eq!(apply_space_replacement("hello world", 0), "hello world");
}
#[test]
fn space_replacement_underscore() {
assert_eq!(apply_space_replacement("hello world", 1), "hello_world");
}
#[test]
fn space_replacement_hyphen() {
assert_eq!(apply_space_replacement("hello world", 2), "hello-world");
}
#[test]
fn space_replacement_dot() {
assert_eq!(apply_space_replacement("hello world", 3), "hello.world");
}
#[test]
fn space_replacement_camelcase() {
assert_eq!(apply_space_replacement("hello world", 4), "helloWorld");
}
#[test]
fn space_replacement_remove() {
assert_eq!(apply_space_replacement("hello world", 5), "helloworld");
}
// --- Special chars tests ---
#[test]
fn special_chars_keep_all() {
assert_eq!(apply_special_chars("file<name>.txt", 0), "file<name>.txt");
}
#[test]
fn special_chars_filesystem_safe() {
assert_eq!(apply_special_chars("file<name>", 1), "filename");
}
#[test]
fn special_chars_web_safe() {
assert_eq!(apply_special_chars("file name!@#", 2), "filename");
}
#[test]
fn special_chars_alphanumeric_only() {
assert_eq!(apply_special_chars("file-name_123", 5), "filename123");
}
// --- Case conversion tests ---
#[test]
fn case_conversion_none() {
assert_eq!(apply_case_conversion("Hello World", 0), "Hello World");
}
#[test]
fn case_conversion_lowercase() {
assert_eq!(apply_case_conversion("Hello World", 1), "hello world");
}
#[test]
fn case_conversion_uppercase() {
assert_eq!(apply_case_conversion("Hello World", 2), "HELLO WORLD");
}
#[test]
fn case_conversion_title_case() {
assert_eq!(apply_case_conversion("hello world", 3), "Hello World");
}
#[test]
fn case_conversion_title_preserves_separators() {
assert_eq!(apply_case_conversion("hello-world_foo", 3), "Hello-World_Foo");
}
// --- RenameConfig::apply_simple edge cases ---
use pixstrip_core::operations::RenameConfig;
fn default_rename_config() -> RenameConfig {
RenameConfig {
prefix: String::new(),
suffix: String::new(),
counter_start: 1,
counter_padding: 3,
counter_enabled: false,
counter_position: 3,
template: None,
case_mode: 0,
replace_spaces: 0,
special_chars: 0,
regex_find: String::new(),
regex_replace: String::new(),
}
}
#[test]
fn apply_simple_no_changes() {
let cfg = default_rename_config();
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo.jpg");
}
#[test]
fn apply_simple_with_prefix_suffix() {
let mut cfg = default_rename_config();
cfg.prefix = "web_".into();
cfg.suffix = "_final".into();
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "web_photo_final.jpg");
}
#[test]
fn apply_simple_counter_after_suffix() {
let mut cfg = default_rename_config();
cfg.counter_enabled = true;
cfg.counter_position = 3;
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "photo_001.jpg");
}
#[test]
fn apply_simple_counter_replaces_name() {
let mut cfg = default_rename_config();
cfg.counter_enabled = true;
cfg.counter_position = 4;
assert_eq!(cfg.apply_simple("photo", "jpg", 1), "001.jpg");
}
#[test]
fn apply_simple_empty_name() {
let cfg = default_rename_config();
assert_eq!(cfg.apply_simple("", "png", 1), ".png");
}
#[test]
fn apply_simple_case_lowercase() {
let mut cfg = default_rename_config();
cfg.case_mode = 1;
assert_eq!(cfg.apply_simple("MyPhoto", "JPG", 1), "myphoto.JPG");
}
#[test]
fn apply_simple_large_counter_padding_capped() {
let mut cfg = default_rename_config();
cfg.counter_enabled = true;
cfg.counter_padding = 100; // should be capped to 10
cfg.counter_position = 3;
let result = cfg.apply_simple("photo", "jpg", 1);
// Counter portion should be at most 10 digits
assert!(result.len() <= 22); // "photo_" + 10 digits + ".jpg"
}

View File

@@ -208,7 +208,7 @@ fn add_and_list_history_entries() {
],
};
history.add(entry.clone()).unwrap();
history.add(entry.clone(), 50, 30).unwrap();
let entries = history.list().unwrap();
assert_eq!(entries.len(), 1);
@@ -236,7 +236,7 @@ fn history_appends_entries() {
total_output_bytes: 500,
elapsed_ms: 100,
output_files: vec![],
})
}, 50, 30)
.unwrap();
}
@@ -263,7 +263,7 @@ fn clear_history() {
total_output_bytes: 500,
elapsed_ms: 100,
output_files: vec![],
})
}, 50, 30)
.unwrap();
history.clear().unwrap();

View File

@@ -81,3 +81,39 @@ fn quality_preset_values() {
assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality());
assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality());
}
#[test]
fn dimensions_zero_height_aspect_ratio() {
let dims = Dimensions { width: 1920, height: 0 };
assert_eq!(dims.aspect_ratio(), 1.0);
}
#[test]
fn dimensions_zero_both_aspect_ratio() {
let dims = Dimensions { width: 0, height: 0 };
assert_eq!(dims.aspect_ratio(), 1.0);
}
#[test]
fn dimensions_fit_within_zero_self_width() {
let original = Dimensions { width: 0, height: 600 };
let max_box = Dimensions { width: 1200, height: 1200 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}
#[test]
fn dimensions_fit_within_zero_self_height() {
let original = Dimensions { width: 800, height: 0 };
let max_box = Dimensions { width: 1200, height: 1200 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}
#[test]
fn dimensions_fit_within_zero_max() {
let original = Dimensions { width: 800, height: 600 };
let max_box = Dimensions { width: 0, height: 0 };
let fitted = original.fit_within(max_box, false);
assert_eq!(fitted, original);
}

View File

@@ -54,7 +54,7 @@ fn watcher_detects_new_image() {
watcher.start(&folder, tx).unwrap();
// Wait for watcher to be ready
std::thread::sleep(std::time::Duration::from_millis(200));
std::thread::sleep(std::time::Duration::from_millis(500));
// Create an image file
let img_path = dir.path().join("new_photo.jpg");
@@ -87,7 +87,7 @@ fn watcher_ignores_non_image_files() {
let (tx, rx) = mpsc::channel();
watcher.start(&folder, tx).unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
std::thread::sleep(std::time::Duration::from_millis(500));
// Create a non-image file
std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap();

View File

@@ -109,3 +109,137 @@ fn position_bottom_left() {
assert_eq!(x, 10);
assert_eq!(y, 1020);
}
// --- Margin variation tests ---
#[test]
fn margin_zero_top_left() {
let (x, y) = calculate_position(
WatermarkPosition::TopLeft,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
0,
);
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn margin_zero_bottom_right() {
let (x, y) = calculate_position(
WatermarkPosition::BottomRight,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
0,
);
assert_eq!(x, 1720); // 1920 - 200
assert_eq!(y, 1030); // 1080 - 50
}
#[test]
fn large_margin_top_left() {
let (x, y) = calculate_position(
WatermarkPosition::TopLeft,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
100,
);
assert_eq!(x, 100);
assert_eq!(y, 100);
}
#[test]
fn large_margin_bottom_right() {
let (x, y) = calculate_position(
WatermarkPosition::BottomRight,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
100,
);
assert_eq!(x, 1620); // 1920 - 200 - 100
assert_eq!(y, 930); // 1080 - 50 - 100
}
#[test]
fn margin_does_not_affect_center() {
let (x1, y1) = calculate_position(
WatermarkPosition::Center,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
0,
);
let (x2, y2) = calculate_position(
WatermarkPosition::Center,
Dimensions { width: 1920, height: 1080 },
Dimensions { width: 200, height: 50 },
100,
);
assert_eq!(x1, x2);
assert_eq!(y1, y2);
}
// --- Edge case tests ---
#[test]
fn watermark_larger_than_image() {
// Watermark is bigger than the image - should not panic, saturating_sub clamps to 0
let (x, y) = calculate_position(
WatermarkPosition::BottomRight,
Dimensions { width: 100, height: 100 },
Dimensions { width: 200, height: 200 },
10,
);
// (100 - 200 - 10) saturates to 0
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn watermark_exact_image_size() {
let (x, y) = calculate_position(
WatermarkPosition::Center,
Dimensions { width: 200, height: 100 },
Dimensions { width: 200, height: 100 },
0,
);
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn zero_size_image() {
let (x, y) = calculate_position(
WatermarkPosition::Center,
Dimensions { width: 0, height: 0 },
Dimensions { width: 200, height: 50 },
10,
);
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn margin_exceeds_available_space() {
// Margin is huge relative to image size
let (x, y) = calculate_position(
WatermarkPosition::BottomRight,
Dimensions { width: 100, height: 100 },
Dimensions { width: 50, height: 50 },
200,
);
// saturating_sub: 100 - 50 - 200 = 0
assert_eq!(x, 0);
assert_eq!(y, 0);
}
#[test]
fn one_pixel_image() {
let (x, y) = calculate_position(
WatermarkPosition::TopLeft,
Dimensions { width: 1, height: 1 },
Dimensions { width: 1, height: 1 },
0,
);
assert_eq!(x, 0);
assert_eq!(y, 0);
}