Files
DarkRoom/core/dr-pipeline/tests/mask_sidecar.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00

618 lines
19 KiB
Rust

//! 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]"));
}
// ---------------------------------------------------------------------------
// Brush strokes
// ---------------------------------------------------------------------------
/// One gesture to paint: whether it erases, its radius, and its path.
type Gesture = (bool, f32, Vec<(f32, f32)>);
/// Paint gestures onto a fresh brush layer.
fn brushed(id: &str, gestures: &[Gesture]) -> MaskLayer {
let mut layer = MaskLayer::new(id, MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
for (erase, radius, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
/// The whole point of storing strokes as parameters: they must come back as
/// the *same numbers*, not as numbers that render similarly. A coordinate that
/// drifts in the sixth decimal on every save is a file that never stops
/// changing, and under per-field merge that is a conflict a day.
#[test]
fn strokes_survive_a_round_trip_exactly() {
let mut graph = EditGraph::default_chain();
let painted = brushed(
"m1",
&[(
false,
0.0625,
vec![(0.1234, 0.5), (0.4, 0.2), (0.8, 0.75), (0.9, 0.1)],
)],
);
let expected = painted.strokes().to_vec();
graph.masks_mut().push(painted);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert_eq!(layer.strokes(), expected.as_slice());
}
/// Order is the mask. An erase written before the add it was meant to cut into
/// would silently repaint what the user removed — the mask still looks like a
/// mask, so nothing announces it.
#[test]
fn stroke_order_and_direction_survive() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[
(false, 0.2, vec![(0.2, 0.5), (0.8, 0.5)]),
(true, 0.1, vec![(0.5, 0.5)]),
],
));
let restored = round_trip(&graph);
let strokes = restored.masks().layers()[0].strokes();
assert_eq!(strokes.len(), 2);
assert!(!strokes[0].erase, "the add must still come first");
assert!(strokes[1].erase, "and the erase second");
assert_eq!(strokes[1].radius, 0.1, "each stroke keeps its own brush");
}
#[test]
fn a_brush_layer_writes_one_line_per_stroke() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[
(false, 0.05, vec![(0.2, 0.5), (0.8, 0.5)]),
(true, 0.05, vec![(0.5, 0.5)]),
],
));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
let lines: Vec<&str> = text
.lines()
.filter(|l| l.starts_with("stroke = "))
.collect();
assert_eq!(
lines,
[
"stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5",
"stroke = erase 0.05 0.5 1 0.5,0.5"
]
);
}
#[test]
fn writing_a_painted_mask_twice_is_byte_identical() {
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(brushed(
"m1",
&[(false, 0.05, vec![(0.2, 0.5), (0.4, 0.6), (0.8, 0.5)])],
));
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let once = sidecar.to_text();
let twice = Sidecar::parse(&once).expect("reparse").to_text();
assert_eq!(once, twice);
}
/// One damaged line must not cost the strokes either side of it. Refusing the
/// whole layer would throw away a mask over a typo, and guessing at the missing
/// half would put paint where nobody touched.
#[test]
fn a_malformed_stroke_costs_only_that_stroke() {
let text = "drsc 1\n\
\n[version default]\n\
name = Default\n\
revision = 1\n\
modified = 0\n\
\n[mask default m1]\n\
source = brush\n\
stroke = add 0.05 0.5 1 0.2,0.5\n\
stroke = sideways 0.05 0.5 1 0.3,0.5\n\
stroke = add 0.05 0.5 1 0.4 0.5\n\
stroke = add 0.05 0.5 1 0.6,0.5\n\
exposure.exposure = 1\n";
let parsed = Sidecar::parse(text).expect("parse");
let strokes = parsed.versions["default"].masks.layers()[0].strokes();
assert_eq!(strokes.len(), 2, "the two readable strokes survived");
assert_eq!(strokes[0].points, vec![(0.2, 0.5)]);
assert_eq!(
strokes[1].points,
vec![(0.6, 0.5)],
"a point without its comma is refused rather than read as one number"
);
}
/// A brush layer with nothing painted on it is still work — the user made the
/// layer and set its adjustment — and must not disappear because it happens to
/// render nothing yet.
#[test]
fn an_unpainted_brush_layer_still_persists() {
let mut graph = EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.set_param("exposure", ParamId("exposure"), 1.0);
graph.masks_mut().push(layer);
let restored = round_trip(&graph);
assert_eq!(restored.masks().len(), 1);
assert_eq!(restored.masks().layers()[0].source, MaskSource::brush());
}
// ---------------------------------------------------------------------------
// 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");
}
/// Print a real sidecar carrying a subject mask, for eyeballing the format.
///
/// Ignored because it asserts nothing — it exists so the on-disk shape can be
/// looked at without reverse-engineering it from the writer.
///
/// ```sh
/// cargo test -p dr-pipeline --test mask_sidecar show_a_sidecar -- --ignored --nocapture
/// ```
#[test]
#[ignore = "prints the format rather than checking it"]
fn show_a_sidecar() {
let mut graph = EditGraph::default_chain();
graph.set_param(dr_pipeline::OpId("exposure"), ParamId("exposure"), 0.35);
let mut subject = MaskLayer::new(
"m1",
MaskSource::Subject {
signature: 0x9f2c_41aa,
index: 0,
class: "person".into(),
score: 0.94,
},
);
subject.name = "person".into();
subject.invert = true;
subject.feather = 0.02;
subject.falloff = dr_pipeline::mask::Falloff::Gaussian;
subject.morphology = dr_pipeline::mask::Morphology::Dilate;
subject.morph_radius = 0.012;
subject.set_param("saturation", ParamId("saturation"), -100.0);
subject.set_param("exposure", ParamId("exposure"), -0.4);
graph.masks_mut().push(subject);
let mut grad = MaskLayer::new(
"m2",
MaskSource::Linear {
centre: (0.5, 0.25),
// The constant rather than four digits of it: clippy rejects the
// literal, and a quarter turn written as a number is a quarter
// turn nobody can see at a glance.
angle: std::f32::consts::FRAC_PI_2,
width: 0.4,
},
);
grad.set_param("exposure", ParamId("exposure"), -0.6);
graph.masks_mut().push(grad);
let mut brush = brushed(
"m3",
&[
(false, 0.06, vec![(0.31, 0.44), (0.35, 0.46), (0.4, 0.52)]),
(true, 0.03, vec![(0.36, 0.47)]),
],
);
brush.name = "dodge".into();
graph.masks_mut().push(brush);
let mut sidecar = Sidecar::new();
let mut v = Version::from_graph("default", "Default", &graph);
v.is_default = true;
v.rating = 4;
sidecar.put(v);
println!("\n{}", sidecar.to_text());
}