//! Local adjustments must survive the sidecar. //! //! The sidecar is the authoritative store (ARCH §6.1) — the catalog is a //! disposable index and the RAW is never written. So a mask that does not //! round-trip is not a persistence bug, it is lost work, and the tests here //! are about the ways that happens quietly rather than loudly. use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource}; use dr_pipeline::{EditGraph, Sidecar, Version}; fn regions(ids: &[u32]) -> MaskSource { MaskSource::Regions { signature: 0xdead_beef, level: 300, ids: ids.to_vec(), } } /// A graph with one masked adjustment on it. fn graph_with_mask() -> EditGraph { let mut graph = EditGraph::default_chain(); let mut layer = MaskLayer::new("m1", regions(&[3, 7, 12])); layer.name = "Subject".into(); layer.set_param("exposure", ParamId("exposure"), 0.75); graph.masks_mut().push(layer); graph } fn round_trip(graph: &EditGraph) -> EditGraph { let mut sidecar = Sidecar::new(); sidecar.put(Version::from_graph("default", "Default", graph)); let text = sidecar.to_text(); let parsed = Sidecar::parse(&text).expect("reparse"); let mut restored = EditGraph::default_chain(); parsed .versions .get("default") .expect("version survived") .apply(&mut restored); restored } #[test] fn a_region_mask_survives_a_round_trip() { let graph = graph_with_mask(); let restored = round_trip(&graph); let layers = restored.masks().layers(); assert_eq!(layers.len(), 1, "the layer came back"); let layer = &layers[0]; assert_eq!(layer.id, "m1"); assert_eq!(layer.name, "Subject"); assert_eq!(layer.source, regions(&[3, 7, 12])); assert_eq!(layer.ops.iter().find(|o| o.descriptor().id.0 == "exposure").map(|o| o.param(ParamId("exposure"))), Some(0.75)); } #[test] fn writing_the_same_state_twice_is_byte_identical() { // What lets a caller skip an upload by comparing content rather than // trusting a dirty flag — the property the whole `versions` ordering // exists for, now that masks are in the file too. let graph = graph_with_mask(); let mut a = Sidecar::new(); a.put(Version::from_graph("default", "Default", &graph)); let once = a.to_text(); let twice = Sidecar::parse(&once).expect("reparse").to_text(); assert_eq!(once, twice); } #[test] fn gradients_keep_their_geometry() { let mut graph = EditGraph::default_chain(); let mut linear = MaskLayer::new( "m1", MaskSource::Linear { centre: (0.25, 0.75), angle: 1.25, width: 0.4, }, ); linear.set_param("exposure", ParamId("exposure"), -1.0); graph.masks_mut().push(linear); let mut radial = MaskLayer::new( "m2", MaskSource::Radial { centre: (0.6, 0.4), radii: (0.3, 0.15), angle: -0.5, feather: 0.65, }, ); radial.set_param("exposure", ParamId("exposure"), 0.5); graph.masks_mut().push(radial); let restored = round_trip(&graph); let layers = restored.masks().layers(); assert_eq!(layers.len(), 2); assert_eq!(layers[0].source, graph.masks().layers()[0].source); assert_eq!(layers[1].source, graph.masks().layers()[1].source); } #[test] fn flags_and_opacity_survive() { let mut graph = EditGraph::default_chain(); let mut layer = MaskLayer::new("m1", regions(&[1])); layer.set_param("exposure", ParamId("exposure"), 1.0); layer.invert = true; layer.opacity = 0.35; layer.enabled = false; graph.masks_mut().push(layer); let restored = round_trip(&graph); let layer = &restored.masks().layers()[0]; assert!(layer.invert); assert!((layer.opacity - 0.35).abs() < 1e-6); assert!(!layer.enabled, "a disabled layer must stay disabled, not vanish"); } /// A selection's identity is the *set*. Two files naming the same regions in /// different orders describe one mask and must compare equal, or the two /// devices that wrote them will fight forever over a difference that is not /// one. #[test] fn region_order_is_normalised_on_read() { let text = "drsc 1\n\ \n[version default]\n\ name = Default\n\ revision = 1\n\ modified = 0\n\ \n[mask default m1]\n\ source = regions\n\ signature = 5\n\ level = 10\n\ regions = 12 3 7 3\n\ exposure.exposure = 1\n"; let parsed = Sidecar::parse(text).expect("parse"); let layer = &parsed.versions["default"].masks.layers()[0]; assert_eq!( layer.source, MaskSource::Regions { signature: 5, level: 10, ids: vec![3, 7, 12], }, "ids sort and deduplicate on the way in" ); } /// The version skew case. An older build must not delete a mask type it has /// never heard of *silently* — but neither may it apply one it cannot read. #[test] fn an_unknown_mask_source_is_skipped_not_guessed() { let text = "drsc 1\n\ \n[version default]\n\ name = Default\n\ revision = 1\n\ modified = 0\n\ \n[mask default m1]\n\ source = luminosity\n\ threshold = 0.5\n\ exposure.exposure = 1\n"; let parsed = Sidecar::parse(text).expect("parse"); assert!( parsed.versions["default"].masks.is_empty(), "an unreadable mask must not become a wrong one" ); } #[test] fn a_mask_naming_no_known_version_is_dropped() { let text = "drsc 1\n\ \n[version default]\n\ name = Default\n\ revision = 1\n\ modified = 0\n\ \n[mask ghost m1]\n\ source = regions\n\ signature = 1\n\ level = 1\n\ regions = 1\n"; let parsed = Sidecar::parse(text).expect("parse"); assert!(parsed.versions["default"].masks.is_empty()); } /// Masks name their version rather than relying on file order, so a block /// that appears before its version still lands on it. #[test] fn a_mask_block_need_not_follow_its_version() { let text = "drsc 1\n\ \n[mask second m1]\n\ source = regions\n\ signature = 1\n\ level = 1\n\ regions = 4 5\n\ exposure.exposure = 1\n\ \n[version first]\n\ name = First\n\ revision = 1\n\ modified = 0\n\ \n[version second]\n\ name = Second\n\ revision = 1\n\ modified = 0\n"; let parsed = Sidecar::parse(text).expect("parse"); assert!(parsed.versions["first"].masks.is_empty()); assert_eq!( parsed.versions["second"].masks.len(), 1, "the block named its version and should have reached it" ); } /// Loading is a replacement, not an overlay: opening an unedited image after /// an edited one must not leave the previous image's masks on screen. #[test] fn applying_a_maskless_version_clears_existing_masks() { let mut graph = graph_with_mask(); assert_eq!(graph.masks().len(), 1); let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain()); plain.apply(&mut graph); assert!(graph.masks().is_empty()); } #[test] fn a_graph_with_only_a_masked_edit_is_not_neutral() { let graph = graph_with_mask(); assert!(!graph.is_neutral(), "a local adjustment is still an edit"); } /// A bare selection with nothing applied to it changes no pixel, so the image /// is unedited — but the layer must still be written, or the selection the /// user made is lost on reload. #[test] fn a_selection_with_no_adjustment_still_persists() { let mut graph = EditGraph::default_chain(); graph.masks_mut().push(MaskLayer::new("m1", regions(&[9]))); assert!(graph.is_neutral(), "no adjustment means no pixel changes"); let restored = round_trip(&graph); assert_eq!( restored.masks().len(), 1, "the selection is work and must survive even though it renders nothing" ); } #[test] fn unknown_top_level_keys_still_round_trip_alongside_masks() { let text = "drsc 1\n\ \n[version default]\n\ name = Default\n\ revision = 1\n\ modified = 0\n\ future.thing = 3\n\ \n[mask default m1]\n\ source = regions\n\ signature = 1\n\ level = 1\n\ regions = 2\n\ exposure.exposure = 1\n"; let out = Sidecar::parse(text).expect("parse").to_text(); assert!(out.contains("future.thing"), "unknown keys are still preserved"); assert!(out.contains("[mask default m1]")); } // --------------------------------------------------------------------------- // Sync merge (FR-NC-9) // --------------------------------------------------------------------------- fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version { let mut graph = EditGraph::default_chain(); build(&mut graph); let mut v = Version::from_graph(uuid, "Default", &graph); v.revision = revision; v } fn lit(id: &str, ids: &[u32], ev: f32) -> MaskLayer { let mut layer = MaskLayer::new(id, regions(ids)); layer.set_param("exposure", ParamId("exposure"), ev); layer } /// The case the whole key-wise merge exists for, one level up: a layer added /// on the phone and a layer added on the desktop are not a conflict. #[test] fn disjoint_layers_from_two_devices_both_survive() { let base = version_with("default", 1, |_| {}); let mut ours = version_with("default", 2, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); let theirs = version_with("default", 2, |g| { g.masks_mut().push(lit("m2", &[2], -1.0)); }); let conflicts = ours.merge(&theirs, Some(&base)); assert!(conflicts.is_empty(), "different layers are not a conflict"); assert_eq!(ours.masks.len(), 2); assert!(ours.masks.get("m1").is_some()); assert!(ours.masks.get("m2").is_some()); } #[test] fn a_layer_only_the_remote_added_arrives() { let base = version_with("default", 1, |_| {}); let mut ours = version_with("default", 2, |_| {}); let theirs = version_with("default", 3, |g| { g.masks_mut().push(lit("m1", &[5], 2.0)); }); assert!(ours.merge(&theirs, Some(&base)).is_empty()); assert_eq!(ours.masks.len(), 1); } #[test] fn both_editing_one_layer_is_a_conflict_resolved_by_revision() { let base = version_with("default", 1, |g| { g.masks_mut().push(lit("m1", &[1], 0.5)); }); let mut ours = version_with("default", 2, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); let theirs = version_with("default", 9, |g| { g.masks_mut().push(lit("m1", &[1], -1.0)); }); let conflicts = ours.merge(&theirs, Some(&base)); assert_eq!(conflicts, vec![("mask".to_string(), "m1".to_string())]); let layer = ours.masks.get("m1").expect("layer survived"); let ev = layer .ops .iter() .find(|o| o.descriptor().id.0 == "exposure") .map(|o| o.param(ParamId("exposure"))); assert_eq!(ev, Some(-1.0), "the higher revision wins the layer whole"); } /// A layer deleted on one device and untouched on the other must stay /// deleted, or a mask the user removed reappears on every sync. #[test] fn a_remote_deletion_is_honoured() { let base = version_with("default", 1, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); let mut ours = version_with("default", 2, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); let theirs = version_with("default", 3, |_| {}); assert!(ours.merge(&theirs, Some(&base)).is_empty()); assert!(ours.masks.is_empty(), "the deletion should not be undone"); } #[test] fn an_untouched_layer_is_left_alone() { let base = version_with("default", 1, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); let mut ours = version_with("default", 2, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); g.masks_mut().push(lit("m2", &[2], 0.5)); }); let theirs = version_with("default", 3, |g| { g.masks_mut().push(lit("m1", &[1], 1.0)); }); assert!(ours.merge(&theirs, Some(&base)).is_empty()); assert_eq!(ours.masks.len(), 2, "our new layer is not a conflict"); }