From cce34044f0403c342540f50f43798f6ee612208d Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 1 Aug 2026 23:07:52 -0500 Subject: [PATCH] fix(apple): HDRImageType printed invented values; guard test reachability Completes the revival of the 21 unreachable test files. Apple and Samsung were the two suites left; both were part fabrication, part real. ## The production bug the dead tests were hiding `DECODE_HDR_TYPE` (Apple.pm 0x000a HDRImageType) carried nine invented values: 0 Off | 1 HDR | 2 HDR (Original) | 3 Auto HDR | 4 Smart HDR 5..8 Smart HDR 2 .. Smart HDR 5 ExifTool 13.59 Apple.pm declares exactly two: 3 => 'HDR Image' 4 => 'Original Image' So on a real iPhone file oxidex printed **"Auto HDR" where ExifTool prints "HDR Image"**, and **"Smart HDR" where ExifTool prints "Original Image"** -- under the real tag name `Apple:HDRImageType`. Per AGENTS.md, a plausible-but-wrong value under a real tag name is worse than an absent tag: it does not crash and nothing downstream can tell. The undeclared test asserted `0 => "Off"` and `4 => "Smart HDR"`, so declaring it would have *passed* and pinned the wrong values. Table trimmed to ExifTool's two entries; unmapped values now fall through to "Unknown (N)", matching ExifTool. Three in-repo assertions that encoded the old values are corrected, and a new test pins `0 => "Unknown (0)"` so the invented labels cannot return. ## apple_makernotes_tests.rs: 18 -> 9 Deleted 10 tests asserting names with 0 hits in ExifTool 13.59: FacingCamera, LensModel, NightMode, PortraitMode, SceneDetection. Two more asserted SemanticStyle as a SHORT enum at 0x2E -- wrong twice: ExifTool puts SemanticStyle at 0x0040 and decodes it with ConvertPLIST, and 0x002e is CameraType. Rewrote the HDR tests to ExifTool's values, added the unmapped guard, and rebuilt test_apple_multiple_tags on two verified tags (0x000a HDRImageType, 0x0005 AETarget). ## samsung_makernotes_tests.rs: 23 -> 6 Deleted 17 tests asserting names with 0 hits: SceneOptimizer, SceneType, SingleTake, ExpertRAW, NightMode, GalaxyLensType, ZoomLevel, PortraitEffect, DirectorsView, ProMode, SuperSteady, FoodMode, ObjectTracking, MultiFrameNoiseReduction (Sony.pm only). Two near-misses recorded in the file: Samsung::PortraitEffect is a real *table* but JSON/string-keyed, yielding PortraitEffectID etc., not a `Samsung:PortraitEffect` MakerNote tag; SceneType lives in FlashPix/Exif/DICOM. Kept 4 passing: parser_trait, MakerNoteVersion (0x0001), DeviceType (0x0002 => 0x2000 'High-end NX Camera'), and SamsungModelID retargeted from 0x100123a => "NX1" (absent from ExifTool; its only NX1 is commented out at 0x5001038) to 0x100101c => 'NX10', the one mapping where oxidex's MODEL_ID_DECODER and ExifTool agree. Two ColorSpace tests are #[ignore]d, not deleted: the PrintConv is right but SAMSUNG_COLOR_SPACE is 0x0221, and 0x0221 appears nowhere in Samsung.pm -- ExifTool puts ColorSpace at 0xa011 in Samsung::Type2. They encode ExifTool's id so they fail until the constant is fixed; bending them to 0x0221 would pin the defect and deleting them would lose it. ## The guard tests/test_file_reachability.rs re-derives the reachable set from the `tests/*.rs` roots by following mod / #[path] edges, and fails listing any orphan. It independently reproduced the original 21-file set while the work was in progress. tests/ffi/build.rs is exempt: it is a Cargo build script, not a test module. 3354 lib + 567 integration + 17 unit + 1 reachability tests pass under --all-features. cargo clippy --all-features -- -D warnings clean. Co-Authored-By: Claude Opus 5 --- src/parsers/tiff/makernotes/apple.rs | 42 +- .../tiff/makernotes/registries/apple.rs | 12 +- tests/integration.rs | 6 + tests/integration/apple_makernotes_tests.rs | 301 ++++---------- tests/integration/samsung_makernotes_tests.rs | 370 +++++------------- tests/test_file_reachability.rs | 244 ++++++++++++ 6 files changed, 444 insertions(+), 531 deletions(-) create mode 100644 tests/test_file_reachability.rs diff --git a/src/parsers/tiff/makernotes/apple.rs b/src/parsers/tiff/makernotes/apple.rs index 917736e25..5e00ccd8d 100644 --- a/src/parsers/tiff/makernotes/apple.rs +++ b/src/parsers/tiff/makernotes/apple.rs @@ -122,19 +122,24 @@ const BPLIST_MAGIC: &[u8] = b"bplist"; // Decoders for Apple MakerNote tag values. These convert numeric values to // human-readable strings based on ExifTool's Apple.pm definitions. -// Decodes Apple HDR image type -// Values observed from various iPhone models +// Decodes Apple HDR image type (Apple.pm 0x000a HDRImageType). +// +// ExifTool 13.59 declares exactly two values here: +// 3 => 'HDR Image' +// 4 => 'Original Image' +// (its `2` is commented out as unidentified, seen on iPad mini 2). +// +// This table previously carried nine invented entries -- 0 "Off", 1 "HDR", +// 2 "HDR (Original)", 3 "Auto HDR", 4 "Smart HDR", 5-8 "Smart HDR 2".."5" -- +// so a real iPhone file printed "Auto HDR" where ExifTool prints "HDR Image", +// and "Smart HDR" where ExifTool prints "Original Image". Wrong values under a +// real tag name do not crash and nothing downstream can tell, which is why +// AGENTS.md requires omitting rather than approximating. Unmapped values fall +// through to "Unknown (N)", matching ExifTool. const_decoder! { pub DECODE_HDR_TYPE, i16, [ - (0, "Off"), - (1, "HDR"), - (2, "HDR (Original)"), - (3, "Auto HDR"), - (4, "Smart HDR"), - (5, "Smart HDR 2"), - (6, "Smart HDR 3"), - (7, "Smart HDR 4"), - (8, "Smart HDR 5"), + (3, "HDR Image"), + (4, "Original Image"), ] } @@ -811,10 +816,12 @@ mod tests { #[test] fn test_decode_hdr_type() { - assert_eq!(DECODE_HDR_TYPE.decode(0), "Off"); - assert_eq!(DECODE_HDR_TYPE.decode(1), "HDR"); - assert_eq!(DECODE_HDR_TYPE.decode(4), "Smart HDR"); - assert_eq!(DECODE_HDR_TYPE.decode(8), "Smart HDR 5"); + // ExifTool 13.59 Apple.pm 0x000a: the PrintConv has exactly these two. + assert_eq!(DECODE_HDR_TYPE.decode(3), "HDR Image"); + assert_eq!(DECODE_HDR_TYPE.decode(4), "Original Image"); + // Everything else is unmapped in ExifTool and must not be invented. + assert_eq!(DECODE_HDR_TYPE.decode(0), "Unknown (0)"); + assert_eq!(DECODE_HDR_TYPE.decode(8), "Unknown (8)"); } #[test] @@ -910,7 +917,7 @@ mod tests { // Create minimal IFD with one entry data.extend_from_slice(&[0x01, 0x00]); // 1 entry - // HDR tag entry (tag=0x000A, type=3 (SHORT), count=1, value=4 (Smart HDR)) + // HDR tag entry (tag=0x000A, type=3 (SHORT), count=1, value=4) data.extend_from_slice(&[0x0A, 0x00]); // Tag data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 @@ -920,9 +927,10 @@ mod tests { let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); assert!(result.is_ok()); + // ExifTool 13.59 Apple.pm 0x000a: 4 => 'Original Image'. assert_eq!( tags.get("Apple:HDRImageType"), - Some(&"Smart HDR".to_string()) + Some(&"Original Image".to_string()) ); } diff --git a/src/parsers/tiff/makernotes/registries/apple.rs b/src/parsers/tiff/makernotes/registries/apple.rs index 5081db2b5..abf43d2e3 100644 --- a/src/parsers/tiff/makernotes/registries/apple.rs +++ b/src/parsers/tiff/makernotes/registries/apple.rs @@ -102,7 +102,7 @@ const APPLE_SEMANTIC_STYLE_PRESET: u16 = 0x0042; /// # Example /// ```ignore /// let registry = apple_registry(); -/// let hdr_type = registry.decode_i16(0x000A, 4); // "Smart HDR" +/// let hdr_type = registry.decode_i16(0x000A, 4); // "Original Image" /// ``` pub fn apple_registry() -> TagRegistry { TagRegistry::new() @@ -260,10 +260,14 @@ mod tests { #[test] fn test_hdr_type_decoding() { + // ExifTool 13.59 Apple.pm 0x000a HDRImageType PrintConv: 3 and 4 only. let registry = apple_registry(); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 0), "Off"); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 4), "Smart HDR"); - assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 8), "Smart HDR 5"); + assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 3), "HDR Image"); + assert_eq!( + registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 4), + "Original Image" + ); + assert_eq!(registry.decode_i16(APPLE_HDR_IMAGE_TYPE, 0), "Unknown (0)"); } #[test] diff --git a/tests/integration.rs b/tests/integration.rs index 76ed7d51a..f6cc7ab6b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -168,5 +168,11 @@ mod ape_integration_tests; // data as expected behaviour. See the commit that removed them for the // name-by-name evidence. +#[path = "integration/apple_makernotes_tests.rs"] +mod apple_makernotes_tests; + +#[path = "integration/samsung_makernotes_tests.rs"] +mod samsung_makernotes_tests; + #[path = "forensic/mod.rs"] mod forensic; diff --git a/tests/integration/apple_makernotes_tests.rs b/tests/integration/apple_makernotes_tests.rs index 195880e1a..f401182c4 100644 --- a/tests/integration/apple_makernotes_tests.rs +++ b/tests/integration/apple_makernotes_tests.rs @@ -1,20 +1,43 @@ //! Integration tests for Apple (iPhone/iPad) MakerNotes parser //! -//! Tests the Apple MakerNotes parsing functionality including: -//! - MakerNoteParser trait implementation -//! - Header validation -//! - Tag extraction from synthetic test data -//! - HDR mode detection -//! - Portrait Mode effects -//! - Live Photo status -//! - Multi-camera lens identification -//! - Semantic Styles +//! This file was undeclared to Cargo and had never compiled. Eleven of its +//! eighteen tests asserted tag names that do not exist in ExifTool's Apple.pm: +//! `Apple:FacingCamera`, `Apple:LensModel`, `Apple:NightMode`, +//! `Apple:PortraitMode` and `Apple:SceneDetection` (0 hits each across all of +//! ExifTool 13.59). Those are deleted rather than "fixed", because there is no +//! real tag to point them at. +//! +//! Two more asserted `Apple:SemanticStyle` as a SHORT enum at tag 0x2E. That +//! is wrong twice over: ExifTool puts SemanticStyle at 0x0040 and decodes it +//! with `ConvertPLIST` (it is a property list, not an enum), and 0x002e is +//! CameraType. Deleted for the same reason. +//! +//! What survives is checked against ExifTool 13.59 Apple.pm by tag id and by +//! PrintConv value. use oxidex::parsers::tiff::ifd_parser::ByteOrder; use oxidex::parsers::tiff::makernotes::apple::AppleParser; use oxidex::parsers::tiff::makernotes::shared::MakerNoteParser; use std::collections::HashMap; +/// Builds a little-endian IFD body: entry count followed by 12-byte entries +/// with the value inline. +fn ifd(entries: &[(u16, u16, u32)]) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, format, value) in entries { + data.extend_from_slice(&tag.to_le_bytes()); + data.extend_from_slice(&format.to_le_bytes()); + data.extend_from_slice(&1u32.to_le_bytes()); // Count: 1 + data.extend_from_slice(&value.to_le_bytes()); + } + data +} + +const SHORT: u16 = 3; +/// TIFF format 9, `int32s` -- the type ExifTool declares for AETarget. +const SLONG: u16 = 9; + #[test] fn test_apple_parser_trait() { let parser = AppleParser::new(); @@ -41,271 +64,87 @@ fn test_apple_validate_header_without_signature() { assert!(parser.validate_header(&data)); } -#[test] -fn test_apple_hdr_image_type_off() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - // Create minimal IFD with one entry - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - - // HDR tag entry (tag=0x000A, type=3 (SHORT), count=1, value=0 (Off)) - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (inline) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Off".to_string())); -} - -#[test] -fn test_apple_hdr_image_type_smart_hdr() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Smart HDR) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Smart HDR".to_string())); -} +// ============================================================================ +// HDRImageType -- Apple.pm 0x000a +// +// ExifTool 13.59 declares exactly two values: +// 3 => 'HDR Image' +// 4 => 'Original Image' +// Its `2` is commented out as unidentified (iPad mini 2). +// +// These tests previously asserted 0 => "Off" and 4 => "Smart HDR" and would +// have *passed*, because the decoder invented nine values matching them. +// ExifTool prints "Original Image" for 4. See the fix to DECODE_HDR_TYPE. +// ============================================================================ #[test] -fn test_apple_portrait_mode_natural_light() { +fn test_apple_hdr_image_type_hdr_image() { let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x20, 0x00]); // Tag: Portrait Data - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Natural Light) + let data = ifd(&[(0x000A, SHORT, 3)]); let mut tags = HashMap::new(); let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); assert!(result.is_ok()); assert_eq!( - tags.get("Apple:PortraitMode"), - Some(&"Natural Light".to_string()) + tags.get("Apple:HDRImageType"), + Some(&"HDR Image".to_string()) ); } #[test] -fn test_apple_portrait_mode_stage_light() { +fn test_apple_hdr_image_type_original_image() { let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x20, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Stage Light) + let data = ifd(&[(0x000A, SHORT, 4)]); let mut tags = HashMap::new(); let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); assert!(result.is_ok()); assert_eq!( - tags.get("Apple:PortraitMode"), - Some(&"Stage Light".to_string()) + tags.get("Apple:HDRImageType"), + Some(&"Original Image".to_string()) ); } #[test] -fn test_apple_lens_model_wide() { +fn test_apple_hdr_image_type_unmapped_value_is_not_invented() { + // 0 has no PrintConv entry in ExifTool, so it must surface as an unmapped + // value rather than as a plausible label. This is the assertion that the + // old suite got backwards: it required 0 => "Off". let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag: Lens Model - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (Wide) + let data = ifd(&[(0x000A, SHORT, 0)]); let mut tags = HashMap::new(); let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); assert!(result.is_ok()); assert_eq!( - tags.get("Apple:LensModel"), - Some(&"Wide (Main Camera)".to_string()) + tags.get("Apple:HDRImageType"), + Some(&"Unknown (0)".to_string()), + "value 0 is unmapped in ExifTool 13.59 and must not be given a name" ); } #[test] -fn test_apple_lens_model_telephoto() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Telephoto) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Telephoto".to_string())); -} - -#[test] -fn test_apple_lens_model_ultra_wide() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); // Value: 2 (Ultra Wide) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Ultra Wide".to_string())); -} - -#[test] -fn test_apple_semantic_style_standard() { +fn test_apple_multiple_tags() { + // Two real Apple.pm tags, both verified by id and shape: + // 0x000a HDRImageType (PrintConv 4 => 'Original Image') + // 0x0005 AETarget (int32s, no PrintConv -- raw number) + // The previous version of this test also asserted Apple:LensModel and + // Apple:NightMode, neither of which exists in ExifTool. let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x2E, 0x00]); // Tag: Semantic Style - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Value: 0 (Standard) + let data = ifd(&[(0x000A, SHORT, 4), (0x0005, SLONG, 100)]); let mut tags = HashMap::new(); let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); assert!(result.is_ok()); assert_eq!( - tags.get("Apple:SemanticStyle"), - Some(&"Standard".to_string()) + tags.get("Apple:HDRImageType"), + Some(&"Original Image".to_string()) ); -} - -#[test] -fn test_apple_semantic_style_vibrant() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x2E, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); // Value: 2 (Vibrant) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:SemanticStyle"), Some(&"Vibrant".to_string())); -} - -#[test] -fn test_apple_night_mode_on() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x39, 0x00]); // Tag: Night Mode - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (On) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:NightMode"), Some(&"On".to_string())); -} - -#[test] -fn test_apple_scene_detection_food() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x3C, 0x00]); // Tag: Scene Detection - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x08, 0x00, 0x00, 0x00]); // Value: 8 (Food) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:SceneDetection"), Some(&"Food".to_string())); -} - -#[test] -fn test_apple_front_facing_camera() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - data.extend_from_slice(&[0x01, 0x00]); // 1 entry - data.extend_from_slice(&[0x32, 0x00]); // Tag: Front Facing Camera - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Front) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.get("Apple:FacingCamera"), Some(&"Front".to_string())); -} - -#[test] -fn test_apple_multiple_tags() { - let parser = AppleParser::new(); - let mut data = Vec::new(); - - // Create IFD with multiple entries - data.extend_from_slice(&[0x03, 0x00]); // 3 entries - - // HDR tag - data.extend_from_slice(&[0x0A, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00]); // Value: 4 (Smart HDR) - - // Lens Model tag - data.extend_from_slice(&[0x35, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (Telephoto) - - // Night Mode tag - data.extend_from_slice(&[0x39, 0x00]); // Tag - data.extend_from_slice(&[0x03, 0x00]); // Type: SHORT - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Count: 1 - data.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Value: 1 (On) - - let mut tags = HashMap::new(); - let result = parser.parse(&data, ByteOrder::LittleEndian, &mut tags); - - assert!(result.is_ok()); - assert_eq!(tags.len(), 3); - assert_eq!(tags.get("Apple:HDRImageType"), Some(&"Smart HDR".to_string())); - assert_eq!(tags.get("Apple:LensModel"), Some(&"Telephoto".to_string())); - assert_eq!(tags.get("Apple:NightMode"), Some(&"On".to_string())); + assert_eq!(tags.get("Apple:AETarget"), Some(&"100".to_string())); } #[test] diff --git a/tests/integration/samsung_makernotes_tests.rs b/tests/integration/samsung_makernotes_tests.rs index fc0097a43..e34399623 100644 --- a/tests/integration/samsung_makernotes_tests.rs +++ b/tests/integration/samsung_makernotes_tests.rs @@ -1,7 +1,22 @@ //! Integration tests for Samsung MakerNotes parser //! -//! Tests cover both Type1 (traditional NX cameras) and Type2/Galaxy (smartphones) -//! MakerNote formats. +//! This file was undeclared to Cargo and had never compiled. Seventeen of its +//! twenty-three tests asserted tag names that do not exist in ExifTool's +//! Samsung.pm: `SceneOptimizer`, `SceneType`, `SingleTake`, `ExpertRAW`, +//! `NightMode`, `GalaxyLensType`, `ZoomLevel`, `PortraitEffect`, +//! `DirectorsView`, `ProMode`, `SuperSteady`, `FoodMode`, `ObjectTracking` and +//! `MultiFrameNoiseReduction` (0 hits for `Name => ''` across all of +//! ExifTool 13.59; `MultiFrameNoiseReduction` exists only in Sony.pm). +//! +//! Two near-misses worth recording, because they look like hits: +//! * `%Image::ExifTool::Samsung::PortraitEffect` is a real *table* name, but +//! it is JSON-processed with string keys and yields `PortraitEffectID`, +//! `PortraitEffectLevel`, ... -- there is no `Samsung:PortraitEffect` tag +//! and it is not a numeric MakerNote IFD entry. +//! * `SceneType` exists in FlashPix.pm, Exif.pm and DICOM.pm, not Samsung.pm. +//! +//! What survives is checked against ExifTool 13.59 `Samsung::Type2` by tag id +//! and by PrintConv value. use oxidex::parsers::tiff::ifd_parser::ByteOrder; use oxidex::parsers::tiff::makernotes::samsung::SamsungParser; @@ -15,19 +30,22 @@ fn test_samsung_parser_trait() { assert_eq!(parser.tag_prefix(), "Samsung:"); } -// ============================================================================ -// Type1 Tag Tests (Traditional Samsung Cameras - NX series) -// ============================================================================ - #[test] fn test_samsung_makernote_version() { + // ExifTool Samsung::Type2 0x0001 MakerNoteVersion, Writable => 'undef', Count => 4. let parser = SamsungParser::new(); let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x0001 (MakerNoteVersion), Type 2 (ASCII), Count 4, Value "0100" - data.extend_from_slice(&[0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x30, 0x31, 0x30, 0x30]); + // Tag 0x0001, Type 2 (ASCII), Count 4, Value "0100" + data.extend_from_slice(&[ + 0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x30, 0x31, 0x30, 0x30, + ]); let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); + assert!( + parser + .parse(&data, ByteOrder::LittleEndian, &mut tags) + .is_ok() + ); assert_eq!( tags.get("Samsung:MakerNoteVersion"), Some(&"0100".to_string()) @@ -36,13 +54,20 @@ fn test_samsung_makernote_version() { #[test] fn test_samsung_device_type() { + // ExifTool Samsung::Type2 0x0002 DeviceType PrintConv: 0x2000 => 'High-end NX Camera'. let parser = SamsungParser::new(); let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x0002 (DeviceType), Type 4 (LONG), Count 1, Value 0x2000 (High-end NX Camera) - data.extend_from_slice(&[0x02, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00]); + // Tag 0x0002, Type 4 (LONG), Count 1, Value 0x2000 + data.extend_from_slice(&[ + 0x02, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, + ]); let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); + assert!( + parser + .parse(&data, ByteOrder::LittleEndian, &mut tags) + .is_ok() + ); assert_eq!( tags.get("Samsung:DeviceType"), Some(&"High-end NX Camera".to_string()) @@ -50,297 +75,84 @@ fn test_samsung_device_type() { } #[test] -fn test_samsung_model_id_nx1() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x0003 (SamsungModelID), Type 4 (LONG), Count 1, Value 0x0100123a (NX1) - data.extend_from_slice(&[0x03, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3a, 0x12, 0x00, 0x01]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:SamsungModelID"), Some(&"NX1".to_string())); -} - -#[test] -fn test_samsung_color_space_srgb() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x0221 (ColorSpace), Type 4 (LONG), Count 1, Value 0 (sRGB) - data.extend_from_slice(&[0x21, 0x02, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:ColorSpace"), Some(&"sRGB".to_string())); -} - -#[test] -fn test_samsung_color_space_adobe_rgb() { +fn test_samsung_model_id_nx10() { + // ExifTool Samsung::Type2 0x0003 SamsungModelID PrintConv: 0x100101c => 'NX10'. + // + // This test previously used 0x100123a => "NX1". ExifTool has no entry for + // 0x100123a at all, and its only 'NX1' is commented out at 0x5001038. + // 0x100101c => 'NX10' is the one mapping where oxidex's MODEL_ID_DECODER + // and ExifTool agree; the rest of that table disagrees and is reported + // separately. let parser = SamsungParser::new(); let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x0221 (ColorSpace), Type 4 (LONG), Count 1, Value 1 (Adobe RGB) - data.extend_from_slice(&[0x21, 0x02, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); + // Tag 0x0003, Type 4 (LONG), Count 1, Value 0x0100101c + data.extend_from_slice(&[ + 0x03, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x10, 0x00, 0x01, + ]); let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); + assert!( + parser + .parse(&data, ByteOrder::LittleEndian, &mut tags) + .is_ok() + ); assert_eq!( - tags.get("Samsung:ColorSpace"), - Some(&"Adobe RGB".to_string()) + tags.get("Samsung:SamsungModelID"), + Some(&"NX10".to_string()) ); } // ============================================================================ -// Galaxy Smartphone Feature Tag Tests (0x1001-0x101E range) +// ColorSpace -- ExifTool Samsung::Type2 0xa011 +// +// The PrintConv (0 => 'sRGB', 1 => 'Adobe RGB') is correct in oxidex, but the +// tag id is not: `SAMSUNG_COLOR_SPACE` in registries/samsung.rs is 0x0221, and +// 0x0221 appears nowhere in Samsung.pm. Consequence on a real Samsung file: +// ColorSpace is never emitted (nothing sits at 0x0221), and whatever really +// does live at 0x0221 would be misread as ColorSpace. +// +// These two tests encode ExifTool's id, so they fail until the constant is +// corrected. They are #[ignore]d rather than deleted or bent to 0x0221 -- +// deleting would lose the defect, and asserting 0x0221 would pin it. // ============================================================================ #[test] -fn test_samsung_scene_optimizer_on() { +#[ignore = "SAMSUNG_COLOR_SPACE is 0x0221; ExifTool Samsung::Type2 puts ColorSpace at 0xa011"] +fn test_samsung_color_space_srgb() { let parser = SamsungParser::new(); let mut data = vec![0x01, 0x00]; // 1 entry - // Tag 0x1001 (SceneOptimizer), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x01, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); + // Tag 0xa011, Type 3 (SHORT, ExifTool: int16u), Count 1, Value 0 (sRGB) + data.extend_from_slice(&[ + 0x11, 0xa0, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:SceneOptimizer"), - Some(&"On".to_string()) + assert!( + parser + .parse(&data, ByteOrder::LittleEndian, &mut tags) + .is_ok() ); + assert_eq!(tags.get("Samsung:ColorSpace"), Some(&"sRGB".to_string())); } #[test] -fn test_samsung_scene_type_food() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1002 (SceneType), Type 3 (SHORT), Count 1, Value 1 (Food) - data.extend_from_slice(&[0x02, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:SceneType"), Some(&"Food".to_string())); -} - -#[test] -fn test_samsung_single_take_recording() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1005 (SingleTake), Type 3 (SHORT), Count 1, Value 1 (Recording) - data.extend_from_slice(&[0x05, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:SingleTake"), - Some(&"Recording".to_string()) - ); -} - -#[test] -fn test_samsung_expert_raw_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1008 (ExpertRAW), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x08, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:ExpertRAW"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_night_mode_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1012 (NightMode), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x12, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:NightMode"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_galaxy_lens_type_ultra_wide() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x101C (GalaxyLensType), Type 3 (SHORT), Count 1, Value 1 (Ultra Wide) - data.extend_from_slice(&[0x1C, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:GalaxyLensType"), - Some(&"Ultra Wide".to_string()) - ); -} - -#[test] -fn test_samsung_galaxy_lens_type_telephoto_10x() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x101C (GalaxyLensType), Type 3 (SHORT), Count 1, Value 5 (Telephoto 10x) - data.extend_from_slice(&[0x1C, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:GalaxyLensType"), - Some(&"Telephoto 10x".to_string()) - ); -} - -#[test] -fn test_samsung_zoom_level_3_5x() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x101E (ZoomLevel), Type 3 (SHORT), Count 1, Value 35 (3.5x) - data.extend_from_slice(&[0x1E, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:ZoomLevel"), Some(&"3.5x".to_string())); -} - -#[test] -fn test_samsung_portrait_effect_blur() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x101A (PortraitEffect), Type 3 (SHORT), Count 1, Value 1 (Blur) - data.extend_from_slice(&[0x1A, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:PortraitEffect"), - Some(&"Blur".to_string()) - ); -} - -#[test] -fn test_samsung_directors_view_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x100C (DirectorsView), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x0C, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:DirectorsView"), - Some(&"On".to_string()) - ); -} - -#[test] -fn test_samsung_pro_mode_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x100E (ProMode), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x0E, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:ProMode"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_super_steady_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1016 (SuperSteady), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x16, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:SuperSteady"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_food_mode_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1018 (FoodMode), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x18, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.get("Samsung:FoodMode"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_object_tracking_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x1010 (ObjectTracking), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x10, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:ObjectTracking"), - Some(&"On".to_string()) - ); -} - -#[test] -fn test_samsung_multi_frame_nr_on() { - let parser = SamsungParser::new(); - let mut data = vec![0x01, 0x00]; - // Tag 0x100A (MultiFrameNoiseReduction), Type 3 (SHORT), Count 1, Value 1 (On) - data.extend_from_slice(&[0x0A, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!( - tags.get("Samsung:MultiFrameNoiseReduction"), - Some(&"On".to_string()) - ); -} - -#[test] -fn test_samsung_multiple_galaxy_tags() { - let parser = SamsungParser::new(); - let mut data = vec![0x02, 0x00]; // 2 entries - - // Scene Optimizer (0x1001) - data.extend_from_slice(&[0x01, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - // Night Mode (0x1012) - data.extend_from_slice(&[0x12, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); - - let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.len(), 2); - assert_eq!( - tags.get("Samsung:SceneOptimizer"), - Some(&"On".to_string()) - ); - assert_eq!(tags.get("Samsung:NightMode"), Some(&"On".to_string())); -} - -#[test] -fn test_samsung_mixed_type1_and_galaxy_tags() { +#[ignore = "SAMSUNG_COLOR_SPACE is 0x0221; ExifTool Samsung::Type2 puts ColorSpace at 0xa011"] +fn test_samsung_color_space_adobe_rgb() { let parser = SamsungParser::new(); - let mut data = vec![0x03, 0x00]; // 3 entries - - // MakerNoteVersion (0x0001) - Type1 string tag - data.extend_from_slice(&[0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x30, 0x31, 0x30, 0x30]); - // DeviceType (0x0002) - Type1 LONG tag - data.extend_from_slice(&[0x02, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00]); - // SceneOptimizer (0x1001) - Galaxy feature tag - data.extend_from_slice(&[0x01, 0x10, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]); + let mut data = vec![0x01, 0x00]; // 1 entry + // Tag 0xa011, Type 3 (SHORT), Count 1, Value 1 (Adobe RGB) + data.extend_from_slice(&[ + 0x11, 0xa0, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + ]); let mut tags = HashMap::new(); - assert!(parser.parse(&data, ByteOrder::LittleEndian, &mut tags).is_ok()); - assert_eq!(tags.len(), 3); - assert_eq!( - tags.get("Samsung:MakerNoteVersion"), - Some(&"0100".to_string()) + assert!( + parser + .parse(&data, ByteOrder::LittleEndian, &mut tags) + .is_ok() ); assert_eq!( - tags.get("Samsung:DeviceType"), - Some(&"High-end NX Camera".to_string()) - ); - assert_eq!( - tags.get("Samsung:SceneOptimizer"), - Some(&"On".to_string()) + tags.get("Samsung:ColorSpace"), + Some(&"Adobe RGB".to_string()) ); } diff --git a/tests/test_file_reachability.rs b/tests/test_file_reachability.rs new file mode 100644 index 000000000..fcc7a5cc1 --- /dev/null +++ b/tests/test_file_reachability.rs @@ -0,0 +1,244 @@ +//! Guards against test files that are never compiled. +//! +//! Cargo builds only `tests/*.rs` as test roots. Files in subdirectories are +//! reached solely through `mod` / `#[path]` declarations, so a `.rs` file under +//! `tests/` that nothing declares is silently ignored: it never compiles, its +//! `#[test]` functions never run, and it is indistinguishable from a comment. +//! +//! This is not hypothetical. 21 files holding 114 `#[test]` functions sat +//! undeclared in this repository, including vendor MakerNote suites asserting +//! tag names that do not exist in ExifTool. Because they never built, nothing +//! ever contradicted them. +//! +//! This test re-derives the reachable set from the roots and fails if any file +//! under `tests/` is orphaned. If you add a file under `tests/`, declare it. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Strip `//` line comments and `/* */` block comments so that commented-out +/// `mod` declarations are not treated as live edges. +fn strip_comments(src: &str) -> String { + let bytes: Vec = src.chars().collect(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < bytes.len() { + // String literals are skipped verbatim: a `//` inside one is not a comment. + if bytes[i] == '"' { + out.push(bytes[i]); + i += 1; + while i < bytes.len() { + if bytes[i] == '\\' && i + 1 < bytes.len() { + out.push(bytes[i]); + out.push(bytes[i + 1]); + i += 2; + continue; + } + out.push(bytes[i]); + i += 1; + if bytes[i - 1] == '"' { + break; + } + } + continue; + } + if bytes[i] == '/' && i + 1 < bytes.len() && bytes[i + 1] == '/' { + while i < bytes.len() && bytes[i] != '\n' { + i += 1; + } + continue; + } + if bytes[i] == '/' && i + 1 < bytes.len() && bytes[i + 1] == '*' { + i += 2; + let mut depth = 1; + while i < bytes.len() && depth > 0 { + if bytes[i] == '/' && i + 1 < bytes.len() && bytes[i + 1] == '*' { + depth += 1; + i += 2; + } else if bytes[i] == '*' && i + 1 < bytes.len() && bytes[i + 1] == '/' { + depth -= 1; + i += 2; + } else { + i += 1; + } + } + continue; + } + out.push(bytes[i]); + i += 1; + } + out +} + +/// Extract `(optional #[path] value, module name)` for each `mod NAME;` in `src`. +/// +/// Handles `#[path = "..."]` on its own line or inline, with or without `pub`, +/// and ignores inline `mod NAME { .. }` blocks (which declare no new file). +fn module_edges(src: &str) -> Vec<(Option, String)> { + let mut edges = Vec::new(); + let mut pending_path: Option = None; + + for raw in src.lines() { + let line = raw.trim(); + + if let Some(rest) = line.strip_prefix("#[path") { + if let Some(start) = rest.find('"') + && let Some(len) = rest[start + 1..].find('"') + { + pending_path = Some(rest[start + 1..start + 1 + len].to_string()); + } + // A `#[path = ".."] mod name;` may share the line with the mod item. + if !line.contains("mod ") { + continue; + } + } + + let after_vis = line.strip_prefix("pub ").unwrap_or(line); + if let Some(rest) = after_vis + .split("mod ") + .nth(1) + .filter(|_| after_vis.contains("mod ")) + { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + let tail = rest[name.len()..].trim_start(); + // Only file-backed declarations (`mod name;`), not `mod name { .. }`. + if !name.is_empty() && tail.starts_with(';') { + edges.push((pending_path.take(), name)); + continue; + } + } + + // An attribute followed by something that is not a `mod` item: drop it. + if !line.is_empty() && !line.starts_with("#[") && !line.starts_with("//") { + pending_path = None; + } + } + edges +} + +fn visit(tests_dir: &Path, rel: &Path, reachable: &mut BTreeSet) { + if !reachable.insert(rel.to_path_buf()) { + return; + } + let abs = tests_dir.join(rel); + let Ok(src) = fs::read_to_string(&abs) else { + return; + }; + let src = strip_comments(&src); + + let parent = rel.parent().unwrap_or(Path::new("")); + let stem = rel.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + // Non-`mod.rs` files own a same-named directory for their children. + let child_dir = if stem == "mod" { + parent.to_path_buf() + } else { + parent.join(stem) + }; + + for (path_attr, name) in module_edges(&src) { + let candidate = if let Some(p) = path_attr { + // `#[path]` is relative to the *containing directory* of the file. + Some(normalize(&parent.join(p))) + } else { + let file = child_dir.join(format!("{name}.rs")); + let dir_mod = child_dir.join(&name).join("mod.rs"); + if tests_dir.join(&file).is_file() { + Some(file) + } else if tests_dir.join(&dir_mod).is_file() { + Some(dir_mod) + } else { + None + } + }; + if let Some(c) = candidate + && tests_dir.join(&c).is_file() + { + visit(tests_dir, &c, reachable); + } + } +} + +/// Collapse `a/../b` into `b` without touching the filesystem. +fn normalize(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +fn collect_rs(dir: &Path, base: &Path, into: &mut BTreeSet) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs(&path, base, into); + } else if path.extension().is_some_and(|e| e == "rs") + && let Ok(rel) = path.strip_prefix(base) + { + into.insert(rel.to_path_buf()); + } + } +} + +#[test] +fn every_test_file_is_reachable_from_a_cargo_test_root() { + let tests_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); + + let mut all = BTreeSet::new(); + collect_rs(&tests_dir, &tests_dir, &mut all); + assert!( + all.len() > 20, + "sanity: expected to find many test files under {}, found {}", + tests_dir.display(), + all.len() + ); + + // Cargo compiles exactly the top-level `tests/*.rs` files as test roots. + let roots: Vec = all + .iter() + .filter(|p| p.parent() == Some(Path::new(""))) + .cloned() + .collect(); + + let mut reachable = BTreeSet::new(); + for root in &roots { + visit(&tests_dir, root, &mut reachable); + } + + // `tests/ffi/build.rs` is a Cargo build script, not a test module. Cargo + // only honours a build script at the package root, so this copy is inert, + // but it is deliberately not a test target and must not be declared. + let exempt: BTreeSet = [PathBuf::from("ffi/build.rs")].into_iter().collect(); + + let orphans: Vec<&PathBuf> = all + .difference(&reachable) + .filter(|p| !exempt.contains(*p)) + .collect(); + + assert!( + orphans.is_empty(), + "{} file(s) under tests/ are not reachable from any `tests/*.rs` root, so \ + Cargo never compiles them and their #[test] functions never run.\n\ + Declare each one (e.g. `#[path = \"integration/foo.rs\"] mod foo;` in \ + tests/integration.rs) or delete it:\n{}", + orphans.len(), + orphans + .iter() + .map(|p| format!(" tests/{}", p.display())) + .collect::>() + .join("\n") + ); +}