From 41064fabb5ecadb0eef50ad42ccfa4e770070f07 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:57 +0000 Subject: [PATCH] refactor(lightcraft-engine): move crs.rs tests to crates/engine/src/crs/tests.rs --- crates/engine/src/crs.rs | 358 +-------------------------------- crates/engine/src/crs/tests.rs | 355 ++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 357 deletions(-) create mode 100644 crates/engine/src/crs/tests.rs diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 093f3a84f..b5d2c856c 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -552,360 +552,4 @@ pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; - - /// A hand-written sidecar in attribute form (as many tools write it). - const SIDECAR: &str = r#" - - - - 0, 064, 52192, 205255, 255 - - - 0, 0255, 255 - - - -"#; - - fn props(x: &str) -> Props { - lightcraft_meta::parse_xmp(x).unwrap().properties - } - - #[test] - fn maps_common_fields() { - let p = props(SIDECAR); - assert!(has_adjustments(&p)); - let partial = to_partial(&p, Target::RawAbsolute); - let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); - assert_eq!(s.wb.mode, WbMode::Custom); - assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); - assert_eq!(s.light.exposure, 0.65); - assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); - assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); - assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); - assert_eq!(s.mixer.orange.hue, -7.0); - assert_eq!(s.mixer.blue.sat, -22.0); - assert_eq!(s.mixer.aqua.lum, 13.0); - assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); - assert_eq!(s.curve.master.len(), 4); - assert_eq!(s.curve.refine_saturation, 40.0); - assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); - assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); - assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); - assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); - assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); - assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); - assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); - assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); - assert_eq!(s.vignette.amount, -21.0); - assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); - assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); - assert_eq!(s.geometry.upright, Upright::Level); - assert_eq!(s.geometry.vertical, -10.0); - assert!(s.optics.remove_ca); - assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); - let cal = s.calibration; - assert_eq!( - (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), - (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) - ); - let c = s.crop.geometry; - assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); - } - - /// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: - /// it is left out, rather than stored with a point the curve editor can't place. - #[test] - fn curves_with_non_finite_points_are_left_out() { - for bad in ["NaN, 0", "64, inf", "-inf, 3"] { - let x = SIDECAR.replace("64, 52", &format!("{bad}")); - let partial = to_partial(&props(&x), Target::RawAbsolute); - let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); - assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); - assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); - } - } - - #[test] - fn only_present_fields_are_in_the_partial() { - let x = r#""#; - let partial = to_partial(&props(x), Target::Any); - assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); - } - - #[test] - fn hdr_edit_mode_turns_hdr_editing_on_or_off() { - let x = |v: &str| { - format!( - r#""# - ) - }; - let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); - assert!(on.hdr.enabled); - assert_eq!(on.light.exposure, 1.5); - assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); - let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); - assert!(true_form.hdr.enabled); - let mut hdr = DevelopSettings::default(); - hdr.hdr.enabled = true; - assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); - // without the field nothing about HDR changes - let plain = r#""#; - assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); - } - - #[test] - fn element_form_bw_and_incremental_wb() { - let x = r#" - - True - -35 - 25 - -6 - 4800 - "#; - let p = props(x); - let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); - assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); - assert_eq!(rendered.bw_mix.blue, -35.0); - assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); - assert_eq!(rendered.wb.tint, -6.0); - let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); - assert_eq!(raw.wb.temp, 4800.0); - } - - #[test] - fn named_wb_and_already_applied() { - let x = r#""#; - let p = props(x); - assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); - assert!(!has_adjustments(&p)); - let bookkeeping = r#""#; - assert!(!has_adjustments(&props(bookkeeping))); - } - - #[test] - fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { - // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white - // of 3650 K / −2 (written for this test from the numbers in the issue). - let x = r#""#; - let p = props(x); - let rel = to_partial(&p, Target::RawRelative); - // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale - let t = rel["wb"]["temp"].as_f64().unwrap(); - assert!((t - 6275.0).abs() < 5.0, "{t}"); - assert_eq!(rel["wb"]["tint"], -3.0); - assert_eq!(rel["wb"]["mode"], "custom"); - // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured - // illuminant (and a preset) keeps the Kelvin value - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); - // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); - assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); - assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); - // the as-shot fields are a reference, not an adjustment: nothing to report - assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); - // no tint next to the as-shot tint: the custom tint is taken as the shift itself - let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); - assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); - } - - #[test] - fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { - // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: - // 3578 K read on the relative scale would be a large blue shift (issue #510) - let x = r#""#; - let p = props(x); - let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); - assert_eq!(partial["wb"], json!({"mode": "asShot"})); - assert_eq!(partial["light"]["exposure"], 0.3); - assert_eq!(unmapped.len(), 1, "{unmapped:?}"); - assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); - // a raw with a measured illuminant and a rendered file read it as before, and report nothing - assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); - assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); - assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); - // an unusable reference (zero) counts as none - let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); - assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); - } - - #[test] - fn target_follows_the_file() { - use lightcraft_catalog::MediaKind; - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); - // Lightroom's `fileFormat` name for proprietary raws - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); - // a raw shown from its embedded preview develops like a rendered file - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); - assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); - // a camera with measured matrices edits in Kelvin (issue #730), under any of its names - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); - assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); - let mut p = - lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); - p.kind = MediaKind::Raw; - assert_eq!(Target::for_photo(&p), Target::RawRelative); - p.meta.camera = "SONY ILCE-7M3".into(); - assert_eq!(Target::for_photo(&p), Target::RawAbsolute); - } - - /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value - /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on - /// a raw without matrices still takes the #510 shift. - #[test] - fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { - use lightcraft_catalog::MediaKind; - let x = r#""#; - let p = props(x); - let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); - assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); - let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); - let shifted = to_partial(&p, a7m2); - assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); - assert_eq!(shifted["wb"]["tint"], 4.0); - assert!(shifted["wb"].get("scale").is_none()); - // without the reference the Kelvin camera still reads it, the other stays As Shot - let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); - assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); - assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); - // a named preset carries no numbers either way - let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); - assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); - } - - #[test] - fn xmp_preset_with_name_and_group() { - // Written for this test: a minimal preset-shaped packet with localized name/group. - let x = r#" - - Punchy Test - My Looks - "#; - let p = preset_from_xmp(x, "file").unwrap(); - assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); - assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); - assert!(!p.builtin); - assert!(preset_from_xmp("", "f").is_none()); - } - - /// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. - fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { - format!( - r#" - - - - {extra_elems} - "# - ) - } - - #[test] - fn channel_curves_need_the_master_and_all_channel_curves() { - // Lightroom applies red / green / blue curves only when the master and all three are present - let seq = |name: &str, pts: &str| format!("{pts}"); - let identity = "0, 0255, 255"; - let bent = "0, 064, 40255, 255"; - let packet = |curves: &[String]| { - format!( - r#" - {} - "#, - curves.concat() - ) - }; - let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); - let (master, red, green, blue) = ( - seq("ToneCurvePV2012", identity), - seq("ToneCurvePV2012Red", identity), - seq("ToneCurvePV2012Green", bent), - seq("ToneCurvePV2012Blue", identity), - ); - // a channel curve alone, or with the master but without its two siblings: ignored - let alone = partial(std::slice::from_ref(&green)); - assert!(alone.pointer("/curve/green").is_none(), "{alone}"); - let master_and_green = partial(&[master.clone(), green.clone()]); - assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); - // all four: read - let all = partial(&[master, red, green, blue]); - assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); - } - - fn unmapped(x: &str) -> Vec { - to_partial_report(&props(x), None, Target::Any, 1.5).1 - } - - #[test] - fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { - let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; - assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); - // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) - let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; - let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); - assert_eq!(got, ["FutureSlider", "PointColors"]); - } - - #[test] - fn lightroom_profile_bookkeeping_is_not_reported() { - // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering - let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" - crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" - crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" - crs:CropConstrainToUnitSquare="1""#; - let look = r#"Vivid"#; - assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); - assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); - // a lens profile's strength away from 100 % is an adjustment we don't carry over - let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); - assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); - } - - #[test] - fn disabled_lens_profile_switch_keeps_embedded_corrections() { - // Lightroom applies a file's built-in lens corrections regardless of its profile switch, - // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off - let p = props(&preset_packet("", "")); - let mut d = DevelopSettings::default(); - d.optics.lens_profile = true; - assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); - let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); - assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); - } -} +mod tests; diff --git a/crates/engine/src/crs/tests.rs b/crates/engine/src/crs/tests.rs new file mode 100644 index 000000000..2779194c1 --- /dev/null +++ b/crates/engine/src/crs/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, Upright, VignetteStyle, WbMode, apply_partial}; + +/// A hand-written sidecar in attribute form (as many tools write it). +const SIDECAR: &str = r#" + + + + 0, 064, 52192, 205255, 255 + + + 0, 0255, 255 + + + +"#; + +fn props(x: &str) -> Props { + lightcraft_meta::parse_xmp(x).unwrap().properties +} + +#[test] +fn maps_common_fields() { + let p = props(SIDECAR); + assert!(has_adjustments(&p)); + let partial = to_partial(&p, Target::RawAbsolute); + let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert_eq!(s.wb.mode, WbMode::Custom); + assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); + assert_eq!(s.light.exposure, 0.65); + assert_eq!((s.light.contrast, s.light.highlights, s.light.shadows, s.light.whites, s.light.blacks), (-14.0, -58.0, 41.0, 9.0, -17.0)); + assert_eq!((s.effects.texture, s.effects.clarity, s.effects.dehaze), (6.0, 11.0, 4.0)); + assert_eq!((s.color.vibrance, s.color.saturation), (19.0, -3.0)); + assert_eq!(s.mixer.orange.hue, -7.0); + assert_eq!(s.mixer.blue.sat, -22.0); + assert_eq!(s.mixer.aqua.lum, 13.0); + assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); + assert_eq!(s.curve.master.len(), 4); + assert_eq!(s.curve.refine_saturation, 40.0); + assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); + assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); + assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); + assert_eq!((s.grading.highlights.hue, s.grading.highlights.sat), (40.0, 9.0)); + assert_eq!((s.grading.midtones.hue, s.grading.midtones.sat), (120.0, 6.0)); + assert_eq!((s.grading.balance, s.grading.blending), (20.0, 70.0)); + assert_eq!((s.detail.sharpen_amount, s.detail.sharpen_radius, s.detail.sharpen_detail, s.detail.sharpen_masking), (55.0, 1.2, 30.0, 12.0)); + assert_eq!((s.detail.nr_luminance, s.detail.nr_color), (18.0, 25.0)); + assert_eq!(s.vignette.amount, -21.0); + assert_eq!(s.vignette.style, VignetteStyle::ColorPriority); + assert_eq!((s.grain.amount, s.grain.size, s.grain.roughness), (15.0, 30.0, 60.0)); + assert_eq!(s.geometry.upright, Upright::Level); + assert_eq!(s.geometry.vertical, -10.0); + assert!(s.optics.remove_ca); + assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); + let c = s.crop.geometry; + assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); +} + +/// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve: +/// it is left out, rather than stored with a point the curve editor can't place. +#[test] +fn curves_with_non_finite_points_are_left_out() { + for bad in ["NaN, 0", "64, inf", "-inf, 3"] { + let x = SIDECAR.replace("64, 52", &format!("{bad}")); + let partial = to_partial(&props(&x), Target::RawAbsolute); + let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); + assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master); + assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + } +} + +#[test] +fn only_present_fields_are_in_the_partial() { + let x = r#""#; + let partial = to_partial(&props(x), Target::Any); + assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); +} + +#[test] +fn hdr_edit_mode_turns_hdr_editing_on_or_off() { + let x = |v: &str| { + format!( + r#""# + ) + }; + let on = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("1")), Target::RawAbsolute), 1.0); + assert!(on.hdr.enabled); + assert_eq!(on.light.exposure, 1.5); + assert_eq!(on.hdr.max_ev, lightcraft_develop::Hdr::DEFAULT_MAX_EV, "the rest of the HDR settings keep their defaults"); + let true_form = apply_partial(&DevelopSettings::default(), &to_partial(&props(&x("True")), Target::RawAbsolute), 1.0); + assert!(true_form.hdr.enabled); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + assert!(!apply_partial(&hdr, &to_partial(&props(&x("0")), Target::RawAbsolute), 1.0).hdr.enabled, "0 turns it off"); + // without the field nothing about HDR changes + let plain = r#""#; + assert!(to_partial(&props(plain), Target::RawAbsolute).get("hdr").is_none()); +} + +#[test] +fn element_form_bw_and_incremental_wb() { + let x = r#" + + True + -35 + 25 + -6 + 4800 + "#; + let p = props(x); + let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); + assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); + assert_eq!(rendered.bw_mix.blue, -35.0); + assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); + assert_eq!(rendered.wb.tint, -6.0); + let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); + assert_eq!(raw.wb.temp, 4800.0); +} + +#[test] +fn named_wb_and_already_applied() { + let x = r#""#; + let p = props(x); + assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); + assert!(!has_adjustments(&p)); + let bookkeeping = r#""#; + assert!(!has_adjustments(&props(bookkeeping))); +} + +#[test] +fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { + // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white + // of 3650 K / −2 (written for this test from the numbers in the issue). + let x = r#""#; + let p = props(x); + let rel = to_partial(&p, Target::RawRelative); + // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale + let t = rel["wb"]["temp"].as_f64().unwrap(); + assert!((t - 6275.0).abs() < 5.0, "{t}"); + assert_eq!(rel["wb"]["tint"], -3.0); + assert_eq!(rel["wb"]["mode"], "custom"); + // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured + // illuminant (and a preset) keeps the Kelvin value + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); + assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); + assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); + // the as-shot fields are a reference, not an adjustment: nothing to report + assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); + // no tint next to the as-shot tint: the custom tint is taken as the shift itself + let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); + assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); +} + +#[test] +fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { + // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: + // 3578 K read on the relative scale would be a large blue shift (issue #510) + let x = r#""#; + let p = props(x); + let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); + assert_eq!(partial["wb"], json!({"mode": "asShot"})); + assert_eq!(partial["light"]["exposure"], 0.3); + assert_eq!(unmapped.len(), 1, "{unmapped:?}"); + assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); + // a raw with a measured illuminant and a rendered file read it as before, and report nothing + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); + assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); + // an unusable reference (zero) counts as none + let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); + assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); +} + +#[test] +fn target_follows_the_file() { + use lightcraft_catalog::MediaKind; + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); + // Lightroom's `fileFormat` name for proprietary raws + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); + // a raw shown from its embedded preview develops like a rendered file + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); + // a camera with measured matrices edits in Kelvin (issue #730), under any of its names + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); + let mut p = + lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); + p.kind = MediaKind::Raw; + assert_eq!(Target::for_photo(&p), Target::RawRelative); + p.meta.camera = "SONY ILCE-7M3".into(); + assert_eq!(Target::for_photo(&p), Target::RawAbsolute); +} + +/// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value +/// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on +/// a raw without matrices still takes the #510 shift. +#[test] +fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::MediaKind; + let x = r#""#; + let p = props(x); + let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); + assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); + let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); + let shifted = to_partial(&p, a7m2); + assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); + assert_eq!(shifted["wb"]["tint"], 4.0); + assert!(shifted["wb"].get("scale").is_none()); + // without the reference the Kelvin camera still reads it, the other stays As Shot + let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); + assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); + assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); + // a named preset carries no numbers either way + let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); + assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); +} + +#[test] +fn xmp_preset_with_name_and_group() { + // Written for this test: a minimal preset-shaped packet with localized name/group. + let x = r#" + + Punchy Test + My Looks + "#; + let p = preset_from_xmp(x, "file").unwrap(); + assert_eq!((p.name.as_str(), p.group.as_str(), p.id.as_str()), ("Punchy Test", "My Looks", "user.xmp.0123abcd")); + assert_eq!(p.settings, json!({"light": {"contrast": 25.0}})); + assert!(!p.builtin); + assert!(preset_from_xmp("", "f").is_none()); +} + +/// A preset packet (written for this test) with the bookkeeping fields Lightroom presets carry. +fn preset_packet(extra_attrs: &str, extra_elems: &str) -> String { + format!( + r#" + + + + {extra_elems} + "# + ) +} + +#[test] +fn channel_curves_need_the_master_and_all_channel_curves() { + // Lightroom applies red / green / blue curves only when the master and all three are present + let seq = |name: &str, pts: &str| format!("{pts}"); + let identity = "0, 0255, 255"; + let bent = "0, 064, 40255, 255"; + let packet = |curves: &[String]| { + format!( + r#" + {} + "#, + curves.concat() + ) + }; + let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); + let (master, red, green, blue) = ( + seq("ToneCurvePV2012", identity), + seq("ToneCurvePV2012Red", identity), + seq("ToneCurvePV2012Green", bent), + seq("ToneCurvePV2012Blue", identity), + ); + // a channel curve alone, or with the master but without its two siblings: ignored + let alone = partial(std::slice::from_ref(&green)); + assert!(alone.pointer("/curve/green").is_none(), "{alone}"); + let master_and_green = partial(&[master.clone(), green.clone()]); + assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); + // all four: read + let all = partial(&[master, red, green, blue]); + assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); +} + +fn unmapped(x: &str) -> Vec { + to_partial_report(&props(x), None, Target::Any, 1.5).1 +} + +#[test] +fn preset_bookkeeping_is_not_reported_but_real_gaps_are() { + let empty_points = "-1.000000, -1.000000, -1.000000, -1.000000"; + assert_eq!(unmapped(&preset_packet(r#"crs:HDREditMode="0""#, empty_points)), Vec::::new()); + // a used Point Color slot and an unknown adjustment still are (an HDR edit is carried over) + let used_points = "0.5, 0.2, 0.1, 10, 0, 0, 0, 0"; + let got = unmapped(&preset_packet(r#"crs:HDREditMode="1" crs:FutureSlider="12""#, used_points)); + assert_eq!(got, ["FutureSlider", "PointColors"]); +} + +#[test] +fn lightroom_profile_bookkeeping_is_not_reported() { + // what Lightroom Classic writes next to every raw's edit: none of it changes the rendering + let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D" + crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False" + crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100" + crs:CropConstrainToUnitSquare="1""#; + let look = r#"Vivid"#; + assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::::new()); + assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap"); + // a lens profile's strength away from 100 % is an adjustment we don't carry over + let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, ""); + assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]); +} + +#[test] +fn disabled_lens_profile_switch_keeps_embedded_corrections() { + // Lightroom applies a file's built-in lens corrections regardless of its profile switch, + // and ours are only those: a preset's `LensProfileEnable=0` must not turn them off + let p = props(&preset_packet("", "")); + let mut d = DevelopSettings::default(); + d.optics.lens_profile = true; + assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); + let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); +}