Ship presets with the application instead of seeding them

The six starter presets were copied into the photographer's own library
on a first run and were theirs from then on. That cannot grow into a
real collection: a copy is frozen at the release that wrote it, so an
improved preset reaches nobody who had the old one, and re-seeding would
overwrite a preset someone had tuned.

`dr_pipeline::bundled` now holds the shipped presets as `.drpl` files
compiled into the binary, in sections — Essentials (the former six) and
three sections of film presets, one per measured stock in dr-film,
printed on the paper its profile names — and never writes them to the
user's file. Every shipped preset is a look (`Reach::Named`), so applying
one keeps the corrections a photograph already has.

A name links a photographer's copy to a shipped preset. Saving over a
shipped name makes their version the one that name applies; it is listed
in the shipped section, marked as changed, and deleting it reverts to the
shipped one. Renaming it makes it one of their own and the shipped preset
reappears. Keyed on the name because that is what the photographer sees
and chooses by.

Copies an older first run seeded are forgotten on load where they are
still exactly as seeded — otherwise all six would list as changed and
stay frozen at their old values. A tuned one is kept and now overrides.

The sheet lists "Yours" first, then each shipped section, with headings.
Shipped rows apply and nothing else; a changed row offers Revert where
the photographer's own offer Delete. A dr-ui test checks every shipped
film names a stock this build can bake, on that stock's own paper,
because dr-pipeline does not link the profile database.

The film presets name stocks by id; the measurements behind them are
spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film.
This commit is contained in:
2026-09-26 13:44:32 -04:00
parent 90c0695c05
commit a7b090cf36
11 changed files with 828 additions and 338 deletions
+37
View File
@@ -0,0 +1,37 @@
drpl 1
# Black and white film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Ilford Delta 100]
film = ilford_delta_100
film_print = kodak_2302
[preset Ilford Delta 400]
film = ilford_delta_400
film_print = kodak_2302
[preset Ilford FP4 Plus]
film = ilford_fp4_plus
film_print = kodak_2302
[preset Ilford HP5 Plus]
film = ilford_hp5_plus
film_print = kodak_2302
[preset Ilford Pan F Plus]
film = ilford_pan_f_plus
film_print = kodak_2302
[preset Kodak Double-X 5222]
film = kodak_doublex
film_print = kodak_2302
[preset Kodak Tri-X Reversal 7266]
film = kodak_trix
+30
View File
@@ -0,0 +1,30 @@
drpl 1
# Cinema film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Kodak Verita 200D]
film = kodak_verita_200d
film_print = kodak_2383
[preset Kodak Vision3 200T]
film = kodak_vision3_200t
film_print = kodak_2383
[preset Kodak Vision3 250D]
film = kodak_vision3_250d
film_print = kodak_2383
[preset Kodak Vision3 500T]
film = kodak_vision3_500t
film_print = kodak_2383
[preset Kodak Vision3 50D]
film = kodak_vision3_50d
film_print = kodak_2383
+66
View File
@@ -0,0 +1,66 @@
drpl 1
# Colour film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Fujifilm C200]
film = fujifilm_c200
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Pro 400H]
film = fujifilm_pro_400h
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Provia 100F]
film = fujifilm_provia_100f
[preset Fujifilm Velvia 100]
film = fujifilm_velvia_100
[preset Fujifilm X-Tra 400]
film = fujifilm_xtra_400
film_print = fujifilm_crystal_archive_typeii
[preset Kodak Ektachrome 100]
film = kodak_ektachrome_100
[preset Kodak Ektar 100]
film = kodak_ektar_100
film_print = kodak_portra_endura
[preset Kodak Gold 200]
film = kodak_gold_200
film_print = kodak_portra_endura
[preset Kodak Kodachrome 64]
film = kodak_kodachrome_64
[preset Kodak Portra 160]
film = kodak_portra_160
film_print = kodak_portra_endura
[preset Kodak Portra 400]
film = kodak_portra_400
film_print = kodak_portra_endura
[preset Kodak Portra 800]
film = kodak_portra_800
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed one stop]
film = kodak_portra_800_push1
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed two stops]
film = kodak_portra_800_push2
film_print = kodak_portra_endura
[preset Kodak Ultramax 400]
film = kodak_ultramax_400
film_print = kodak_portra_endura
+41
View File
@@ -0,0 +1,41 @@
drpl 1
# Essentials: small, general corrections written against this pipeline.
# These were the first-run starter set; they ship here now so that a
# new release can improve them without rewriting anybody's own presets.
# Deliberately mild — see bundled.rs.
[preset Crisp detail]
capture_sharpen.amount = 35
clarity.amount = 10
texture.amount = 20
[preset Lift the shadows]
blacks_whites.blacks = 12
contrast.contrast = -5
highlights_shadows.shadows = 40
[preset Muted]
contrast.contrast = -10
highlights_shadows.shadows = 12
saturation.saturation = -30
vibrance.vibrance = 10
[preset Punch]
blacks_whites.blacks = -8
clarity.amount = 12
contrast.contrast = 18
vibrance.vibrance = 18
[preset Recover the sky]
blacks_whites.whites = -10
highlights_shadows.highlights = -55
highlights_shadows.shadows = 35
[preset Soft portrait]
clarity.amount = -10
contrast.contrast = -8
highlights_shadows.highlights = -20
highlights_shadows.shadows = 15
saturation.saturation = -5
vibrance.vibrance = 10
+444
View File
@@ -0,0 +1,444 @@
//! TRACES: FR-DEV-6
//! The presets that ship with the application.
//!
//! # Shipped, not seeded
//!
//! The first six used to be *copied* into the photographer's own library on
//! the first run and were theirs from then on. That was the right answer for
//! six, and it cannot grow: a copy is frozen at the version that made it, so a
//! better "Portra" in the next release would reach nobody who already had the
//! old one, and re-seeding would overwrite a preset someone had tuned. So the
//! shipped set is now read from the binary every time, never written to the
//! user's file, and changes when the application does.
//!
//! # Your copy wins, as long as it keeps the name
//!
//! Saving over a shipped preset's name makes the photographer's version the
//! one that name means, here and in every apply. It is still *that* preset —
//! listed where the shipped one was, marked as changed — and deleting it
//! reveals the shipped one again, which is what "revert" means to the person
//! pressing it. Renaming it cuts the link: it becomes one of their own, and
//! the shipped preset reappears beside it. The lookup is by name because the
//! name is what the photographer sees and chooses by; an id they never see
//! would link two presets they believe are different.
//!
//! # Looks, not whole edits
//!
//! Every shipped preset reaches only the operations it names
//! ([`Reach::Named`]): a look applied to a corrected photograph must keep the
//! correction. A photographer's own saved edits keep [`Reach::Whole`], which
//! is what saving an edit has always meant.
//!
//! # Why the data is text files
//!
//! The same format the user's library is written in, so a shipped preset can
//! be read, diffed and copied into one's own library by hand, and each file
//! can say in a comment where its looks came from — the attribution a licence
//! may require travels with the data it covers.
//!
//! # Why this lives in the core
//!
//! It names operations — "Punch" is a statement about contrast and clarity —
//! and nothing in `ui/` may (`ui_names_no_operation.rs`, ARCH §4.3a). The
//! frontend asks for the listing and applies what it is handed.
use crate::preset::{Preset, PresetLibrary, Reach};
/// One group of shipped presets, as the sheet lists it.
pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
}
/// The files, in the order the sheet lists them.
const SECTIONS: &[(&str, &str, &str)] = &[
(
"essentials",
"Essentials",
include_str!("../presets/essentials.drpl"),
),
(
"colour_film",
"Colour film",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Cinema film",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Black and white film",
include_str!("../presets/bw_film.drpl"),
),
];
/// Every shipped section, parsed.
///
/// Parsed on each call rather than held: it is a few kilobytes read when the
/// preset sheet is drawn, and a static would be one more thing to keep
/// consistent with the files in a test. A file that fails to parse costs its
/// section, with a warning, rather than the sheet — and the tests below make
/// sure none does.
pub fn sections() -> Vec<Section> {
SECTIONS
.iter()
.filter_map(|(id, title, text)| match PresetLibrary::parse(text) {
Ok(library) => Some(Section {
id,
title,
presets: as_looks(library),
}),
Err(e) => {
log::warn!("shipped preset section {id} is unreadable ({e}); skipping");
None
}
})
.collect()
}
/// Mark every preset in `library` as a look.
///
/// Here rather than as a `reach = named` line in every block of every file:
/// the rule is about where a preset came from, and a file that forgot the
/// line would ship a preset that wiped a photographer's corrections.
fn as_looks(library: PresetLibrary) -> PresetLibrary {
let mut looks = PresetLibrary::default();
for (name, preset) in library.iter() {
let _ = looks.insert(name, preset.clone().with_reach(Reach::Named));
}
looks
}
/// Where a listed preset comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// The photographer's own, with no shipped preset of that name.
Yours,
/// Shipped, and not overridden.
Shipped,
/// Shipped, and overridden by the photographer's copy under the same
/// name. The copy is what applies; deleting it reverts to the shipped one.
Changed,
}
/// One row of the preset sheet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub name: String,
pub origin: Origin,
}
/// One group of rows: the photographer's own, then each shipped section.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedSection {
/// `None` for the photographer's own presets.
pub id: Option<&'static str>,
pub title: &'static str,
pub rows: Vec<Listed>,
}
/// Everything the sheet lists, in order, with the photographer's copies
/// standing in for the shipped presets they override.
///
/// Their own presets come first, because a photographer reaches for their own
/// work more than for anybody's defaults, and a copy of a shipped preset is
/// listed in the shipped section rather than among their own — it is still
/// that preset, changed, and belongs where they would look for it.
pub fn listing(yours: &PresetLibrary) -> Vec<ListedSection> {
let shipped = sections();
let is_shipped = |name: &str| shipped.iter().any(|section| section.presets.contains(name));
let mut out = vec![ListedSection {
id: None,
title: "Yours",
rows: yours
.names()
.filter(|name| !is_shipped(name))
.map(|name| Listed {
name: name.to_string(),
origin: Origin::Yours,
})
.collect(),
}];
out.extend(shipped.iter().map(|section| {
ListedSection {
id: Some(section.id),
title: section.title,
rows: section
.presets
.names()
.map(|name| Listed {
name: name.to_string(),
origin: if yours.contains(name) {
Origin::Changed
} else {
Origin::Shipped
},
})
.collect(),
}
}));
out
}
/// The preset a name means: the photographer's if they have one, the shipped
/// one otherwise.
pub fn lookup(yours: &PresetLibrary, name: &str) -> Option<Preset> {
yours.get(name).cloned().or_else(|| {
sections()
.into_iter()
.find_map(|section| section.presets.get(name).cloned())
})
}
/// Whether `name` is a shipped preset's.
pub fn is_shipped(name: &str) -> bool {
sections()
.iter()
.any(|section| section.presets.contains(name))
}
/// Remove the copies a first run used to seed, where they are still exactly
/// as seeded. Returns how many went.
///
/// Those copies would otherwise all list as changed — overriding a shipped
/// preset with an identical one — and would freeze the six at their old
/// values forever. One that differs in any way was tuned by somebody and is
/// kept: it is theirs, and it now overrides the shipped one, which is the
/// rule above doing what it is for.
///
/// Compared on the parameters and the film and not on [`Reach`]: the seeded
/// copies were whole edits, the shipped ones are looks, and that difference
/// is the thing this migration exists to deliver.
pub fn forget_unchanged_copies(yours: &mut PresetLibrary) -> usize {
let shipped = sections();
let stale: Vec<String> = yours
.iter()
.filter(|(name, preset)| {
shipped.iter().any(|section| {
section
.presets
.get(name)
.is_some_and(|s| s.params() == preset.params() && s.film() == preset.film())
})
})
.map(|(name, _)| name.to_string())
.collect();
for name in &stale {
yours.remove(name);
}
stale.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
fn all() -> Vec<(&'static str, String, Preset)> {
sections()
.into_iter()
.flat_map(|s| {
let id = s.id;
s.presets
.iter()
.map(|(n, p)| (id, n.to_string(), p.clone()))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn every_file_parses_and_every_line_is_understood() {
// A misspelt key would otherwise be kept as a line this build does
// not understand — preserved faithfully, and doing nothing.
assert_eq!(
sections().len(),
SECTIONS.len(),
"a section failed to parse"
);
for (id, _, text) in SECTIONS {
let library = PresetLibrary::parse(text).unwrap();
assert_eq!(library.unread_lines(), 0, "{id} has lines nobody reads");
assert!(!library.is_empty(), "{id} is empty");
}
}
#[test]
fn every_shipped_name_is_unique_across_sections() {
// A name is what the lookup and the override key on. Two shipped
// presets sharing one would make one of them unreachable.
let mut names: Vec<String> = all().into_iter().map(|(_, n, _)| n).collect();
let before = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), before);
}
#[test]
fn every_shipped_preset_is_a_look() {
for (id, name, preset) in all() {
assert_eq!(preset.reach(), Reach::Named, "{id}/{name}");
}
}
#[test]
fn every_shipped_preset_names_parameters_this_build_actually_has() {
// A renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for (id, name, preset) in all() {
for (op, param) in preset.params().keys() {
let capability = capabilities
.iter()
.find(|c| c.id.0 == op)
.unwrap_or_else(|| panic!("{id}/{name}: no operation {op:?}"));
assert!(
capability.params.iter().any(|p| p.id.0 == param),
"{id}/{name}: operation {op:?} has no parameter {param:?}"
);
}
}
}
#[test]
fn every_shipped_preset_changes_something() {
// A preset that applies to nothing teaches the photographer that the
// list does not work.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
let rebake = preset.apply(&mut graph, Scope::adjustments());
assert!(
rebake.wanted().is_some() || Preset::capture(&graph) != Preset::default(),
"{id}/{name} left the graph at its defaults"
);
}
}
#[test]
fn no_shipped_preset_carries_a_crop() {
for (id, name, preset) in all() {
assert!(!preset.touches_framing(), "{id}/{name} carries framing");
}
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal would be
// silently trimmed and the preset would not be the one written.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
preset
.clone()
.with_film(None)
.apply(&mut graph, Scope::everything())
.expect_no_film();
for ((op, param), value) in preset.params() {
let (op_id, param_id) = crate::preset::resolve(&graph, op, param).unwrap();
assert_eq!(
graph.param(op_id, param_id),
Some(*value),
"{id}/{name}: {op}.{param} = {value} was clamped"
);
}
}
}
// --- the listing and the override ------------------------------------
fn yours_with(names: &[(&str, Preset)]) -> PresetLibrary {
let mut lib = PresetLibrary::default();
for (n, p) in names {
lib.insert(n, p.clone()).unwrap();
}
lib
}
fn first_shipped() -> (String, Preset) {
let (_, name, preset) = all().into_iter().next().unwrap();
(name, preset)
}
#[test]
fn your_copy_under_a_shipped_name_is_what_that_name_applies() {
let (name, shipped) = first_shipped();
let mine = Preset::default();
let yours = yours_with(&[(&name, mine.clone())]);
assert_eq!(lookup(&yours, &name), Some(mine));
assert_eq!(lookup(&PresetLibrary::default(), &name), Some(shipped));
}
#[test]
fn your_copy_is_listed_in_the_shipped_section_as_changed() {
let (name, _) = first_shipped();
let yours = yours_with(&[(&name, Preset::default()), ("Mine", Preset::default())]);
let listing = listing(&yours);
assert_eq!(listing[0].id, None);
assert_eq!(
listing[0].rows,
vec![Listed {
name: "Mine".into(),
origin: Origin::Yours
}],
"an override must not be listed twice"
);
let row = listing[1..]
.iter()
.flat_map(|s| &s.rows)
.find(|r| r.name == name)
.unwrap();
assert_eq!(row.origin, Origin::Changed);
}
#[test]
fn deleting_your_copy_reverts_to_the_shipped_one() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.remove(&name);
assert_eq!(lookup(&yours, &name), Some(shipped));
}
#[test]
fn renaming_your_copy_cuts_the_link() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.rename(&name, "My version").unwrap();
assert_eq!(lookup(&yours, &name), Some(shipped));
assert_eq!(lookup(&yours, "My version"), Some(Preset::default()));
assert_eq!(listing(&yours)[0].rows[0].name, "My version");
}
#[test]
fn the_old_seeded_copies_are_forgotten_and_tuned_ones_kept() {
// The six as a first run wrote them: the same parameters, as whole
// edits, because that is what a seeded copy was.
let essentials = sections().into_iter().next().unwrap().presets;
let mut yours = PresetLibrary::default();
for (name, preset) in essentials.iter() {
yours
.insert(name, preset.clone().with_reach(Reach::Whole))
.unwrap();
}
let tuned = essentials.names().next().unwrap().to_string();
yours.insert(&tuned, Preset::default()).unwrap();
yours.insert("Mine", Preset::default()).unwrap();
let forgotten = forget_unchanged_copies(&mut yours);
assert_eq!(forgotten, essentials.len() - 1);
assert!(yours.contains(&tuned), "a tuned copy was thrown away");
assert!(yours.contains("Mine"));
assert_eq!(yours.len(), 2);
}
}
+1 -1
View File
@@ -32,6 +32,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled;
pub mod coverage;
pub mod declared;
pub mod descriptor;
@@ -48,7 +49,6 @@ pub mod orphan;
pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod starter;
pub mod state;
pub use coverage::Coverage;
-222
View File
@@ -1,222 +0,0 @@
//! TRACES: FR-DEV-6
//! The presets a first run starts with.
//!
//! # Why any at all
//!
//! A preset sheet that opens on "No presets yet" teaches the photographer that
//! the feature is homework. These exist so the first thing the sheet does is
//! demonstrate what a preset *is* — and so that applying one to a selection,
//! which is the action worth discovering, is available before anybody has
//! saved anything.
//!
//! # Why these are ours and not Adobe's
//!
//! Lightroom ships a large bundled set, and importing one of *those* files is
//! what [`crate::preset_import`] is for — a photographer's own library,
//! carried across. Redistributing Adobe's inside this application would be
//! shipping their creative work under a licence that does not permit it, which
//! is a reason on its own; and their numbers are calibrated against their tone
//! curve rather than ours, so the look would not survive the trip even if the
//! licence allowed it.
//!
//! So these are written against this pipeline, in its units, and they are
//! deliberately mild. A starter preset is a starting point — a photographer
//! who wanted the full effect can push the sliders, where one who is handed a
//! caricature learns to distrust the list.
//!
//! # Why they live in the core rather than in the interface
//!
//! Because they name operations, and nothing in `ui/` may
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
//! preset called "Punch" *is* a statement about contrast, clarity and
//! vibrance, which makes it a statement in the pipeline's vocabulary rather
//! than a fact about any interface. The frontend asks for the set and stores
//! it; it never learns what is in it.
//!
//! # Why they are seeded rather than merged
//!
//! Written once, on the first run that finds no library at all, and never
//! again. Re-adding them on every start would resurrect one the photographer
//! deleted on purpose, and updating them in place would silently rewrite an
//! edit they had adjusted and kept under the same name. After the first run
//! these are ordinary presets: renameable, editable, deletable, and gone for
//! good when deleted.
use std::collections::BTreeMap;
use crate::{Preset, PresetLibrary};
/// One starter preset: a name and the parameters that differ from default.
struct Starter {
name: &'static str,
params: &'static [(&'static str, &'static str, f32)],
}
/// The set. Small on purpose — six a photographer might actually reach for
/// beats forty they have to scroll past.
const STARTERS: &[Starter] = &[
Starter {
name: "Punch",
params: &[
("contrast", "contrast", 18.0),
("clarity", "amount", 12.0),
("vibrance", "vibrance", 18.0),
("blacks_whites", "blacks", -8.0),
],
},
Starter {
name: "Soft portrait",
params: &[
("contrast", "contrast", -8.0),
("highlights_shadows", "highlights", -20.0),
("highlights_shadows", "shadows", 15.0),
("clarity", "amount", -10.0),
("vibrance", "vibrance", 10.0),
("saturation", "saturation", -5.0),
],
},
Starter {
name: "Recover the sky",
params: &[
// The most common single fix in landscape work: a bright sky and a
// dark foreground, both pulled back toward the middle.
("highlights_shadows", "highlights", -55.0),
("highlights_shadows", "shadows", 35.0),
("blacks_whites", "whites", -10.0),
],
},
Starter {
name: "Lift the shadows",
params: &[
("highlights_shadows", "shadows", 40.0),
("blacks_whites", "blacks", 12.0),
("contrast", "contrast", -5.0),
],
},
Starter {
name: "Crisp detail",
params: &[
("texture", "amount", 20.0),
("clarity", "amount", 10.0),
("capture_sharpen", "amount", 35.0),
],
},
Starter {
name: "Muted",
params: &[
("saturation", "saturation", -30.0),
("vibrance", "vibrance", 10.0),
("contrast", "contrast", -10.0),
("highlights_shadows", "shadows", 12.0),
],
},
];
/// The starter library, for a device that has never had one.
pub fn library() -> PresetLibrary {
let mut library = PresetLibrary::default();
for starter in STARTERS {
let params: BTreeMap<(String, String), f32> = starter
.params
.iter()
.map(|(op, param, value)| ((op.to_string(), param.to_string()), *value))
.collect();
// The name is a literal in this file, so a refusal would be a bug here
// rather than bad input — but it still must not take the whole set
// down, since the alternative to five presets is not six, it is none.
if let Err(e) = library.insert(starter.name, Preset::from_params(params)) {
log::warn!(
"starter preset {:?} is unusable ({e:?}); skipping",
starter.name
);
}
}
library
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
#[test]
fn every_starter_names_parameters_this_build_actually_has() {
// The same guard the importer's table has, for the same reason: a
// renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for starter in STARTERS {
for (op, param, _) in starter.params {
let capability = capabilities
.iter()
.find(|c| c.id.0 == *op)
.unwrap_or_else(|| panic!("{:?}: no operation {op:?}", starter.name));
assert!(
capability.params.iter().any(|p| p.id.0 == *param),
"{:?}: operation {op:?} has no parameter {param:?}",
starter.name
);
}
}
}
#[test]
fn every_starter_actually_changes_something() {
// A preset that applies to nothing is worse than one fewer preset: it
// teaches the photographer that the list does not work.
for (name, preset) in library().iter() {
assert!(!preset.is_empty(), "{name} carries nothing");
let mut graph = EditGraph::default_chain();
preset
.apply(&mut graph, Scope::adjustments())
.expect_no_film();
assert_ne!(
Preset::capture(&graph),
Preset::default(),
"{name} left the graph at its defaults"
);
}
}
#[test]
fn no_starter_carries_a_crop() {
// These are looks, not compositions. One that re-framed every image it
// was applied to would be the exact accident `Scope`'s default exists
// to prevent.
for (name, preset) in library().iter() {
assert!(!preset.touches_framing(), "{name} carries framing");
}
}
#[test]
fn the_names_are_distinct() {
assert_eq!(library().len(), STARTERS.len());
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal here would be
// silently trimmed and the preset would not be the one written.
let graph = EditGraph::default_chain();
for starter in STARTERS {
for (op, param, value) in starter.params {
let mut applied = EditGraph::default_chain();
let capability = graph
.capabilities()
.into_iter()
.find(|c| c.id.0 == *op)
.unwrap();
let descriptor = capability.params.iter().find(|p| p.id.0 == *param).unwrap();
applied.set_param(capability.id, descriptor.id, *value);
assert_eq!(
applied.param(capability.id, descriptor.id),
Some(*value),
"{}: {op}.{param} = {value} was clamped",
starter.name
);
}
}
}
}