Let a preset name its film, and let a look reach only what it names

A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
This commit is contained in:
2026-09-26 13:44:32 -04:00
parent c6d4c1ba62
commit 90c0695c05
11 changed files with 503 additions and 67 deletions
+437 -23
View File
@@ -44,6 +44,7 @@ use std::fmt::Write as _;
use crate::descriptor::{Attribute, OpId, ParamId};
use crate::graph::EditGraph;
use crate::state::{FilmRebake, FilmRef};
/// Which parts of an edit a copy carries.
///
@@ -173,6 +174,15 @@ impl Scope {
self.bits == 0
}
/// Whether this scope carries the film — the stock as well as its sliders.
///
/// Asked of the film node's own classification rather than decided here,
/// so the stock travels under exactly the scopes its exposure slider
/// does. Splitting the two would put one stock's exposure on another.
pub fn carries_film(self) -> bool {
self.covers(crate::ops::film_sim::ID.0)
}
fn bit(attribute: Attribute) -> u8 {
1 << Attribute::ALL
.iter()
@@ -216,6 +226,17 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
TABLE.get(op).map(Vec::as_slice)
}
/// How much of the target an applied preset replaces. See the module note.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Reach {
/// Everything in scope: absence means default. A copy, a saved edit.
#[default]
Whole,
/// Only the operations the preset names, and the film if it names one.
/// Everything else in the target is left as it was. A look.
Named,
}
/// A set of non-default parameter values, ready to apply elsewhere.
///
/// Ordered, so two captures of the same edit compare equal and a caller can
@@ -223,6 +244,10 @@ fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Preset {
params: BTreeMap<(String, String), f32>,
reach: Reach,
/// TRACES: FR-DEV-3f
/// The stock this edit develops on, by id. See the module note.
film: Option<FilmRef>,
}
impl Preset {
@@ -237,6 +262,21 @@ impl Preset {
/// have to be re-copied to change one's mind about framing, and a preset
/// that had already discarded the crop could never grow it back.
pub fn capture(graph: &EditGraph) -> Self {
Self {
film: graph.film().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
..Self::capture_params(graph)
}
}
/// The parameters alone, without the film.
///
/// For [`EditGraph::state`], which carries the film in a field of its own
/// ([`crate::EditState::film`]). Capturing it twice would give one edit
/// two places to disagree about its stock.
pub(crate) fn capture_params(graph: &EditGraph) -> Self {
let mut params = BTreeMap::new();
for cap in graph.capabilities() {
for p in &cap.params {
@@ -245,12 +285,59 @@ impl Preset {
}
}
}
Self { params }
Self::from_params(params)
}
/// Build from an already-captured parameter map — a sidecar's, typically.
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
Self { params }
Self {
params,
reach: Reach::Whole,
film: None,
}
}
/// The same preset, reaching as far as `reach` says.
pub fn with_reach(self, reach: Reach) -> Self {
Self { reach, ..self }
}
/// How much of the target this preset replaces.
pub fn reach(&self) -> Reach {
self.reach
}
/// Whether applying this preset replaces `op` — before any scope is asked.
fn reaches(&self, op: &str) -> bool {
match self.reach {
Reach::Whole => true,
Reach::Named => {
self.params.keys().any(|(o, _)| o == op)
|| (self.film.is_some() && op == crate::ops::film_sim::ID.0)
}
}
}
/// The same preset, developed on `film`.
pub fn with_film(self, film: Option<FilmRef>) -> Self {
Self { film, ..self }
}
/// The stock this preset develops on, if it names one.
pub fn film(&self) -> Option<&FilmRef> {
self.film.as_ref()
}
/// What applying at `scope` does to the target's film.
///
/// `None` when the scope leaves the film alone; `Some(None)` when it
/// develops the target without one. The same two levels the sidecar
/// writer takes, which is who asks: the batch path amends files rather
/// than graphs, and has to know whether the film line is being written at
/// all before it knows what to write.
pub fn film_for(&self, scope: Scope) -> Option<Option<&FilmRef>> {
(scope.carries_film() && self.reaches(crate::ops::film_sim::ID.0))
.then_some(self.film.as_ref())
}
/// The parameters, for a caller that stores them.
@@ -270,7 +357,7 @@ impl Preset {
/// answers is whether there is a clipboard to offer, which is why the UI
/// asks it before enabling a paste.
pub fn is_empty(&self) -> bool {
self.params.is_empty()
self.params.is_empty() && self.film.is_none()
}
/// How many parameters were captured.
@@ -296,10 +383,14 @@ impl Preset {
/// rather than parameters because thirty-six mixer sliders is a number
/// about the mixer's shape, not about how much was copied.
pub fn op_count(&self, scope: Scope) -> usize {
// A stock with every film slider at default is still the film node
// at work, and a preset holding nothing else is not "Neutral".
let film = self.film.is_some().then_some(crate::ops::film_sim::ID.0);
let mut ops: Vec<&str> = self
.params
.keys()
.map(|(op, _)| op.as_str())
.chain(film)
.filter(|op| scope.covers(op))
.collect();
ops.sort_unstable();
@@ -319,7 +410,13 @@ impl Preset {
/// to a fitted view mid-comparison, which reads as the paste having
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
/// lives here so every caller inherits it rather than each remembering.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) {
///
/// The **film** is replaced when the scope carries it, and handed back as
/// a [`FilmRebake`] because this crate cannot bake a stock — the same debt
/// [`EditGraph::set_state`] returns, for the same reason. It is cleared
/// even when the preset names the stock already on the graph: the tables
/// were baked from the film sliders this call has just replaced.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) -> FilmRebake {
let view = graph.framing().view();
// Clear the scope first, so absence means default (see the module
@@ -328,7 +425,7 @@ impl Preset {
let clears: Vec<(OpId, ParamId, f32)> = graph
.capabilities()
.iter()
.filter(|cap| scope.covers(cap.id.0))
.filter(|cap| scope.covers(cap.id.0) && self.reaches(cap.id.0))
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
.collect();
for (op, param, default) in clears {
@@ -347,6 +444,17 @@ impl Preset {
}
graph.framing_mut().set_view(view);
match self.film_for(scope) {
None => FilmRebake::NotNeeded,
Some(film) => {
graph.set_film(None);
match film {
None => FilmRebake::NotNeeded,
Some(film) => FilmRebake::Wanted(film.clone()),
}
}
}
}
/// Apply to a parameter map — the sidecar of an image that is not open.
@@ -361,8 +469,11 @@ impl Preset {
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
/// the default scope — are left exactly as they were.
///
/// A parameter map has no film in it, so the stock is not written here:
/// the caller asks [`Self::film_for`] and writes it beside the map.
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
target.retain(|(op, _), _| !scope.covers(op));
target.retain(|(op, _), _| !(scope.covers(op) && self.reaches(op)));
for ((op, param), value) in &self.params {
if scope.covers(op) {
target.insert((op.clone(), param.clone()), *value);
@@ -556,6 +667,14 @@ impl PresetLibrary {
self.presets.is_empty()
}
/// How many lines were kept without being understood.
///
/// For a file this build wrote itself, or shipped, that should be none: a
/// misspelt key is preserved faithfully and does nothing.
pub fn unread_lines(&self) -> usize {
self.unknown.values().map(Vec::len).sum()
}
/// Serialise to the on-disk form.
///
/// Deterministic, like the sidecar's: the same library always produces the
@@ -567,6 +686,22 @@ impl PresetLibrary {
for ((op, param), value) in preset.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
// TRACES: FR-DEV-3f
// Spelled as the sidecar spells them, so a block can still be
// pasted from one file into the other. A build that predates
// these lines reads them as lines it does not understand and
// writes them back untouched, which is the promise below.
// Only when it differs from the default, so every library written
// before looks existed still writes the same bytes.
if preset.reach() == Reach::Named {
let _ = writeln!(out, "reach = named");
}
if let Some(film) = preset.film() {
let _ = writeln!(out, "film = {}", film.stock);
if let Some(print) = &film.print {
let _ = writeln!(out, "film_print = {print}");
}
}
for line in self.unknown.get(name).into_iter().flatten() {
let _ = writeln!(out, "{line}");
}
@@ -597,6 +732,8 @@ impl PresetLibrary {
let mut library = Self::default();
let mut current: Option<String> = None;
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
let mut film: Option<FilmRef> = None;
let mut reach = Reach::Whole;
for line in lines {
let line = line.trim();
@@ -609,9 +746,16 @@ impl PresetLibrary {
.and_then(|l| l.strip_suffix(']'))
{
if let Some(name) = current.take() {
library.presets.insert(name, Preset::from_params(params));
params = BTreeMap::new();
library.presets.insert(
name,
Preset::from_params(std::mem::take(&mut params))
.with_film(film.take())
.with_reach(reach),
);
}
params.clear();
film = None;
reach = Reach::Whole;
// A name the writer should never have produced is dropped
// rather than taken: accepting it would mean writing a file
// back out that no longer parses as this one.
@@ -631,6 +775,23 @@ impl PresetLibrary {
};
match line.split_once('=') {
// TRACES: FR-DEV-3f
// Not checked against the installed stocks: this crate does
// not link them, and a preset naming a stock this device
// lacks must survive being stored here. Whoever bakes it
// reports the miss — the sidecar's rule, for its reason.
// `get_or_insert_with` because a hand-edited block may name
// the paper first.
Some((key, value)) if key.trim() == "reach" && value.trim() == "named" => {
reach = Reach::Named;
}
Some((key, value)) if key.trim() == "film" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).stock = value.trim().to_string();
}
Some((key, value)) if key.trim() == "film_print" && !value.trim().is_empty() => {
film.get_or_insert_with(FilmRef::default).print =
Some(value.trim().to_string());
}
Some((key, value)) => {
let key = key.trim();
let value = value.trim();
@@ -654,7 +815,21 @@ impl PresetLibrary {
}
if let Some(name) = current {
library.presets.insert(name, Preset::from_params(params));
library.presets.insert(
name,
Preset::from_params(params)
.with_film(film)
.with_reach(reach),
);
}
// A paper with no film named beside it is a print of nothing. Dropped
// rather than kept, since baking it would have no stock to start from.
for preset in library.presets.values_mut() {
if preset.film.as_ref().is_some_and(|f| f.stock.is_empty()) {
log::warn!("preset library: a paper without a film; ignoring it");
preset.film = None;
}
}
// A block whose every line was unreadable still produced a preset, and
@@ -803,7 +978,9 @@ mod tests {
};
target.set_crop(target_crop);
preset.apply(&mut target, Scope::adjustments());
preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
@@ -835,7 +1012,9 @@ mod tests {
fn pasting_everything_carries_the_composition_too() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::everything());
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
@@ -857,7 +1036,9 @@ mod tests {
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
neutral.apply(&mut target, Scope::adjustments());
neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
@@ -876,7 +1057,9 @@ mod tests {
let mut target = EditGraph::default_chain();
target.set_param(framing::ID, framing::ANGLE, 3.5);
neutral.apply(&mut target, Scope::adjustments());
neutral
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
}
@@ -894,7 +1077,9 @@ mod tests {
height: 0.25,
});
preset.apply(&mut target, Scope::everything());
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
assert!(
target.framing().is_zoomed(),
"the paste threw away the viewport: {:?}",
@@ -911,7 +1096,9 @@ mod tests {
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
preset.apply(&mut sideways, Scope::everything());
preset
.apply(&mut sideways, Scope::everything())
.expect_no_film();
assert_eq!(
sideways.framing().baseline(),
@@ -928,7 +1115,9 @@ mod tests {
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::adjustments());
Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
}
@@ -939,7 +1128,9 @@ mod tests {
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::adjustments());
Preset::from_params(params)
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
@@ -1001,11 +1192,13 @@ mod tests {
let mut target_map = Preset::capture(&target_graph).into_params();
preset.apply(&mut target_graph, scope);
preset.apply(&mut target_graph, scope).expect_no_film();
preset.amend(&mut target_map, scope);
let mut rebuilt = EditGraph::default_chain();
Preset::from_params(target_map).apply(&mut rebuilt, Scope::everything());
Preset::from_params(target_map)
.apply(&mut rebuilt, Scope::everything())
.expect_no_film();
for cap in target_graph.capabilities() {
for p in &cap.params {
@@ -1030,7 +1223,9 @@ mod tests {
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::everything());
preset
.apply(&mut target, Scope::everything())
.expect_no_film();
for cap in source.capabilities() {
for p in &cap.params {
@@ -1068,6 +1263,188 @@ mod tests {
assert_eq!(excluded, vec![framing::ID.0]);
}
// --- the film ------------------------------------------------------------
/// A graph developing on `stock`. The tables are invented; what is under
/// test is whether the *choice* travels.
fn on_film(stock: &str) -> EditGraph {
let mut g = edited();
g.set_film(Some(crate::graph::Film {
stock: stock.to_string(),
print: Some("kodak_portra_endura".to_string()),
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_copy_carries_the_stock_it_was_developed_on() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let film = preset.film().expect("the stock was captured");
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
assert!(Preset::capture(&edited()).film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The graph's own state keeps the film in one place only.
#[test]
fn an_edit_state_does_not_carry_the_film_twice() {
let state = on_film("kodak_portra_400").state();
assert!(state.film.is_some());
assert_eq!(state.params.film(), None);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Applying a preset that names a stock asks for it to be baked, and
/// clears the tables it found — they came from the sliders it replaced.
#[test]
fn applying_a_film_preset_asks_for_its_stock() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let rebake = preset.apply(&mut target, Scope::adjustments());
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
assert!(target.film().is_none(), "the old tables were left standing");
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// Replacement, as for every parameter: a preset with no stock, at a
/// scope that carries the film, develops the target without one.
#[test]
fn a_preset_without_a_film_clears_the_targets() {
let mut target = on_film("ilford_hp5");
Preset::capture(&edited())
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert!(target.film().is_none());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// A scope that leaves the film node behind leaves the stock behind too,
/// so an exposure never lands on a stock it was not set for.
#[test]
fn a_scope_without_the_film_leaves_the_stock_alone() {
let preset = Preset::capture(&on_film("kodak_portra_400"));
let mut target = on_film("ilford_hp5");
let tone = Scope::of([Attribute::Tone]);
assert!(!tone.carries_film());
preset.apply(&mut target, tone).expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
assert_eq!(preset.film_for(tone), None);
assert!(preset.film_for(Scope::adjustments()).is_some());
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_stock_alone_is_not_a_neutral_preset() {
let preset = Preset::default().with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}));
assert!(!preset.is_empty());
assert_eq!(preset.op_count(Scope::adjustments()), 1);
assert_eq!(preset.op_count(Scope::of([Attribute::Tone])), 0);
}
// --- reach ---------------------------------------------------------------
/// A look: a stock and a contrast, and nothing else.
fn look() -> Preset {
let mut params = BTreeMap::new();
params.insert(("contrast".to_string(), "contrast".to_string()), 20.0);
Preset::from_params(params)
.with_film(Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}))
.with_reach(Reach::Named)
}
/// TRACES: FR-DEV-6
/// The reason `Reach` exists: a look applied over a corrected photograph
/// keeps the correction.
#[test]
fn a_look_leaves_what_it_does_not_name_alone() {
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
target.set_param(saturation::ID, saturation::SATURATION, -40.0);
let rebake = look().apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
assert_eq!(
target.param(saturation::ID, saturation::SATURATION),
Some(-40.0)
);
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("kodak_portra_400")
);
}
/// TRACES: FR-DEV-6
/// A look without a film leaves the target's film alone, where a whole
/// edit without one would clear it.
#[test]
fn a_look_without_a_film_keeps_the_targets() {
let mut target = on_film("ilford_hp5");
let only_contrast = look().with_film(None);
assert_eq!(only_contrast.film_for(Scope::adjustments()), None);
only_contrast
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
}
/// TRACES: FR-DEV-6
/// The batch path agrees: a look amends only the operations it names.
#[test]
fn a_look_amends_only_what_it_names() {
let mut target = BTreeMap::new();
target.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
target.insert(("contrast".to_string(), "contrast".to_string()), -50.0);
look().amend(&mut target, Scope::adjustments());
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
Some(&1.25)
);
assert_eq!(
target.get(&("contrast".to_string(), "contrast".to_string())),
Some(&20.0)
);
}
/// TRACES: FR-DEV-6
#[test]
fn a_look_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Look", look()).unwrap();
let text = lib.to_text();
assert!(text.contains("reach = named\n"), "{text}");
let back = PresetLibrary::parse(&text).unwrap();
assert_eq!(back, lib);
// And the default is not written, so an existing library's bytes are
// unchanged by this format ever having grown the line.
assert_eq!(named().to_text().matches("reach").count(), 0);
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
@@ -1147,6 +1524,37 @@ mod tests {
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
#[test]
fn a_film_survives_the_library_round_trip() {
let mut lib = named();
lib.insert("Portra", Preset::capture(&on_film("kodak_portra_400")))
.unwrap();
let text = lib.to_text();
assert!(text.contains("film = kodak_portra_400\n"), "{text}");
assert!(
text.contains("film_print = kodak_portra_endura\n"),
"{text}"
);
assert_eq!(PresetLibrary::parse(&text).unwrap(), lib);
}
/// TRACES: FR-DEV-6 | FR-DEV-3f
/// The paper may come first in a hand-edited block, and a paper with no
/// film is dropped rather than baked from nothing.
#[test]
fn film_lines_read_in_either_order_and_a_lone_paper_is_dropped() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset A]\nfilm_print = p\nfilm = s\n\n\
[preset B]\nfilm_print = p\nexposure.exposure = 1\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let a = lib.get("A").unwrap().film().unwrap();
assert_eq!((a.stock.as_str(), a.print.as_deref()), ("s", Some("p")));
assert_eq!(lib.get("B").unwrap().film(), None);
assert_eq!(lib.get("B").unwrap().len(), 1);
}
#[test]
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
@@ -1234,7 +1642,9 @@ mod tests {
let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::adjustments());
preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
// The target keeps its own framing on the default scope.
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
@@ -1268,7 +1678,9 @@ mod tests {
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::of([Attribute::Tone]));
preset
.apply(&mut target, Scope::of([Attribute::Tone]))
.expect_no_film();
// Tone was picked, so the exposure travelled.
assert_eq!(
@@ -1333,7 +1745,9 @@ mod tests {
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
Preset::capture(&edited()).apply(&mut target, empty);
Preset::capture(&edited())
.apply(&mut target, empty)
.expect_no_film();
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(-1.25),