Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
//! 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::Geometry),
|
||||
"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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user