Files
vectorcraft/crates/engine/src/tests/turn_direction.rs
T
HassanKhan b3860bf7d0 Commands: every angle and rotation param says which way it turns (#6340)
The params docs now state the direction (and the zero) of each angle or
rotation a command takes; behaviour is unchanged. Shape creation, text.create,
the grids, add-on shapes, saved views, Liquify, Puppet Warp pins, Mirror and
Radial Repeat and Vector Halftone turn clockwise; text character rotation,
gradients, the eraser tip and print screen angles turn counter-clockwise.

view.saved.new's rotation note changes its schema, so the API level goes to 3.

A test walks every command's params doc and fails when a param named like an
angle or rotation doesn't say clockwise or counter-clockwise.
2026-11-11 21:40:27 +05:00

117 lines
5.8 KiB
Rust

//! Every angle or rotation a command takes says which way it turns (#6340): creation commands turn
//! clockwise and most edits counter-clockwise, so a client can't guess.
use crate::cmd::params::{is_key, object_body, split_top};
use crate::command_specs;
/// Params that are angles but not a turn, with why.
const NOT_A_TURN: &[(&str, &str, &str)] = &[
("addon.pathtools.cornerSet", "maxAngle", "the angle a corner's sides meet at"),
("addon.pathtools.cornerSet", "minAngle", "the angle a corner's sides meet at"),
("addon.shapes.box3d", "angleX", "a tilt out of the page: the description says which side turns away"),
("addon.shapes.box3d", "angleY", "a tilt out of the page: the description says which side turns away"),
("addon.shapes.configure", "angleX", "a tilt out of the page: the description says which side turns away"),
("addon.shapes.configure", "angleY", "a tilt out of the page: the description says which side turns away"),
("object.path.simplify", "cornerAngle", "the angle a corner's sides meet at"),
("perspective.grid.define", "angle", "the viewing angle, 0 to 90°"),
];
/// A param name that is an angle or a turn.
fn is_turn(key: &str) -> bool {
let k = key.to_ascii_lowercase();
k.ends_with("angle") || k.ends_with("angles") || k.starts_with("angle") || ["rotation", "rotate", "piestart", "pieend"].contains(&k.as_str())
}
/// The bodies of the `{…}` objects in `text` that are not inside another one.
fn objects_in(text: &str) -> Vec<&str> {
let mut out = vec![];
let mut rest = text;
while let Some(i) = rest.find('{') {
let Ok((body, after)) = object_body(&rest[i..]) else { break };
out.push(body);
rest = after;
}
out
}
/// `(key, what the description says about it)` for each key the object `body` names, and for the
/// keys of the objects inside it. A key given without a note (`a?, b?: degrees`) shares the note of
/// the next key that has one.
fn keys_and_notes(body: &str, out: &mut Vec<(String, String)>) {
let mut items: Vec<(&str, String)> = vec![];
for entry in split_top(body, ',').into_iter().map(str::trim).filter(|e| !e.is_empty() && !e.starts_with('…')) {
for (i, alternative) in split_top(entry, '|').into_iter().enumerate() {
let head = alternative.split([':', '=', '(']).next().unwrap_or_default();
let note = alternative.get(head.len()..).unwrap_or_default();
let name = head.trim().trim_end_matches('?').trim_end();
let a_key = is_key(name) && (i == 0 || head.trim_end().ends_with('?') || note.starts_with(':'));
match items.last_mut() {
Some((_, last)) if !a_key => last.push_str(alternative),
_ if a_key => items.push((name, note.to_string())),
_ => {}
}
}
}
for (i, (key, note)) in items.iter().enumerate() {
let shared = items.get(i..).unwrap_or_default().iter().map(|(_, n)| n.trim()).find(|n| !n.is_empty()).unwrap_or_default();
out.push((key.to_string(), shared.to_string()));
for inner in objects_in(note) {
keys_and_notes(inner, out);
}
}
}
/// The turn params of description `doc` whose note doesn't say which way they turn.
fn undirected(doc: &str) -> Vec<String> {
let mut found = vec![];
let mut next = Some(doc);
// `{…} or {…}`: each object the command takes.
while let Some(text) = next {
let Ok((body, rest)) = object_body(text) else { break };
keys_and_notes(body, &mut found);
next = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{'));
}
found.into_iter().filter(|(k, note)| is_turn(k) && !note.to_ascii_lowercase().contains("clockwise")).map(|(k, _)| k).collect()
}
#[test]
fn a_note_is_found_for_each_key() {
assert_eq!(undirected("{angle: deg (counter-clockwise), origin?}"), Vec::<String>::new());
assert_eq!(undirected("{x, rotation?: deg} → {rotation}"), ["rotation"]);
// A shared note, an alternative, a nested object, and the object after `or`.
assert_eq!(undirected("{inAngle?, outAngle?: degrees counter-clockwise, size?: pt}"), Vec::<String>::new());
assert_eq!(undirected("{startAngle?, endAngle?, center?: [x,y] | hSpacing? | angle?}"), ["startAngle", "endAngle", "angle"]);
assert_eq!(undirected("{gradient?: {kind?: linear|radial, angle?: deg}, id?}"), ["angle"]);
assert_eq!(undirected("{id} or {ids, rotate?: deg}"), ["rotate"]);
// Not a turn by its name, or a value rather than a key.
assert_eq!(undirected("{autoRotate: true, orientation?: portrait|landscape, mode?: rotation|scale}"), Vec::<String>::new());
}
/// Each command param that is an angle or a rotation says in its description which way it turns
/// (clockwise or counter-clockwise), unless [`NOT_A_TURN`] says why it isn't a turn.
#[test]
fn every_turn_param_says_which_way_it_turns() {
let mut missing = vec![];
for c in command_specs() {
for key in undirected(c.params) {
if !NOT_A_TURN.iter().any(|(id, k, _)| *id == c.id && *k == key) {
missing.push(format!("{}: {key}", c.id));
}
}
}
assert!(missing.is_empty(), "{} params don't say whether they turn clockwise or counter-clockwise:\n{}", missing.len(), missing.join("\n"));
let stale: Vec<_> = NOT_A_TURN
.iter()
.filter(|(id, key, _)| !command_specs().iter().any(|c| c.id == *id && keys_named(c.params).iter().any(|k| k == key)))
.collect();
assert!(stale.is_empty(), "NOT_A_TURN names params no command takes: {stale:?}");
}
fn keys_named(doc: &str) -> Vec<String> {
let mut found = vec![];
if let Ok((body, _)) = object_body(doc) {
keys_and_notes(body, &mut found);
}
found.into_iter().map(|(k, _)| k).collect()
}