Files
DarkRoom/core/dr-pipeline/tests/attributes.rs
T
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`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>
2026-09-05 14:34:03 +02:00

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());
}
}