//! TRACES: FR-DEV-3 //! The tone curve's four curves, seen from outside the crate. //! //! The unit tests beside the operation assert what one `ToneCurve` does. These //! assert the two properties that only show up once it is a node in a chain //! with a sidecar under it: that a file written before the per-channel curves //! existed still describes the edit it described, and that the three new //! curves cost a photograph that does not use them precisely nothing. use dr_pipeline::ops::curve::{self, Axis, Channel}; use dr_pipeline::{EditGraph, Sidecar}; /// A version block carrying `params`, in the on-disk spelling. fn sidecar_with(params: &[(&str, f32)]) -> String { let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n"); for (key, value) in params { text.push_str(&format!("{key} = {value}\n")); } text } fn apply(text: &str) -> EditGraph { let sidecar = Sidecar::parse(text).expect("a valid sidecar"); let mut graph = EditGraph::default_chain(); sidecar .default_version() .expect("a default version") .apply(&mut graph); graph } /// TRACES: FR-CAT-8 /// **The compatibility guarantee, end to end.** /// /// An edit made before this build existed has to produce the same image now. /// Asserted on the generated shader rather than on the parameter values, /// because that is what the photograph is actually made of: same source, same /// uniforms, same picture. #[test] fn an_edit_written_before_the_channel_curves_renders_as_it_did() { let from_file = apply(&sidecar_with(&[ ("tone_curve.p1_y", 0.15), ("tone_curve.p3_y", 0.85), ])); let mut by_hand = EditGraph::default_chain(); by_hand.set_param(curve::ID, curve::P1_Y, 0.15); by_hand.set_param(curve::ID, curve::P3_Y, 0.85); let restored = from_file.compose(); let expected = by_hand.compose(); assert_eq!(restored.source, expected.source); assert_eq!(restored.uniforms, expected.uniforms); assert_eq!(restored.structure_hash, expected.structure_hash); } /// **Neutral means absent, and stays absent with four times as much to be /// neutral about.** /// /// The curve carries forty parameters now. An image edited with an S-curve and /// nothing else must generate the shader it generated when it carried ten: /// three untouched curves are not three identity evaluations, they are nothing /// at all. #[test] fn three_untouched_curves_cost_nothing() { let mut graph = EditGraph::default_chain(); graph.set_param(curve::ID, curve::P2_Y, 0.62); let shader = graph.compose(); assert!( shader.source.contains("---- tone_curve ----"), "the master curve must reach the shader" ); assert!( !shader.source.contains("channel_curve"), "an untouched channel curve reached the shader:\n{}", shader.source ); for prefix in ["r_", "g_", "b_"] { assert!( !shader.source.contains(&format!("tone_curve_{prefix}")), "an untouched channel curve declared uniforms:\n{}", shader.source ); } } /// And with none of them touched, the operation is not there at all — the /// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the /// node that just quadrupled in size. #[test] fn a_curve_at_its_defaults_is_absent_from_the_shader() { let graph = EditGraph::default_chain(); assert!(graph.is_neutral()); assert!(!graph.compose().source.contains("tone_curve")); // Including when every one of the forty parameters has been explicitly // written to its own default, which is what a sidecar round trip through // a build with a different idea of "default" would produce. let mut written = EditGraph::default_chain(); for cap in written.capabilities() { if cap.id != curve::ID { continue; } for p in &cap.params { written.set_param(curve::ID, p.id, p.default); } } assert!(written.is_neutral()); } /// A grade with no tonal work is a real edit, and it must not drag the /// luminance path in behind it. #[test] fn a_channel_curve_reaches_the_shader_on_its_own() { let mut graph = EditGraph::default_chain(); graph.set_param( curve::ID, curve::coordinate(Channel::Blue, 0, Axis::Y), 0.08, ); let shader = graph.compose(); assert!(shader.source.contains("c.b = channel_curve(c.b,")); assert!( !shader.source.contains("apply_tone_gain"), "the identity master curve reached the shader:\n{}", shader.source ); // Blue's ten points, and nothing else: the other two curves are at the // identity and contribute no slot to the uniform block. for prefix in ["r_", "g_"] { assert!( !shader.source.contains(&format!("tone_curve_{prefix}")), "an untouched channel curve declared uniforms:\n{}", shader.source ); } for point in 0..curve::POINTS { assert!( shader.source.contains(&format!("tone_curve_b_x{point}")), "blue's point {point} is missing from the uniform block" ); } } /// Each curve is its own shader, so the pipeline cache cannot hand the red /// curve's compiled program to an edit that moved the green one. #[test] fn every_curve_generates_a_distinct_shader() { let mut hashes: Vec = Vec::new(); for channel in Channel::ALL { let mut graph = EditGraph::default_chain(); graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62); hashes.push(graph.compose().structure_hash); } let before = hashes.len(); hashes.sort_unstable(); hashes.dedup(); assert_eq!(before, hashes.len(), "two curves share a compiled shader"); } /// The forty parameters all persist, and the file names each one once. #[test] fn every_point_of_every_curve_round_trips_through_a_sidecar() { let mut graph = EditGraph::default_chain(); // A different y per coordinate, so a point wired to the wrong channel // cannot pass by holding the value it was supposed to hold anyway. // Thirty-secondths because they survive both the decimal the file is // written in and the binary32 it is read back into exactly — this test is // about which parameter a value lands in, and a rounding difference here // would fail it for an unrelated reason. let mut step = 1; for channel in Channel::ALL { for point in 0..curve::POINTS { graph.set_param( curve::ID, curve::coordinate(channel, point, Axis::Y), step as f32 / 32.0, ); step += 1; } } let mut sidecar = Sidecar::new(); sidecar.put(dr_pipeline::sidecar::Version::from_graph( "u1", "Default", &graph, )); let restored = apply(&sidecar.to_text()); for channel in Channel::ALL { for point in 0..curve::POINTS { let id = curve::coordinate(channel, point, Axis::Y); assert_eq!( restored.param(curve::ID, id), graph.param(curve::ID, id), "{id} did not survive the sidecar" ); } } }