refactor(lightcraft-engine): move crs_masks.rs tests to crates/engine/src/crs_masks/tests.rs

This commit is contained in:
aiongg
2026-10-26 18:15:52 +00:00
parent 47adb933db
commit 6cf21f552a
2 changed files with 333 additions and 334 deletions
+1 -334
View File
@@ -446,337 +446,4 @@ pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
assert!(!rad.components[0].invert);
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let d = lightcraft_meta::parse_xmp(x).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
assert_eq!(s.masks[0].adjust.clarity, -30.0);
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
/// One radial correction in `container`, with `attrs` on its mask component (values written
/// for this test; the component attributes follow what Lightroom Classic writes).
fn radial_packet(container: &str, attrs: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0">
<crs:{container}><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:{container}>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
assert!(unmapped.is_empty(), "{unmapped:?}");
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
s
}
/// (the radial's `invert`, its feather, the component's `invert`).
fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
let c = &s.masks[0].components[0];
let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
(*invert, *feather, c.invert)
}
/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
let render =
|d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
(g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
}
#[test]
fn lightroom_radial_flipped_is_inside() {
// as Lightroom Classic writes an ordinary radial mask today (#1815)
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
// no MaskInverted at all reads the same
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
}
#[test]
fn lightroom_radial_mask_inverted_is_outside() {
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
// version 2 with Flipped="false": outside, the legacy sense of the flag
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
assert_eq!(radial_of(&s), (true, 25.0, false));
}
#[test]
fn versionless_group_radial_is_inside_with_half_the_feather() {
// Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
for flipped in ["true", "false"] {
let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
}
}
#[test]
fn legacy_radial_filter_is_outside_unless_flipped() {
// the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
assert_eq!(radial_of(&s), (true, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
assert_eq!(radial_of(&s), (false, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
}
#[test]
fn lrtemplate_radial_filter_reads_flipped() {
// .lrtemplate presets and catalog develop settings are the same Lua tables
let t = |flipped: &str| {
format!(
r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
{{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
{{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
)
};
for (flipped, outside) in [("true", false), ("false", true)] {
let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
}
}
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
const SPOTS: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.1">
<crs:RetouchInfo><rdf:Seq>
<rdf:li>centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal</rdf:li>
</rdf:Seq></crs:RetouchInfo>
<crs:RetouchAreas><rdf:Seq>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:Method="poisson" crs:SourceX="0.45" crs:OffsetY="0.3" crs:Opacity="1" crs:Feather="0" crs:Seed="1">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:MaskActive="true" crs:MaskValue="1" crs:X="0.4" crs:Y="0.3" crs:SizeX="0.01" crs:SizeY="0.015"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="clone" crs:SourceState="sourceAutoComputed" crs:Opacity="0.5" crs:Feather="0.25">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:X="0.7" crs:Y="0.6" crs:SizeX="0.02" crs:SizeY="0.02"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:SourceX="0.2" crs:OffsetY="0.9">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Paint" crs:Radius="0.03" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.8</rdf:li><rdf:li>d 0.12 0.82</rdf:li><rdf:li>d 0.14 0.84</rdf:li></rdf:Seq></crs:Dabs></rdf:li></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceX="0.6" crs:OffsetY="0.5">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/CircularGradient" crs:Top="0.48" crs:Left="0.49" crs:Bottom="0.52" crs:Right="0.51"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Gradient" crs:FullX="0" crs:FullY="0" crs:ZeroX="1" crs:ZeroY="1"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:RetouchAreas>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn retouch_areas_become_spots() {
use lightcraft_develop::SpotMode;
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
assert_eq!(s.light.exposure, 0.1, "global settings still map");
assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
let heal = &s.spots[0];
assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
let src = heal.source_offset.expect("explicit source");
assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
let clone = &s.spots[1];
assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
let brushed = &s.spots[2];
assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
let src = brushed.source_offset.expect("explicit source");
assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
let circ = &s.spots[3];
assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
}
#[test]
fn portrait_target_scales_spot_sizes() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
}
#[test]
fn legacy_retouch_info_is_read_on_its_own() {
let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
let d = lightcraft_meta::parse_xmp(&packet).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
}
#[test]
fn spots_render() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture");
}
}
mod tests;
+332
View File
@@ -0,0 +1,332 @@
use super::*;
use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
/// A packet in the shape local corrections take (values written for this test).
const PACKET: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.2">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:CorrectionActive="true" crs:CorrectionName="Darker sky"
crs:LocalExposure2012="-0.25" crs:LocalSaturation="0.3" crs:LocalTemperature="-0.1">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="2" crs:MaskBlendMode="0" crs:MaskInverted="false"/>
<rdf:li crs:What="Mask/Gradient" crs:MaskBlendMode="2" crs:FullX="0.5" crs:FullY="0" crs:ZeroX="0.5" crs:ZeroY="0.6"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/>
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionActive="false" crs:LocalExposure2012="1">
<crs:CorrectionMasks><rdf:Seq><rdf:li crs:What="Mask/Image" crs:MaskSubType="1"/></rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
<crs:PaintBasedCorrections><rdf:Seq>
<rdf:li crs:What="Correction" crs:LocalClarity2012="0.4"><crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.05" crs:Flow="0.8" crs:CenterWeight="0.3" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.2</rdf:li><rdf:li>d 0.15 0.22</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
<rdf:li crs:What="Mask/Paint" crs:Radius="0.02" crs:MaskValue="1"><crs:Dabs><rdf:Seq><rdf:li>d 0.5 0.5</rdf:li></rdf:Seq></crs:Dabs></rdf:li>
</rdf:Seq></crs:CorrectionMasks></rdf:li>
</rdf:Seq></crs:PaintBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
fn settings() -> (DevelopSettings, Vec<String>) {
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
}
#[test]
fn corrections_become_masks() {
let (s, unmapped) = settings();
assert_eq!(s.light.exposure, 0.2, "global settings still map");
assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
let sky = &s.masks[0];
assert_eq!(sky.name, "Darker sky");
assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
assert_eq!(sky.components[0].shape, MaskShape::Sky);
assert_eq!(sky.components[1].op, MaskOp::Intersect);
let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
let rad = &s.masks[1];
assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
assert!(!rad.components[0].invert);
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
assert!(lo < hi && *lo_feather > 0.0);
let brush = &s.masks[2];
assert_eq!(brush.adjust.clarity, 40.0);
assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
}
#[test]
fn masks_render() {
let (s, _) = settings();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
#[test]
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:MaskGroupBasedCorrections><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:MaskGroupBasedCorrections>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let d = lightcraft_meta::parse_xmp(x).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
assert_eq!(s.masks[0].adjust.clarity, -30.0);
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
}
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
{ What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(s.masks.len(), 1);
assert_eq!(s.masks[0].adjust.exposure, -2.0);
}
#[test]
fn presets_add_masks_once() {
let (s, _) = settings();
let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
let p =
lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
let mut mine = DevelopSettings::default();
mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
let once = p.apply(&mine, 1.0);
assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
assert_eq!(once.masks[1].id, 8, "fresh ids");
assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
let half = p.apply(&mine, 0.5);
assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
}
#[test]
fn sidecar_radials_fit_the_photo() {
let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
refit_radials(&mut v, 1.5, 1.0);
let s = &v["masks"][0]["components"][0]["shape"];
assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
}
/// One radial correction in `container`, with `attrs` on its mask component (values written
/// for this test; the component attributes follow what Lightroom Classic writes).
fn radial_packet(container: &str, attrs: &str) -> String {
format!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0">
<crs:{container}><rdf:Seq>
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25">
<crs:CorrectionMasks><rdf:Seq>
<rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/>
</rdf:Seq></crs:CorrectionMasks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:{container}>
</rdf:Description></rdf:RDF></x:xmpmeta>"#
)
}
fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
assert!(unmapped.is_empty(), "{unmapped:?}");
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
s
}
/// (the radial's `invert`, its feather, the component's `invert`).
fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
let c = &s.masks[0].components[0];
let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
(*invert, *feather, c.invert)
}
/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
let render =
|d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
(g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
}
#[test]
fn lightroom_radial_flipped_is_inside() {
// as Lightroom Classic writes an ordinary radial mask today (#1815)
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
// no MaskInverted at all reads the same
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, false));
}
#[test]
fn lightroom_radial_mask_inverted_is_outside() {
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
// version 2 with Flipped="false": outside, the legacy sense of the flag
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
assert_eq!(radial_of(&s), (true, 25.0, false));
}
#[test]
fn versionless_group_radial_is_inside_with_half_the_feather() {
// Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
for flipped in ["true", "false"] {
let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
}
}
#[test]
fn legacy_radial_filter_is_outside_unless_flipped() {
// the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
assert_eq!(radial_of(&s), (true, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
assert_eq!(radial_of(&s), (false, 50.0, false));
let (inside, outside) = lift_inside_outside(&s);
assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
}
#[test]
fn lrtemplate_radial_filter_reads_flipped() {
// .lrtemplate presets and catalog develop settings are the same Lua tables
let t = |flipped: &str| {
format!(
r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
{{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
{{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
)
};
for (flipped, outside) in [("true", false), ("false", true)] {
let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
}
}
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
const SPOTS: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.1">
<crs:RetouchInfo><rdf:Seq>
<rdf:li>centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal</rdf:li>
</rdf:Seq></crs:RetouchInfo>
<crs:RetouchAreas><rdf:Seq>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:Method="poisson" crs:SourceX="0.45" crs:OffsetY="0.3" crs:Opacity="1" crs:Feather="0" crs:Seed="1">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:MaskActive="true" crs:MaskValue="1" crs:X="0.4" crs:Y="0.3" crs:SizeX="0.01" crs:SizeY="0.015"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="clone" crs:SourceState="sourceAutoComputed" crs:Opacity="0.5" crs:Feather="0.25">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Ellipse" crs:X="0.7" crs:Y="0.6" crs:SizeX="0.02" crs:SizeY="0.02"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceState="sourceSetExplicitly" crs:SourceX="0.2" crs:OffsetY="0.9">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Paint" crs:Radius="0.03" crs:MaskValue="1">
<crs:Dabs><rdf:Seq><rdf:li>d 0.1 0.8</rdf:li><rdf:li>d 0.12 0.82</rdf:li><rdf:li>d 0.14 0.84</rdf:li></rdf:Seq></crs:Dabs></rdf:li></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal" crs:SourceX="0.6" crs:OffsetY="0.5">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/CircularGradient" crs:Top="0.48" crs:Left="0.49" crs:Bottom="0.52" crs:Right="0.51"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
<rdf:li><rdf:Description crs:SpotType="heal">
<crs:Masks><rdf:Seq><rdf:li crs:What="Mask/Gradient" crs:FullX="0" crs:FullY="0" crs:ZeroX="1" crs:ZeroY="1"/></rdf:Seq></crs:Masks>
</rdf:Description></rdf:li>
</rdf:Seq></crs:RetouchAreas>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn retouch_areas_become_spots() {
use lightcraft_develop::SpotMode;
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
assert_eq!(s.light.exposure, 0.1, "global settings still map");
assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
let heal = &s.spots[0];
assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
let src = heal.source_offset.expect("explicit source");
assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
let clone = &s.spots[1];
assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
let brushed = &s.spots[2];
assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
let src = brushed.source_offset.expect("explicit source");
assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
let circ = &s.spots[3];
assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
}
#[test]
fn portrait_target_scales_spot_sizes() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
}
#[test]
fn legacy_retouch_info_is_read_on_its_own() {
let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
let d = lightcraft_meta::parse_xmp(&packet).unwrap();
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
}
#[test]
fn spots_render() {
let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
let s = DevelopSettings::default().merged(&partial).unwrap();
let img = lightcraft_scenes::demo_library()[0].render(96, 64);
let render = |d: &DevelopSettings| {
lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
};
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture");
}