use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::engine::{
Parameters, PartialColorGradingParams, PartialDehazeParams, PartialDetailParams,
PartialGrainParams, PartialHslChannels, PartialNoiseReductionParams, PartialParameters,
PartialToneCurve, PartialToneCurveParams, PartialVignetteParams,
};
use crate::error::{AgxError, Result};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PresetMetadata {
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extends: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct ToneSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
exposure: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
contrast: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
highlights: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shadows: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
whites: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
blacks: Option<f32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct WhiteBalanceSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tint: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct LutSection {
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PresetRaw {
#[serde(default)]
metadata: PresetMetadata,
#[serde(default)]
tone: ToneSection,
#[serde(default)]
white_balance: WhiteBalanceSection,
#[serde(default)]
lut: LutSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
hsl: Option<PartialHslChannels>,
#[serde(default, skip_serializing_if = "Option::is_none")]
vignette: Option<PartialVignetteParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color_grading: Option<PartialColorGradingParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tone_curve: Option<PartialToneCurveParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<PartialDetailParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dehaze: Option<PartialDehazeParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
noise_reduction: Option<PartialNoiseReductionParams>,
#[serde(default, skip_serializing_if = "Option::is_none")]
grain: Option<PartialGrainParams>,
}
fn validate_tone_curve_params(params: &PartialToneCurveParams) -> Result<()> {
fn validate_channel(tc: &Option<PartialToneCurve>, name: &str) -> Result<()> {
if let Some(ref c) = tc {
if let Some(ref pts) = c.points {
let curve = crate::adjust::ToneCurve {
points: pts.clone(),
};
curve
.validate()
.map_err(|e| AgxError::Preset(format!("tone_curve.{name}: {e}")))?;
}
}
Ok(())
}
validate_channel(¶ms.rgb, "rgb")?;
validate_channel(¶ms.luma, "luma")?;
validate_channel(¶ms.red, "red")?;
validate_channel(¶ms.green, "green")?;
validate_channel(¶ms.blue, "blue")?;
Ok(())
}
fn validate_detail_params(params: &PartialDetailParams) -> Result<()> {
if let Some(ref sharp) = params.sharpening {
if let Some(amount) = sharp.amount {
if !(0.0..=100.0).contains(&amount) {
return Err(AgxError::Preset(format!(
"detail.sharpening.amount must be 0-100, got {amount}"
)));
}
}
if let Some(radius) = sharp.radius {
if !(0.5..=3.0).contains(&radius) {
return Err(AgxError::Preset(format!(
"detail.sharpening.radius must be 0.5-3.0, got {radius}"
)));
}
}
if let Some(threshold) = sharp.threshold {
if !(0.0..=100.0).contains(&threshold) {
return Err(AgxError::Preset(format!(
"detail.sharpening.threshold must be 0-100, got {threshold}"
)));
}
}
if let Some(masking) = sharp.masking {
if !(0.0..=100.0).contains(&masking) {
return Err(AgxError::Preset(format!(
"detail.sharpening.masking must be 0-100, got {masking}"
)));
}
}
}
if let Some(clarity) = params.clarity {
if !(-100.0..=100.0).contains(&clarity) {
return Err(AgxError::Preset(format!(
"detail.clarity must be -100 to 100, got {clarity}"
)));
}
}
if let Some(texture) = params.texture {
if !(-100.0..=100.0).contains(&texture) {
return Err(AgxError::Preset(format!(
"detail.texture must be -100 to 100, got {texture}"
)));
}
}
Ok(())
}
fn validate_dehaze_params(params: &PartialDehazeParams) -> Result<()> {
if let Some(amount) = params.amount {
if !(-100.0..=100.0).contains(&amount) {
return Err(AgxError::Preset(format!(
"dehaze.amount must be -100 to 100, got {amount}"
)));
}
}
Ok(())
}
fn validate_noise_reduction_params(params: &PartialNoiseReductionParams) -> Result<()> {
if let Some(luminance) = params.luminance {
if !(0.0..=100.0).contains(&luminance) {
return Err(AgxError::Preset(format!(
"noise_reduction.luminance must be 0-100, got {luminance}"
)));
}
}
if let Some(color) = params.color {
if !(0.0..=100.0).contains(&color) {
return Err(AgxError::Preset(format!(
"noise_reduction.color must be 0-100, got {color}"
)));
}
}
if let Some(detail) = params.detail {
if !(0.0..=100.0).contains(&detail) {
return Err(AgxError::Preset(format!(
"noise_reduction.detail must be 0-100, got {detail}"
)));
}
}
Ok(())
}
fn validate_grain_params(params: &PartialGrainParams) -> Result<()> {
if let Some(amount) = params.amount {
if !(0.0..=100.0).contains(&amount) {
return Err(AgxError::Preset(format!(
"grain amount must be 0-100, got {amount}"
)));
}
}
if let Some(size) = params.size {
if !(0.0..=100.0).contains(&size) {
return Err(AgxError::Preset(format!(
"grain size must be 0-100, got {size}"
)));
}
}
Ok(())
}
fn build_partial_params(raw: &PresetRaw) -> PartialParameters {
PartialParameters {
exposure: raw.tone.exposure,
contrast: raw.tone.contrast,
highlights: raw.tone.highlights,
shadows: raw.tone.shadows,
whites: raw.tone.whites,
blacks: raw.tone.blacks,
temperature: raw.white_balance.temperature,
tint: raw.white_balance.tint,
hsl: raw.hsl.clone(),
vignette: raw.vignette.clone(),
color_grading: raw.color_grading.clone(),
tone_curve: raw.tone_curve.clone(),
detail: raw.detail.clone(),
dehaze: raw.dehaze.clone(),
noise_reduction: raw.noise_reduction.clone(),
grain: raw.grain.clone(),
}
}
#[derive(Debug, Clone, Default)]
pub struct Preset {
pub metadata: PresetMetadata,
pub partial_params: PartialParameters,
pub lut: Option<Arc<crate::lut::Lut3D>>,
}
impl Preset {
pub fn params(&self) -> Parameters {
self.partial_params.materialize()
}
}
impl PartialEq for Preset {
fn eq(&self, other: &Self) -> bool {
self.metadata == other.metadata && self.partial_params == other.partial_params
}
}
impl Preset {
pub fn from_toml(toml_str: &str) -> Result<Self> {
let raw: PresetRaw =
toml::from_str(toml_str).map_err(|e| AgxError::Preset(e.to_string()))?;
let partial = build_partial_params(&raw);
if let Some(ref tc) = partial.tone_curve {
validate_tone_curve_params(tc)?;
}
if let Some(ref detail) = partial.detail {
validate_detail_params(detail)?;
}
if let Some(ref dehaze) = partial.dehaze {
validate_dehaze_params(dehaze)?;
}
if let Some(ref nr) = partial.noise_reduction {
validate_noise_reduction_params(nr)?;
}
if let Some(ref g) = partial.grain {
validate_grain_params(g)?;
}
Ok(Self {
metadata: raw.metadata,
partial_params: partial,
lut: None,
})
}
pub fn to_toml(&self) -> Result<String> {
let raw = PresetRaw {
metadata: self.metadata.clone(),
tone: ToneSection {
exposure: self.partial_params.exposure,
contrast: self.partial_params.contrast,
highlights: self.partial_params.highlights,
shadows: self.partial_params.shadows,
whites: self.partial_params.whites,
blacks: self.partial_params.blacks,
},
white_balance: WhiteBalanceSection {
temperature: self.partial_params.temperature,
tint: self.partial_params.tint,
},
lut: LutSection::default(),
hsl: self.partial_params.hsl.clone(),
vignette: self.partial_params.vignette.clone(),
color_grading: self.partial_params.color_grading.clone(),
tone_curve: self.partial_params.tone_curve.clone(),
detail: self.partial_params.detail.clone(),
dehaze: self.partial_params.dehaze.clone(),
noise_reduction: self.partial_params.noise_reduction.clone(),
grain: self.partial_params.grain.clone(),
};
toml::to_string_pretty(&raw).map_err(|e| AgxError::Preset(e.to_string()))
}
pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
let mut visited = std::collections::HashSet::new();
Self::load_from_file_with_visited(path, &mut visited)
}
fn load_from_file_with_visited(
path: &std::path::Path,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
) -> Result<Self> {
let canonical = path.canonicalize().map_err(AgxError::Io)?;
if !visited.insert(canonical.clone()) {
return Err(AgxError::Preset(format!(
"circular extends: {} already visited",
canonical.display()
)));
}
let content = std::fs::read_to_string(path)?;
let raw: PresetRaw =
toml::from_str(&content).map_err(|e| AgxError::Preset(e.to_string()))?;
let base_dir = path.parent().unwrap_or(std::path::Path::new("."));
let this_partial = build_partial_params(&raw);
if let Some(ref detail) = this_partial.detail {
validate_detail_params(detail)?;
}
if let Some(ref dehaze) = this_partial.dehaze {
validate_dehaze_params(dehaze)?;
}
if let Some(ref nr) = this_partial.noise_reduction {
validate_noise_reduction_params(nr)?;
}
if let Some(ref g) = this_partial.grain {
validate_grain_params(g)?;
}
let (merged_partial, base_lut) = if let Some(extends_path) = &raw.metadata.extends {
let extends_full = base_dir.join(extends_path);
let base_preset = Self::load_from_file_with_visited(&extends_full, visited)?;
let merged = base_preset.partial_params.merge(&this_partial);
(merged, base_preset.lut)
} else {
(this_partial.clone(), None)
};
let lut = if let Some(lut_path_str) = &raw.lut.path {
let lut_path = base_dir.join(lut_path_str);
Some(Arc::new(crate::lut::Lut3D::from_cube_file(&lut_path)?))
} else {
base_lut
};
Ok(Self {
metadata: raw.metadata,
partial_params: merged_partial,
lut,
})
}
pub fn save_to_file(&self, path: &std::path::Path) -> Result<()> {
let toml_str = self.to_toml()?;
std::fs::write(path, toml_str)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_preset_is_neutral() {
let preset = Preset::default();
assert_eq!(preset.params(), Parameters::default());
assert_eq!(preset.metadata.name, "");
}
#[test]
fn serialize_contains_expected_keys() {
let mut preset = Preset::default();
preset.metadata.name = "Test".into();
preset.partial_params.exposure = Some(1.5);
preset.partial_params.temperature = Some(30.0);
let toml_str = preset.to_toml().unwrap();
assert!(toml_str.contains("name = \"Test\""));
assert!(toml_str.contains("exposure = 1.5"));
assert!(toml_str.contains("temperature = 30.0"));
}
#[test]
fn deserialize_parses_values() {
let toml_str = r#"
[metadata]
name = "Golden Hour"
version = "1.0"
author = "test"
[tone]
exposure = 0.5
contrast = 15.0
highlights = -30.0
shadows = 25.0
[white_balance]
temperature = 6200.0
tint = 10.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.metadata.name, "Golden Hour");
assert_eq!(preset.params().exposure, 0.5);
assert_eq!(preset.params().contrast, 15.0);
assert_eq!(preset.params().highlights, -30.0);
assert_eq!(preset.params().shadows, 25.0);
assert_eq!(preset.params().temperature, 6200.0);
assert_eq!(preset.params().tint, 10.0);
}
#[test]
fn roundtrip_serialize_deserialize() {
let mut preset = Preset::default();
preset.metadata.name = "Roundtrip".into();
preset.partial_params.exposure = Some(2.0);
preset.partial_params.contrast = Some(-10.0);
preset.partial_params.temperature = Some(50.0);
preset.partial_params.tint = Some(-5.0);
let toml_str = preset.to_toml().unwrap();
let parsed = Preset::from_toml(&toml_str).unwrap();
assert_eq!(preset, parsed);
}
#[test]
fn missing_fields_default_to_zero() {
let toml_str = r#"
[metadata]
name = "Minimal"
[tone]
exposure = 1.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().exposure, 1.0);
assert_eq!(preset.params().contrast, 0.0);
assert_eq!(preset.params().highlights, 0.0);
assert_eq!(preset.params().temperature, 0.0);
}
#[test]
fn invalid_toml_returns_error() {
let result = Preset::from_toml("this is not valid toml {{{{");
assert!(result.is_err());
}
#[test]
fn file_save_and_load_roundtrip() {
let temp_path = std::env::temp_dir().join("agx_test_preset.toml");
let mut preset = Preset::default();
preset.metadata.name = "File Test".into();
preset.partial_params.exposure = Some(1.5);
preset.partial_params.contrast = Some(20.0);
preset.save_to_file(&temp_path).unwrap();
let loaded = Preset::load_from_file(&temp_path).unwrap();
assert_eq!(preset, loaded);
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn load_nonexistent_file_returns_error() {
let result = Preset::load_from_file(std::path::Path::new("/nonexistent/preset.toml"));
assert!(result.is_err());
}
#[test]
fn preset_with_lut_path_loads_lut() {
let temp_dir = std::env::temp_dir();
let cube_path = temp_dir.join("agx_preset_test.cube");
let preset_path = temp_dir.join("agx_preset_test.toml");
std::fs::write(
&cube_path,
"\
LUT_3D_SIZE 2
0.0 0.0 0.0
1.0 0.0 0.0
0.0 1.0 0.0
1.0 1.0 0.0
0.0 0.0 1.0
1.0 0.0 1.0
0.0 1.0 1.0
1.0 1.0 1.0
",
)
.unwrap();
let toml_content = format!(
"[metadata]\nname = \"LUT Test\"\n\n[tone]\nexposure = 0.5\n\n[lut]\npath = \"{}\"\n",
cube_path.file_name().unwrap().to_str().unwrap()
);
std::fs::write(&preset_path, &toml_content).unwrap();
let preset = Preset::load_from_file(&preset_path).unwrap();
assert_eq!(preset.params().exposure, 0.5);
assert!(preset.lut.is_some());
assert_eq!(preset.lut.as_ref().unwrap().size, 2);
let _ = std::fs::remove_file(&cube_path);
let _ = std::fs::remove_file(&preset_path);
}
#[test]
fn preset_without_lut_section_has_no_lut() {
let toml_str = "[metadata]\nname = \"No LUT\"\n\n[tone]\nexposure = 1.0\n";
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.lut.is_none());
}
#[test]
fn preset_hsl_roundtrip() {
use crate::engine::{PartialHslChannel, PartialHslChannels};
let mut preset = Preset::default();
preset.partial_params.hsl = Some(PartialHslChannels {
red: Some(PartialHslChannel {
hue: Some(15.0),
saturation: None,
luminance: None,
}),
green: Some(PartialHslChannel {
hue: None,
saturation: Some(-30.0),
luminance: None,
}),
blue: Some(PartialHslChannel {
hue: None,
saturation: None,
luminance: Some(20.0),
}),
..Default::default()
});
let toml_str = preset.to_toml().unwrap();
let parsed = Preset::from_toml(&toml_str).unwrap();
assert_eq!(preset.params().hsl, parsed.params().hsl);
}
#[test]
fn preset_missing_hsl_defaults_to_zero() {
let toml_str = "[metadata]\nname = \"No HSL\"\n\n[tone]\nexposure = 1.0\n";
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.params().hsl.is_default());
}
#[test]
fn preset_partial_hsl_channels_default() {
let toml_str = r#"
[metadata]
name = "Partial HSL"
[hsl.red]
hue = 10.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().hsl.red.hue, 10.0);
assert_eq!(preset.params().hsl.red.saturation, 0.0);
assert!(preset.params().hsl.green == crate::engine::HslChannel::default());
}
#[test]
fn preset_with_missing_lut_file_returns_error() {
let temp_dir = std::env::temp_dir();
let preset_path = temp_dir.join("agx_missing_lut_test.toml");
std::fs::write(
&preset_path,
"[metadata]\nname = \"Bad\"\n\n[lut]\npath = \"nonexistent.cube\"\n",
)
.unwrap();
let result = Preset::load_from_file(&preset_path);
assert!(result.is_err());
let _ = std::fs::remove_file(&preset_path);
}
#[test]
fn preset_partial_only_specified_fields_are_some() {
let toml_str = r#"
[metadata]
name = "Warm"
[tone]
exposure = 1.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.partial_params.exposure, Some(1.0));
assert_eq!(preset.partial_params.contrast, None);
assert_eq!(preset.partial_params.temperature, None);
}
#[test]
fn preset_partial_explicit_zero_is_some() {
let toml_str = r#"
[metadata]
name = "Zero"
[tone]
exposure = 0.0
contrast = 0.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.partial_params.exposure, Some(0.0));
assert_eq!(preset.partial_params.contrast, Some(0.0));
assert_eq!(preset.partial_params.highlights, None);
}
#[test]
fn preset_partial_hsl_only_specified() {
let toml_str = r#"
[metadata]
name = "HSL"
[hsl.red]
hue = 10.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let hsl = preset.partial_params.hsl.as_ref().unwrap();
assert!(hsl.red.is_some());
assert_eq!(hsl.red.as_ref().unwrap().hue, Some(10.0));
assert_eq!(hsl.red.as_ref().unwrap().saturation, None);
assert!(hsl.green.is_none());
}
#[test]
fn preset_materialized_params_match_legacy_behavior() {
let toml_str = r#"
[metadata]
name = "Full"
[tone]
exposure = 1.0
contrast = 20.0
highlights = -10.0
shadows = 15.0
whites = 5.0
blacks = -5.0
[white_balance]
temperature = 30.0
tint = -5.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().exposure, 1.0);
assert_eq!(preset.params().contrast, 20.0);
assert_eq!(preset.params().temperature, 30.0);
}
#[test]
fn preset_roundtrip_preserves_partial() {
let toml_str = r#"
[metadata]
name = "Partial"
[tone]
exposure = 1.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.partial_params.exposure, Some(1.0));
assert_eq!(preset.partial_params.contrast, None);
assert_eq!(preset.params().exposure, 1.0);
assert_eq!(preset.params().contrast, 0.0);
}
#[test]
fn preset_vignette_roundtrip() {
let mut preset = Preset::default();
preset.partial_params.vignette = Some(crate::engine::PartialVignetteParams {
amount: Some(-30.0),
shape: Some(crate::adjust::VignetteShape::Circular),
});
let toml_str = preset.to_toml().unwrap();
let parsed = Preset::from_toml(&toml_str).unwrap();
assert_eq!(preset.params().vignette, parsed.params().vignette);
}
#[test]
fn preset_vignette_from_toml() {
let toml_str = r#"
[metadata]
name = "Vignette Test"
[vignette]
amount = -30.0
shape = "circular"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().vignette.amount, -30.0);
assert_eq!(
preset.params().vignette.shape,
crate::adjust::VignetteShape::Circular
);
}
#[test]
fn preset_vignette_default_shape() {
let toml_str = r#"
[metadata]
name = "Vignette Default Shape"
[vignette]
amount = -20.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().vignette.amount, -20.0);
assert_eq!(
preset.params().vignette.shape,
crate::adjust::VignetteShape::Elliptical
);
}
#[test]
fn preset_missing_vignette_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No Vignette"
[tone]
exposure = 1.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert_eq!(preset.params().vignette.amount, 0.0);
}
#[test]
fn preset_vignette_partial_only_amount() {
let toml_str = r#"
[metadata]
name = "Partial"
[vignette]
amount = -15.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let vig = preset.partial_params.vignette.as_ref().unwrap();
assert_eq!(vig.amount, Some(-15.0));
assert_eq!(vig.shape, None);
}
#[test]
fn color_grading_preset_round_trip() {
let toml_str = r#"
[metadata]
name = "Color Grading Test"
[color_grading]
balance = -10.0
[color_grading.shadows]
hue = 200.0
saturation = 30.0
luminance = -5.0
[color_grading.highlights]
hue = 30.0
saturation = 25.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let cg = preset.partial_params.color_grading.as_ref().unwrap();
assert_eq!(cg.balance, Some(-10.0));
let shadows = cg.shadows.as_ref().unwrap();
assert_eq!(shadows.hue, Some(200.0));
assert_eq!(shadows.saturation, Some(30.0));
assert_eq!(shadows.luminance, Some(-5.0));
let highlights = cg.highlights.as_ref().unwrap();
assert_eq!(highlights.hue, Some(30.0));
assert_eq!(highlights.saturation, Some(25.0));
assert_eq!(highlights.luminance, None);
assert!(cg.midtones.is_none());
}
#[test]
fn preset_missing_color_grading_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No CG"
[tone]
exposure = 1.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.params().color_grading.is_default());
}
#[test]
fn preset_extends_single_level() {
let temp_dir = std::env::temp_dir();
let base_path = temp_dir.join("agx_extends_base.toml");
let child_path = temp_dir.join("agx_extends_child.toml");
std::fs::write(
&base_path,
r#"
[metadata]
name = "Base"
[tone]
exposure = 1.0
contrast = 20.0
"#,
)
.unwrap();
std::fs::write(
&child_path,
format!(
r#"
[metadata]
name = "Child"
extends = "{}"
[tone]
contrast = 50.0
"#,
base_path.file_name().unwrap().to_str().unwrap()
),
)
.unwrap();
let preset = Preset::load_from_file(&child_path).unwrap();
assert_eq!(preset.metadata.name, "Child");
assert_eq!(preset.params().exposure, 1.0);
assert_eq!(preset.params().contrast, 50.0);
let _ = std::fs::remove_file(&base_path);
let _ = std::fs::remove_file(&child_path);
}
#[test]
fn preset_extends_multi_level() {
let temp_dir = std::env::temp_dir();
let grandparent = temp_dir.join("agx_extends_gp.toml");
let parent = temp_dir.join("agx_extends_parent.toml");
let child = temp_dir.join("agx_extends_child2.toml");
std::fs::write(
&grandparent,
r#"
[metadata]
name = "Grandparent"
[tone]
exposure = 1.0
contrast = 10.0
highlights = -20.0
"#,
)
.unwrap();
std::fs::write(
&parent,
format!(
r#"
[metadata]
name = "Parent"
extends = "{}"
[tone]
contrast = 30.0
"#,
grandparent.file_name().unwrap().to_str().unwrap()
),
)
.unwrap();
std::fs::write(
&child,
format!(
r#"
[metadata]
name = "Child"
extends = "{}"
[tone]
highlights = 10.0
"#,
parent.file_name().unwrap().to_str().unwrap()
),
)
.unwrap();
let preset = Preset::load_from_file(&child).unwrap();
assert_eq!(preset.params().exposure, 1.0);
assert_eq!(preset.params().contrast, 30.0);
assert_eq!(preset.params().highlights, 10.0);
let _ = std::fs::remove_file(&grandparent);
let _ = std::fs::remove_file(&parent);
let _ = std::fs::remove_file(&child);
}
#[test]
fn preset_extends_cycle_detection() {
let temp_dir = std::env::temp_dir();
let a_path = temp_dir.join("agx_cycle_a.toml");
let b_path = temp_dir.join("agx_cycle_b.toml");
std::fs::write(
&a_path,
format!(
r#"
[metadata]
name = "A"
extends = "{}"
"#,
b_path.file_name().unwrap().to_str().unwrap()
),
)
.unwrap();
std::fs::write(
&b_path,
format!(
r#"
[metadata]
name = "B"
extends = "{}"
"#,
a_path.file_name().unwrap().to_str().unwrap()
),
)
.unwrap();
let result = Preset::load_from_file(&a_path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("circular"),
"Expected circular error, got: {err_msg}"
);
let _ = std::fs::remove_file(&a_path);
let _ = std::fs::remove_file(&b_path);
}
#[test]
fn tone_curve_preset_round_trip() {
let toml_str = r#"
[metadata]
name = "Test Tone Curve"
[tone_curve.rgb]
points = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]]
[tone_curve.red]
points = [[0.0, 0.0], [0.5, 0.6], [1.0, 1.0]]
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let params = preset.partial_params.tone_curve.as_ref().unwrap();
let rgb_pts = params.rgb.as_ref().unwrap().points.as_ref().unwrap();
assert_eq!(rgb_pts.len(), 4);
assert_eq!(rgb_pts[1], (0.25, 0.15));
let serialized = preset.to_toml().unwrap();
let preset2 = Preset::from_toml(&serialized).unwrap();
let params2 = preset2.partial_params.tone_curve.as_ref().unwrap();
let rgb_pts2 = params2.rgb.as_ref().unwrap().points.as_ref().unwrap();
assert_eq!(rgb_pts, rgb_pts2);
}
#[test]
fn preset_missing_tone_curve_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No curves"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let materialized = preset.partial_params.materialize();
assert!(materialized.tone_curve.is_default());
}
#[test]
fn preset_tone_curve_invalid_points_rejected() {
let toml_str = r#"
[metadata]
name = "Bad curve"
[tone_curve.rgb]
points = [[0.0, 0.0], [0.8, 0.5], [0.3, 0.7], [1.0, 1.0]]
"#;
let result = Preset::from_toml(toml_str);
assert!(result.is_err(), "non-increasing x should be rejected");
}
#[test]
fn detail_section_roundtrip() {
use crate::engine::{PartialDetailParams, PartialSharpeningParams};
let mut preset = Preset::default();
preset.partial_params.detail = Some(PartialDetailParams {
sharpening: Some(PartialSharpeningParams {
amount: Some(40.0),
radius: Some(1.5),
threshold: Some(30.0),
masking: Some(50.0),
}),
clarity: Some(25.0),
texture: Some(-10.0),
});
let toml_str = preset.to_toml().unwrap();
let parsed = Preset::from_toml(&toml_str).unwrap();
assert_eq!(preset, parsed);
}
#[test]
fn missing_detail_section_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No Detail"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.params().detail.is_neutral());
}
#[test]
fn detail_sharpening_subtable_parses() {
let toml_str = r#"
[metadata]
name = "Sharp"
[detail]
clarity = 30.0
[detail.sharpening]
amount = 60.0
radius = 2.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let p = preset.params();
assert_eq!(p.detail.clarity, 30.0);
assert_eq!(p.detail.sharpening.amount, 60.0);
assert_eq!(p.detail.sharpening.radius, 2.0);
assert_eq!(p.detail.sharpening.threshold, 25.0); }
#[test]
fn detail_validation_rejects_out_of_range() {
let toml_str = r#"
[detail.sharpening]
amount = 150.0
"#;
let result = Preset::from_toml(toml_str);
assert!(result.is_err(), "amount > 100 should be rejected");
}
#[test]
fn dehaze_section_roundtrip() {
let toml_str = r#"
[metadata]
name = "Dehaze Test"
[dehaze]
amount = 40.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let params = preset.params();
assert!((params.dehaze.amount - 40.0).abs() < 1e-6);
let serialized = preset.to_toml().unwrap();
let roundtrip = Preset::from_toml(&serialized).unwrap();
assert_eq!(preset, roundtrip);
}
#[test]
fn missing_dehaze_section_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No Dehaze"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.params().dehaze.is_neutral());
}
#[test]
fn dehaze_validation_rejects_out_of_range() {
let toml_str = r#"
[dehaze]
amount = 150.0
"#;
let result = Preset::from_toml(toml_str);
assert!(result.is_err());
let toml_str2 = r#"
[dehaze]
amount = -150.0
"#;
let result2 = Preset::from_toml(toml_str2);
assert!(result2.is_err());
}
#[test]
fn nr_section_roundtrip() {
let toml_str = r#"
[metadata]
name = "NR Test"
[noise_reduction]
luminance = 40.0
color = 25.0
detail = 50.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let params = preset.params();
assert!((params.noise_reduction.luminance - 40.0).abs() < 1e-6);
assert!((params.noise_reduction.color - 25.0).abs() < 1e-6);
assert!((params.noise_reduction.detail - 50.0).abs() < 1e-6);
let serialized = preset.to_toml().unwrap();
let roundtrip = Preset::from_toml(&serialized).unwrap();
assert_eq!(preset, roundtrip);
}
#[test]
fn missing_nr_section_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No NR"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
assert!(preset.params().noise_reduction.is_neutral());
}
#[test]
fn nr_validation_rejects_out_of_range() {
let toml_str = r#"
[noise_reduction]
luminance = 150.0
"#;
assert!(Preset::from_toml(toml_str).is_err());
let toml_str2 = r#"
[noise_reduction]
color = -10.0
"#;
assert!(Preset::from_toml(toml_str2).is_err());
let toml_str3 = r#"
[noise_reduction]
detail = 101.0
"#;
assert!(Preset::from_toml(toml_str3).is_err());
}
#[test]
fn grain_section_roundtrip() {
let toml_str = r#"
[metadata]
name = "Grain Test"
version = "1.0"
[grain]
type = "harsh"
amount = 60.0
size = 70.0
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let params = preset.params();
assert_eq!(params.grain.grain_type, crate::adjust::GrainType::Harsh);
assert_eq!(params.grain.amount, 60.0);
assert_eq!(params.grain.size, 70.0);
let round_tripped = preset.to_toml().unwrap();
let preset2 = Preset::from_toml(&round_tripped).unwrap();
let params2 = preset2.params();
assert_eq!(params.grain, params2.grain);
}
#[test]
fn missing_grain_section_defaults_to_neutral() {
let toml_str = r#"
[metadata]
name = "No Grain"
version = "1.0"
"#;
let preset = Preset::from_toml(toml_str).unwrap();
let params = preset.params();
assert!(params.grain.is_neutral());
}
#[test]
fn grain_validation_rejects_out_of_range() {
let toml_str = r#"
[metadata]
name = "Bad Grain"
version = "1.0"
[grain]
amount = 150.0
"#;
let result = Preset::from_toml(toml_str);
assert!(result.is_err());
}
}