dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1127 lines
43 KiB
Rust
1127 lines
43 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::graph::EditGraph;
|
|
use crate::preset::{resolve, Preset};
|
|
|
|
/// 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.
|
|
///
|
|
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
|
|
/// parameters and a copied preset are the same values taken from the same
|
|
/// list, and two routines building the same map would be two places for the
|
|
/// non-default rule to drift.
|
|
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
|
Preset::capture(graph).into_params()
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|