Files
DarkRoom/core/dr-pipeline/src/sidecar.rs
T
dtourolleandClaude Opus 5 7c57f490fe Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:08:16 +02:00

1141 lines
44 KiB
Rust

//! Sidecar serialisation — the edit graph as durable, mergeable data.
//!
//! # Generic, for the same reason the UI is generic
//!
//! `dr-ui` builds its panel by walking [`EditGraph::capabilities`] and never
//! names an operation (FR-DEV-3c). This module does the same thing to the
//! same list: it reads every parameter an operation *declares*, and writes
//! the ones that differ from their default. No operation implements a
//! serialisation method, and adding one needs no change here — the
//! descriptor it already publishes for the UI is exactly the description the
//! sidecar needs.
//!
//! That symmetry is the point. There is one place an operation says what its
//! parameters are, and both the interface and the persistence layer read it.
//! A third place would be a third thing to forget to update.
//!
//! # Only non-default values are written
//!
//! An operation at neutral contributes nothing to the file, which is what
//! makes the format survive both directions of version skew:
//!
//! - **Reading an old sidecar in a new build.** An operation added since is
//! simply absent, and absence means default, which means neutral. The
//! image renders as it did.
//! - **Reading a new sidecar in an old build.** An unknown operation's lines
//! are preserved verbatim (see [`Version::unknown`]) and written back
//! untouched, so a device running behind cannot silently destroy an edit it
//! does not understand.
//!
//! The alternative — writing every parameter — would make every file grow
//! with the operation count and would still not solve either case.
//!
//! # Why a flat text format rather than serde
//!
//! FR-NC-9 requires conflict merge **at the edit-graph node level**: a crop
//! made on one device and an exposure change made on another must both
//! survive. In this format a node *is* a line, keyed by `op.param`, so the
//! merge is a key-wise comparison over two maps ([`Version::merge`]) rather
//! than a tree diff. A nested document would need the same map built at merge
//! time anyway.
//!
//! It also keeps this crate dependency-free, which is the property that lets
//! the descriptor and codegen logic be tested without a device (ARCH §6.5a).
//!
//! # Shape
//!
//! ```text
//! drsc 1
//!
//! [version 8f04c0e2-1f9a-4a63-b0e9-3d1f5a0c77b1]
//! name = Default
//! default = 1
//! revision = 7
//! device = 3a1c5f80-9d2e-4b11-8c6a-0f7e2d4b9a35
//! modified = 1754697600
//! exposure.exposure = 0.75
//! framing.crop_w = 0.8
//! ```
//!
//! Values are decimal floats; keys are `op_id.param_id`. Both come from the
//! descriptors, so the file is readable by a human debugging an edit that
//! went wrong — which is the case that matters, since sidecars are the
//! authoritative store (ARCH §6.12) and the catalog is the disposable index.
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::descriptor::{OpId, ParamId};
use crate::graph::EditGraph;
/// Format version of the document itself.
///
/// Bumped only for a change no reader could otherwise survive. Adding an
/// operation is *not* such a change — that is what the non-default rule
/// above buys — so this is expected to stay at 1 for a long time.
pub const FORMAT_VERSION: u32 = 1;
/// The file extension for a DarkRoom sidecar.
pub const EXTENSION: &str = "drsc";
/// Highest star rating. Mirrors `dr_catalog::rating::MAX_RATING`; duplicated
/// rather than shared because this crate deliberately depends on nothing.
pub const MAX_RATING: u8 = 5;
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
pub const MAX_FLAG: u8 = 2;
/// TRACES: FR-CAT-8 | FR-NC-8
/// One image's sidecar: a keyed set of versions.
///
/// A set rather than a single graph because an image may carry several
/// virtual copies (FR-CAT-12), and because version identity has to be part of
/// the format for conflict merge to operate per-version (FR-NC-8).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Sidecar {
/// Versions by uuid. Ordered, so writing the same state twice produces
/// byte-identical output — which is what lets a caller skip an upload by
/// comparing content rather than trusting a dirty flag.
pub versions: BTreeMap<String, Version>,
/// Lines from a `[version]` block whose keys this build did not
/// recognise as `op.param`, and any unrecognised top-level lines.
///
/// Preserved so a older build round-trips a newer file without loss.
unknown_blocks: Vec<String>,
}
/// TRACES: FR-CAT-12 | FR-NC-8
/// One named edit variant.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Version {
pub uuid: String,
pub name: String,
pub is_default: bool,
/// Monotonic per-edit counter (FR-NC-8).
///
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
/// with a skewed clock must not be able to overwrite real work simply by
/// claiming a later timestamp.
pub revision: u64,
/// The device that last wrote this version (FR-NC-8).
pub device: String,
/// Unix seconds. Breaks exact `revision` ties only.
pub modified: i64,
/// TRACES: FR-CAT-5 | FR-CULL-4
/// Star rating, 0..=5. Zero means *unrated*, which is a state rather than
/// a low score — it is what "filter to unjudged" selects.
///
/// Stored here, not only in the catalog, because the catalog is a
/// disposable index (ARCH §6.12): a photographer who culls 3,000 frames
/// and then deletes the catalog must not lose that afternoon's work. This
/// and [`Self::flag`] are the two fields that make a cull durable.
///
/// Written as its own top-level key rather than as an `op.param` line
/// because a rating is not an edit — it changes no pixel, and putting it
/// in the parameter map would make it an operation the graph must own.
pub rating: u8,
/// The pick/reject axis, independent of [`Self::rating`].
///
/// `0` unflagged, `1` pick, `2` reject — matching the catalog's encoding,
/// so a value moving between the two stores needs no translation table
/// that could drift.
pub flag: u8,
/// The edit itself: `(op, param) -> value`, non-default values only.
pub params: BTreeMap<(String, String), f32>,
/// Keys this build did not recognise, kept verbatim.
///
/// An operation this build lacks would otherwise be deleted the moment an
/// older device saved the file — silent data loss across a version skew,
/// which for an authoritative store is the worst failure available.
pub unknown: BTreeMap<String, String>,
}
impl Version {
/// A new version holding the non-default parameters of `graph`.
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
Self {
uuid: uuid.into(),
name: name.into(),
is_default: false,
revision: 1,
device: String::new(),
modified: 0,
// A new version is unjudged: the graph says nothing about whether
// the photograph is any good, and inventing a rating here would
// put every image at zero stars *deliberately* rather than leaving
// it in the "not yet looked at" state a cull resumes from.
rating: 0,
flag: 0,
params: capture(graph),
unknown: BTreeMap::new(),
}
}
/// Apply this version's parameters to a graph.
///
/// The graph is reset first, so loading is a *replacement* rather than an
/// overlay: a parameter absent from the file means default, and would
/// otherwise silently inherit whatever the graph happened to hold.
///
/// Unknown operations and parameters are skipped with a warning by
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt
/// or newer file cannot reach a shader.
pub fn apply(&self, graph: &mut EditGraph) {
graph.reset();
for ((op, param), value) in &self.params {
// `OpId` and `ParamId` hold `&'static str` because descriptors
// are statics, and a sidecar's strings are not. `resolve` matches
// the file's names against the descriptors and hands back the
// static ids, so no string read from disk is ever leaked to get
// a lifetime it did not earn.
let Some((op, param)) = resolve(graph, op, param) else {
log::warn!("sidecar: unknown parameter {op}.{param}; ignoring");
continue;
};
graph.set_param(op, param, *value);
}
}
/// Record `graph` into this version, bumping the revision.
///
/// The revision bump is what makes this the write path rather than a
/// setter: FR-NC-9 resolves conflicts by revision, so a local edit that
/// did not bump it is a local edit a remote one will silently win.
pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
self.params = capture(graph);
self.revision = self.revision.saturating_add(1);
self.device = device.to_string();
self.modified = now;
}
/// TRACES: FR-NC-9
/// Merge a remote version into this one at the node level.
///
/// Disjoint edits both survive: a crop made on one device and an exposure
/// change made on the other are different keys, so neither is a conflict
/// and the result carries both. Only a key *both* sides changed is
/// ambiguous, and those resolve wholesale to the higher revision — not
/// per key, because two values of the same parameter cannot be combined
/// into a third that either user intended.
///
/// Returns the keys that genuinely conflicted, so a caller can surface
/// them (FR-NC-9: "only genuinely ambiguous merges surface to the UI").
pub fn merge(&mut self, remote: &Version, base: Option<&Version>) -> Vec<(String, String)> {
let empty = BTreeMap::new();
let base_params = base.map(|b| &b.params).unwrap_or(&empty);
let changed = |side: &BTreeMap<(String, String), f32>, key: &(String, String)| {
side.get(key) != base_params.get(key)
};
// The remote wins ties by revision, then by timestamp. Computed once:
// applying it per key would let a single merge take some keys from
// each side, producing a state neither device ever had.
let remote_wins = (remote.revision, remote.modified) > (self.revision, self.modified);
let mut conflicts = Vec::new();
let keys: Vec<(String, String)> = self
.params
.keys()
.chain(remote.params.keys())
.chain(base_params.keys())
.cloned()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for key in keys {
let ours = changed(&self.params, &key);
let theirs = changed(&remote.params, &key);
match (ours, theirs) {
// Only they touched it — take theirs. This is the disjoint
// case, and the whole reason the merge is key-wise.
(false, true) => match remote.params.get(&key) {
Some(v) => {
self.params.insert(key, *v);
}
None => {
self.params.remove(&key);
}
},
// Both touched it, to different values: genuinely ambiguous.
(true, true) if self.params.get(&key) != remote.params.get(&key) => {
conflicts.push(key.clone());
if remote_wins {
match remote.params.get(&key) {
Some(v) => {
self.params.insert(key, *v);
}
None => {
self.params.remove(&key);
}
}
}
}
// Only we touched it, or both landed on the same value.
_ => {}
}
}
// Judgement is not a parameter and is not merged key-wise: a rating is
// a single scalar, so there is no disjoint case to preserve — two
// devices that both rated a frame simply disagree, and the higher
// revision is the answer, exactly as for a contested parameter.
//
// The asymmetry with `params` is deliberate. A device that has *not*
// rated a frame holds 0, which is indistinguishable from "rated
// zero", so treating the remote's 0 as an edit would let an
// un-culled device silently wipe the ratings of a culled one. Taking
// a non-zero remote value when we hold none is the safe direction:
// a judgement can be added across devices but never erased by one
// that never had it.
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
// Unknown keys follow the same rule, so an operation neither side
// understands is not dropped by the merge either.
for (k, v) in &remote.unknown {
self.unknown.entry(k.clone()).or_insert_with(|| v.clone());
}
// The merged result is newer than either input, or the next write
// would look stale to a device that has already seen the remote.
self.revision = self.revision.max(remote.revision).saturating_add(1);
self.modified = self.modified.max(remote.modified);
conflicts
}
}
/// Resolve one judgement scalar — a rating or a flag — across two devices.
///
/// Zero carries no information here. A device that has never judged a frame
/// holds 0, and that is indistinguishable from a deliberate "back to
/// unrated", so the two cases cannot be told apart from the value alone. The
/// resolution follows from which mistake is worse:
///
/// - Taking a remote judgement when we have none **adds** work that was
/// genuinely done elsewhere. If it was wrong, the user re-presses a key.
/// - Taking a remote zero when we have a rating **erases** an afternoon of
/// culling, silently, on a device that was never involved.
///
/// So a zero never overwrites a judgement; a real judgement overwrites ours
/// only when the remote also wins on revision. The cost is that clearing a
/// rating does not propagate — pressing `0` on one device leaves the other
/// device's star standing. That is the deliberate trade, and it is the same
/// direction of caution the merge takes everywhere else.
fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
match (ours, theirs) {
// Nothing to lose: any real remote judgement is strictly more
// information than we hold.
(0, t) => t,
// We hold one and they hold none — theirs says nothing.
(o, 0) => o,
// Both judged. A genuine disagreement, resolved by revision like any
// other contested value.
(o, t) => {
if remote_wins {
t
} else {
o
}
}
}
}
/// Every non-default parameter in the graph, keyed by `(op, param)`.
///
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
/// from — so an operation is persisted by virtue of being in the chain, with
/// nothing to register and nothing to forget.
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
let mut out = BTreeMap::new();
for cap in graph.capabilities() {
for p in &cap.params {
if p.is_modified() {
out.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
}
}
}
out
}
/// Find the `'static` ids matching these names, or `None` if this build has
/// no such parameter.
///
/// Looking them up in the descriptors rather than leaking the file's strings
/// is what bounds memory: an unrecognised name never becomes a `'static`.
fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
let p = cap.params.iter().find(|p| p.id.0 == param)?;
Some((cap.id, p.id))
}
impl Sidecar {
pub fn new() -> Self {
Self::default()
}
/// The version marked default, or the first if none is.
pub fn default_version(&self) -> Option<&Version> {
self.versions
.values()
.find(|v| v.is_default)
.or_else(|| self.versions.values().next())
}
/// Insert or replace a version.
pub fn put(&mut self, version: Version) {
self.versions.insert(version.uuid.clone(), version);
}
/// Serialise to the on-disk form.
///
/// Deterministic: the same state always produces the same bytes, so a
/// caller may compare content to decide whether an upload is needed.
pub fn to_text(&self) -> String {
let mut out = format!("drsc {FORMAT_VERSION}\n");
for block in &self.unknown_blocks {
let _ = writeln!(out, "{block}");
}
for v in self.versions.values() {
let _ = write!(out, "\n[version {}]\n", v.uuid);
let _ = writeln!(out, "name = {}", v.name);
if v.is_default {
let _ = writeln!(out, "default = 1");
}
let _ = writeln!(out, "revision = {}", v.revision);
if !v.device.is_empty() {
let _ = writeln!(out, "device = {}", v.device);
}
let _ = writeln!(out, "modified = {}", v.modified);
// Judgement, written only when there is one. An unrated,
// unflagged frame contributes nothing — the same non-default rule
// the parameters follow, so a library that has never been culled
// does not grow a line per file.
if v.rating > 0 {
let _ = writeln!(out, "rating = {}", v.rating);
}
if v.flag > 0 {
let _ = writeln!(out, "flag = {}", v.flag);
}
for ((op, param), value) in &v.params {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
for (k, raw) in &v.unknown {
let _ = writeln!(out, "{k} = {raw}");
}
}
out
}
/// Parse the on-disk form.
///
/// Tolerant by design. A sidecar is the authoritative store, so a single
/// unreadable line must cost that line and not the file: unrecognised
/// keys are preserved rather than rejected, and a malformed value is
/// skipped with a warning. The one hard failure is a format version this
/// build does not understand, where continuing would mean guessing.
pub fn parse(text: &str) -> Result<Self, ParseError> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let format = header
.strip_prefix("drsc ")
.and_then(|v| v.trim().parse::<u32>().ok())
.ok_or(ParseError::NotASidecar)?;
if format > FORMAT_VERSION {
return Err(ParseError::UnsupportedVersion(format));
}
let mut sidecar = Sidecar::new();
let mut current: Option<Version> = None;
for raw in lines {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(uuid) = line
.strip_prefix("[version ")
.and_then(|s| s.strip_suffix(']'))
{
if let Some(v) = current.take() {
sidecar.put(v);
}
current = Some(Version {
uuid: uuid.trim().to_string(),
..Version::default()
});
continue;
}
let Some((key, value)) = line.split_once('=') else {
// Not a key-value line and not a block header. Keep it so a
// newer format's construct survives a round trip here.
match &mut current {
Some(v) => {
v.unknown.insert(line.to_string(), String::new());
}
None => sidecar.unknown_blocks.push(line.to_string()),
}
continue;
};
let (key, value) = (key.trim(), value.trim());
let Some(version) = current.as_mut() else {
sidecar.unknown_blocks.push(line.to_string());
continue;
};
match key {
"name" => version.name = value.to_string(),
"default" => version.is_default = value != "0",
"revision" => version.revision = value.parse().unwrap_or(0),
"device" => version.device = value.to_string(),
"modified" => version.modified = value.parse().unwrap_or(0),
// Clamped rather than trusted: this file may have been written
// by a build with a wider scale, or hand-edited. An
// out-of-range rating would sort above five stars forever and
// no filter would reach it.
"rating" => version.rating = value.parse::<u8>().unwrap_or(0).min(MAX_RATING),
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
_ => match key.split_once('.') {
// An `op.param` line whose value does not parse is a
// corrupt number, not an unknown key; dropping it lets
// the rest of the edit load, which beats failing the file.
Some((op, param)) => match value.parse::<f32>() {
Ok(v) if v.is_finite() => {
version
.params
.insert((op.to_string(), param.to_string()), v);
}
_ => log::warn!("sidecar: unreadable value for {key}; ignoring"),
},
None => {
version.unknown.insert(key.to_string(), value.to_string());
}
},
}
}
if let Some(v) = current.take() {
sidecar.put(v);
}
Ok(sidecar)
}
}
/// Format a value without a trailing `.0` on whole numbers, and without
/// exponent notation — both so the file stays diffable and hand-readable.
fn format_value(v: f32) -> String {
let mut s = format!("{v:.6}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if s == "-0" {
s = "0".to_string();
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
/// The header line was missing or not a `drsc` header.
NotASidecar,
/// Written by a newer build, in a format this one cannot read.
UnsupportedVersion(u32),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotASidecar => f.write_str("not a DarkRoom sidecar"),
Self::UnsupportedVersion(v) => {
write!(f, "sidecar format version {v} is newer than this build")
}
}
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::ops::{exposure, saturation, white_balance};
fn edited() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g
}
fn version_of(graph: &EditGraph) -> Version {
Version::from_graph("uuid-1", "Default", graph)
}
#[test]
fn only_non_default_values_are_written() {
// The property the whole format rests on: a neutral operation is
// absent, so a file stays small and an operation added later reads
// as neutral rather than as missing.
let v = version_of(&edited());
assert_eq!(v.params.len(), 2);
assert!(v
.params
.contains_key(&("exposure".into(), "exposure".into())));
assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0));
}
#[test]
fn a_neutral_graph_writes_no_parameters() {
let v = version_of(&EditGraph::default_chain());
assert!(v.params.is_empty());
}
#[test]
fn a_graph_round_trips_through_text() {
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&edited()));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
assert_eq!(
restored.param(white_balance::ID, white_balance::TEMPERATURE),
Some(30.0)
);
assert!(!restored.is_neutral());
}
#[test]
fn every_parameter_in_the_chain_round_trips() {
// The generic claim, asserted against the whole chain rather than a
// sample: if an operation needed special handling to persist, this
// is where it would fail.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let crate::ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
}
}
}
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
for cap in g.capabilities() {
for p in &cap.params {
assert_eq!(
restored.param(cap.id, p.id),
Some(p.value),
"{}.{} did not survive the sidecar",
cap.id,
p.id
);
}
}
}
#[test]
fn framing_survives_the_round_trip() {
// Framing is not an `Operation`, so it is exactly the stage a
// serialiser written against the op list alone would silently drop.
let mut g = EditGraph::default_chain();
g.set_crop(crate::CropRect {
x: 0.1,
y: 0.2,
width: 0.5,
height: 0.6,
});
g.set_param(framing::ID, framing::ANGLE, -1.5);
g.rotate_quarters(1);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
assert_eq!(restored.crop(), g.crop());
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
}
#[test]
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
// How a file stored its pixels is a fact about the file, so it must
// not travel in the sidecar — a shared edit would then carry one
// camera's sensor scan onto another's. Two failures are checked
// together because they are the same mistake seen from each end.
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
// Nothing was edited, so there is nothing to write. If the baseline
// leaked into `param`, a rotation would appear here.
let v = version_of(&sideways);
let text = {
let mut s = Sidecar::new();
s.put(v);
s.to_text()
};
assert!(
!text.contains("framing.rotation"),
"an untouched sideways file wrote a rotation:\n{text}"
);
// And applying an edit — which resets the graph first — must leave the
// orientation where it was, or reopening an edited portrait frame
// shows it on its side.
let mut edited = EditGraph::default_chain();
edited.set_param(framing::ID, framing::ANGLE, -1.5);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&edited));
Sidecar::parse(&sidecar.to_text())
.expect("valid")
.default_version()
.expect("a version")
.apply(&mut sideways);
assert_eq!(
sideways.framing().baseline(),
dr_types::Orientation::from_exif(6)
);
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
}
#[test]
fn applying_a_version_replaces_rather_than_overlays() {
// Loading an edit onto a graph that already holds one must not leave
// the previous image's exposure behind.
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&EditGraph::default_chain()));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut g = edited();
parsed.default_version().expect("a version").apply(&mut g);
assert!(g.is_neutral(), "a neutral version must clear the graph");
}
#[test]
fn writing_the_same_state_twice_is_byte_identical() {
// What lets a caller skip an upload by comparing content.
let mut a = Sidecar::new();
a.put(version_of(&edited()));
let once = a.to_text();
let twice = Sidecar::parse(&once).expect("valid").to_text();
assert_eq!(once, twice);
}
#[test]
fn an_unknown_operation_survives_a_round_trip() {
// The data-loss case that matters: a device running an older build
// opens a file written by a newer one, saves, and must not delete
// the operation it never understood.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\
exposure.exposure = 0.5\ntime_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid");
let written = parsed.to_text();
assert!(
written.contains("time_machine.year = 1994"),
"an unknown operation must not be dropped:\n{written}"
);
}
#[test]
fn an_unknown_operation_does_not_reach_the_graph() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
time_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
assert!(g.is_neutral());
}
#[test]
fn a_corrupt_value_costs_its_line_and_not_the_file() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
g.param(white_balance::ID, white_balance::TEMPERATURE),
Some(20.0)
);
}
#[test]
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
// A sidecar written by a build with a wider range must not put an
// out-of-range value into a uniform.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
exposure.exposure = 99\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed.default_version().expect("a version").apply(&mut g);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
#[test]
fn a_newer_format_version_is_refused_rather_than_guessed() {
let err = Sidecar::parse("drsc 99\n").unwrap_err();
assert_eq!(err, ParseError::UnsupportedVersion(99));
}
#[test]
fn a_non_sidecar_is_rejected() {
assert_eq!(
Sidecar::parse("<?xml version=\"1.0\"?>").unwrap_err(),
ParseError::NotASidecar
);
}
#[test]
fn several_versions_are_kept_apart() {
// FR-CAT-12: one image, several virtual copies, independent edits.
let mut sidecar = Sidecar::new();
let mut a = Version::from_graph("u1", "Colour", &edited());
a.is_default = true;
let mut mono = EditGraph::default_chain();
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
sidecar.put(a);
sidecar.put(Version::from_graph("u2", "Mono", &mono));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
assert_eq!(parsed.versions.len(), 2);
assert_eq!(parsed.default_version().expect("default").name, "Colour");
let mut g = EditGraph::default_chain();
parsed.versions["u2"].apply(&mut g);
assert_eq!(
g.param(saturation::ID, saturation::SATURATION),
Some(-100.0)
);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
}
#[test]
fn update_bumps_the_revision() {
// FR-NC-9 resolves by revision; a write that did not bump it would
// lose to a stale remote.
let mut v = version_of(&EditGraph::default_chain());
let before = v.revision;
v.update(&edited(), "device-a", 1000);
assert_eq!(v.revision, before + 1);
assert_eq!(v.device, "device-a");
assert_eq!(v.modified, 1000);
}
#[test]
fn disjoint_edits_both_survive_a_merge() {
// FR-NC-9's motivating case, verbatim: a crop on one device and an
// exposure change on the other.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_crop(crate::CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
remote.update(&rg, "device-b", 200);
let conflicts = local.merge(&remote, Some(&base));
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
assert_eq!(g.crop().width, 0.5);
}
#[test]
fn a_genuine_conflict_resolves_to_the_higher_revision() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
remote.update(&rg, "device-b", 200);
remote.revision = local.revision + 1;
let conflicts = local.merge(&remote, Some(&base));
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
}
#[test]
fn a_skewed_clock_cannot_beat_a_higher_revision() {
// Revision ahead of timestamp, deliberately: a device with a wrong
// clock must not silently overwrite real work.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
local.revision = 50;
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
remote.update(&rg, "device-b", 999_999);
remote.revision = 2;
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g);
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0),
"the far-future timestamp must not win against a higher revision"
);
}
#[test]
fn a_remote_reset_to_default_removes_the_parameter() {
// Deletion is an edit too: clearing exposure on another device must
// propagate, not be masked by the key simply being absent.
let mut base = version_of(&edited());
base.revision = 1;
let mut local = base.clone();
let mut remote = base.clone();
remote.update(&EditGraph::default_chain(), "device-b", 200);
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g);
assert!(g.is_neutral(), "the remote reset must survive the merge");
}
#[test]
fn a_merge_is_newer_than_either_input() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.revision = 4;
let mut remote = base.clone();
remote.revision = 9;
local.merge(&remote, Some(&base));
assert!(local.revision > 9, "a merged result must not look stale");
}
#[test]
fn a_rating_survives_the_round_trip() {
// The durability requirement: the catalog is disposable (ARCH §6.12),
// so a cull that lives only there is a cull one `rm` destroys.
let mut v = version_of(&EditGraph::default_chain());
v.rating = 4;
v.flag = 1;
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let back = parsed.default_version().expect("a version");
assert_eq!(back.rating, 4);
assert_eq!(back.flag, 1);
}
#[test]
fn an_unrated_image_writes_no_judgement_lines() {
// The non-default rule applied to judgement: a library that has never
// been culled must not grow two lines per file.
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&EditGraph::default_chain()));
let text = sidecar.to_text();
assert!(!text.contains("rating"), "{text}");
assert!(!text.contains("flag"), "{text}");
}
#[test]
fn judgement_travels_with_an_otherwise_neutral_edit() {
// Culling produces no pixel change at all, so this is the *normal*
// sidecar during a cull — not an edge case. A writer that skipped
// files with a neutral graph would drop every rating.
let mut v = version_of(&EditGraph::default_chain());
v.rating = 5;
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let back = parsed.default_version().expect("a version");
assert_eq!(back.rating, 5);
assert!(back.params.is_empty(), "no edit, just a judgement");
}
#[test]
fn an_out_of_range_rating_in_a_file_is_clamped() {
// Written by a build with a wider scale, or hand-edited. A stored 9
// would sort above five stars and no filter would reach it.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
rating = 9\nflag = 77\n";
let parsed = Sidecar::parse(text).expect("valid");
let v = parsed.default_version().expect("a version");
assert_eq!(v.rating, MAX_RATING);
assert_eq!(v.flag, MAX_FLAG);
}
#[test]
fn a_corrupt_rating_costs_its_line_and_not_the_file() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
rating = later\nexposure.exposure = 0.5\n";
let parsed = Sidecar::parse(text).expect("valid");
let v = parsed.default_version().expect("a version");
assert_eq!(v.rating, 0);
assert_eq!(
v.params.get(&("exposure".into(), "exposure".into())),
Some(&0.5),
"the rest of the edit still loads"
);
}
#[test]
fn a_rating_from_a_device_that_never_culled_does_not_erase_one() {
// The failure this merge rule exists to prevent: a tablet that synced
// before the cull holds 0, and must not wipe the desktop's afternoon
// of work merely by having a later revision.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 5;
local.revision = 2;
let mut remote = base.clone();
remote.rating = 0;
remote.revision = 99;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5, "an unjudged remote must not erase a cull");
}
#[test]
fn a_rating_made_elsewhere_arrives_when_we_have_none() {
// The other direction: culling on the tablet must reach the desktop.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut remote = base.clone();
remote.rating = 3;
remote.flag = 1;
remote.revision = 5;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 3);
assert_eq!(local.flag, 1);
}
#[test]
fn two_devices_that_both_rated_resolve_by_revision() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 2;
local.revision = 3;
let mut remote = base.clone();
remote.rating = 5;
remote.revision = 9;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5, "the higher revision wins a real conflict");
}
#[test]
fn a_lower_revision_does_not_overwrite_our_rating() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 5;
local.revision = 40;
let mut remote = base.clone();
remote.rating = 1;
remote.revision = 2;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5);
}
#[test]
fn a_rating_and_an_edit_merge_independently() {
// Culling on a tablet while editing on a desktop is the whole point of
// the multi-device workflow (FR-CULL-7); neither may cost the other.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
local.update(&lg, "desktop", 100);
let mut remote = base.clone();
remote.rating = 4;
remote.revision = local.revision + 1;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 4, "the tablet's cull arrived");
let mut g = EditGraph::default_chain();
local.apply(&mut g);
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.5),
"and the desktop's edit survived it"
);
}
#[test]
fn values_are_written_without_exponent_notation() {
// The file is meant to be readable when an edit goes wrong, and a
// sidecar is the authoritative store — so that case matters.
assert_eq!(format_value(0.0001), "0.0001");
assert_eq!(format_value(1.0), "1");
assert_eq!(format_value(-0.0), "0");
assert_eq!(format_value(0.75), "0.75");
}
}