//! 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, /// 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, } /// 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, } impl Version { /// A new version holding the non-default parameters of `graph`. pub fn from_graph(uuid: impl Into, name: impl Into, 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::>() .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 { 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::().ok()) .ok_or(ParseError::NotASidecar)?; if format > FORMAT_VERSION { return Err(ParseError::UnsupportedVersion(format)); } let mut sidecar = Sidecar::new(); let mut current: Option = 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::().unwrap_or(0).min(MAX_RATING), "flag" => version.flag = value.parse::().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::() { 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("").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"); } }