Files
DarkRoom/core/dr-pipeline/tests/mask_sidecar.rs
T
dtourolleandClaude Opus 5 4efab496c2
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
Put the refinement on a slider, per layer
The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:40 +02:00

781 lines
25 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)
.expect_no_film();
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).expect_no_film();
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());
}
/// A category mask is a name plus a signature, and both have to survive.
///
/// The name especially. `MaskSource::Category` stores one rather than an index
/// precisely so that editing `models/scene/categories.txt` cannot repoint a
/// stored layer — and that reasoning is worth nothing if the name is what the
/// sidecar drops.
#[test]
fn category_masks_keep_their_name() {
let mut graph = EditGraph::default_chain();
let mut sky = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 0x5EED_1234,
name: "sky".into(),
},
);
sky.name = "sky".into();
sky.feather = 0.03;
sky.set_param("exposure", ParamId("exposure"), 0.5);
graph.masks_mut().push(sky);
// A multi-word name too: the writer emits `category = swimming pool` on one
// line, and a reader that split on whitespace would silently truncate it to
// a category no model has.
let mut pool = MaskLayer::new(
"m2",
MaskSource::Category {
signature: 0x5EED_1234,
name: "swimming pool".into(),
},
);
pool.set_param("saturation", ParamId("saturation"), -30.0);
graph.masks_mut().push(pool);
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);
assert!((layers[0].feather - 0.03).abs() < 1e-6);
}
/// The refine strictness survives, and its absence means zero.
///
/// Both halves matter and they are different claims. A dropped strictness
/// would reopen a sky the photographer had closed, silently, on the next
/// launch — the mask would still be there and would simply have got the
/// chimneys back.
///
/// And a *missing* key has to read as zero rather than as the default the
/// panel starts a new layer at, because that is what makes a file written
/// before this control existed render exactly as it did then. A default of 4
/// on absence would quietly re-grade every stored category mask in the
/// catalogue.
#[test]
fn a_categorys_refine_strictness_survives_and_defaults_off() {
let mut graph = EditGraph::default_chain();
let mut sky = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 0x5EED_1234,
name: "sky".into(),
},
);
sky.refine = 3.7;
graph.masks_mut().push(sky);
// Left at zero, so the writer omits the key entirely — which is the case
// the second half of this test is really about.
let plain = MaskLayer::new(
"m2",
MaskSource::Category {
signature: 0x5EED_1234,
name: "water".into(),
},
);
graph.masks_mut().push(plain);
let text = {
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
sidecar.to_text()
};
assert_eq!(
text.matches("refine =").count(),
1,
"only the layer that set one should write the key:\n{text}"
);
let layers = round_trip(&graph);
let layers = layers.masks().layers();
assert!(
(layers[0].refine - 3.7).abs() < 1e-6,
"got {}",
layers[0].refine
);
assert_eq!(
layers[1].refine, 0.0,
"an absent key is the model's own mask"
);
}
/// A file is not trusted to ask for a strictness off the end of the scale.
///
/// Past the top of the range every colour fails the test, so the mask deletes
/// itself — which reads as a lost edit rather than as a bad file.
#[test]
fn an_out_of_range_refine_is_clamped() {
let mut graph = EditGraph::default_chain();
let mut sky = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 0x5EED_1234,
name: "sky".into(),
},
);
sky.refine = 3.0;
graph.masks_mut().push(sky);
// Edited in the written text rather than assembled by hand, so this test
// cannot drift out of step with the format the writer actually emits.
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text().replace("refine = 3", "refine = 900");
assert!(text.contains("refine = 900"), "the edit must have applied");
let parsed = Sidecar::parse(&text).expect("reparse");
let mut restored = EditGraph::default_chain();
parsed
.versions
.get("default")
.expect("version survived")
.apply(&mut restored)
.expect_no_film();
let refine = restored.masks().layers()[0].refine;
assert!(
refine <= dr_pipeline::mask::MAX_REFINE,
"a file asked for 900 and got {refine}"
);
}
/// A category is stale when its run is, exactly as a subject is.
///
/// The coverage buffer lives in the session, not the sidecar, so a layer whose
/// signature no longer matches is pointing at pixels that were never computed.
/// Rendering it anyway would mask nothing and read as a broken adjustment.
#[test]
fn a_category_from_another_run_is_stale() {
let layer = MaskLayer::new(
"m1",
MaskSource::Category {
signature: 1,
name: "sky".into(),
},
);
assert!(layer.is_stale(2), "a different run must invalidate it");
assert!(!layer.is_stale(1), "the run it was built against must not");
}