`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves from `[tone, colour]` to `[effect]`. Two categories were doing the wrong job. "Geometry" describes what crop, straighten and the quarter turns do to coordinates — but it describes lens distortion correction exactly as well, and that is not a compositional choice at all. Naming the attribute for the photographer's decision is what separates it from `Optics`: one is what the lens did, the other is what they chose. The maths the two have in common is not the thing worth filing them under. A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in the Light group beside exposure and again in Colour beside white balance — two places, neither of which is where anyone looks for it. It is neither: `Effect` is defined in this same file as "applied rather than corrected — a look, not a fix", which is what a stock is. That it moves tone and colour is true of every look, and is not what the attribute is for. `from_name` still accepts "geometry" on the way in. That string is persisted in `develop.copy_attributes`, and an entry it fails to parse is not an error — `presets::scope_for` logs it and drops it — so without the alias an existing settings file would have quietly narrowed what a paste carries. `name` writes the current spelling, so the file migrates itself the first time it is saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.1 KiB
Rust
124 lines
4.1 KiB
Rust
//! What operations say they are about (ARCH §4.3a).
|
|
//!
|
|
//! The point of an attribute is that a panel can group operations without
|
|
//! knowing what any of them is. These assert the properties that makes
|
|
//! possible, over the real chain rather than over fixtures — including the
|
|
//! hand-written operations, which `build.rs` never sees and so cannot check.
|
|
|
|
use dr_pipeline::{Attribute, EditGraph};
|
|
|
|
/// The invariant the whole scheme rests on.
|
|
///
|
|
/// An operation with no attribute is invisible to a panel that groups by them.
|
|
/// `build.rs` refuses to *generate* one; this covers the ones it does not
|
|
/// generate.
|
|
#[test]
|
|
fn every_operation_says_what_it_is_about() {
|
|
let graph = EditGraph::default_chain();
|
|
for cap in graph.capabilities() {
|
|
assert!(
|
|
!cap.attributes.is_empty(),
|
|
"{} declares no attribute, so no panel grouping by attribute \
|
|
would ever show it",
|
|
cap.id.0
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A panel can build its groups from the chain alone, with no table mapping
|
|
/// operations to groups — which is what lets `ui/` name no operation
|
|
/// (FR-DEV-3a).
|
|
#[test]
|
|
fn the_groups_are_derivable_from_the_chain() {
|
|
let graph = EditGraph::default_chain();
|
|
let caps = graph.capabilities();
|
|
|
|
let mut present: Vec<Attribute> = caps
|
|
.iter()
|
|
.flat_map(|c| c.attributes.iter().copied())
|
|
.collect();
|
|
present.sort();
|
|
present.dedup();
|
|
|
|
assert!(
|
|
present.contains(&Attribute::Tone),
|
|
"the chain has tonal work"
|
|
);
|
|
assert!(present.contains(&Attribute::Colour));
|
|
assert!(
|
|
present.contains(&Attribute::Compose),
|
|
"framing is in the capability list and is geometry"
|
|
);
|
|
|
|
// And nothing derived is empty: a group with no operations in it would be
|
|
// a tab that opens onto nothing.
|
|
for a in &present {
|
|
assert!(
|
|
caps.iter().any(|c| c.attributes.contains(a)),
|
|
"{a:?} appeared with nothing in it"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The case the plural exists for.
|
|
#[test]
|
|
fn an_operation_may_be_about_two_things() {
|
|
let graph = EditGraph::default_chain();
|
|
let curve = graph
|
|
.capabilities()
|
|
.into_iter()
|
|
.find(|c| c.id.0 == "tone_curve")
|
|
.expect("the chain has a tone curve");
|
|
|
|
assert!(curve.attributes.contains(&Attribute::Tone));
|
|
assert!(
|
|
curve.attributes.contains(&Attribute::Colour),
|
|
"the per-channel curves are chromatic; filing it under tone alone \
|
|
would hide it from half the people looking for it"
|
|
);
|
|
}
|
|
|
|
/// Attributes describe the operation, never the screen — so a frontend that
|
|
/// groups by them can still reach every parameter, and one that ignores them
|
|
/// loses nothing.
|
|
#[test]
|
|
fn grouping_reaches_every_parameter() {
|
|
let graph = EditGraph::default_chain();
|
|
let caps = graph.capabilities();
|
|
|
|
let ungrouped: usize = caps.iter().map(|c| c.params.len()).sum();
|
|
let reachable: usize = caps
|
|
.iter()
|
|
.filter(|c| Attribute::ALL.iter().any(|a| c.attributes.contains(a)))
|
|
.map(|c| c.params.len())
|
|
.sum();
|
|
|
|
assert_eq!(
|
|
reachable, ungrouped,
|
|
"a panel showing every attribute must show every parameter"
|
|
);
|
|
}
|
|
|
|
/// The vocabulary is closed, so a typo cannot invent a category holding one
|
|
/// operation — which is indistinguishable from a deliberate new one until
|
|
/// somebody notices the tab with a single control in it.
|
|
#[test]
|
|
fn the_vocabulary_is_closed() {
|
|
assert_eq!(Attribute::from_name("tone"), Some(Attribute::Tone));
|
|
assert_eq!(Attribute::from_name("colour"), Some(Attribute::Colour));
|
|
assert_eq!(Attribute::from_name("Tone"), None, "names are lower case");
|
|
assert_eq!(Attribute::from_name("color"), None, "and are spelled once");
|
|
assert_eq!(Attribute::from_name("tonal"), None);
|
|
}
|
|
|
|
/// Every attribute names a concept the localiser can resolve, the way an
|
|
/// operation's own label does.
|
|
#[test]
|
|
fn every_attribute_is_nameable() {
|
|
for a in Attribute::ALL {
|
|
let key = a.label().0;
|
|
assert!(key.starts_with("attr."), "{a:?} has key {key:?}");
|
|
assert!(key.len() > "attr.".len());
|
|
}
|
|
}
|